mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/codex-structured-core
# Conflicts: # mobile/src/transport/stable-logical-rpc-client.test.ts # src/main/runtime/orca-runtime.ts # src/main/runtime/rpc/e2ee-channel.ts
This commit is contained in:
@@ -29,6 +29,9 @@ on:
|
||||
- 'electron.vite.config.ts'
|
||||
- 'config/build-plugins/**'
|
||||
- 'src/main/daemon/**'
|
||||
- 'src/main/providers/local-pty-provider.ts'
|
||||
- 'src/main/providers/windows-shell-args.ts'
|
||||
- 'src/main/providers/windows-shell-preflight-runtime.windows.test.ts'
|
||||
- 'native/computer-use-macos/**'
|
||||
- 'native/computer-use-linux/**'
|
||||
- 'native/computer-use-windows/**'
|
||||
@@ -106,6 +109,7 @@ jobs:
|
||||
src/main/computer/macos-native-provider-socket.test.ts
|
||||
src/main/computer/macos-computer-use-permissions.test.ts
|
||||
src/main/computer/macos-computer-use-permission-status.test.ts
|
||||
src/main/providers/windows-shell-preflight-runtime.windows.test.ts
|
||||
src/main/computer/desktop-script-provider-client.test.ts
|
||||
src/main/computer/desktop-script-provider-cache.test.ts
|
||||
src/main/computer/desktop-script-provider-actions.test.ts
|
||||
|
||||
@@ -234,7 +234,6 @@ inline src/renderer/src/components/sidebar/RemoteFileBrowser.tsx
|
||||
inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx
|
||||
inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
|
||||
inline src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts
|
||||
inline src/renderer/src/components/sidebar/WorktreeList.tsx
|
||||
inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts
|
||||
inline src/renderer/src/components/sidebar/worktree-list-groups.test.ts
|
||||
inline src/renderer/src/components/sidebar/worktree-list-groups.ts
|
||||
@@ -259,7 +258,6 @@ inline src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts
|
||||
inline src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
|
||||
inline src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx
|
||||
inline src/renderer/src/hooks/useAutomationDispatchEvents.ts
|
||||
inline src/renderer/src/hooks/useComposerState.ts
|
||||
inline src/renderer/src/hooks/useEditorExternalWatch.ts
|
||||
|
||||
@@ -1452,6 +1452,181 @@
|
||||
],
|
||||
"demotionRule": "Quarantine the Electron journey only with a linked product or harness defect; demote if activation changes the owner/runtime/daemon/PTY identity, loses prior output, opens before provider readiness, or fails to honor a committed quit."
|
||||
},
|
||||
{
|
||||
"id": "runtime.renderer-graph-reload-termination",
|
||||
"title": "Renderer graph reloads terminate under exact document and window authority",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "runtime-platform",
|
||||
"layer": "electron-runtime-contract",
|
||||
"surfaces": [
|
||||
"desktop renderer reload and crash recovery",
|
||||
"headless-to-desktop promotion",
|
||||
"CLI and paired-runtime graph availability"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "daemon", "ssh", "paired-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "daemon", "paired-runtime"],
|
||||
"coverageNotes": "Deterministic lifecycle, IPC, window-notification, and runtime ownership tests cover generation replacement, stale callbacks, same-frame old-document publication, blocked external navigation, cancelled renderer navigation, concurrent provisional starts, process loss, timeout cleanup, headless fallback, and competing windows. Isolated macOS Electron validation covers cancelled navigation, forced renderer recovery, and headless serve promotion with live daemon-backed PTY identity and I/O. Linux, Windows, WSL, SSH promotion, and mixed-version desktop/server live runs remain uncollected; the renderer generation stays local to Electron IPC and does not change the remote wire.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/14066",
|
||||
"https://linear.app/stably/issue/STA-4016",
|
||||
"https://github.com/stablyai/orca/pull/14070"
|
||||
],
|
||||
"invariant": "Every genuine renderer graph reload revision settles exactly once, while a blocked external navigation that preserves the current document never starts a revision. A cancelled renderer-document navigation restores authority only when that same renderer survived; cancellation after an earlier document committed remains fenced until publication or timeout. Replacing a reload cancels only the prior revision; its timer and callbacks cannot settle a newer revision. A desktop reload succeeds only from a publication carrying a new renderer-document generation, while a failed headless promotion may recover only from its originating window. Timeout, renderer loss, and close reach a terminal graph state, and successful desktop publication permanently retires headless sentinel fallback until a new headless runtime is established.",
|
||||
"oracle": "Start a blocked external main-frame navigation and require the current renderer graph and notification path to remain authoritative. Start a same-renderer navigation, cancel it before commit, and require the renderer canary and ready authority to survive; coalesce overlapping provisional starts into one fence, and keep an earlier committed reload fenced if a later navigation is cancelled. Drive two genuine reloads half a timeout apart and require the first deadline to leave the second revision reloading while the second deadline alone makes it unavailable. During the second revision publish from the prior renderer document through the still-current main frame and require rejection without graph mutation or timeout cancellation. Restore headless authority after failed promotion, publish from a competing window, and require rejection while the sentinel remains authoritative. Then force a real renderer crash and separately promote an isolated headless serve owner; require runtime graph readiness, stable runtime/main/daemon/PTY identities, recovered visible terminal output, and post-recovery input/output.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/runtime-graph-reload-lifecycle.test.ts src/main/window/runtime-renderer-notification-sender.test.ts src/main/window/renderer-document-navigation.test.ts src/main/ipc/runtime.test.ts src/main/window/attach-main-window-services.test.ts src/main/runtime/orca-runtime.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/runtime/sync-runtime-graph-payload-partition.test.ts",
|
||||
"pnpm exec electron-vite build --mode e2e",
|
||||
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/renderer-blocked-navigation-graph-authority.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"SKIP_BUILD=1 pnpm exec playwright test e2e/renderer-crash-recovery-terminal-input.spec.ts tests/e2e/renderer-crash-recovery-terminal-input.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"SKIP_BUILD=1 pnpm exec playwright test e2e/headless-serve-desktop-activation.spec.ts tests/e2e/headless-serve-desktop-activation.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/runtime/runtime-graph-reload-lifecycle.test.ts",
|
||||
"src/main/window/runtime-renderer-notification-sender.test.ts",
|
||||
"src/main/window/renderer-document-navigation.test.ts",
|
||||
"src/main/ipc/runtime.test.ts",
|
||||
"src/main/window/attach-main-window-services.test.ts",
|
||||
"src/main/runtime/orca-runtime.test.ts",
|
||||
"src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts",
|
||||
"src/renderer/src/runtime/sync-runtime-graph-payload-partition.test.ts",
|
||||
"tests/e2e/renderer-blocked-navigation-graph-authority.spec.ts",
|
||||
"tests/e2e/renderer-crash-recovery-terminal-input.spec.ts",
|
||||
"tests/e2e/headless-serve-desktop-activation.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/runtime/runtime-graph-reload-lifecycle.test.ts",
|
||||
"assertions": [
|
||||
"each revision settles exactly once",
|
||||
"replacement cancels the old revision and fences its timeout",
|
||||
"settlement and timeout release their timers"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/orca-runtime.test.ts",
|
||||
"assertions": [
|
||||
"only the newest reload owns the terminal timeout",
|
||||
"a superseded renderer-document generation cannot settle a reload",
|
||||
"cancelled navigation restores only the renderer or headless authority that actually survived",
|
||||
"cancellation after an earlier document commit stays fenced until its restarted timeout",
|
||||
"failed headless promotion remains pinned to its originating window",
|
||||
"successful desktop publication retires headless fallback"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/runtime.test.ts",
|
||||
"assertions": [
|
||||
"graph publication requires a non-empty renderer generation",
|
||||
"only the current BrowserWindow main frame may publish"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/window/runtime-renderer-notification-sender.test.ts",
|
||||
"assertions": [
|
||||
"renderer failure is contained once per load generation",
|
||||
"late events cannot revive a closed sender"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/window/renderer-document-navigation.test.ts",
|
||||
"assertions": [
|
||||
"blocked external navigation does not start a renderer generation",
|
||||
"only the packaged renderer file or development origin starts a renderer generation",
|
||||
"concurrent provisional starts share one reload fence and cancel only after every attempt fails"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/renderer-blocked-navigation-graph-authority.spec.ts",
|
||||
"assertions": [
|
||||
"blocked external navigation starts and stops loading without replacing the renderer document",
|
||||
"runtime graph epoch, readiness, and authoritative window remain unchanged",
|
||||
"a later listener can cancel a renderer navigation while its canary and authoritative graph recover"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/renderer-crash-recovery-terminal-input.spec.ts",
|
||||
"assertions": [
|
||||
"main observes renderer process loss and a completed recovery load",
|
||||
"the same daemon-backed PTY remains reachable through transport and direct-write probes"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/headless-serve-desktop-activation.spec.ts",
|
||||
"assertions": [
|
||||
"promotion preserves main owner, runtime, daemon, and PTY identity",
|
||||
"terminal output survives promotion and post-promotion input/output remains live"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-14",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/runtime-graph-reload-lifecycle.test.ts src/main/window/runtime-renderer-notification-sender.test.ts src/main/window/renderer-document-navigation.test.ts src/main/ipc/runtime.test.ts src/main/window/attach-main-window-services.test.ts src/main/runtime/orca-runtime.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/runtime/sync-runtime-graph-payload-partition.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 12,
|
||||
"summary": "Eight focused files passed with 1,175 tests and one pre-existing skip after the merge from latest main. The suite covers exact reload settlement, timeout replacement, cancelled-navigation recovery, concurrent provisional starts, document and window authority, notification containment, preload IPC, and renderer publication scheduling."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-14",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/renderer-blocked-navigation-graph-authority.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 8,
|
||||
"summary": "Two isolated Electron flows passed: blocked external navigation left epoch and authority unchanged, and a same-renderer navigation cancelled by a later listener preserved its canary while returning the incremented graph generation to ready under the same authoritative window."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-13",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "SKIP_BUILD=1 pnpm exec playwright test e2e/renderer-crash-recovery-terminal-input.spec.ts tests/e2e/renderer-crash-recovery-terminal-input.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 18,
|
||||
"summary": "Two forced renderer process deaths each produced a main-observed recovery load; the same daemon-backed PTY remained live through renderer transport and direct-write probes."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-13",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "SKIP_BUILD=1 pnpm exec playwright test e2e/headless-serve-desktop-activation.spec.ts tests/e2e/headless-serve-desktop-activation.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 10,
|
||||
"summary": "The isolated headless serve owner promoted to a real renderer while preserving main owner, runtime, daemon, and PTY identity. Pre-promotion output remained visible and post-promotion terminal input/output succeeded; a full-window proof image records both markers."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 420,
|
||||
"scope": "focused deterministic contracts plus isolated forced-crash and headless-promotion Electron journeys"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "Fresh deterministic and two isolated Electron journeys are green; CI soak history is pending."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "A byte-identical three-case oracle was rerun on 2026-08-13. Latest main ee8dd4796e failed all three: reload remained reloading after 22.5 seconds, the prior renderer document settled the replacement, and a competing window stole failed-promotion authority. Submitted PR head feeca6c22f passed the bounded replacement timeout but failed both authority cases. The locally rewritten candidate passed all three. Disabling the local document-generation and pending-window fences reproduces the submitted-head failures; disabling the cancelled-navigation classifier leaves the graph reloading and increments its epoch in the new live oracle."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Fresh delegated audit passed with no blocking, major, or actionable minor findings. The candidate retains at most one unref'd timer, reuses the existing renderer document UUID, adds about 67 serialized bytes to each already-coalesced local graph IPC, and performs constant-time generation/window checks. It adds no polling, subprocess, network request, remote frame, provider scan, or steady-state fanout; a dedicated benchmark would measure existing graph/structured-clone noise rather than meaningful incremental work."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect CI and soak history without unexplained flakes.",
|
||||
"Collect live Linux and Windows desktop reload evidence.",
|
||||
"Add live SSH or WSL promotion evidence if provider-specific graph restoration diverges."
|
||||
],
|
||||
"knownGaps": [
|
||||
"The live journeys cover macOS and daemon-backed local terminals, not Linux desktop, Windows, WSL, or SSH promotion.",
|
||||
"The same-frame stale-document race is deterministic fault injection because Playwright cannot schedule an invoke precisely between did-start-loading and frame replacement.",
|
||||
"No mixed-version live run is required because rendererGeneration is confined to same-version preload/main Electron IPC and the remote runtime wire is unchanged."
|
||||
],
|
||||
"demotionRule": "Demote if a reload revision settles more than once, a stale document or competing window can publish, a timer survives settlement, desktop failure stays indefinitely reloading, headless fallback returns after successful promotion, or either isolated Electron journey loses runtime or PTY identity."
|
||||
},
|
||||
{
|
||||
"id": "runtime.websocket-heartbeat-cadence",
|
||||
"title": "Runtime WebSockets enter an owned shared heartbeat cadence",
|
||||
@@ -8470,10 +8645,14 @@
|
||||
"providers": ["local", "daemon", "ssh", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local"],
|
||||
"coverageNotes": "Local macOS evidence covers the runtime PTY write contract, a live dev-runtime CLI repro, and a Haiku composer trace. The Claude render gate stays on the PTY-owning runtime, so paired and SSH callers use the same provider write/output path without a wire change. SSH, daemon, remote-runtime, Linux, and Windows live journeys remain gaps.",
|
||||
"motivatingLinks": ["https://github.com/stablyai/orca/issues/7226"],
|
||||
"invariant": "Injected orchestration task prompts for recognized agent CLIs must send the prompt body inside one bracketed-paste frame, sanitize embedded ESC bytes, preserve chunk boundaries without losing the frame, and submit exactly once only after the agent can accept Enter. Claude must emit a post-paste composer marker and then settle, or reach the bounded fallback first.",
|
||||
"oracle": "Runtime tests assert the exact PTY write sequence, failure cleanup, and a Claude-shaped multi-frame render where the legacy 500 ms delay and first show-cursor marker are both premature. The candidate waits for post-marker output quiescence before one submit, resets that window on later frames, and still submits once at the hard deadline if output never settles. Orchestration tests assert dispatch/coordinator use the agent prompt path; the live CLI harness covers long Codex-like framing.",
|
||||
"coverageNotes": "Local macOS evidence covers the runtime PTY write contract, live Codex orchestration, a dev-runtime CLI repro, and Claude and Codex composer traces. The render gate stays on the PTY-owning runtime, so paired and SSH callers use the same provider write/output path without a wire change. SSH, daemon, remote-runtime, Linux, and Windows live journeys remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/7226",
|
||||
"https://github.com/stablyai/orca/issues/13821",
|
||||
"https://github.com/stablyai/orca/issues/14347"
|
||||
],
|
||||
"invariant": "Injected orchestration task prompts for recognized agent CLIs must send the prompt body inside one bracketed-paste frame, sanitize embedded ESC bytes, preserve chunk boundaries without losing the frame, and submit exactly once only after the agent can accept Enter. Claude and Codex must emit a post-paste composer marker and then settle, or reach the bounded fallback first; a late marker receives a fresh settlement window.",
|
||||
"oracle": "Runtime tests assert the exact PTY write sequence, failure cleanup, and Claude- and Codex-shaped multi-frame renders where the legacy platform delay and first show-cursor marker are both premature. The candidate waits for post-marker output quiescence before one submit, resets that window on later frames, gives a late marker a fresh bounded window, and still submits once at the hard deadline if output never settles. Orchestration tests assert dispatch/coordinator use the agent prompt path; the live CLI harness covers long Codex-like framing.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/runtime/orchestration/coordinator.test.ts",
|
||||
"node tests/tools/repro-orchestration-long-prompt.mjs --cli out/bin/orca-dev --mode codex-like --size-kb 32 --timeout-ms 20000"
|
||||
@@ -8499,7 +8678,8 @@
|
||||
"file": "src/main/runtime/orca-runtime.test.ts",
|
||||
"assertions": [
|
||||
"runtime writes bracketed paste before a delayed submit",
|
||||
"Claude ignores unrelated and split pre-marker output, then waits for post-marker render quiescence before one submit",
|
||||
"Claude and Codex ignore unrelated and split pre-marker output, then wait for post-marker render quiescence before one submit",
|
||||
"a late Codex marker receives a fresh settlement window before one submit",
|
||||
"a silent Claude composer reaches one bounded fallback submit",
|
||||
"a marked Claude composer with continuous render output reaches one bounded fallback submit",
|
||||
"large prompt frames are chunked and reconstructed before submit",
|
||||
@@ -8531,6 +8711,15 @@
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-14",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/runtime/orchestration/coordinator.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 11.32,
|
||||
"summary": "4 files and 1,303 tests passed with one skipped. Claude and Codex both wait for post-marker quiescence, and a Codex marker arriving at 7.9 seconds receives a fresh window through its final slow frame. Exact-build live Codex workers accepted injected prompts without manual Enter, replied, called worker_done, and settled successfully in the rendered Electron UI."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-13",
|
||||
"runner": "local",
|
||||
@@ -8578,11 +8767,11 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "The original Claude-shaped oracle fails on unmodified main and passes with the render gate. The hardened multi-frame oracle then fails on the first-marker candidate, which submits at 751 ms during an intermediate frame, and passes when the gate requires post-marker quiescence. The earlier live harness also reproduced the unsafe raw multiline contract before its framing fix. Intentional-break evidence remains local rather than a separate CI job."
|
||||
"evidence": "The original Claude-shaped oracle fails on unmodified main and passes with the render gate. The hardened multi-frame oracle then fails on the first-marker candidate, which submits at 751 ms during an intermediate frame, and passes when the gate requires post-marker quiescence. Codex used the legacy platform delay before this candidate; the late-marker oracle proves the original deadline would pre-empt slow-host settlement without re-arming. The earlier live harness also reproduced the unsafe raw multiline contract before its framing fix. Intentional-break evidence remains local rather than a separate CI job."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Agent prompt dispatch remains O(prompt bytes) with existing 16KB chunking. Claude adds one PTY-local output listener until 1.5s of post-marker output quiescence or an 8s fallback; each later frame resets one timer. Other agents retain the platform delay. No polling, provider listing, subprocess churn, hidden-pane wakeups, renderer work, or wire traffic is added."
|
||||
"evidence": "Agent prompt dispatch remains O(prompt bytes) with existing 16KB chunking. Claude and Codex add one PTY-local output listener until 1.5s of post-marker output quiescence or an 8s fallback; a late first marker restarts the bounded window and each later frame resets one quiet timer. Other agents retain the platform delay. No polling, provider listing, subprocess churn, hidden-pane wakeups, renderer work, or wire traffic is added."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Run the live harness in soak with a self-starting dev runtime or provider-contract fixture.",
|
||||
@@ -12056,6 +12245,105 @@
|
||||
"Platform-named bundles are loaded on macOS locally; physical Linux and Windows evidence is not yet recorded."
|
||||
],
|
||||
"demotionRule": "Demote if any shipped companion stops loading on Node 18, selected installers mutate outside the isolated home, or the CI lane no longer runs the built artifact with Node 18."
|
||||
},
|
||||
{
|
||||
"id": "terminal-history.daemon-checkpoint-session-isolation",
|
||||
"title": "Daemon history checkpoints isolate stalled terminal sessions",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "daemon-terminal",
|
||||
"layer": "main-process-daemon-history",
|
||||
"surfaces": [
|
||||
"warm daemon terminal reattach",
|
||||
"deep daemon buffer snapshots",
|
||||
"periodic and final durable-history checkpoints"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local-daemon", "ssh-daemon", "wsl-daemon", "paired-runtime-host"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local-daemon"],
|
||||
"coverageNotes": "A real daemon server and adapter on macOS cover per-session isolation, same-session deadlines, post-deadline commit, aggregate overlay admission, and live-window fallback. The provider-neutral adapter has no new path or wire assumptions; a live Linux SSH-relay journey covers the unchanged remote consumer, while live Linux daemon, Windows, WSL, and paired-runtime-host journeys remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://linear.app/stably/issue/STA-4173",
|
||||
"https://github.com/stablyai/orca/pull/14346"
|
||||
],
|
||||
"invariant": "A stalled durable-history checkpoint may delay only its own daemon session. Warm reattach and deep snapshots degrade to the live window after five seconds, non-final background checkpoints defer after fifteen seconds, same-session writes remain serialized, non-final running plus queued work stays bounded to three per session, overlay compacts stay bounded to four across sessions, and final sleep or disconnect checkpoints remain deadline-free.",
|
||||
"oracle": "Park one session inside the real history checkpoint write, then require an unrelated warm reattach to return within two seconds with its own output while the first write remains parked. Require the stalled session to return its live window after the overlay deadline and later commit the abandoned write. Park four distinct overlay compacts, require a fifth reattach to return its live window without starting another compact, and require fast non-final RPC rejection plus both deadlines to leave diagnostic breadcrumbs.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-checkpoint-session-queue.test.ts src/main/daemon/daemon-reattach-checkpoint-isolation.test.ts src/main/daemon/daemon-pty-adapter.test.ts --reporter=dot"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/daemon/daemon-checkpoint-session-queue.test.ts",
|
||||
"src/main/daemon/daemon-reattach-checkpoint-isolation.test.ts",
|
||||
"src/main/daemon/daemon-pty-adapter.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/daemon/daemon-checkpoint-session-queue.test.ts",
|
||||
"assertions": [
|
||||
"same-session operations serialize while unrelated sessions run independently",
|
||||
"deadline fallback leaves the operation running and retains later waiter ordering",
|
||||
"operation rejection remains distinct from a deadline",
|
||||
"running plus queued work is bounded per session and drains"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/daemon/daemon-reattach-checkpoint-isolation.test.ts",
|
||||
"assertions": [
|
||||
"an unrelated session reattaches while another checkpoint remains parked",
|
||||
"same-session deadline fallback returns the live window and logs a breadcrumb",
|
||||
"an abandoned checkpoint commits when the stalled write resumes",
|
||||
"four parked overlays turn away a fifth compact without blocking its reattach"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/daemon/daemon-pty-adapter.test.ts",
|
||||
"assertions": [
|
||||
"periodic checkpoint work caps concurrent snapshot, disk operations, and admission probes at four",
|
||||
"non-final checkpoint RPC failures retain the existing warning path",
|
||||
"final checkpoints bypass cooldown and remain awaited"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-13",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"result": "passed",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-checkpoint-session-queue.test.ts src/main/daemon/daemon-reattach-checkpoint-isolation.test.ts src/main/daemon/daemon-pty-adapter.test.ts --reporter=dot",
|
||||
"durationSeconds": 15,
|
||||
"summary": "Three focused files passed, including unrelated-session reattach, five-second fallback, post-deadline durable commit, aggregate non-final admission, late-rejection diagnostics, and final-checkpoint behavior."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 30,
|
||||
"scope": "focused queue, real daemon-adapter isolation, and daemon adapter regression suite"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "not-started",
|
||||
"evidence": "Fresh deterministic local runs pass; CI and soak history have not started."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "Origin main routes the parked overlay through one process-wide checkpoint tail, while the candidate oracle observes unrelated reattach at about 79 milliseconds. The exact reverted-source oracle was not executed in this review worktree."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Non-final work holds one admission per session and four globally; a full admission set stops the current periodic pass after four probes, and its diagnostic state stays bounded to the active admissions plus one global marker. Deadline timers are unrefed, abandoned waits retain admission until the actual write settles, dirty-gated polling remains unchanged, final checkpoints remain deadline-free, and the change adds no subprocess, renderer, store, startup probe, or wire work."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Execute the oracle against an affected baseline and retain the exact red result.",
|
||||
"Collect 100 consecutive focused CI passes or 14 days of soak history.",
|
||||
"Collect live Linux daemon, Windows, WSL, and paired-runtime-host reattach evidence.",
|
||||
"Keep per-session and aggregate admission, deadline, diagnostics, and final-checkpoint assertions green."
|
||||
],
|
||||
"knownGaps": [
|
||||
"Live Electron evidence covers the unchanged Linux SSH-relay consumer rather than a daemon running on the SSH host.",
|
||||
"Physical Windows, WSL, Linux daemon, and paired-runtime-host journeys were not run.",
|
||||
"The reverted-source failure is established from the old process-wide call path but was not executed."
|
||||
],
|
||||
"demotionRule": "Demote if unrelated reattach joins a stalled session tail, overlay or periodic work exceeds its admission bound, a deadline cancels durable work, final checkpoints defer, or failures and deadline fallbacks lose their diagnostic breadcrumbs."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ export const KO_VALUE_OVERRIDES = {
|
||||
'아래 워크스페이스 오류를 확인한 후 새로고침하세요.',
|
||||
'Review inactive workspaces before deleting their local files and Orca state.':
|
||||
'로컬 파일과 Orca 상태를 삭제하기 전에 비활성 워크스페이스를 확인하세요.',
|
||||
'Review inactive workspaces (': '비활성 워크스페이스 확인(',
|
||||
'Clean up workspaces': '워크스페이스 정리',
|
||||
'Review the settings that will be imported from your Ghostty config.':
|
||||
'Ghostty 구성에서 가져올 설정을 확인하세요.',
|
||||
'GitHub requires review approval before this pull request can merge':
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 19m">
|
||||
<title>downloads: 19m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 20m">
|
||||
<title>downloads: 20m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">19m</text>
|
||||
<text x="90" y="14">19m</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">20m</text>
|
||||
<text x="90" y="14">20m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 935 B After Width: | Height: | Size: 935 B |
@@ -7,12 +7,12 @@ import { colors } from '../theme/mobile-theme'
|
||||
import { useHostClient } from '../transport/client-context'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform'
|
||||
import { getWorktreeLabel } from '../session/worktree-label'
|
||||
import {
|
||||
activateStructuredAiVaultSession,
|
||||
buildMobileAiVaultResumeLaunch,
|
||||
createMobileAiVaultResumeMutationRegistry,
|
||||
readMobileRuntimeHostPlatform,
|
||||
readMobileRuntimeTerminalWindowsShell,
|
||||
resolveMobileAiVaultResumePlatform,
|
||||
resumeAiVaultSessionInTerminal,
|
||||
|
||||
@@ -44,18 +44,32 @@ vi.mock('./MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentIcon' }))
|
||||
vi.mock('./TaskProviderLogo', () => ({ TaskProviderLogo: 'TaskProviderLogo' }))
|
||||
|
||||
import { setCachedRepos } from '../cache/repo-cache'
|
||||
import { getLocalExecutionHostLabel } from '../../../src/shared/execution-host'
|
||||
import { NewWorktreeModal } from './NewWorktreeModal'
|
||||
|
||||
const repos = [{ id: 'repo-1', displayName: 'orca', path: '/src/orca', kind: 'git' }]
|
||||
const LOCAL_HOST_LABEL = getLocalExecutionHostLabel('darwin')
|
||||
|
||||
function repoPickerNames(renderer: ReactTestRenderer | null): string[] {
|
||||
const pickers = renderer?.root.findAll((node) => node.type === 'PickerListDrawer') ?? []
|
||||
const repoPicker = pickers.find((node) => node.props.title === 'Repository')
|
||||
return ((repoPicker?.props.items ?? []) as { label: string }[]).map((item) => item.label)
|
||||
const repos = [
|
||||
{
|
||||
id: 'repo-1',
|
||||
displayName: 'orca',
|
||||
path: '/src/orca',
|
||||
kind: 'git',
|
||||
upstream: { owner: 'stablyai', repo: 'orca' }
|
||||
}
|
||||
]
|
||||
|
||||
function pickerItems(
|
||||
renderer: ReactTestRenderer,
|
||||
title: string
|
||||
): { label: string; detail: string }[] {
|
||||
const pickers = renderer.root.findAll((node) => node.type === 'PickerListDrawer')
|
||||
const picker = pickers.find((node) => node.props.title === title)
|
||||
return picker?.props.items ?? []
|
||||
}
|
||||
|
||||
describe('NewWorktreeModal repo list', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
describe('NewWorktreeModal project targets', () => {
|
||||
let renderer: ReactTestRenderer
|
||||
|
||||
beforeEach(() => {
|
||||
setCachedRepos('host-1', repos)
|
||||
@@ -63,7 +77,6 @@ describe('NewWorktreeModal repo list', () => {
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
it('keeps the cached repos when the in-flight repo.list rejects on a dropped connection', async () => {
|
||||
@@ -71,6 +84,9 @@ describe('NewWorktreeModal repo list', () => {
|
||||
if (method === 'repo.list') {
|
||||
return Promise.reject(new Error('connection closed'))
|
||||
}
|
||||
if (method === 'status.get') {
|
||||
return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } })
|
||||
}
|
||||
return new Promise(() => {})
|
||||
})
|
||||
const client = { sendRequest } as unknown as RpcClient
|
||||
@@ -92,6 +108,60 @@ describe('NewWorktreeModal repo list', () => {
|
||||
})
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledWith('repo.list')
|
||||
expect(repoPickerNames(renderer)).toEqual(['orca'])
|
||||
expect(pickerItems(renderer, 'Project')).toEqual([
|
||||
expect.objectContaining({ label: 'orca', detail: 'stablyai/orca' })
|
||||
])
|
||||
expect(pickerItems(renderer, 'Run on')).toEqual([
|
||||
expect.objectContaining({ label: LOCAL_HOST_LABEL, detail: '/src/orca' })
|
||||
])
|
||||
})
|
||||
|
||||
it('groups same-name checkouts under one project with separate run targets', async () => {
|
||||
const listedRepos = [
|
||||
...repos,
|
||||
{
|
||||
id: 'repo-2',
|
||||
displayName: 'orca',
|
||||
path: '/home/dev/orca',
|
||||
connectionId: 'build-server',
|
||||
kind: 'git',
|
||||
upstream: { owner: 'stablyai', repo: 'orca' }
|
||||
}
|
||||
]
|
||||
const client = {
|
||||
sendRequest: vi.fn().mockImplementation((method: string) => {
|
||||
if (method === 'repo.list') {
|
||||
return Promise.resolve({ ok: true, result: { repos: listedRepos } })
|
||||
}
|
||||
if (method === 'status.get') {
|
||||
return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } })
|
||||
}
|
||||
return new Promise(() => {})
|
||||
})
|
||||
} as unknown as RpcClient
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(NewWorktreeModal, {
|
||||
visible: true,
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
onCreated: () => {},
|
||||
onClose: () => {}
|
||||
})
|
||||
)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(pickerItems(renderer, 'Project')).toEqual([
|
||||
expect.objectContaining({ label: 'orca', detail: 'stablyai/orca' })
|
||||
])
|
||||
expect(pickerItems(renderer, 'Run on')).toEqual([
|
||||
expect.objectContaining({ label: LOCAL_HOST_LABEL, detail: '/src/orca' }),
|
||||
expect.objectContaining({
|
||||
label: 'SSH · build-server',
|
||||
detail: '/home/dev/orca'
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
ActivityIndicator,
|
||||
Keyboard
|
||||
} from 'react-native'
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react-native'
|
||||
import { ChevronDown, ChevronUp, Monitor } from 'lucide-react-native'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse, RpcSuccess } from '../transport/types'
|
||||
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
|
||||
@@ -30,8 +30,10 @@ import {
|
||||
} from '../tasks/setup-hook-trust'
|
||||
import { isMobileTuiAgentEnabled } from '../tasks/mobile-tui-agents'
|
||||
import type { PersistedTrustedOrcaHooks } from '../../../src/shared/orca-yaml-hook-types'
|
||||
import type { Repo as SharedRepo } from '../../../src/shared/repo-types'
|
||||
import type { TuiAgent } from '../../../src/shared/tui-agent'
|
||||
import type { SshConnectionState } from '../../../src/shared/ssh-types'
|
||||
import { getProjectIdentityKey } from '../../../src/shared/project-host-setup-projection'
|
||||
import {
|
||||
NEW_WORKTREE_AGENT_OPTIONS as AGENT_OPTIONS,
|
||||
NEW_WORKTREE_BLANK_AGENT as BLANK_TERMINAL,
|
||||
@@ -64,17 +66,26 @@ import { SmartWorkspaceSourceField } from './SmartWorkspaceSourceField'
|
||||
import { SmartWorkspaceSourceDrawer } from './SmartWorkspaceSourceDrawer'
|
||||
import { SmartWorkspaceAdvancedFields } from './SmartWorkspaceAdvancedFields'
|
||||
import { SetupHookTrustDrawer, type SetupTrustPrompt } from './SetupHookTrustDrawer'
|
||||
import { NewWorktreeProjectTargetFields } from './NewWorktreeProjectTargetFields'
|
||||
import {
|
||||
buildNewWorkspaceProjectOptions,
|
||||
buildNewWorkspaceRunTargetOptions,
|
||||
getNewWorkspaceRunTarget
|
||||
} from './new-workspace-project-targets'
|
||||
|
||||
type Repo = {
|
||||
id: string
|
||||
displayName: string
|
||||
path: string
|
||||
badgeColor?: string
|
||||
connectionId?: string | null
|
||||
kind?: 'git' | 'folder'
|
||||
upstream?: { owner: string; repo: string } | null
|
||||
gitRemoteIdentity?: { remoteUrl?: string; canonicalKey?: string } | null
|
||||
}
|
||||
type Repo = Pick<SharedRepo, 'id' | 'displayName' | 'path'> &
|
||||
Partial<
|
||||
Pick<
|
||||
SharedRepo,
|
||||
| 'badgeColor'
|
||||
| 'connectionId'
|
||||
| 'executionHostId'
|
||||
| 'kind'
|
||||
| 'upstream'
|
||||
| 'repoIcon'
|
||||
| 'gitRemoteIdentity'
|
||||
>
|
||||
>
|
||||
|
||||
type SetupDecision = 'inherit' | 'run' | 'skip'
|
||||
type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default'
|
||||
@@ -201,10 +212,8 @@ function NewWorktreeModalContent({
|
||||
const [sshConnectingTargetId, setSshConnectingTargetId] = useState<string | null>(null)
|
||||
const [note, setNote] = useState('')
|
||||
const [availableProviders, setAvailableProviders] = useState<TaskProvider[]>([])
|
||||
const { tasksSupported, getWorktreeCreateCutoverSupport } = useNewWorktreeRuntimeCapabilities(
|
||||
client,
|
||||
visible
|
||||
)
|
||||
const { tasksSupported, hostPlatform, getWorktreeCreateCutoverSupport } =
|
||||
useNewWorktreeRuntimeCapabilities(client, visible)
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [setupHookDetails, setSetupHookDetails] = useState<SetupHookDetails | null>(null)
|
||||
const [trustedOrcaHooks, setTrustedOrcaHooks] = useState<PersistedTrustedOrcaHooks>({})
|
||||
@@ -695,10 +704,17 @@ function NewWorktreeModalContent({
|
||||
isMobileTuiAgentEnabled(agent.id, runtimeSettings?.disabledTuiAgents)
|
||||
)
|
||||
const pickerAgentOptions = [...visibleAgentOptions, BLANK_TERMINAL]
|
||||
const repoPickerItems = useMemo(
|
||||
() => repos.map((repo) => ({ id: repo.id, label: repo.displayName, repo })),
|
||||
[repos]
|
||||
const projectPickerItems = useMemo(() => buildNewWorkspaceProjectOptions(repos), [repos])
|
||||
const selectedProjectId = selectedRepo ? getProjectIdentityKey(selectedRepo) : null
|
||||
const selectedProject =
|
||||
projectPickerItems.find((project) => project.id === selectedProjectId) ?? null
|
||||
const runTargetPickerItems = useMemo(
|
||||
() => buildNewWorkspaceRunTargetOptions(repos, selectedProjectId, hostPlatform),
|
||||
[hostPlatform, repos, selectedProjectId]
|
||||
)
|
||||
const selectedRunTarget = selectedRepo
|
||||
? getNewWorkspaceRunTarget(selectedRepo, hostPlatform)
|
||||
: null
|
||||
|
||||
function prepareSelectionPickerOpen(): void {
|
||||
// Why: picker taps can beat an open soft keyboard; dismissing it prevents the
|
||||
@@ -786,10 +802,7 @@ function NewWorktreeModalContent({
|
||||
>
|
||||
<BottomDrawer visible={formSheetVisible} interactive={formSheetInteractive} onClose={onClose}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Create Workspace</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Pick a repository and agent to spin up a new workspace.
|
||||
</Text>
|
||||
<Text style={styles.title}>Create worktree</Text>
|
||||
</View>
|
||||
|
||||
{loading ? (
|
||||
@@ -798,33 +811,23 @@ function NewWorktreeModalContent({
|
||||
</View>
|
||||
) : repos.length === 0 ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={styles.emptyText}>No repositories found</Text>
|
||||
<Text style={styles.emptyText}>No projects found</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Repository</Text>
|
||||
<Pressable
|
||||
style={styles.fieldButton}
|
||||
onPress={() => {
|
||||
prepareSelectionPickerOpen()
|
||||
transitionDrawer('repo')
|
||||
}}
|
||||
>
|
||||
{selectedRepo ? (
|
||||
<View
|
||||
style={[styles.repoDot, { backgroundColor: repoBadgeColor(selectedRepo) }]}
|
||||
/>
|
||||
) : null}
|
||||
<Text
|
||||
style={[styles.fieldButtonText, !selectedRepo && styles.fieldButtonPlaceholder]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{selectedRepo?.displayName ?? 'Select a repository'}
|
||||
</Text>
|
||||
<ChevronDown size={14} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<NewWorktreeProjectTargetFields
|
||||
project={selectedProject}
|
||||
runTarget={selectedRunTarget}
|
||||
projectBadgeColor={selectedRepo ? repoBadgeColor(selectedRepo) : null}
|
||||
onOpenProject={() => {
|
||||
prepareSelectionPickerOpen()
|
||||
transitionDrawer('project')
|
||||
}}
|
||||
onOpenRunTarget={() => {
|
||||
prepareSelectionPickerOpen()
|
||||
transitionDrawer('runTarget')
|
||||
}}
|
||||
/>
|
||||
|
||||
<SmartWorkspaceSourceField
|
||||
composer={composer}
|
||||
@@ -894,7 +897,7 @@ function NewWorktreeModalContent({
|
||||
>
|
||||
<MobileAgentIcon agentId={selectedAgent.id} size={16} />
|
||||
<Text style={styles.fieldButtonText} numberOfLines={1}>
|
||||
{sshGate.requiresConnection ? 'Connect repository first' : selectedAgent.label}
|
||||
{sshGate.requiresConnection ? 'Connect target first' : selectedAgent.label}
|
||||
</Text>
|
||||
<ChevronDown size={14} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
@@ -996,7 +999,7 @@ function NewWorktreeModalContent({
|
||||
<ActivityIndicator size="small" color={colors.bgBase} />
|
||||
) : (
|
||||
<Text style={styles.createText}>
|
||||
{sshGate.requiresConnection ? 'Connect Repository' : 'Create Workspace'}
|
||||
{sshGate.requiresConnection ? 'Connect target' : 'Create worktree'}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
@@ -1025,10 +1028,10 @@ function NewWorktreeModalContent({
|
||||
/>
|
||||
|
||||
<PickerListDrawer
|
||||
visible={visible && drawerView === 'repo'}
|
||||
title="Repository"
|
||||
items={repoPickerItems}
|
||||
selectedId={selectedRepo?.id ?? ''}
|
||||
visible={visible && drawerView === 'project'}
|
||||
title="Project"
|
||||
items={projectPickerItems}
|
||||
selectedId={selectedProjectId ?? ''}
|
||||
onSelect={(item) => handleRepoSelected(item.repo)}
|
||||
onClose={() => transitionDrawer('form')}
|
||||
renderIcon={(item) => {
|
||||
@@ -1036,6 +1039,16 @@ function NewWorktreeModalContent({
|
||||
}}
|
||||
/>
|
||||
|
||||
<PickerListDrawer
|
||||
visible={visible && drawerView === 'runTarget'}
|
||||
title="Run on"
|
||||
items={runTargetPickerItems}
|
||||
selectedId={selectedRepo?.id ?? ''}
|
||||
onSelect={(item) => handleRepoSelected(item.repo)}
|
||||
onClose={() => transitionDrawer('form')}
|
||||
renderIcon={() => <Monitor size={16} color={colors.textMuted} />}
|
||||
/>
|
||||
|
||||
<PickerListDrawer
|
||||
visible={visible && drawerView === 'agent'}
|
||||
title="Agent"
|
||||
@@ -1072,11 +1085,6 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 13,
|
||||
color: colors.textMuted,
|
||||
marginTop: 2
|
||||
},
|
||||
loadingContainer: {
|
||||
paddingVertical: spacing.xl,
|
||||
alignItems: 'center'
|
||||
@@ -1110,7 +1118,6 @@ const styles = StyleSheet.create({
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
fieldButtonText: {
|
||||
flex: 1,
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { View, Text, Pressable, StyleSheet, Platform } from 'react-native'
|
||||
import { ChevronDown, Monitor } from 'lucide-react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
|
||||
type Selection = { label: string; detail?: string }
|
||||
|
||||
export function NewWorktreeProjectTargetFields({
|
||||
project,
|
||||
runTarget,
|
||||
projectBadgeColor,
|
||||
onOpenProject,
|
||||
onOpenRunTarget
|
||||
}: {
|
||||
project: Selection | null
|
||||
runTarget: Selection | null
|
||||
projectBadgeColor: string | null
|
||||
onOpenProject: () => void
|
||||
onOpenRunTarget: () => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Project</Text>
|
||||
<Pressable style={styles.fieldButton} onPress={onOpenProject}>
|
||||
{projectBadgeColor ? (
|
||||
<View style={[styles.projectDot, { backgroundColor: projectBadgeColor }]} />
|
||||
) : null}
|
||||
<SelectionCopy selection={project} placeholder="Select a project" />
|
||||
<ChevronDown size={14} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Run on</Text>
|
||||
<Pressable style={styles.fieldButton} onPress={onOpenRunTarget}>
|
||||
<Monitor size={14} color={colors.textMuted} />
|
||||
<SelectionCopy selection={runTarget} placeholder="Select a run target" />
|
||||
<ChevronDown size={14} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectionCopy({
|
||||
selection,
|
||||
placeholder
|
||||
}: {
|
||||
selection: Selection | null
|
||||
placeholder: string
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.fieldButtonCopy}>
|
||||
<Text
|
||||
style={[styles.fieldButtonText, !selection && styles.fieldButtonPlaceholder]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{selection?.label ?? placeholder}
|
||||
</Text>
|
||||
{selection?.detail ? (
|
||||
<Text style={styles.fieldButtonDetail} numberOfLines={1}>
|
||||
{selection.detail}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
field: {
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
label: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: colors.textSecondary,
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
fieldButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: radii.input,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm
|
||||
},
|
||||
projectDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 999
|
||||
},
|
||||
fieldButtonCopy: {
|
||||
flex: 1,
|
||||
minWidth: 0
|
||||
},
|
||||
fieldButtonText: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
fieldButtonDetail: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textMuted,
|
||||
marginTop: 1
|
||||
},
|
||||
fieldButtonPlaceholder: {
|
||||
color: colors.textMuted
|
||||
}
|
||||
})
|
||||
@@ -6,7 +6,9 @@ import { colors, spacing, typography } from '../theme/mobile-theme'
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
import { BOTTOM_DRAWER_HIDE_DURATION_MS } from './bottom-drawer-constants'
|
||||
|
||||
type Props<T extends { id: string; label: string }> = {
|
||||
type PickerListItem = { id: string; label: string; detail?: string }
|
||||
|
||||
type Props<T extends PickerListItem> = {
|
||||
visible: boolean
|
||||
title: string
|
||||
items: T[]
|
||||
@@ -16,7 +18,7 @@ type Props<T extends { id: string; label: string }> = {
|
||||
renderIcon?: (item: T) => ReactNode
|
||||
}
|
||||
|
||||
export function PickerListDrawer<T extends { id: string; label: string }>({
|
||||
export function PickerListDrawer<T extends PickerListItem>({
|
||||
visible,
|
||||
title,
|
||||
items,
|
||||
@@ -87,12 +89,19 @@ export function PickerListDrawer<T extends { id: string; label: string }>({
|
||||
onPress={() => closeThenSelect(item)}
|
||||
>
|
||||
{renderIcon?.(item)}
|
||||
<Text
|
||||
style={[styles.itemText, selected && styles.itemTextSelected]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
<View style={styles.itemCopy}>
|
||||
<Text
|
||||
style={[styles.itemText, selected && styles.itemTextSelected]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.detail ? (
|
||||
<Text style={styles.itemDetail} numberOfLines={1}>
|
||||
{item.detail}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{selected && <Check size={14} color={colors.textPrimary} />}
|
||||
</Pressable>
|
||||
)
|
||||
@@ -142,10 +151,18 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
itemText: {
|
||||
flex: 1,
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
itemCopy: {
|
||||
flex: 1,
|
||||
minWidth: 0
|
||||
},
|
||||
itemDetail: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textMuted,
|
||||
marginTop: 1
|
||||
},
|
||||
itemTextSelected: {
|
||||
fontWeight: '600'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getLocalExecutionHostLabel } from '../../../src/shared/execution-host'
|
||||
import {
|
||||
buildNewWorkspaceProjectOptions,
|
||||
buildNewWorkspaceRunTargetOptions,
|
||||
getNewWorkspaceRunTarget
|
||||
} from './new-workspace-project-targets'
|
||||
|
||||
const LOCAL_HOST_LABEL = getLocalExecutionHostLabel('darwin')
|
||||
|
||||
describe('new workspace project targets', () => {
|
||||
it('groups local and SSH checkouts of the same project', () => {
|
||||
const upstream = { owner: 'stablyai', repo: 'orca' }
|
||||
const options = buildNewWorkspaceProjectOptions([
|
||||
{ id: 'local', displayName: 'orca', path: '/src/orca', upstream },
|
||||
{
|
||||
id: 'ssh',
|
||||
displayName: 'orca',
|
||||
path: '/home/dev/orca',
|
||||
connectionId: 'build-server',
|
||||
upstream
|
||||
}
|
||||
])
|
||||
|
||||
expect(options).toHaveLength(1)
|
||||
expect(options[0]).toMatchObject({ label: 'orca', detail: 'stablyai/orca' })
|
||||
})
|
||||
|
||||
it('shows the provider slug recovered from canonical git identity', () => {
|
||||
const options = buildNewWorkspaceProjectOptions([
|
||||
{
|
||||
id: 'local',
|
||||
displayName: 'orca',
|
||||
path: '/src/orca',
|
||||
gitRemoteIdentity: {
|
||||
canonicalKey: 'github.com/stablyai/orca',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'git@github.com:stablyai/orca.git'
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
expect(options[0]).toMatchObject({ label: 'orca', detail: 'stablyai/orca' })
|
||||
})
|
||||
|
||||
it('labels local, SSH, and paired runtime targets', () => {
|
||||
expect(
|
||||
getNewWorkspaceRunTarget({ id: 'local', displayName: 'orca', path: '/src/orca' }, 'darwin')
|
||||
).toEqual({ label: LOCAL_HOST_LABEL, detail: '/src/orca' })
|
||||
expect(
|
||||
getNewWorkspaceRunTarget({ id: 'local', displayName: 'orca', path: 'C:\\src\\orca' })
|
||||
).toEqual({ label: 'This computer', detail: 'C:\\src\\orca' })
|
||||
expect(
|
||||
getNewWorkspaceRunTarget({ id: 'local', displayName: 'orca', path: 'C:\\src\\orca' }, 'win32')
|
||||
).toEqual({ label: 'Local Windows', detail: 'C:\\src\\orca' })
|
||||
expect(
|
||||
getNewWorkspaceRunTarget({
|
||||
id: 'ssh',
|
||||
displayName: 'orca',
|
||||
path: 'C:\\src\\orca',
|
||||
executionHostId: 'ssh:Windows%20VM'
|
||||
})
|
||||
).toEqual({ label: 'SSH · Windows VM', detail: 'C:\\src\\orca' })
|
||||
expect(
|
||||
getNewWorkspaceRunTarget({
|
||||
id: 'runtime',
|
||||
displayName: 'orca',
|
||||
path: '/src/orca',
|
||||
executionHostId: 'runtime:devbox'
|
||||
})
|
||||
).toEqual({ label: 'Remote · devbox', detail: '/src/orca' })
|
||||
})
|
||||
|
||||
it('shows one target per host when the project has multiple local worktrees', () => {
|
||||
const upstream = { owner: 'stablyai', repo: 'orca' }
|
||||
const repos = [
|
||||
{ id: 'local-a', displayName: 'orca-a', path: '/src/orca-a', upstream },
|
||||
{ id: 'local-b', displayName: 'orca-b', path: '/src/orca-b', upstream },
|
||||
{
|
||||
id: 'ssh',
|
||||
displayName: 'orca',
|
||||
path: '/home/dev/orca',
|
||||
connectionId: 'build-server',
|
||||
upstream
|
||||
}
|
||||
]
|
||||
const projectId = buildNewWorkspaceProjectOptions(repos)[0]?.id ?? null
|
||||
|
||||
expect(buildNewWorkspaceRunTargetOptions(repos, projectId, 'darwin')).toEqual([
|
||||
expect.objectContaining({ id: 'local-a', label: LOCAL_HOST_LABEL, detail: '/src/orca-a' }),
|
||||
expect.objectContaining({ id: 'ssh', label: 'SSH · build-server', detail: '/home/dev/orca' })
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Repo } from '../../../src/shared/repo-types'
|
||||
import {
|
||||
getExecutionHostLabel,
|
||||
getLocalExecutionHostLabel,
|
||||
getRepoExecutionHostId,
|
||||
parseExecutionHostId
|
||||
} from '../../../src/shared/execution-host'
|
||||
import {
|
||||
getProjectIdentityKey,
|
||||
getProjectProviderIdentity
|
||||
} from '../../../src/shared/project-host-setup-projection'
|
||||
|
||||
type WorkspaceRepo = Pick<Repo, 'id' | 'displayName' | 'path'> &
|
||||
Partial<
|
||||
Pick<Repo, 'connectionId' | 'executionHostId' | 'upstream' | 'repoIcon' | 'gitRemoteIdentity'>
|
||||
>
|
||||
|
||||
export type NewWorkspaceProjectOption<TRepo extends WorkspaceRepo> = {
|
||||
id: string
|
||||
label: string
|
||||
detail?: string
|
||||
repo: TRepo
|
||||
}
|
||||
|
||||
export type NewWorkspaceRunTargetOption<TRepo extends WorkspaceRepo> = {
|
||||
id: string
|
||||
label: string
|
||||
detail: string
|
||||
repo: TRepo
|
||||
}
|
||||
|
||||
export function buildNewWorkspaceProjectOptions<TRepo extends WorkspaceRepo>(
|
||||
repos: readonly TRepo[]
|
||||
): NewWorkspaceProjectOption<TRepo>[] {
|
||||
const options = new Map<string, NewWorkspaceProjectOption<TRepo>>()
|
||||
const hostIdsByProject = new Map<string, Set<string>>()
|
||||
for (const repo of repos) {
|
||||
const id = getProjectIdentityKey(repo)
|
||||
const hostIds = hostIdsByProject.get(id) ?? new Set<string>()
|
||||
hostIds.add(getRepoExecutionHostId(repo))
|
||||
hostIdsByProject.set(id, hostIds)
|
||||
if (!options.has(id)) {
|
||||
options.set(id, {
|
||||
id,
|
||||
label: repo.displayName,
|
||||
repo
|
||||
})
|
||||
}
|
||||
}
|
||||
return [...options.values()].map((option) => {
|
||||
const providerIdentity = getProjectProviderIdentity(option.repo)
|
||||
const providerDetail = providerIdentity
|
||||
? `${providerIdentity.owner}/${providerIdentity.repo}`
|
||||
: ''
|
||||
const hostCount = hostIdsByProject.get(option.id)?.size ?? 0
|
||||
const detail = providerDetail || (hostCount > 1 ? `${hostCount} hosts configured` : '')
|
||||
return detail ? { ...option, detail } : option
|
||||
})
|
||||
}
|
||||
|
||||
export function getNewWorkspaceRunTarget(
|
||||
repo: WorkspaceRepo,
|
||||
localPlatform: NodeJS.Platform | null = null
|
||||
): {
|
||||
label: string
|
||||
detail: string
|
||||
} {
|
||||
const hostId = getRepoExecutionHostId(repo)
|
||||
const host = parseExecutionHostId(hostId)
|
||||
const hostLabel = getExecutionHostLabel(hostId)
|
||||
if (host?.kind === 'ssh') {
|
||||
return { label: `SSH · ${hostLabel}`, detail: repo.path }
|
||||
}
|
||||
if (host?.kind === 'runtime') {
|
||||
return { label: `Remote · ${hostLabel}`, detail: repo.path }
|
||||
}
|
||||
return {
|
||||
label: localPlatform ? getLocalExecutionHostLabel(localPlatform) : 'This computer',
|
||||
detail: repo.path
|
||||
}
|
||||
}
|
||||
|
||||
export function buildNewWorkspaceRunTargetOptions<TRepo extends WorkspaceRepo>(
|
||||
repos: readonly TRepo[],
|
||||
projectId: string | null,
|
||||
localPlatform: NodeJS.Platform | null = null
|
||||
): NewWorkspaceRunTargetOption<TRepo>[] {
|
||||
if (!projectId) {
|
||||
return []
|
||||
}
|
||||
const options = new Map<string, NewWorkspaceRunTargetOption<TRepo>>()
|
||||
for (const repo of repos) {
|
||||
if (getProjectIdentityKey(repo) !== projectId) {
|
||||
continue
|
||||
}
|
||||
const hostId = getRepoExecutionHostId(repo)
|
||||
if (!options.has(hostId)) {
|
||||
options.set(hostId, {
|
||||
id: repo.id,
|
||||
...getNewWorkspaceRunTarget(repo, localPlatform),
|
||||
repo
|
||||
})
|
||||
}
|
||||
}
|
||||
return [...options.values()]
|
||||
}
|
||||
@@ -30,7 +30,7 @@ describe('resolveNewWorktreeFormSheetVisible', () => {
|
||||
expect(
|
||||
resolveNewWorktreeFormSheetVisible({
|
||||
modalVisible: true,
|
||||
drawerView: 'repo',
|
||||
drawerView: 'project',
|
||||
formPinnedUnderSource: false
|
||||
})
|
||||
).toBe(false)
|
||||
|
||||
@@ -2,7 +2,14 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { BOTTOM_DRAWER_HIDE_DURATION_MS } from './bottom-drawer-constants'
|
||||
import { resolveNewWorktreeFormSheetVisible } from './new-worktree-form-sheet-visibility'
|
||||
|
||||
export type NewWorktreeDrawerView = 'form' | 'transition' | 'source' | 'repo' | 'agent' | 'trust'
|
||||
export type NewWorktreeDrawerView =
|
||||
| 'form'
|
||||
| 'transition'
|
||||
| 'source'
|
||||
| 'project'
|
||||
| 'runTarget'
|
||||
| 'agent'
|
||||
| 'trust'
|
||||
|
||||
// Why: iOS cannot reliably present a second native modal until the first drawer's
|
||||
// exit commits; one extra frame keeps transitions sequential on slower devices.
|
||||
|
||||
@@ -138,6 +138,17 @@ describe('resolveMobileFileTabDoc', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Why: the host now caps oversized diffs with an error envelope, and a client that knows nothing
|
||||
// about the code must still surface the message instead of rendering an empty diff.
|
||||
it('propagates a host diff_too_large failure instead of rendering an empty diff', async () => {
|
||||
const client = clientOf({
|
||||
'git.diff': fail('diff_too_large', 'This diff is too large to open over a remote connection.')
|
||||
})
|
||||
await expect(
|
||||
resolveMobileFileTabDoc(client, { ...WT, relativePath: 'a.ts', diffSource: 'staged' })
|
||||
).rejects.toThrow('This diff is too large to open over a remote connection.')
|
||||
})
|
||||
|
||||
it('propagates the RPC error message when a read fails', async () => {
|
||||
const client = clientOf({ 'files.read': fail('EIO', 'file_too_large') })
|
||||
await expect(
|
||||
|
||||
@@ -6,11 +6,11 @@ import {
|
||||
buildMobileAiVaultResumeLaunch,
|
||||
buildMobileAiVaultResumeCommand,
|
||||
createMobileAiVaultResumeMutationRegistry,
|
||||
readMobileRuntimeHostPlatform,
|
||||
readMobileRuntimeTerminalWindowsShell,
|
||||
resolveMobileAiVaultResumePlatform,
|
||||
resumeAiVaultSessionInTerminal
|
||||
} from './ai-vault-resume-launch'
|
||||
import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform'
|
||||
import { RESUME_RPC_TIMEOUT_MS } from './ai-vault-resume-preparation'
|
||||
|
||||
describe('activateStructuredAiVaultSession', () => {
|
||||
|
||||
@@ -24,20 +24,6 @@ import {
|
||||
} from './mobile-diff-review-rpc'
|
||||
import type { MobileAiVaultResumeTargetStatus } from '../agent-history/agent-history-resume-target'
|
||||
|
||||
const NODE_PLATFORMS = new Set<NodeJS.Platform>([
|
||||
'aix',
|
||||
'android',
|
||||
'darwin',
|
||||
'freebsd',
|
||||
'haiku',
|
||||
'linux',
|
||||
'openbsd',
|
||||
'sunos',
|
||||
'win32',
|
||||
'cygwin',
|
||||
'netbsd'
|
||||
])
|
||||
|
||||
export function buildMobileAiVaultResumeCommand(args: {
|
||||
session: Pick<AiVaultSession, 'agent' | 'sessionId' | 'cwd' | 'codexHome'> &
|
||||
Partial<Pick<AiVaultSession, 'filePath'>>
|
||||
@@ -260,16 +246,6 @@ export function createMobileAiVaultResumeMutationRegistry(
|
||||
}
|
||||
}
|
||||
|
||||
export function readMobileRuntimeHostPlatform(statusResult: unknown): NodeJS.Platform | null {
|
||||
if (!statusResult || typeof statusResult !== 'object') {
|
||||
return null
|
||||
}
|
||||
const hostPlatform = (statusResult as { hostPlatform?: unknown }).hostPlatform
|
||||
return typeof hostPlatform === 'string' && NODE_PLATFORMS.has(hostPlatform as NodeJS.Platform)
|
||||
? (hostPlatform as NodeJS.Platform)
|
||||
: null
|
||||
}
|
||||
|
||||
export function readMobileRuntimeTerminalWindowsShell(statusResult: unknown): string | null {
|
||||
if (!statusResult || typeof statusResult !== 'object') {
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
import type { MobileDiffReviewQueueItem } from './mobile-diff-review-queue'
|
||||
import { loadMobileDiffReviewDiff } from './mobile-diff-review-loaders'
|
||||
|
||||
const DELETED_ITEM: MobileDiffReviewQueueItem = {
|
||||
key: 'unstaged\0unstaged\0\0deleted.ts',
|
||||
scope: 'unstaged',
|
||||
area: 'unstaged',
|
||||
filePath: 'deleted.ts',
|
||||
status: 'deleted',
|
||||
title: 'deleted.ts',
|
||||
subtitle: 'Unstaged',
|
||||
canStage: true,
|
||||
canUnstage: false,
|
||||
canDiscard: true,
|
||||
isGeneratedOrLockFile: false,
|
||||
diffIdentity: 'deleted-diff',
|
||||
noteCount: 0,
|
||||
unsentNoteCount: 0,
|
||||
staleNoteCount: 0,
|
||||
isReviewed: false,
|
||||
changedSinceReview: false
|
||||
}
|
||||
|
||||
function clientWith(response: RpcResponse): RpcClient {
|
||||
return {
|
||||
sendRequest: vi.fn().mockResolvedValue(response)
|
||||
} as unknown as RpcClient
|
||||
}
|
||||
|
||||
function failure(code: string, message: string): RpcResponse {
|
||||
return { id: 'rpc-1', ok: false, error: { code, message }, _meta: { runtimeId: 'runtime-1' } }
|
||||
}
|
||||
|
||||
describe('loadMobileDiffReviewDiff', () => {
|
||||
it('shows the too-large state for an oversized deleted diff', async () => {
|
||||
await expect(
|
||||
loadMobileDiffReviewDiff({
|
||||
client: clientWith(failure('diff_too_large', 'Diff too large')),
|
||||
worktreeId: 'wt-1',
|
||||
item: DELETED_ITEM,
|
||||
branchCompare: null
|
||||
})
|
||||
).resolves.toEqual({ kind: 'too-large', itemKey: DELETED_ITEM.key })
|
||||
})
|
||||
|
||||
it('keeps the deleted fallback for hosts that cannot return deleted content', async () => {
|
||||
await expect(
|
||||
loadMobileDiffReviewDiff({
|
||||
client: clientWith(failure('internal_error', 'Unable to read deleted file')),
|
||||
worktreeId: 'wt-1',
|
||||
item: DELETED_ITEM,
|
||||
branchCompare: null
|
||||
})
|
||||
).resolves.toEqual({ kind: 'deleted', itemKey: DELETED_ITEM.key })
|
||||
})
|
||||
})
|
||||
@@ -130,6 +130,9 @@ export async function loadMobileDiffReviewDiff(input: DiffLoadInput): Promise<Re
|
||||
staged: item.scope === 'staged'
|
||||
})
|
||||
if (!response.ok) {
|
||||
if (response.error?.code === 'diff_too_large') {
|
||||
return { kind: 'too-large', itemKey: item.key }
|
||||
}
|
||||
if (item.status === 'deleted') {
|
||||
return { kind: 'deleted', itemKey: item.key }
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ function statusClient(outcomes: Array<'cutover' | 'error' | string[]>): RpcClien
|
||||
return {
|
||||
id: '1',
|
||||
ok: true,
|
||||
result: { capabilities: outcome },
|
||||
result: { capabilities: outcome, hostPlatform: 'darwin' },
|
||||
_meta: { runtimeId: 'r' }
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,8 @@ describe('readNewWorktreeRuntimeCapabilities', () => {
|
||||
)
|
||||
).resolves.toEqual({
|
||||
tasksSupported: true,
|
||||
idempotentWorktreeCreateSupported: true
|
||||
idempotentWorktreeCreateSupported: true,
|
||||
hostPlatform: 'darwin'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -44,14 +45,16 @@ describe('readNewWorktreeRuntimeCapabilities', () => {
|
||||
)
|
||||
).resolves.toEqual({
|
||||
tasksSupported: false,
|
||||
idempotentWorktreeCreateSupported: true
|
||||
idempotentWorktreeCreateSupported: true,
|
||||
hostPlatform: 'darwin'
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when capability detection is unavailable', async () => {
|
||||
await expect(readNewWorktreeRuntimeCapabilities(statusClient(['error']))).resolves.toEqual({
|
||||
tasksSupported: false,
|
||||
idempotentWorktreeCreateSupported: false
|
||||
idempotentWorktreeCreateSupported: false,
|
||||
hostPlatform: null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform'
|
||||
import { MOBILE_TASKS_CAPABILITY } from './mobile-tasks-capability'
|
||||
|
||||
// Why: older hosts strip worktree.create's clientMutationId, so mobile must not
|
||||
@@ -14,11 +15,13 @@ const STATUS_CUTOVER_MAX_RETRIES = 5
|
||||
export type NewWorktreeRuntimeCapabilities = {
|
||||
tasksSupported: boolean
|
||||
idempotentWorktreeCreateSupported: boolean
|
||||
hostPlatform: NodeJS.Platform | null
|
||||
}
|
||||
|
||||
const UNSUPPORTED_CAPABILITIES: NewWorktreeRuntimeCapabilities = {
|
||||
tasksSupported: false,
|
||||
idempotentWorktreeCreateSupported: false
|
||||
idempotentWorktreeCreateSupported: false,
|
||||
hostPlatform: null
|
||||
}
|
||||
|
||||
// Why: status.get is safe to replay and must settle before create, independently
|
||||
@@ -32,13 +35,14 @@ export async function readNewWorktreeRuntimeCapabilities(
|
||||
if (!response.ok) {
|
||||
return UNSUPPORTED_CAPABILITIES
|
||||
}
|
||||
const capabilities =
|
||||
((response as RpcSuccess).result as { capabilities?: string[] }).capabilities ?? []
|
||||
const result = (response as RpcSuccess).result as { capabilities?: string[] }
|
||||
const capabilities = result.capabilities ?? []
|
||||
return {
|
||||
tasksSupported: capabilities.includes(MOBILE_TASKS_CAPABILITY),
|
||||
idempotentWorktreeCreateSupported: capabilities.includes(
|
||||
MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY
|
||||
)
|
||||
),
|
||||
hostPlatform: readMobileRuntimeHostPlatform(result)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isLogicalClientCutoverError(error) || migrationRetry >= STATUS_CUTOVER_MAX_RETRIES) {
|
||||
@@ -53,9 +57,11 @@ export function useNewWorktreeRuntimeCapabilities(
|
||||
enabled: boolean
|
||||
): {
|
||||
tasksSupported: boolean
|
||||
hostPlatform: NodeJS.Platform | null
|
||||
getWorktreeCreateCutoverSupport: () => Promise<boolean>
|
||||
} {
|
||||
const [tasksSupported, setTasksSupported] = useState(false)
|
||||
const [hostPlatform, setHostPlatform] = useState<NodeJS.Platform | null>(null)
|
||||
const capabilityProbeRef = useRef<{
|
||||
client: RpcClient | null
|
||||
promise: Promise<NewWorktreeRuntimeCapabilities>
|
||||
@@ -82,6 +88,7 @@ export function useNewWorktreeRuntimeCapabilities(
|
||||
void getCapabilities().then((capabilities) => {
|
||||
if (!stale) {
|
||||
setTasksSupported(capabilities.tasksSupported)
|
||||
setHostPlatform(capabilities.hostPlatform)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
@@ -93,5 +100,5 @@ export function useNewWorktreeRuntimeCapabilities(
|
||||
() => getCapabilities().then((capabilities) => capabilities.idempotentWorktreeCreateSupported),
|
||||
[getCapabilities]
|
||||
)
|
||||
return { tasksSupported, getWorktreeCreateCutoverSupport }
|
||||
return { tasksSupported, hostPlatform, getWorktreeCreateCutoverSupport }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
const WORKTREE_VISIBILITY_SOURCE_DEFAULTS_PARAM = 'supportsWorktreeVisibilitySourceDefaults'
|
||||
|
||||
export function projectMobileRpcRequestParams(method: string, params: unknown): unknown {
|
||||
if (method !== 'worktree.ps') {
|
||||
return params
|
||||
}
|
||||
const current =
|
||||
params && typeof params === 'object' && !Array.isArray(params)
|
||||
? (params as Record<string, unknown>)
|
||||
: {}
|
||||
return { ...current, [WORKTREE_VISIBILITY_SOURCE_DEFAULTS_PARAM]: true }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
const NODE_PLATFORMS = new Set<NodeJS.Platform>([
|
||||
'aix',
|
||||
'android',
|
||||
'darwin',
|
||||
'freebsd',
|
||||
'haiku',
|
||||
'linux',
|
||||
'openbsd',
|
||||
'sunos',
|
||||
'win32',
|
||||
'cygwin',
|
||||
'netbsd'
|
||||
])
|
||||
|
||||
export function readMobileRuntimeHostPlatform(statusResult: unknown): NodeJS.Platform | null {
|
||||
const hostPlatform = (statusResult as { hostPlatform?: unknown } | null)?.hostPlatform
|
||||
return typeof hostPlatform === 'string' && NODE_PLATFORMS.has(hostPlatform as NodeJS.Platform)
|
||||
? (hostPlatform as NodeJS.Platform)
|
||||
: null
|
||||
}
|
||||
@@ -79,6 +79,23 @@ describe('stable logical RPC client', () => {
|
||||
expect(session.confirmStructuredStreamLongevity).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('advertises source-default support on worktree catalog requests', async () => {
|
||||
const session = new FakeSession('connected')
|
||||
session.sendRequest.mockResolvedValue(success([]))
|
||||
const client = createStableLogicalRpcClient(session, 'lan')
|
||||
|
||||
await client.sendRequest('worktree.ps', { limit: 10_000 })
|
||||
await client.sendRequest('status.get')
|
||||
|
||||
expect(session.sendRequest).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'worktree.ps',
|
||||
{ limit: 10_000, supportsWorktreeVisibilitySourceDefaults: true },
|
||||
undefined
|
||||
)
|
||||
expect(session.sendRequest).toHaveBeenNthCalledWith(2, 'status.get', undefined, undefined)
|
||||
})
|
||||
|
||||
it('makes before break, rejects in-flight work, and replays subscriptions', async () => {
|
||||
const oldSession = new FakeSession('connected')
|
||||
const nextSession = new FakeSession('connecting')
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type MigrationDialStateForwarder
|
||||
} from './migration-dial-state-forwarder'
|
||||
import { waitForAuthenticated } from './replacement-session-authentication'
|
||||
import { projectMobileRpcRequestParams } from './mobile-rpc-request-projection'
|
||||
|
||||
export type MobileConnectionPath = 'lan' | 'tailscale' | 'relay'
|
||||
|
||||
@@ -84,22 +85,24 @@ export function createStableLogicalRpcClient(
|
||||
return new Promise<RpcResponse>((resolve, reject) => {
|
||||
const pending = { reject }
|
||||
pendingRequests.add(pending)
|
||||
void session.sendRequest(method, params, options).then(
|
||||
(response) => {
|
||||
pendingRequests.delete(pending)
|
||||
if (closed) {
|
||||
reject(new Error('Client closed'))
|
||||
} else if (requestGeneration !== generation) {
|
||||
reject(new LogicalClientCutoverError())
|
||||
} else {
|
||||
resolve(response)
|
||||
void session
|
||||
.sendRequest(method, projectMobileRpcRequestParams(method, params), options)
|
||||
.then(
|
||||
(response) => {
|
||||
pendingRequests.delete(pending)
|
||||
if (closed) {
|
||||
reject(new Error('Client closed'))
|
||||
} else if (requestGeneration !== generation) {
|
||||
reject(new LogicalClientCutoverError())
|
||||
} else {
|
||||
resolve(response)
|
||||
}
|
||||
},
|
||||
(error: unknown) => {
|
||||
pendingRequests.delete(pending)
|
||||
reject(error)
|
||||
}
|
||||
},
|
||||
(error: unknown) => {
|
||||
pendingRequests.delete(pending)
|
||||
reject(error)
|
||||
}
|
||||
)
|
||||
)
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@ import {
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
|
||||
RUNTIME_PROTOCOL_VERSION,
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
|
||||
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY
|
||||
} from '../../shared/protocol-version'
|
||||
|
||||
vi.mock('./launch', () => ({
|
||||
@@ -64,7 +66,9 @@ describe('CLI remote WebSocket transport', () => {
|
||||
expect.objectContaining({
|
||||
clientCapabilities: [
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
|
||||
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
@@ -48,11 +48,13 @@ export async function readHooksJsonRemote(
|
||||
export async function writeHooksJsonRemote(
|
||||
sftp: SFTPWrapper,
|
||||
remotePath: string,
|
||||
config: HooksConfig
|
||||
config: HooksConfig,
|
||||
// Why: mirrors the local writer — a JSONC config supplies text edited in place.
|
||||
options?: { serialized?: string }
|
||||
): Promise<void> {
|
||||
const dir = dirnamePosix(remotePath)
|
||||
await mkdirpRemote(sftp, dir)
|
||||
const serialized = `${JSON.stringify(config, null, 2)}\n`
|
||||
const serialized = options?.serialized ?? `${JSON.stringify(config, null, 2)}\n`
|
||||
// Why: skip the write when on-disk content is identical so repeated
|
||||
// install() calls do not bump the file's mtime / inode unnecessarily.
|
||||
try {
|
||||
|
||||
@@ -316,7 +316,9 @@ function writeScriptWithAclRetry(scriptPath: string, content: string): void {
|
||||
export function writeHooksJson(
|
||||
configPath: string,
|
||||
config: HooksConfig,
|
||||
options?: { preserveMode?: boolean }
|
||||
// Why: `serialized` lets a JSONC config (Devin) supply text edited in place, so the
|
||||
// atomic write + rolling backup below stay shared instead of being reimplemented.
|
||||
options?: { preserveMode?: boolean; serialized?: string }
|
||||
): void {
|
||||
const writePath = resolveHooksJsonWritePath(configPath)
|
||||
const dir = dirname(writePath)
|
||||
@@ -325,7 +327,7 @@ export function writeHooksJson(
|
||||
// Why: temp+rename leaves the original untouched on a crash/disk-full mid-write.
|
||||
// Why randomUUID: avoids tmp-path collisions when two install() calls fire in the same millisecond.
|
||||
const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`)
|
||||
const serialized = `${JSON.stringify(config, null, 2)}\n`
|
||||
const serialized = options?.serialized ?? `${JSON.stringify(config, null, 2)}\n`
|
||||
const existingMode =
|
||||
options?.preserveMode === true && existsSync(writePath) ? statSync(writePath).mode : undefined
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { parse as parseJsonc } from 'jsonc-parser'
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
@@ -415,7 +416,11 @@ describe('remote hook service installers', () => {
|
||||
expect(grokConfig.hooks.PostToolUse?.[0]?.matcher).toBe('.*')
|
||||
expect(grokConfig.hooks.StopFailure?.[0]?.matcher).toBeUndefined()
|
||||
|
||||
const devinConfig = JSON.parse(devin.fs.files.get('/home/dev/.config/devin/config.json')!) as {
|
||||
const devinText = devin.fs.files.get('/home/dev/.config/devin/config.json')!
|
||||
// Why: Devin config.json is JSONC — parse it as such, and assert the user's comment
|
||||
// survived. Asserting with JSON.parse would only pass if the install had stripped it.
|
||||
expect(devinText).toContain('// Existing Devin config comment')
|
||||
const devinConfig = parseJsonc(devinText) as {
|
||||
permissions: { mode: string }
|
||||
hooks: Record<string, { matcher?: string; hooks?: { command: string }[] }[]>
|
||||
}
|
||||
|
||||
@@ -7182,6 +7182,303 @@ describe('Last-status persistence', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps an unknown post-restart child stop from confirming a stale working row', async () => {
|
||||
const firstServer = new AgentHookServer()
|
||||
await firstServer.start({ env: 'production', userDataPath })
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'may finish offline' })
|
||||
)
|
||||
firstServer.flushStatusPersistSync()
|
||||
firstServer.stop()
|
||||
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
const restored = server.getStatusSnapshot()[0]
|
||||
expect(restored).toMatchObject({ state: 'working', restoredUnconfirmed: true })
|
||||
|
||||
await postHookEvent(
|
||||
server,
|
||||
buildBody({ hook_event_name: 'SubagentStop', agent_id: 'a0000000000000001' })
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toEqual(restored)
|
||||
expect(server._getStateForTests().claudeSubagentRosterByPaneKey.size).toBe(0)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('waits for a lead boundary after a post-restart stop matches a restored child', async () => {
|
||||
const firstServer = new AgentHookServer()
|
||||
await firstServer.start({ env: 'production', userDataPath })
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({
|
||||
hook_event_name: 'SubagentStart',
|
||||
agent_id: 'a0000000000000002',
|
||||
agent_type: 'reviewer'
|
||||
})
|
||||
)
|
||||
firstServer.flushStatusPersistSync()
|
||||
firstServer.stop()
|
||||
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
const statusListener = vi.fn()
|
||||
server.subscribeEnrichedStatus(statusListener)
|
||||
const restored = server.getStatusSnapshot()[0]
|
||||
expect(restored).toMatchObject({
|
||||
state: 'working',
|
||||
restoredUnconfirmed: true,
|
||||
subagents: [expect.objectContaining({ id: 'a0000000000000002' })]
|
||||
})
|
||||
|
||||
await postHookEvent(
|
||||
server,
|
||||
buildBody({ hook_event_name: 'SubagentStop', agent_id: 'a0000000000000002' })
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'working',
|
||||
restoredUnconfirmed: true,
|
||||
subagents: undefined
|
||||
})
|
||||
expect(server.getStatusChangeSnapshot()[0]?.observedInCurrentRuntime).toBe(false)
|
||||
expect(server._getStateForTests().claudeSubagentRosterByPaneKey.size).toBe(0)
|
||||
expect(statusListener).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ restoredUnconfirmed: true })
|
||||
)
|
||||
|
||||
await postHookEvent(server, buildBody({ hook_event_name: 'Stop', background_tasks: [] }))
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done' })
|
||||
expect(server.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined()
|
||||
expect(server.getStatusSnapshot()[0]?.subagents).toBeUndefined()
|
||||
expect(statusListener).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not resurrect a matched restored child across a second restart', async () => {
|
||||
const firstServer = new AgentHookServer()
|
||||
await firstServer.start({ env: 'production', userDataPath })
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({
|
||||
hook_event_name: 'SubagentStart',
|
||||
agent_id: 'a0000000000000012',
|
||||
agent_type: 'reviewer'
|
||||
})
|
||||
)
|
||||
firstServer.flushStatusPersistSync()
|
||||
firstServer.stop()
|
||||
|
||||
const secondServer = new AgentHookServer()
|
||||
await secondServer.start({ env: 'production', userDataPath })
|
||||
await postHookEvent(
|
||||
secondServer,
|
||||
buildBody({ hook_event_name: 'SubagentStop', agent_id: 'a0000000000000012' })
|
||||
)
|
||||
expect(secondServer.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'working',
|
||||
restoredUnconfirmed: true,
|
||||
subagents: undefined
|
||||
})
|
||||
secondServer.flushStatusPersistSync()
|
||||
secondServer.stop()
|
||||
|
||||
const thirdServer = new AgentHookServer()
|
||||
await thirdServer.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
expect(thirdServer._getStateForTests().claudeSubagentRosterByPaneKey.size).toBe(0)
|
||||
|
||||
await postHookEvent(thirdServer, buildBody({ hook_event_name: 'Stop' }))
|
||||
|
||||
expect(thirdServer.getStatusSnapshot()[0]).toMatchObject({ state: 'done' })
|
||||
expect(thirdServer.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined()
|
||||
} finally {
|
||||
thirdServer.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a legacy Stop unconfirmed when only a restored child gates it', async () => {
|
||||
const firstServer = new AgentHookServer()
|
||||
await firstServer.start({ env: 'production', userDataPath })
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({
|
||||
hook_event_name: 'SubagentStart',
|
||||
agent_id: 'a0000000000000013',
|
||||
agent_type: 'reviewer'
|
||||
})
|
||||
)
|
||||
firstServer.flushStatusPersistSync()
|
||||
firstServer.stop()
|
||||
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
await postHookEvent(server, buildBody({ hook_event_name: 'Stop' }))
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'working',
|
||||
restoredUnconfirmed: true,
|
||||
subagents: [expect.objectContaining({ id: 'a0000000000000013' })]
|
||||
})
|
||||
expect(server.getStatusChangeSnapshot()[0]?.observedInCurrentRuntime).toBe(false)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not confirm restored child work from an unrelated stop', async () => {
|
||||
const firstServer = new AgentHookServer()
|
||||
await firstServer.start({ env: 'production', userDataPath })
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({
|
||||
hook_event_name: 'SubagentStart',
|
||||
agent_id: 'a0000000000000003',
|
||||
agent_type: 'reviewer'
|
||||
})
|
||||
)
|
||||
firstServer.flushStatusPersistSync()
|
||||
firstServer.stop()
|
||||
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
const restored = server.getStatusSnapshot()[0]
|
||||
expect(restored).toMatchObject({ state: 'working', restoredUnconfirmed: true })
|
||||
|
||||
await postHookEvent(
|
||||
server,
|
||||
buildBody({ hook_event_name: 'SubagentStop', agent_id: 'a0000000000000004' })
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toEqual(restored)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps an unmatched restored child unconfirmed after current-runtime work drains', async () => {
|
||||
const firstServer = new AgentHookServer()
|
||||
await firstServer.start({ env: 'production', userDataPath })
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({
|
||||
hook_event_name: 'SubagentStart',
|
||||
agent_id: 'a0000000000000005',
|
||||
agent_type: 'reviewer'
|
||||
})
|
||||
)
|
||||
firstServer.flushStatusPersistSync()
|
||||
firstServer.stop()
|
||||
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
await postHookEvent(
|
||||
server,
|
||||
buildBody({
|
||||
hook_event_name: 'SubagentStart',
|
||||
agent_id: 'a0000000000000006',
|
||||
agent_type: 'reviewer'
|
||||
})
|
||||
)
|
||||
expect(server.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined()
|
||||
|
||||
await postHookEvent(
|
||||
server,
|
||||
buildBody({ hook_event_name: 'SubagentStop', agent_id: 'a0000000000000006' })
|
||||
)
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'working',
|
||||
restoredUnconfirmed: true,
|
||||
subagents: [expect.objectContaining({ id: 'a0000000000000005' })]
|
||||
})
|
||||
expect(server.getStatusChangeSnapshot()[0]?.observedInCurrentRuntime).toBe(false)
|
||||
|
||||
await postHookEvent(
|
||||
server,
|
||||
buildBody({ hook_event_name: 'SubagentStop', agent_id: 'a0000000000000005' })
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'working',
|
||||
restoredUnconfirmed: true
|
||||
})
|
||||
expect(server._getStateForTests().claudeSubagentRosterByPaneKey.size).toBe(0)
|
||||
|
||||
await postHookEvent(server, buildBody({ hook_event_name: 'Stop', background_tasks: [] }))
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done' })
|
||||
expect(server.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined()
|
||||
expect(server.getStatusSnapshot()[0]?.subagents).toBeUndefined()
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('waits for a lead boundary after a post-restart idle matches a restored teammate', async () => {
|
||||
const firstServer = new AgentHookServer()
|
||||
await firstServer.start({ env: 'production', userDataPath })
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({
|
||||
hook_event_name: 'SubagentStart',
|
||||
agent_id: 'areviewer-6d3cb5b5',
|
||||
agent_type: 'reviewer'
|
||||
})
|
||||
)
|
||||
firstServer.flushStatusPersistSync()
|
||||
firstServer.stop()
|
||||
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
const restored = server.getStatusSnapshot()[0]
|
||||
expect(restored).toMatchObject({
|
||||
state: 'working',
|
||||
restoredUnconfirmed: true,
|
||||
subagents: [expect.objectContaining({ id: 'areviewer-6d3cb5b5' })]
|
||||
})
|
||||
|
||||
await postHookEvent(
|
||||
server,
|
||||
buildBody({ hook_event_name: 'TeammateIdle', teammate_name: 'reviewer' })
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'working',
|
||||
restoredUnconfirmed: true,
|
||||
subagents: [expect.objectContaining({ id: 'areviewer-6d3cb5b5', state: 'idle' })]
|
||||
})
|
||||
expect(server.getStatusChangeSnapshot()[0]?.observedInCurrentRuntime).toBe(false)
|
||||
|
||||
await postHookEvent(
|
||||
server,
|
||||
buildBody({
|
||||
hook_event_name: 'Stop',
|
||||
background_tasks: [{ id: 'treviewer', type: 'teammate', status: 'running' }]
|
||||
})
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'done',
|
||||
subagents: [expect.objectContaining({ id: 'areviewer-6d3cb5b5', state: 'idle' })]
|
||||
})
|
||||
expect(server.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined()
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('lets an identical live OSC observation confirm a hydrated row', async () => {
|
||||
const payload = {
|
||||
state: 'working' as const,
|
||||
|
||||
@@ -1277,7 +1277,12 @@ export class AgentHookServer {
|
||||
}
|
||||
const cachedPayload = resolveCachedClaudeCompactOwnership(previous, effectivePayload)
|
||||
const enriched = this.attachStatusTiming(cachedPayload, now)
|
||||
this.runtimeObservedStatusPaneKeys.add(enriched.paneKey)
|
||||
// Why: an identity-matched event can still leave the aggregate backed only by another restored child; keep liveness reconciliation eligible.
|
||||
if (enriched.restoredUnconfirmed) {
|
||||
this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey)
|
||||
} else {
|
||||
this.runtimeObservedStatusPaneKeys.add(enriched.paneKey)
|
||||
}
|
||||
this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched)
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
|
||||
@@ -3,7 +3,8 @@ import { DatabaseSync } from 'node:sqlite'
|
||||
import type { Cookie } from 'electron'
|
||||
import {
|
||||
identitiesFromClearCookies,
|
||||
removeTransplantableCookies
|
||||
removeTransplantableCookies,
|
||||
type CookieClearIdentity
|
||||
} from './browser-cookie-import-clear'
|
||||
import {
|
||||
isGoogleSourceBoundCookie,
|
||||
@@ -49,6 +50,29 @@ describe('isGoogleSourceBoundCookie', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Why: mirrors what openCookieClearStore returns — get/remove plus the CDP identity pair, and
|
||||
// deliberately no 'set', so a partition-dropping reconstruction cannot be written against it.
|
||||
function replaceStore(
|
||||
existing: Cookie[],
|
||||
overrides: {
|
||||
remove?: Mock
|
||||
snapshot?: Mock
|
||||
restore?: Mock
|
||||
} = {}
|
||||
) {
|
||||
const get = vi.fn().mockResolvedValue(existing)
|
||||
const remove = overrides.remove ?? vi.fn().mockResolvedValue(undefined)
|
||||
const snapshotClearIdentities =
|
||||
overrides.snapshot ??
|
||||
vi
|
||||
.fn()
|
||||
.mockImplementation(async (cookies: readonly { cookie: Cookie; url: string }[]) =>
|
||||
identitiesFromClearCookies(cookies)
|
||||
)
|
||||
const restoreClearIdentities = overrides.restore ?? vi.fn().mockResolvedValue(undefined)
|
||||
return { get, remove, snapshotClearIdentities, restoreClearIdentities }
|
||||
}
|
||||
|
||||
describe('replaceCookiesForImportedDomains', () => {
|
||||
it('removes parent, exact, and child-domain cookies while preserving unrelated sites', async () => {
|
||||
const existing = [
|
||||
@@ -59,99 +83,129 @@ describe('replaceCookiesForImportedDomains', () => {
|
||||
cookie('.google.com.evil.example', 'suffix-confusion'),
|
||||
cookie('.example.com', 'unrelated')
|
||||
]
|
||||
const get = vi.fn().mockResolvedValue(existing)
|
||||
const remove = vi.fn().mockResolvedValue(undefined)
|
||||
const set = vi.fn().mockResolvedValue(undefined)
|
||||
const store = replaceStore(existing)
|
||||
|
||||
const removed = await replaceCookiesForImportedDomains({ get, remove, set }, [
|
||||
'accounts.google.com'
|
||||
])
|
||||
const { removed } = await replaceCookiesForImportedDomains(store, ['accounts.google.com'])
|
||||
|
||||
expect(removed).toHaveLength(3)
|
||||
expect(get).toHaveBeenCalledWith({})
|
||||
expect(remove.mock.calls).toEqual([
|
||||
expect(store.get).toHaveBeenCalledWith({})
|
||||
expect(store.remove.mock.calls).toEqual([
|
||||
['https://google.com/', 'parent'],
|
||||
['https://accounts.google.com/signin', 'exact'],
|
||||
['http://child.accounts.google.com/nested', 'child']
|
||||
])
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
// Why: the snapshot must cover the whole removal plan before the first removal runs.
|
||||
expect(store.snapshotClearIdentities).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
store.snapshotClearIdentities.mock.calls[0]?.[0].map(
|
||||
({ cookie: entry }: { cookie: Cookie }) => entry.name
|
||||
)
|
||||
).toEqual(['parent', 'exact', 'child'])
|
||||
expect(store.restoreClearIdentities).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not replace a private-suffix host cookie for a tenant import', async () => {
|
||||
const get = vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ ...cookie('github.io', 'host-only-suffix'), hostOnly: true },
|
||||
cookie('.user.github.io', 'tenant')
|
||||
])
|
||||
const remove = vi.fn().mockResolvedValue(undefined)
|
||||
const set = vi.fn().mockResolvedValue(undefined)
|
||||
const store = replaceStore([
|
||||
{ ...cookie('github.io', 'host-only-suffix'), hostOnly: true },
|
||||
cookie('.user.github.io', 'tenant')
|
||||
])
|
||||
|
||||
const removed = await replaceCookiesForImportedDomains({ get, remove, set }, ['user.github.io'])
|
||||
const { removed } = await replaceCookiesForImportedDomains(store, ['user.github.io'])
|
||||
|
||||
expect(removed.map(({ name }) => name)).toEqual(['tenant'])
|
||||
expect(remove).toHaveBeenCalledWith('https://user.github.io/', 'tenant')
|
||||
expect(store.remove).toHaveBeenCalledWith('https://user.github.io/', 'tenant')
|
||||
})
|
||||
|
||||
it('does not read or mutate the store when no valid domain scope exists', async () => {
|
||||
const get = vi.fn()
|
||||
const remove = vi.fn()
|
||||
const set = vi.fn()
|
||||
const store = replaceStore([])
|
||||
|
||||
await expect(
|
||||
replaceCookiesForImportedDomains({ get, remove, set }, [
|
||||
'',
|
||||
'...',
|
||||
'com',
|
||||
'co.uk',
|
||||
'github.io'
|
||||
])
|
||||
).resolves.toEqual([])
|
||||
expect(get).not.toHaveBeenCalled()
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
replaceCookiesForImportedDomains(store, ['', '...', 'com', 'co.uk', 'github.io'])
|
||||
).resolves.toEqual({ removed: [], identities: [] })
|
||||
expect(store.get).not.toHaveBeenCalled()
|
||||
expect(store.remove).not.toHaveBeenCalled()
|
||||
expect(store.snapshotClearIdentities).not.toHaveBeenCalled()
|
||||
expect(store.restoreClearIdentities).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps single-label intranet scopes from selecting descendant hosts', async () => {
|
||||
const get = vi
|
||||
.fn()
|
||||
.mockResolvedValue([cookie('local', 'exact'), cookie('.service.local', 'descendant')])
|
||||
const remove = vi.fn().mockResolvedValue(undefined)
|
||||
const set = vi.fn().mockResolvedValue(undefined)
|
||||
const store = replaceStore([cookie('local', 'exact'), cookie('.service.local', 'descendant')])
|
||||
|
||||
const removed = await replaceCookiesForImportedDomains({ get, remove, set }, ['local'])
|
||||
const { removed } = await replaceCookiesForImportedDomains(store, ['local'])
|
||||
|
||||
expect(removed.map(({ name }) => name)).toEqual(['exact'])
|
||||
expect(remove).toHaveBeenCalledOnce()
|
||||
expect(remove).toHaveBeenCalledWith('https://local/', 'exact')
|
||||
expect(store.remove).toHaveBeenCalledOnce()
|
||||
expect(store.remove).toHaveBeenCalledWith('https://local/', 'exact')
|
||||
})
|
||||
|
||||
it('restores cookies removed before a later removal fails', async () => {
|
||||
// Why (STA-4097): cookies.get strips partitionKey and cookies.set ignores it, so a rollback
|
||||
// that rebuilds cookies through the Electron API silently downgrades CHIPS cookies. The undo
|
||||
// has to travel back through the CDP identities that actually carry the partition.
|
||||
it('restores removed cookies through CDP identities, keeping partition identity', async () => {
|
||||
const existing = [
|
||||
cookie('.example.com', 'first', '/one'),
|
||||
cookie('.example.com', 'second', '/two')
|
||||
]
|
||||
const get = vi.fn().mockResolvedValue(existing)
|
||||
const partitionKey = { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
|
||||
const snapshot = vi
|
||||
.fn()
|
||||
.mockImplementation(async (cookies: readonly { cookie: Cookie; url: string }[]) =>
|
||||
identitiesFromClearCookies(cookies).map((identity) =>
|
||||
identity.name === 'first' ? { ...identity, partitionKey } : identity
|
||||
)
|
||||
)
|
||||
const remove = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('cookie store unavailable'))
|
||||
const set = vi.fn().mockResolvedValue(undefined)
|
||||
const store = replaceStore(existing, { remove, snapshot })
|
||||
|
||||
await expect(
|
||||
replaceCookiesForImportedDomains({ get, remove, set }, ['example.com'])
|
||||
).rejects.toThrow('cookie store unavailable')
|
||||
expect(set).toHaveBeenCalledOnce()
|
||||
expect(set).toHaveBeenCalledWith({
|
||||
url: 'https://example.com/one',
|
||||
name: 'first',
|
||||
value: 'secret',
|
||||
domain: '.example.com',
|
||||
path: '/one',
|
||||
secure: true,
|
||||
httpOnly: undefined,
|
||||
sameSite: 'unspecified'
|
||||
})
|
||||
await expect(replaceCookiesForImportedDomains(store, ['example.com'])).rejects.toThrow(
|
||||
'cookie store unavailable'
|
||||
)
|
||||
|
||||
expect(store.restoreClearIdentities).toHaveBeenCalledOnce()
|
||||
const restored = store.restoreClearIdentities.mock.calls[0]?.[0] as CookieClearIdentity[]
|
||||
// Why: the failing coordinate is restored too — a rejected remove cannot prove it survived.
|
||||
expect(restored.map(({ name }) => name)).toEqual(['second', 'first'])
|
||||
expect(restored.find(({ name }) => name === 'first')?.partitionKey).toEqual(partitionKey)
|
||||
expect(restored.find(({ name }) => name === 'first')?.url).toBe('https://example.com/one')
|
||||
})
|
||||
|
||||
it('aborts without removing anything when the snapshot cannot cover the removal plan', async () => {
|
||||
const existing = [
|
||||
cookie('.example.com', 'first', '/one'),
|
||||
cookie('.example.com', 'second', '/two')
|
||||
]
|
||||
const snapshot = vi
|
||||
.fn()
|
||||
.mockImplementation(async (cookies: readonly { cookie: Cookie; url: string }[]) =>
|
||||
identitiesFromClearCookies(cookies).filter((identity) => identity.name !== 'second')
|
||||
)
|
||||
const store = replaceStore(existing, { snapshot })
|
||||
|
||||
await expect(replaceCookiesForImportedDomains(store, ['example.com'])).rejects.toThrow(
|
||||
'the session was left unchanged'
|
||||
)
|
||||
expect(store.remove).not.toHaveBeenCalled()
|
||||
expect(store.restoreClearIdentities).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports both failures when the CDP rollback itself fails', async () => {
|
||||
const remove = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('cookie store unavailable'))
|
||||
const restore = vi.fn().mockRejectedValue(new Error('debugger detached'))
|
||||
const store = replaceStore(
|
||||
[cookie('.example.com', 'first', '/one'), cookie('.example.com', 'second', '/two')],
|
||||
{ remove, restore }
|
||||
)
|
||||
|
||||
await expect(replaceCookiesForImportedDomains(store, ['example.com'])).rejects.toThrow(
|
||||
'Cookie replacement and rollback failed'
|
||||
)
|
||||
expect(restore).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Cookie, Cookies } from 'electron'
|
||||
import { parse as parseDomain } from 'psl'
|
||||
// Why: type-only, so this does not create a runtime cycle with the clear module.
|
||||
import type { CookieClearIdentity } from './browser-cookie-import-clear'
|
||||
|
||||
const GOOGLE_SOURCE_BOUND_COOKIE_NAMES = new Set([
|
||||
'SIDCC',
|
||||
@@ -167,69 +169,102 @@ export function cookieRemovalUrl(cookie: Cookie, domain: string): string | null
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreImportedDomainCookies(
|
||||
store: Pick<Cookies, 'set'>,
|
||||
cookies: readonly Cookie[]
|
||||
): Promise<void> {
|
||||
const failures: unknown[] = []
|
||||
for (const cookie of cookies) {
|
||||
try {
|
||||
const domain = cookie.domain ? normalizeCookieDomain(cookie.domain) : null
|
||||
const url = domain ? cookieRemovalUrl(cookie, domain) : null
|
||||
if (!url) {
|
||||
continue
|
||||
}
|
||||
await store.set({
|
||||
url,
|
||||
name: cookie.name,
|
||||
value: cookie.value,
|
||||
...(cookie.hostOnly ? {} : { domain: cookie.domain }),
|
||||
...(cookie.path ? { path: cookie.path } : {}),
|
||||
secure: cookie.secure,
|
||||
httpOnly: cookie.httpOnly,
|
||||
sameSite: cookie.sameSite,
|
||||
...(cookie.expirationDate ? { expirationDate: cookie.expirationDate } : {})
|
||||
})
|
||||
} catch (err) {
|
||||
failures.push(err)
|
||||
// Why (STA-4097): 'set' stays out so the partition-dropping reconstruction cannot return.
|
||||
// Undoing a removal is only possible through CDP identities, which carry partitionKey.
|
||||
export type ImportedDomainReplaceStore = Pick<Cookies, 'get' | 'remove'> & {
|
||||
snapshotClearIdentities(
|
||||
cookies: readonly { cookie: Cookie; url: string }[]
|
||||
): Promise<CookieClearIdentity[]>
|
||||
restoreClearIdentities(identities: readonly CookieClearIdentity[]): Promise<void>
|
||||
}
|
||||
|
||||
export type ReplacedImportedDomainCookies = {
|
||||
removed: Cookie[]
|
||||
identities: CookieClearIdentity[]
|
||||
}
|
||||
|
||||
function replaceRemovalKey(url: string, name: string): string {
|
||||
return JSON.stringify([url, name])
|
||||
}
|
||||
|
||||
function assertIdentitiesCoverRemovable(
|
||||
removable: readonly { cookie: Cookie; url: string }[],
|
||||
identities: readonly CookieClearIdentity[]
|
||||
): void {
|
||||
const covered = new Set(
|
||||
identities.map((identity) => replaceRemovalKey(identity.url, identity.name))
|
||||
)
|
||||
for (const item of removable) {
|
||||
if (!covered.has(replaceRemovalKey(item.url, item.cookie.name))) {
|
||||
throw new Error('Could not replace existing cookies; the session was left unchanged')
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, 'Could not restore replaced cookies')
|
||||
}
|
||||
}
|
||||
|
||||
export async function replaceCookiesForImportedDomains(
|
||||
store: Pick<Cookies, 'get' | 'remove' | 'set'>,
|
||||
store: ImportedDomainReplaceStore,
|
||||
importedDomains: readonly string[]
|
||||
): Promise<Cookie[]> {
|
||||
): Promise<ReplacedImportedDomainCookies> {
|
||||
const scopes = importedDomainScopes(importedDomains)
|
||||
if (scopes.exact.size === 0) {
|
||||
return []
|
||||
return { removed: [], identities: [] }
|
||||
}
|
||||
|
||||
// Why (STA-4170): the removal plan is fixed here, beside the identities that can undo it, so
|
||||
// the restorable set always equals the mutated set. Re-reading the jar later would widen the
|
||||
// removal past what the snapshot can restore.
|
||||
const existingCookies = await store.get({})
|
||||
const removedCookies: Cookie[] = []
|
||||
const removable: { cookie: Cookie; url: string }[] = []
|
||||
for (const cookie of existingCookies) {
|
||||
const domain = cookie.domain ? normalizeCookieDomain(cookie.domain) : null
|
||||
if (!domain || !overlapsImportedDomain(cookie, domain, scopes)) {
|
||||
continue
|
||||
}
|
||||
const url = cookieRemovalUrl(cookie, domain)
|
||||
if (!url) {
|
||||
continue
|
||||
if (url) {
|
||||
removable.push({ cookie, url })
|
||||
}
|
||||
}
|
||||
if (removable.length === 0) {
|
||||
return { removed: [], identities: [] }
|
||||
}
|
||||
|
||||
// Why: snapshotting before the first removal is what makes the rollback lossless; an
|
||||
// incomplete snapshot aborts while the session is still untouched.
|
||||
const identities = await store.snapshotClearIdentities(removable)
|
||||
assertIdentitiesCoverRemovable(removable, identities)
|
||||
const identitiesByKey = new Map<string, CookieClearIdentity[]>()
|
||||
for (const identity of identities) {
|
||||
const key = replaceRemovalKey(identity.url, identity.name)
|
||||
const group = identitiesByKey.get(key) ?? []
|
||||
group.push(identity)
|
||||
identitiesByKey.set(key, group)
|
||||
}
|
||||
|
||||
const removed: Cookie[] = []
|
||||
// Why: one remove(url, name) deletes every cookie at that coordinate, partitioned twins
|
||||
// included, so the rollback set is tracked per coordinate rather than per cookie.
|
||||
const attemptedKeys = new Set<string>()
|
||||
const attemptedIdentities: CookieClearIdentity[] = []
|
||||
for (const { cookie, url } of removable) {
|
||||
const key = replaceRemovalKey(url, cookie.name)
|
||||
if (!attemptedKeys.has(key)) {
|
||||
attemptedKeys.add(key)
|
||||
attemptedIdentities.push(...(identitiesByKey.get(key) ?? []))
|
||||
}
|
||||
try {
|
||||
await store.remove(url, cookie.name)
|
||||
removedCookies.push(cookie)
|
||||
removed.push(cookie)
|
||||
} catch (err) {
|
||||
try {
|
||||
await restoreImportedDomainCookies(store, removedCookies)
|
||||
// Why: the failing coordinate is included because a rejected remove cannot prove the
|
||||
// cookie survived; restoring a live cookie rewrites the value it was snapshotted with.
|
||||
await store.restoreClearIdentities(attemptedIdentities.toReversed())
|
||||
} catch (restoreError) {
|
||||
throw new AggregateError([err, restoreError], 'Cookie replacement and rollback failed')
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
return removedCookies
|
||||
return { removed, identities }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { build as buildVite } from 'vite'
|
||||
|
||||
const electronBinary = createRequire(import.meta.url)('electron') as string
|
||||
const fixtureRoots: string[] = []
|
||||
|
||||
afterAll(() => {
|
||||
for (const root of fixtureRoots) {
|
||||
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
}
|
||||
})
|
||||
|
||||
type CdpCookie = { name: string; value: string; partitionKey?: Record<string, unknown> }
|
||||
|
||||
type FixtureResult = {
|
||||
beforePartitionKey: Record<string, unknown> | undefined
|
||||
electronGetSawPartitionKey: boolean
|
||||
removeCalls: number
|
||||
removeCallsBeforeThrow: number
|
||||
snapshotCalls: number
|
||||
restoreCalls: number
|
||||
importedRemoveCalls: number
|
||||
replaceError: string | null
|
||||
chipsAfterReplace: CdpCookie[]
|
||||
chipsAfter: CdpCookie[]
|
||||
plainAfter: CdpCookie[]
|
||||
}
|
||||
|
||||
const EXPECTED_PARTITION_KEY = {
|
||||
topLevelSite: 'https://top.example',
|
||||
hasCrossSiteAncestor: true
|
||||
}
|
||||
|
||||
type FixtureMode = 'site-a' | 'site-b'
|
||||
|
||||
function buildFixtureMain(bundlePath: string, resultPath: string, mode: FixtureMode): string {
|
||||
return `
|
||||
const { app, BrowserWindow, session } = require('electron')
|
||||
const { writeFileSync } = require('node:fs')
|
||||
const { openCookieClearStore, replaceCookiesForImportedDomains } = require(${JSON.stringify(bundlePath)})
|
||||
const resultPath = ${JSON.stringify(resultPath)}
|
||||
const mode = ${JSON.stringify(mode)}
|
||||
const expectedPartitionKey = ${JSON.stringify(EXPECTED_PARTITION_KEY)}
|
||||
let currentStep = 'starting'
|
||||
const mark = (step) => {
|
||||
currentStep = step
|
||||
writeFileSync(resultPath, JSON.stringify({ step }))
|
||||
}
|
||||
|
||||
const project = (cookies, name) => cookies
|
||||
.filter((cookie) => cookie.name === name)
|
||||
.map((cookie) => ({ name: cookie.name, value: cookie.value, partitionKey: cookie.partitionKey }))
|
||||
|
||||
async function run() {
|
||||
const timeout = setTimeout(() => {
|
||||
writeFileSync(resultPath, JSON.stringify({ step: 'timed out after ' + currentStep }))
|
||||
app.exit(1)
|
||||
}, 20000)
|
||||
await app.whenReady()
|
||||
mark('ready')
|
||||
const partition = 'persist:replace-partition-rollback-' + mode
|
||||
const targetSession = session.fromPartition(partition)
|
||||
const window = new BrowserWindow({ show: false, webPreferences: { partition } })
|
||||
mark('window created')
|
||||
await window.loadURL('data:text/html,<title>replace partition rollback fixture</title>')
|
||||
mark('window loaded')
|
||||
const debug = window.webContents.debugger
|
||||
debug.attach('1.3')
|
||||
mark('debugger attached')
|
||||
|
||||
// Only CDP can create a partitioned cookie; Electron's cookies API has no partitionKey.
|
||||
await debug.sendCommand('Network.setCookie', {
|
||||
url: 'https://app.acme-chips.test/',
|
||||
name: 'chips-auth',
|
||||
value: 'keep-me',
|
||||
secure: true,
|
||||
sameSite: 'None',
|
||||
partitionKey: expectedPartitionKey
|
||||
})
|
||||
const beforeChips = (await debug.sendCommand('Network.getAllCookies')).cookies
|
||||
.find((cookie) => cookie.name === 'chips-auth')
|
||||
if (!beforeChips || !beforeChips.partitionKey) {
|
||||
throw new Error('CHIPS fixture cookie was not stored partitioned')
|
||||
}
|
||||
mark('partitioned cookie set')
|
||||
|
||||
await targetSession.cookies.set({
|
||||
url: 'https://plain.acme-chips.test/',
|
||||
name: 'plain',
|
||||
value: 'original',
|
||||
secure: true
|
||||
})
|
||||
await targetSession.cookies.set({
|
||||
url: 'https://victim.acme-chips.test/',
|
||||
name: 'victim',
|
||||
value: 'original',
|
||||
secure: true
|
||||
})
|
||||
mark('ordinary cookies set')
|
||||
|
||||
let removeCalls = 0
|
||||
let removeCallsBeforeThrow = 0
|
||||
let snapshotCalls = 0
|
||||
let restoreCalls = 0
|
||||
let importedRemoveCalls = 0
|
||||
let chipsRemoved = false
|
||||
let electronGetSawPartitionKey = false
|
||||
let replaceError = null
|
||||
let chipsAfterReplace = []
|
||||
|
||||
const cookieClearStore = openCookieClearStore(targetSession)
|
||||
const store = {
|
||||
get: async (filter) => {
|
||||
const cookies = await targetSession.cookies.get(filter)
|
||||
// Why: proves the Electron API cannot see the partition it is about to destroy.
|
||||
electronGetSawPartitionKey = cookies.some(
|
||||
(cookie) => cookie.name === 'chips-auth' && cookie.partitionKey !== undefined
|
||||
)
|
||||
// Why: cookies.get order is not a contract; put CHIPS first so it is provably already
|
||||
// removed by the time a later removal rejects.
|
||||
return [...cookies].sort((left, right) => {
|
||||
if (left.name === 'chips-auth') return -1
|
||||
if (right.name === 'chips-auth') return 1
|
||||
return 0
|
||||
})
|
||||
},
|
||||
remove: async (url, name) => {
|
||||
removeCalls++
|
||||
if (mode === 'site-a' && chipsRemoved) {
|
||||
removeCallsBeforeThrow = removeCalls - 1
|
||||
throw new Error('forced removal failure after chips was removed')
|
||||
}
|
||||
await targetSession.cookies.remove(url, name)
|
||||
if (name === 'chips-auth') chipsRemoved = true
|
||||
},
|
||||
snapshotClearIdentities: async (cookies) => {
|
||||
snapshotCalls++
|
||||
return cookieClearStore.snapshotClearIdentities(cookies)
|
||||
},
|
||||
restoreClearIdentities: async (identities) => {
|
||||
restoreCalls++
|
||||
return cookieClearStore.restoreClearIdentities(identities)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (mode === 'site-a') {
|
||||
try {
|
||||
await replaceCookiesForImportedDomains(store, ['acme-chips.test'])
|
||||
} catch (error) {
|
||||
replaceError = String(error && error.message ? error.message : error)
|
||||
}
|
||||
mark('site-a replace finished')
|
||||
} else {
|
||||
const replaced = await replaceCookiesForImportedDomains(store, ['acme-chips.test'])
|
||||
chipsAfterReplace = project((await debug.sendCommand('Network.getAllCookies')).cookies, 'chips-auth')
|
||||
mark('site-b replace finished')
|
||||
// Why: stands in for importValidatedCookies' mid-import cookies.set rejection — the
|
||||
// imported cookie is removed (lossless) and the user's originals go back through CDP.
|
||||
await targetSession.cookies.set({
|
||||
url: 'https://imported.acme-chips.test/',
|
||||
name: 'imported-new',
|
||||
value: 'new',
|
||||
secure: true
|
||||
})
|
||||
await targetSession.cookies.remove('https://imported.acme-chips.test/', 'imported-new')
|
||||
importedRemoveCalls = 1
|
||||
mark('site-b imported cookies rolled back')
|
||||
restoreCalls++
|
||||
await cookieClearStore.restoreClearIdentities(replaced.identities.toReversed())
|
||||
mark('site-b restore finished')
|
||||
}
|
||||
} finally {
|
||||
cookieClearStore.dispose()
|
||||
}
|
||||
|
||||
const afterCookies = (await debug.sendCommand('Network.getAllCookies')).cookies
|
||||
clearTimeout(timeout)
|
||||
writeFileSync(resultPath, JSON.stringify({
|
||||
beforePartitionKey: beforeChips.partitionKey,
|
||||
electronGetSawPartitionKey,
|
||||
removeCalls,
|
||||
removeCallsBeforeThrow,
|
||||
snapshotCalls,
|
||||
restoreCalls,
|
||||
importedRemoveCalls,
|
||||
replaceError,
|
||||
chipsAfterReplace,
|
||||
chipsAfter: project(afterCookies, 'chips-auth'),
|
||||
plainAfter: project(afterCookies, 'plain')
|
||||
}))
|
||||
debug.detach()
|
||||
window.destroy()
|
||||
app.exit(0)
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
writeFileSync(resultPath, JSON.stringify({ step: currentStep, error: String(error && error.stack ? error.stack : error) }))
|
||||
app.exit(1)
|
||||
})
|
||||
`
|
||||
}
|
||||
|
||||
async function runFixture(mode: FixtureMode): Promise<FixtureResult> {
|
||||
const root = mkdtempSync(join(tmpdir(), `orca-replace-partition-rollback-${mode}-`))
|
||||
fixtureRoots.push(root)
|
||||
const bundlePath = join(root, 'cookie-replace-rollback.cjs')
|
||||
const bundleEntryPath = join(root, 'cookie-replace-rollback.ts')
|
||||
const resultPath = join(root, 'result.json')
|
||||
const fixturePath = join(root, 'main.cjs')
|
||||
writeFileSync(
|
||||
bundleEntryPath,
|
||||
[
|
||||
`export { openCookieClearStore } from ${JSON.stringify(join(process.cwd(), 'src/main/browser/browser-cookie-clear-store.ts'))}`,
|
||||
`export { replaceCookiesForImportedDomains } from ${JSON.stringify(join(process.cwd(), 'src/main/browser/browser-cookie-import-policy.ts'))}`
|
||||
].join('\n')
|
||||
)
|
||||
await buildVite({
|
||||
configFile: false,
|
||||
logLevel: 'silent',
|
||||
build: {
|
||||
emptyOutDir: false,
|
||||
lib: {
|
||||
entry: bundleEntryPath,
|
||||
formats: ['cjs'],
|
||||
fileName: () => 'cookie-replace-rollback.cjs'
|
||||
},
|
||||
outDir: root,
|
||||
target: 'node20',
|
||||
rollupOptions: { external: ['electron', /^node:/] }
|
||||
}
|
||||
})
|
||||
writeFileSync(fixturePath, buildFixtureMain(bundlePath, resultPath, mode))
|
||||
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env
|
||||
const electronArgs = [fixturePath, `--user-data-dir=${join(root, 'profile')}`]
|
||||
const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary
|
||||
const args =
|
||||
process.platform === 'linux'
|
||||
? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox']
|
||||
: electronArgs
|
||||
const run = spawnSync(executable, args, {
|
||||
encoding: 'utf8',
|
||||
env,
|
||||
timeout: 60_000
|
||||
})
|
||||
const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result'
|
||||
expect(run.error).toBeUndefined()
|
||||
expect(run.status, `${fixtureResult}\n${run.stdout}\n${run.stderr}`).toBe(0)
|
||||
return JSON.parse(fixtureResult) as FixtureResult
|
||||
}
|
||||
|
||||
describe('partitioned cookies under a failed replace-imported-domains import', () => {
|
||||
// Why (STA-4097): this rollback undoes deletions the import already made to the user's own
|
||||
// cookies. Rebuilding them with cookies.set dropped partitionKey silently, so a CHIPS cookie
|
||||
// came back as an ordinary one and no restart recovered it.
|
||||
it('site A: restores a CHIPS cookie with its partition after a later removal rejects', async () => {
|
||||
const result = await runFixture('site-a')
|
||||
|
||||
expect(result.beforePartitionKey).toEqual(EXPECTED_PARTITION_KEY)
|
||||
// Why: without this the fixture could pass on a cookie that was never partitioned.
|
||||
expect(result.electronGetSawPartitionKey).toBe(false)
|
||||
// Why: assert the rollback was reached, not just that the jar looks right at the end.
|
||||
expect(result.snapshotCalls).toBe(1)
|
||||
expect(result.removeCallsBeforeThrow).toBeGreaterThanOrEqual(1)
|
||||
expect(result.restoreCalls).toBe(1)
|
||||
expect(result.replaceError).toContain('forced removal failure after chips was removed')
|
||||
expect(result.chipsAfter).toEqual([
|
||||
{ name: 'chips-auth', value: 'keep-me', partitionKey: EXPECTED_PARTITION_KEY }
|
||||
])
|
||||
expect(result.plainAfter).toEqual([{ name: 'plain', value: 'original' }])
|
||||
}, 90_000)
|
||||
|
||||
it('site B: restores a CHIPS cookie with its partition after a mid-import set failure', async () => {
|
||||
const result = await runFixture('site-b')
|
||||
|
||||
expect(result.beforePartitionKey).toEqual(EXPECTED_PARTITION_KEY)
|
||||
expect(result.electronGetSawPartitionKey).toBe(false)
|
||||
expect(result.snapshotCalls).toBe(1)
|
||||
// Why: the replace really emptied the scope, so the restore below is doing the work.
|
||||
expect(result.chipsAfterReplace).toEqual([])
|
||||
expect(result.importedRemoveCalls).toBe(1)
|
||||
expect(result.restoreCalls).toBe(1)
|
||||
expect(result.chipsAfter).toEqual([
|
||||
{ name: 'chips-auth', value: 'keep-me', partitionKey: EXPECTED_PARTITION_KEY }
|
||||
])
|
||||
expect(result.plainAfter).toEqual([{ name: 'plain', value: 'original' }])
|
||||
}, 90_000)
|
||||
})
|
||||
@@ -3,15 +3,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const {
|
||||
appGetPathMock,
|
||||
clearPendingCookieImportMock,
|
||||
disposeClearStoreMock,
|
||||
execFileSyncMock,
|
||||
restoreClearIdentitiesMock,
|
||||
sessionFromPartitionMock,
|
||||
setPendingCookieImportMock
|
||||
setPendingCookieImportMock,
|
||||
snapshotClearIdentitiesMock
|
||||
} = vi.hoisted(() => ({
|
||||
appGetPathMock: vi.fn(),
|
||||
clearPendingCookieImportMock: vi.fn(),
|
||||
disposeClearStoreMock: vi.fn(),
|
||||
execFileSyncMock: vi.fn(),
|
||||
restoreClearIdentitiesMock: vi.fn(),
|
||||
sessionFromPartitionMock: vi.fn(),
|
||||
setPendingCookieImportMock: vi.fn()
|
||||
setPendingCookieImportMock: vi.fn(),
|
||||
snapshotClearIdentitiesMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./browser-session-registry', () => ({
|
||||
@@ -26,6 +32,7 @@ vi.mock('electron', () => ({
|
||||
dialog: { showOpenDialog: vi.fn() },
|
||||
session: { fromPartition: sessionFromPartitionMock }
|
||||
}))
|
||||
// Why: snapshot/restore are spies, not no-ops, so a rollback that never ran cannot pass as one.
|
||||
vi.mock('./browser-cookie-clear-store', () => ({
|
||||
openCookieClearStore: (targetSession: {
|
||||
cookies: {
|
||||
@@ -35,10 +42,9 @@ vi.mock('./browser-cookie-clear-store', () => ({
|
||||
}) => ({
|
||||
get: (filter: object) => targetSession.cookies.get(filter),
|
||||
remove: (url: string, name: string) => targetSession.cookies.remove(url, name),
|
||||
snapshotClearIdentities: async (items: { cookie: Record<string, unknown>; url: string }[]) =>
|
||||
items.map(({ cookie, url }) => ({ url, ...cookie })),
|
||||
restoreClearIdentities: async () => undefined,
|
||||
dispose: () => undefined
|
||||
snapshotClearIdentities: snapshotClearIdentitiesMock,
|
||||
restoreClearIdentities: restoreClearIdentitiesMock,
|
||||
dispose: disposeClearStoreMock
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -48,6 +54,16 @@ import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
beforeEach(() => {
|
||||
snapshotClearIdentitiesMock
|
||||
.mockReset()
|
||||
.mockImplementation(async (items: { cookie: Record<string, unknown>; url: string }[]) =>
|
||||
items.map(({ cookie: entry, url }) => ({ url, ...entry }))
|
||||
)
|
||||
restoreClearIdentitiesMock.mockReset().mockResolvedValue(undefined)
|
||||
disposeClearStoreMock.mockReset()
|
||||
})
|
||||
|
||||
describe('validated cookie replacement', () => {
|
||||
let cookiesGetMock: ReturnType<typeof vi.fn>
|
||||
let cookiesRemoveMock: ReturnType<typeof vi.fn>
|
||||
@@ -139,8 +155,17 @@ describe('validated cookie replacement', () => {
|
||||
expect(cookiesSetMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores the previous snapshot when an incoming cookie is rejected', async () => {
|
||||
// Why (STA-4097): this rollback puts back the user's ORIGINAL cookies, which the import had
|
||||
// already deleted. cookies.get drops partitionKey and cookies.set ignores it, so rebuilding
|
||||
// them through the Electron API resurrected CHIPS cookies unpartitioned. The undo now travels
|
||||
// back through the CDP identities, which are the only thing that carries the partition.
|
||||
it('restores the previous cookies through CDP identities when an incoming cookie is rejected', async () => {
|
||||
const partitionKey = { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
|
||||
cookiesGetMock.mockResolvedValue([cookie('.example.com', 'existing')])
|
||||
snapshotClearIdentitiesMock.mockImplementation(
|
||||
async (items: { cookie: Record<string, unknown>; url: string }[]) =>
|
||||
items.map(({ cookie: entry, url }) => ({ url, ...entry, partitionKey }))
|
||||
)
|
||||
cookiesSetMock
|
||||
.mockResolvedValue(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
@@ -157,16 +182,35 @@ describe('validated cookie replacement', () => {
|
||||
['https://example.com/', 'existing'],
|
||||
['https://example.com/', 'first']
|
||||
])
|
||||
expect(cookiesSetMock).toHaveBeenLastCalledWith({
|
||||
url: 'https://example.com/',
|
||||
name: 'existing',
|
||||
value: 'old',
|
||||
domain: '.example.com',
|
||||
path: '/',
|
||||
secure: true,
|
||||
httpOnly: undefined,
|
||||
sameSite: 'unspecified'
|
||||
})
|
||||
expect(restoreClearIdentitiesMock).toHaveBeenCalledOnce()
|
||||
expect(restoreClearIdentitiesMock.mock.calls[0]?.[0]).toEqual([
|
||||
{ url: 'https://example.com/', ...cookie('.example.com', 'existing'), partitionKey }
|
||||
])
|
||||
// Why: cookies.set is only ever the two imported cookies — a third call would mean the
|
||||
// partition-dropping reconstruction came back.
|
||||
expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['first', 'second'])
|
||||
expect(disposeClearStoreMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// Why: restoreClearIdentities attaches a debugger before it iterates, so calling it with an
|
||||
// empty restore set would create a hidden BrowserWindow to put nothing back.
|
||||
it('does not reach for CDP when the rollback has nothing to restore', async () => {
|
||||
cookiesGetMock.mockResolvedValue([])
|
||||
cookiesSetMock
|
||||
.mockResolvedValue(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('cookie rejected'))
|
||||
const filePath = writeCookies([
|
||||
{ domain: '.example.com', name: 'first', value: 'new', secure: true },
|
||||
{ domain: '.example.com', name: 'second', value: 'new', secure: true }
|
||||
])
|
||||
|
||||
const result = await importCookiesFromFile(filePath, 'persist:test')
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
// Why: the imported cookie is still removed — that half of the rollback is lossless.
|
||||
expect(cookiesRemoveMock.mock.calls).toEqual([['https://example.com/', 'first']])
|
||||
expect(restoreClearIdentitiesMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed when existing cookies cannot be replaced', async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable max-lines -- Why: cookie import is one pipeline (detect → decrypt → stage → swap) that must stay together to keep encryption/schema/staging in sync. */
|
||||
import { app, type BrowserWindow, type Cookie, dialog, session } from 'electron'
|
||||
import { app, type BrowserWindow, dialog, session } from 'electron'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { createDecipheriv, pbkdf2Sync, randomUUID } from 'node:crypto'
|
||||
import {
|
||||
@@ -81,8 +81,8 @@ import {
|
||||
normalizeCookieDomain,
|
||||
normalizeCookieImportDomain,
|
||||
replaceCookiesForImportedDomains,
|
||||
restoreImportedDomainCookies,
|
||||
type CookieImportMode
|
||||
type CookieImportMode,
|
||||
type ReplacedImportedDomainCookies
|
||||
} from './browser-cookie-import-policy'
|
||||
import { removeTransplantableCookies, withCookieClearLock } from './browser-cookie-import-clear'
|
||||
import { openCookieClearStore } from './browser-cookie-clear-store'
|
||||
@@ -594,97 +594,112 @@ async function importValidatedCookies(
|
||||
let importedCount = 0
|
||||
let skipped = totalInput - importableCookies.length
|
||||
const domainSet = new Set<string>()
|
||||
let replacedCookies: Cookie[] | null = null
|
||||
let replaced: ReplacedImportedDomainCookies | null = null
|
||||
// Why (STA-4097): the rollback below has to put back cookies this import already deleted, and
|
||||
// only CDP identities carry partitionKey — rebuilding them with cookies.set drops it silently.
|
||||
const cookieClearStore =
|
||||
mode === 'replace-imported-domains' && importableCookies.length > 0
|
||||
? openCookieClearStore(targetSession)
|
||||
: null
|
||||
|
||||
if (mode === 'replace-imported-domains' && importableCookies.length > 0) {
|
||||
try {
|
||||
replacedCookies = await replaceCookiesForImportedDomains(
|
||||
targetSession.cookies,
|
||||
importableCookies.map((cookie) => cookie.domain)
|
||||
)
|
||||
diag(` removed ${replacedCookies.length} existing cookies in imported domain scopes`)
|
||||
} catch (err) {
|
||||
diag(` existing cookie replacement failed: ${summarizeCookieImportError(err)}`)
|
||||
try {
|
||||
if (cookieClearStore) {
|
||||
try {
|
||||
replaced = await replaceCookiesForImportedDomains(
|
||||
cookieClearStore,
|
||||
importableCookies.map((cookie) => cookie.domain)
|
||||
)
|
||||
diag(` removed ${replaced.removed.length} existing cookies in imported domain scopes`)
|
||||
} catch (err) {
|
||||
diag(` existing cookie replacement failed: ${summarizeCookieImportError(err)}`)
|
||||
return {
|
||||
ok: false,
|
||||
reason: reasonWithDiagLog('Could not replace existing cookies for the imported sites.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: Electron's cookies.set() rejects any non-printable-ASCII byte; strip as a safety net.
|
||||
const stripNonPrintable = (s: string): string => s.replace(/[^\x20-\x7E]/g, '')
|
||||
const importedCookieKeys: { url: string; name: string }[] = []
|
||||
let setFailure: unknown = null
|
||||
|
||||
for (const cookie of importableCookies) {
|
||||
try {
|
||||
// Why: Chromium rejects __Host- cookies unless they omit domain and use path=/.
|
||||
const isHostPrefixed = cookie.name.startsWith('__Host-')
|
||||
const path = isHostPrefixed ? '/' : cookie.path
|
||||
await targetSession.cookies.set({
|
||||
url: cookie.url,
|
||||
name: cookie.name,
|
||||
value: stripNonPrintable(cookie.value),
|
||||
...(isHostPrefixed ? {} : { domain: cookie.domain }),
|
||||
path,
|
||||
secure: cookie.secure,
|
||||
httpOnly: cookie.httpOnly,
|
||||
sameSite: cookie.sameSite,
|
||||
expirationDate: cookie.expirationDate
|
||||
})
|
||||
const removalUrl = new URL(cookie.url)
|
||||
removalUrl.pathname = path.startsWith('/') ? path : '/'
|
||||
importedCookieKeys.push({ url: removalUrl.toString(), name: cookie.name })
|
||||
importedCount++
|
||||
// Why: surface only the domain (never name/value/path) so the summary doesn't leak secret cookie data.
|
||||
const cleanDomain = cookie.domain.startsWith('.') ? cookie.domain.slice(1) : cookie.domain
|
||||
domainSet.add(cleanDomain)
|
||||
} catch (err) {
|
||||
skipped++
|
||||
setFailure = err
|
||||
if (skipped <= 5) {
|
||||
// Find the exact offending character position and code
|
||||
const val = cookie.value
|
||||
let badInfo = 'none found'
|
||||
for (let i = 0; i < val.length; i++) {
|
||||
const code = val.charCodeAt(i)
|
||||
if (code < 0x20 || code > 0x7e) {
|
||||
badInfo = `pos=${i} char=U+${code.toString(16).padStart(4, '0')}`
|
||||
break
|
||||
}
|
||||
}
|
||||
diag(
|
||||
` cookie.set FAILED: domain=${cookie.domain} name=${cookie.name} valLen=${val.length} badChar=${badInfo} err=${String(err)}`
|
||||
)
|
||||
}
|
||||
if (replaced) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: replaced is only ever set alongside cookieClearStore, which owns the CDP restore.
|
||||
if (setFailure && replaced && cookieClearStore) {
|
||||
const rollbackFailures: unknown[] = []
|
||||
for (const cookie of importedCookieKeys.toReversed()) {
|
||||
try {
|
||||
await targetSession.cookies.remove(cookie.url, cookie.name)
|
||||
} catch (err) {
|
||||
rollbackFailures.push(err)
|
||||
}
|
||||
}
|
||||
// Why: restoreClearIdentities attaches the debugger before it iterates, so an empty
|
||||
// restore set would spin up a hidden BrowserWindow to put nothing back.
|
||||
if (replaced.identities.length > 0) {
|
||||
try {
|
||||
await cookieClearStore.restoreClearIdentities(replaced.identities.toReversed())
|
||||
} catch (err) {
|
||||
rollbackFailures.push(err)
|
||||
}
|
||||
}
|
||||
if (rollbackFailures.length > 0) {
|
||||
diag(` cookie replacement rollback failed: ${rollbackFailures.length} operation(s)`)
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
reason: reasonWithDiagLog('Could not replace existing cookies for the imported sites.')
|
||||
reason: reasonWithDiagLog('Could not safely replace cookies for the imported sites.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: Electron's cookies.set() rejects any non-printable-ASCII byte; strip as a safety net.
|
||||
const stripNonPrintable = (s: string): string => s.replace(/[^\x20-\x7E]/g, '')
|
||||
const importedCookieKeys: { url: string; name: string }[] = []
|
||||
let setFailure: unknown = null
|
||||
|
||||
for (const cookie of importableCookies) {
|
||||
try {
|
||||
// Why: Chromium rejects __Host- cookies unless they omit domain and use path=/.
|
||||
const isHostPrefixed = cookie.name.startsWith('__Host-')
|
||||
const path = isHostPrefixed ? '/' : cookie.path
|
||||
await targetSession.cookies.set({
|
||||
url: cookie.url,
|
||||
name: cookie.name,
|
||||
value: stripNonPrintable(cookie.value),
|
||||
...(isHostPrefixed ? {} : { domain: cookie.domain }),
|
||||
path,
|
||||
secure: cookie.secure,
|
||||
httpOnly: cookie.httpOnly,
|
||||
sameSite: cookie.sameSite,
|
||||
expirationDate: cookie.expirationDate
|
||||
})
|
||||
const removalUrl = new URL(cookie.url)
|
||||
removalUrl.pathname = path.startsWith('/') ? path : '/'
|
||||
importedCookieKeys.push({ url: removalUrl.toString(), name: cookie.name })
|
||||
importedCount++
|
||||
// Why: surface only the domain (never name/value/path) so the summary doesn't leak secret cookie data.
|
||||
const cleanDomain = cookie.domain.startsWith('.') ? cookie.domain.slice(1) : cookie.domain
|
||||
domainSet.add(cleanDomain)
|
||||
} catch (err) {
|
||||
skipped++
|
||||
setFailure = err
|
||||
if (skipped <= 5) {
|
||||
// Find the exact offending character position and code
|
||||
const val = cookie.value
|
||||
let badInfo = 'none found'
|
||||
for (let i = 0; i < val.length; i++) {
|
||||
const code = val.charCodeAt(i)
|
||||
if (code < 0x20 || code > 0x7e) {
|
||||
badInfo = `pos=${i} char=U+${code.toString(16).padStart(4, '0')}`
|
||||
break
|
||||
}
|
||||
}
|
||||
diag(
|
||||
` cookie.set FAILED: domain=${cookie.domain} name=${cookie.name} valLen=${val.length} badChar=${badInfo} err=${String(err)}`
|
||||
)
|
||||
}
|
||||
if (replacedCookies) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (setFailure && replacedCookies) {
|
||||
const rollbackFailures: unknown[] = []
|
||||
for (const cookie of importedCookieKeys.toReversed()) {
|
||||
try {
|
||||
await targetSession.cookies.remove(cookie.url, cookie.name)
|
||||
} catch (err) {
|
||||
rollbackFailures.push(err)
|
||||
}
|
||||
}
|
||||
try {
|
||||
await restoreImportedDomainCookies(targetSession.cookies, replacedCookies)
|
||||
} catch (err) {
|
||||
rollbackFailures.push(err)
|
||||
}
|
||||
if (rollbackFailures.length > 0) {
|
||||
diag(` cookie replacement rollback failed: ${rollbackFailures.length} operation(s)`)
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
reason: reasonWithDiagLog('Could not safely replace cookies for the imported sites.')
|
||||
}
|
||||
} finally {
|
||||
cookieClearStore?.dispose()
|
||||
}
|
||||
|
||||
diag(
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { join } from 'node:path'
|
||||
|
||||
// Why `orca-ide` on Linux: GNOME Orca ships /usr/bin/orca, so the CLI never claims that name.
|
||||
export const LINUX_CLI_COMMAND_NAME = 'orca-ide'
|
||||
|
||||
/** Absolute path of the CLI launcher this app ships in its own resources bundle.
|
||||
* Lives apart from cli-installer so callers that only need the path (PTY env
|
||||
* assembly) don't pull in the installer's `electron` dependency. */
|
||||
export function getBundledLauncherPath(
|
||||
platform: NodeJS.Platform,
|
||||
resourcesPath: string
|
||||
): string | null {
|
||||
if (platform === 'darwin') {
|
||||
return join(resourcesPath, 'bin', 'orca')
|
||||
}
|
||||
if (platform === 'linux') {
|
||||
return join(resourcesPath, 'bin', LINUX_CLI_COMMAND_NAME)
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
return join(resourcesPath, 'bin', 'orca.exe')
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { promisify } from 'node:util'
|
||||
import type { CliInstallMethod, CliInstallStatus } from '../../shared/cli-install-types'
|
||||
import { expandWindowsEnvironmentVariables } from '../../shared/windows-environment-expansion'
|
||||
import { buildAppImageCliWrapper } from './appimage-cli-wrapper'
|
||||
import { getBundledLauncherPath, LINUX_CLI_COMMAND_NAME } from './bundled-cli-launcher-path'
|
||||
import {
|
||||
invalidateWindowsUserPathRegistryCache,
|
||||
readFreshWindowsUserPathRegistry,
|
||||
@@ -29,7 +30,6 @@ import {
|
||||
const execFileAsync = promisify(execFile)
|
||||
const DEFAULT_MAC_COMMAND_PATH = '/usr/local/bin/orca'
|
||||
const DEV_COMMAND_NAME = 'orca-dev'
|
||||
const LINUX_COMMAND_NAME = 'orca-ide'
|
||||
const LEGACY_LINUX_COMMAND_NAME = 'orca'
|
||||
const DEV_LAUNCHER_DIR = ['cli', 'bin']
|
||||
const WINDOWS_PATH_WRITE_TIMEOUT_MS = 5_000
|
||||
@@ -88,7 +88,7 @@ export class CliInstaller {
|
||||
return DEV_COMMAND_NAME
|
||||
}
|
||||
// Why: packaged Linux uses `orca-ide` to avoid shadowing GNOME Orca's /usr/bin/orca.
|
||||
return this.platform === 'linux' ? LINUX_COMMAND_NAME : 'orca'
|
||||
return this.platform === 'linux' ? LINUX_CLI_COMMAND_NAME : 'orca'
|
||||
}
|
||||
|
||||
constructor(options: CliInstallerOptions = {}) {
|
||||
@@ -351,7 +351,7 @@ export class CliInstaller {
|
||||
if (this.platform === 'linux') {
|
||||
// Why: Linux lacks a privileged global command flow; ~/.local/bin is the least-surprising user-scoped dir.
|
||||
// Why `orca-ide`: GNOME Orca ships /usr/bin/orca, so avoid shadowing that screen reader.
|
||||
return join(this.homePath, '.local', 'bin', LINUX_COMMAND_NAME)
|
||||
return join(this.homePath, '.local', 'bin', LINUX_CLI_COMMAND_NAME)
|
||||
}
|
||||
|
||||
if (this.platform === 'win32') {
|
||||
@@ -1200,18 +1200,4 @@ function quotePowerShell(value: string): string {
|
||||
return `'${value.replaceAll("'", "''")}'`
|
||||
}
|
||||
|
||||
export function getBundledLauncherPath(
|
||||
platform: NodeJS.Platform,
|
||||
resourcesPath: string
|
||||
): string | null {
|
||||
if (platform === 'darwin') {
|
||||
return join(resourcesPath, 'bin', 'orca')
|
||||
}
|
||||
if (platform === 'linux') {
|
||||
return join(resourcesPath, 'bin', LINUX_COMMAND_NAME)
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
return join(resourcesPath, 'bin', 'orca.exe')
|
||||
}
|
||||
return null
|
||||
}
|
||||
export { getBundledLauncherPath }
|
||||
|
||||
@@ -4,12 +4,18 @@ import {
|
||||
CheckpointSessionQueue
|
||||
} from './daemon-checkpoint-session-queue'
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
reject: (error: unknown) => void
|
||||
} {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((res) => {
|
||||
let reject!: (error: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve }
|
||||
return { promise, reject, resolve }
|
||||
}
|
||||
|
||||
describe('CheckpointSessionQueue', () => {
|
||||
@@ -57,6 +63,7 @@ describe('CheckpointSessionQueue', () => {
|
||||
const queue = new CheckpointSessionQueue()
|
||||
const stalled = deferred<void>()
|
||||
let completed = false
|
||||
const onDeadlineFired = vi.fn()
|
||||
|
||||
const outcome = await queue.runWithDeadline(
|
||||
'stalled',
|
||||
@@ -66,10 +73,12 @@ describe('CheckpointSessionQueue', () => {
|
||||
return 'durable'
|
||||
},
|
||||
5,
|
||||
'live'
|
||||
'live',
|
||||
{ onDeadline: onDeadlineFired }
|
||||
)
|
||||
|
||||
expect(outcome).toBe('live')
|
||||
expect(onDeadlineFired).toHaveBeenCalledOnce()
|
||||
// Why: abandoning the wait must not abandon the write, or a reattach deadline
|
||||
// would drop durable history instead of merely delaying it.
|
||||
expect(completed).toBe(false)
|
||||
@@ -77,6 +86,47 @@ describe('CheckpointSessionQueue', () => {
|
||||
await vi.waitFor(() => expect(completed).toBe(true))
|
||||
})
|
||||
|
||||
it('propagates operation failures instead of reporting a deadline', async () => {
|
||||
const queue = new CheckpointSessionQueue()
|
||||
const onDeadlineFired = vi.fn()
|
||||
|
||||
await expect(
|
||||
queue.runWithDeadline(
|
||||
'failed',
|
||||
async () => {
|
||||
throw new Error('daemon unavailable')
|
||||
},
|
||||
50,
|
||||
'timed-out',
|
||||
{ onDeadline: onDeadlineFired }
|
||||
)
|
||||
).rejects.toThrow('daemon unavailable')
|
||||
expect(onDeadlineFired).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports an operation rejection that arrives after the deadline', async () => {
|
||||
const queue = new CheckpointSessionQueue()
|
||||
const stalled = deferred<void>()
|
||||
const onAbandonedRejection = vi.fn()
|
||||
|
||||
await expect(
|
||||
queue.runWithDeadline<void | 'timed-out'>(
|
||||
'failed-late',
|
||||
async () => await stalled.promise,
|
||||
5,
|
||||
'timed-out',
|
||||
{ onAbandonedRejection }
|
||||
)
|
||||
).resolves.toBe('timed-out')
|
||||
|
||||
stalled.reject(new Error('late failure'))
|
||||
await vi.waitFor(() =>
|
||||
expect(onAbandonedRejection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'late failure' })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a later waiter behind an abandoned operation', async () => {
|
||||
const queue = new CheckpointSessionQueue()
|
||||
const stalled = deferred<void>()
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
/** Running plus queued operations one session may hold before callers are turned away. */
|
||||
export const CHECKPOINT_SESSION_QUEUE_MAX_PENDING = 3
|
||||
|
||||
type DeadlineCallbacks = {
|
||||
onDeadline?: () => void
|
||||
onAbandonedRejection?: (error: unknown) => void
|
||||
}
|
||||
|
||||
export class CheckpointSessionQueue {
|
||||
private tails = new Map<string, Promise<unknown>>()
|
||||
private pending = new Map<string, number>()
|
||||
@@ -36,20 +41,33 @@ export class CheckpointSessionQueue {
|
||||
sessionId: string,
|
||||
operation: () => Promise<T>,
|
||||
deadlineMs: number,
|
||||
onDeadline: T
|
||||
onDeadline: T,
|
||||
callbacks: DeadlineCallbacks = {}
|
||||
): Promise<T> {
|
||||
const work = this.enqueue(sessionId, operation)
|
||||
return new Promise<T>((resolve) => {
|
||||
const timer = setTimeout(() => resolve(onDeadline), deadlineMs)
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let deadlineFired = false
|
||||
const timer = setTimeout(() => {
|
||||
deadlineFired = true
|
||||
try {
|
||||
callbacks.onDeadline?.()
|
||||
} finally {
|
||||
resolve(onDeadline)
|
||||
}
|
||||
}, deadlineMs)
|
||||
timer.unref?.()
|
||||
work.then(
|
||||
(value) => {
|
||||
clearTimeout(timer)
|
||||
resolve(value)
|
||||
},
|
||||
() => {
|
||||
(error: unknown) => {
|
||||
clearTimeout(timer)
|
||||
resolve(onDeadline)
|
||||
if (deadlineFired) {
|
||||
callbacks.onAbandonedRejection?.(error)
|
||||
return
|
||||
}
|
||||
reject(error)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2525,6 +2525,180 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
expect(checkpoint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps abandoned periodic checkpoints within the global work cap', async () => {
|
||||
const adapterClass = DaemonPtyAdapter as unknown as {
|
||||
PERIODIC_CHECKPOINT_DEADLINE_MS: number
|
||||
}
|
||||
const previousDeadline = adapterClass.PERIODIC_CHECKPOINT_DEADLINE_MS
|
||||
adapterClass.PERIODIC_CHECKPOINT_DEADLINE_MS = 5
|
||||
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
|
||||
const releases: (() => void)[] = []
|
||||
const requestedSessionIds: string[] = []
|
||||
const request = vi.fn(async (_type: string, payload: { sessionId: string }) => {
|
||||
requestedSessionIds.push(payload.sessionId)
|
||||
await new Promise<void>((resolve) => releases.push(resolve))
|
||||
return {
|
||||
records: [{ kind: 'output', data: payload.sessionId }],
|
||||
seq: 1,
|
||||
overflowed: false,
|
||||
snapshot: null
|
||||
}
|
||||
})
|
||||
const appendIncrements = vi.fn(async () => 'ok' as const)
|
||||
const internals = historyAdapter as unknown as {
|
||||
client: { request: typeof request; disconnect: ReturnType<typeof vi.fn> }
|
||||
historyManager: {
|
||||
checkpoint: ReturnType<typeof vi.fn>
|
||||
appendIncrements: typeof appendIncrements
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
}
|
||||
checkpointSessions(sessionIds: Iterable<string>): Promise<Set<string>>
|
||||
nonFinalCheckpointAdmissionSessionIds: Set<string>
|
||||
tryAdmitNonFinalCheckpoint(sessionId: string): boolean
|
||||
}
|
||||
internals.client = { request, disconnect: vi.fn() }
|
||||
internals.historyManager = {
|
||||
checkpoint: vi.fn(async () => 'committed' as const),
|
||||
appendIncrements,
|
||||
dispose: vi.fn(async () => {})
|
||||
}
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const tryAdmit = vi.spyOn(internals, 'tryAdmitNonFinalCheckpoint')
|
||||
|
||||
try {
|
||||
const checkpointing = internals.checkpointSessions(['a', 'b', 'c', 'd', 'e', 'f'])
|
||||
await waitFor(() => requestedSessionIds.length === 4)
|
||||
await expect(checkpointing).resolves.toEqual(new Set())
|
||||
|
||||
expect(requestedSessionIds).toEqual(['a', 'b', 'c', 'd'])
|
||||
expect(tryAdmit).toHaveBeenCalledTimes(4)
|
||||
expect(internals.nonFinalCheckpointAdmissionSessionIds).toEqual(
|
||||
new Set(['a', 'b', 'c', 'd'])
|
||||
)
|
||||
expect(warn).toHaveBeenCalledWith('[history] periodic checkpoint deadline exceeded:', 'a')
|
||||
|
||||
for (const release of releases) {
|
||||
release()
|
||||
}
|
||||
await waitFor(() => internals.nonFinalCheckpointAdmissionSessionIds.size === 0)
|
||||
} finally {
|
||||
adapterClass.PERIODIC_CHECKPOINT_DEADLINE_MS = previousDeadline
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('lets one session hold only one global non-final admission', () => {
|
||||
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
|
||||
const internals = historyAdapter as unknown as {
|
||||
tryAdmitNonFinalCheckpoint(sessionId: string): boolean
|
||||
releaseNonFinalCheckpointAdmission(sessionId: string): void
|
||||
nonFinalCheckpointAdmissionSessionIds: Set<string>
|
||||
nonFinalAdmissionDeniedSessionIds: Set<string>
|
||||
}
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
expect(internals.tryAdmitNonFinalCheckpoint('stalled')).toBe(true)
|
||||
expect(internals.tryAdmitNonFinalCheckpoint('stalled')).toBe(false)
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[history] non-final checkpoint already in flight:',
|
||||
'stalled'
|
||||
)
|
||||
expect(internals.tryAdmitNonFinalCheckpoint('healthy-a')).toBe(true)
|
||||
expect(internals.tryAdmitNonFinalCheckpoint('healthy-b')).toBe(true)
|
||||
expect(internals.tryAdmitNonFinalCheckpoint('healthy-c')).toBe(true)
|
||||
expect(internals.tryAdmitNonFinalCheckpoint('overflow')).toBe(false)
|
||||
expect(internals.tryAdmitNonFinalCheckpoint('another-overflow')).toBe(false)
|
||||
|
||||
expect(internals.nonFinalCheckpointAdmissionSessionIds).toEqual(
|
||||
new Set(['stalled', 'healthy-a', 'healthy-b', 'healthy-c'])
|
||||
)
|
||||
expect(internals.nonFinalAdmissionDeniedSessionIds).toEqual(new Set(['stalled']))
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[history] non-final checkpoint global admission limit reached:',
|
||||
'overflow'
|
||||
)
|
||||
expect(
|
||||
warn.mock.calls.filter(
|
||||
([message]) =>
|
||||
message === '[history] non-final checkpoint global admission limit reached:'
|
||||
)
|
||||
).toHaveLength(1)
|
||||
|
||||
internals.releaseNonFinalCheckpointAdmission('stalled')
|
||||
expect(internals.nonFinalAdmissionDeniedSessionIds).toEqual(new Set())
|
||||
expect(internals.tryAdmitNonFinalCheckpoint('overflow')).toBe(true)
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('logs non-final checkpoint RPC failures', async () => {
|
||||
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
|
||||
const request = vi.fn(async () => {
|
||||
throw new Error('daemon socket unavailable')
|
||||
})
|
||||
const internals = historyAdapter as unknown as {
|
||||
client: { request: typeof request; disconnect: ReturnType<typeof vi.fn> }
|
||||
checkpointSessions(sessionIds: Iterable<string>): Promise<Set<string>>
|
||||
}
|
||||
internals.client = { request, disconnect: vi.fn() }
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
await expect(internals.checkpointSessions(['broken'])).resolves.toEqual(new Set())
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[history] checkpoint failed:',
|
||||
'broken',
|
||||
expect.objectContaining({ message: 'daemon socket unavailable' })
|
||||
)
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('logs a periodic checkpoint failure that arrives after its deadline', async () => {
|
||||
const adapterClass = DaemonPtyAdapter as unknown as {
|
||||
PERIODIC_CHECKPOINT_DEADLINE_MS: number
|
||||
}
|
||||
const previousDeadline = adapterClass.PERIODIC_CHECKPOINT_DEADLINE_MS
|
||||
adapterClass.PERIODIC_CHECKPOINT_DEADLINE_MS = 5
|
||||
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
|
||||
let rejectRequest!: (error: unknown) => void
|
||||
const request = vi.fn(
|
||||
async () =>
|
||||
await new Promise<never>((_resolve, reject) => {
|
||||
rejectRequest = reject
|
||||
})
|
||||
)
|
||||
const internals = historyAdapter as unknown as {
|
||||
client: { request: typeof request; disconnect: ReturnType<typeof vi.fn> }
|
||||
checkpointSessions(sessionIds: Iterable<string>): Promise<Set<string>>
|
||||
}
|
||||
internals.client = { request, disconnect: vi.fn() }
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
const checkpointing = internals.checkpointSessions(['late-failure'])
|
||||
await waitFor(() => request.mock.calls.length === 1)
|
||||
await expect(checkpointing).resolves.toEqual(new Set())
|
||||
|
||||
rejectRequest(new Error('late daemon failure'))
|
||||
await waitFor(() =>
|
||||
warn.mock.calls.some(
|
||||
([message, sessionId, error]) =>
|
||||
message === '[history] checkpoint failed:' &&
|
||||
sessionId === 'late-failure' &&
|
||||
error instanceof Error &&
|
||||
error.message === 'late daemon failure'
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
adapterClass.PERIODIC_CHECKPOINT_DEADLINE_MS = previousDeadline
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
describe('full-snapshot cooldown', () => {
|
||||
type CooldownInternals = {
|
||||
client: { request: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn> }
|
||||
|
||||
@@ -162,9 +162,6 @@ const MAX_CONCURRENT_CHECKPOINTS = 4
|
||||
// DAEMON_RESTORE_SCROLLBACK_ROWS through the headless emulator in well under a second, so this
|
||||
// only fires when the write path is genuinely wedged (STA-4173).
|
||||
const DURABLE_HISTORY_OVERLAY_DEADLINE_MS = 5_000
|
||||
// Why the background pass gets a longer one: blowing it only defers a still-dirty session to the
|
||||
// next tick, so the budget should sit above any slow-but-working disk rather than churn it.
|
||||
const PERIODIC_CHECKPOINT_DEADLINE_MS = 15_000
|
||||
|
||||
// Why far below the client's 30s default: a wedged daemon holds its socket open, so an unbounded
|
||||
// probe stalls a pane mount for the full request timeout — and the owner fan-out waits on every
|
||||
@@ -179,6 +176,40 @@ function remainingRequestTimeoutMs(deadlineMs: number | undefined): number | und
|
||||
return deadlineMs === undefined ? undefined : Math.max(1, deadlineMs - Date.now())
|
||||
}
|
||||
|
||||
// Why a distinct error: teardown callers must read this as "not proven stopped" and leave the PTY
|
||||
// alive, not as a kill that failed. It never matches isPtyAlreadyGoneError, so `stopAndWait` reports
|
||||
// the pty unverified and worktree sleep declines to commit it.
|
||||
export class FinalCheckpointWaitExpiredError extends Error {
|
||||
constructor(sessionId: string) {
|
||||
super(`Final history checkpoint did not settle within the teardown deadline: ${sessionId}`)
|
||||
this.name = 'FinalCheckpointWaitExpiredError'
|
||||
}
|
||||
}
|
||||
|
||||
// Why only the caller's wait is bounded and never the work itself: cancelling durable work would
|
||||
// silently drop what the user left on screen. This decides how long a caller blocks, nothing more —
|
||||
// `work` keeps running behind an abandoned wait and still commits. False means the caller gave up.
|
||||
// A rejection before the deadline still propagates, so a genuinely failed operation is not masked.
|
||||
async function awaitWithinCallerDeadline(
|
||||
work: Promise<void>,
|
||||
deadlineMs: number
|
||||
): Promise<boolean> {
|
||||
// Why up front: once the race is abandoned nothing observes a later rejection here.
|
||||
void work.catch(() => {})
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
return await Promise.race([
|
||||
work.then(() => true),
|
||||
new Promise<boolean>((resolve) => {
|
||||
timer = setTimeout(() => resolve(false), Math.max(1, deadlineMs - Date.now()))
|
||||
timer.unref?.()
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
export class TerminalKilledError extends Error {
|
||||
constructor(sessionId: string) {
|
||||
super(`Session "${sessionId}" was explicitly killed`)
|
||||
@@ -258,6 +289,11 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
// Why per session: exclusivity protects one session directory's tmp-write/rename, so ordering
|
||||
// every session behind one tail let a single stalled checkpoint block all reattaches (STA-4173).
|
||||
private checkpointQueue = new CheckpointSessionQueue()
|
||||
private nonFinalCheckpointAdmissionSessionIds = new Set<string>()
|
||||
private nonFinalAdmissionDeniedSessionIds = new Set<string>()
|
||||
private nonFinalGlobalAdmissionWarningActive = false
|
||||
private overlayDeadlineWarnedSessionIds = new Set<string>()
|
||||
private periodicDeadlineWarnedSessionIds = new Set<string>()
|
||||
private keepHistoryShutdowns = new Set<Promise<void>>()
|
||||
private disconnectOnlyPromise: Promise<void> | null = null
|
||||
// Why: checkpoint persistence needs the getSnapshot RPC (v4+); legacy daemons reject it, spamming logs every 5s.
|
||||
@@ -274,6 +310,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
// Why: a daemon surviving a socket drop can hold a pause whose resume died with the connection; owe a resume on reconnect (daemon's 5s failsafe covers the gap).
|
||||
private producerResumesOwedOnReconnect = new Set<string>()
|
||||
private static CHECKPOINT_INTERVAL_MS = 5_000
|
||||
// Why the background pass gets a longer deadline: deferral keeps the session dirty, so this
|
||||
// should sit above slow-but-working disk rather than churn it.
|
||||
private static PERIODIC_CHECKPOINT_DEADLINE_MS = 15_000
|
||||
// Why: streaming sessions re-trigger full multi-MB checkpoints every tick; this cooldown caps cap/overflow snapshots per session (~9x less writes, bounded cold-crash staleness).
|
||||
private static FULL_CHECKPOINT_COOLDOWN_MS = 45_000
|
||||
private lastFullCheckpointAt = new Map<string, number>()
|
||||
@@ -1116,16 +1155,27 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number }
|
||||
): Promise<void> {
|
||||
// Why: shutdown can be the first lazy-client operation after restart; connect
|
||||
// before killing so a healthy daemon session is not orphaned (#7742). Connect
|
||||
// and kill share the caller's one absolute deadline, so a wedged handshake
|
||||
// cannot burn the whole teardown budget before the kill even starts.
|
||||
// before killing so a healthy daemon session is not orphaned (#7742). Connect,
|
||||
// the final-checkpoint wait, and kill all share the caller's one absolute
|
||||
// deadline, so neither a wedged handshake nor a stalled history write can burn
|
||||
// the whole teardown budget before the kill even starts. Only the waits are
|
||||
// bounded — the checkpoint itself stays deadline-free and lossless (STA-4228).
|
||||
await this.ensureConnected(opts.deadlineMs)
|
||||
// Why: sleep/exact-stop kills the live PTY before the periodic checkpoint may run.
|
||||
// Force a final snapshot so wake can restore the pane users left.
|
||||
if (opts.keepHistory) {
|
||||
await this.runExclusiveCheckpoint(async () => {
|
||||
await this.checkpointSessions([id], { final: true, teardown: true })
|
||||
})
|
||||
const committed = await this.runExclusiveCheckpoint(
|
||||
async () => {
|
||||
await this.checkpointSessions([id], { final: true, teardown: true })
|
||||
},
|
||||
{ callerDeadlineMs: opts.deadlineMs }
|
||||
)
|
||||
// Why throw instead of killing anyway: the snapshot the caller asked us to prove is still
|
||||
// being written. Killing here would race the wake-time restore source to disk, so report the
|
||||
// pty unverified and leave it alive — worktree sleep declines to commit it and retries.
|
||||
if (!committed) {
|
||||
throw new FinalCheckpointWaitExpiredError(id)
|
||||
}
|
||||
const wslDistro = this.wslDistrosBySessionId.get(id)
|
||||
const detection = await this.historyReader?.detectColdRestoreState(id, { wslDistro })
|
||||
const detected = detection?.status === 'restored' ? detection.restoreInfo : null
|
||||
@@ -1171,6 +1221,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
this.sessionsNeedingFullCheckpoint.delete(id)
|
||||
this.sessionsNeedingLiveCheckpoint.delete(id)
|
||||
this.sessionsNeedingContinuityCheckpoint.delete(id)
|
||||
this.overlayDeadlineWarnedSessionIds.delete(id)
|
||||
this.periodicDeadlineWarnedSessionIds.delete(id)
|
||||
this.nonFinalAdmissionDeniedSessionIds.delete(id)
|
||||
this.lastFullCheckpointAt.delete(id)
|
||||
this.stopCheckpointTimerIfIdle()
|
||||
this.initialCwds.delete(id)
|
||||
@@ -1326,18 +1379,69 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
if (this.checkpointQueue.isSaturated(sessionId)) {
|
||||
return liveSnapshot
|
||||
}
|
||||
// Why reserve before enqueueing: pane mounts can arrive in one turn, before any compact starts.
|
||||
// Count abandoned waits until their writes settle so a relaunch cannot fan out unbounded work.
|
||||
if (!this.tryAdmitNonFinalCheckpoint(sessionId)) {
|
||||
return liveSnapshot
|
||||
}
|
||||
// Why per session with a deadline: a reattach is a user click, so it must wait
|
||||
// on this session's own compact and nothing else. A blown deadline does not
|
||||
// cancel that compact — it keeps running and still commits — so the fallback
|
||||
// costs restore depth for this one reattach, never durable history (STA-4173).
|
||||
return await this.checkpointQueue.runWithDeadline(
|
||||
sessionId,
|
||||
() => this.compactDurableRestoreSnapshot(sessionId, liveSnapshot, scrollbackRows),
|
||||
async () => {
|
||||
try {
|
||||
return await this.compactDurableRestoreSnapshot(sessionId, liveSnapshot, scrollbackRows)
|
||||
} finally {
|
||||
this.releaseNonFinalCheckpointAdmission(sessionId)
|
||||
this.overlayDeadlineWarnedSessionIds.delete(sessionId)
|
||||
}
|
||||
},
|
||||
DURABLE_HISTORY_OVERLAY_DEADLINE_MS,
|
||||
liveSnapshot
|
||||
liveSnapshot,
|
||||
{
|
||||
onDeadline: () => {
|
||||
if (!this.overlayDeadlineWarnedSessionIds.has(sessionId)) {
|
||||
this.overlayDeadlineWarnedSessionIds.add(sessionId)
|
||||
console.warn('[history] durable snapshot overlay deadline exceeded:', sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private tryAdmitNonFinalCheckpoint(sessionId: string): boolean {
|
||||
if (this.nonFinalCheckpointAdmissionSessionIds.has(sessionId)) {
|
||||
if (!this.nonFinalAdmissionDeniedSessionIds.has(sessionId)) {
|
||||
this.nonFinalAdmissionDeniedSessionIds.add(sessionId)
|
||||
console.warn('[history] non-final checkpoint already in flight:', sessionId)
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (this.nonFinalCheckpointAdmissionSessionIds.size >= MAX_CONCURRENT_CHECKPOINTS) {
|
||||
this.reportNonFinalGlobalAdmissionDenial(sessionId)
|
||||
return false
|
||||
}
|
||||
this.nonFinalAdmissionDeniedSessionIds.delete(sessionId)
|
||||
this.nonFinalCheckpointAdmissionSessionIds.add(sessionId)
|
||||
return true
|
||||
}
|
||||
|
||||
private reportNonFinalGlobalAdmissionDenial(sessionId: string): void {
|
||||
if (!this.nonFinalGlobalAdmissionWarningActive) {
|
||||
this.nonFinalGlobalAdmissionWarningActive = true
|
||||
console.warn('[history] non-final checkpoint global admission limit reached:', sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
private releaseNonFinalCheckpointAdmission(sessionId: string): void {
|
||||
if (this.nonFinalCheckpointAdmissionSessionIds.delete(sessionId)) {
|
||||
this.nonFinalAdmissionDeniedSessionIds.delete(sessionId)
|
||||
this.nonFinalGlobalAdmissionWarningActive = false
|
||||
}
|
||||
}
|
||||
|
||||
private async compactDurableRestoreSnapshot(
|
||||
sessionId: string,
|
||||
liveSnapshot: NonNullable<GetSnapshotResult['snapshot']>,
|
||||
@@ -1671,6 +1775,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
this.sessionsNeedingFullCheckpoint.clear()
|
||||
this.sessionsNeedingLiveCheckpoint.clear()
|
||||
this.sessionsNeedingContinuityCheckpoint.clear()
|
||||
this.overlayDeadlineWarnedSessionIds.clear()
|
||||
this.periodicDeadlineWarnedSessionIds.clear()
|
||||
this.nonFinalAdmissionDeniedSessionIds.clear()
|
||||
this.pausedProducerSessionIds.clear()
|
||||
this.producerResumesOwedOnReconnect.clear()
|
||||
this.stopCheckpointTimer()
|
||||
@@ -1781,6 +1888,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
this.sessionsNeedingFullCheckpoint.clear()
|
||||
this.sessionsNeedingLiveCheckpoint.clear()
|
||||
this.sessionsNeedingContinuityCheckpoint.clear()
|
||||
this.overlayDeadlineWarnedSessionIds.clear()
|
||||
this.periodicDeadlineWarnedSessionIds.clear()
|
||||
this.nonFinalAdmissionDeniedSessionIds.clear()
|
||||
this.coldRestoreCache.clear()
|
||||
this.wslDistrosBySessionId.clear()
|
||||
this.pausedProducerSessionIds.clear()
|
||||
@@ -2072,25 +2182,43 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
this.stopCheckpointTimerIfIdle()
|
||||
}
|
||||
|
||||
/** False only when `callerDeadlineMs` expired first; the checkpoint itself keeps running. */
|
||||
private async runExclusiveCheckpoint(
|
||||
operation: () => Promise<void>,
|
||||
options: { rescheduleDirty?: boolean } = {}
|
||||
): Promise<void> {
|
||||
options: { rescheduleDirty?: boolean; callerDeadlineMs?: number } = {}
|
||||
): Promise<boolean> {
|
||||
this.stopCheckpointTimer()
|
||||
// Why: a promise tail keeps every waiter ordered; awaiting one active operation lets sibling waiters resume together.
|
||||
const previous = this.checkpointInFlight ?? Promise.resolve()
|
||||
const checkpoint = previous.catch(() => {}).then(operation)
|
||||
this.checkpointInFlight = checkpoint
|
||||
try {
|
||||
await checkpoint
|
||||
} finally {
|
||||
if (this.checkpointInFlight === checkpoint) {
|
||||
this.checkpointInFlight = null
|
||||
}
|
||||
this.stopCheckpointTimer()
|
||||
if (options.rescheduleDirty !== false) {
|
||||
this.scheduleCheckpointTimer()
|
||||
// Why the release rides the checkpoint instead of the caller's await: a caller that walks away
|
||||
// at its deadline must leave this checkpoint as the tail, so the durable write still runs to
|
||||
// completion, still commits, and the next waiter still queues behind it (STA-4228).
|
||||
const settled = checkpoint.then(
|
||||
() => this.releaseExclusiveCheckpoint(checkpoint, options.rescheduleDirty),
|
||||
(err: unknown) => {
|
||||
this.releaseExclusiveCheckpoint(checkpoint, options.rescheduleDirty)
|
||||
throw err
|
||||
}
|
||||
)
|
||||
if (options.callerDeadlineMs === undefined) {
|
||||
await settled
|
||||
return true
|
||||
}
|
||||
return await awaitWithinCallerDeadline(settled, options.callerDeadlineMs)
|
||||
}
|
||||
|
||||
private releaseExclusiveCheckpoint(
|
||||
checkpoint: Promise<void>,
|
||||
rescheduleDirty: boolean | undefined
|
||||
): void {
|
||||
if (this.checkpointInFlight === checkpoint) {
|
||||
this.checkpointInFlight = null
|
||||
}
|
||||
this.stopCheckpointTimer()
|
||||
if (rescheduleDirty !== false) {
|
||||
this.scheduleCheckpointTimer()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2116,6 +2244,17 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
|
||||
const checkpointNext = async (): Promise<void> => {
|
||||
for (;;) {
|
||||
// No worker in this pass awaits abandoned work, so a full admission set cannot open here.
|
||||
if (
|
||||
opts?.final !== true &&
|
||||
this.nonFinalCheckpointAdmissionSessionIds.size >= MAX_CONCURRENT_CHECKPOINTS
|
||||
) {
|
||||
const deferredSessionId = ids[nextIndex]
|
||||
if (deferredSessionId !== undefined) {
|
||||
this.reportNonFinalGlobalAdmissionDenial(deferredSessionId)
|
||||
}
|
||||
return
|
||||
}
|
||||
const index = nextIndex
|
||||
nextIndex++
|
||||
if (index >= ids.length) {
|
||||
@@ -2158,22 +2297,45 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
sessionId: string,
|
||||
opts: { final: boolean; teardown: boolean }
|
||||
): Promise<'done' | 'deferred'> {
|
||||
const run = (): Promise<'done' | 'deferred'> => this.writeSessionCheckpoint(sessionId, opts)
|
||||
// Why final waits without a deadline: sleep/disconnect needs the last snapshot on disk, and
|
||||
// deferring there would silently drop what the user left on screen rather than delay it.
|
||||
if (opts.final) {
|
||||
return await this.checkpointQueue.run(sessionId, run)
|
||||
return await this.checkpointQueue.run(sessionId, () =>
|
||||
this.writeSessionCheckpoint(sessionId, opts)
|
||||
)
|
||||
}
|
||||
// Why 'deferred' is safe: the session stays dirty, so the operation that beat us to the queue
|
||||
// still commits and the next tick retries this one. Nothing on disk is discarded.
|
||||
if (this.checkpointQueue.isSaturated(sessionId)) {
|
||||
if (
|
||||
this.checkpointQueue.isSaturated(sessionId) ||
|
||||
!this.tryAdmitNonFinalCheckpoint(sessionId)
|
||||
) {
|
||||
return 'deferred'
|
||||
}
|
||||
const run = async (): Promise<'done' | 'deferred'> => {
|
||||
try {
|
||||
return await this.writeSessionCheckpoint(sessionId, opts)
|
||||
} finally {
|
||||
this.releaseNonFinalCheckpointAdmission(sessionId)
|
||||
this.periodicDeadlineWarnedSessionIds.delete(sessionId)
|
||||
}
|
||||
}
|
||||
return await this.checkpointQueue.runWithDeadline(
|
||||
sessionId,
|
||||
run,
|
||||
PERIODIC_CHECKPOINT_DEADLINE_MS,
|
||||
'deferred'
|
||||
DaemonPtyAdapter.PERIODIC_CHECKPOINT_DEADLINE_MS,
|
||||
'deferred',
|
||||
{
|
||||
onDeadline: () => {
|
||||
if (!this.periodicDeadlineWarnedSessionIds.has(sessionId)) {
|
||||
this.periodicDeadlineWarnedSessionIds.add(sessionId)
|
||||
console.warn('[history] periodic checkpoint deadline exceeded:', sessionId)
|
||||
}
|
||||
},
|
||||
onAbandonedRejection: (error) => {
|
||||
console.warn('[history] checkpoint failed:', sessionId, error)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2691,6 +2853,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
this.sessionsNeedingFullCheckpoint.delete(event.sessionId)
|
||||
this.sessionsNeedingLiveCheckpoint.delete(event.sessionId)
|
||||
this.sessionsNeedingContinuityCheckpoint.delete(event.sessionId)
|
||||
this.overlayDeadlineWarnedSessionIds.delete(event.sessionId)
|
||||
this.periodicDeadlineWarnedSessionIds.delete(event.sessionId)
|
||||
this.nonFinalAdmissionDeniedSessionIds.delete(event.sessionId)
|
||||
// Why: a reused sessionId (renderer respawns a persisted ptyId) must not inherit the dead session's snapshot cooldown.
|
||||
this.lastFullCheckpointAt.delete(event.sessionId)
|
||||
this.stopCheckpointTimerIfIdle()
|
||||
|
||||
@@ -154,20 +154,72 @@ describe('STA-4173 reattach isolation from a stalled checkpoint', () => {
|
||||
it('falls back to the live window when the session own checkpoint blows the deadline', async () => {
|
||||
const stalledId = await spawnWithOutput('deadline-session', 'DEADLINE_OUTPUT\r\n')
|
||||
const stall = await stallCheckpointFor(stalledId)
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const reattach = await withinBudget(
|
||||
adapter.spawn({ cols: 80, rows: 24, sessionId: stalledId, cwd: '/tmp' }),
|
||||
DEADLINE_BUDGET_MS
|
||||
)
|
||||
try {
|
||||
const reattach = await withinBudget(
|
||||
adapter.spawn({ cols: 80, rows: 24, sessionId: stalledId, cwd: '/tmp' }),
|
||||
DEADLINE_BUDGET_MS
|
||||
)
|
||||
|
||||
expect(stall.entered()).toBeGreaterThan(0)
|
||||
expect(reattach).not.toBe('timed-out')
|
||||
expect(reattach).toMatchObject({ isReattach: true })
|
||||
// Degraded to the daemon's live window, not empty: the deadline costs restore depth, not history.
|
||||
expect((reattach as { snapshot: string }).snapshot).toContain('DEADLINE_OUTPUT')
|
||||
stall.release()
|
||||
expect(stall.entered()).toBeGreaterThan(0)
|
||||
expect(reattach).not.toBe('timed-out')
|
||||
expect(reattach).toMatchObject({ isReattach: true })
|
||||
// Degraded to the daemon's live window, not empty: the deadline costs restore depth, not history.
|
||||
expect((reattach as { snapshot: string }).snapshot).toContain('DEADLINE_OUTPUT')
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[history] durable snapshot overlay deadline exceeded:',
|
||||
stalledId
|
||||
)
|
||||
} finally {
|
||||
stall.release()
|
||||
warn.mockRestore()
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('bounds overlay compacts across distinct sessions without blocking reattach', async () => {
|
||||
const stalledIds = ['fanout-a', 'fanout-b', 'fanout-c', 'fanout-d']
|
||||
for (const sessionId of stalledIds) {
|
||||
await spawnWithOutput(sessionId, `${sessionId.toUpperCase()}\r\n`)
|
||||
}
|
||||
const overflowId = await spawnWithOutput('fanout-overflow', 'FANOUT_OVERFLOW\r\n')
|
||||
const manager = adapter.getHistoryManager()!
|
||||
const original = manager.checkpoint.bind(manager)
|
||||
const entered = new Set<string>()
|
||||
const checkpointed: string[] = []
|
||||
let release = (): void => {}
|
||||
const stalled = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
vi.spyOn(manager, 'checkpoint').mockImplementation(async (sessionId, snapshot, opts) => {
|
||||
checkpointed.push(sessionId)
|
||||
if (stalledIds.includes(sessionId)) {
|
||||
entered.add(sessionId)
|
||||
await stalled
|
||||
}
|
||||
return await original(sessionId, snapshot, opts)
|
||||
})
|
||||
|
||||
const reattaches = stalledIds.map((sessionId) =>
|
||||
adapter.spawn({ cols: 80, rows: 24, sessionId, cwd: '/tmp' })
|
||||
)
|
||||
try {
|
||||
await vi.waitFor(() => expect(entered.size).toBe(stalledIds.length))
|
||||
|
||||
const overflowReattach = await withinBudget(
|
||||
adapter.spawn({ cols: 80, rows: 24, sessionId: overflowId, cwd: '/tmp' }),
|
||||
REATTACH_BUDGET_MS
|
||||
)
|
||||
expect(overflowReattach).not.toBe('timed-out')
|
||||
expect(overflowReattach).toMatchObject({ isReattach: true })
|
||||
expect((overflowReattach as { snapshot: string }).snapshot).toContain('FANOUT_OVERFLOW')
|
||||
expect(checkpointed).not.toContain(overflowId)
|
||||
} finally {
|
||||
release()
|
||||
await Promise.allSettled(reattaches)
|
||||
}
|
||||
})
|
||||
|
||||
it('still commits the abandoned checkpoint once the history write completes', async () => {
|
||||
const stalledId = await spawnWithOutput('resumed-session', 'RESUMED_OUTPUT\r\n')
|
||||
const stall = await stallCheckpointFor(stalledId)
|
||||
|
||||
@@ -486,6 +486,55 @@ describe('STA-4091 previously recoverable restore depth', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// Assert durable depth, not the sequence that merely enables it.
|
||||
it('preserves durable depth when an empty incremental take precedes a warm reattach', async () => {
|
||||
const { id } = await adapter.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
sessionId: 'empty-take-depth',
|
||||
cwd: '/tmp'
|
||||
})
|
||||
lastSubprocess.emitData(numberedOutput(DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT))
|
||||
await adapter.getBufferSnapshot(id)
|
||||
lastSubprocess.emitData(`${FRESH_AFTER_CHECKPOINT}\r\n`)
|
||||
|
||||
const oldInternals = adapter as unknown as { checkpointDirtySessions: () => Promise<void> }
|
||||
await oldInternals.checkpointDirtySessions()
|
||||
const beforeReattach = await new HistoryReader(historyDir).detectColdRestore(id, {
|
||||
ignoreCleanEnd: true
|
||||
})
|
||||
expect(snapshotText(beforeReattach ?? {})).toContain(OLDEST_WRITTEN_LINE)
|
||||
|
||||
// The trigger: a dirty mark with no new PTY records (the mock swallows the write, nothing echoes back).
|
||||
adapter.write(id, 'noop')
|
||||
await oldInternals.checkpointDirtySessions()
|
||||
|
||||
simulateAdapterCrash(adapter)
|
||||
adapter = new DaemonPtyAdapter({
|
||||
socketPath: getDaemonSocketPath(dir),
|
||||
tokenPath: join(dir, 'test.token'),
|
||||
historyPath: historyDir
|
||||
})
|
||||
|
||||
const reattach = await adapter.spawn({ cols: 80, rows: 24, sessionId: id, cwd: '/tmp' })
|
||||
expect(reattach.snapshot).toContain(FRESH_AFTER_CHECKPOINT)
|
||||
expect(reattach.snapshot).toContain(OLDEST_WRITTEN_LINE)
|
||||
|
||||
// First post-reattach compact runs the continuity proof; it must not flatten the deep checkpoint.
|
||||
const newInternals = adapter as unknown as {
|
||||
checkpointDirtySessions: () => Promise<void>
|
||||
}
|
||||
adapter.write(id, 'noop')
|
||||
await newInternals.checkpointDirtySessions()
|
||||
|
||||
const restored = await new HistoryReader(historyDir).detectColdRestore(id, {
|
||||
ignoreCleanEnd: true
|
||||
})
|
||||
expect(snapshotText(restored ?? {})).toContain(OLDEST_WRITTEN_LINE)
|
||||
expect(snapshotText(restored ?? {})).toContain(PREVIOUSLY_RECOVERABLE_LINE)
|
||||
expect(restored?.scrollbackLines).toBeGreaterThan(DAEMON_SESSION_SCROLLBACK_ROWS)
|
||||
})
|
||||
|
||||
it('falls back to the live window when durable history cannot be read', async () => {
|
||||
const { id } = await adapter.spawn({
|
||||
cols: 80,
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DaemonPtyAdapter, FinalCheckpointWaitExpiredError } from './daemon-pty-adapter'
|
||||
import { DaemonServer } from './daemon-server'
|
||||
import { getDaemonSocketPath } from './daemon-spawner'
|
||||
import type { DaemonFileLog } from './daemon-file-log'
|
||||
import { HistoryReader } from './history-reader'
|
||||
import type { HistoryCheckpointResult } from './terminal-history-manager-options'
|
||||
import type { SubprocessHandle } from './session'
|
||||
import type { TerminalSnapshot } from './types'
|
||||
|
||||
// Worktree sleep threads an absolute deadline into stopAndWait; this stands in for it.
|
||||
const CALLER_DEADLINE_MS = 300
|
||||
// Why well above the deadline it proves: a stop that only settles because the whole suite is slow
|
||||
// would prove nothing, and a stop that never settles must fail this test rather than hang the run.
|
||||
const STOP_BUDGET_MS = 8_000
|
||||
|
||||
function createMockSubprocess(): SubprocessHandle & { emitData: (data: string) => void } {
|
||||
let onData: ((data: string) => void) | undefined
|
||||
let onExit: ((code: number) => void) | undefined
|
||||
return {
|
||||
pid: 4243,
|
||||
getForegroundProcess: vi.fn(() => null),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(() => setTimeout(() => onExit?.(0), 1)),
|
||||
forceKill: vi.fn(() => onExit?.(137)),
|
||||
signal: vi.fn(),
|
||||
onData(callback) {
|
||||
onData = callback
|
||||
},
|
||||
onExit(callback) {
|
||||
onExit = callback
|
||||
},
|
||||
dispose: vi.fn(),
|
||||
emitData(data) {
|
||||
onData?.(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves 'timed-out' instead of hanging, so a stranded stop fails the test rather than the run. */
|
||||
async function withinBudget<T>(work: Promise<T>, budgetMs: number): Promise<T | 'timed-out'> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const budget = new Promise<'timed-out'>((resolve) => {
|
||||
timer = setTimeout(() => resolve('timed-out'), budgetMs)
|
||||
})
|
||||
try {
|
||||
return await Promise.race([work, budget])
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectionOf(work: Promise<unknown>): Promise<unknown> {
|
||||
try {
|
||||
await work
|
||||
} catch (error) {
|
||||
return error
|
||||
}
|
||||
throw new Error('Expected work to reject')
|
||||
}
|
||||
|
||||
describe('STA-4228 keep-history stop bounds only the caller wait on the final checkpoint', () => {
|
||||
let dir: string
|
||||
let server: DaemonServer
|
||||
let adapter: DaemonPtyAdapter
|
||||
let subprocesses: ReturnType<typeof createMockSubprocess>[]
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'orca-final-checkpoint-deadline-'))
|
||||
subprocesses = []
|
||||
const log: DaemonFileLog = { log: () => {}, close: () => {} }
|
||||
server = new DaemonServer({
|
||||
socketPath: getDaemonSocketPath(dir),
|
||||
tokenPath: join(dir, 'test.token'),
|
||||
log,
|
||||
spawnSubprocess: () => {
|
||||
const subprocess = createMockSubprocess()
|
||||
subprocesses.push(subprocess)
|
||||
return subprocess
|
||||
}
|
||||
})
|
||||
await server.start()
|
||||
adapter = new DaemonPtyAdapter({
|
||||
socketPath: getDaemonSocketPath(dir),
|
||||
tokenPath: join(dir, 'test.token'),
|
||||
historyPath: join(dir, 'history')
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
adapter?.dispose()
|
||||
await server?.shutdown()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** Wedges one session's checkpoint at the history-write layer until explicitly released. */
|
||||
function stallCheckpointFor(stalledSessionId: string): { release: () => void } {
|
||||
const manager = adapter.getHistoryManager()
|
||||
expect(manager).not.toBeNull()
|
||||
const original = manager!.checkpoint.bind(manager!)
|
||||
let release = (): void => {}
|
||||
const stalled = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
vi.spyOn(manager!, 'checkpoint').mockImplementation(
|
||||
async (
|
||||
sessionId: string,
|
||||
snapshot: TerminalSnapshot,
|
||||
opts?: { pendingOutputSeq?: number }
|
||||
): Promise<HistoryCheckpointResult> => {
|
||||
if (sessionId !== stalledSessionId) {
|
||||
return await original(sessionId, snapshot, opts)
|
||||
}
|
||||
await stalled
|
||||
return await original(sessionId, snapshot, opts)
|
||||
}
|
||||
)
|
||||
return { release }
|
||||
}
|
||||
|
||||
async function spawnWithOutput(sessionId: string, output: string): Promise<string> {
|
||||
const { id } = await adapter.spawn({ cols: 80, rows: 24, sessionId, cwd: '/tmp' })
|
||||
subprocesses.at(-1)!.emitData(output)
|
||||
return id
|
||||
}
|
||||
|
||||
function stopKeepingHistory(id: string): Promise<void> {
|
||||
return adapter.shutdown(id, {
|
||||
immediate: true,
|
||||
keepHistory: true,
|
||||
deadlineMs: Date.now() + CALLER_DEADLINE_MS
|
||||
})
|
||||
}
|
||||
|
||||
async function onDisk(sessionId: string): Promise<string> {
|
||||
const restore = await new HistoryReader(join(dir, 'history')).detectColdRestore(sessionId, {
|
||||
ignoreCleanEnd: true
|
||||
})
|
||||
return `${restore?.scrollbackAnsi ?? ''}${restore?.snapshotAnsi ?? ''}`
|
||||
}
|
||||
|
||||
it('lets a later keep-history stop settle while an earlier final checkpoint is wedged', async () => {
|
||||
const wedgedId = await spawnWithOutput('wedged-session', 'WEDGED_OUTPUT\r\n')
|
||||
const laterId = await spawnWithOutput('later-session', 'LATER_OUTPUT\r\n')
|
||||
const stall = stallCheckpointFor(wedgedId)
|
||||
|
||||
expect(
|
||||
await withinBudget(rejectionOf(stopKeepingHistory(wedgedId)), STOP_BUDGET_MS)
|
||||
).toBeInstanceOf(FinalCheckpointWaitExpiredError)
|
||||
// Why issued only now: this is the sleep-stranding case — a stop that starts while the wedged
|
||||
// checkpoint still owns the exclusive tail, which before this fix waited on it forever.
|
||||
expect(
|
||||
await withinBudget(rejectionOf(stopKeepingHistory(laterId)), STOP_BUDGET_MS)
|
||||
).toBeInstanceOf(FinalCheckpointWaitExpiredError)
|
||||
stall.release()
|
||||
}, 30_000)
|
||||
|
||||
it('leaves the unverified pty alive instead of falling through to the kill', async () => {
|
||||
const wedgedId = await spawnWithOutput('alive-session', 'ALIVE_OUTPUT\r\n')
|
||||
const stall = stallCheckpointFor(wedgedId)
|
||||
|
||||
expect(
|
||||
await withinBudget(rejectionOf(stopKeepingHistory(wedgedId)), STOP_BUDGET_MS)
|
||||
).toBeInstanceOf(FinalCheckpointWaitExpiredError)
|
||||
|
||||
// Why liveness and not just bookkeeping: the whole point is that an unproven snapshot must not
|
||||
// authorize the kill, so the daemon must still answer for this session.
|
||||
expect(await adapter.probePtyLiveness(wedgedId)).toBe(true)
|
||||
expect(adapter.hasPty(wedgedId)).toBe(true)
|
||||
stall.release()
|
||||
}, 30_000)
|
||||
|
||||
it('still commits every abandoned final checkpoint once the wedged history write completes', async () => {
|
||||
const wedgedId = await spawnWithOutput('committing-session', 'COMMITTING_OUTPUT\r\n')
|
||||
const laterId = await spawnWithOutput('queued-session', 'QUEUED_OUTPUT\r\n')
|
||||
const stall = stallCheckpointFor(wedgedId)
|
||||
|
||||
expect(
|
||||
await withinBudget(rejectionOf(stopKeepingHistory(wedgedId)), STOP_BUDGET_MS)
|
||||
).toBeInstanceOf(FinalCheckpointWaitExpiredError)
|
||||
expect(
|
||||
await withinBudget(rejectionOf(stopKeepingHistory(laterId)), STOP_BUDGET_MS)
|
||||
).toBeInstanceOf(FinalCheckpointWaitExpiredError)
|
||||
|
||||
stall.release()
|
||||
// Why this is the whole point of bounding the wait and not the work: both callers walked away,
|
||||
// and both durable writes still land on disk — the abandoned one and the one queued behind it.
|
||||
await vi.waitFor(async () => {
|
||||
expect(await onDisk(wedgedId)).toContain('COMMITTING_OUTPUT')
|
||||
expect(await onDisk(laterId)).toContain('QUEUED_OUTPUT')
|
||||
})
|
||||
}, 30_000)
|
||||
|
||||
it('resumes periodic durable writes after a rejected exclusive checkpoint', async () => {
|
||||
const adapterClass = DaemonPtyAdapter as unknown as { CHECKPOINT_INTERVAL_MS: number }
|
||||
const internals = adapter as unknown as {
|
||||
runExclusiveCheckpoint(operation: () => Promise<void>): Promise<boolean>
|
||||
}
|
||||
const previousInterval = adapterClass.CHECKPOINT_INTERVAL_MS
|
||||
const rejection = new Error('injected checkpoint rejection')
|
||||
|
||||
try {
|
||||
await expect(
|
||||
internals.runExclusiveCheckpoint(async () => {
|
||||
throw rejection
|
||||
})
|
||||
).rejects.toBe(rejection)
|
||||
|
||||
adapterClass.CHECKPOINT_INTERVAL_MS = 10
|
||||
const id = await spawnWithOutput('post-rejection-session', 'POST_REJECTION_OUTPUT\r\n')
|
||||
await vi.waitFor(async () => {
|
||||
expect(await onDisk(id)).toContain('POST_REJECTION_OUTPUT')
|
||||
})
|
||||
} finally {
|
||||
adapterClass.CHECKPOINT_INTERVAL_MS = previousInterval
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -248,7 +248,7 @@ export class HistoryManager {
|
||||
|
||||
try {
|
||||
// Why: tmp+rename is atomic (corrupt checkpoint > stale); async so a sync ~MB write can't stall IPC (worse under Windows AV).
|
||||
// The adapter's checkpointInFlight guard serializes checkpoints, so concurrent async writes can't collide on the fixed .tmp path.
|
||||
// The adapter's per-session checkpoint queue prevents concurrent writes from colliding on the fixed .tmp path.
|
||||
const checkpoint = await writer.checkpoint(snapshot, opts)
|
||||
if (checkpoint.result === 'retryable') {
|
||||
this.onWriteError?.(sessionId, checkpoint.error)
|
||||
|
||||
@@ -39,6 +39,7 @@ vi.mock('../pwsh', () => ({
|
||||
const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
|
||||
const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
|
||||
const CMD_ABS = 'C:\\Windows\\System32\\cmd.exe'
|
||||
const CODEX_LAUNCH_PREFLIGHT = 'C:\\Program Files\\Orca\\orca.exe'
|
||||
vi.mock('../providers/windows-powershell-executable', () => ({
|
||||
resolveWindowsPowerShellExecutablePath: (family: 'pwsh.exe' | 'powershell.exe') =>
|
||||
family === 'pwsh.exe' ? PWSH7_ABS : WINDOWS_POWERSHELL_ABS,
|
||||
@@ -2457,7 +2458,8 @@ describe('createPtySubprocess', () => {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
shellOverride: 'cmd.exe',
|
||||
terminalWindowsPowerShellImplementation: 'pwsh.exe'
|
||||
terminalWindowsPowerShellImplementation: 'pwsh.exe',
|
||||
env: { ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT }
|
||||
})
|
||||
} finally {
|
||||
if (platform) {
|
||||
@@ -2467,8 +2469,13 @@ describe('createPtySubprocess', () => {
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'cmd.exe',
|
||||
['/K', 'chcp 65001 > nul'],
|
||||
expect.any(Object)
|
||||
[
|
||||
'/K',
|
||||
'chcp 65001 > nul & if defined ORCA_CODEX_LAUNCH_PREFLIGHT call %ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%%ORCA_CODEX_LAUNCH_PREFLIGHT%%ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE% agent hooks prepare-codex > nul 2>&1'
|
||||
],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE: '"' })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2517,7 +2524,8 @@ describe('createPtySubprocess', () => {
|
||||
rows: 24,
|
||||
cwd: 'C:\\repo\\orca',
|
||||
shellOverride: 'cmd.exe',
|
||||
command: `codex ${'x'.repeat(7000)}`
|
||||
command: `codex ${'x'.repeat(7000)}`,
|
||||
env: { ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT }
|
||||
})
|
||||
} finally {
|
||||
if (platform) {
|
||||
@@ -2527,7 +2535,10 @@ describe('createPtySubprocess', () => {
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'cmd.exe',
|
||||
['/K', 'chcp 65001 > nul'],
|
||||
[
|
||||
'/K',
|
||||
'chcp 65001 > nul & if defined ORCA_CODEX_LAUNCH_PREFLIGHT call %ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%%ORCA_CODEX_LAUNCH_PREFLIGHT%%ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE% agent hooks prepare-codex > nul 2>&1'
|
||||
],
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(handle!.startupCommandDeliveredInShellArgs).toBeUndefined()
|
||||
@@ -2546,7 +2557,8 @@ describe('createPtySubprocess', () => {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: 'C:\\Users\\jin\\repo',
|
||||
shellOverride: 'C:\\PortableGit\\bin\\bash.exe'
|
||||
shellOverride: 'C:\\PortableGit\\bin\\bash.exe',
|
||||
env: { ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT }
|
||||
})
|
||||
} finally {
|
||||
if (platform) {
|
||||
@@ -2556,10 +2568,18 @@ describe('createPtySubprocess', () => {
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'C:\\PortableGit\\bin\\bash.exe',
|
||||
['-c', 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i'],
|
||||
[
|
||||
'-c',
|
||||
expect.stringMatching(
|
||||
/^chcp\.com 65001 >\/dev\/null 2>&1; exec "\$BASH" --rcfile '.*shell-ready\/bash\/rcfile' -i$/
|
||||
)
|
||||
],
|
||||
expect.objectContaining({
|
||||
cwd: 'C:\\Users\\jin\\repo',
|
||||
env: expect.objectContaining({ CHERE_INVOKING: '1' })
|
||||
env: expect.objectContaining({
|
||||
CHERE_INVOKING: '1',
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -20,7 +20,10 @@ import {
|
||||
import { wrapShellSpawnForMacosTccAttribution } from '../providers/macos-tcc-login-shell'
|
||||
import { signalPosixPtyForegroundGroup } from '../pty/posix-pty-foreground-group'
|
||||
import { readPtsName } from '../pty/node-pty-pts-name'
|
||||
import { resolveWindowsShellLaunchArgs } from '../providers/windows-shell-args'
|
||||
import {
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV,
|
||||
resolveWindowsShellLaunchArgs
|
||||
} from '../providers/windows-shell-args'
|
||||
import {
|
||||
resolveEffectiveWindowsPowerShell,
|
||||
shouldProbeWindowsPowerShellAvailability,
|
||||
@@ -693,6 +696,13 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
||||
}) ?? shellPath)
|
||||
: shellPath
|
||||
}
|
||||
if (
|
||||
pathWin32.basename(shellPath).toLowerCase() === 'cmd.exe' &&
|
||||
env.ORCA_CODEX_LAUNCH_PREFLIGHT
|
||||
) {
|
||||
// Why: node-pty backslash-escapes argv quotes; expand the quote inside cmd.exe instead.
|
||||
env[ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV] = '"'
|
||||
}
|
||||
// Why: a bare `pwsh.exe` resolves to the Store App Execution Alias stub whose launch fails with ERROR_ACCESS_DENIED (5).
|
||||
windowsFallbackAttempts = buildWindowsPowerShellSpawnAttempts({
|
||||
shellPath,
|
||||
@@ -714,7 +724,8 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
||||
spawnCwd,
|
||||
getDefaultCwd(),
|
||||
resolvedWslContext,
|
||||
opts.command
|
||||
opts.command,
|
||||
env.ORCA_CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
shellArgs = resolved.shellArgs
|
||||
spawnCwd = resolved.effectiveCwd
|
||||
@@ -745,7 +756,8 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
||||
{
|
||||
distro: codexHomeWslInfo.distro
|
||||
},
|
||||
opts.command
|
||||
opts.command,
|
||||
env.ORCA_CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
shellArgs = resolved.shellArgs
|
||||
spawnCwd = resolved.effectiveCwd
|
||||
|
||||
@@ -494,7 +494,10 @@ export class Session {
|
||||
this.pendingOutputRecords = []
|
||||
this.pendingOutputBytes = 0
|
||||
this.pendingOutputOverflowed = false
|
||||
this.pendingOutputSeq += 1
|
||||
// Empty incremental takes are not persisted; advancing them would create a false reattach gap.
|
||||
if (includeSnapshot || records.length > 0 || overflowed) {
|
||||
this.pendingOutputSeq += 1
|
||||
}
|
||||
return {
|
||||
records: includeSnapshot
|
||||
? releasedHeldBytes
|
||||
|
||||
@@ -21,7 +21,7 @@ async function restoreSerializedTerminal(source: ParityTerminal): Promise<Parity
|
||||
const restored = createRendererParityTerminal({ cols: snapshot.cols, rows: snapshot.rows })
|
||||
const preamble =
|
||||
snapshot.alternateScreen && snapshot.scrollbackAnsi !== undefined
|
||||
? `\x1b[?1049l\x1b[2J\x1b[3J\x1b[H${snapshot.scrollbackAnsi}${SNAPSHOT_REPLAY_PREAMBLE_ALT}`
|
||||
? `${SNAPSHOT_REPLAY_PREAMBLE_NORMAL}${snapshot.scrollbackAnsi}${SNAPSHOT_REPLAY_PREAMBLE_ALT}`
|
||||
: snapshot.alternateScreen
|
||||
? SNAPSHOT_REPLAY_PREAMBLE_ALT
|
||||
: SNAPSHOT_REPLAY_PREAMBLE_NORMAL
|
||||
|
||||
@@ -285,9 +285,10 @@ export type TakePendingOutputResult = {
|
||||
/** Drained pending queue. Absent on older daemons. includeSnapshot still
|
||||
* keeps `records` as held-only so mixed-version adapters do not double-replay. */
|
||||
drainedRecords?: PendingOutputRecord[]
|
||||
/** Monotonic per-session batch sequence. The history log stores it so the
|
||||
/** Non-decreasing per-session batch sequence. The history log stores it so the
|
||||
* cold-restore reader can detect a lost batch (gap) and discard the log
|
||||
* instead of replaying a stream with missing bytes. */
|
||||
* instead of replaying a stream with missing bytes. Snapshot, record, and
|
||||
* overflow takes advance it; empty incremental takes repeat the prior value. */
|
||||
seq: number
|
||||
/** True when the session's pending buffer exceeded its cap and records were
|
||||
* dropped. The caller must fall back to a full snapshot checkpoint. */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { parse as parseJsonc, type ParseError } from 'jsonc-parser'
|
||||
import { applyEdits, modify, parse as parseJsonc, type ParseError } from 'jsonc-parser'
|
||||
import { isPlainObject, type HooksConfig } from '../agent-hooks/installer-utils'
|
||||
|
||||
/** Devin documents config.json as JSONC; stock JSON.parse rejects comments. */
|
||||
@@ -16,6 +16,60 @@ export function readDevinHooksConfig(configPath: string): HooksConfig | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** Original file text alongside its parsed form, so a write can edit the text in place. */
|
||||
export function readDevinHooksSource(
|
||||
configPath: string
|
||||
): { text: string | null; config: HooksConfig } | null {
|
||||
if (!existsSync(configPath)) {
|
||||
return { text: null, config: {} }
|
||||
}
|
||||
|
||||
let text: string
|
||||
try {
|
||||
text = readFileSync(configPath, 'utf-8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const config = parseDevinHooksConfigText(text, 'Devin config.json')
|
||||
return config === null ? null : { text, config }
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize by editing the original JSONC text one hook event at a time, so the user's
|
||||
* comments, key order, and formatting survive. A parse -> JSON.stringify round trip would
|
||||
* silently drop all of them.
|
||||
*/
|
||||
export function serializeDevinHooksConfig(
|
||||
originalText: string | null,
|
||||
nextConfig: HooksConfig
|
||||
): string {
|
||||
if (originalText === null) {
|
||||
return `${JSON.stringify(nextConfig, null, 2)}\n`
|
||||
}
|
||||
|
||||
const previous = parseJsonc(originalText) as HooksConfig | undefined
|
||||
const previousHooks = isPlainObject(previous?.hooks) ? (previous.hooks ?? {}) : {}
|
||||
const nextHooks = nextConfig.hooks ?? {}
|
||||
|
||||
let text = originalText
|
||||
// Why: touch only the events that actually changed, so comments attached to a user's
|
||||
// own untouched hook entries stay put.
|
||||
for (const eventName of new Set([...Object.keys(previousHooks), ...Object.keys(nextHooks)])) {
|
||||
const nextValue = nextHooks[eventName]
|
||||
if (JSON.stringify(previousHooks[eventName]) === JSON.stringify(nextValue)) {
|
||||
continue
|
||||
}
|
||||
text = applyEdits(
|
||||
text,
|
||||
// Why: `undefined` removes the key, which is how remove() drops an emptied event.
|
||||
modify(text, ['hooks', eventName], nextValue, {
|
||||
formattingOptions: { insertSpaces: true, tabSize: 2 }
|
||||
})
|
||||
)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
export function parseDevinHooksConfigText(
|
||||
text: string,
|
||||
diagnosticName: string
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { parse as parseJsonc } from 'jsonc-parser'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
@@ -104,7 +105,45 @@ describe('DevinHookService', () => {
|
||||
const status = new DevinHookService().install()
|
||||
|
||||
expect(status.state).toBe('installed')
|
||||
expect(JSON.parse(readFileSync(configPath, 'utf8')).hooks.UserPromptSubmit).toBeDefined()
|
||||
// Why: parse as JSONC, not JSON — asserting with JSON.parse would only pass once the
|
||||
// comment had been stripped, which is the regression this test exists to catch.
|
||||
expect(parseJsonc(readFileSync(configPath, 'utf8')).hooks.UserPromptSubmit).toBeDefined()
|
||||
})
|
||||
|
||||
it('keeps user comments and unrelated formatting when installing and removing', () => {
|
||||
const configPath = getDevinConfigPath()
|
||||
mkdirSync(dirname(configPath), { recursive: true })
|
||||
writeFileSync(
|
||||
configPath,
|
||||
`{
|
||||
// keep me: chosen deliberately
|
||||
"permissions": { "mode": "normal" },
|
||||
/* block comment */
|
||||
"hooks": {
|
||||
// the user's own hook
|
||||
"SessionEnd": [{ "hooks": [{ "type": "command", "command": "mine.sh" }] }]
|
||||
}
|
||||
}
|
||||
`
|
||||
)
|
||||
|
||||
const service = new DevinHookService()
|
||||
service.install()
|
||||
|
||||
const installed = readFileSync(configPath, 'utf8')
|
||||
expect(installed).toContain('// keep me: chosen deliberately')
|
||||
expect(installed).toContain('/* block comment */')
|
||||
expect(installed).toContain("// the user's own hook")
|
||||
expect(installed).toContain('mine.sh')
|
||||
expect(parseJsonc(installed).permissions.mode).toBe('normal')
|
||||
expect(parseJsonc(installed).hooks.UserPromptSubmit).toBeDefined()
|
||||
|
||||
service.remove()
|
||||
|
||||
const removed = readFileSync(configPath, 'utf8')
|
||||
expect(removed).toContain('// keep me: chosen deliberately')
|
||||
expect(removed).toContain('mine.sh')
|
||||
expect(parseJsonc(removed).hooks.UserPromptSubmit).toBeUndefined()
|
||||
})
|
||||
|
||||
it('surfaces read_config_from overlap in status detail', () => {
|
||||
|
||||
@@ -32,7 +32,9 @@ import {
|
||||
mergeHookInstallDetail,
|
||||
parseDevinHooksConfigText,
|
||||
readConfigFromOrcaOverlapDetail,
|
||||
readDevinHooksConfig
|
||||
readDevinHooksConfig,
|
||||
readDevinHooksSource,
|
||||
serializeDevinHooksConfig
|
||||
} from './hook-config-json'
|
||||
|
||||
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
@@ -140,8 +142,8 @@ export class DevinHookService {
|
||||
install(): AgentHookInstallStatus {
|
||||
const configPath = getDevinConfigPath()
|
||||
const scriptPath = getDevinManagedScriptPath()
|
||||
const config = readDevinHooksConfig(configPath)
|
||||
if (!config) {
|
||||
const source = readDevinHooksSource(configPath)
|
||||
if (!source) {
|
||||
return {
|
||||
agent: 'devin',
|
||||
state: 'error',
|
||||
@@ -152,9 +154,15 @@ export class DevinHookService {
|
||||
}
|
||||
|
||||
const command = getDevinManagedCommand(scriptPath)
|
||||
const nextConfig = applyDevinManagedHooks(config, command, getDevinManagedScriptFileName())
|
||||
const nextConfig = applyDevinManagedHooks(
|
||||
source.config,
|
||||
command,
|
||||
getDevinManagedScriptFileName()
|
||||
)
|
||||
writeManagedScript(scriptPath, getManagedScript())
|
||||
writeHooksJson(configPath, nextConfig)
|
||||
writeHooksJson(configPath, nextConfig, {
|
||||
serialized: serializeDevinHooksConfig(source.text, nextConfig)
|
||||
})
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
@@ -187,7 +195,9 @@ export class DevinHookService {
|
||||
// Why: write script before settings so a mid-install failure never leaves settings.json referencing a missing script.
|
||||
// Why: SSH remotes use POSIX `.sh` hooks even when Orca runs on Windows; never derive remote script syntax from local OS.
|
||||
await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
|
||||
await writeHooksJsonRemote(sftp, remoteConfigPath, nextConfig)
|
||||
await writeHooksJsonRemote(sftp, remoteConfigPath, nextConfig, {
|
||||
serialized: serializeDevinHooksConfig(body, nextConfig)
|
||||
})
|
||||
|
||||
return {
|
||||
agent: 'devin',
|
||||
@@ -209,8 +219,8 @@ export class DevinHookService {
|
||||
|
||||
remove(): AgentHookInstallStatus {
|
||||
const configPath = getDevinConfigPath()
|
||||
const config = readDevinHooksConfig(configPath)
|
||||
if (!config) {
|
||||
const source = readDevinHooksSource(configPath)
|
||||
if (!source) {
|
||||
return {
|
||||
agent: 'devin',
|
||||
state: 'error',
|
||||
@@ -220,11 +230,13 @@ export class DevinHookService {
|
||||
}
|
||||
}
|
||||
const { config: nextConfig, changed } = removeDevinManagedHooks(
|
||||
config,
|
||||
source.config,
|
||||
getDevinManagedScriptFileName()
|
||||
)
|
||||
if (changed) {
|
||||
writeHooksJson(configPath, nextConfig)
|
||||
writeHooksJson(configPath, nextConfig, {
|
||||
serialized: serializeDevinHooksConfig(source.text, nextConfig)
|
||||
})
|
||||
}
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
@@ -17,9 +17,13 @@ import { AGENT_SESSION_CLAIM_DIGEST_VERSION } from '../../shared/agent-session-h
|
||||
import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
|
||||
import { TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors'
|
||||
import type * as Wsl from '../wsl'
|
||||
import { getBundledLauncherPath } from '../cli/bundled-cli-launcher-path'
|
||||
|
||||
const isWindowsHost = process.platform === 'win32'
|
||||
const posixOnlyIt = isWindowsHost ? it.skip : it
|
||||
const BUNDLED_RESOURCES_PATH = join('/tmp', 'orca-bundled-resources')
|
||||
// Why: this suite forces darwin before every test, including on Linux CI.
|
||||
const BUNDLED_CLI_PATH = getBundledLauncherPath('darwin', BUNDLED_RESOURCES_PATH) as string
|
||||
// Why: bare shells no longer mkdir ~/.omp; OMP status lives under userData (#10196).
|
||||
const expectedOmpStatusExtension = posix.join(
|
||||
'/tmp/orca-user-data',
|
||||
@@ -133,7 +137,8 @@ vi.mock('fs', () => ({
|
||||
writeFileSync: writeFileSyncMock,
|
||||
chmodSync: chmodSyncMock,
|
||||
constants: {
|
||||
X_OK: 1
|
||||
X_OK: 1,
|
||||
R_OK: 4
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -1939,6 +1944,42 @@ describe('registerPtyHandlers', () => {
|
||||
]
|
||||
}
|
||||
|
||||
// Why: the Codex launch preflight now carries the bundled CLI's verified absolute
|
||||
// path, so these cases need a resources root whose launcher passes the exec check.
|
||||
async function withBundledCli<T>(
|
||||
run: () => Promise<T>,
|
||||
options?: { launcherExecutable?: boolean }
|
||||
): Promise<T> {
|
||||
const launcherExecutable = options?.launcherExecutable ?? true
|
||||
const previousResourcesPath = process.resourcesPath
|
||||
Object.defineProperty(process, 'resourcesPath', {
|
||||
configurable: true,
|
||||
value: BUNDLED_RESOURCES_PATH
|
||||
})
|
||||
// Why: only teach the launcher path to look like an executable file; every other
|
||||
// stat/access keeps the permissive default the rest of the harness relies on.
|
||||
statSyncMock.mockImplementation((target: string) => ({
|
||||
isDirectory: () => target !== BUNDLED_CLI_PATH,
|
||||
isFile: () => target === BUNDLED_CLI_PATH,
|
||||
mode: 0o755
|
||||
}))
|
||||
if (!launcherExecutable) {
|
||||
accessSyncMock.mockImplementation((target: string) => {
|
||||
if (target === BUNDLED_CLI_PATH) {
|
||||
throw new Error(`EACCES: ${target}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
Object.defineProperty(process, 'resourcesPath', {
|
||||
configurable: true,
|
||||
value: previousResourcesPath
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
describe('spawn environment', () => {
|
||||
it('refreshes the outer Windows PATH for a WSL spawn without forwarding it', async () => {
|
||||
const originalPlatform = process.platform
|
||||
@@ -2133,10 +2174,40 @@ describe('registerPtyHandlers', () => {
|
||||
})
|
||||
|
||||
it('injects the selected Codex home into Orca terminal PTYs', async () => {
|
||||
const env = await spawnAndGetEnv(undefined, undefined, () => TEST_CODEX_HOME)
|
||||
const env = await withBundledCli(() =>
|
||||
spawnAndGetEnv(undefined, undefined, () => TEST_CODEX_HOME)
|
||||
)
|
||||
expect(env.CODEX_HOME).toBe(TEST_CODEX_HOME)
|
||||
expect(env.ORCA_CODEX_HOME).toBe(TEST_CODEX_HOME)
|
||||
expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT).toBe('orca')
|
||||
// Why (STA-4270): a bare name would be resolved by the post-profile PATH the codex()
|
||||
// wrapper inherits, so the preflight must carry the CLI's verified absolute path.
|
||||
expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT).toBe(BUNDLED_CLI_PATH)
|
||||
})
|
||||
|
||||
it('skips the Codex launch preflight when the bundled CLI is not executable', async () => {
|
||||
const env = await withBundledCli(
|
||||
() => spawnAndGetEnv(undefined, undefined, () => TEST_CODEX_HOME),
|
||||
{ launcherExecutable: false }
|
||||
)
|
||||
|
||||
expect(env.CODEX_HOME).toBe(TEST_CODEX_HOME)
|
||||
expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT).toBeUndefined()
|
||||
})
|
||||
|
||||
// Why (STA-4270): profile scripts run before the codex() wrapper and routinely prepend
|
||||
// directories to PATH, so a scratch `orca` there must never become the preflight.
|
||||
it('pins the Codex launch preflight to the bundled CLI even when PATH leads elsewhere', async () => {
|
||||
const env = await withBundledCli(() =>
|
||||
spawnAndGetEnv(
|
||||
{ PATH: `/tmp/hijack-scratch${delimiter}/usr/bin` },
|
||||
undefined,
|
||||
() => TEST_CODEX_HOME
|
||||
)
|
||||
)
|
||||
|
||||
expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT).toBe(BUNDLED_CLI_PATH)
|
||||
expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT).not.toBe('orca')
|
||||
expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT.startsWith('/tmp/hijack-scratch')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not install the Codex launch preflight when Codex hooks are disabled', async () => {
|
||||
|
||||
+3
-1
@@ -1869,7 +1869,9 @@ export function buildPtyHostEnv(
|
||||
hooksEnabled: opts.codexStatusHooksEnabled ?? opts.agentStatusHooksEnabled,
|
||||
isPackaged: opts.isPackaged,
|
||||
isWsl: opts.isWsl,
|
||||
managedHomePath: opts.selectedCodexHomePath
|
||||
managedHomePath: opts.selectedCodexHomePath,
|
||||
userDataPath: opts.userDataPath,
|
||||
resourcesPath: opts.resourcesPath
|
||||
})
|
||||
if (preflightCommand) {
|
||||
baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT = preflightCommand
|
||||
|
||||
@@ -1185,14 +1185,12 @@ describe('repos:addRemote', () => {
|
||||
kind: 'git',
|
||||
displayName: 'project',
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||||
externalWorktreeVisibility: 'hide',
|
||||
externalWorktreeVisibilityLegacy: false,
|
||||
projectHostSetupMethod: 'imported-existing-folder'
|
||||
})
|
||||
)
|
||||
expect(result).toHaveProperty('repo.id')
|
||||
expect(result).toHaveProperty('repo.connectionId', 'conn-1')
|
||||
expect(result).toHaveProperty('repo.externalWorktreeVisibility', 'hide')
|
||||
expect(result).not.toHaveProperty('repo.externalWorktreeVisibility')
|
||||
})
|
||||
|
||||
it('uses custom displayName when provided', async () => {
|
||||
@@ -1234,9 +1232,7 @@ describe('repos:addRemote', () => {
|
||||
connectionId: 'conn-1',
|
||||
kind: 'git',
|
||||
displayName: 'orca',
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||||
externalWorktreeVisibility: 'hide',
|
||||
externalWorktreeVisibilityLegacy: false
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR
|
||||
})
|
||||
)
|
||||
expect(mockMultiplexer.notify).toHaveBeenCalledWith('session.registerRoot', {
|
||||
@@ -1462,8 +1458,7 @@ describe('repos:addRemote', () => {
|
||||
path: '/home/user/created',
|
||||
connectionId: 'conn-1',
|
||||
kind: 'git',
|
||||
displayName: 'created',
|
||||
externalWorktreeVisibility: 'hide'
|
||||
displayName: 'created'
|
||||
})
|
||||
)
|
||||
expect(result).toHaveProperty('repo.path', '/home/user/created')
|
||||
@@ -1879,19 +1874,19 @@ describe('repos:add + repos:clone', () => {
|
||||
expect(result).toHaveProperty('repo.badgeColor', DEFAULT_REPO_BADGE_COLOR)
|
||||
})
|
||||
|
||||
it('defaults new git repos:add records to hiding non-Orca worktrees', async () => {
|
||||
it('inherits global non-Orca visibility while retaining the mixed-version safety marker', async () => {
|
||||
const result = await handlers.get('repos:add')!(null, { path: '/tmp/from-add', kind: 'git' })
|
||||
|
||||
expect(mockStore.addRepo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: '/tmp/from-add',
|
||||
kind: 'git',
|
||||
externalWorktreeVisibility: 'hide',
|
||||
externalWorktreeVisibilityLegacy: false,
|
||||
projectHostSetupMethod: 'imported-existing-folder'
|
||||
})
|
||||
)
|
||||
expect(result).toHaveProperty('repo.externalWorktreeVisibility', 'hide')
|
||||
expect(result).not.toHaveProperty('repo.externalWorktreeVisibility')
|
||||
expect(result).toHaveProperty('repo.externalWorktreeVisibilityLegacy', false)
|
||||
})
|
||||
|
||||
it('prepares the worktree root when adding a local git repo', async () => {
|
||||
@@ -2280,13 +2275,11 @@ describe('repos:add + repos:clone', () => {
|
||||
expect.objectContaining({
|
||||
path: join(destination, 'orca'),
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||||
kind: 'git',
|
||||
externalWorktreeVisibility: 'hide',
|
||||
externalWorktreeVisibilityLegacy: false
|
||||
kind: 'git'
|
||||
})
|
||||
)
|
||||
expect(result).toHaveProperty('badgeColor', DEFAULT_REPO_BADGE_COLOR)
|
||||
expect(result).toHaveProperty('externalWorktreeVisibility', 'hide')
|
||||
expect(result).not.toHaveProperty('externalWorktreeVisibility')
|
||||
})
|
||||
|
||||
it('drops a same-path negative submodule cache before a local clone', async () => {
|
||||
|
||||
@@ -356,7 +356,6 @@ async function addLocalRepoFromPath(
|
||||
kind: repoKind,
|
||||
...(repoKind === 'git'
|
||||
? {
|
||||
externalWorktreeVisibility: 'hide' as const,
|
||||
externalWorktreeVisibilityLegacy: false,
|
||||
// Why: new Add Project imports are explicit ready host setups; 'legacy-repo' is reserved for older records/projection.
|
||||
projectHostSetupMethod: 'imported-existing-folder' as const
|
||||
@@ -455,7 +454,6 @@ async function addRemoteRepoFromPath(
|
||||
connectionId: args.connectionId,
|
||||
...(repoKind === 'git'
|
||||
? {
|
||||
externalWorktreeVisibility: 'hide' as const,
|
||||
externalWorktreeVisibilityLegacy: false,
|
||||
projectHostSetupMethod: args.setupMethod ?? ('imported-existing-folder' as const)
|
||||
}
|
||||
@@ -1774,7 +1772,6 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
addedAt: Date.now(),
|
||||
kind: 'git',
|
||||
...(args.connectionId ? { connectionId: args.connectionId } : {}),
|
||||
externalWorktreeVisibility: 'hide',
|
||||
externalWorktreeVisibilityLegacy: false,
|
||||
projectHostSetupMethod: 'imported-existing-folder',
|
||||
...(group
|
||||
@@ -2034,7 +2031,6 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
kind: repoKind,
|
||||
...(repoKind === 'git'
|
||||
? {
|
||||
externalWorktreeVisibility: 'hide' as const,
|
||||
externalWorktreeVisibilityLegacy: false,
|
||||
projectHostSetupMethod: 'imported-existing-folder' as const
|
||||
}
|
||||
@@ -2126,17 +2122,17 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
| 'symlinkPaths'
|
||||
| 'issueSourcePreference'
|
||||
| 'forkSyncMode'
|
||||
| 'externalWorktreeVisibility'
|
||||
| 'externalWorktreeVisibilityPromptDismissedAt'
|
||||
| 'externalWorktreeInboxBaselinePaths'
|
||||
| 'importedExternalWorktreePaths'
|
||||
| 'agentWorktreeVisibility'
|
||||
| 'customWorktreeVisibilitySources'
|
||||
| 'worktreeVisibilitySourcePreferences'
|
||||
| 'projectGroupId'
|
||||
| 'projectGroupOrder'
|
||||
>
|
||||
> & {
|
||||
externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null
|
||||
agentWorktreeVisibility?: Repo['agentWorktreeVisibility'] | null
|
||||
sourceControlAi?: Repo['sourceControlAi'] | null
|
||||
externalWorktreeDiscoverySuppressedAt?:
|
||||
| Repo['externalWorktreeDiscoverySuppressedAt']
|
||||
@@ -2195,7 +2191,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
updates.badgeColor = badgeColor
|
||||
}
|
||||
}
|
||||
if (
|
||||
if ('externalWorktreeVisibility' in updates && updates.externalWorktreeVisibility === null) {
|
||||
updates.externalWorktreeVisibility = undefined
|
||||
} else if (
|
||||
'externalWorktreeVisibility' in updates &&
|
||||
updates.externalWorktreeVisibility !== undefined &&
|
||||
updates.externalWorktreeVisibility !== 'hide' &&
|
||||
@@ -2205,6 +2203,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
}
|
||||
if (
|
||||
'agentWorktreeVisibility' in updates &&
|
||||
updates.agentWorktreeVisibility !== null &&
|
||||
updates.agentWorktreeVisibility !== undefined &&
|
||||
updates.agentWorktreeVisibility !== 'hide' &&
|
||||
updates.agentWorktreeVisibility !== 'show'
|
||||
@@ -2551,7 +2550,6 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
...detected,
|
||||
addedAt: Date.now(),
|
||||
kind: 'git',
|
||||
externalWorktreeVisibility: 'hide',
|
||||
externalWorktreeVisibilityLegacy: false,
|
||||
projectHostSetupMethod: 'cloned'
|
||||
}
|
||||
|
||||
@@ -49,13 +49,51 @@ describe('registerRuntimeHandlers', () => {
|
||||
|
||||
fromWebContentsMock.mockReturnValue({ id: 17 })
|
||||
|
||||
const currentMainFrame = {}
|
||||
const sender = { mainFrame: currentMainFrame }
|
||||
const handler = syncRegistration![1]
|
||||
const result = handler({ sender: {} }, { tabs: [], leaves: [] })
|
||||
const graph = { tabs: [], leaves: [], rendererGeneration: 'renderer-1' }
|
||||
const result = handler({ sender, senderFrame: currentMainFrame }, graph)
|
||||
|
||||
expect(runtime.syncWindowGraph).toHaveBeenCalledWith(17, { tabs: [], leaves: [] })
|
||||
expect(runtime.syncWindowGraph).toHaveBeenCalledWith(17, graph)
|
||||
expect(result).toEqual({ graphStatus: 'ready' })
|
||||
})
|
||||
|
||||
it('rejects a graph publication queued by a superseded main frame', () => {
|
||||
const runtime = {
|
||||
syncWindowGraph: vi.fn(),
|
||||
getStatus: vi.fn(),
|
||||
getRuntimeId: vi.fn()
|
||||
}
|
||||
registerRuntimeHandlers(runtime as never)
|
||||
const handler = handleMock.mock.calls.find(
|
||||
([channel]) => channel === 'runtime:syncWindowGraph'
|
||||
)![1]
|
||||
const sender = { mainFrame: { generation: 2 } }
|
||||
fromWebContentsMock.mockReturnValue({ id: 17 })
|
||||
|
||||
expect(() =>
|
||||
handler({ sender, senderFrame: { generation: 1 } }, { tabs: [], leaves: [] })
|
||||
).toThrow('Runtime graph sync must originate from the current main frame')
|
||||
expect(runtime.syncWindowGraph).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects graph publications without a renderer generation', () => {
|
||||
const runtime = { syncWindowGraph: vi.fn() }
|
||||
registerRuntimeHandlers(runtime as never)
|
||||
const handler = handleMock.mock.calls.find(
|
||||
([channel]) => channel === 'runtime:syncWindowGraph'
|
||||
)![1]
|
||||
const currentMainFrame = {}
|
||||
const sender = { mainFrame: currentMainFrame }
|
||||
fromWebContentsMock.mockReturnValue({ id: 17 })
|
||||
|
||||
expect(() =>
|
||||
handler({ sender, senderFrame: currentMainFrame }, { tabs: [], leaves: [] })
|
||||
).toThrow('Runtime graph sync requires a renderer generation')
|
||||
expect(runtime.syncWindowGraph).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes generic local runtime RPC calls through the dispatcher', async () => {
|
||||
const runtime = {
|
||||
syncWindowGraph: vi.fn(),
|
||||
|
||||
+10
-2
@@ -2,9 +2,9 @@ import { BrowserWindow, ipcMain } from 'electron'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import type {
|
||||
RuntimeBrowserDriverState,
|
||||
RuntimeRendererSyncWindowGraph,
|
||||
RuntimeStatus,
|
||||
RuntimeSyncWindowGraphResult,
|
||||
RuntimeSyncWindowGraph,
|
||||
RuntimeTerminalDriverState
|
||||
} from '../../shared/runtime-types'
|
||||
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
|
||||
@@ -33,11 +33,19 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
|
||||
|
||||
ipcMain.handle(
|
||||
'runtime:syncWindowGraph',
|
||||
(event, graph: RuntimeSyncWindowGraph): RuntimeSyncWindowGraphResult => {
|
||||
(event, graph: RuntimeRendererSyncWindowGraph): RuntimeSyncWindowGraphResult => {
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!window) {
|
||||
throw new Error('Runtime graph sync must originate from a BrowserWindow')
|
||||
}
|
||||
if (event.senderFrame !== event.sender.mainFrame) {
|
||||
// Why: a disposed main frame can leave an invoke queued after its
|
||||
// replacement starts. It must not settle the replacement generation.
|
||||
throw new Error('Runtime graph sync must originate from the current main frame')
|
||||
}
|
||||
if (typeof graph.rendererGeneration !== 'string' || graph.rendererGeneration.length === 0) {
|
||||
throw new Error('Runtime graph sync requires a renderer generation')
|
||||
}
|
||||
return runtime.syncWindowGraph(window.id, graph)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
@@ -135,10 +137,40 @@ describe('resolveWorkspaceCleanupActivityWorktree', () => {
|
||||
readTextFile
|
||||
)
|
||||
|
||||
expect(readTextFile).toHaveBeenCalledWith(reflogPath)
|
||||
// Why: only the tail of the reflog is read — the newest entry is enough.
|
||||
expect(readTextFile).toHaveBeenCalledWith(reflogPath, { tailBytes: 8192 })
|
||||
expect(worktree.lastActivityAt).toBe(1_700_000_900_000)
|
||||
})
|
||||
|
||||
it('falls back to a full reflog read when the newest entry exceeds the tail window', async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), 'cleanup-activity-'))
|
||||
try {
|
||||
const gitDir = path.join(dir, 'gitdir')
|
||||
await mkdir(path.join(gitDir, 'logs'), { recursive: true })
|
||||
const worktreePath = path.join(dir, 'wt')
|
||||
await mkdir(worktreePath, { recursive: true })
|
||||
await writeFile(path.join(worktreePath, '.git'), `gitdir: ${gitDir}\n`)
|
||||
// Why: a single record longer than the 8192-byte tail window keeps its
|
||||
// timestamp before the window; the reader must fall back to a full read.
|
||||
await writeFile(
|
||||
path.join(gitDir, 'logs', 'HEAD'),
|
||||
'0000 1111 Dev <dev@example.com> 1700000000 -0700\tbranch: Created from HEAD\n' +
|
||||
`1111 2222 Dev <dev@example.com> 1700000900 -0700\tcommit: ${'x'.repeat(9000)}\n`
|
||||
)
|
||||
const statPath = vi.fn(async () => ({ mtimeMs: 10_000 }))
|
||||
|
||||
const worktree = await resolveWorkspaceCleanupActivityWorktree(
|
||||
REPO,
|
||||
makeWorktree({ path: worktreePath }),
|
||||
statPath
|
||||
)
|
||||
|
||||
expect(worktree.lastActivityAt).toBe(1_700_000_900_000)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('degrades to other probes when the reflog was expired to an empty file', async () => {
|
||||
const gitDirPath = path.join('/repo', '.git', 'worktrees', 'repo-feature')
|
||||
const statPath = vi.fn(async () => ({ mtimeMs: 10_000 }))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lstat, readFile } from 'node:fs/promises'
|
||||
import { lstat, open, readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { Worktree } from '../../shared/worktree/types'
|
||||
@@ -7,7 +7,11 @@ import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import { toWindowsWslPath } from '../wsl'
|
||||
|
||||
type StatPath = (targetPath: string) => Promise<{ mtimeMs: number }>
|
||||
type ReadTextFile = (targetPath: string) => Promise<string>
|
||||
type ReadTextFile = (targetPath: string, options?: { tailBytes?: number }) => Promise<string>
|
||||
|
||||
// Why: only the newest reflog entry matters; a long-lived main worktree's full
|
||||
// reflog can be megabytes, and it was read and split whole per broad scan.
|
||||
const REFLOG_TAIL_BYTES = 8192
|
||||
|
||||
export function resolvePersistedWorkspaceCleanupActivityWorktree(worktree: Worktree): Worktree {
|
||||
const persistedActivityAt = getPersistedWorkspaceCleanupActivityAt(worktree)
|
||||
@@ -17,13 +21,24 @@ export function resolvePersistedWorkspaceCleanupActivityWorktree(worktree: Workt
|
||||
return { ...worktree, lastActivityAt: persistedActivityAt }
|
||||
}
|
||||
|
||||
export type WorkspaceCleanupFsActivityCache = Map<string, Promise<number>>
|
||||
|
||||
export async function resolveWorkspaceCleanupActivityWorktree(
|
||||
repo: Repo,
|
||||
worktree: Worktree,
|
||||
statPath: StatPath = statLocalPath,
|
||||
readTextFile: ReadTextFile = readLocalTextFile
|
||||
readTextFile: ReadTextFile = readLocalTextFile,
|
||||
// Why: folder workspaces surface one row per instance of the same directory;
|
||||
// the filesystem probes are identical per path and must run once per scan.
|
||||
fsActivityCache?: WorkspaceCleanupFsActivityCache
|
||||
): Promise<Worktree> {
|
||||
const activityAt = await resolveWorkspaceCleanupActivityAt(repo, worktree, statPath, readTextFile)
|
||||
const activityAt = await resolveWorkspaceCleanupActivityAt(
|
||||
repo,
|
||||
worktree,
|
||||
statPath,
|
||||
readTextFile,
|
||||
fsActivityCache
|
||||
)
|
||||
if (activityAt <= worktree.lastActivityAt) {
|
||||
return worktree
|
||||
}
|
||||
@@ -35,27 +50,48 @@ async function statLocalPath(targetPath: string): Promise<{ mtimeMs: number }> {
|
||||
return { mtimeMs: Number(stats.mtimeMs) }
|
||||
}
|
||||
|
||||
async function readLocalTextFile(targetPath: string): Promise<string> {
|
||||
return readFile(targetPath, 'utf8')
|
||||
async function readLocalTextFile(
|
||||
targetPath: string,
|
||||
options?: { tailBytes?: number }
|
||||
): Promise<string> {
|
||||
if (options?.tailBytes === undefined) {
|
||||
return readFile(targetPath, 'utf8')
|
||||
}
|
||||
const handle = await open(targetPath, 'r')
|
||||
try {
|
||||
const { size } = await handle.stat()
|
||||
const length = Math.min(options.tailBytes, size)
|
||||
if (length === 0) {
|
||||
return ''
|
||||
}
|
||||
const { buffer, bytesRead } = await handle.read({
|
||||
buffer: Buffer.alloc(length),
|
||||
position: size - length
|
||||
})
|
||||
return buffer.toString('utf8', 0, bytesRead)
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveWorkspaceCleanupActivityAt(
|
||||
repo: Repo,
|
||||
worktree: Worktree,
|
||||
statPath: StatPath,
|
||||
readTextFile: ReadTextFile
|
||||
readTextFile: ReadTextFile,
|
||||
fsActivityCache?: WorkspaceCleanupFsActivityCache
|
||||
): Promise<number> {
|
||||
const persistedActivityAt = getPersistedWorkspaceCleanupActivityAt(worktree)
|
||||
if (repo.connectionId) {
|
||||
return persistedActivityAt
|
||||
}
|
||||
|
||||
const filesystemActivityAt = await getNewestLocalWorktreeActivityAt(
|
||||
worktree.path,
|
||||
statPath,
|
||||
readTextFile
|
||||
)
|
||||
return Math.max(persistedActivityAt, filesystemActivityAt)
|
||||
let filesystemActivity = fsActivityCache?.get(worktree.path)
|
||||
if (!filesystemActivity) {
|
||||
filesystemActivity = getNewestLocalWorktreeActivityAt(worktree.path, statPath, readTextFile)
|
||||
fsActivityCache?.set(worktree.path, filesystemActivity)
|
||||
}
|
||||
return Math.max(persistedActivityAt, await filesystemActivity)
|
||||
}
|
||||
|
||||
// Why: best-effort only. Win32 stat over \\wsl.localhost (9P) can falsely
|
||||
@@ -97,20 +133,33 @@ async function readNewestReflogEntryAt(
|
||||
readTextFile: ReadTextFile
|
||||
): Promise<number> {
|
||||
try {
|
||||
const lines = (await readTextFile(reflogPath)).split('\n')
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
// The trailing timezone + tab anchors the capture, so no digit-count floor is needed.
|
||||
const seconds = /\s(\d{1,11})\s[-+]\d{4}\t/.exec(lines[index] ?? '')?.[1]
|
||||
if (seconds) {
|
||||
return Number(seconds) * 1000
|
||||
}
|
||||
// A tail cut can start mid-line; scanning backward for the last parseable
|
||||
// entry tolerates that.
|
||||
const tail = await readTextFile(reflogPath, { tailBytes: REFLOG_TAIL_BYTES })
|
||||
const newestFromTail = parseNewestReflogEntryAt(tail)
|
||||
if (newestFromTail !== 0 || tail.length === 0) {
|
||||
return newestFromTail
|
||||
}
|
||||
return 0
|
||||
// Why: a single record longer than the tail window keeps its timestamp
|
||||
// before the window starts; fall back to the full read in that rare case.
|
||||
return parseNewestReflogEntryAt(await readTextFile(reflogPath))
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function parseNewestReflogEntryAt(reflog: string): number {
|
||||
const lines = reflog.split('\n')
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
// The trailing timezone + tab anchors the capture, so no digit-count floor is needed.
|
||||
const seconds = /\s(\d{1,11})\s[-+]\d{4}\t/.exec(lines[index] ?? '')?.[1]
|
||||
if (seconds) {
|
||||
return Number(seconds) * 1000
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
async function readLocalWorktreeGitDir(
|
||||
worktreePath: string,
|
||||
gitPath: string,
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import path from 'node:path'
|
||||
import type { Store } from '../persistence'
|
||||
import type * as RepoWorktreesModule from '../repo-worktrees'
|
||||
import type { GitStatusResult } from '../../shared/git-status-types'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { GitWorktreeInfo } from '../../shared/worktree/types'
|
||||
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
|
||||
import { WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT } from '../../shared/workspace-cleanup'
|
||||
|
||||
const {
|
||||
lstatMock,
|
||||
readFileMock,
|
||||
listRepoWorktreesMock,
|
||||
getStatusMock,
|
||||
gitExecFileAsyncMock,
|
||||
getLocalProjectWorktreeGitOptionsMock,
|
||||
getSshGitProviderMock
|
||||
} = vi.hoisted(() => ({
|
||||
lstatMock: vi.fn(),
|
||||
readFileMock: vi.fn(),
|
||||
listRepoWorktreesMock: vi.fn(),
|
||||
getStatusMock: vi.fn(),
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
getLocalProjectWorktreeGitOptionsMock: vi.fn(),
|
||||
getSshGitProviderMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
lstat: lstatMock,
|
||||
readFile: readFileMock
|
||||
}))
|
||||
|
||||
vi.mock('../repo-worktrees', async () => {
|
||||
const actual = await vi.importActual<typeof RepoWorktreesModule>('../repo-worktrees')
|
||||
return {
|
||||
listRepoWorktrees: listRepoWorktreesMock,
|
||||
createFolderWorktree: actual.createFolderWorktree
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../git/status', () => ({
|
||||
getStatus: getStatusMock
|
||||
}))
|
||||
|
||||
vi.mock('../git/runner', () => ({
|
||||
gitExecFileAsync: gitExecFileAsyncMock
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: getSshGitProviderMock
|
||||
}))
|
||||
|
||||
vi.mock('../project-runtime-git-options', () => ({
|
||||
getLocalProjectWorktreeGitOptions: getLocalProjectWorktreeGitOptionsMock
|
||||
}))
|
||||
|
||||
import { scanWorkspaceCleanup } from './workspace-cleanup-scan'
|
||||
|
||||
const NOW = 1_700_000_000_000
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
const REPO: Repo = {
|
||||
id: 'repo-1',
|
||||
path: '/repo',
|
||||
displayName: 'Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: NOW,
|
||||
symlinkPaths: ['node_modules']
|
||||
}
|
||||
const FOLDER_REPO: Repo = {
|
||||
...REPO,
|
||||
id: 'repo-folder',
|
||||
path: '/folder-workspace',
|
||||
displayName: 'Folder',
|
||||
kind: 'folder'
|
||||
}
|
||||
|
||||
const GIT_WORKTREES: GitWorktreeInfo[] = [
|
||||
{
|
||||
path: '/repo',
|
||||
head: 'main123',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
},
|
||||
{
|
||||
path: '/repo-old',
|
||||
head: 'old123',
|
||||
branch: 'refs/heads/old',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
},
|
||||
{
|
||||
path: '/repo-recent',
|
||||
head: 'recent123',
|
||||
branch: 'refs/heads/recent',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
]
|
||||
|
||||
function makeWorktreeMeta(overrides: Partial<WorktreeMeta> = {}): WorktreeMeta {
|
||||
return {
|
||||
displayName: '',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: NOW,
|
||||
baseRef: 'origin/main',
|
||||
...overrides
|
||||
} as WorktreeMeta
|
||||
}
|
||||
|
||||
const META_BY_WORKTREE_ID: Record<string, WorktreeMeta> = {
|
||||
'repo-1::/repo': makeWorktreeMeta({ lastActivityAt: NOW - 40 * DAY_MS }),
|
||||
'repo-1::/repo-old': makeWorktreeMeta({ lastActivityAt: NOW - 40 * DAY_MS }),
|
||||
'repo-1::/repo-recent': makeWorktreeMeta({ lastActivityAt: NOW - 2 * DAY_MS }),
|
||||
'repo-folder::/folder-workspace': makeWorktreeMeta({ lastActivityAt: NOW - 40 * DAY_MS })
|
||||
}
|
||||
|
||||
function makeStore(repos: Repo[] = [REPO], allMeta: Record<string, WorktreeMeta> = {}): Store {
|
||||
return {
|
||||
getRepos: () => repos,
|
||||
getWorktreeMeta: (worktreeId: string) => META_BY_WORKTREE_ID[worktreeId] ?? allMeta[worktreeId],
|
||||
getAllWorktreeMeta: () => allMeta,
|
||||
getGitHubCache: () => ({ pr: {}, issue: {} })
|
||||
} as unknown as Store
|
||||
}
|
||||
|
||||
describe('workspace cleanup broad scan opt-in', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(NOW)
|
||||
lstatMock.mockReset().mockResolvedValue({ mtimeMs: 0 })
|
||||
readFileMock.mockReset().mockRejectedValue(new Error('not a gitdir pointer'))
|
||||
listRepoWorktreesMock.mockReset().mockResolvedValue(GIT_WORKTREES)
|
||||
getStatusMock.mockReset().mockResolvedValue({
|
||||
entries: [],
|
||||
conflictOperation: 'unknown',
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }
|
||||
} satisfies GitStatusResult)
|
||||
gitExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '0\n', stderr: '' })
|
||||
getLocalProjectWorktreeGitOptionsMock.mockReset().mockReturnValue({})
|
||||
getSshGitProviderMock.mockReset().mockReturnValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('emits every worktree when the client opts into the full workspace list', async () => {
|
||||
const result = await scanWorkspaceCleanup(makeStore(), { includeAllWorkspaces: true })
|
||||
|
||||
expect(result.errors).toEqual([])
|
||||
expect(result.candidates.map((candidate) => candidate.worktreeId)).toEqual([
|
||||
'repo-1::/repo',
|
||||
'repo-1::/repo-old',
|
||||
'repo-1::/repo-recent'
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves a worktree runtime host on cleanup candidates', async () => {
|
||||
const runtimeMeta = makeWorktreeMeta({
|
||||
lastActivityAt: NOW - 40 * DAY_MS,
|
||||
hostId: 'runtime:env-1'
|
||||
})
|
||||
const store = {
|
||||
...makeStore(),
|
||||
getWorktreeMeta: (worktreeId: string) =>
|
||||
worktreeId === 'repo-1::/repo-old' ? runtimeMeta : META_BY_WORKTREE_ID[worktreeId]
|
||||
} as Store
|
||||
|
||||
const result = await scanWorkspaceCleanup(store, { includeAllWorkspaces: true })
|
||||
|
||||
expect(
|
||||
result.candidates.find((candidate) => candidate.worktreeId === 'repo-1::/repo-old')
|
||||
?.executionHostId
|
||||
).toBe('runtime:env-1')
|
||||
})
|
||||
|
||||
it('keeps the legacy suggestion-only projection when the flag is absent', async () => {
|
||||
const result = await scanWorkspaceCleanup(makeStore())
|
||||
|
||||
expect(result.candidates.map((candidate) => candidate.worktreeId)).toEqual([
|
||||
'repo-1::/repo-old'
|
||||
])
|
||||
})
|
||||
|
||||
it('reports recent workspaces with no cleanup reasons', async () => {
|
||||
const result = await scanWorkspaceCleanup(makeStore(), { includeAllWorkspaces: true })
|
||||
|
||||
const recent = result.candidates.find(
|
||||
(candidate) => candidate.worktreeId === 'repo-1::/repo-recent'
|
||||
)
|
||||
expect(recent).toMatchObject({
|
||||
reasons: [],
|
||||
tier: 'review',
|
||||
selectedByDefault: false,
|
||||
lastActivityAt: NOW - 2 * DAY_MS
|
||||
})
|
||||
})
|
||||
|
||||
it('reports the main worktree with a main-worktree blocker', async () => {
|
||||
const result = await scanWorkspaceCleanup(makeStore(), { includeAllWorkspaces: true })
|
||||
|
||||
const main = result.candidates.find((candidate) => candidate.worktreeId === 'repo-1::/repo')
|
||||
expect(main).toMatchObject({
|
||||
tier: 'protected',
|
||||
selectedByDefault: false,
|
||||
blockers: ['main-worktree']
|
||||
})
|
||||
})
|
||||
|
||||
it('reports folder workspaces with a folder-repo blocker', async () => {
|
||||
const instanceId = `${FOLDER_REPO.id}::${FOLDER_REPO.path}::workspace:11111111-2222-4333-8444-555555555555`
|
||||
const instanceMeta = makeWorktreeMeta({
|
||||
displayName: 'Folder session',
|
||||
lastActivityAt: NOW - 2 * DAY_MS
|
||||
})
|
||||
const result = await scanWorkspaceCleanup(
|
||||
makeStore([FOLDER_REPO], { [instanceId]: instanceMeta }),
|
||||
{ includeAllWorkspaces: true }
|
||||
)
|
||||
|
||||
expect(result.candidates).toEqual([
|
||||
expect.objectContaining({
|
||||
worktreeId: 'repo-folder::/folder-workspace',
|
||||
tier: 'protected',
|
||||
selectedByDefault: false,
|
||||
blockers: expect.arrayContaining(['folder-repo', 'main-worktree'])
|
||||
}),
|
||||
expect.objectContaining({
|
||||
worktreeId: instanceId,
|
||||
displayName: 'Folder session',
|
||||
path: FOLDER_REPO.path,
|
||||
tier: 'protected',
|
||||
selectedByDefault: false,
|
||||
blockers: ['folder-repo']
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('does not mark connected SSH folder workspaces as disconnected', async () => {
|
||||
const sshFolderRepo: Repo = { ...FOLDER_REPO, connectionId: 'ssh-1' }
|
||||
getSshGitProviderMock.mockReturnValue({})
|
||||
|
||||
const result = await scanWorkspaceCleanup(makeStore([sshFolderRepo]), {
|
||||
includeAllWorkspaces: true
|
||||
})
|
||||
|
||||
expect(result.candidates).toHaveLength(1)
|
||||
expect(result.candidates[0]).toMatchObject({
|
||||
worktreeId: 'repo-folder::/folder-workspace',
|
||||
connectionId: 'ssh-1',
|
||||
blockers: ['main-worktree', 'folder-repo']
|
||||
})
|
||||
})
|
||||
|
||||
it('defers git evidence for rows that no cleanup decision can use', async () => {
|
||||
const result = await scanWorkspaceCleanup(makeStore(), { includeAllWorkspaces: true })
|
||||
|
||||
// Why: only the inactive row can ever be queued, so it is the only row worth
|
||||
// a git read; the rest stream immediately with empty evidence.
|
||||
expect(getStatusMock).toHaveBeenCalledTimes(1)
|
||||
expect(getStatusMock).toHaveBeenCalledWith('/repo-old', expect.anything())
|
||||
expect(
|
||||
result.candidates.find((candidate) => candidate.worktreeId === 'repo-1::/repo-recent')?.git
|
||||
).toEqual({ clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null })
|
||||
expect(
|
||||
result.candidates.find((candidate) => candidate.worktreeId === 'repo-1::/repo-old')?.git
|
||||
).toMatchObject({ clean: true, checkedAt: expect.any(Number) })
|
||||
})
|
||||
|
||||
it('does not read activity files for rows already known to be recent', async () => {
|
||||
const recentWorktrees = ['/repo-recent-a', '/repo-recent-b'].map(
|
||||
(worktreePath, index): GitWorktreeInfo => ({
|
||||
path: worktreePath,
|
||||
head: `recent-${index}`,
|
||||
branch: `refs/heads/recent-${index}`,
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
})
|
||||
)
|
||||
listRepoWorktreesMock.mockResolvedValue(recentWorktrees)
|
||||
const recentMeta = makeWorktreeMeta({ lastActivityAt: NOW - 2 * DAY_MS })
|
||||
const store = {
|
||||
...makeStore(),
|
||||
getWorktreeMeta: () => recentMeta
|
||||
} as unknown as Store
|
||||
|
||||
const result = await scanWorkspaceCleanup(store, { includeAllWorkspaces: true })
|
||||
|
||||
expect(result.candidates).toHaveLength(recentWorktrees.length)
|
||||
expect(readFileMock).not.toHaveBeenCalled()
|
||||
expect(lstatMock).not.toHaveBeenCalled()
|
||||
expect(getStatusMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('batches fleet progress instead of emitting one IPC payload per row', async () => {
|
||||
const worktrees = [
|
||||
GIT_WORKTREES[0],
|
||||
...Array.from(
|
||||
{ length: 200 },
|
||||
(_, index): GitWorktreeInfo => ({
|
||||
path: `/repo-batch-${index}`,
|
||||
head: `head-${index}`,
|
||||
branch: `refs/heads/batch-${index}`,
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
})
|
||||
)
|
||||
]
|
||||
listRepoWorktreesMock.mockResolvedValue(worktrees)
|
||||
const onProgress = vi.fn()
|
||||
|
||||
const result = await scanWorkspaceCleanup(
|
||||
makeStore(),
|
||||
{
|
||||
scanId: 'fleet-scan',
|
||||
includeAllWorkspaces: true,
|
||||
skipGitWorktreeIds: worktrees.map((worktree) => `${REPO.id}::${worktree.path}`)
|
||||
},
|
||||
{ onProgress }
|
||||
)
|
||||
|
||||
expect(onProgress.mock.calls.length).toBeLessThanOrEqual(2)
|
||||
const progress = onProgress.mock.lastCall?.[0]
|
||||
expect(progress).toMatchObject({
|
||||
scannedWorktreeCount: result.candidates.length,
|
||||
totalWorktreeCount: result.candidates.length,
|
||||
candidateMode: 'append'
|
||||
})
|
||||
const streamedCandidates = onProgress.mock.calls.flatMap(([update]) => update.candidates)
|
||||
expect(streamedCandidates.map((candidate) => candidate.worktreeId)).toEqual(
|
||||
result.candidates.map((candidate) => candidate.worktreeId)
|
||||
)
|
||||
})
|
||||
|
||||
it('still forces a git read for a focused scan of a recent workspace', async () => {
|
||||
const result = await scanWorkspaceCleanup(makeStore(), {
|
||||
worktreeId: 'repo-1::/repo-recent',
|
||||
includeAllWorkspaces: true
|
||||
})
|
||||
|
||||
expect(getStatusMock).toHaveBeenCalledTimes(1)
|
||||
expect(lstatMock).toHaveBeenCalledWith('/repo-recent')
|
||||
expect(lstatMock).toHaveBeenCalledWith(path.join('/repo-recent', '.git'))
|
||||
expect(result.candidates).toHaveLength(1)
|
||||
expect(result.candidates[0]).toMatchObject({
|
||||
worktreeId: 'repo-1::/repo-recent',
|
||||
reasons: [],
|
||||
git: { clean: true, checkedAt: expect.any(Number) }
|
||||
})
|
||||
})
|
||||
|
||||
it('lists a repo once for a targeted Git-evidence batch', async () => {
|
||||
const targets = ['repo-1::/repo-recent', 'repo-1::/repo-old']
|
||||
|
||||
const result = await scanWorkspaceCleanup(makeStore(), { worktreeIds: targets })
|
||||
|
||||
expect(listRepoWorktreesMock).toHaveBeenCalledTimes(1)
|
||||
expect(getStatusMock).toHaveBeenCalledTimes(2)
|
||||
expect(lstatMock).not.toHaveBeenCalled()
|
||||
expect(new Set(result.candidates.map((candidate) => candidate.worktreeId))).toEqual(
|
||||
new Set(targets)
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the maximum targeted batch to one repo listing', async () => {
|
||||
const worktrees = Array.from(
|
||||
{ length: WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT + 1 },
|
||||
(_, index): GitWorktreeInfo => ({
|
||||
path: `/repo-evidence-${index}`,
|
||||
head: `head-${index}`,
|
||||
branch: `refs/heads/evidence-${index}`,
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
})
|
||||
)
|
||||
listRepoWorktreesMock.mockResolvedValue(worktrees)
|
||||
|
||||
const result = await scanWorkspaceCleanup(makeStore(), {
|
||||
worktreeIds: worktrees.map((worktree) => `${REPO.id}::${worktree.path}`)
|
||||
})
|
||||
|
||||
expect(result.candidates).toHaveLength(WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT)
|
||||
expect(listRepoWorktreesMock).toHaveBeenCalledTimes(1)
|
||||
expect(getStatusMock).toHaveBeenCalledTimes(WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT)
|
||||
expect(lstatMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lists disconnected SSH workspaces only on an opt-in scan', async () => {
|
||||
const sshRepo: Repo = { ...REPO, id: 'repo-ssh', connectionId: 'ssh-1' }
|
||||
const allMeta: Record<string, WorktreeMeta> = {
|
||||
'repo-ssh::/remote/recent': makeWorktreeMeta({ lastActivityAt: NOW - 2 * DAY_MS })
|
||||
}
|
||||
|
||||
const legacy = await scanWorkspaceCleanup(makeStore([sshRepo], allMeta))
|
||||
const optIn = await scanWorkspaceCleanup(makeStore([sshRepo], allMeta), {
|
||||
includeAllWorkspaces: true
|
||||
})
|
||||
|
||||
expect(legacy.candidates).toEqual([])
|
||||
expect(optIn.candidates).toHaveLength(1)
|
||||
expect(optIn.candidates[0]).toMatchObject({
|
||||
worktreeId: 'repo-ssh::/remote/recent',
|
||||
blockers: ['ssh-disconnected'],
|
||||
reasons: []
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the backing path for disconnected SSH folder instances', async () => {
|
||||
const sshFolderRepo: Repo = { ...FOLDER_REPO, connectionId: 'ssh-1' }
|
||||
const instanceId = `${sshFolderRepo.id}::${sshFolderRepo.path}::workspace:11111111-2222-4333-8444-555555555555`
|
||||
const allMeta = {
|
||||
[instanceId]: makeWorktreeMeta({ displayName: 'Remote folder session' })
|
||||
}
|
||||
|
||||
const result = await scanWorkspaceCleanup(makeStore([sshFolderRepo], allMeta), {
|
||||
includeAllWorkspaces: true
|
||||
})
|
||||
|
||||
expect(result.candidates).toEqual([
|
||||
expect.objectContaining({
|
||||
worktreeId: instanceId,
|
||||
path: sshFolderRepo.path,
|
||||
branch: 'folder-workspace',
|
||||
blockers: ['ssh-disconnected']
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('streams every row through scan progress on an opt-in scan', async () => {
|
||||
const progress: { scannedWorktreeCount: number; totalWorktreeCount: number }[] = []
|
||||
|
||||
await scanWorkspaceCleanup(
|
||||
makeStore(),
|
||||
{ includeAllWorkspaces: true, scanId: 'scan-all' },
|
||||
{ onProgress: (event) => progress.push(event) }
|
||||
)
|
||||
|
||||
expect(progress.at(-1)).toMatchObject({
|
||||
scanId: 'scan-all',
|
||||
scannedWorktreeCount: 3,
|
||||
totalWorktreeCount: 3
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import type { IGitProvider } from '../providers/types'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { Worktree } from '../../shared/worktree/types'
|
||||
import { getWorktreeExecutionHostId } from '../../shared/execution-host'
|
||||
import {
|
||||
applyWorkspaceCleanupPolicy,
|
||||
createWorkspaceCleanupFingerprint,
|
||||
@@ -24,8 +25,9 @@ export async function buildWorkspaceCleanupCandidate(args: {
|
||||
provider: IGitProvider | null
|
||||
skipGit: boolean
|
||||
forceGitCheck: boolean
|
||||
signal?: AbortSignal
|
||||
}): Promise<WorkspaceCleanupCandidate> {
|
||||
const { repo, worktree, scannedAt, provider, skipGit, forceGitCheck } = args
|
||||
const { repo, worktree, scannedAt, provider, skipGit, forceGitCheck, signal } = args
|
||||
const blockers: WorkspaceCleanupBlocker[] = []
|
||||
const reasons = getWorkspaceCleanupInactivityReasonsForWorkspace(worktree, scannedAt)
|
||||
const repoIsFolder = isFolderRepo(repo)
|
||||
@@ -51,7 +53,7 @@ export async function buildWorkspaceCleanupCandidate(args: {
|
||||
|
||||
const gitEvidence = !shouldReadGit
|
||||
? createEmptyWorkspaceCleanupGitEvidence()
|
||||
: await readWorkspaceCleanupGitEvidence(worktree, repo, provider)
|
||||
: await readWorkspaceCleanupGitEvidence(worktree, repo, provider, signal)
|
||||
appendWorkspaceCleanupItems(blockers, gitEvidence.blockers)
|
||||
|
||||
const candidateWithoutFingerprint: WorkspaceCleanupCandidate = {
|
||||
@@ -59,6 +61,7 @@ export async function buildWorkspaceCleanupCandidate(args: {
|
||||
repoId: repo.id,
|
||||
repoName: repo.displayName,
|
||||
connectionId: repo.connectionId ?? null,
|
||||
executionHostId: getWorktreeExecutionHostId(worktree, repo),
|
||||
displayName: worktree.displayName,
|
||||
branch: shortWorkspaceCleanupBranchName(worktree.branch),
|
||||
path: worktree.path,
|
||||
@@ -103,6 +106,7 @@ export function buildWorkspaceCleanupCandidateFromError(
|
||||
repoId: repo.id,
|
||||
repoName: repo.displayName,
|
||||
connectionId: repo.connectionId ?? null,
|
||||
executionHostId: getWorktreeExecutionHostId(worktree, repo),
|
||||
displayName: worktree.displayName,
|
||||
branch: shortWorkspaceCleanupBranchName(worktree.branch),
|
||||
path: worktree.path,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { basename } from 'node:path'
|
||||
import { getRepoExecutionHostId, normalizeExecutionHostId } from '../../shared/execution-host'
|
||||
import type { Store } from '../persistence'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
|
||||
@@ -7,7 +8,7 @@ import {
|
||||
createWorkspaceCleanupFingerprint,
|
||||
type WorkspaceCleanupCandidate
|
||||
} from '../../shared/workspace-cleanup'
|
||||
import { splitWorktreeId } from '../../shared/worktree/id'
|
||||
import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id'
|
||||
import {
|
||||
getNewestWorkspaceCleanupDiffCommentAt,
|
||||
getWorkspaceCleanupInactivityReasonsForWorkspace,
|
||||
@@ -18,17 +19,24 @@ export function synthesizeDisconnectedSshCleanupCandidates(
|
||||
store: Store,
|
||||
repo: Repo,
|
||||
scannedAt: number,
|
||||
targetWorktreeId?: string
|
||||
targetWorktreeIds?: ReadonlySet<string>,
|
||||
includeAllWorkspaces = false
|
||||
): WorkspaceCleanupCandidate[] {
|
||||
const repoWorktreePrefix = `${repo.id}::`
|
||||
if (targetWorktreeId) {
|
||||
if (!targetWorktreeId.startsWith(repoWorktreePrefix)) {
|
||||
return []
|
||||
}
|
||||
// Why: focused delete preflight names one workspace already; walking all
|
||||
if (targetWorktreeIds) {
|
||||
const candidates: WorkspaceCleanupCandidate[] = []
|
||||
// Why: targeted refreshes name their workspaces already; walking all
|
||||
// persisted metadata is unnecessary for disconnected SSH repos.
|
||||
const meta = store.getWorktreeMeta(targetWorktreeId)
|
||||
return meta ? [createDisconnectedSshCandidate(repo, scannedAt, targetWorktreeId, meta)] : []
|
||||
for (const worktreeId of targetWorktreeIds) {
|
||||
if (!worktreeId.startsWith(repoWorktreePrefix)) {
|
||||
continue
|
||||
}
|
||||
const meta = store.getWorktreeMeta(worktreeId)
|
||||
if (meta) {
|
||||
candidates.push(createDisconnectedSshCandidate(repo, scannedAt, worktreeId, meta))
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
const candidates: WorkspaceCleanupCandidate[] = []
|
||||
@@ -38,7 +46,7 @@ export function synthesizeDisconnectedSshCleanupCandidates(
|
||||
continue
|
||||
}
|
||||
const meta = allMeta[worktreeId]
|
||||
if (!meta || !isWorkspaceInactiveForCleanup(meta, scannedAt)) {
|
||||
if (!meta || (!includeAllWorkspaces && !isWorkspaceInactiveForCleanup(meta, scannedAt))) {
|
||||
continue
|
||||
}
|
||||
candidates.push(createDisconnectedSshCandidate(repo, scannedAt, worktreeId, meta))
|
||||
@@ -52,7 +60,7 @@ function createDisconnectedSshCandidate(
|
||||
worktreeId: string,
|
||||
meta: WorktreeMeta
|
||||
): WorkspaceCleanupCandidate {
|
||||
const parsed = splitWorktreeId(worktreeId)
|
||||
const parsed = splitWorktreeIdForFilesystem(worktreeId)
|
||||
const path = parsed?.worktreePath ?? worktreeId
|
||||
const reasons = getWorkspaceCleanupInactivityReasonsForWorkspace(meta, scannedAt)
|
||||
return applyWorkspaceCleanupPolicy({
|
||||
@@ -60,6 +68,7 @@ function createDisconnectedSshCandidate(
|
||||
repoId: repo.id,
|
||||
repoName: repo.displayName,
|
||||
connectionId: repo.connectionId ?? null,
|
||||
executionHostId: normalizeExecutionHostId(meta.hostId) ?? getRepoExecutionHostId(repo),
|
||||
displayName: meta.displayName || basename(path),
|
||||
branch: basename(path),
|
||||
path,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Store } from '../persistence'
|
||||
import { createFolderWorktree } from '../repo-worktrees'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { Worktree } from '../../shared/worktree/types'
|
||||
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree/id'
|
||||
import { mergeWorktree } from './worktree-logic'
|
||||
|
||||
export function listWorkspaceCleanupFolderWorkspaces(store: Store, repo: Repo): Worktree[] {
|
||||
const rootId = `${repo.id}::${repo.path}`
|
||||
const instancePrefix = `${rootId}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`
|
||||
const allMeta = store.getAllWorktreeMeta()
|
||||
const worktreeIds = [
|
||||
rootId,
|
||||
...Object.keys(allMeta).filter((worktreeId) => worktreeId.startsWith(instancePrefix))
|
||||
]
|
||||
const folderWorktree = createFolderWorktree(repo)
|
||||
|
||||
return worktreeIds.map((worktreeId) => ({
|
||||
...mergeWorktree(repo.id, folderWorktree, store.getWorktreeMeta(worktreeId), repo.displayName),
|
||||
id: worktreeId,
|
||||
isMainWorktree: worktreeId === rootId
|
||||
}))
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type { Worktree } from '../../shared/worktree/types'
|
||||
import type { WorkspaceCleanupBlocker } from '../../shared/workspace-cleanup'
|
||||
import {
|
||||
WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS,
|
||||
WorkspaceCleanupScanCancelledError,
|
||||
withWorkspaceCleanupTimeout
|
||||
} from './workspace-cleanup-scan-primitives'
|
||||
import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories'
|
||||
@@ -32,7 +33,8 @@ export function createEmptyWorkspaceCleanupGitEvidence(): WorkspaceCleanupGitEvi
|
||||
export async function readWorkspaceCleanupGitEvidence(
|
||||
worktree: Worktree,
|
||||
repo: Repo,
|
||||
provider: IGitProvider | null
|
||||
provider: IGitProvider | null,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkspaceCleanupGitEvidence> {
|
||||
const blockers: WorkspaceCleanupBlocker[] = []
|
||||
let status: GitStatusResult
|
||||
@@ -49,9 +51,13 @@ export async function readWorkspaceCleanupGitEvidence(
|
||||
...(sharedLinkPaths.length > 0 ? { sharedLinkPaths } : {})
|
||||
}),
|
||||
WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS,
|
||||
'Timed out reading git status.'
|
||||
'Timed out reading git status.',
|
||||
signal
|
||||
)
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceCleanupScanCancelledError) {
|
||||
throw error
|
||||
}
|
||||
return {
|
||||
...createEmptyWorkspaceCleanupGitEvidence(),
|
||||
blockers: ['git-status-error']
|
||||
@@ -76,7 +82,7 @@ export async function readWorkspaceCleanupGitEvidence(
|
||||
blockers.push('unpushed-commits')
|
||||
}
|
||||
if (clean && upstreamAhead === null) {
|
||||
const unpushedCommitCount = await readUnpushedCommitCount(worktree, repo, provider)
|
||||
const unpushedCommitCount = await readUnpushedCommitCount(worktree, repo, provider, signal)
|
||||
if (unpushedCommitCount === null) {
|
||||
blockers.push('unknown-base')
|
||||
} else if (unpushedCommitCount > 0) {
|
||||
@@ -96,7 +102,8 @@ export async function readWorkspaceCleanupGitEvidence(
|
||||
async function readUnpushedCommitCount(
|
||||
worktree: Worktree,
|
||||
repo: Repo,
|
||||
provider: IGitProvider | null
|
||||
provider: IGitProvider | null,
|
||||
signal?: AbortSignal
|
||||
): Promise<number | null> {
|
||||
try {
|
||||
const result = await withWorkspaceCleanupTimeout(
|
||||
@@ -110,11 +117,15 @@ async function readUnpushedCommitCount(
|
||||
signal
|
||||
}),
|
||||
WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS,
|
||||
'Timed out checking unpushed commits.'
|
||||
'Timed out checking unpushed commits.',
|
||||
signal
|
||||
)
|
||||
const count = Number.parseInt(result.stdout.trim(), 10)
|
||||
return Number.isFinite(count) ? count : null
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceCleanupScanCancelledError) {
|
||||
throw error
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ipcMain } from 'electron'
|
||||
import type { Store } from '../persistence'
|
||||
|
||||
const { getSshPtyProviderMock } = vi.hoisted(() => ({
|
||||
getSshPtyProviderMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: vi.fn(),
|
||||
removeHandler: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./pty', () => ({
|
||||
getSshPtyProvider: getSshPtyProviderMock
|
||||
}))
|
||||
|
||||
vi.mock('../memory/pty-registry', () => ({
|
||||
listRegisteredPtys: vi.fn(() => [])
|
||||
}))
|
||||
|
||||
vi.mock('../workspace-cleanup-scan-snapshot', () => ({
|
||||
persistWorkspaceCleanupScanResult: vi.fn(async () => undefined),
|
||||
readWorkspaceCleanupScanSnapshot: vi.fn(async () => null)
|
||||
}))
|
||||
|
||||
vi.mock('../workspace-cleanup-removal-snapshot-prune', () => ({
|
||||
beginWorkspaceCleanupRemovalSnapshotPruneBatch: vi.fn(),
|
||||
finishWorkspaceCleanupRemovalSnapshotPruneBatch: vi.fn(async () => undefined),
|
||||
recordWorkspaceCleanupRemovalSnapshotPrune: vi.fn()
|
||||
}))
|
||||
|
||||
import { registerWorkspaceCleanupHandlers } from './workspace-cleanup'
|
||||
|
||||
function makeEmptyStore(): Store {
|
||||
return {
|
||||
getProfileStorageDirectory: () => '/profile-a',
|
||||
getRepos: () => [],
|
||||
getWorktreeMeta: () => ({}),
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getGitHubCache: () => ({ pr: {}, issue: {} })
|
||||
} as unknown as Store
|
||||
}
|
||||
|
||||
function getPreflightHandler(): ((...args: never[]) => unknown) | undefined {
|
||||
return vi
|
||||
.mocked(ipcMain.handle)
|
||||
.mock.calls.find(([channel]) => channel === 'workspaceCleanup:hasKillableLocalProcesses')?.[1]
|
||||
}
|
||||
|
||||
describe('workspace cleanup process preflight', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(ipcMain.handle).mockReset()
|
||||
getSshPtyProviderMock.mockReset()
|
||||
})
|
||||
|
||||
it('reports local processes that workspace deletion would kill', async () => {
|
||||
const localProvider = {
|
||||
listProcesses: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'repo-1::/repo-feature@@session-1',
|
||||
cwd: '/repo-feature',
|
||||
title: 'zsh'
|
||||
}
|
||||
])
|
||||
}
|
||||
registerWorkspaceCleanupHandlers(makeEmptyStore(), {
|
||||
runtime: {
|
||||
hasTerminalsForWorktree: vi.fn().mockResolvedValue(false)
|
||||
} as never,
|
||||
getLocalPtyProvider: () => localProvider as never
|
||||
})
|
||||
|
||||
await expect(
|
||||
getPreflightHandler()?.({} as never, { worktreeId: 'repo-1::/repo-feature' } as never)
|
||||
).resolves.toEqual({
|
||||
hasKillableProcesses: true
|
||||
})
|
||||
})
|
||||
|
||||
it('reports SSH processes inside the remote workspace path', async () => {
|
||||
getSshPtyProviderMock.mockReturnValue({
|
||||
listProcesses: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'remote-session-1',
|
||||
cwd: '/remote/repo-feature/subdir',
|
||||
title: 'codex'
|
||||
}
|
||||
])
|
||||
})
|
||||
registerWorkspaceCleanupHandlers(makeEmptyStore(), {
|
||||
runtime: {
|
||||
hasTerminalsForWorktree: vi.fn().mockResolvedValue(false)
|
||||
} as never
|
||||
})
|
||||
|
||||
await expect(
|
||||
getPreflightHandler()?.(
|
||||
{} as never,
|
||||
{
|
||||
worktreeId: 'repo-ssh::/remote/repo-feature',
|
||||
connectionId: 'ssh-1',
|
||||
worktreePath: '/remote/repo-feature'
|
||||
} as never
|
||||
)
|
||||
).resolves.toEqual({
|
||||
hasKillableProcesses: true
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import type {
|
||||
WorkspaceCleanupCandidate,
|
||||
WorkspaceCleanupScanError,
|
||||
WorkspaceCleanupScanProgress
|
||||
} from '../../shared/workspace-cleanup'
|
||||
import { appendWorkspaceCleanupItems } from './workspace-cleanup-scan-primitives'
|
||||
|
||||
const WORKSPACE_CLEANUP_PROGRESS_EMIT_INTERVAL_MS = 100
|
||||
|
||||
export type WorkspaceCleanupScanOptions = {
|
||||
onProgress?: (progress: WorkspaceCleanupScanProgress) => void
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export type WorkspaceCleanupProgressEmitter = {
|
||||
addDiscovered: (count: number) => void
|
||||
addCandidate: (candidate: WorkspaceCleanupCandidate) => void
|
||||
addErrors: (errors: WorkspaceCleanupScanError[]) => void
|
||||
flush: () => void
|
||||
}
|
||||
|
||||
/** Caps renderer projection churn while preserving an immediate final progress state. */
|
||||
export function createWorkspaceCleanupProgressEmitter(
|
||||
scanId: string | undefined,
|
||||
scannedAt: number,
|
||||
options: WorkspaceCleanupScanOptions
|
||||
): WorkspaceCleanupProgressEmitter {
|
||||
const errors: WorkspaceCleanupScanError[] = []
|
||||
let pendingCandidates: WorkspaceCleanupCandidate[] = []
|
||||
let totalWorktreeCount = 0
|
||||
let scannedWorktreeCount = 0
|
||||
let dirty = false
|
||||
let emittedInitialDiscovery = false
|
||||
let timer: NodeJS.Timeout | null = null
|
||||
const flush = (): void => {
|
||||
if (!scanId || !dirty) {
|
||||
return
|
||||
}
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
dirty = false
|
||||
const candidates = pendingCandidates
|
||||
pendingCandidates = []
|
||||
options.onProgress?.({
|
||||
scanId,
|
||||
scannedAt,
|
||||
totalWorktreeCount,
|
||||
scannedWorktreeCount,
|
||||
candidates,
|
||||
errors: [...errors],
|
||||
candidateMode: 'append'
|
||||
})
|
||||
}
|
||||
const schedule = (): void => {
|
||||
if (!scanId) {
|
||||
return
|
||||
}
|
||||
dirty = true
|
||||
timer ??= setTimeout(flush, WORKSPACE_CLEANUP_PROGRESS_EMIT_INTERVAL_MS)
|
||||
}
|
||||
return {
|
||||
addDiscovered: (count) => {
|
||||
totalWorktreeCount += count
|
||||
schedule()
|
||||
if (!emittedInitialDiscovery) {
|
||||
emittedInitialDiscovery = true
|
||||
flush()
|
||||
}
|
||||
},
|
||||
addCandidate: (candidate) => {
|
||||
pendingCandidates.push(candidate)
|
||||
scannedWorktreeCount += 1
|
||||
schedule()
|
||||
},
|
||||
addErrors: (newErrors) => {
|
||||
appendWorkspaceCleanupItems(errors, newErrors)
|
||||
schedule()
|
||||
},
|
||||
flush
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Worktree } from '../../shared/worktree/types'
|
||||
import { getPersistedWorkspaceCleanupActivityAt } from '../../shared/workspace-cleanup'
|
||||
import { isWorkspaceInactiveForCleanup } from './workspace-cleanup-candidate'
|
||||
|
||||
/**
|
||||
* Which worktrees a broad (non-targeted) cleanup scan is allowed to emit.
|
||||
*
|
||||
* Why: a client that has not opted into the full workspace list still renders
|
||||
* the suggestion-only views, so the host must keep publishing exactly the rows
|
||||
* it always did — inactive, non-main, non-folder worktrees — or an older client
|
||||
* is flooded with rows it has no view for.
|
||||
*/
|
||||
export function shouldScanBroadWorkspaceCleanupWorktree(args: {
|
||||
includeAllWorkspaces: boolean
|
||||
repoIsFolder: boolean
|
||||
worktree: Worktree
|
||||
scannedAt: number
|
||||
}): boolean {
|
||||
const { includeAllWorkspaces, repoIsFolder, worktree, scannedAt } = args
|
||||
if (includeAllWorkspaces) {
|
||||
return true
|
||||
}
|
||||
if (repoIsFolder || worktree.isMainWorktree) {
|
||||
return false
|
||||
}
|
||||
// Why: persisted stamps only; the filesystem read that refines them is the
|
||||
// expensive part this pre-filter exists to avoid.
|
||||
return isWorkspaceInactiveForCleanup(
|
||||
{
|
||||
isArchived: worktree.isArchived,
|
||||
lastActivityAt: getPersistedWorkspaceCleanupActivityAt(worktree)
|
||||
},
|
||||
scannedAt
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,19 @@ import type { WorkspaceCleanupScanError } from '../../shared/workspace-cleanup'
|
||||
|
||||
export const WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS = 8_000
|
||||
|
||||
export class WorkspaceCleanupScanCancelledError extends Error {
|
||||
constructor() {
|
||||
super('Workspace cleanup scan cancelled')
|
||||
this.name = 'WorkspaceCleanupScanCancelledError'
|
||||
}
|
||||
}
|
||||
|
||||
export function throwIfWorkspaceCleanupScanAborted(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) {
|
||||
throw new WorkspaceCleanupScanCancelledError()
|
||||
}
|
||||
}
|
||||
|
||||
export function appendWorkspaceCleanupItems<T>(target: T[], entries: readonly T[]): void {
|
||||
// Why: cleanup can aggregate generated-size worktree batches; spreading
|
||||
// those batches into push can exceed JavaScript's argument limit.
|
||||
@@ -35,15 +48,25 @@ export async function mapWorkspaceCleanupWithConcurrency<T, R>(
|
||||
export async function withWorkspaceCleanupTimeout<T>(
|
||||
run: (signal: AbortSignal) => Promise<T>,
|
||||
timeoutMs: number,
|
||||
message: string
|
||||
message: string,
|
||||
parentSignal?: AbortSignal
|
||||
): Promise<T> {
|
||||
const controller = new AbortController()
|
||||
let timeoutId: NodeJS.Timeout | undefined
|
||||
let onParentAbort: (() => void) | undefined
|
||||
const timeoutPromise = new Promise<T>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
controller.abort()
|
||||
reject(new Error(message))
|
||||
}, timeoutMs)
|
||||
onParentAbort = () => {
|
||||
controller.abort()
|
||||
reject(new WorkspaceCleanupScanCancelledError())
|
||||
}
|
||||
parentSignal?.addEventListener('abort', onParentAbort, { once: true })
|
||||
if (parentSignal?.aborted) {
|
||||
onParentAbort()
|
||||
}
|
||||
})
|
||||
try {
|
||||
return await Promise.race([run(controller.signal), timeoutPromise])
|
||||
@@ -51,6 +74,9 @@ export async function withWorkspaceCleanupTimeout<T>(
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
if (onParentAbort) {
|
||||
parentSignal?.removeEventListener('abort', onParentAbort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { splitWorktreeId } from '../../shared/worktree/id'
|
||||
import {
|
||||
WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT,
|
||||
type WorkspaceCleanupScanArgs
|
||||
} from '../../shared/workspace-cleanup'
|
||||
|
||||
export function hasTargetedWorkspaceCleanupScan(args: WorkspaceCleanupScanArgs): boolean {
|
||||
return typeof args.worktreeId === 'string' || Array.isArray(args.worktreeIds)
|
||||
}
|
||||
|
||||
export function getTargetWorktreeIdsByRepo(
|
||||
args: WorkspaceCleanupScanArgs
|
||||
): Map<string, Set<string>> {
|
||||
const requestedIds = args.worktreeId
|
||||
? [args.worktreeId]
|
||||
: (args.worktreeIds ?? []).slice(0, WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT)
|
||||
const idsByRepo = new Map<string, Set<string>>()
|
||||
for (const worktreeId of requestedIds) {
|
||||
if (typeof worktreeId !== 'string') {
|
||||
continue
|
||||
}
|
||||
const parsed = splitWorktreeId(worktreeId)
|
||||
if (!parsed) {
|
||||
continue
|
||||
}
|
||||
const repoIds = idsByRepo.get(parsed.repoId) ?? new Set<string>()
|
||||
repoIds.add(worktreeId)
|
||||
idsByRepo.set(parsed.repoId, repoIds)
|
||||
}
|
||||
return idsByRepo
|
||||
}
|
||||
@@ -1,23 +1,24 @@
|
||||
import type { Store } from '../persistence'
|
||||
import { listRepoWorktrees, createFolderWorktree } from '../repo-worktrees'
|
||||
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import type { IGitProvider } from '../providers/types'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types'
|
||||
import { mergeWorktree } from './worktree-logic'
|
||||
import { splitWorktreeId } from '../../shared/worktree/id'
|
||||
import type {
|
||||
WorkspaceCleanupCandidate,
|
||||
WorkspaceCleanupScanError,
|
||||
WorkspaceCleanupScanArgs,
|
||||
WorkspaceCleanupScanProgress,
|
||||
WorkspaceCleanupScanError,
|
||||
WorkspaceCleanupScanResult
|
||||
} from '../../shared/workspace-cleanup'
|
||||
import { getPersistedWorkspaceCleanupActivityAt } from '../../shared/workspace-cleanup'
|
||||
import {
|
||||
handleRepoWorktreeListError,
|
||||
listCleanupGitWorktrees
|
||||
} from './workspace-cleanup-worktree-listing'
|
||||
import { shouldScanBroadWorkspaceCleanupWorktree } from './workspace-cleanup-scan-eligibility'
|
||||
import {
|
||||
resolvePersistedWorkspaceCleanupActivityWorktree,
|
||||
resolveWorkspaceCleanupActivityWorktree
|
||||
resolveWorkspaceCleanupActivityWorktree,
|
||||
type WorkspaceCleanupFsActivityCache
|
||||
} from './workspace-cleanup-activity'
|
||||
import {
|
||||
buildWorkspaceCleanupCandidate,
|
||||
@@ -27,19 +28,26 @@ import {
|
||||
import { synthesizeDisconnectedSshCleanupCandidates } from './workspace-cleanup-disconnected-ssh'
|
||||
import {
|
||||
WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS,
|
||||
WorkspaceCleanupScanCancelledError,
|
||||
appendWorkspaceCleanupItems,
|
||||
createWorkspaceCleanupScanError,
|
||||
mapWorkspaceCleanupWithConcurrency,
|
||||
toSafeWorkspaceCleanupRepoScanError,
|
||||
throwIfWorkspaceCleanupScanAborted,
|
||||
withWorkspaceCleanupTimeout
|
||||
} from './workspace-cleanup-scan-primitives'
|
||||
import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
|
||||
import { listWorkspaceCleanupFolderWorkspaces } from './workspace-cleanup-folder-workspaces'
|
||||
import {
|
||||
createWorkspaceCleanupProgressEmitter,
|
||||
type WorkspaceCleanupScanOptions
|
||||
} from './workspace-cleanup-progress-emitter'
|
||||
import {
|
||||
getTargetWorktreeIdsByRepo,
|
||||
hasTargetedWorkspaceCleanupScan
|
||||
} from './workspace-cleanup-scan-targets'
|
||||
|
||||
const WORKTREE_SCAN_CONCURRENCY = 3
|
||||
|
||||
type WorkspaceCleanupScanOptions = {
|
||||
onProgress?: (progress: WorkspaceCleanupScanProgress) => void
|
||||
}
|
||||
// Why: SSH repos pay a worktree-list round trip each; strictly serial repos
|
||||
// made scan wall-clock the sum of per-repo network latencies.
|
||||
const REPO_SCAN_CONCURRENCY = 2
|
||||
|
||||
type WorkspaceCleanupScanRepoProgress = {
|
||||
onWorktreesDiscovered?: (count: number) => void
|
||||
@@ -47,45 +55,54 @@ type WorkspaceCleanupScanRepoProgress = {
|
||||
onErrors?: (errors: WorkspaceCleanupScanError[]) => void
|
||||
}
|
||||
|
||||
type WorkspaceCleanupProgressEmitter = {
|
||||
addDiscovered: (count: number) => void
|
||||
addCandidate: (candidate: WorkspaceCleanupCandidate) => void
|
||||
addErrors: (errors: WorkspaceCleanupScanError[]) => void
|
||||
}
|
||||
|
||||
export async function scanWorkspaceCleanup(
|
||||
store: Store,
|
||||
args: WorkspaceCleanupScanArgs = {},
|
||||
options: WorkspaceCleanupScanOptions = {}
|
||||
): Promise<WorkspaceCleanupScanResult> {
|
||||
throwIfWorkspaceCleanupScanAborted(options.signal)
|
||||
const scannedAt = Date.now()
|
||||
const parsedTarget = args.worktreeId ? splitWorktreeId(args.worktreeId) : null
|
||||
if (args.worktreeId && !parsedTarget) {
|
||||
const targetWorktreeIdsByRepo = getTargetWorktreeIdsByRepo(args)
|
||||
if (hasTargetedWorkspaceCleanupScan(args) && targetWorktreeIdsByRepo.size === 0) {
|
||||
return { scannedAt, candidates: [], errors: [] }
|
||||
}
|
||||
const repos = parsedTarget
|
||||
? store.getRepos().filter((repo) => repo.id === parsedTarget.repoId)
|
||||
: store.getRepos()
|
||||
const progress = createProgressEmitter(args.scanId, scannedAt, options)
|
||||
const repos =
|
||||
targetWorktreeIdsByRepo.size > 0
|
||||
? store.getRepos().filter((repo) => targetWorktreeIdsByRepo.has(repo.id))
|
||||
: store.getRepos()
|
||||
const progress = createWorkspaceCleanupProgressEmitter(args.scanId, scannedAt, options)
|
||||
const errors: WorkspaceCleanupScanResult['errors'] = []
|
||||
const candidates: WorkspaceCleanupCandidate[] = []
|
||||
|
||||
for (const repo of repos) {
|
||||
const result = await scanRepoWorkspaces({
|
||||
store,
|
||||
repo,
|
||||
scannedAt,
|
||||
targetWorktreeId: args.worktreeId,
|
||||
skipGitWorktreeIds: new Set(args.skipGitWorktreeIds ?? []),
|
||||
onWorktreesDiscovered: progress.addDiscovered,
|
||||
onCandidateScanned: progress.addCandidate,
|
||||
onErrors: progress.addErrors
|
||||
})
|
||||
appendWorkspaceCleanupItems(candidates, result.candidates)
|
||||
appendWorkspaceCleanupItems(errors, result.errors)
|
||||
try {
|
||||
const repoResults = await mapWorkspaceCleanupWithConcurrency(
|
||||
repos,
|
||||
REPO_SCAN_CONCURRENCY,
|
||||
async (repo) => {
|
||||
throwIfWorkspaceCleanupScanAborted(options.signal)
|
||||
return scanRepoWorkspaces({
|
||||
store,
|
||||
repo,
|
||||
scannedAt,
|
||||
targetWorktreeIds: targetWorktreeIdsByRepo.get(repo.id),
|
||||
refreshTargetActivity: args.worktreeId !== undefined || args.refreshActivity === true,
|
||||
includeAllWorkspaces: args.includeAllWorkspaces === true,
|
||||
skipGitWorktreeIds: new Set(args.skipGitWorktreeIds ?? []),
|
||||
signal: options.signal,
|
||||
onWorktreesDiscovered: progress.addDiscovered,
|
||||
onCandidateScanned: progress.addCandidate,
|
||||
onErrors: progress.addErrors
|
||||
})
|
||||
}
|
||||
)
|
||||
for (const result of repoResults) {
|
||||
appendWorkspaceCleanupItems(candidates, result.candidates)
|
||||
appendWorkspaceCleanupItems(errors, result.errors)
|
||||
}
|
||||
return { scannedAt, candidates, errors }
|
||||
} finally {
|
||||
progress.flush()
|
||||
}
|
||||
|
||||
return { scannedAt, candidates, errors }
|
||||
}
|
||||
|
||||
async function scanRepoWorkspaces(
|
||||
@@ -93,16 +110,22 @@ async function scanRepoWorkspaces(
|
||||
store: Store
|
||||
repo: Repo
|
||||
scannedAt: number
|
||||
targetWorktreeId?: string
|
||||
targetWorktreeIds?: ReadonlySet<string>
|
||||
refreshTargetActivity: boolean
|
||||
includeAllWorkspaces: boolean
|
||||
skipGitWorktreeIds: Set<string>
|
||||
signal?: AbortSignal
|
||||
} & WorkspaceCleanupScanRepoProgress
|
||||
): Promise<WorkspaceCleanupScanResult> {
|
||||
const {
|
||||
store,
|
||||
repo,
|
||||
scannedAt,
|
||||
targetWorktreeId,
|
||||
targetWorktreeIds,
|
||||
refreshTargetActivity,
|
||||
includeAllWorkspaces,
|
||||
skipGitWorktreeIds,
|
||||
signal,
|
||||
onWorktreesDiscovered,
|
||||
onCandidateScanned,
|
||||
onErrors
|
||||
@@ -113,17 +136,35 @@ async function scanRepoWorkspaces(
|
||||
let gitWorktrees: GitWorktreeInfo[] = []
|
||||
|
||||
try {
|
||||
const discovered = await listCleanupGitWorktrees(store, repo, repoIsFolder)
|
||||
const discovered = await listCleanupGitWorktrees(store, repo, repoIsFolder, signal)
|
||||
provider = discovered.provider
|
||||
gitWorktrees = discovered.gitWorktrees
|
||||
} catch (error) {
|
||||
return handleRepoWorktreeListError({ repo, targetWorktreeId, scannedAt, error, onErrors })
|
||||
if (error instanceof WorkspaceCleanupScanCancelledError) {
|
||||
throw error
|
||||
}
|
||||
return handleRepoWorktreeListError({
|
||||
repo,
|
||||
targeted: targetWorktreeIds !== undefined,
|
||||
scannedAt,
|
||||
error,
|
||||
onErrors
|
||||
})
|
||||
}
|
||||
|
||||
if (repo.connectionId && !provider) {
|
||||
const candidates = targetWorktreeId
|
||||
? synthesizeDisconnectedSshCleanupCandidates(store, repo, scannedAt, targetWorktreeId)
|
||||
: []
|
||||
// Why: a disconnected host still owns real workspaces; the full list shows
|
||||
// them (blocked), while legacy scans keep omitting what they cannot inspect.
|
||||
const candidates =
|
||||
targetWorktreeIds || includeAllWorkspaces
|
||||
? synthesizeDisconnectedSshCleanupCandidates(
|
||||
store,
|
||||
repo,
|
||||
scannedAt,
|
||||
targetWorktreeIds,
|
||||
includeAllWorkspaces
|
||||
)
|
||||
: []
|
||||
onWorktreesDiscovered?.(candidates.length)
|
||||
for (const candidate of candidates) {
|
||||
onCandidateScanned?.(candidate)
|
||||
@@ -131,43 +172,84 @@ async function scanRepoWorkspaces(
|
||||
return { scannedAt, candidates, errors: [] }
|
||||
}
|
||||
|
||||
const mergedWorktrees = gitWorktrees.map((gitWorktree) => {
|
||||
const worktreeId = `${repo.id}::${gitWorktree.path}`
|
||||
const meta = store.getWorktreeMeta(worktreeId)
|
||||
return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName)
|
||||
})
|
||||
const candidateWorktrees = targetWorktreeId
|
||||
? mergedWorktrees.filter((worktree) => worktree.id === targetWorktreeId)
|
||||
const mergedWorktrees =
|
||||
repoIsFolder && includeAllWorkspaces
|
||||
? listWorkspaceCleanupFolderWorkspaces(store, repo)
|
||||
: gitWorktrees.map((gitWorktree) => {
|
||||
const worktreeId = `${repo.id}::${gitWorktree.path}`
|
||||
const meta = store.getWorktreeMeta(worktreeId)
|
||||
return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName)
|
||||
})
|
||||
// Why: with includeAllWorkspaces the browser shows every workspace and lets
|
||||
// filters narrow it; an age threshold here would hide rows from all views.
|
||||
const candidateWorktrees = targetWorktreeIds
|
||||
? mergedWorktrees.filter((worktree) => targetWorktreeIds.has(worktree.id))
|
||||
: mergedWorktrees.filter((worktree) =>
|
||||
shouldResolveBroadWorkspaceCleanupActivity(repoIsFolder, worktree, scannedAt)
|
||||
shouldScanBroadWorkspaceCleanupWorktree({
|
||||
includeAllWorkspaces,
|
||||
repoIsFolder,
|
||||
worktree,
|
||||
scannedAt
|
||||
})
|
||||
)
|
||||
// Why: with a target list or the full-list browser, every filtered row will
|
||||
// be reported; counting them upfront keeps the progress bar honest instead
|
||||
// of advancing discovered/scanned in lockstep at ~100%.
|
||||
const reportDiscoveredUpfront = targetWorktreeIds !== undefined || includeAllWorkspaces
|
||||
if (reportDiscoveredUpfront && candidateWorktrees.length > 0) {
|
||||
onWorktreesDiscovered?.(candidateWorktrees.length)
|
||||
}
|
||||
// Why: fs stat has no cancellation, so on a hung network/WSL mount every
|
||||
// timed-out row would abandon more threadpool work. After the first timeout,
|
||||
// stop statting this repo and use persisted activity only.
|
||||
let activityStatsUnavailable = false
|
||||
const fsActivityCache: WorkspaceCleanupFsActivityCache = new Map()
|
||||
const candidatesWithSkipped = await mapWorkspaceCleanupWithConcurrency(
|
||||
candidateWorktrees,
|
||||
WORKTREE_SCAN_CONCURRENCY,
|
||||
async (worktree) => {
|
||||
throwIfWorkspaceCleanupScanAborted(signal)
|
||||
// Why: externally-created worktrees can miss Orca activity stamps; local
|
||||
// filesystem metadata is a conservative guard before suggesting deletion.
|
||||
const worktreeWithActivity = activityStatsUnavailable
|
||||
? resolvePersistedWorkspaceCleanupActivityWorktree(worktree)
|
||||
: await resolveCleanupActivityWithTimeout(repo, worktree, () => {
|
||||
activityStatsUnavailable = true
|
||||
})
|
||||
if (!targetWorktreeId && !isWorkspaceInactiveForCleanup(worktreeWithActivity, scannedAt)) {
|
||||
const persistedActivityWorktree = resolvePersistedWorkspaceCleanupActivityWorktree(worktree)
|
||||
const persistedActivityIsRecent = !isWorkspaceInactiveForCleanup(
|
||||
persistedActivityWorktree,
|
||||
scannedAt
|
||||
)
|
||||
const worktreeWithActivity =
|
||||
activityStatsUnavailable ||
|
||||
(targetWorktreeIds ? !refreshTargetActivity : persistedActivityIsRecent)
|
||||
? persistedActivityWorktree
|
||||
: await resolveCleanupActivityWithTimeout(
|
||||
repo,
|
||||
worktree,
|
||||
() => {
|
||||
activityStatsUnavailable = true
|
||||
},
|
||||
signal,
|
||||
fsActivityCache
|
||||
)
|
||||
const isInactive = isWorkspaceInactiveForCleanup(worktreeWithActivity, scannedAt)
|
||||
if (!targetWorktreeIds && !includeAllWorkspaces && !isInactive) {
|
||||
return null
|
||||
}
|
||||
onWorktreesDiscovered?.(1)
|
||||
if (!reportDiscoveredUpfront) {
|
||||
onWorktreesDiscovered?.(1)
|
||||
}
|
||||
const candidate = await buildWorkspaceCleanupCandidate({
|
||||
repo,
|
||||
worktree: worktreeWithActivity,
|
||||
scannedAt,
|
||||
provider,
|
||||
skipGit: skipGitWorktreeIds.has(worktreeWithActivity.id),
|
||||
forceGitCheck: Boolean(targetWorktreeId)
|
||||
// Why: a row with no inactivity reason can never be queued or selected,
|
||||
// so full-fleet scans stream it now and let a focused scan read git later.
|
||||
skipGit: skipGitWorktreeIds.has(worktreeWithActivity.id) || !isInactive,
|
||||
forceGitCheck: Boolean(targetWorktreeIds),
|
||||
signal
|
||||
}).catch((error) => {
|
||||
if (error instanceof WorkspaceCleanupScanCancelledError) {
|
||||
throw error
|
||||
}
|
||||
console.error('Workspace cleanup candidate scan failed', error)
|
||||
return buildWorkspaceCleanupCandidateFromError(repo, worktreeWithActivity, scannedAt)
|
||||
})
|
||||
@@ -185,128 +267,30 @@ async function scanRepoWorkspaces(
|
||||
async function resolveCleanupActivityWithTimeout(
|
||||
repo: Repo,
|
||||
worktree: Worktree,
|
||||
onActivityStatsUnavailable: () => void
|
||||
onActivityStatsUnavailable: () => void,
|
||||
signal?: AbortSignal,
|
||||
fsActivityCache?: WorkspaceCleanupFsActivityCache
|
||||
): Promise<Worktree> {
|
||||
try {
|
||||
return await withWorkspaceCleanupTimeout(
|
||||
() => resolveWorkspaceCleanupActivityWorktree(repo, worktree),
|
||||
() =>
|
||||
resolveWorkspaceCleanupActivityWorktree(
|
||||
repo,
|
||||
worktree,
|
||||
undefined,
|
||||
undefined,
|
||||
fsActivityCache
|
||||
),
|
||||
WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS,
|
||||
'Timed out reading worktree activity.'
|
||||
'Timed out reading worktree activity.',
|
||||
signal
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceCleanupScanCancelledError) {
|
||||
throw error
|
||||
}
|
||||
onActivityStatsUnavailable()
|
||||
console.warn('Workspace cleanup activity scan failed', error)
|
||||
return resolvePersistedWorkspaceCleanupActivityWorktree(worktree)
|
||||
}
|
||||
}
|
||||
|
||||
function shouldResolveBroadWorkspaceCleanupActivity(
|
||||
repoIsFolder: boolean,
|
||||
worktree: Worktree,
|
||||
scannedAt: number
|
||||
): boolean {
|
||||
if (repoIsFolder || worktree.isMainWorktree) {
|
||||
return false
|
||||
}
|
||||
return isWorkspaceInactiveForCleanup(
|
||||
{
|
||||
isArchived: worktree.isArchived,
|
||||
lastActivityAt: getPersistedWorkspaceCleanupActivityAt(worktree)
|
||||
},
|
||||
scannedAt
|
||||
)
|
||||
}
|
||||
|
||||
async function listCleanupGitWorktrees(
|
||||
store: Store,
|
||||
repo: Repo,
|
||||
repoIsFolder: boolean
|
||||
): Promise<{ provider: IGitProvider | null; gitWorktrees: GitWorktreeInfo[] }> {
|
||||
if (repoIsFolder) {
|
||||
return { provider: null, gitWorktrees: [createFolderWorktree(repo)] }
|
||||
}
|
||||
if (repo.connectionId) {
|
||||
const provider = getSshGitProvider(repo.connectionId) ?? null
|
||||
if (!provider) {
|
||||
// Why: cleanup should reflect only workspaces Orca can currently inspect.
|
||||
return { provider: null, gitWorktrees: [] }
|
||||
}
|
||||
return {
|
||||
provider,
|
||||
gitWorktrees: await withWorkspaceCleanupTimeout(
|
||||
(signal) => provider.listWorktrees(repo.path, { signal }),
|
||||
WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS,
|
||||
'Timed out listing SSH worktrees.'
|
||||
)
|
||||
}
|
||||
}
|
||||
const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo)
|
||||
return {
|
||||
provider: null,
|
||||
gitWorktrees: await withWorkspaceCleanupTimeout(
|
||||
(signal) => listRepoWorktrees(repo, { ...localGitOptions, signal }),
|
||||
WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS,
|
||||
'Timed out listing worktrees.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function handleRepoWorktreeListError(args: {
|
||||
repo: Repo
|
||||
targetWorktreeId?: string
|
||||
scannedAt: number
|
||||
error: unknown
|
||||
onErrors?: (errors: WorkspaceCleanupScanError[]) => void
|
||||
}): WorkspaceCleanupScanResult {
|
||||
const { repo, targetWorktreeId, scannedAt, error, onErrors } = args
|
||||
console.error('Workspace cleanup repo scan failed', error)
|
||||
if (repo.connectionId && !targetWorktreeId) {
|
||||
// Why: broad cleanup only shows remote workspaces Orca can inspect now.
|
||||
// A connected SSH repo that fails mid-scan is omitted, not bannered.
|
||||
return { scannedAt, candidates: [], errors: [] }
|
||||
}
|
||||
const errors = [createWorkspaceCleanupScanError(repo, toSafeWorkspaceCleanupRepoScanError(error))]
|
||||
onErrors?.(errors)
|
||||
return { scannedAt, candidates: [], errors }
|
||||
}
|
||||
|
||||
function createProgressEmitter(
|
||||
scanId: string | undefined,
|
||||
scannedAt: number,
|
||||
options: WorkspaceCleanupScanOptions
|
||||
): WorkspaceCleanupProgressEmitter {
|
||||
const errors: WorkspaceCleanupScanError[] = []
|
||||
let totalWorktreeCount = 0
|
||||
let scannedWorktreeCount = 0
|
||||
const emit = (
|
||||
candidates: WorkspaceCleanupCandidate[],
|
||||
candidateMode: WorkspaceCleanupScanProgress['candidateMode'] = 'snapshot'
|
||||
): void => {
|
||||
if (!scanId) {
|
||||
return
|
||||
}
|
||||
options.onProgress?.({
|
||||
scanId,
|
||||
scannedAt,
|
||||
totalWorktreeCount,
|
||||
scannedWorktreeCount,
|
||||
candidates,
|
||||
errors: [...errors],
|
||||
candidateMode
|
||||
})
|
||||
}
|
||||
return {
|
||||
addDiscovered: (count) => {
|
||||
totalWorktreeCount += count
|
||||
emit([], 'append')
|
||||
},
|
||||
addCandidate: (candidate) => {
|
||||
scannedWorktreeCount += 1
|
||||
emit([candidate], 'append')
|
||||
},
|
||||
addErrors: (newErrors) => {
|
||||
appendWorkspaceCleanupItems(errors, newErrors)
|
||||
emit([], 'append')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ipcMain } from 'electron'
|
||||
import type { Store } from '../persistence'
|
||||
|
||||
const { persistScanResultMock, readScanSnapshotMock, scanWorkspaceCleanupMock } = vi.hoisted(
|
||||
() => ({
|
||||
persistScanResultMock: vi.fn(),
|
||||
readScanSnapshotMock: vi.fn(),
|
||||
scanWorkspaceCleanupMock: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
const { beginPruneBatchMock, finishPruneBatchMock, recordPruneMock } = vi.hoisted(() => ({
|
||||
beginPruneBatchMock: vi.fn(),
|
||||
finishPruneBatchMock: vi.fn().mockResolvedValue(undefined),
|
||||
recordPruneMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: vi.fn(),
|
||||
removeHandler: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../workspace-cleanup-scan-snapshot', () => ({
|
||||
persistWorkspaceCleanupScanResult: persistScanResultMock,
|
||||
readWorkspaceCleanupScanSnapshot: readScanSnapshotMock
|
||||
}))
|
||||
|
||||
vi.mock('./workspace-cleanup-scan', () => ({
|
||||
scanWorkspaceCleanup: scanWorkspaceCleanupMock
|
||||
}))
|
||||
|
||||
vi.mock('../workspace-cleanup-removal-snapshot-prune', () => ({
|
||||
beginWorkspaceCleanupRemovalSnapshotPruneBatch: beginPruneBatchMock,
|
||||
finishWorkspaceCleanupRemovalSnapshotPruneBatch: finishPruneBatchMock,
|
||||
recordWorkspaceCleanupRemovalSnapshotPrune: recordPruneMock
|
||||
}))
|
||||
|
||||
import { registerWorkspaceCleanupHandlers } from './workspace-cleanup'
|
||||
|
||||
const NOW = 1_700_000_000_000
|
||||
|
||||
function makeScanEvent(senderOverrides: Record<string, unknown> = {}): never {
|
||||
return {
|
||||
sender: {
|
||||
id: 1,
|
||||
isDestroyed: () => false,
|
||||
send: vi.fn(),
|
||||
once: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
...senderOverrides
|
||||
}
|
||||
} as never
|
||||
}
|
||||
|
||||
function makeEmptyStore(): Store {
|
||||
return {
|
||||
getProfileStorageDirectory: () => '/profile-a',
|
||||
getRepos: () => [],
|
||||
getWorktreeMeta: () => ({}),
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getGitHubCache: () => ({ pr: {}, issue: {} })
|
||||
} as unknown as Store
|
||||
}
|
||||
|
||||
describe('workspace cleanup snapshot IPC', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(ipcMain.handle).mockClear()
|
||||
persistScanResultMock.mockReset().mockResolvedValue(undefined)
|
||||
readScanSnapshotMock.mockReset()
|
||||
scanWorkspaceCleanupMock.mockReset().mockImplementation(async (_store, args, options) => {
|
||||
const result = { scannedAt: NOW, candidates: [], errors: [] }
|
||||
options.onProgress?.({
|
||||
scanId: args.scanId,
|
||||
scannedAt: NOW,
|
||||
scannedWorktreeCount: 0,
|
||||
totalWorktreeCount: 0,
|
||||
candidates: [],
|
||||
errors: []
|
||||
})
|
||||
return result
|
||||
})
|
||||
beginPruneBatchMock.mockReset()
|
||||
finishPruneBatchMock.mockReset().mockResolvedValue(undefined)
|
||||
recordPruneMock.mockReset()
|
||||
})
|
||||
|
||||
it('persists the completed scan result after replying', async () => {
|
||||
registerWorkspaceCleanupHandlers(makeEmptyStore())
|
||||
const handler = vi
|
||||
.mocked(ipcMain.handle)
|
||||
.mock.calls.find(([channel]) => channel === 'workspaceCleanup:scan')?.[1]
|
||||
|
||||
const args = { includeAllWorkspaces: true }
|
||||
const result = await handler?.(makeScanEvent(), args)
|
||||
|
||||
expect(persistScanResultMock).toHaveBeenCalledWith('/profile-a', args, result)
|
||||
})
|
||||
|
||||
it('does not rewrite the fleet snapshot for a focused scan', async () => {
|
||||
registerWorkspaceCleanupHandlers(makeEmptyStore())
|
||||
const handler = vi
|
||||
.mocked(ipcMain.handle)
|
||||
.mock.calls.find(([channel]) => channel === 'workspaceCleanup:scan')?.[1]
|
||||
|
||||
await handler?.(makeScanEvent(), { worktreeId: 'repo-1::/repo-feature' })
|
||||
|
||||
expect(persistScanResultMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not rewrite the fleet snapshot for a targeted evidence batch', async () => {
|
||||
registerWorkspaceCleanupHandlers(makeEmptyStore())
|
||||
const handler = vi
|
||||
.mocked(ipcMain.handle)
|
||||
.mock.calls.find(([channel]) => channel === 'workspaceCleanup:scan')?.[1]
|
||||
|
||||
await handler?.(makeScanEvent(), {
|
||||
worktreeIds: ['repo-1::/repo-a', 'repo-1::/repo-b']
|
||||
})
|
||||
|
||||
expect(persistScanResultMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not persist an empty-target scan over the fleet snapshot', async () => {
|
||||
registerWorkspaceCleanupHandlers(makeEmptyStore())
|
||||
const handler = vi
|
||||
.mocked(ipcMain.handle)
|
||||
.mock.calls.find(([channel]) => channel === 'workspaceCleanup:scan')?.[1]
|
||||
|
||||
// Why: worktreeIds: [] is a targeted scan that returns nothing; persisting
|
||||
// it as broad would wipe the fleet cache.
|
||||
await handler?.(makeScanEvent(), { worktreeIds: [], includeAllWorkspaces: true })
|
||||
|
||||
expect(persistScanResultMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops streaming progress after the invoking renderer is destroyed', async () => {
|
||||
registerWorkspaceCleanupHandlers(makeEmptyStore())
|
||||
const handler = vi
|
||||
.mocked(ipcMain.handle)
|
||||
.mock.calls.find(([channel]) => channel === 'workspaceCleanup:scan')?.[1]
|
||||
const send = vi.fn(() => {
|
||||
throw new Error('Object has been destroyed')
|
||||
})
|
||||
|
||||
await expect(
|
||||
handler?.(makeScanEvent({ isDestroyed: () => true, send }), { scanId: 'scan-1' })
|
||||
).resolves.toEqual({ scannedAt: NOW, candidates: [], errors: [] })
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('aborts the scan when the invoking renderer is destroyed mid-flight', async () => {
|
||||
let abortListener: (() => void) | undefined
|
||||
scanWorkspaceCleanupMock.mockImplementation((_store, _args, options) => {
|
||||
return new Promise((_resolve, reject) => {
|
||||
options.signal?.addEventListener('abort', () => reject(new Error('cancelled')), {
|
||||
once: true
|
||||
})
|
||||
})
|
||||
})
|
||||
registerWorkspaceCleanupHandlers(makeEmptyStore())
|
||||
const handler = vi
|
||||
.mocked(ipcMain.handle)
|
||||
.mock.calls.find(([channel]) => channel === 'workspaceCleanup:scan')?.[1]
|
||||
const event = makeScanEvent({
|
||||
once: vi.fn((eventName: string, listener: () => void) => {
|
||||
if (eventName === 'destroyed') {
|
||||
abortListener = listener
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const scan = handler?.(event, { includeAllWorkspaces: true })
|
||||
abortListener?.()
|
||||
await expect(scan).rejects.toThrow('cancelled')
|
||||
})
|
||||
|
||||
it('keeps legacy suggestion-only and full broad scans isolated across modes', async () => {
|
||||
scanWorkspaceCleanupMock.mockImplementation((_store, _args, options) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
options.signal?.addEventListener('abort', () => reject(new Error('cancelled')), {
|
||||
once: true
|
||||
})
|
||||
setTimeout(() => resolve({ scannedAt: NOW, candidates: [], errors: [] }), 5)
|
||||
})
|
||||
})
|
||||
registerWorkspaceCleanupHandlers(makeEmptyStore())
|
||||
const handler = vi
|
||||
.mocked(ipcMain.handle)
|
||||
.mock.calls.find(([channel]) => channel === 'workspaceCleanup:scan')?.[1]
|
||||
|
||||
// Why: different includeAllWorkspaces modes are separate lanes; one must
|
||||
// not abort the other even from the same renderer.
|
||||
const [legacy, full] = await Promise.allSettled([
|
||||
handler?.(makeScanEvent({ id: 7 }), { includeAllWorkspaces: false }) as Promise<unknown>,
|
||||
handler?.(makeScanEvent({ id: 7 }), { includeAllWorkspaces: true }) as Promise<unknown>
|
||||
])
|
||||
|
||||
expect(legacy?.status).toBe('fulfilled')
|
||||
expect(full?.status).toBe('fulfilled')
|
||||
})
|
||||
|
||||
it('supersedes a concurrent broad scan from the same renderer', async () => {
|
||||
const settlements: Promise<unknown>[] = []
|
||||
scanWorkspaceCleanupMock.mockImplementation((_store, _args, options) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
options.signal?.addEventListener('abort', () => reject(new Error('cancelled')), {
|
||||
once: true
|
||||
})
|
||||
setTimeout(() => resolve({ scannedAt: NOW, candidates: [], errors: [] }), 5)
|
||||
})
|
||||
})
|
||||
registerWorkspaceCleanupHandlers(makeEmptyStore())
|
||||
const handler = vi
|
||||
.mocked(ipcMain.handle)
|
||||
.mock.calls.find(([channel]) => channel === 'workspaceCleanup:scan')?.[1]
|
||||
|
||||
settlements.push(
|
||||
handler?.(makeScanEvent({ id: 7 }), { includeAllWorkspaces: true }) as Promise<unknown>,
|
||||
handler?.(makeScanEvent({ id: 7 }), { includeAllWorkspaces: true }) as Promise<unknown>
|
||||
)
|
||||
const [first, second] = await Promise.allSettled(settlements)
|
||||
|
||||
expect(first?.status).toBe('rejected')
|
||||
expect(second?.status).toBe('fulfilled')
|
||||
})
|
||||
|
||||
it('cancels only the invoking renderer scan id', async () => {
|
||||
scanWorkspaceCleanupMock.mockImplementation((_store, _args, options) => {
|
||||
return new Promise((_resolve, reject) => {
|
||||
options.signal?.addEventListener('abort', () => reject(new Error('cancelled')), {
|
||||
once: true
|
||||
})
|
||||
})
|
||||
})
|
||||
registerWorkspaceCleanupHandlers(makeEmptyStore())
|
||||
const handlers = Object.fromEntries(vi.mocked(ipcMain.handle).mock.calls)
|
||||
const event = makeScanEvent({ id: 7 })
|
||||
|
||||
const scan = handlers['workspaceCleanup:scan']?.(event, { scanId: 'scan-1' })
|
||||
expect(handlers['workspaceCleanup:cancelScan']?.(event, 'scan-1')).toBe(true)
|
||||
await expect(scan).rejects.toThrow('cancelled')
|
||||
expect(
|
||||
handlers['workspaceCleanup:cancelScan']?.({ sender: { id: 8 } } as never, 'scan-1')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('serves the cached scan snapshot through getCachedScan', async () => {
|
||||
const snapshot = { scannedAt: NOW, candidates: [], errors: [] }
|
||||
readScanSnapshotMock.mockResolvedValue(snapshot)
|
||||
registerWorkspaceCleanupHandlers(makeEmptyStore())
|
||||
const handler = vi
|
||||
.mocked(ipcMain.handle)
|
||||
.mock.calls.find(([channel]) => channel === 'workspaceCleanup:getCachedScan')?.[1]
|
||||
|
||||
await expect(handler?.({} as never)).resolves.toBe(snapshot)
|
||||
expect(readScanSnapshotMock).toHaveBeenCalledWith('/profile-a')
|
||||
expect(vi.mocked(ipcMain.removeHandler)).toHaveBeenCalledWith('workspaceCleanup:getCachedScan')
|
||||
})
|
||||
|
||||
it('routes a validated removal snapshot prune batch through its explicit boundary', async () => {
|
||||
registerWorkspaceCleanupHandlers(makeEmptyStore())
|
||||
const handlers = Object.fromEntries(vi.mocked(ipcMain.handle).mock.calls)
|
||||
const batch = { batchId: 'batch-1' }
|
||||
const target = {
|
||||
...batch,
|
||||
worktreeId: 'repo-ssh::/remote/feature',
|
||||
executionHostId: 'ssh:ssh-1'
|
||||
}
|
||||
|
||||
await handlers['workspaceCleanup:beginRemovalSnapshotPruneBatch']?.({} as never, batch)
|
||||
await handlers['workspaceCleanup:recordRemovalSnapshotPrune']?.({} as never, target)
|
||||
await handlers['workspaceCleanup:finishRemovalSnapshotPruneBatch']?.({} as never, batch)
|
||||
|
||||
expect(beginPruneBatchMock).toHaveBeenCalledExactlyOnceWith('/profile-a', batch)
|
||||
expect(recordPruneMock).toHaveBeenCalledExactlyOnceWith('/profile-a', target)
|
||||
expect(finishPruneBatchMock).toHaveBeenCalledExactlyOnceWith('/profile-a', batch)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Store } from '../persistence'
|
||||
import { listRepoWorktrees, createFolderWorktree } from '../repo-worktrees'
|
||||
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import type { IGitProvider } from '../providers/types'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { GitWorktreeInfo } from '../../shared/worktree/types'
|
||||
import type {
|
||||
WorkspaceCleanupScanError,
|
||||
WorkspaceCleanupScanResult
|
||||
} from '../../shared/workspace-cleanup'
|
||||
import {
|
||||
WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS,
|
||||
createWorkspaceCleanupScanError,
|
||||
toSafeWorkspaceCleanupRepoScanError,
|
||||
withWorkspaceCleanupTimeout
|
||||
} from './workspace-cleanup-scan-primitives'
|
||||
import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
|
||||
|
||||
export async function listCleanupGitWorktrees(
|
||||
store: Store,
|
||||
repo: Repo,
|
||||
repoIsFolder: boolean,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ provider: IGitProvider | null; gitWorktrees: GitWorktreeInfo[] }> {
|
||||
if (repoIsFolder) {
|
||||
return {
|
||||
provider: repo.connectionId ? (getSshGitProvider(repo.connectionId) ?? null) : null,
|
||||
gitWorktrees: [createFolderWorktree(repo)]
|
||||
}
|
||||
}
|
||||
if (repo.connectionId) {
|
||||
const provider = getSshGitProvider(repo.connectionId) ?? null
|
||||
if (!provider) {
|
||||
// Why: cleanup should reflect only workspaces Orca can currently inspect.
|
||||
return { provider: null, gitWorktrees: [] }
|
||||
}
|
||||
return {
|
||||
provider,
|
||||
gitWorktrees: await withWorkspaceCleanupTimeout(
|
||||
(signal) => provider.listWorktrees(repo.path, { signal }),
|
||||
WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS,
|
||||
'Timed out listing SSH worktrees.',
|
||||
signal
|
||||
)
|
||||
}
|
||||
}
|
||||
const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo)
|
||||
return {
|
||||
provider: null,
|
||||
gitWorktrees: await withWorkspaceCleanupTimeout(
|
||||
(signal) => listRepoWorktrees(repo, { ...localGitOptions, signal }),
|
||||
WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS,
|
||||
'Timed out listing worktrees.',
|
||||
signal
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function handleRepoWorktreeListError(args: {
|
||||
repo: Repo
|
||||
targeted: boolean
|
||||
scannedAt: number
|
||||
error: unknown
|
||||
onErrors?: (errors: WorkspaceCleanupScanError[]) => void
|
||||
}): WorkspaceCleanupScanResult {
|
||||
const { repo, targeted, scannedAt, error, onErrors } = args
|
||||
console.error('Workspace cleanup repo scan failed', error)
|
||||
if (repo.connectionId && !targeted) {
|
||||
// Why: broad cleanup only shows remote workspaces Orca can inspect now.
|
||||
// A connected SSH repo that fails mid-scan is omitted, not bannered.
|
||||
return { scannedAt, candidates: [], errors: [] }
|
||||
}
|
||||
const errors = [createWorkspaceCleanupScanError(repo, toSafeWorkspaceCleanupRepoScanError(error))]
|
||||
onErrors?.(errors)
|
||||
return { scannedAt, candidates: [], errors }
|
||||
}
|
||||
@@ -17,7 +17,6 @@ const {
|
||||
gitExecFileAsyncMock,
|
||||
getLocalProjectWorktreeGitOptionsMock,
|
||||
getSshGitProviderMock,
|
||||
getSshPtyProviderMock,
|
||||
listRegisteredPtysMock
|
||||
} = vi.hoisted(() => ({
|
||||
lstatMock: vi.fn(),
|
||||
@@ -27,7 +26,6 @@ const {
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
getLocalProjectWorktreeGitOptionsMock: vi.fn(),
|
||||
getSshGitProviderMock: vi.fn(),
|
||||
getSshPtyProviderMock: vi.fn(),
|
||||
listRegisteredPtysMock: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -69,10 +67,16 @@ vi.mock('../memory/pty-registry', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('./pty', () => ({
|
||||
getSshPtyProvider: getSshPtyProviderMock
|
||||
getSshPtyProvider: vi.fn()
|
||||
}))
|
||||
|
||||
import { registerWorkspaceCleanupHandlers, scanWorkspaceCleanup } from './workspace-cleanup'
|
||||
// Why: snapshot persistence does real file I/O; covered in workspace-cleanup-snapshot-ipc.test.ts.
|
||||
vi.mock('../workspace-cleanup-scan-snapshot', () => ({
|
||||
persistWorkspaceCleanupScanResult: vi.fn(async () => undefined),
|
||||
readWorkspaceCleanupScanSnapshot: vi.fn(async () => null)
|
||||
}))
|
||||
|
||||
import { scanWorkspaceCleanup } from './workspace-cleanup'
|
||||
|
||||
const NOW = 1_700_000_000_000
|
||||
const REPO: Repo = {
|
||||
@@ -161,7 +165,6 @@ describe('workspace cleanup scan', () => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
getLocalProjectWorktreeGitOptionsMock.mockReset().mockReturnValue({})
|
||||
getSshGitProviderMock.mockReset()
|
||||
getSshPtyProviderMock.mockReset()
|
||||
listRegisteredPtysMock.mockReset()
|
||||
listRegisteredPtysMock.mockReturnValue([])
|
||||
lstatMock.mockResolvedValue({ mtimeMs: 0 })
|
||||
@@ -246,11 +249,11 @@ describe('workspace cleanup scan', () => {
|
||||
(event as WorkspaceCleanupScanProgress).candidateMode === 'append' &&
|
||||
(event as WorkspaceCleanupScanProgress).candidates.length > 0
|
||||
)
|
||||
expect(candidateProgress).toHaveLength(2)
|
||||
expect(candidateProgress.every((event) => event.candidates.length === 1)).toBe(true)
|
||||
expect(candidateProgress.length).toBeLessThanOrEqual(2)
|
||||
const progressWorktreeIds = candidateProgress.flatMap((event) =>
|
||||
event.candidates.map((candidate) => candidate.worktreeId)
|
||||
)
|
||||
expect(progressWorktreeIds).toHaveLength(2)
|
||||
expect(progressWorktreeIds).toEqual(
|
||||
expect.arrayContaining(['repo-1::/repo-feature-a', 'repo-1::/repo-feature-b'])
|
||||
)
|
||||
@@ -829,61 +832,4 @@ describe('workspace cleanup scan', () => {
|
||||
})
|
||||
expect(result.candidates[0]).not.toHaveProperty('linkedPR')
|
||||
})
|
||||
|
||||
it('reports local processes that workspace deletion would kill', async () => {
|
||||
const localProvider = {
|
||||
listProcesses: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'repo-1::/repo-feature@@session-1',
|
||||
cwd: '/repo-feature',
|
||||
title: 'zsh'
|
||||
}
|
||||
])
|
||||
}
|
||||
registerWorkspaceCleanupHandlers(makeStore(), {
|
||||
runtime: {
|
||||
hasTerminalsForWorktree: vi.fn().mockResolvedValue(false)
|
||||
} as never,
|
||||
getLocalPtyProvider: () => localProvider as never
|
||||
})
|
||||
|
||||
const handler = vi
|
||||
.mocked(ipcMain.handle)
|
||||
.mock.calls.find(([channel]) => channel === 'workspaceCleanup:hasKillableLocalProcesses')?.[1]
|
||||
|
||||
await expect(handler?.({} as never, { worktreeId: 'repo-1::/repo-feature' })).resolves.toEqual({
|
||||
hasKillableProcesses: true
|
||||
})
|
||||
})
|
||||
|
||||
it('reports SSH processes inside the remote workspace path', async () => {
|
||||
getSshPtyProviderMock.mockReturnValue({
|
||||
listProcesses: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'remote-session-1',
|
||||
cwd: '/remote/repo-feature/subdir',
|
||||
title: 'codex'
|
||||
}
|
||||
])
|
||||
})
|
||||
registerWorkspaceCleanupHandlers(makeStore(), {
|
||||
runtime: {
|
||||
hasTerminalsForWorktree: vi.fn().mockResolvedValue(false)
|
||||
} as never
|
||||
})
|
||||
|
||||
const handler = vi
|
||||
.mocked(ipcMain.handle)
|
||||
.mock.calls.find(([channel]) => channel === 'workspaceCleanup:hasKillableLocalProcesses')?.[1]
|
||||
|
||||
await expect(
|
||||
handler?.({} as never, {
|
||||
worktreeId: 'repo-ssh::/remote/repo-feature',
|
||||
connectionId: 'ssh-1',
|
||||
worktreePath: '/remote/repo-feature'
|
||||
})
|
||||
).resolves.toEqual({
|
||||
hasKillableProcesses: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,9 +10,22 @@ import {
|
||||
type WorkspaceCleanupLocalProcessArgs,
|
||||
type WorkspaceCleanupLocalProcessResult,
|
||||
type WorkspaceCleanupScanArgs,
|
||||
type WorkspaceCleanupScanResult
|
||||
type WorkspaceCleanupScanResult,
|
||||
type WorkspaceCleanupSnapshotPruneBatchArgs,
|
||||
type WorkspaceCleanupSnapshotPruneRecordArgs
|
||||
} from '../../shared/workspace-cleanup'
|
||||
import { parseExecutionHostId } from '../../shared/execution-host'
|
||||
import { scanWorkspaceCleanup } from './workspace-cleanup-scan'
|
||||
import { hasTargetedWorkspaceCleanupScan } from './workspace-cleanup-scan-targets'
|
||||
import {
|
||||
persistWorkspaceCleanupScanResult,
|
||||
readWorkspaceCleanupScanSnapshot
|
||||
} from '../workspace-cleanup-scan-snapshot'
|
||||
import {
|
||||
beginWorkspaceCleanupRemovalSnapshotPruneBatch,
|
||||
finishWorkspaceCleanupRemovalSnapshotPruneBatch,
|
||||
recordWorkspaceCleanupRemovalSnapshotPrune
|
||||
} from '../workspace-cleanup-removal-snapshot-prune'
|
||||
|
||||
export { scanWorkspaceCleanup }
|
||||
|
||||
@@ -21,28 +34,109 @@ type WorkspaceCleanupHandlerDeps = {
|
||||
getLocalPtyProvider?: () => IPtyProvider
|
||||
}
|
||||
|
||||
// Why: module scope — handler re-registration on a new main window must not
|
||||
// orphan the previous window's controllers in a discarded map.
|
||||
const activeScans = new Map<string, AbortController>()
|
||||
// Keyed by sender AND scan mode: legacy suggestion-only and full-workspace
|
||||
// broad scans are separate lanes and must not supersede each other, matching
|
||||
// the renderer's broad-scan registry.
|
||||
const broadScanControllersBySenderMode = new Map<string, AbortController>()
|
||||
|
||||
function getBroadScanModeKey(senderId: number, args: WorkspaceCleanupScanArgs): string {
|
||||
return `${senderId}\0${args.includeAllWorkspaces === true}`
|
||||
}
|
||||
|
||||
export function registerWorkspaceCleanupHandlers(
|
||||
store: Store,
|
||||
deps: WorkspaceCleanupHandlerDeps = {}
|
||||
): void {
|
||||
const snapshotDirectory = store.getProfileStorageDirectory()
|
||||
ipcMain.removeHandler('workspaceCleanup:scan')
|
||||
ipcMain.removeHandler('workspaceCleanup:cancelScan')
|
||||
ipcMain.removeHandler('workspaceCleanup:getCachedScan')
|
||||
ipcMain.removeHandler('workspaceCleanup:dismiss')
|
||||
ipcMain.removeHandler('workspaceCleanup:clearDismissals')
|
||||
ipcMain.removeHandler('workspaceCleanup:hasKillableLocalProcesses')
|
||||
ipcMain.removeHandler('workspaceCleanup:beginRemovalSnapshotPruneBatch')
|
||||
ipcMain.removeHandler('workspaceCleanup:recordRemovalSnapshotPrune')
|
||||
ipcMain.removeHandler('workspaceCleanup:finishRemovalSnapshotPruneBatch')
|
||||
|
||||
ipcMain.handle(
|
||||
'workspaceCleanup:scan',
|
||||
(event, args?: WorkspaceCleanupScanArgs): Promise<WorkspaceCleanupScanResult> =>
|
||||
scanWorkspaceCleanup(store, args ?? {}, {
|
||||
onProgress: args?.scanId
|
||||
? (progress) => event.sender.send('workspaceCleanup:scanProgress', progress)
|
||||
: undefined
|
||||
})
|
||||
async (event, args?: WorkspaceCleanupScanArgs): Promise<WorkspaceCleanupScanResult> => {
|
||||
const scanArgs = args ?? {}
|
||||
const sender = event.sender
|
||||
const scanKey = getWorkspaceCleanupScanKey(sender.id, scanArgs.scanId)
|
||||
const controller = new AbortController()
|
||||
if (scanKey) {
|
||||
activeScans.set(scanKey, controller)
|
||||
}
|
||||
const targeted = hasTargetedWorkspaceCleanupScan(scanArgs)
|
||||
const broadScanKey = getBroadScanModeKey(sender.id, scanArgs)
|
||||
if (!targeted) {
|
||||
// Why: two same-mode broad fleet scans from one renderer can only be a
|
||||
// refresh race; running both doubles git subprocess and fs load for a
|
||||
// result the renderer will discard.
|
||||
broadScanControllersBySenderMode.get(broadScanKey)?.abort()
|
||||
broadScanControllersBySenderMode.set(broadScanKey, controller)
|
||||
}
|
||||
// Why: a window close or reload must stop the fleet scan's git and fs
|
||||
// work, not merely mute its progress events.
|
||||
const onSenderDestroyed = (): void => controller.abort()
|
||||
sender.once('destroyed', onSenderDestroyed)
|
||||
try {
|
||||
const result = await scanWorkspaceCleanup(store, scanArgs, {
|
||||
signal: controller.signal,
|
||||
onProgress: scanArgs.scanId
|
||||
? (progress) => {
|
||||
if (!sender.isDestroyed()) {
|
||||
sender.send('workspaceCleanup:scanProgress', progress)
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
})
|
||||
// Focused scans are live-only; persisting each rewrites and fsyncs the
|
||||
// fleet snapshot. worktreeIds: [] is still targeted — persisting its
|
||||
// empty result would wipe the fleet cache.
|
||||
if (!targeted) {
|
||||
void persistWorkspaceCleanupScanResult(snapshotDirectory, scanArgs, result)
|
||||
}
|
||||
return result
|
||||
} finally {
|
||||
if (!sender.isDestroyed()) {
|
||||
sender.removeListener('destroyed', onSenderDestroyed)
|
||||
}
|
||||
if (scanKey && activeScans.get(scanKey) === controller) {
|
||||
activeScans.delete(scanKey)
|
||||
}
|
||||
if (!targeted && broadScanControllersBySenderMode.get(broadScanKey) === controller) {
|
||||
broadScanControllersBySenderMode.delete(broadScanKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('workspaceCleanup:cancelScan', (event, scanId: string): boolean => {
|
||||
const scanKey = getWorkspaceCleanupScanKey(event.sender.id, scanId)
|
||||
const controller = scanKey ? activeScans.get(scanKey) : undefined
|
||||
if (!controller || controller.signal.aborted) {
|
||||
return false
|
||||
}
|
||||
controller.abort()
|
||||
return true
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'workspaceCleanup:getCachedScan',
|
||||
(): Promise<WorkspaceCleanupScanResult | null> =>
|
||||
readWorkspaceCleanupScanSnapshot(snapshotDirectory)
|
||||
)
|
||||
|
||||
ipcMain.handle('workspaceCleanup:dismiss', (_event, args: WorkspaceCleanupDismissArgs) => {
|
||||
const current = store.getUI().workspaceCleanup?.dismissals ?? {}
|
||||
const next = { ...current }
|
||||
const next = { ...store.getUI().workspaceCleanup?.dismissals }
|
||||
for (const worktreeId of args.removedWorktreeIds ?? []) {
|
||||
delete next[worktreeId]
|
||||
}
|
||||
for (const dismissal of args.dismissals ?? []) {
|
||||
if (
|
||||
dismissal &&
|
||||
@@ -69,6 +163,49 @@ export function registerWorkspaceCleanupHandlers(
|
||||
hasKillableProcesses: await hasKillableProcesses(args, deps)
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'workspaceCleanup:beginRemovalSnapshotPruneBatch',
|
||||
(_event, args: WorkspaceCleanupSnapshotPruneBatchArgs) => {
|
||||
if (isSnapshotPruneBatchId(args?.batchId)) {
|
||||
beginWorkspaceCleanupRemovalSnapshotPruneBatch(snapshotDirectory, args)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'workspaceCleanup:recordRemovalSnapshotPrune',
|
||||
(_event, args: WorkspaceCleanupSnapshotPruneRecordArgs) => {
|
||||
if (
|
||||
isSnapshotPruneBatchId(args?.batchId) &&
|
||||
typeof args?.worktreeId === 'string' &&
|
||||
args.worktreeId.length > 0 &&
|
||||
(args.executionHostId === undefined || parseExecutionHostId(args.executionHostId))
|
||||
) {
|
||||
recordWorkspaceCleanupRemovalSnapshotPrune(snapshotDirectory, args)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'workspaceCleanup:finishRemovalSnapshotPruneBatch',
|
||||
(_event, args: WorkspaceCleanupSnapshotPruneBatchArgs) => {
|
||||
if (isSnapshotPruneBatchId(args?.batchId)) {
|
||||
return finishWorkspaceCleanupRemovalSnapshotPruneBatch(snapshotDirectory, args)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function isSnapshotPruneBatchId(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.length > 0 && value.length <= 128
|
||||
}
|
||||
|
||||
function getWorkspaceCleanupScanKey(senderId: number, scanId: unknown): string | null {
|
||||
return typeof scanId === 'string' && scanId.length > 0 && scanId.length <= 128
|
||||
? `${senderId}\0${scanId}`
|
||||
: null
|
||||
}
|
||||
|
||||
async function hasKillableProcesses(
|
||||
|
||||
@@ -11,6 +11,8 @@ const {
|
||||
analyzeWorkspaceSpaceMock,
|
||||
removeHandlerMock,
|
||||
handleMock,
|
||||
persistAnalysisSnapshotMock,
|
||||
readAnalysisSnapshotMock,
|
||||
WorkspaceSpaceScanCancelledErrorMock
|
||||
} = vi.hoisted(() => {
|
||||
const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>()
|
||||
@@ -21,6 +23,8 @@ const {
|
||||
handleMock: vi.fn((channel: string, handler: (...args: unknown[]) => Promise<unknown>) => {
|
||||
handlers.set(channel, handler)
|
||||
}),
|
||||
persistAnalysisSnapshotMock: vi.fn(),
|
||||
readAnalysisSnapshotMock: vi.fn(),
|
||||
WorkspaceSpaceScanCancelledErrorMock: class WorkspaceSpaceScanCancelledError extends Error {}
|
||||
}
|
||||
})
|
||||
@@ -37,6 +41,11 @@ vi.mock('../workspace-space-analysis', () => ({
|
||||
analyzeWorkspaceSpace: analyzeWorkspaceSpaceMock
|
||||
}))
|
||||
|
||||
vi.mock('../workspace-space-analysis-snapshot', () => ({
|
||||
persistWorkspaceSpaceAnalysisSnapshot: persistAnalysisSnapshotMock,
|
||||
readWorkspaceSpaceAnalysisSnapshot: readAnalysisSnapshotMock
|
||||
}))
|
||||
|
||||
import { registerWorkspaceSpaceHandlers } from './workspace-space'
|
||||
|
||||
function createAnalysis(scannedAt: number): WorkspaceSpaceAnalysis {
|
||||
@@ -65,13 +74,19 @@ function createEvent() {
|
||||
}
|
||||
}
|
||||
|
||||
function createStore(): Store {
|
||||
return { getProfileStorageDirectory: () => '/profile-a' } as Store
|
||||
}
|
||||
|
||||
describe('registerWorkspaceSpaceHandlers', () => {
|
||||
beforeEach(() => {
|
||||
analyzeWorkspaceSpaceMock.mockReset()
|
||||
persistAnalysisSnapshotMock.mockReset().mockResolvedValue(undefined)
|
||||
readAnalysisSnapshotMock.mockReset().mockResolvedValue(null)
|
||||
})
|
||||
|
||||
it('shares an in-flight analysis request', async () => {
|
||||
const store = {} as Store
|
||||
const store = createStore()
|
||||
let resolveFirstScan: (analysis: WorkspaceSpaceAnalysis) => void = () => {}
|
||||
const firstScan = new Promise<WorkspaceSpaceAnalysis>((resolve) => {
|
||||
resolveFirstScan = resolve
|
||||
@@ -109,7 +124,7 @@ describe('registerWorkspaceSpaceHandlers', () => {
|
||||
})
|
||||
|
||||
it('forwards scan progress to the requesting renderer', async () => {
|
||||
const store = {} as Store
|
||||
const store = createStore()
|
||||
let onProgress: ((progress: WorkspaceSpaceScanProgress) => void) | undefined
|
||||
analyzeWorkspaceSpaceMock.mockImplementationOnce((_store, options) => {
|
||||
onProgress = options.onProgress
|
||||
@@ -138,8 +153,65 @@ describe('registerWorkspaceSpaceHandlers', () => {
|
||||
expect(event.sender.send).toHaveBeenCalledWith('workspaceSpace:progress', progress)
|
||||
})
|
||||
|
||||
it('batches completed measurements without dropping throttled rows', async () => {
|
||||
const store = createStore()
|
||||
let onProgress: ((progress: WorkspaceSpaceScanProgress) => void) | undefined
|
||||
let resolveScan!: (analysis: WorkspaceSpaceAnalysis) => void
|
||||
analyzeWorkspaceSpaceMock.mockImplementationOnce((_store, options) => {
|
||||
onProgress = options.onProgress
|
||||
return new Promise<WorkspaceSpaceAnalysis>((resolve) => {
|
||||
resolveScan = resolve
|
||||
})
|
||||
})
|
||||
const now = vi.spyOn(Date, 'now').mockReturnValue(1_000)
|
||||
|
||||
try {
|
||||
registerWorkspaceSpaceHandlers(store)
|
||||
const event = createEvent()
|
||||
const promise = handlers.get('workspaceSpace:analyze')!(event)
|
||||
const base = {
|
||||
scanId: 'scan-1',
|
||||
state: 'running' as const,
|
||||
startedAt: 1,
|
||||
updatedAt: 1,
|
||||
totalRepoCount: 1,
|
||||
scannedRepoCount: 0,
|
||||
totalWorktreeCount: 2,
|
||||
currentRepoDisplayName: 'orca',
|
||||
currentWorktreeDisplayName: 'feature'
|
||||
}
|
||||
onProgress?.({ ...base, scannedWorktreeCount: 0 })
|
||||
onProgress?.({
|
||||
...base,
|
||||
scannedWorktreeCount: 1,
|
||||
completedMeasurements: [{ worktreeId: 'a', status: 'ok', sizeBytes: 10 }]
|
||||
})
|
||||
onProgress?.({
|
||||
...base,
|
||||
scannedWorktreeCount: 2,
|
||||
completedMeasurements: [{ worktreeId: 'b', status: 'ok', sizeBytes: 20 }]
|
||||
})
|
||||
resolveScan(createAnalysis(1))
|
||||
await promise
|
||||
|
||||
expect(event.sender.send).toHaveBeenCalledTimes(2)
|
||||
expect(event.sender.send).toHaveBeenLastCalledWith(
|
||||
'workspaceSpace:progress',
|
||||
expect.objectContaining({
|
||||
scannedWorktreeCount: 2,
|
||||
completedMeasurements: [
|
||||
{ worktreeId: 'a', status: 'ok', sizeBytes: 10 },
|
||||
{ worktreeId: 'b', status: 'ok', sizeBytes: 20 }
|
||||
]
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
now.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('cancels the in-flight scan', async () => {
|
||||
const store = {} as Store
|
||||
const store = createStore()
|
||||
let signal: AbortSignal | undefined
|
||||
analyzeWorkspaceSpaceMock.mockImplementationOnce((_store, options) => {
|
||||
signal = options.signal
|
||||
@@ -157,12 +229,36 @@ describe('registerWorkspaceSpaceHandlers', () => {
|
||||
})
|
||||
|
||||
it('returns a normal cancelled result instead of rejecting expected cancellation', async () => {
|
||||
const store = {} as Store
|
||||
const store = createStore()
|
||||
analyzeWorkspaceSpaceMock.mockRejectedValueOnce(new WorkspaceSpaceScanCancelledErrorMock())
|
||||
|
||||
registerWorkspaceSpaceHandlers(store)
|
||||
const analyzeHandler = handlers.get('workspaceSpace:analyze')
|
||||
|
||||
await expect(analyzeHandler!(createEvent())).resolves.toEqual({ ok: false, cancelled: true })
|
||||
expect(persistAnalysisSnapshotMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('persists the completed analysis snapshot', async () => {
|
||||
const analysis = createAnalysis(7)
|
||||
analyzeWorkspaceSpaceMock.mockResolvedValueOnce(analysis)
|
||||
|
||||
registerWorkspaceSpaceHandlers(createStore())
|
||||
const analyzeHandler = handlers.get('workspaceSpace:analyze')
|
||||
|
||||
await expect(analyzeHandler!(createEvent())).resolves.toEqual({ ok: true, analysis })
|
||||
expect(persistAnalysisSnapshotMock).toHaveBeenCalledWith('/profile-a', analysis)
|
||||
})
|
||||
|
||||
it('serves the cached analysis through getCachedAnalysis', async () => {
|
||||
const cached = createAnalysis(3)
|
||||
readAnalysisSnapshotMock.mockResolvedValue(cached)
|
||||
|
||||
registerWorkspaceSpaceHandlers(createStore())
|
||||
expect(removeHandlerMock).toHaveBeenCalledWith('workspaceSpace:getCachedAnalysis')
|
||||
const cachedHandler = handlers.get('workspaceSpace:getCachedAnalysis')
|
||||
|
||||
await expect(cachedHandler!()).resolves.toBe(cached)
|
||||
expect(readAnalysisSnapshotMock).toHaveBeenCalledWith('/profile-a')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import type { Store } from '../persistence'
|
||||
import type {
|
||||
WorkspaceSpaceAnalysis,
|
||||
WorkspaceSpaceAnalyzeResult,
|
||||
WorkspaceSpaceScanProgress
|
||||
WorkspaceSpaceScanProgress,
|
||||
WorkspaceSpaceWorktreeMeasurement
|
||||
} from '../../shared/workspace-space-types'
|
||||
import {
|
||||
analyzeWorkspaceSpace,
|
||||
WorkspaceSpaceScanCancelledError
|
||||
} from '../workspace-space-analysis'
|
||||
import {
|
||||
persistWorkspaceSpaceAnalysisSnapshot,
|
||||
readWorkspaceSpaceAnalysisSnapshot
|
||||
} from '../workspace-space-analysis-snapshot'
|
||||
|
||||
const PROGRESS_EMIT_INTERVAL_MS = 100
|
||||
|
||||
@@ -19,9 +25,11 @@ type InFlightWorkspaceSpaceScan = {
|
||||
}
|
||||
|
||||
export function registerWorkspaceSpaceHandlers(store: Store): void {
|
||||
const snapshotDirectory = store.getProfileStorageDirectory()
|
||||
let inFlightScan: InFlightWorkspaceSpaceScan | null = null
|
||||
ipcMain.removeHandler('workspaceSpace:cancel')
|
||||
ipcMain.removeHandler('workspaceSpace:analyze')
|
||||
ipcMain.removeHandler('workspaceSpace:getCachedAnalysis')
|
||||
ipcMain.handle('workspaceSpace:analyze', async (event): Promise<WorkspaceSpaceAnalyzeResult> => {
|
||||
if (!inFlightScan) {
|
||||
const controller = new AbortController()
|
||||
@@ -39,7 +47,11 @@ export function registerWorkspaceSpaceHandlers(store: Store): void {
|
||||
currentWorktreeDisplayName: null
|
||||
}
|
||||
let lastProgressSentAt = 0
|
||||
let pendingMeasurements: WorkspaceSpaceWorktreeMeasurement[] = []
|
||||
const sendProgress = (progress: WorkspaceSpaceScanProgress): void => {
|
||||
if (progress.completedMeasurements?.length) {
|
||||
pendingMeasurements.push(...progress.completedMeasurements)
|
||||
}
|
||||
// Why: large fleets can report one progress event per worktree; keep
|
||||
// the UI responsive without repainting the full Space page for each row.
|
||||
const now = Date.now()
|
||||
@@ -57,7 +69,12 @@ export function registerWorkspaceSpaceHandlers(store: Store): void {
|
||||
}
|
||||
lastProgressSentAt = now
|
||||
if (!event.sender.isDestroyed()) {
|
||||
event.sender.send('workspaceSpace:progress', progress)
|
||||
const completedMeasurements = pendingMeasurements
|
||||
pendingMeasurements = []
|
||||
event.sender.send('workspaceSpace:progress', {
|
||||
...progress,
|
||||
...(completedMeasurements.length > 0 ? { completedMeasurements } : {})
|
||||
})
|
||||
}
|
||||
}
|
||||
// Why: large worktree fleets require real disk traversal; duplicate
|
||||
@@ -78,7 +95,12 @@ export function registerWorkspaceSpaceHandlers(store: Store): void {
|
||||
sendProgress(progress)
|
||||
}
|
||||
})
|
||||
.then((analysis): WorkspaceSpaceAnalyzeResult => ({ ok: true, analysis }))
|
||||
.then((analysis): WorkspaceSpaceAnalyzeResult => {
|
||||
// Fire-and-forget: a ~6-minute cold analysis must survive reload/restart, but
|
||||
// persistence must never delay or fail the reply.
|
||||
void persistWorkspaceSpaceAnalysisSnapshot(snapshotDirectory, analysis)
|
||||
return { ok: true, analysis }
|
||||
})
|
||||
.catch((error: unknown): WorkspaceSpaceAnalyzeResult => {
|
||||
if (error instanceof WorkspaceSpaceScanCancelledError) {
|
||||
return { ok: false, cancelled: true }
|
||||
@@ -92,6 +114,12 @@ export function registerWorkspaceSpaceHandlers(store: Store): void {
|
||||
return inFlightScan.promise
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'workspaceSpace:getCachedAnalysis',
|
||||
(): Promise<WorkspaceSpaceAnalysis | null> =>
|
||||
readWorkspaceSpaceAnalysisSnapshot(snapshotDirectory)
|
||||
)
|
||||
|
||||
ipcMain.handle('workspaceSpace:cancel', async (): Promise<boolean> => {
|
||||
if (!inFlightScan || inFlightScan.controller.signal.aborted) {
|
||||
return false
|
||||
|
||||
@@ -151,6 +151,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
|
||||
}
|
||||
}
|
||||
const store = {
|
||||
getProfileStorageDirectory: vi.fn(() => '/profile-a'),
|
||||
getRepos: vi.fn(),
|
||||
getRepo: vi.fn(),
|
||||
getProjects: vi.fn(),
|
||||
|
||||
@@ -236,6 +236,36 @@ vi.mock('../ports/advertised-url-watcher', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
const {
|
||||
pruneCleanupScanSnapshotMock,
|
||||
pruneCleanupScanSnapshotsMock,
|
||||
pruneSpaceAnalysisSnapshotMock,
|
||||
pruneSpaceAnalysisSnapshotsMock
|
||||
} = vi.hoisted(() => ({
|
||||
pruneCleanupScanSnapshotMock: vi.fn().mockResolvedValue(undefined),
|
||||
pruneCleanupScanSnapshotsMock: vi.fn().mockResolvedValue(undefined),
|
||||
pruneSpaceAnalysisSnapshotMock: vi.fn().mockResolvedValue(undefined),
|
||||
pruneSpaceAnalysisSnapshotsMock: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
vi.mock('../workspace-cleanup-scan-snapshot', () => ({
|
||||
pruneWorkspaceCleanupScanSnapshot: pruneCleanupScanSnapshotMock,
|
||||
pruneWorkspaceCleanupScanSnapshots: pruneCleanupScanSnapshotsMock
|
||||
}))
|
||||
|
||||
vi.mock('../workspace-space-analysis-snapshot', () => ({
|
||||
pruneWorkspaceSpaceAnalysisSnapshot: pruneSpaceAnalysisSnapshotMock,
|
||||
pruneWorkspaceSpaceAnalysisSnapshots: pruneSpaceAnalysisSnapshotsMock
|
||||
}))
|
||||
|
||||
const { recordRemovalSnapshotPruneMock } = vi.hoisted(() => ({
|
||||
recordRemovalSnapshotPruneMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../workspace-cleanup-removal-snapshot-prune', () => ({
|
||||
recordWorkspaceCleanupRemovalSnapshotPrune: recordRemovalSnapshotPruneMock
|
||||
}))
|
||||
|
||||
const {
|
||||
killAllProcessesForWorktreeMock,
|
||||
clearProviderPtyStateMock,
|
||||
@@ -303,6 +333,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
}
|
||||
const ipcEvent = { sender: { id: 1 } }
|
||||
const store = {
|
||||
getProfileStorageDirectory: vi.fn(() => '/profile-a'),
|
||||
getRepos: vi.fn(),
|
||||
getRepo: vi.fn(),
|
||||
getProjects: vi.fn(),
|
||||
@@ -408,6 +439,11 @@ describe('registerWorktreeHandlers', () => {
|
||||
getSshPtyProviderMock,
|
||||
deleteWorktreeHistoryDirMock,
|
||||
advertisedUrlWatcherForgetWorktreeMock,
|
||||
pruneCleanupScanSnapshotMock,
|
||||
pruneCleanupScanSnapshotsMock,
|
||||
pruneSpaceAnalysisSnapshotMock,
|
||||
pruneSpaceAnalysisSnapshotsMock,
|
||||
recordRemovalSnapshotPruneMock,
|
||||
findExistingWorktreeSymlinkPathsMock,
|
||||
removeWorktreeLinkedPathsMock
|
||||
]) {
|
||||
@@ -2709,6 +2745,10 @@ describe('registerWorktreeHandlers', () => {
|
||||
const metaById: Record<string, Record<string, unknown>> = {
|
||||
'repo-1::/remote/repo': makeWorktreeMeta({ displayName: 'main', hostId: sshHostId }),
|
||||
'repo-1::/remote/deleted': makeWorktreeMeta({ displayName: 'deleted', hostId: sshHostId }),
|
||||
'repo-1::/remote/deleted-too': makeWorktreeMeta({
|
||||
displayName: 'deleted too',
|
||||
hostId: sshHostId
|
||||
}),
|
||||
'repo-1::/remote/other-host': makeWorktreeMeta({
|
||||
displayName: 'other host',
|
||||
hostId: toSshExecutionHostId('target-b')
|
||||
@@ -2726,18 +2766,33 @@ describe('registerWorktreeHandlers', () => {
|
||||
const forgotten = await handlers['worktrees:forgetRemovedForExecutionHost'](null, {
|
||||
repoId: sshRepo.id,
|
||||
executionHostId: sshHostId,
|
||||
// Only the first is this host's row; the rest must survive an over-broad request.
|
||||
// Only the first two are this host's rows; the rest must survive an over-broad request.
|
||||
worktreeIds: [
|
||||
'repo-1::/remote/deleted',
|
||||
'repo-1::/remote/deleted-too',
|
||||
'repo-1::/remote/other-host',
|
||||
'repo-2::/remote/other-repo',
|
||||
'repo-1::/remote/never-persisted'
|
||||
]
|
||||
})
|
||||
|
||||
expect(forgotten).toEqual({ forgottenWorktreeIds: ['repo-1::/remote/deleted'] })
|
||||
expect(store.removeWorktreeMeta).toHaveBeenCalledTimes(1)
|
||||
expect(forgotten).toEqual({
|
||||
forgottenWorktreeIds: ['repo-1::/remote/deleted', 'repo-1::/remote/deleted-too']
|
||||
})
|
||||
expect(store.removeWorktreeMeta).toHaveBeenCalledTimes(2)
|
||||
expect(store.removeWorktreeMeta).toHaveBeenCalledWith('repo-1::/remote/deleted', sshHostId)
|
||||
const pruneTargets = [
|
||||
{ worktreeId: 'repo-1::/remote/deleted', executionHostId: sshHostId },
|
||||
{ worktreeId: 'repo-1::/remote/deleted-too', executionHostId: sshHostId }
|
||||
]
|
||||
expect(pruneCleanupScanSnapshotsMock).toHaveBeenCalledExactlyOnceWith(
|
||||
'/profile-a',
|
||||
pruneTargets
|
||||
)
|
||||
expect(pruneSpaceAnalysisSnapshotsMock).toHaveBeenCalledExactlyOnceWith(
|
||||
'/profile-a',
|
||||
pruneTargets
|
||||
)
|
||||
|
||||
// Why: the renderer's suppression memory is session-scoped, so the next launch must not re-list the row.
|
||||
const relisted = (await handlers['worktrees:listKnownForExecutionHost'](null, {
|
||||
@@ -8295,6 +8350,45 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('prunes the persisted cleanup and space snapshots on removal', async () => {
|
||||
mockKnownFeatureWorktree()
|
||||
getEffectiveHooksMock.mockReturnValue(null)
|
||||
removeWorktreeMock.mockResolvedValue({})
|
||||
|
||||
await handlers['worktrees:remove'](null, { worktreeId: 'repo-1::/workspace/feature-wt' })
|
||||
|
||||
// A removed workspace must never resurrect from the cached scan snapshots.
|
||||
expect(pruneCleanupScanSnapshotMock).toHaveBeenCalledWith(
|
||||
'/profile-a',
|
||||
'repo-1::/workspace/feature-wt',
|
||||
'local'
|
||||
)
|
||||
expect(pruneSpaceAnalysisSnapshotMock).toHaveBeenCalledWith(
|
||||
'/profile-a',
|
||||
'repo-1::/workspace/feature-wt',
|
||||
'local'
|
||||
)
|
||||
})
|
||||
|
||||
it('tombstones a cleanup-batch removal without scheduling singular sidecar writes', async () => {
|
||||
mockKnownFeatureWorktree()
|
||||
getEffectiveHooksMock.mockReturnValue(null)
|
||||
removeWorktreeMock.mockResolvedValue({})
|
||||
|
||||
await handlers['worktrees:remove'](null, {
|
||||
worktreeId: 'repo-1::/workspace/feature-wt',
|
||||
snapshotPruneBatchId: 'cleanup-batch-1'
|
||||
})
|
||||
|
||||
expect(recordRemovalSnapshotPruneMock).toHaveBeenCalledExactlyOnceWith('/profile-a', {
|
||||
batchId: 'cleanup-batch-1',
|
||||
worktreeId: 'repo-1::/workspace/feature-wt',
|
||||
executionHostId: 'local'
|
||||
})
|
||||
expect(pruneCleanupScanSnapshotMock).not.toHaveBeenCalled()
|
||||
expect(pruneSpaceAnalysisSnapshotMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('traces the removal as worktree.remove with a stage sub-span tree', async () => {
|
||||
const records: RedactableSpan[] = []
|
||||
setActiveSink({
|
||||
|
||||
+92
-15
@@ -17,6 +17,15 @@ import { getProjectGroupSubtreeIds } from '../../shared/project-groups'
|
||||
import { projectResolvedWorktreeLineage } from '../../shared/resolved-worktree-lineage'
|
||||
import { isPathInsideOrEqual, isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
|
||||
import { deleteWorktreeHistoryDir } from '../terminal-history-deletion'
|
||||
import {
|
||||
pruneWorkspaceCleanupScanSnapshot,
|
||||
pruneWorkspaceCleanupScanSnapshots
|
||||
} from '../workspace-cleanup-scan-snapshot'
|
||||
import {
|
||||
pruneWorkspaceSpaceAnalysisSnapshot,
|
||||
pruneWorkspaceSpaceAnalysisSnapshots
|
||||
} from '../workspace-space-analysis-snapshot'
|
||||
import { recordWorkspaceCleanupRemovalSnapshotPrune } from '../workspace-cleanup-removal-snapshot-prune'
|
||||
import type { OrcaHooks } from '../../shared/orca-yaml-hook-types'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type {
|
||||
@@ -70,7 +79,7 @@ import {
|
||||
} from '../../shared/worktree/ownership'
|
||||
import {
|
||||
createWorktreeVisibilitySourceMatcher,
|
||||
normalizeCustomWorktreeVisibilitySources
|
||||
resolveCustomWorktreeVisibilitySources
|
||||
} from '../../shared/worktree/visibility-sources'
|
||||
import {
|
||||
assertWorktreeCleanForRemoval,
|
||||
@@ -162,6 +171,7 @@ type RemoveWorktreeArgs = {
|
||||
/** Explicit Force Delete only — `force` alone is set by the ordinary confirmation (#11960). */
|
||||
allowUnverifiedPtyStop?: boolean
|
||||
skipArchive?: boolean
|
||||
snapshotPruneBatchId?: string
|
||||
}
|
||||
|
||||
type DetectedWorktreeRequestArgs = { repoId: string } | ListDetectedWorktreesArgs
|
||||
@@ -326,7 +336,8 @@ function resolveWorktreeRemovalOwnerHostId(
|
||||
function removeWorktreeMetadataAndTransientState(
|
||||
store: Store,
|
||||
worktreeId: string,
|
||||
hostId?: ExecutionHostId
|
||||
hostId?: ExecutionHostId,
|
||||
snapshotPruneBatchId?: string
|
||||
): void {
|
||||
// Why: worktree IDs are path-derived and reusable; drop process-local caches before the same ID can map to a new workspace.
|
||||
if (hostId) {
|
||||
@@ -341,6 +352,18 @@ function removeWorktreeMetadataAndTransientState(
|
||||
deleteWorktreeHistoryDir(worktreeId)
|
||||
// Why: release the removed worktree's PR-refresh aliases so coalesced queue entries don't retain it all session (memory creep).
|
||||
pruneWorktreePRRefreshAliases(worktreeId)
|
||||
// Why: removed workspaces must never resurrect from the persisted cleanup/space scan snapshots.
|
||||
const snapshotDirectory = store.getProfileStorageDirectory()
|
||||
if (snapshotPruneBatchId) {
|
||||
recordWorkspaceCleanupRemovalSnapshotPrune(snapshotDirectory, {
|
||||
batchId: snapshotPruneBatchId,
|
||||
worktreeId,
|
||||
executionHostId: hostId
|
||||
})
|
||||
return
|
||||
}
|
||||
void pruneWorkspaceCleanupScanSnapshot(snapshotDirectory, worktreeId, hostId)
|
||||
void pruneWorkspaceSpaceAnalysisSnapshot(snapshotDirectory, worktreeId, hostId)
|
||||
}
|
||||
|
||||
function getProjectHostSetupMetaUpdates(
|
||||
@@ -858,7 +881,7 @@ function buildDetectedGitWorktrees(
|
||||
)
|
||||
const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher(
|
||||
[repo.path, ...liveWorktrees.map((worktree) => worktree.path)],
|
||||
normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? []
|
||||
resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults)
|
||||
)
|
||||
const detected = liveWorktrees.map((gitWorktree) => {
|
||||
const worktreeId = `${repo.id}::${gitWorktree.path}`
|
||||
@@ -1010,7 +1033,7 @@ function buildFolderDetectedWorktrees(store: Store, repo: Repo): DetectedWorktre
|
||||
const worktrees = listFolderWorkspaces(store, repo)
|
||||
const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher(
|
||||
[repo.path, ...worktrees.map((worktree) => worktree.path)],
|
||||
normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? []
|
||||
resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults)
|
||||
)
|
||||
return worktrees.map((worktree) =>
|
||||
toDetectedWorktree({
|
||||
@@ -1095,7 +1118,7 @@ function buildDisconnectedDetectedWorktrees(
|
||||
const settings = store.getSettings()
|
||||
const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher(
|
||||
[repo.path, ...worktrees.map((worktree) => worktree.path)],
|
||||
normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? []
|
||||
resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults)
|
||||
)
|
||||
const detected = worktrees.map((worktree) => {
|
||||
const meta = store.getWorktreeMeta(worktree.id)
|
||||
@@ -2072,6 +2095,15 @@ export function registerWorktreeHandlers(
|
||||
store.removeWorktreeMeta(worktreeId, requestedExecutionHostId)
|
||||
forgottenWorktreeIds.push(worktreeId)
|
||||
}
|
||||
if (forgottenWorktreeIds.length > 0) {
|
||||
const snapshotDirectory = store.getProfileStorageDirectory()
|
||||
const targets = forgottenWorktreeIds.map((worktreeId) => ({
|
||||
worktreeId,
|
||||
executionHostId: requestedExecutionHostId
|
||||
}))
|
||||
void pruneWorkspaceCleanupScanSnapshots(snapshotDirectory, targets)
|
||||
void pruneWorkspaceSpaceAnalysisSnapshots(snapshotDirectory, targets)
|
||||
}
|
||||
return { forgottenWorktreeIds }
|
||||
}
|
||||
)
|
||||
@@ -2483,7 +2515,12 @@ export function registerWorktreeHandlers(
|
||||
})
|
||||
})
|
||||
await withWorktreeRemoveStageSpan('metadata_purge', 'folder', async () => {
|
||||
removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId)
|
||||
removeWorktreeMetadataAndTransientState(
|
||||
store,
|
||||
args.worktreeId,
|
||||
removalHostId,
|
||||
args.snapshotPruneBatchId
|
||||
)
|
||||
})
|
||||
preservedBranchCleanupByScope.delete(
|
||||
preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, hostId: removalHostId })
|
||||
@@ -2595,7 +2632,12 @@ export function registerWorktreeHandlers(
|
||||
invalidateAuthorizedRootsCache()
|
||||
}
|
||||
runtime.clearOptimisticReconcileToken(args.worktreeId)
|
||||
removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId)
|
||||
removeWorktreeMetadataAndTransientState(
|
||||
store,
|
||||
args.worktreeId,
|
||||
removalHostId,
|
||||
args.snapshotPruneBatchId
|
||||
)
|
||||
preservedBranchCleanupByScope.delete(
|
||||
preservedBranchCleanupScopeKey({
|
||||
worktreeId: args.worktreeId,
|
||||
@@ -2645,7 +2687,12 @@ export function registerWorktreeHandlers(
|
||||
localWorktreeGitOptions
|
||||
)
|
||||
runtime.clearOptimisticReconcileToken(args.worktreeId)
|
||||
removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId)
|
||||
removeWorktreeMetadataAndTransientState(
|
||||
store,
|
||||
args.worktreeId,
|
||||
removalHostId,
|
||||
args.snapshotPruneBatchId
|
||||
)
|
||||
preservedBranchCleanupByScope.delete(
|
||||
preservedBranchCleanupScopeKey({
|
||||
worktreeId: args.worktreeId,
|
||||
@@ -2682,7 +2729,12 @@ export function registerWorktreeHandlers(
|
||||
invalidateAuthorizedRootsCache()
|
||||
}
|
||||
runtime.clearOptimisticReconcileToken(args.worktreeId)
|
||||
removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId)
|
||||
removeWorktreeMetadataAndTransientState(
|
||||
store,
|
||||
args.worktreeId,
|
||||
removalHostId,
|
||||
args.snapshotPruneBatchId
|
||||
)
|
||||
preservedBranchCleanupByScope.delete(
|
||||
preservedBranchCleanupScopeKey({
|
||||
worktreeId: args.worktreeId,
|
||||
@@ -2742,7 +2794,12 @@ export function registerWorktreeHandlers(
|
||||
removedPushTarget
|
||||
)
|
||||
runtime.clearOptimisticReconcileToken(args.worktreeId)
|
||||
removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId)
|
||||
removeWorktreeMetadataAndTransientState(
|
||||
store,
|
||||
args.worktreeId,
|
||||
removalHostId,
|
||||
args.snapshotPruneBatchId
|
||||
)
|
||||
invalidateAuthorizedRootsCache()
|
||||
notifyWorktreesChanged(mainWindow, repoId)
|
||||
return removalResult ?? {}
|
||||
@@ -2841,7 +2898,12 @@ export function registerWorktreeHandlers(
|
||||
)
|
||||
runtime.clearOptimisticReconcileToken(args.worktreeId)
|
||||
await withWorktreeRemoveStageSpan('metadata_purge', 'remote', async () => {
|
||||
removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId)
|
||||
removeWorktreeMetadataAndTransientState(
|
||||
store,
|
||||
args.worktreeId,
|
||||
removalHostId,
|
||||
args.snapshotPruneBatchId
|
||||
)
|
||||
})
|
||||
notifyWorktreesChanged(mainWindow, repoId)
|
||||
return removalResult ?? {}
|
||||
@@ -2987,7 +3049,12 @@ export function registerWorktreeHandlers(
|
||||
localWorktreeGitOptions
|
||||
)
|
||||
runtime.clearOptimisticReconcileToken(args.worktreeId)
|
||||
removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId)
|
||||
removeWorktreeMetadataAndTransientState(
|
||||
store,
|
||||
args.worktreeId,
|
||||
removalHostId,
|
||||
args.snapshotPruneBatchId
|
||||
)
|
||||
preservedBranchCleanupByScope.delete(
|
||||
preservedBranchCleanupScopeKey({
|
||||
worktreeId: args.worktreeId,
|
||||
@@ -3024,7 +3091,12 @@ export function registerWorktreeHandlers(
|
||||
)
|
||||
runtime.clearOptimisticReconcileToken(args.worktreeId)
|
||||
await withWorktreeRemoveStageSpan('metadata_purge', 'local', async () => {
|
||||
removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId)
|
||||
removeWorktreeMetadataAndTransientState(
|
||||
store,
|
||||
args.worktreeId,
|
||||
removalHostId,
|
||||
args.snapshotPruneBatchId
|
||||
)
|
||||
})
|
||||
await withWorktreeRemoveStageSpan('cache_invalidation', 'local', async () => {
|
||||
invalidateAuthorizedRootsCache()
|
||||
@@ -3056,7 +3128,7 @@ export function registerWorktreeHandlers(
|
||||
'worktrees:forgetLocal',
|
||||
async (
|
||||
_event,
|
||||
args: Pick<RemoveWorktreeArgs, 'worktreeId' | 'hostId'>
|
||||
args: Pick<RemoveWorktreeArgs, 'worktreeId' | 'hostId' | 'snapshotPruneBatchId'>
|
||||
): Promise<RemoveWorktreeResult> => {
|
||||
const { repoId } = parseWorktreeId(args.worktreeId)
|
||||
const repo = getRepoForWorktreeRemoval(store, repoId, args.hostId)
|
||||
@@ -3118,7 +3190,12 @@ export function registerWorktreeHandlers(
|
||||
|
||||
runtime.clearOptimisticReconcileToken(args.worktreeId)
|
||||
// The resolved owner, not args.hostId: an orphan forget with no hostId still has to purge its SSH/runtime partition.
|
||||
removeWorktreeMetadataAndTransientState(store, args.worktreeId, ownerHost?.id)
|
||||
removeWorktreeMetadataAndTransientState(
|
||||
store,
|
||||
args.worktreeId,
|
||||
ownerHost?.id,
|
||||
args.snapshotPruneBatchId
|
||||
)
|
||||
// Why: cached roots outlive the forgotten workspace, so an ownerless path stays filesystem-authorized until a rebuild.
|
||||
invalidateAuthorizedRootsCache()
|
||||
if (ownerHost?.id) {
|
||||
|
||||
@@ -37,6 +37,7 @@ import { SshConnectionStore } from './ssh/ssh-connection-store'
|
||||
import { setSourceControlActionDefault } from '../shared/source-control-ai-actions'
|
||||
import { LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types'
|
||||
import { closeTerminalTabInWorkspaceSession } from '../shared/workspace-session-terminal-tab-close'
|
||||
import { createDefaultWorkspaceCleanupBrowseState } from '../shared/workspace-cleanup-browse-state'
|
||||
|
||||
// Shared mutable state so the electron mock can reference a per-test directory
|
||||
const testState = { dir: '' }
|
||||
@@ -4853,7 +4854,94 @@ describe('Store', () => {
|
||||
expect(updated!.externalWorktreeVisibilityLegacy).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps rollback agent visibility writes authoritative for both built-in sources', async () => {
|
||||
it('migrates implicit legacy visibility to an explicit override before defaulting global hide', async () => {
|
||||
const persisted = getDefaultPersistedState(testState.dir)
|
||||
delete persisted.settings.worktreeVisibilityDefaults
|
||||
persisted.repos = [
|
||||
makeRepo({
|
||||
id: 'legacy',
|
||||
kind: 'git',
|
||||
externalWorktreeVisibility: undefined,
|
||||
externalWorktreeVisibilityLegacy: undefined
|
||||
}),
|
||||
makeRepo({ id: 'explicit', externalWorktreeVisibility: 'hide' }),
|
||||
makeRepo({
|
||||
id: 'inherited',
|
||||
externalWorktreeVisibility: undefined,
|
||||
externalWorktreeVisibilityLegacy: false
|
||||
})
|
||||
]
|
||||
writeDataFile(persisted)
|
||||
|
||||
const store = await createStore()
|
||||
|
||||
expect(store.getSettings().worktreeVisibilityDefaults).toEqual({ external: 'hide' })
|
||||
expect(store.getRepo('legacy')).toMatchObject({
|
||||
externalWorktreeVisibility: 'show',
|
||||
externalWorktreeVisibilityLegacy: true
|
||||
})
|
||||
expect(store.getRepo('explicit')?.externalWorktreeVisibility).toBe('hide')
|
||||
expect(store.getRepo('inherited')?.externalWorktreeVisibility).toBeUndefined()
|
||||
|
||||
store.flush()
|
||||
const reloaded = await createStore()
|
||||
expect(reloaded.getRepo('legacy')).toMatchObject({
|
||||
externalWorktreeVisibility: 'show',
|
||||
externalWorktreeVisibilityLegacy: true
|
||||
})
|
||||
expect(reloaded.getRepo('inherited')?.externalWorktreeVisibility).toBeUndefined()
|
||||
})
|
||||
|
||||
it('merges visibility-default patches so future source defaults survive older controls', async () => {
|
||||
const store = await createStore()
|
||||
store.updateSettings({
|
||||
worktreeVisibilityDefaults: {
|
||||
external: 'show',
|
||||
futureSource: 'hide'
|
||||
} as GlobalSettings['worktreeVisibilityDefaults']
|
||||
})
|
||||
|
||||
store.updateSettings({ worktreeVisibilityDefaults: { external: 'hide' } })
|
||||
|
||||
expect(store.getSettings().worktreeVisibilityDefaults).toEqual({
|
||||
external: 'hide',
|
||||
futureSource: 'hide'
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes and persists global worktree source defaults', async () => {
|
||||
const store = await createStore()
|
||||
|
||||
store.updateSettings({
|
||||
worktreeVisibilityDefaults: {
|
||||
external: 'show',
|
||||
customSources: [
|
||||
{ id: 'team', rootPath: ' /srv/team-worktrees ' },
|
||||
{ id: 'invalid', rootPath: '../relative' }
|
||||
],
|
||||
sourcePreferences: {
|
||||
builtIn: { claude: 'show', gsd: 'hide' },
|
||||
custom: { team: 'show' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(store.getSettings().worktreeVisibilityDefaults).toEqual({
|
||||
external: 'show',
|
||||
customSources: [{ id: 'team', rootPath: '/srv/team-worktrees' }],
|
||||
sourcePreferences: {
|
||||
builtIn: { claude: 'show', gsd: 'hide' },
|
||||
custom: { team: 'show' }
|
||||
}
|
||||
})
|
||||
store.flush()
|
||||
const reloaded = await createStore()
|
||||
expect(reloaded.getSettings().worktreeVisibilityDefaults).toEqual(
|
||||
store.getSettings().worktreeVisibilityDefaults
|
||||
)
|
||||
})
|
||||
|
||||
it('persists agent worktree visibility independently from external visibility', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo({ externalWorktreeVisibility: 'hide' }))
|
||||
|
||||
@@ -4908,6 +4996,25 @@ describe('Store', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('clears an explicit external-worktree override without stamping an inherited default', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(
|
||||
makeRepo({
|
||||
externalWorktreeVisibility: 'show',
|
||||
externalWorktreeVisibilityLegacy: true
|
||||
})
|
||||
)
|
||||
|
||||
const updated = store.updateRepo('r1', { externalWorktreeVisibility: null })
|
||||
|
||||
expect(updated?.externalWorktreeVisibility).toBeUndefined()
|
||||
expect(updated?.externalWorktreeVisibilityLegacy).toBe(false)
|
||||
store.flush()
|
||||
const reloaded = await createStore()
|
||||
expect(reloaded.getRepo('r1')?.externalWorktreeVisibility).toBeUndefined()
|
||||
expect(reloaded.getRepo('r1')?.externalWorktreeVisibilityLegacy).toBe(false)
|
||||
})
|
||||
|
||||
it('updateRepo clears source-control AI overrides independently from other clearable fields', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(
|
||||
@@ -7129,6 +7236,29 @@ describe('Store', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('updateUI preserves browse state when a legacy peer publishes dismissals only', async () => {
|
||||
const store = await createStore()
|
||||
const browse = createDefaultWorkspaceCleanupBrowseState()
|
||||
browse.filters.query = 'stale'
|
||||
|
||||
store.updateUI({ workspaceCleanup: { dismissals: {}, browse } })
|
||||
store.updateUI({
|
||||
workspaceCleanup: {
|
||||
dismissals: {
|
||||
'wt-1': {
|
||||
worktreeId: 'wt-1',
|
||||
dismissedAt: 1700000000000,
|
||||
fingerprint: 'fp-1',
|
||||
classifierVersion: 2
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(store.getUI().workspaceCleanup?.browse).toEqual(browse)
|
||||
expect(store.getUI().workspaceCleanup?.dismissals).toHaveProperty('wt-1')
|
||||
})
|
||||
|
||||
it('updateUI merges contextual tour seen ids instead of replacing stale snapshots', async () => {
|
||||
const store = await createStore()
|
||||
|
||||
|
||||
+48
-2
@@ -186,6 +186,7 @@ import {
|
||||
} from '../shared/cross-platform-path'
|
||||
import { normalizeTerminalQuickCommands } from '../shared/terminal-quick-commands'
|
||||
import { normalizeTaskProviderSettings } from '../shared/task-providers'
|
||||
import { mergeWorkspaceCleanupUIState } from '../shared/workspace-cleanup-ui-state'
|
||||
import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../shared/auto-rename-branch-from-work-settings'
|
||||
import {
|
||||
addMobilePairingCustomAddress,
|
||||
@@ -226,6 +227,10 @@ import {
|
||||
import { clampMarkdownTocPanelWidth } from '../shared/markdown-toc-panel-width'
|
||||
import { clampCombinedDiffFileTreeWidth } from '../shared/combined-diff-file-tree-width'
|
||||
import { isLegacyRepoForExternalWorktreeVisibility } from '../shared/worktree/ownership'
|
||||
import {
|
||||
migrateExternalWorktreeVisibilityDefaults,
|
||||
normalizeWorktreeVisibilityDefaults
|
||||
} from '../shared/external-worktree-visibility'
|
||||
import { sanitizeRepoIcon } from '../shared/repo-icon'
|
||||
import { normalizeRepoBadgeColor } from '../shared/repo-badge-color'
|
||||
import {
|
||||
@@ -3215,6 +3220,13 @@ export class Store {
|
||||
// Merge with defaults in case new fields were added
|
||||
const homeDir = homedir()
|
||||
const defaults = getDefaultPersistedState(homeDir)
|
||||
const migratedExternalVisibility = migrateExternalWorktreeVisibilityDefaults(
|
||||
Array.isArray(parsed.repos) ? parsed.repos : [],
|
||||
parsed.settings?.worktreeVisibilityDefaults
|
||||
)
|
||||
if (migratedExternalVisibility.changed) {
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
const migratedTerminalScrollback = migrateTerminalScrollbackRows(parsed.settings)
|
||||
if (migratedTerminalScrollback.needsSave) {
|
||||
this.loadNeedsSave = true
|
||||
@@ -3484,6 +3496,7 @@ export class Store {
|
||||
parsed.featureInteractionTelemetryBuckets
|
||||
),
|
||||
projectGroups: normalizedProjectGroups,
|
||||
repos: migratedExternalVisibility.repos,
|
||||
folderWorkspaces: normalizeFolderWorkspaces(
|
||||
parsed.folderWorkspaces,
|
||||
normalizedProjectGroups
|
||||
@@ -3502,6 +3515,7 @@ export class Store {
|
||||
...defaults.settings,
|
||||
// Why (#7977): keep persisted experimentalNewWorktreeCardStyle:true — v1.4.130's onboarding auto-wrote it as a plain boolean, so it's indistinguishable from a real opt-in; only the default changed.
|
||||
...stripRetiredGlobalSettings(parsed.settings),
|
||||
worktreeVisibilityDefaults: migratedExternalVisibility.defaults,
|
||||
prBotAuthorOverrides: normalizePRBotAuthorOverrides(
|
||||
parsed.settings?.prBotAuthorOverrides
|
||||
),
|
||||
@@ -4313,6 +4327,10 @@ export class Store {
|
||||
|
||||
// ── Repos ──────────────────────────────────────────────────────────
|
||||
|
||||
getProfileStorageDirectory(): string {
|
||||
return dirname(this.dataFile)
|
||||
}
|
||||
|
||||
getRepos(): Repo[] {
|
||||
return this.state.repos.map((repo) => this.hydrateRepo(repo))
|
||||
}
|
||||
@@ -5002,11 +5020,9 @@ export class Store {
|
||||
| 'symlinkPaths'
|
||||
| 'issueSourcePreference'
|
||||
| 'forkSyncMode'
|
||||
| 'externalWorktreeVisibility'
|
||||
| 'externalWorktreeVisibilityPromptDismissedAt'
|
||||
| 'externalWorktreeInboxBaselinePaths'
|
||||
| 'importedExternalWorktreePaths'
|
||||
| 'agentWorktreeVisibility'
|
||||
| 'customWorktreeVisibilitySources'
|
||||
| 'worktreeVisibilitySourcePreferences'
|
||||
| 'projectGroupId'
|
||||
@@ -5014,6 +5030,8 @@ export class Store {
|
||||
| 'projectHostSetupMethod'
|
||||
>
|
||||
> & {
|
||||
externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null
|
||||
agentWorktreeVisibility?: Repo['agentWorktreeVisibility'] | null
|
||||
sourceControlAi?: Repo['sourceControlAi'] | null
|
||||
externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null
|
||||
},
|
||||
@@ -5075,6 +5093,22 @@ export class Store {
|
||||
delete repo.worktreeBasePath
|
||||
delete sanitizedUpdates.worktreeBasePath
|
||||
}
|
||||
if (
|
||||
'externalWorktreeVisibility' in sanitizedUpdates &&
|
||||
(sanitizedUpdates.externalWorktreeVisibility === undefined ||
|
||||
sanitizedUpdates.externalWorktreeVisibility === null)
|
||||
) {
|
||||
delete repo.externalWorktreeVisibility
|
||||
repo.externalWorktreeVisibilityLegacy = false
|
||||
delete sanitizedUpdates.externalWorktreeVisibility
|
||||
}
|
||||
if (
|
||||
'agentWorktreeVisibility' in sanitizedUpdates &&
|
||||
sanitizedUpdates.agentWorktreeVisibility === null
|
||||
) {
|
||||
delete repo.agentWorktreeVisibility
|
||||
delete sanitizedUpdates.agentWorktreeVisibility
|
||||
}
|
||||
if (
|
||||
'externalWorktreeVisibility' in sanitizedUpdates &&
|
||||
repo.externalWorktreeVisibilityLegacy === undefined
|
||||
@@ -5955,6 +5989,14 @@ export class Store {
|
||||
if ('disabledTuiAgents' in updates) {
|
||||
sanitizedUpdates.disabledTuiAgents = normalizeDisabledTuiAgents(updates.disabledTuiAgents)
|
||||
}
|
||||
if ('worktreeVisibilityDefaults' in updates) {
|
||||
sanitizedUpdates.worktreeVisibilityDefaults = {
|
||||
...this.state.settings.worktreeVisibilityDefaults,
|
||||
...(normalizeWorktreeVisibilityDefaults(updates.worktreeVisibilityDefaults) ?? {
|
||||
external: 'hide'
|
||||
})
|
||||
}
|
||||
}
|
||||
if ('agentDefaultArgs' in updates) {
|
||||
sanitizedUpdates.agentDefaultArgs = normalizeTuiAgentArgsRecord(updates.agentDefaultArgs)
|
||||
sanitizedUpdates.agentYoloDefaultsMigrated = true
|
||||
@@ -6219,6 +6261,10 @@ export class Store {
|
||||
const nextUI = {
|
||||
...currentUI,
|
||||
...durableUpdates,
|
||||
workspaceCleanup: mergeWorkspaceCleanupUIState(
|
||||
currentUI.workspaceCleanup,
|
||||
durableUpdates.workspaceCleanup
|
||||
),
|
||||
groupBy: durableUpdates.groupBy
|
||||
? normalizeGroupBy(durableUpdates.groupBy)
|
||||
: normalizeGroupBy(this.state.ui?.groupBy),
|
||||
|
||||
@@ -19,9 +19,14 @@ export type FileReadResult = {
|
||||
mimeType?: string
|
||||
}
|
||||
|
||||
export type FileReadLimits = {
|
||||
maxBinaryBytes?: number
|
||||
maxTextBytes?: number
|
||||
}
|
||||
|
||||
export type IFilesystemProvider = {
|
||||
readDir(dirPath: string): Promise<DirEntry[]>
|
||||
readFile(filePath: string): Promise<FileReadResult>
|
||||
readFile(filePath: string, limits?: FileReadLimits): Promise<FileReadResult>
|
||||
readTerminalArtifact?(
|
||||
filePath: string,
|
||||
options: TerminalArtifactAccessOptions
|
||||
|
||||
@@ -70,6 +70,7 @@ vi.mock('../pty-descendant-termination', () => ({
|
||||
const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
|
||||
const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
|
||||
const CMD_ABS = 'C:\\Windows\\System32\\cmd.exe'
|
||||
const CODEX_LAUNCH_PREFLIGHT = 'C:\\Program Files\\Orca\\orca.exe'
|
||||
vi.mock('./windows-powershell-executable', () => ({
|
||||
resolveWindowsPowerShellExecutablePath: (family: 'pwsh.exe' | 'powershell.exe') =>
|
||||
family === 'pwsh.exe' ? PWSH7_ABS : WINDOWS_POWERSHELL_ABS,
|
||||
@@ -1371,7 +1372,13 @@ describe('LocalPtyProvider', () => {
|
||||
const originalProgramFiles = process.env.ProgramFiles
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
process.env.ProgramFiles = 'C:\\Program Files'
|
||||
provider.configure({ getWindowsShell: () => 'git-bash' })
|
||||
provider.configure({
|
||||
getWindowsShell: () => 'git-bash',
|
||||
buildSpawnEnv: (_id, env) => ({
|
||||
...env,
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
await provider.spawn({
|
||||
@@ -1392,12 +1399,52 @@ describe('LocalPtyProvider', () => {
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
['-c', 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i'],
|
||||
[
|
||||
'-c',
|
||||
expect.stringMatching(
|
||||
/^chcp\.com 65001 >\/dev\/null 2>&1; exec "\$BASH" --rcfile '.*shell-ready\/bash\/rcfile' -i$/
|
||||
)
|
||||
],
|
||||
expect.objectContaining({
|
||||
cwd: 'C:\\Users\\jin\\repo',
|
||||
env: expect.objectContaining({
|
||||
CHERE_INVOKING: '1',
|
||||
PYTHONUTF8: '1'
|
||||
PYTHONUTF8: '1',
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('runs the Codex preflight once in the cmd.exe startup chain', async () => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
provider.configure({
|
||||
getWindowsShell: () => 'cmd.exe',
|
||||
buildSpawnEnv: (_id, env) => ({
|
||||
...env,
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
await provider.spawn({ cols: 80, rows: 24, cwd: 'C:\\Users\\jin\\repo' })
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'cmd.exe',
|
||||
[
|
||||
'/K',
|
||||
'chcp 65001 > nul & if defined ORCA_CODEX_LAUNCH_PREFLIGHT call %ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%%ORCA_CODEX_LAUNCH_PREFLIGHT%%ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE% agent hooks prepare-codex > nul 2>&1'
|
||||
],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT,
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE: '"'
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/* eslint-disable max-lines -- Why: splitting spawn() would scatter tightly coupled PTY lifecycle logic (scan → ready → write → exit) with no cleaner ownership seam. */
|
||||
import { basename, delimiter, win32 as pathWin32 } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { resolveWindowsShellLaunchArgs } from './windows-shell-args'
|
||||
import {
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV,
|
||||
resolveWindowsShellLaunchArgs
|
||||
} from './windows-shell-args'
|
||||
import {
|
||||
resolveEffectiveWindowsPowerShell,
|
||||
shouldProbeWindowsPowerShellAvailability,
|
||||
@@ -777,6 +780,30 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
delete finalEnv.CODEX_HOME
|
||||
delete finalEnv.ORCA_CODEX_HOME
|
||||
}
|
||||
|
||||
const shellBasename = pathWin32.basename(shellPath).toLowerCase()
|
||||
const codexLaunchPreflightCommand = finalEnv.ORCA_CODEX_LAUNCH_PREFLIGHT
|
||||
if (
|
||||
codexLaunchPreflightCommand &&
|
||||
(shellBasename === 'cmd.exe' || isWindowsGitBashShellPath(shellPath))
|
||||
) {
|
||||
if (shellBasename === 'cmd.exe') {
|
||||
// Why: node-pty backslash-escapes argv quotes; expand the quote inside cmd.exe instead.
|
||||
finalEnv[ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV] = '"'
|
||||
}
|
||||
const resolved = resolveWindowsShellLaunchArgs(
|
||||
shellPath,
|
||||
cwd,
|
||||
defaultCwd,
|
||||
launchWslContext,
|
||||
args.command,
|
||||
codexLaunchPreflightCommand
|
||||
)
|
||||
shellArgs = resolved.shellArgs
|
||||
effectiveCwd = resolved.effectiveCwd
|
||||
validationCwd = resolved.validationCwd
|
||||
startupCommandDeliveredInShellArgs = resolved.startupCommandDeliveredInShellArgs === true
|
||||
}
|
||||
}
|
||||
seedPowerlevel10kWizardEnv(finalEnv, { envToDelete: args.envToDelete })
|
||||
if (
|
||||
|
||||
@@ -155,6 +155,21 @@ describe('SshFilesystemProvider readFile streaming', () => {
|
||||
expect(mux.notify).toHaveBeenCalledWith('fs.cancelStream', { streamId: 1 })
|
||||
})
|
||||
|
||||
it('applies a caller binary cap before allocating the stream buffer', async () => {
|
||||
mux.request.mockResolvedValue({
|
||||
streamId: 2,
|
||||
totalSize: 2,
|
||||
isBinary: true,
|
||||
chunkEncoding: 'base64',
|
||||
resultEncoding: 'base64'
|
||||
})
|
||||
|
||||
await expect(provider.readFile('/home/x.bin', { maxBinaryBytes: 1 })).rejects.toThrow(
|
||||
/exceeds client cap/i
|
||||
)
|
||||
expect(mux.notify).toHaveBeenCalledWith('fs.cancelStream', { streamId: 2 })
|
||||
})
|
||||
|
||||
it('rejects on fs.streamError notification', async () => {
|
||||
const totalSize = 1024
|
||||
mux.request.mockImplementation(async () => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from './ssh-filesystem-provider-watch'
|
||||
import type {
|
||||
IFilesystemProvider,
|
||||
FileReadLimits,
|
||||
FileStat,
|
||||
FileReadResult,
|
||||
FileUploadSession,
|
||||
@@ -88,14 +89,14 @@ export class SshFilesystemProvider implements IFilesystemProvider {
|
||||
return (await this.mux.request('fs.readDir', { dirPath })) as DirEntry[]
|
||||
}
|
||||
|
||||
async readFile(filePath: string): Promise<FileReadResult> {
|
||||
async readFile(filePath: string, limits?: FileReadLimits): Promise<FileReadResult> {
|
||||
// Why: streaming is the default path so previews above the legacy single-
|
||||
// frame budget (~12 MB after base64) don't hit MAX_MESSAGE_SIZE. Old relays
|
||||
// that don't implement fs.readFileStream surface as MethodNotFound; we fall
|
||||
// back to the legacy single-shot fs.readFile (which retains the old 10 MB
|
||||
// cap on those hosts).
|
||||
try {
|
||||
return await readFileViaStream(this.mux, filePath)
|
||||
return await readFileViaStream(this.mux, filePath, limits)
|
||||
} catch (err) {
|
||||
if (isMethodNotFoundError(err)) {
|
||||
if (!this.loggedStreamFallback) {
|
||||
|
||||
@@ -18,6 +18,7 @@ export type {
|
||||
// ─── Filesystem Provider ────────────────────────────────────────────
|
||||
|
||||
export type {
|
||||
FileReadLimits,
|
||||
FileReadResult,
|
||||
FileStat,
|
||||
FileUploadSession,
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
import { resolveSetupRunnerCommand } from '../../shared/setup-runner-command'
|
||||
import { resolveWindowsShellLaunchArgs } from './windows-shell-args'
|
||||
|
||||
const CODEX_LAUNCH_PREFLIGHT = 'C:\\Program Files\\Orca\\orca.exe'
|
||||
const CMD_CODEX_LAUNCH_PREFLIGHT =
|
||||
'if defined ORCA_CODEX_LAUNCH_PREFLIGHT call %ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%%ORCA_CODEX_LAUNCH_PREFLIGHT%%ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE% agent hooks prepare-codex > nul 2>&1'
|
||||
|
||||
function expectedWslArgs(linuxCwd: string, distro?: string): string[] {
|
||||
const command = `cd '${linuxCwd}' && export PATH="$HOME/.local/bin:$PATH" && ${buildWslInteractiveLoginShellCommand()}`
|
||||
const shellArgs = ['--', 'sh', '-c', escapeWslShCommandForWindows(command)]
|
||||
@@ -24,6 +28,14 @@ function expectedPowerShellRestoreCwdCommand(cwdLiteral: string): string {
|
||||
return `try { Set-Location -LiteralPath ${cwdLiteral} -ErrorAction Stop } catch { Write-Warning "Failed to restore working directory: $_" }`
|
||||
}
|
||||
|
||||
function getGitBashRcfilePath(command: string): string {
|
||||
const rcfilePath = command.match(/--rcfile '([^']+)'/)?.[1]
|
||||
if (!rcfilePath) {
|
||||
throw new Error(`Git Bash launch command has no rcfile: ${command}`)
|
||||
}
|
||||
return rcfilePath
|
||||
}
|
||||
|
||||
describe('resolveWindowsShellLaunchArgs', () => {
|
||||
let previousUserDataPath: string | undefined
|
||||
let userDataPath: string
|
||||
@@ -44,8 +56,15 @@ describe('resolveWindowsShellLaunchArgs', () => {
|
||||
})
|
||||
|
||||
it('returns cmd.exe args with chcp 65001 for UTF-8 output', () => {
|
||||
const result = resolveWindowsShellLaunchArgs('cmd.exe', 'C:\\Users\\alice', 'C:\\Users\\alice')
|
||||
expect(result.shellArgs).toEqual(['/K', 'chcp 65001 > nul'])
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'cmd.exe',
|
||||
'C:\\Users\\alice',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
undefined,
|
||||
CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
expect(result.shellArgs).toEqual(['/K', `chcp 65001 > nul & ${CMD_CODEX_LAUNCH_PREFLIGHT}`])
|
||||
expect(result.startupCommandDeliveredInShellArgs).toBeUndefined()
|
||||
expect(result.effectiveCwd).toBe('C:\\Users\\alice')
|
||||
expect(result.validationCwd).toBe('C:\\Users\\alice')
|
||||
@@ -57,9 +76,13 @@ describe('resolveWindowsShellLaunchArgs', () => {
|
||||
'C:\\Users\\alice',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
'codex --no-alt-screen'
|
||||
'codex --no-alt-screen',
|
||||
CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
expect(result.shellArgs).toEqual(['/K', 'chcp 65001 > nul & codex --no-alt-screen'])
|
||||
expect(result.shellArgs).toEqual([
|
||||
'/K',
|
||||
`chcp 65001 > nul & ${CMD_CODEX_LAUNCH_PREFLIGHT} & codex --no-alt-screen`
|
||||
])
|
||||
expect(result.startupCommandDeliveredInShellArgs).toBe(true)
|
||||
})
|
||||
|
||||
@@ -83,9 +106,10 @@ describe('resolveWindowsShellLaunchArgs', () => {
|
||||
'C:\\Users\\alice',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
`codex ${'x'.repeat(7000)}`
|
||||
`codex ${'x'.repeat(7000)}`,
|
||||
CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
expect(result.shellArgs).toEqual(['/K', 'chcp 65001 > nul'])
|
||||
expect(result.shellArgs).toEqual(['/K', `chcp 65001 > nul & ${CMD_CODEX_LAUNCH_PREFLIGHT}`])
|
||||
expect(result.startupCommandDeliveredInShellArgs).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -216,11 +240,32 @@ describe('resolveWindowsShellLaunchArgs', () => {
|
||||
expect(decodePowerShellCommand(result)).toContain(expectedPowerShellRestoreCwdCommand("'C:\\'"))
|
||||
})
|
||||
|
||||
it('does not change PowerShell args when the cmd/Git Bash preflight is provided', () => {
|
||||
const baseline = resolveWindowsShellLaunchArgs(
|
||||
'powershell.exe',
|
||||
'C:\\Users\\alice',
|
||||
'C:\\Users\\alice'
|
||||
)
|
||||
const withWindowsPreflight = resolveWindowsShellLaunchArgs(
|
||||
'powershell.exe',
|
||||
'C:\\Users\\alice',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
undefined,
|
||||
CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
|
||||
expect(withWindowsPreflight).toEqual(baseline)
|
||||
})
|
||||
|
||||
it('starts Git Bash as an interactive login shell with UTF-8 console setup', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
'/c',
|
||||
'C:\\Users\\alice'
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
undefined,
|
||||
CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
|
||||
// Why: Git Bash inherits the ConPTY OEM code page (CP437), so a byte-writing
|
||||
@@ -231,7 +276,8 @@ describe('resolveWindowsShellLaunchArgs', () => {
|
||||
expect(result.shellArgs[0]).toBe('-c')
|
||||
const bashCommand = result.shellArgs[1]
|
||||
expect(bashCommand).toContain('chcp.com 65001')
|
||||
expect(bashCommand).toMatch(/exec "\$BASH" --login -i$/)
|
||||
expect(bashCommand).toContain('--rcfile')
|
||||
expect(bashCommand).toMatch(/exec "\$BASH" .* -i$/)
|
||||
// The UTF-8 switch must run before the login shell is exec'd.
|
||||
expect(bashCommand.indexOf('chcp.com')).toBeLessThan(bashCommand.indexOf('exec'))
|
||||
// Must stay fail-open: `;` (not `&&`) so a missing chcp.com can't abort the
|
||||
@@ -239,6 +285,52 @@ describe('resolveWindowsShellLaunchArgs', () => {
|
||||
expect(bashCommand).not.toContain('&&')
|
||||
expect(result.effectiveCwd).toBe('C:\\')
|
||||
expect(result.validationCwd).toBe('C:\\')
|
||||
|
||||
const bashRcfile = readFileSync(getGitBashRcfilePath(bashCommand), 'utf8')
|
||||
expect(bashRcfile).toContain('"${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex')
|
||||
expect(bashRcfile).not.toContain(CODEX_LAUNCH_PREFLIGHT)
|
||||
})
|
||||
|
||||
it('keeps Git Bash startup commands on stdin delivery with the preflight wrapper', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
'C:\\Users\\alice',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
'codex --no-alt-screen',
|
||||
CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
|
||||
expect(result.shellArgs[1]).toContain('chcp.com 65001')
|
||||
expect(result.shellArgs[1]).toContain('--rcfile')
|
||||
expect(result.startupCommandDeliveredInShellArgs).toBeUndefined()
|
||||
})
|
||||
|
||||
it('quotes a spaced preflight path through each shell environment', () => {
|
||||
const cmd = resolveWindowsShellLaunchArgs(
|
||||
'cmd.exe',
|
||||
'C:\\Users\\alice',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
undefined,
|
||||
CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
const gitBash = resolveWindowsShellLaunchArgs(
|
||||
'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
'C:\\Users\\alice',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
undefined,
|
||||
CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
|
||||
expect(cmd.shellArgs[1]).toContain(
|
||||
'call %ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%%ORCA_CODEX_LAUNCH_PREFLIGHT%%ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%'
|
||||
)
|
||||
expect(cmd.shellArgs[1]).not.toContain('"')
|
||||
expect(cmd.shellArgs[1]).not.toContain(CODEX_LAUNCH_PREFLIGHT)
|
||||
const rcfilePath = getGitBashRcfilePath(gitBash.shellArgs[1])
|
||||
expect(readFileSync(rcfilePath, 'utf8')).toContain('"${ORCA_CODEX_LAUNCH_PREFLIGHT}"')
|
||||
})
|
||||
|
||||
it('does not apply Git Bash launch args to unrelated bash.exe paths', () => {
|
||||
@@ -254,13 +346,22 @@ describe('resolveWindowsShellLaunchArgs', () => {
|
||||
})
|
||||
|
||||
it('translates Windows cwd to /mnt/<drive>/... for wsl.exe', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
const baseline = resolveWindowsShellLaunchArgs(
|
||||
'wsl.exe',
|
||||
'C:\\Users\\alice\\code',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
'codex'
|
||||
)
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'wsl.exe',
|
||||
'C:\\Users\\alice\\code',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
'codex',
|
||||
CODEX_LAUNCH_PREFLIGHT
|
||||
)
|
||||
expect(result).toEqual(baseline)
|
||||
expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/code'))
|
||||
expect(result.startupCommandDeliveredInShellArgs).toBeUndefined()
|
||||
// Why: WSL cannot cd into a Windows path, so node-pty must start from the
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
escapeWslShCommandForWindows,
|
||||
quotePosixShell
|
||||
} from '../../shared/wsl-login-shell-command'
|
||||
import { ensureShellReadyWrappersAt } from './local-pty-shell-ready'
|
||||
import { ensureShellReadyWrappersAt, getMarkerlessShellLaunchConfig } from './local-pty-shell-ready'
|
||||
import {
|
||||
encodePowerShellCommand,
|
||||
getPowerShellOsc133Bootstrap
|
||||
@@ -17,6 +17,8 @@ const CMD_EXE_COMMAND_LINE_MAX_CHARS = 8191
|
||||
const STARTUP_COMMAND_TEXT_MAX_CHARS = 6000
|
||||
const POWERSHELL_ENCODED_COMMAND_ARG_MAX_CHARS = 28_000
|
||||
const CMD_UTF8_SETUP_COMMAND = 'chcp 65001 > nul'
|
||||
export const ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV = 'ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE'
|
||||
const CMD_CODEX_LAUNCH_PREFLIGHT = `if defined ORCA_CODEX_LAUNCH_PREFLIGHT call %${ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV}%%ORCA_CODEX_LAUNCH_PREFLIGHT%%${ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV}% agent hooks prepare-codex > nul 2>&1`
|
||||
// Why: Git for Windows' bash inherits the ConPTY console's OEM code page
|
||||
// (CP437), so a TUI that writes UTF-8 bytes straight to the console — agents
|
||||
// like Claude Code use WriteFile, not WriteConsoleW — renders as mojibake
|
||||
@@ -25,6 +27,22 @@ const CMD_UTF8_SETUP_COMMAND = 'chcp 65001 > nul'
|
||||
// `&&`) keeps startup working even if chcp.com is missing.
|
||||
const GIT_BASH_UTF8_LOGIN_COMMAND = 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i'
|
||||
|
||||
function getGitBashLaunchCommand(codexLaunchPreflightCommand?: string): string {
|
||||
if (!codexLaunchPreflightCommand) {
|
||||
return GIT_BASH_UTF8_LOGIN_COMMAND
|
||||
}
|
||||
|
||||
ensureShellReadyWrappersAt()
|
||||
const wrapperArgs = getMarkerlessShellLaunchConfig('bash').args
|
||||
if (!wrapperArgs) {
|
||||
return GIT_BASH_UTF8_LOGIN_COMMAND
|
||||
}
|
||||
const bashArgs = [...wrapperArgs, '-i']
|
||||
.map((arg) => (arg.startsWith('-') ? arg : quotePosixShell(arg.replace(/\\/g, '/'))))
|
||||
.join(' ')
|
||||
return `chcp.com 65001 >/dev/null 2>&1; exec "$BASH" ${bashArgs}`
|
||||
}
|
||||
|
||||
/** Result of resolving a Windows shell to its launch args + effective cwd.
|
||||
*
|
||||
* Why this module exists: both the in-process LocalPtyProvider and the
|
||||
@@ -159,20 +177,21 @@ export function resolveWindowsShellLaunchArgs(
|
||||
cwd: string,
|
||||
defaultCwd: string,
|
||||
wslContext?: WindowsShellWslContext,
|
||||
startupCommand?: string
|
||||
startupCommand?: string,
|
||||
codexLaunchPreflightCommand?: string
|
||||
): WindowsShellLaunchArgs {
|
||||
const shellBasename = pathWin32.basename(shellPath).toLowerCase()
|
||||
const nativeCwd = normalizeWindowsTerminalCwd(cwd)
|
||||
|
||||
if (shellBasename === 'cmd.exe') {
|
||||
const shellArgStartupCommand = getCmdShellArgStartupCommand(startupCommand)
|
||||
const startupCommands = [
|
||||
CMD_UTF8_SETUP_COMMAND,
|
||||
...(codexLaunchPreflightCommand ? [CMD_CODEX_LAUNCH_PREFLIGHT] : []),
|
||||
...(shellArgStartupCommand ? [shellArgStartupCommand] : [])
|
||||
]
|
||||
return {
|
||||
shellArgs: [
|
||||
'/K',
|
||||
shellArgStartupCommand
|
||||
? `${CMD_UTF8_SETUP_COMMAND} & ${shellArgStartupCommand}`
|
||||
: CMD_UTF8_SETUP_COMMAND
|
||||
],
|
||||
shellArgs: ['/K', startupCommands.join(' & ')],
|
||||
...(shellArgStartupCommand ? { startupCommandDeliveredInShellArgs: true } : {}),
|
||||
effectiveCwd: nativeCwd,
|
||||
validationCwd: nativeCwd
|
||||
@@ -195,7 +214,7 @@ export function resolveWindowsShellLaunchArgs(
|
||||
|
||||
if (isWindowsGitBashShellPath(shellPath)) {
|
||||
return {
|
||||
shellArgs: ['-c', GIT_BASH_UTF8_LOGIN_COMMAND],
|
||||
shellArgs: ['-c', getGitBashLaunchCommand(codexLaunchPreflightCommand)],
|
||||
effectiveCwd: nativeCwd,
|
||||
validationCwd: nativeCwd
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
linkSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import * as pty from 'node-pty'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { resolveGitBashPath } from '../git-bash'
|
||||
import {
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV,
|
||||
resolveWindowsShellLaunchArgs
|
||||
} from './windows-shell-args'
|
||||
|
||||
const describeWindows = process.platform === 'win32' ? describe : describe.skip
|
||||
const tempDirs: string[] = []
|
||||
|
||||
function makeTempDir(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca shell preflight '))
|
||||
tempDirs.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
function linkNodeExecutable(destination: string): void {
|
||||
try {
|
||||
linkSync(process.execPath, destination)
|
||||
} catch {
|
||||
copyFileSync(process.execPath, destination)
|
||||
}
|
||||
}
|
||||
|
||||
function writeFailingPreflight(root: string): string {
|
||||
const executable = join(root, 'orca preflight.exe')
|
||||
linkNodeExecutable(executable)
|
||||
writeFileSync(
|
||||
join(root, 'agent'),
|
||||
[
|
||||
"const { writeFileSync } = require('node:fs')",
|
||||
"if (process.argv.slice(2).join(' ') !== 'hooks prepare-codex') process.exit(2)",
|
||||
"writeFileSync(process.env.ORCA_PREFLIGHT_MARKER, 'ran')",
|
||||
'process.exit(7)'
|
||||
].join('\n')
|
||||
)
|
||||
return executable
|
||||
}
|
||||
|
||||
function withPathEntry(env: NodeJS.ProcessEnv, entry: string): NodeJS.ProcessEnv {
|
||||
const result = { ...env }
|
||||
const pathKey = Object.keys(result).find((key) => key.toLowerCase() === 'path')
|
||||
const inheritedPath = pathKey ? result[pathKey] : ''
|
||||
if (pathKey) {
|
||||
delete result[pathKey]
|
||||
}
|
||||
result.PATH = `${entry};${inheritedPath ?? ''}`
|
||||
return result
|
||||
}
|
||||
|
||||
async function runPty(options: {
|
||||
shellPath: string
|
||||
shellArgs: string[]
|
||||
cwd: string
|
||||
env: NodeJS.ProcessEnv
|
||||
input?: string
|
||||
timeoutMs?: number
|
||||
}): Promise<string> {
|
||||
const proc = pty.spawn(options.shellPath, options.shellArgs, {
|
||||
name: 'xterm-256color',
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
cwd: options.cwd,
|
||||
env: options.env
|
||||
})
|
||||
let output = ''
|
||||
proc.onData((data) => {
|
||||
output += data
|
||||
})
|
||||
const exitPromise = new Promise<number>((resolve) => {
|
||||
proc.onExit(({ exitCode }) => resolve(exitCode))
|
||||
})
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(
|
||||
() => reject(new Error(`timed out waiting for Windows shell PTY:\n${output}`)),
|
||||
options.timeoutMs ?? 10_000
|
||||
)
|
||||
})
|
||||
|
||||
try {
|
||||
if (options.input) {
|
||||
proc.write(options.input.replaceAll('\n', '\r'))
|
||||
}
|
||||
const exitCode = await Promise.race([exitPromise, timeoutPromise])
|
||||
expect(exitCode, output).toBe(0)
|
||||
return output
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
try {
|
||||
proc.kill()
|
||||
} catch {
|
||||
// The PTY may already have exited.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of tempDirs.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describeWindows('Windows Codex shell preflight runtime', () => {
|
||||
it('runs a spaced executable through cmd.exe and continues after failure', async () => {
|
||||
const root = makeTempDir()
|
||||
const preflight = writeFailingPreflight(root)
|
||||
const preflightMarker = join(root, 'cmd-preflight-ran')
|
||||
const startupMarker = join(root, 'cmd-started')
|
||||
const resolved = resolveWindowsShellLaunchArgs(
|
||||
'cmd.exe',
|
||||
root,
|
||||
root,
|
||||
undefined,
|
||||
'echo launched>cmd-started & exit /b 0',
|
||||
preflight
|
||||
)
|
||||
|
||||
await runPty({
|
||||
shellPath: 'cmd.exe',
|
||||
shellArgs: resolved.shellArgs,
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: preflight,
|
||||
[ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV]: '"',
|
||||
ORCA_PREFLIGHT_MARKER: preflightMarker
|
||||
}
|
||||
})
|
||||
|
||||
expect(readFileSync(preflightMarker, 'utf8')).toBe('ran')
|
||||
expect(readFileSync(startupMarker, 'utf8').trim()).toBe('launched')
|
||||
})
|
||||
|
||||
it('runs the typed-Codex wrapper through Git Bash without MSYS switch rewriting', async () => {
|
||||
const gitBash = resolveGitBashPath()
|
||||
expect(gitBash).not.toBeNull()
|
||||
if (!gitBash) {
|
||||
return
|
||||
}
|
||||
|
||||
const root = makeTempDir()
|
||||
const preflight = writeFailingPreflight(root)
|
||||
const codexExecutable = join(root, 'codex.exe')
|
||||
linkNodeExecutable(codexExecutable)
|
||||
const preflightMarker = join(root, 'git-bash-preflight-ran')
|
||||
const codexMarker = join(root, 'git-bash-codex-ran')
|
||||
const previousUserDataPath = process.env.ORCA_USER_DATA_PATH
|
||||
process.env.ORCA_USER_DATA_PATH = join(root, 'user data')
|
||||
|
||||
try {
|
||||
const resolved = resolveWindowsShellLaunchArgs(
|
||||
gitBash,
|
||||
root,
|
||||
root,
|
||||
undefined,
|
||||
undefined,
|
||||
preflight
|
||||
)
|
||||
await runPty({
|
||||
shellPath: gitBash,
|
||||
shellArgs: resolved.shellArgs,
|
||||
cwd: root,
|
||||
env: {
|
||||
...withPathEntry(process.env, root),
|
||||
CHERE_INVOKING: '1',
|
||||
HOME: root,
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: preflight,
|
||||
ORCA_PREFLIGHT_MARKER: preflightMarker,
|
||||
ORCA_CODEX_MARKER: codexMarker,
|
||||
TERM: 'xterm-256color'
|
||||
},
|
||||
input:
|
||||
"codex -e \"require('node:fs').writeFileSync(process.env.ORCA_CODEX_MARKER,'ran')\"\nexit\n",
|
||||
// Paired "Windows low spec" QA measured 12.7–15.8s across four runs: Git Bash
|
||||
// cold-starts two large Node executables for AV scanning, so allow 25s without
|
||||
// inflating the faster cmd.exe budget.
|
||||
timeoutMs: 25_000
|
||||
})
|
||||
} finally {
|
||||
if (previousUserDataPath === undefined) {
|
||||
delete process.env.ORCA_USER_DATA_PATH
|
||||
} else {
|
||||
process.env.ORCA_USER_DATA_PATH = previousUserDataPath
|
||||
}
|
||||
}
|
||||
|
||||
expect(existsSync(preflightMarker)).toBe(true)
|
||||
expect(readFileSync(codexMarker, 'utf8')).toBe('ran')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { delimiter, join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { getBundledLauncherPath } from '../cli/bundled-cli-launcher-path'
|
||||
import { getDaemonBashShellReadyRcfileContent } from '../daemon/shell-ready'
|
||||
import { resolveCodexShellLaunchPreflightCommand } from './codex-shell-launch-preflight'
|
||||
|
||||
// Why (STA-4270): the generated rcfile sources /etc/profile and the user's profile
|
||||
// *before* it defines the codex() wrapper, so a profile PATH prepend decides what an
|
||||
// unqualified preflight command resolves to. This drives the real generated rcfile
|
||||
// through a real bash and uses filesystem markers — not scraped output — as the oracle.
|
||||
|
||||
const roots: string[] = []
|
||||
const bashAvailable = process.platform !== 'win32' && existsSync('/bin/bash')
|
||||
|
||||
type Fixture = {
|
||||
root: string
|
||||
resourcesPath: string
|
||||
intendedMarker: string
|
||||
hijackMarker: string
|
||||
codexMarker: string
|
||||
homePath: string
|
||||
}
|
||||
|
||||
function writeStub(path: string, markerPath: string): void {
|
||||
mkdirSync(join(path, '..'), { recursive: true })
|
||||
writeFileSync(path, `#!/bin/sh\nprintf '%s\\n' "$*" > ${JSON.stringify(markerPath)}\n`)
|
||||
chmodSync(path, 0o755)
|
||||
}
|
||||
|
||||
function buildFixture(): Fixture {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-codex-profile-path-'))
|
||||
roots.push(root)
|
||||
const resourcesPath = join(root, 'resources')
|
||||
const hijackDir = join(root, 'hijack')
|
||||
const codexDir = join(root, 'codex-bin')
|
||||
const homePath = join(root, 'home')
|
||||
const intendedMarker = join(root, 'intended-ran')
|
||||
const hijackMarker = join(root, 'hijack-ran')
|
||||
const codexMarker = join(root, 'codex-ran')
|
||||
mkdirSync(hijackDir, { recursive: true })
|
||||
mkdirSync(codexDir, { recursive: true })
|
||||
mkdirSync(homePath, { recursive: true })
|
||||
|
||||
// The CLI Orca ships, at the absolute path Orca controls.
|
||||
writeStub(getBundledLauncherPath(process.platform, resourcesPath) as string, intendedMarker)
|
||||
// The impostor a user's own bin directory could hold under every CLI name Orca uses.
|
||||
for (const name of ['orca', 'orca-ide', 'orca-dev']) {
|
||||
writeStub(join(hijackDir, name), hijackMarker)
|
||||
}
|
||||
writeStub(join(codexDir, 'codex'), codexMarker)
|
||||
|
||||
// Why: /etc/profile runs first and macOS path_helper rebuilds PATH from /etc/paths,
|
||||
// so the profile must re-prepend both dirs to model a real user's dotfile winning.
|
||||
writeFileSync(
|
||||
join(homePath, '.bash_profile'),
|
||||
`export PATH=${JSON.stringify(codexDir)}:"$PATH"\nexport PATH=${JSON.stringify(hijackDir)}:"$PATH"\n`
|
||||
)
|
||||
return { root, resourcesPath, intendedMarker, hijackMarker, codexMarker, homePath }
|
||||
}
|
||||
|
||||
function launchCodexThroughRcfile(fixture: Fixture, preflightValue: string): void {
|
||||
const rcfilePath = join(fixture.root, 'rcfile')
|
||||
writeFileSync(rcfilePath, getDaemonBashShellReadyRcfileContent(), 'utf8')
|
||||
execFileSync('/bin/bash', ['--rcfile', rcfilePath, '-i', '-c', 'codex --version'], {
|
||||
stdio: 'ignore',
|
||||
env: {
|
||||
HOME: fixture.homePath,
|
||||
// Why: the hijack dir is deliberately absent here — only the profile adds it.
|
||||
PATH: ['/usr/bin', '/bin', '/usr/sbin', '/sbin'].join(delimiter),
|
||||
TERM: 'dumb',
|
||||
SHELL: '/bin/bash',
|
||||
ORCA_SHELL_READY_MARKER: '0',
|
||||
ORCA_SHELL_STARTUP_IDENTITY: '0',
|
||||
ORCA_CODEX_HOME: join(fixture.root, 'codex-home'),
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: preflightValue
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe.skipIf(!bashAvailable)('Codex preflight under a profile-rewritten PATH', () => {
|
||||
it('sources the user profile before emitting the codex wrapper', () => {
|
||||
const rcfile = getDaemonBashShellReadyRcfileContent()
|
||||
|
||||
expect(rcfile.indexOf('source "$HOME/.bash_profile"')).toBeLessThan(
|
||||
rcfile.indexOf('ORCA_CODEX_LAUNCH_PREFLIGHT')
|
||||
)
|
||||
})
|
||||
|
||||
it('runs the bundled CLI the resolver picked, not the impostor the profile put first', () => {
|
||||
const fixture = buildFixture()
|
||||
const preflightCommand = resolveCodexShellLaunchPreflightCommand({
|
||||
hooksEnabled: true,
|
||||
isPackaged: true,
|
||||
managedHomePath: join(fixture.root, 'codex-home'),
|
||||
userDataPath: join(fixture.root, 'user-data'),
|
||||
resourcesPath: fixture.resourcesPath
|
||||
})
|
||||
expect(preflightCommand).toBe(getBundledLauncherPath(process.platform, fixture.resourcesPath))
|
||||
|
||||
launchCodexThroughRcfile(fixture, preflightCommand as string)
|
||||
|
||||
expect(existsSync(fixture.intendedMarker)).toBe(true)
|
||||
expect(existsSync(fixture.hijackMarker)).toBe(false)
|
||||
// Guard against a vacuous pass: the wrapper must still have reached `command codex`.
|
||||
expect(existsSync(fixture.codexMarker)).toBe(true)
|
||||
})
|
||||
|
||||
// Why: pins the defect itself, so a regression back to an unqualified name fails here.
|
||||
it('would run the impostor if the preflight carried an unqualified command name', () => {
|
||||
const fixture = buildFixture()
|
||||
|
||||
launchCodexThroughRcfile(fixture, 'orca')
|
||||
|
||||
expect(existsSync(fixture.hijackMarker)).toBe(true)
|
||||
expect(existsSync(fixture.intendedMarker)).toBe(false)
|
||||
expect(existsSync(fixture.codexMarker)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { delimiter, join } from 'node:path'
|
||||
import { delimiter, isAbsolute, join } from 'node:path'
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
@@ -198,21 +198,112 @@ describe('PowerShell Codex shell launch preflight', () => {
|
||||
})
|
||||
|
||||
describe('Codex shell launch preflight command', () => {
|
||||
it('uses the packaged or development CLI only for native managed homes', () => {
|
||||
function makeCliRoot(): { root: string; userDataPath: string; resourcesPath: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-codex-preflight-cli-'))
|
||||
roots.push(root)
|
||||
const userDataPath = join(root, 'user-data')
|
||||
const resourcesPath = join(root, 'resources')
|
||||
mkdirSync(join(userDataPath, 'cli', 'bin'), { recursive: true })
|
||||
mkdirSync(join(resourcesPath, 'bin'), { recursive: true })
|
||||
return { root, userDataPath, resourcesPath }
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ platform: 'darwin' as const, bundled: 'orca' },
|
||||
{ platform: 'linux' as const, bundled: 'orca-ide' },
|
||||
{ platform: 'win32' as const, bundled: 'orca.exe' }
|
||||
])('carries the verified bundled $platform launcher as an absolute path', (config) => {
|
||||
const { userDataPath, resourcesPath } = makeCliRoot()
|
||||
const launcherPath = join(resourcesPath, 'bin', config.bundled)
|
||||
writeExecutable(launcherPath, '#!/bin/sh\nexit 0\n')
|
||||
|
||||
expect(
|
||||
resolveCodexShellLaunchPreflightCommand({
|
||||
hooksEnabled: true,
|
||||
isPackaged: true,
|
||||
managedHomePath: '/managed/home'
|
||||
managedHomePath: '/managed/home',
|
||||
userDataPath,
|
||||
resourcesPath,
|
||||
platform: config.platform
|
||||
})
|
||||
).toBe('orca')
|
||||
).toBe(launcherPath)
|
||||
})
|
||||
|
||||
it('carries the verified dev launcher as an absolute path', () => {
|
||||
const { userDataPath, resourcesPath } = makeCliRoot()
|
||||
const launcherPath = join(userDataPath, 'cli', 'bin', 'orca-dev')
|
||||
writeExecutable(launcherPath, '#!/bin/sh\nexit 0\n')
|
||||
|
||||
expect(
|
||||
resolveCodexShellLaunchPreflightCommand({
|
||||
hooksEnabled: true,
|
||||
isPackaged: false,
|
||||
managedHomePath: '/managed/home'
|
||||
managedHomePath: '/managed/home',
|
||||
userDataPath,
|
||||
resourcesPath,
|
||||
platform: 'darwin'
|
||||
})
|
||||
).toBe('orca-dev')
|
||||
).toBe(launcherPath)
|
||||
})
|
||||
|
||||
it('never returns an unqualified command name that a profile-rewritten PATH could hijack', () => {
|
||||
const { userDataPath, resourcesPath } = makeCliRoot()
|
||||
writeExecutable(join(resourcesPath, 'bin', 'orca'), '#!/bin/sh\nexit 0\n')
|
||||
writeExecutable(join(userDataPath, 'cli', 'bin', 'orca-dev'), '#!/bin/sh\nexit 0\n')
|
||||
|
||||
for (const isPackaged of [true, false]) {
|
||||
const command = resolveCodexShellLaunchPreflightCommand({
|
||||
hooksEnabled: true,
|
||||
isPackaged,
|
||||
managedHomePath: '/managed/home',
|
||||
userDataPath,
|
||||
resourcesPath,
|
||||
platform: 'darwin'
|
||||
})
|
||||
expect(command).not.toBeNull()
|
||||
expect(isAbsolute(command as string)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ label: 'the launcher file is missing', create: null },
|
||||
{ label: 'the launcher is not executable', create: 0o644 },
|
||||
{ label: 'the launcher path is a directory', create: 'directory' as const }
|
||||
])('skips the preflight when $label', (config) => {
|
||||
const { userDataPath, resourcesPath } = makeCliRoot()
|
||||
const launcherPath = join(resourcesPath, 'bin', 'orca')
|
||||
if (config.create === 'directory') {
|
||||
mkdirSync(launcherPath)
|
||||
} else if (config.create !== null) {
|
||||
writeFileSync(launcherPath, '#!/bin/sh\nexit 0\n')
|
||||
chmodSync(launcherPath, config.create)
|
||||
}
|
||||
|
||||
expect(
|
||||
resolveCodexShellLaunchPreflightCommand({
|
||||
hooksEnabled: true,
|
||||
isPackaged: true,
|
||||
managedHomePath: '/managed/home',
|
||||
userDataPath,
|
||||
resourcesPath,
|
||||
platform: 'darwin'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('skips the preflight when the packaged build exposes no resources root', () => {
|
||||
const { userDataPath } = makeCliRoot()
|
||||
|
||||
expect(
|
||||
resolveCodexShellLaunchPreflightCommand({
|
||||
hooksEnabled: true,
|
||||
isPackaged: true,
|
||||
managedHomePath: '/managed/home',
|
||||
userDataPath,
|
||||
resourcesPath: null,
|
||||
platform: 'darwin'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -220,6 +311,103 @@ describe('Codex shell launch preflight command', () => {
|
||||
{ hooksEnabled: true, isWsl: true, managedHomePath: '/managed/home' },
|
||||
{ hooksEnabled: true, isWsl: false, managedHomePath: null }
|
||||
])('does not enable an unsupported preflight for %o', (options) => {
|
||||
expect(resolveCodexShellLaunchPreflightCommand({ ...options, isPackaged: true })).toBeNull()
|
||||
const { userDataPath, resourcesPath } = makeCliRoot()
|
||||
writeExecutable(join(resourcesPath, 'bin', 'orca'), '#!/bin/sh\nexit 0\n')
|
||||
|
||||
expect(
|
||||
resolveCodexShellLaunchPreflightCommand({
|
||||
...options,
|
||||
isPackaged: true,
|
||||
userDataPath,
|
||||
resourcesPath,
|
||||
platform: 'darwin'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// Why: the resolved value is now an absolute path, and app bundles (macOS) and
|
||||
// Program Files (Windows) both put spaces in it.
|
||||
describe.skipIf(process.platform === 'win32')('Codex preflight paths containing spaces', () => {
|
||||
function writeSpacedPreflight(root: string): { preflightPath: string; markerPath: string } {
|
||||
const dir = join(root, 'Orca Dev.app', 'Contents', 'Resources', 'bin')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
const markerPath = join(root, 'preflight-ran')
|
||||
const preflightPath = join(dir, 'orca')
|
||||
writeExecutable(
|
||||
preflightPath,
|
||||
`#!/bin/sh\n[ "$1 $2 $3" = "agent hooks prepare-codex" ] || exit 2\nprintf ran > ${JSON.stringify(markerPath)}\n`
|
||||
)
|
||||
return { preflightPath, markerPath }
|
||||
}
|
||||
|
||||
it('invokes a POSIX preflight whose absolute path contains spaces', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-codex-spaced-posix-'))
|
||||
roots.push(root)
|
||||
const bin = join(root, 'bin')
|
||||
mkdirSync(bin)
|
||||
writeExecutable(join(bin, 'codex'), '#!/bin/sh\nexit 0\n')
|
||||
const { preflightPath, markerPath } = writeSpacedPreflight(root)
|
||||
|
||||
execFileSync(
|
||||
'/bin/bash',
|
||||
['--noprofile', '--norc', '-c', `${getPosixCodexShellLaunchPreflight()}\ncodex`],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${bin}${delimiter}${process.env.PATH ?? ''}`,
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: preflightPath
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(existsSync(markerPath)).toBe(true)
|
||||
})
|
||||
|
||||
it.skipIf(!fishAvailable)('invokes a fish preflight whose absolute path contains spaces', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-codex-spaced-fish-'))
|
||||
roots.push(root)
|
||||
const bin = join(root, 'bin')
|
||||
mkdirSync(bin)
|
||||
writeExecutable(join(bin, 'codex'), '#!/bin/sh\nexit 0\n')
|
||||
const { preflightPath, markerPath } = writeSpacedPreflight(root)
|
||||
|
||||
execFileSync('fish', ['--no-config', '-c', `${getFishCodexShellLaunchPreflight()}\ncodex`], {
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${bin}${delimiter}${process.env.PATH ?? ''}`,
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: preflightPath
|
||||
}
|
||||
})
|
||||
|
||||
expect(existsSync(markerPath)).toBe(true)
|
||||
})
|
||||
|
||||
it.skipIf(!pwshAvailable)(
|
||||
'invokes a PowerShell preflight whose absolute path contains spaces',
|
||||
() => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-codex-spaced-pwsh-'))
|
||||
roots.push(root)
|
||||
const bin = join(root, 'bin')
|
||||
mkdirSync(bin)
|
||||
writeExecutable(join(bin, 'codex'), '#!/bin/sh\nprintf launched\n')
|
||||
const { preflightPath, markerPath } = writeSpacedPreflight(root)
|
||||
|
||||
const result = spawnSync(
|
||||
'pwsh',
|
||||
['-NoLogo', '-NoProfile', '-Command', `${getPowerShellCodexShellLaunchPreflight()}\ncodex`],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${bin}${delimiter}${process.env.PATH ?? ''}`,
|
||||
ORCA_CODEX_LAUNCH_PREFLIGHT: preflightPath
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(existsSync(markerPath)).toBe(true)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,13 +1,61 @@
|
||||
export function resolveCodexShellLaunchPreflightCommand(options: {
|
||||
import { accessSync, constants, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { getBundledLauncherPath } from '../cli/bundled-cli-launcher-path'
|
||||
|
||||
const DEV_LAUNCHER_DIR = ['cli', 'bin']
|
||||
const DEV_COMMAND_NAME = 'orca-dev'
|
||||
|
||||
export type CodexShellLaunchPreflightCommandOptions = {
|
||||
hooksEnabled: boolean
|
||||
isPackaged: boolean
|
||||
isWsl?: boolean
|
||||
managedHomePath: string | null
|
||||
}): string | null {
|
||||
/** Where the dev launcher is written; `join(userDataPath, 'cli', 'bin')` is also what managed dev PTYs prepend to PATH. */
|
||||
userDataPath: string
|
||||
/** Packaged app resources root; the bundled launcher lives under it. */
|
||||
resourcesPath?: string | null
|
||||
/** Test seam. */
|
||||
platform?: NodeJS.Platform
|
||||
}
|
||||
|
||||
/** Absolute path of the Orca CLI the preflight must execute, or null to skip it.
|
||||
*
|
||||
* Why absolute: the value rides in ORCA_CODEX_LAUNCH_PREFLIGHT and is invoked
|
||||
* from the codex() wrapper, which shell-ready emits *after* the user's profile
|
||||
* scripts run. Those scripts routinely rewrite PATH, so an unqualified name
|
||||
* would be resolved against a PATH Orca neither controls nor can predict —
|
||||
* handing Orca's managed Codex environment to an unidentified program. When no
|
||||
* path verifies, skipping the preflight is the predictable degradation. */
|
||||
export function resolveCodexShellLaunchPreflightCommand(
|
||||
options: CodexShellLaunchPreflightCommandOptions
|
||||
): string | null {
|
||||
if (!options.hooksEnabled || options.isWsl || !options.managedHomePath) {
|
||||
return null
|
||||
}
|
||||
return options.isPackaged ? 'orca' : 'orca-dev'
|
||||
const platform = options.platform ?? process.platform
|
||||
const candidate = options.isPackaged
|
||||
? options.resourcesPath
|
||||
? getBundledLauncherPath(platform, options.resourcesPath)
|
||||
: null
|
||||
: join(
|
||||
options.userDataPath,
|
||||
...DEV_LAUNCHER_DIR,
|
||||
platform === 'win32' ? `${DEV_COMMAND_NAME}.cmd` : DEV_COMMAND_NAME
|
||||
)
|
||||
return candidate && isExecutableFileOnDisk(candidate, platform) ? candidate : null
|
||||
}
|
||||
|
||||
function isExecutableFileOnDisk(path: string, platform: NodeJS.Platform): boolean {
|
||||
try {
|
||||
if (!statSync(path).isFile()) {
|
||||
return false
|
||||
}
|
||||
// Why: Windows has no exec bit, so a readable launcher file is the strongest signal available.
|
||||
accessSync(path, platform === 'win32' ? constants.R_OK : constants.X_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function getPosixCodexShellLaunchPreflight(): string {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { accessSync, constants, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { accessSync, constants, readFileSync, statSync } from 'node:fs'
|
||||
import { basename, join } from 'node:path'
|
||||
|
||||
const SHELL_DOLLAR = '$'
|
||||
|
||||
@@ -36,13 +36,14 @@ function isExecutable(candidate: string): boolean {
|
||||
// directly rather than falling through to an ambient lookup.
|
||||
const WINDOWS_POSIX_INTERPRETER = '/bin/bash'
|
||||
|
||||
/** Returns null when no absolute interpreter can be verified; see the caller for what that means. */
|
||||
export function resolvePosixTombstoneInterpreter(
|
||||
pathValue: string | undefined = process.env.PATH,
|
||||
// Why: injectable so the PATH-search branch below is reachable in tests on hosts that do have
|
||||
// a well-known bash.
|
||||
candidates: readonly string[] = POSIX_INTERPRETER_CANDIDATES,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): string {
|
||||
): string | null {
|
||||
if (platform === 'win32') {
|
||||
return WINDOWS_POSIX_INTERPRETER
|
||||
}
|
||||
@@ -64,25 +65,53 @@ export function resolvePosixTombstoneInterpreter(
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
// Why: last resort. Leaves the ambient lookup in place, but only when no absolute bash exists
|
||||
// anywhere, in which case the wrapper would not run at all otherwise.
|
||||
return '/usr/bin/env bash'
|
||||
// Why: no absolute interpreter anywhere. Callers must delete the legacy wrapper rather than
|
||||
// write one with an ambient shebang, which would resolve bash from the cwd.
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute interpreter the wrapper being replaced already used, or null.
|
||||
*
|
||||
* Why: when this process cannot see an absolute bash, deleting the wrapper strands a shell that
|
||||
* has the path hashed -- it reports 127 rather than falling through to the next PATH entry. The
|
||||
* file about to be overwritten ran on this host, so its own shebang names an interpreter known to
|
||||
* work here, and reusing it keeps the compatibility promise without an ambient lookup.
|
||||
*/
|
||||
export function readVerifiedShebangInterpreter(filePath: string): string | null {
|
||||
let firstLine: string
|
||||
try {
|
||||
firstLine = readFileSync(filePath, 'utf8').split('\n', 1)[0] ?? ''
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const match = /^#!\s*(\S+)/.exec(firstLine)
|
||||
const interpreter = match?.[1]
|
||||
if (!interpreter?.startsWith('/')) {
|
||||
return null
|
||||
}
|
||||
// Why exactly bash: the body below uses BASH_SOURCE, [[, and local, so any other shell fails at
|
||||
// runtime -- a #!/bin/zsh wrapper was accepted and then exited 1 on `BASH_SOURCE[0]: parameter
|
||||
// not set`. This also rejects `#!/usr/bin/env bash`, which is absolute but defers the real
|
||||
// lookup to PATH, the current-directory exposure this whole path exists to avoid.
|
||||
if (basename(interpreter) !== 'bash') {
|
||||
return null
|
||||
}
|
||||
return isExecutable(interpreter) ? interpreter : null
|
||||
}
|
||||
|
||||
const POSIX_TOMBSTONE = String.raw`#!__ORCA_INTERPRETER__
|
||||
set -u
|
||||
|
||||
command_name="__ORCA_COMMAND__"
|
||||
# Why: parameter expansion, not the external dirname — if that were unresolvable the substitution
|
||||
# would be empty and cd into it succeeds, silently making wrapper_dir the cwd so the wrapper
|
||||
# fails to exclude itself from PATH.
|
||||
# Why this shape, rather than dirname: an unresolvable external would leave the substitution empty
|
||||
# and cd into it succeeds, silently making wrapper_dir the cwd. With no slash the %/* strip yields the
|
||||
# file name rather than a directory, so that case takes $PWD. CDPATH is cleared because cd searches
|
||||
# it for a relative operand and echoes where it landed, which the substitution would capture.
|
||||
# Each of these made self-exclusion miss the wrapper's own directory, so the lookup resolved back
|
||||
# to this script.
|
||||
wrapper_src="${SHELL_DOLLAR}{BASH_SOURCE[0]}"
|
||||
case "$wrapper_src" in
|
||||
# Why: with no slash the %/* strip yields the file name, not a directory, so self-exclusion
|
||||
# would miss the wrapper's own dir and the lookup would resolve back to this script.
|
||||
# Why: with CDPATH set, cd searches it for a relative operand and echoes the directory it lands
|
||||
# in, which the command substitution then captures — wrapper_dir ends up wrong and doubled.
|
||||
# Clear it for this one command.
|
||||
*/*) wrapper_dir="$(CDPATH= cd -P -- "${SHELL_DOLLAR}{wrapper_src%/*}" 2>/dev/null && pwd)" ;;
|
||||
*) wrapper_dir="$PWD" ;;
|
||||
esac
|
||||
@@ -99,7 +128,7 @@ filter_path() {
|
||||
local filtered_path=""
|
||||
local separator=""
|
||||
path_entry_kept=0
|
||||
local entry normalized candidate has_more
|
||||
local entry normalized has_more
|
||||
while true; do
|
||||
if [[ "$remaining" == *:* ]]; then
|
||||
entry="${SHELL_DOLLAR}{remaining%%:*}"
|
||||
@@ -113,14 +142,22 @@ filter_path() {
|
||||
while [[ "$normalized" != "/" && "$normalized" == */ ]]; do
|
||||
normalized="${SHELL_DOLLAR}{normalized%/}"
|
||||
done
|
||||
candidate="${SHELL_DOLLAR}{entry:-.}"
|
||||
if [[ "$entry" != /* ]]; then
|
||||
# Why: an empty or relative PATH element resolves against the current directory, so keeping
|
||||
# it would let a repository-local git/gh win the lookup below.
|
||||
:
|
||||
elif [[ -n "$legacy_target" && "$normalized" == "$legacy_target" ]]; then
|
||||
:
|
||||
elif [[ "$candidate" -ef "$wrapper_dir" ]]; then
|
||||
elif [[ "$legacy_target" == /* && "$entry" -ef "$legacy_target" ]]; then
|
||||
# Why -ef as well as the lexical test above: it compares filesystem identity, so it catches
|
||||
# the same directory reached through a symlink or a /legacy/../legacy spelling. It is false
|
||||
# when either path is gone, which is when the lexical test still holds, so neither alone is
|
||||
# enough.
|
||||
# Why only for an absolute target: bash resolves a relative -ef operand against the current
|
||||
# directory, so a relative ORCA_ATTRIBUTION_SHIM_DIR let the cwd decide which PATH entry
|
||||
# counted as the legacy directory and got a legitimate one skipped.
|
||||
:
|
||||
elif [[ "$entry" -ef "$wrapper_dir" ]]; then
|
||||
:
|
||||
else
|
||||
filtered_path+="$separator$entry"
|
||||
@@ -152,7 +189,7 @@ PATH="$cleaned_path" exec "$real_command" "$@"
|
||||
|
||||
export function renderLegacyTerminalPosixTombstone(
|
||||
command: 'git' | 'gh',
|
||||
interpreter = resolvePosixTombstoneInterpreter()
|
||||
interpreter: string = resolvePosixTombstoneInterpreter() ?? WINDOWS_POSIX_INTERPRETER
|
||||
): string {
|
||||
return POSIX_TOMBSTONE.replaceAll('__ORCA_INTERPRETER__', interpreter).replaceAll(
|
||||
'__ORCA_COMMAND__',
|
||||
|
||||
@@ -13,9 +13,14 @@ import {
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { resolvePosixTombstoneInterpreter } from './legacy-terminal-posix-tombstone'
|
||||
import type * as PosixTombstoneModule from './legacy-terminal-posix-tombstone'
|
||||
import {
|
||||
readVerifiedShebangInterpreter,
|
||||
resolvePosixTombstoneInterpreter
|
||||
} from './legacy-terminal-posix-tombstone'
|
||||
import {
|
||||
__resetLegacyTerminalShimNeutralizationForTests,
|
||||
isLegacyTerminalShimPathEntry,
|
||||
neutralizeLegacyTerminalShimDir,
|
||||
stripLegacyTerminalShimEnv
|
||||
} from './legacy-terminal-shim-dir'
|
||||
@@ -83,153 +88,6 @@ describe('legacy terminal shim neutralization', () => {
|
||||
expect(version.trim()).not.toBe('7')
|
||||
})
|
||||
|
||||
it('rejects stale Windows real-command paths inside the wrapper directory', () => {
|
||||
const userData = makeUserDataDir()
|
||||
const win32Dir = join(userData, 'orca-terminal-attribution', 'win32')
|
||||
mkdirSync(win32Dir, { recursive: true })
|
||||
|
||||
neutralizeLegacyTerminalShimDir(userData)
|
||||
|
||||
const cmd = readFileSync(join(win32Dir, 'git.cmd'), 'utf8')
|
||||
expect(cmd).toContain(
|
||||
'if defined orca_real for %%G in ("%orca_real%") do if /I "%%~dpG"=="%~dp0" set "orca_real="'
|
||||
)
|
||||
// Why: a captured path that no longer exists must be cleared, or the where.exe fallback below
|
||||
// is skipped and the wrapper execs a missing binary.
|
||||
expect(cmd).toContain('if defined orca_real if not exist "%orca_real%" set "orca_real="')
|
||||
expectOrdered(cmd, 'if not exist "%orca_real%"', ':orca_try_candidate')
|
||||
const powershell = readFileSync(join(win32Dir, 'git-wrapper.ps1'), 'utf8')
|
||||
expect(powershell).toContain('[StringComparison]::OrdinalIgnoreCase')
|
||||
expect(powershell).toContain('$realCommand = $null')
|
||||
expectOrdered(
|
||||
powershell,
|
||||
'[StringComparison]::OrdinalIgnoreCase',
|
||||
'Test-Path -LiteralPath $realCommand'
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves Windows fallbacks against PATH only, never the current directory', () => {
|
||||
// Why (STA-4169): bare `where.exe git.exe` searches cwd before PATH, so a repository-local
|
||||
// git.exe/gh.exe could be executed with the user's arguments.
|
||||
const userData = makeUserDataDir()
|
||||
const win32Dir = join(userData, 'orca-terminal-attribution', 'win32')
|
||||
mkdirSync(win32Dir, { recursive: true })
|
||||
|
||||
neutralizeLegacyTerminalShimDir(userData)
|
||||
|
||||
for (const command of ['git', 'gh'] as const) {
|
||||
const cmd = readFileSync(join(win32Dir, `${command}.cmd`), 'utf8')
|
||||
// No where.exe at all: it searches cwd first, so the wrapper walks the cleaned PATH.
|
||||
expect(cmd).not.toContain('where.exe')
|
||||
expect(cmd).toContain('for %%P in ("%orca_clean_path:;=" "%") do call :orca_try_candidate')
|
||||
expect(cmd).toContain(`if exist "%orca_candidate_dir%\\${command}.exe"`)
|
||||
// Why: %~f preserves a trailing separator; without normalizing, a wrapper-dir entry
|
||||
// spelled with one escapes self-exclusion and the wrapper tail-loops on itself.
|
||||
// Why: `if "%var:~-1%"=="\\"` breaks cmd's parser, so the trailing separator is stripped
|
||||
// with a sentinel instead. Verified on Windows.
|
||||
// Why: compared against a variable holding the separator — a literal backslash before the
|
||||
// closing quote breaks cmd's parser, and a sentinel would corrupt paths containing it.
|
||||
// The value matters: any other character silently un-pins the trailing-separator fix.
|
||||
expect(cmd).toContain('set "orca_sep=\\"')
|
||||
// Why: nothing else asserts the *reject* path, so deleting it would reopen the cwd hijack
|
||||
// while every accept-path assertion stayed green.
|
||||
expect(cmd).toMatch(/goto orca_candidate_rooted\r?\nexit \/b/)
|
||||
expect(cmd).toContain('if "%orca_candidate_dir:~-1%"=="%orca_sep%"')
|
||||
expect(cmd).not.toContain(':\\#=#%')
|
||||
// A candidate inside the wrapper directory must still be rejected, compared against the
|
||||
// cached wrapper dir because %~dp0 is rebound inside a CALL.
|
||||
expect(cmd).toContain('if /I "%orca_candidate_dir%\\"=="%orca_wrapper_dir%" exit /b')
|
||||
// Relative entries resolve against the cwd, so they must be rejected like empty ones.
|
||||
// Why: the rooted-path test must not shell out — an external tool would itself be
|
||||
// resolved from the cwd, reintroducing the hijack.
|
||||
expect(cmd).not.toContain('findstr')
|
||||
expect(cmd).toContain('if "%orca_candidate:~1,2%"==":\\" goto orca_candidate_rooted')
|
||||
expect(cmd).toContain('if "%orca_candidate:~0,2%"=="\\\\" goto orca_candidate_rooted')
|
||||
// Why: these two guards are the only thing stopping an empty element reaching the cwd on
|
||||
// Windows; deleting either left every other assertion green.
|
||||
expect(cmd).toContain('if "%~1"=="" exit /b')
|
||||
|
||||
const powershell = readFileSync(join(win32Dir, `${command}-wrapper.ps1`), 'utf8')
|
||||
expect(powershell).not.toContain('Get-Command')
|
||||
expect(powershell).toContain("($env:PATH -split ';')")
|
||||
// Why: the rooted check must reject drive-relative 'C:foo', which still resolves against
|
||||
// the cwd — so the IsPathRooted call must be gone, replaced by an explicit prefix match.
|
||||
expect(powershell).not.toContain('[IO.Path]::IsPathRooted(')
|
||||
// Why the full pattern, not a prefix: `^([A-Za-z]:|\\\\)` also starts with this and would
|
||||
// accept drive-relative `C:foo`, which still resolves against the cwd on that drive.
|
||||
expect(powershell).toContain("-notmatch '^([A-Za-z]:[\\\\/]|\\\\\\\\)'")
|
||||
// Why: trailing-separator normalization is what makes wrapper self-exclusion work; every
|
||||
// one of these survived mutation with the suite green.
|
||||
expect(powershell).toContain("ForEach-Object { $_.TrimEnd('\\') }")
|
||||
expect(powershell).toContain("$pathEntry.TrimEnd('\\')")
|
||||
expect(powershell).toContain("$dir.TrimEnd('\\')")
|
||||
expect(powershell).toContain("$capturedDir.TrimEnd('\\')")
|
||||
// Why: the fallback loop must skip wrapper dirs, or the wrapper can resolve to itself.
|
||||
expect(powershell).toContain(
|
||||
"$dir.TrimEnd('\\'), [StringComparison]::OrdinalIgnoreCase) }) { continue }"
|
||||
)
|
||||
// Why: `C:/Program Files/Git/cmd` style entries are legitimate and must stay accepted.
|
||||
expect(cmd).toContain('if "%orca_candidate:~1,2%"==":/" goto orca_candidate_rooted')
|
||||
// Why: the legacy-dir compare needs the same trailing-separator strip as its twins.
|
||||
expect(cmd).toContain('if "%orca_legacy_norm:~-1%"=="%orca_sep%"')
|
||||
expect(powershell).toContain('if (-not $dir) { continue }')
|
||||
expect(powershell).toContain('Test-Path -LiteralPath $candidate -PathType Leaf')
|
||||
}
|
||||
})
|
||||
|
||||
it('emits Windows wrappers with CRLF line endings', () => {
|
||||
// Why: cmd resolves `call :label` by byte offset and that lookup is unreliable in LF-only
|
||||
// files — the same script worked at 2.4 KB and failed with "cannot find the batch label"
|
||||
// once it grew past ~3.7 KB. Verified on Windows.
|
||||
const userData = makeUserDataDir()
|
||||
const win32Dir = join(userData, 'orca-terminal-attribution', 'win32')
|
||||
mkdirSync(win32Dir, { recursive: true })
|
||||
|
||||
neutralizeLegacyTerminalShimDir(userData)
|
||||
|
||||
for (const file of ['git.cmd', 'gh.cmd', 'git-wrapper.ps1', 'gh-wrapper.ps1']) {
|
||||
const body = readFileSync(join(win32Dir, file), 'utf8')
|
||||
expect(body, file).toContain('\r\n')
|
||||
expect(body.replaceAll('\r\n', ''), file).not.toContain('\n')
|
||||
}
|
||||
})
|
||||
|
||||
it('guards both cmd PATH walks so an empty variable cannot break parsing', () => {
|
||||
// Why: `%VAR:;=" "%` on an empty variable leaves an unbalanced quote that desynchronizes
|
||||
// cmd parsing for the rest of the file, turning the not-found branch into
|
||||
// `1>&2 was unexpected at this time.` Reproduced on Windows before this guard.
|
||||
const userData = makeUserDataDir()
|
||||
const win32Dir = join(userData, 'orca-terminal-attribution', 'win32')
|
||||
mkdirSync(win32Dir, { recursive: true })
|
||||
|
||||
neutralizeLegacyTerminalShimDir(userData)
|
||||
|
||||
for (const command of ['git', 'gh'] as const) {
|
||||
const cmd = readFileSync(join(win32Dir, `${command}.cmd`), 'utf8')
|
||||
expect(cmd).toContain('if not defined PATH goto :orca_path_walked')
|
||||
expect(cmd).toContain('if not defined orca_clean_path goto :orca_candidates_walked')
|
||||
for (const [guard, loop] of [
|
||||
['if not defined PATH goto :orca_path_walked', 'for %%P in ("%PATH:;='],
|
||||
[
|
||||
'if not defined orca_clean_path goto :orca_candidates_walked',
|
||||
'for %%P in ("%orca_clean_path:;='
|
||||
]
|
||||
] as const) {
|
||||
expectOrdered(cmd, guard, loop)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('never bakes an ambient interpreter lookup on Windows', () => {
|
||||
// Why: the POSIX tombstone is written on Windows too (Git Bash and WSL panes run it), but no
|
||||
// absolute candidate exists to a Windows process and PATH there is ';'-separated, so the
|
||||
// search cannot succeed — without this the fallback reopened the interpreter hijack.
|
||||
expect(resolvePosixTombstoneInterpreter(undefined, [], 'win32')).toBe('/bin/bash')
|
||||
expect(resolvePosixTombstoneInterpreter('C:\\Git\\bin;C:\\Windows', [], 'win32')).toBe(
|
||||
'/bin/bash'
|
||||
)
|
||||
})
|
||||
|
||||
itOnPosix('rejects interpreter candidates that are unusable in a shebang', () => {
|
||||
const userData = makeUserDataDir()
|
||||
const spaced = join(userData, 'a b')
|
||||
@@ -239,9 +97,9 @@ describe('legacy terminal shim neutralization', () => {
|
||||
writeFileSync(join(spaced, 'bash'), '#!/bin/sh\nexit 0\n', { mode: 0o755 })
|
||||
|
||||
// A shebang cannot quote, so a path containing whitespace is unusable.
|
||||
expect(resolvePosixTombstoneInterpreter(spaced, [], 'linux')).toBe('/usr/bin/env bash')
|
||||
expect(resolvePosixTombstoneInterpreter(spaced, [], 'linux')).toBeNull()
|
||||
// X_OK is true for a directory; it still cannot be exec'd.
|
||||
expect(resolvePosixTombstoneInterpreter(dirNamedBash, [], 'linux')).toBe('/usr/bin/env bash')
|
||||
expect(resolvePosixTombstoneInterpreter(dirNamedBash, [], 'linux')).toBeNull()
|
||||
})
|
||||
|
||||
itOnPosix('resolves the interpreter from absolute PATH entries only', () => {
|
||||
@@ -267,7 +125,7 @@ describe('legacy terminal shim neutralization', () => {
|
||||
mkdirSync(cwdRelDir, { recursive: true })
|
||||
try {
|
||||
writeFileSync(join(cwdRelDir, 'bash'), '#!/bin/sh\nexit 0\n', { mode: 0o755 })
|
||||
expect(resolvePosixTombstoneInterpreter(`.:${cwdRelName}`, [])).toBe('/usr/bin/env bash')
|
||||
expect(resolvePosixTombstoneInterpreter(`.:${cwdRelName}`, [])).toBeNull()
|
||||
} finally {
|
||||
rmSync(cwdRelDir, { recursive: true, force: true })
|
||||
}
|
||||
@@ -372,6 +230,196 @@ describe('legacy terminal shim neutralization', () => {
|
||||
expect(run.stdout).not.toContain('HOSTILE')
|
||||
})
|
||||
|
||||
itOnPosix('rejects the legacy shim directory reached by a different spelling', () => {
|
||||
// Why: the legacy-dir compare was lexical while the self-compare used -ef, so a PATH entry
|
||||
// spelled `<legacy>/../<legacy>` or reached through a symlink named the same directory but
|
||||
// survived the filter, and the still-live attribution wrapper won the lookup.
|
||||
const userData = makeUserDataDir()
|
||||
const posixDir = join(userData, 'orca-terminal-attribution', 'posix')
|
||||
const legacyDir = join(userData, 'legacy-shim')
|
||||
const realBin = join(userData, 'real-bin')
|
||||
for (const dir of [posixDir, legacyDir, realBin]) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
writeFileSync(join(posixDir, 'git'), 'legacy attribution wrapper')
|
||||
|
||||
neutralizeLegacyTerminalShimDir(userData)
|
||||
|
||||
writeFileSync(join(legacyDir, 'git'), "#!/bin/bash\nprintf 'HOSTILE\\n'\n", { mode: 0o755 })
|
||||
writeFileSync(join(realBin, 'git'), "#!/bin/bash\nprintf 'REAL\\n'\n", { mode: 0o755 })
|
||||
const legacyLink = join(userData, 'legacy-link')
|
||||
symlinkSync(legacyDir, legacyLink)
|
||||
|
||||
for (const spelling of [join(legacyDir, '..', 'legacy-shim'), legacyLink]) {
|
||||
const run = spawnSync(join(posixDir, 'git'), ['--version'], {
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_ATTRIBUTION_SHIM_DIR: legacyDir,
|
||||
PATH: `${spelling}:${realBin}:/usr/bin:/bin`
|
||||
},
|
||||
encoding: 'utf8',
|
||||
timeout: 20_000
|
||||
})
|
||||
expect(run.stdout, `spelling ${spelling}`).toContain('REAL')
|
||||
expect(run.stdout, `spelling ${spelling}`).not.toContain('HOSTILE')
|
||||
}
|
||||
})
|
||||
|
||||
itOnPosix('reuses the replaced wrapper shebang when no absolute bash is verifiable', () => {
|
||||
// Why: deleting the wrapper strands a shell that already hashed the path -- it reports 127
|
||||
// rather than falling through to PATH. The file being replaced ran on this host, so its own
|
||||
// shebang names an interpreter known to work here.
|
||||
const userData = makeUserDataDir()
|
||||
const wrapper = join(userData, 'git')
|
||||
writeFileSync(wrapper, '#!/bin/bash\nexit 0\n', { mode: 0o755 })
|
||||
expect(readVerifiedShebangInterpreter(wrapper)).toBe('/bin/bash')
|
||||
|
||||
// Why: `env` is absolute but defers the lookup to PATH, which is the cwd exposure this exists
|
||||
// to close. Accepting it would silently reinstate the hijack the null branch prevents.
|
||||
writeFileSync(wrapper, '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 })
|
||||
expect(readVerifiedShebangInterpreter(wrapper)).toBeNull()
|
||||
|
||||
// Why /bin/sh: it exists on every POSIX host, so this kills the mutant deterministically.
|
||||
// The rendered body needs BASH_SOURCE, [[ and local, so any other shell exits 1 at runtime.
|
||||
writeFileSync(wrapper, '#!/bin/sh\nexit 0\n', { mode: 0o755 })
|
||||
expect(readVerifiedShebangInterpreter(wrapper)).toBeNull()
|
||||
|
||||
writeFileSync(wrapper, '#!bash\nexit 0\n', { mode: 0o755 })
|
||||
expect(readVerifiedShebangInterpreter(wrapper)).toBeNull()
|
||||
|
||||
writeFileSync(wrapper, '#!/nonexistent/bash\nexit 0\n', { mode: 0o755 })
|
||||
expect(readVerifiedShebangInterpreter(wrapper)).toBeNull()
|
||||
|
||||
writeFileSync(wrapper, 'no shebang at all\n', { mode: 0o755 })
|
||||
expect(readVerifiedShebangInterpreter(wrapper)).toBeNull()
|
||||
|
||||
expect(readVerifiedShebangInterpreter(join(userData, 'absent'))).toBeNull()
|
||||
})
|
||||
|
||||
itOnPosix('keeps the POSIX wrapper on a host where no absolute bash resolves', async () => {
|
||||
// Why through neutralizeLegacyTerminalShimDir, not the resolver alone: CI hosts always have
|
||||
// /bin/bash, so the primary branch is always taken and deleting the fallback wiring left the
|
||||
// suite green. Forcing the resolver to null is the only way to reach it.
|
||||
vi.resetModules()
|
||||
vi.doMock('./legacy-terminal-posix-tombstone', async () => {
|
||||
const actual = await vi.importActual<typeof PosixTombstoneModule>(
|
||||
'./legacy-terminal-posix-tombstone'
|
||||
)
|
||||
return { ...actual, resolvePosixTombstoneInterpreter: () => null }
|
||||
})
|
||||
try {
|
||||
const shimDir = await import('./legacy-terminal-shim-dir')
|
||||
shimDir.__resetLegacyTerminalShimNeutralizationForTests()
|
||||
const userData = makeUserDataDir()
|
||||
const posixDir = join(userData, 'orca-terminal-attribution', 'posix')
|
||||
mkdirSync(posixDir, { recursive: true })
|
||||
writeFileSync(join(posixDir, 'git'), '#!/bin/bash\nlegacy attribution wrapper\n', {
|
||||
mode: 0o755
|
||||
})
|
||||
// Why gh differs: its wrapper carries no reusable shebang, so it must still be deleted
|
||||
// rather than left executing the retired attribution logic.
|
||||
writeFileSync(join(posixDir, 'gh'), '#!/usr/bin/env bash\nlegacy\n', { mode: 0o755 })
|
||||
|
||||
shimDir.neutralizeLegacyTerminalShimDir(userData)
|
||||
|
||||
const git = readFileSync(join(posixDir, 'git'), 'utf8')
|
||||
expect(git.split('\n')[0]).toBe('#!/bin/bash')
|
||||
expect(git).toContain('Orca compatibility wrapper could not locate')
|
||||
expect(existsSync(join(posixDir, 'gh'))).toBe(false)
|
||||
} finally {
|
||||
vi.doUnmock('./legacy-terminal-posix-tombstone')
|
||||
vi.resetModules()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not collapse .. when classifying a shim PATH entry', () => {
|
||||
// Why deliberately not collapsed: collapsing `..` textually is not resolving it. If
|
||||
// `<shim>/posix` is a symlink, `<shim>/posix/../posix` lands somewhere else, and classifying
|
||||
// it as the shim directory deletes a legitimate PATH entry and leaves git unresolvable.
|
||||
// Resolving for real is not available here either: this env is also built for remote and WSL
|
||||
// panes whose paths do not exist on the local filesystem.
|
||||
expect(isLegacyTerminalShimPathEntry('/tmp/old/orca-terminal-attribution/posix/../posix')).toBe(
|
||||
false
|
||||
)
|
||||
expect(isLegacyTerminalShimPathEntry('/tmp/old/orca-terminal-attribution/posix')).toBe(true)
|
||||
expect(isLegacyTerminalShimPathEntry('/tmp/old/orca-terminal-attribution/win32//')).toBe(true)
|
||||
expect(isLegacyTerminalShimPathEntry('/usr/local/bin')).toBe(false)
|
||||
})
|
||||
|
||||
it('strips a captured shim directory spelled with repeated separators', () => {
|
||||
// Why: pathEntrySpellings can only enumerate one added separator, so an entry repeating it
|
||||
// survived the literal removal and kept the captured directory on PATH.
|
||||
const posixEnv = {
|
||||
PATH: '/custom/elsewhere///:/usr/bin',
|
||||
ORCA_ATTRIBUTION_SHIM_DIR: '/custom/elsewhere'
|
||||
}
|
||||
stripLegacyTerminalShimEnv(posixEnv, 'linux')
|
||||
expect(posixEnv.PATH).toBe('/usr/bin')
|
||||
|
||||
const windowsEnv = {
|
||||
Path: 'C:\\Custom\\Else\\\\;C:\\Windows',
|
||||
ORCA_ATTRIBUTION_SHIM_DIR: 'C:\\Custom\\Else'
|
||||
}
|
||||
stripLegacyTerminalShimEnv(windowsEnv, 'win32')
|
||||
expect(windowsEnv.Path).toBe('C:\\Windows')
|
||||
})
|
||||
|
||||
itOnPosix(
|
||||
'ignores a relative legacy shim directory instead of resolving it against the cwd',
|
||||
() => {
|
||||
// Why: bash resolves a relative -ef operand against the wrapper's current directory, so a
|
||||
// relative ORCA_ATTRIBUTION_SHIM_DIR let the cwd decide which PATH entry counted as the legacy
|
||||
// directory. Reproduced as SAFE vs LATER purely by changing the cwd.
|
||||
const userData = makeUserDataDir()
|
||||
const posixDir = join(userData, 'orca-terminal-attribution', 'posix')
|
||||
const safeBin = join(userData, 'safe-bin')
|
||||
const laterBin = join(userData, 'later-bin')
|
||||
for (const dir of [posixDir, safeBin, laterBin]) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
writeFileSync(join(posixDir, 'git'), 'legacy attribution wrapper')
|
||||
|
||||
neutralizeLegacyTerminalShimDir(userData)
|
||||
|
||||
writeFileSync(join(safeBin, 'git'), "#!/bin/bash\nprintf 'SAFE\\n'\n", { mode: 0o755 })
|
||||
writeFileSync(join(laterBin, 'git'), "#!/bin/bash\nprintf 'LATER\\n'\n", { mode: 0o755 })
|
||||
|
||||
const outputs = [userData, join(userData, 'orca-terminal-attribution')].map((cwd) => {
|
||||
const run = spawnSync(join(posixDir, 'git'), ['--version'], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_ATTRIBUTION_SHIM_DIR: 'safe-bin',
|
||||
PATH: `${safeBin}:${laterBin}:/usr/bin:/bin`
|
||||
},
|
||||
encoding: 'utf8',
|
||||
timeout: 20_000
|
||||
})
|
||||
return run.stdout.trim()
|
||||
})
|
||||
|
||||
// Why identical: which binary runs must not depend on where the wrapper was invoked from.
|
||||
expect(outputs[0]).toBe(outputs[1])
|
||||
expect(outputs[0]).toBe('SAFE')
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps a POSIX directory whose name ends in a backslash', () => {
|
||||
// Why: a backslash is a legal filename character on POSIX. Treating it as a separator made
|
||||
// `/tmp/captured\` and `/tmp/captured` compare equal and deleted a real directory from PATH.
|
||||
const env = {
|
||||
PATH: '/tmp/captured\\',
|
||||
ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/captured'
|
||||
}
|
||||
stripLegacyTerminalShimEnv(env, 'linux')
|
||||
expect(env.PATH).toBe('/tmp/captured\\')
|
||||
|
||||
// Why the Windows half: there a backslash really is a separator, so it must still be stripped.
|
||||
const windowsEnv = { Path: 'C:\\captured\\', ORCA_ATTRIBUTION_SHIM_DIR: 'C:\\captured' }
|
||||
stripLegacyTerminalShimEnv(windowsEnv, 'win32')
|
||||
expect(windowsEnv.Path).toBeUndefined()
|
||||
})
|
||||
|
||||
itOnPosix('does not fall back to the cwd when every PATH entry is filtered out', () => {
|
||||
// Why: with the shim dir as the only entry the cleaned PATH is empty; without the
|
||||
// path_entry_kept guard the lookup runs against that empty PATH and finds a cwd-local git.
|
||||
@@ -534,13 +582,13 @@ describe('legacy terminal shim neutralization', () => {
|
||||
const cmd = readFileSync(join(win32Dir, 'git.cmd'), 'utf8')
|
||||
const cmdCapture = 'set "orca_legacy_wrapper_dir=%ORCA_ATTRIBUTION_SHIM_DIR%"'
|
||||
expectOrdered(cmd, cmdCapture, 'set "ORCA_ATTRIBUTION_SHIM_DIR="')
|
||||
expect(cmd).toContain('for %%P in ("%PATH:;=" "%") do call :orca_append_path "%%~P"')
|
||||
expect(cmd).toContain('for %%P in ("%PATH:;=" "%") do (')
|
||||
expect(cmd).toContain('if /I "%orca_path_entry_dir%"=="%orca_wrapper_dir%" exit /b')
|
||||
expect(cmd).toContain('if defined orca_legacy_wrapper_dir call :orca_reject_legacy_dir')
|
||||
// Why: `call :label && ...` is not valid cmd; the flag variable is what makes it work.
|
||||
expect(cmd).toContain('if defined orca_skip_entry exit /b')
|
||||
expect(cmd).not.toContain('call :orca_reject_legacy_dir &&')
|
||||
expect(cmd).toContain('if "%orca_path_entry_dir:~-1%"=="%orca_sep%"')
|
||||
expect(cmd).toContain('if "%orca_path_entry_dir:~-1%."=="%orca_sep%."')
|
||||
|
||||
const powershell = readFileSync(join(win32Dir, 'git-wrapper.ps1'), 'utf8')
|
||||
expectOrdered(
|
||||
@@ -707,6 +755,25 @@ describe('legacy terminal shim neutralization', () => {
|
||||
expect(env).toEqual({ PATH: '/usr/local/bin:/usr/bin', HOME: '/home/u' })
|
||||
})
|
||||
|
||||
it('strips the captured shim directory when PATH spells it with a trailing separator', () => {
|
||||
// Why: the captured value and the PATH entry can differ by a trailing separator; comparing
|
||||
// them literally left the shim directory on PATH and the wrapper reachable.
|
||||
const posix: Record<string, string> = {
|
||||
PATH: '/custom/elsewhere/:/usr/bin',
|
||||
ORCA_ATTRIBUTION_SHIM_DIR: '/custom/elsewhere'
|
||||
}
|
||||
stripLegacyTerminalShimEnv(posix, 'linux')
|
||||
expect(posix.PATH).toBe('/usr/bin')
|
||||
|
||||
// And the reverse spelling, plus Windows slash style.
|
||||
const win: Record<string, string> = {
|
||||
Path: 'C:\\Custom\\Else;C:\\Windows',
|
||||
ORCA_ATTRIBUTION_SHIM_DIR: 'C:\\Custom\\Else\\'
|
||||
}
|
||||
stripLegacyTerminalShimEnv(win, 'win32')
|
||||
expect(win.Path).toBe('C:\\Windows')
|
||||
})
|
||||
|
||||
it('uses the captured POSIX shim directory literally when it contains a colon', () => {
|
||||
const shimDir = '/tmp/orca:user/orca-terminal-attribution/posix'
|
||||
const env: Record<string, string> = {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { chmodSync, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { renderLegacyTerminalPosixTombstone } from './legacy-terminal-posix-tombstone'
|
||||
import {
|
||||
readVerifiedShebangInterpreter,
|
||||
renderLegacyTerminalPosixTombstone,
|
||||
resolvePosixTombstoneInterpreter
|
||||
} from './legacy-terminal-posix-tombstone'
|
||||
import {
|
||||
renderLegacyTerminalWindowsCmdTombstone,
|
||||
renderLegacyTerminalWindowsPowerShellTombstone
|
||||
@@ -95,8 +99,19 @@ function writeNeutralWrappers(rootDir: string): void {
|
||||
const win32Dir = join(rootDir, 'win32')
|
||||
mkdirSync(posixDir, { recursive: true })
|
||||
mkdirSync(win32Dir, { recursive: true })
|
||||
// Why: a tombstone with no verifiable absolute interpreter would need an ambient shebang and
|
||||
// could take bash from the current directory. Falling back to the shebang of the wrapper being
|
||||
// replaced keeps a shell that has the path hashed working -- deleting the file strands it on
|
||||
// 127 rather than falling through to PATH. Deleting is the last resort, not the first.
|
||||
const interpreter = resolvePosixTombstoneInterpreter()
|
||||
for (const command of ['git', 'gh'] as const) {
|
||||
writeFileAtomically(join(posixDir, command), renderLegacyTerminalPosixTombstone(command), 0o755)
|
||||
const posixPath = join(posixDir, command)
|
||||
const resolved = interpreter ?? readVerifiedShebangInterpreter(posixPath)
|
||||
if (resolved === null) {
|
||||
rmSync(posixPath, { force: true })
|
||||
} else {
|
||||
writeFileAtomically(posixPath, renderLegacyTerminalPosixTombstone(command, resolved), 0o755)
|
||||
}
|
||||
writeFileAtomically(
|
||||
join(win32Dir, `${command}.cmd`),
|
||||
renderLegacyTerminalWindowsCmdTombstone(command),
|
||||
@@ -126,14 +141,62 @@ function writeFileAtomically(filePath: string, contents: string, mode: number):
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the captured directory and the PATH entry naming it can differ by a trailing separator (or
|
||||
// its slash style on Windows). A literal compare missed that and left the shim dir on PATH. The
|
||||
// literal compare itself has to stay: a shim path may contain the PATH delimiter, which splitting
|
||||
// would fragment.
|
||||
function pathEntrySpellings(dir: string, windows: boolean): string[] {
|
||||
const base = stripTrailingSeparators(dir, windows)
|
||||
if (!base) {
|
||||
return [dir]
|
||||
}
|
||||
const spellings = new Set<string>([dir, base, `${base}/`])
|
||||
if (windows) {
|
||||
spellings.add(`${base}\\`)
|
||||
}
|
||||
return [...spellings]
|
||||
}
|
||||
|
||||
// Why purely lexical, and why `..` is deliberately not collapsed: collapsing it textually is not
|
||||
// the same as resolving it. If `<shim>/posix` is a symlink, `<shim>/posix/../posix` resolves
|
||||
// somewhere else entirely, and treating it as the shim directory deletes a legitimate PATH entry
|
||||
// and leaves git unresolvable. Resolving for real is not an option either: this env is also built
|
||||
// for remote and WSL panes, where these paths do not name anything on the local filesystem.
|
||||
// A `..` spelling that does slip through costs nothing at runtime -- the directory now holds the
|
||||
// pass-through tombstone, and the tombstone excludes its own directory by -ef, so the lookup still
|
||||
// reaches the real git.
|
||||
export function isLegacyTerminalShimPathEntry(entry: string): boolean {
|
||||
const normalized = entry.replaceAll('\\', '/').replace(/\/+$/, '').toLowerCase()
|
||||
// Why `windows` unconditionally here: this classifier only ever matches Orca's own
|
||||
// `orca-terminal-attribution/{posix,win32}` layout, and a Windows PATH can reach it through the
|
||||
// remote env, so both slash styles must be understood regardless of the local platform.
|
||||
const normalized = stripTrailingSeparators(entry.replaceAll('\\', '/'), true).toLowerCase()
|
||||
return (
|
||||
normalized.endsWith(`/${LEGACY_SHIM_ROOT_DIR}/posix`) ||
|
||||
normalized.endsWith(`/${LEGACY_SHIM_ROOT_DIR}/win32`)
|
||||
)
|
||||
}
|
||||
|
||||
// Why: `pathEntrySpellings` can only enumerate one added separator, so an entry repeating it
|
||||
// survived the literal removal. Comparing separator-stripped forms covers any number of them.
|
||||
// Why platform-specific: a backslash is a legal filename character on POSIX, so treating it as a
|
||||
// separator there made `/tmp/captured\` and `/tmp/captured` compare equal and deleted a real
|
||||
// directory from PATH.
|
||||
function stripTrailingSeparators(value: string, windows: boolean): string {
|
||||
return value.replace(windows ? /[\\/]+$/ : /\/+$/, '')
|
||||
}
|
||||
|
||||
function namesCapturedShimDir(entry: string, shimDirs: string[], windows: boolean): boolean {
|
||||
const candidate = stripTrailingSeparators(entry, windows)
|
||||
return shimDirs.some((shimDir) => {
|
||||
const target = stripTrailingSeparators(shimDir, windows)
|
||||
return target
|
||||
? windows
|
||||
? candidate.toLowerCase() === target.toLowerCase()
|
||||
: candidate === target
|
||||
: false
|
||||
})
|
||||
}
|
||||
|
||||
export function stripLegacyTerminalShimEnv(
|
||||
env: Record<string, string>,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
@@ -164,12 +227,21 @@ export function stripLegacyTerminalShimEnv(
|
||||
continue
|
||||
}
|
||||
const withoutExplicitDirs = explicitShimDirs.reduce(
|
||||
(pathValue, shimDir) => removeLiteralPathEntry(pathValue, shimDir, delimiter, windows),
|
||||
(pathValue, shimDir) =>
|
||||
pathEntrySpellings(shimDir, windows).reduce(
|
||||
(value, spelling) => removeLiteralPathEntry(value, spelling, delimiter, windows),
|
||||
pathValue
|
||||
),
|
||||
current
|
||||
)
|
||||
const cleaned = withoutExplicitDirs
|
||||
.split(delimiter)
|
||||
.filter((entry) => entry && !isLegacyTerminalShimPathEntry(entry))
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry &&
|
||||
!isLegacyTerminalShimPathEntry(entry) &&
|
||||
!namesCapturedShimDir(entry, explicitShimDirs, windows)
|
||||
)
|
||||
.join(delimiter)
|
||||
if (cleaned) {
|
||||
env[pathKey] = cleaned
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { resolvePosixTombstoneInterpreter } from './legacy-terminal-posix-tombstone'
|
||||
import {
|
||||
__resetLegacyTerminalShimNeutralizationForTests,
|
||||
neutralizeLegacyTerminalShimDir
|
||||
} from './legacy-terminal-shim-dir'
|
||||
|
||||
// Why: `expect(text.indexOf(a)).toBeLessThan(text.indexOf(b))` passes when `a` is absent, because
|
||||
// indexOf returns -1. Every ordering pin must assert both operands exist first.
|
||||
function expectOrdered(text: string, first: string, second: string): void {
|
||||
expect(text, `missing: ${first}`).toContain(first)
|
||||
expect(text, `missing: ${second}`).toContain(second)
|
||||
expect(text.indexOf(first)).toBeLessThan(text.indexOf(second))
|
||||
}
|
||||
|
||||
describe('legacy terminal Windows tombstone text', () => {
|
||||
const tempRoots: string[] = []
|
||||
|
||||
const makeUserDataDir = (): string => {
|
||||
const userData = mkdtempSync(join(tmpdir(), 'orca-legacy-shim-win-'))
|
||||
tempRoots.push(userData)
|
||||
return userData
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
__resetLegacyTerminalShimNeutralizationForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const tempRoot of tempRoots.splice(0)) {
|
||||
rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects stale Windows real-command paths inside the wrapper directory', () => {
|
||||
const userData = makeUserDataDir()
|
||||
const win32Dir = join(userData, 'orca-terminal-attribution', 'win32')
|
||||
mkdirSync(win32Dir, { recursive: true })
|
||||
|
||||
neutralizeLegacyTerminalShimDir(userData)
|
||||
|
||||
const cmd = readFileSync(join(win32Dir, 'git.cmd'), 'utf8')
|
||||
expect(cmd).toContain(
|
||||
'if defined orca_real for %%G in ("%orca_real%") do if /I "%%~dpG"=="%~dp0" set "orca_real="'
|
||||
)
|
||||
// Why: a captured path that no longer exists must be cleared, or the where.exe fallback below
|
||||
// is skipped and the wrapper execs a missing binary.
|
||||
expect(cmd).toContain('if defined orca_real if not exist "%orca_real%" set "orca_real="')
|
||||
expectOrdered(cmd, 'if not exist "%orca_real%"', ':orca_try_candidate')
|
||||
const powershell = readFileSync(join(win32Dir, 'git-wrapper.ps1'), 'utf8')
|
||||
expect(powershell).toContain('[StringComparison]::OrdinalIgnoreCase')
|
||||
expect(powershell).toContain('$realCommand = $null')
|
||||
expectOrdered(
|
||||
powershell,
|
||||
'[StringComparison]::OrdinalIgnoreCase',
|
||||
'Test-Path -LiteralPath $realCommand'
|
||||
)
|
||||
})
|
||||
|
||||
it('emits no percent expression inside a cmd rem comment', () => {
|
||||
// Why: cmd expands variables inside rem, so a comment mentioning the working directory
|
||||
// substitutes it into the comment text. Harmless at top level -- verified on Windows 11 that
|
||||
// rem does not re-parse the result, so a cwd of `C:\\x&pwned&rem` executed nothing -- but rem
|
||||
// treats separators differently inside a parenthesized block, and this script now has some.
|
||||
const userData = makeUserDataDir()
|
||||
const win32Dir = join(userData, 'orca-terminal-attribution', 'win32')
|
||||
mkdirSync(win32Dir, { recursive: true })
|
||||
|
||||
neutralizeLegacyTerminalShimDir(userData)
|
||||
|
||||
for (const command of ['git', 'gh'] as const) {
|
||||
const offending = readFileSync(join(win32Dir, `${command}.cmd`), 'utf8')
|
||||
.split('\r\n')
|
||||
.filter((line) => /^\s*rem\b/i.test(line) && line.includes('%'))
|
||||
expect(offending, `rem comments with percent expressions: ${offending.join(' | ')}`).toEqual(
|
||||
[]
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the Windows wrappers ASCII-only', () => {
|
||||
// Why: cmd.exe seeks through a batch file in bytes but advances by decoded character count,
|
||||
// so a single multi-byte character shifts every following line. Two em dashes in comments
|
||||
// made it drop the first four characters of every line and the wrapper died with "The
|
||||
// syntax of the command is incorrect." Proven on Windows 11; no test caught it.
|
||||
const userData = makeUserDataDir()
|
||||
const win32Dir = join(userData, 'orca-terminal-attribution', 'win32')
|
||||
mkdirSync(win32Dir, { recursive: true })
|
||||
|
||||
neutralizeLegacyTerminalShimDir(userData)
|
||||
|
||||
for (const name of ['git.cmd', 'gh.cmd', 'git-wrapper.ps1', 'gh-wrapper.ps1']) {
|
||||
const contents = readFileSync(join(win32Dir, name), 'utf8')
|
||||
const offending = [...contents].find((character) => character.charCodeAt(0) > 0x7f)
|
||||
expect(offending, `${name} contains non-ASCII ${JSON.stringify(offending)}`).toBeUndefined()
|
||||
expect(Buffer.byteLength(contents, 'utf8')).toBe(contents.length)
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves Windows fallbacks against PATH only, never the current directory', () => {
|
||||
// Why (STA-4169): bare `where.exe git.exe` searches cwd before PATH, so a repository-local
|
||||
// git.exe/gh.exe could be executed with the user's arguments.
|
||||
const userData = makeUserDataDir()
|
||||
const win32Dir = join(userData, 'orca-terminal-attribution', 'win32')
|
||||
mkdirSync(win32Dir, { recursive: true })
|
||||
|
||||
neutralizeLegacyTerminalShimDir(userData)
|
||||
|
||||
for (const command of ['git', 'gh'] as const) {
|
||||
const cmd = readFileSync(join(win32Dir, `${command}.cmd`), 'utf8')
|
||||
// No where.exe at all: it searches cwd first, so the wrapper walks the cleaned PATH.
|
||||
expect(cmd).not.toContain('where.exe')
|
||||
expect(cmd).toContain('for %%P in ("%orca_clean_path:;=" "%") do (')
|
||||
expect(cmd).toContain(`if exist "%orca_candidate_dir%\\${command}.exe"`)
|
||||
// Why: %~f preserves a trailing separator; without normalizing, a wrapper-dir entry
|
||||
// spelled with one escapes self-exclusion and the wrapper tail-loops on itself. The
|
||||
// separator lives in a variable because a literal backslash before the closing quote
|
||||
// breaks cmd's parser, and a sentinel character would corrupt paths containing it.
|
||||
expect(cmd).toContain('set "orca_sep=\\"')
|
||||
// Why: bare setlocal inherits the caller's delayed-expansion state. Under a parent shell
|
||||
// with /V:ON, a literal !CD! PATH entry became the cwd and ran a planted git.cmd (exit 66),
|
||||
// and a legitimate directory containing ! stopped resolving (exit 127). Both on Windows 11.
|
||||
expect(cmd).toContain('setlocal DisableDelayedExpansion')
|
||||
expect(cmd).not.toContain('\r\nsetlocal\r\n')
|
||||
// Why: a captured ORCA_REAL_* may be relative, and both the existence test and the
|
||||
// invocation resolve it against the cwd.
|
||||
expect(cmd).toContain('if defined orca_real call :orca_check_rooted')
|
||||
expect(cmd).toContain('if defined orca_real if not defined orca_rooted set "orca_real="')
|
||||
// Why: the exported PATH is inherited by the real command; a relative entry left in it lets
|
||||
// the cwd select the tools that command spawns.
|
||||
// Why the exact block, not an ordering pin: the first ':orca_append_path' in the file is
|
||||
// the top-level CALL, and the first 'if not defined orca_rooted' belongs to a different
|
||||
// subroutine, so an ordering pin stayed green with this subroutine's guard deleted.
|
||||
expect(cmd).toContain(':orca_append_path\r\nif not defined orca_entry exit /b\r\n')
|
||||
expect(cmd).toContain(
|
||||
'set "orca_probe=%orca_entry%"\r\ncall :orca_check_rooted\r\nif not defined orca_rooted exit /b\r\nfor %%G in ("%orca_entry%") do set "orca_path_entry_dir=%%~fG"'
|
||||
)
|
||||
// Why: CALL re-expands its own command line, so path data passed as an argument gets a
|
||||
// second round of percent expansion. A PATH entry holding a literal %CD% became the
|
||||
// current directory before the rooted check saw it and the planted git.cmd ran (rc=66 on
|
||||
// Windows 11). Every caller must hand the value over in a variable instead.
|
||||
expect(cmd).not.toContain('call :orca_check_rooted "')
|
||||
expect(cmd).not.toContain('call :orca_try_candidate "')
|
||||
expect(cmd).not.toContain('call :orca_append_path "')
|
||||
expect(cmd).not.toContain('%~1')
|
||||
expect(cmd).toContain('set "orca_probe=%orca_entry%"\r\ncall :orca_check_rooted\r\n')
|
||||
// Why: orca_sep is compared against before the legacy dir is normalized; defining it later
|
||||
// made that strip silently no-op and left the legacy dir on PATH.
|
||||
expectOrdered(cmd, 'set "orca_sep=', 'if "%orca_legacy_norm:~-1%."')
|
||||
// Why: %orca_sep% expands before the line is parsed, so `=="%orca_sep%"` becomes `=="\"` —
|
||||
// the literal trap the variable exists to avoid. Both sides must carry the trailing dot.
|
||||
expect(cmd).not.toContain('=="%orca_sep%"')
|
||||
// Why: nothing else asserts the *reject* path, so deleting it would reopen the cwd hijack
|
||||
// while every accept-path assertion stayed green.
|
||||
expectOrdered(
|
||||
cmd,
|
||||
'call :orca_check_rooted\r\nif not defined orca_rooted exit /b',
|
||||
'for %%G in ("%orca_entry%") do set "orca_candidate_dir='
|
||||
)
|
||||
expect(cmd).toContain('if not defined orca_rooted exit /b')
|
||||
expect(cmd).toContain('if "%orca_candidate_dir:~-1%."=="%orca_sep%."')
|
||||
expect(cmd).not.toContain(':\\#=#%')
|
||||
// A candidate inside the wrapper directory must still be rejected, compared against the
|
||||
// cached wrapper dir because %~dp0 is rebound inside a CALL.
|
||||
expect(cmd).toContain('if /I "%orca_candidate_dir%\\"=="%orca_wrapper_dir%" exit /b')
|
||||
// Relative entries resolve against the cwd, so they must be rejected like empty ones.
|
||||
// Why: the rooted-path test must not shell out — an external tool would itself be
|
||||
// resolved from the cwd, reintroducing the hijack.
|
||||
expect(cmd).not.toContain('findstr')
|
||||
expect(cmd).toContain('if "%orca_probe:~1,2%"==":\\" set "orca_rooted=1"')
|
||||
expect(cmd).toContain('if "%orca_probe:~0,2%"=="\\\\" set "orca_rooted=1"')
|
||||
// Why: these two guards are the only thing stopping an empty element reaching the cwd on
|
||||
// Windows; deleting either left every other assertion green.
|
||||
expect(cmd).toContain('if not defined orca_entry exit /b')
|
||||
|
||||
const powershell = readFileSync(join(win32Dir, `${command}-wrapper.ps1`), 'utf8')
|
||||
expect(powershell).not.toContain('Get-Command')
|
||||
expect(powershell).toContain("($env:PATH -split ';')")
|
||||
// Why: the rooted check must reject drive-relative 'C:foo', which still resolves against
|
||||
// the cwd — so the IsPathRooted call must be gone, replaced by an explicit prefix match.
|
||||
expect(powershell).not.toContain('[IO.Path]::IsPathRooted(')
|
||||
expect(powershell).toContain("$pathEntry -match '^([A-Za-z]:[\\\\/]|\\\\\\\\)'")
|
||||
expect(powershell).toContain("$realCommand -notmatch '^([A-Za-z]:[\\\\/]|\\\\\\\\)'")
|
||||
// Why the full pattern, not a prefix: `^([A-Za-z]:|\\\\)` also starts with this and would
|
||||
// accept drive-relative `C:foo`, which still resolves against the cwd on that drive.
|
||||
expect(powershell).toContain("-notmatch '^([A-Za-z]:[\\\\/]|\\\\\\\\)'")
|
||||
// Why: trailing-separator normalization is what makes wrapper self-exclusion work; every
|
||||
// one of these survived mutation with the suite green.
|
||||
expect(powershell).toContain("ForEach-Object { $_.TrimEnd('\\', '/') }")
|
||||
expect(powershell).toContain("$pathEntry.TrimEnd('\\', '/')")
|
||||
expect(powershell).toContain("$dir.TrimEnd('\\', '/')")
|
||||
expect(powershell).toContain("$capturedDir.TrimEnd('\\', '/')")
|
||||
// Why: the fallback loop must skip wrapper dirs, or the wrapper can resolve to itself.
|
||||
expect(powershell).toContain(
|
||||
"$dir.TrimEnd('\\', '/'), [StringComparison]::OrdinalIgnoreCase) }) { continue }"
|
||||
)
|
||||
// Why both separators: the rooted-path regex accepts forward slashes, so a wrapper or
|
||||
// legacy directory spelled with a trailing / missed the lexical exclusion.
|
||||
expect(powershell).not.toContain("TrimEnd('\\')")
|
||||
// Why: `C:/Program Files/Git/cmd` style entries are legitimate and must stay accepted.
|
||||
expect(cmd).toContain('if "%orca_probe:~1,2%"==":/" set "orca_rooted=1"')
|
||||
// Why: the legacy-dir compare needs the same trailing-separator strip as its twins.
|
||||
expect(cmd).toContain('if "%orca_legacy_norm:~-1%."=="%orca_sep%."')
|
||||
// Why: cmd expands a whole line before evaluating `if defined`, so this strip must live in
|
||||
// a CALL body. Inline, it ran its substring syntax against an unset variable and mangled
|
||||
// the line into a syntax error. Proven on Windows 11.
|
||||
expect(cmd).toContain('if defined orca_legacy_wrapper_dir call :orca_normalize_legacy_dir')
|
||||
expect(cmd).not.toContain('if defined orca_legacy_wrapper_dir for ')
|
||||
expect(powershell).toContain('if (-not $dir) { continue }')
|
||||
expect(powershell).toContain('Test-Path -LiteralPath $candidate -PathType Leaf')
|
||||
}
|
||||
})
|
||||
|
||||
it('emits Windows wrappers with CRLF line endings', () => {
|
||||
// Why: cmd resolves `call :label` by byte offset and that lookup is unreliable in LF-only
|
||||
// files — the same script worked at 2.4 KB and failed with "cannot find the batch label"
|
||||
// once it grew past ~3.7 KB. Verified on Windows.
|
||||
const userData = makeUserDataDir()
|
||||
const win32Dir = join(userData, 'orca-terminal-attribution', 'win32')
|
||||
mkdirSync(win32Dir, { recursive: true })
|
||||
|
||||
neutralizeLegacyTerminalShimDir(userData)
|
||||
|
||||
for (const file of ['git.cmd', 'gh.cmd', 'git-wrapper.ps1', 'gh-wrapper.ps1']) {
|
||||
const body = readFileSync(join(win32Dir, file), 'utf8')
|
||||
expect(body, file).toContain('\r\n')
|
||||
expect(body.replaceAll('\r\n', ''), file).not.toContain('\n')
|
||||
}
|
||||
})
|
||||
|
||||
it('guards both cmd PATH walks so an empty variable cannot break parsing', () => {
|
||||
// Why: `%VAR:;=" "%` on an empty variable leaves an unbalanced quote that desynchronizes
|
||||
// cmd parsing for the rest of the file, turning the not-found branch into
|
||||
// `1>&2 was unexpected at this time.` Reproduced on Windows before this guard.
|
||||
const userData = makeUserDataDir()
|
||||
const win32Dir = join(userData, 'orca-terminal-attribution', 'win32')
|
||||
mkdirSync(win32Dir, { recursive: true })
|
||||
|
||||
neutralizeLegacyTerminalShimDir(userData)
|
||||
|
||||
for (const command of ['git', 'gh'] as const) {
|
||||
const cmd = readFileSync(join(win32Dir, `${command}.cmd`), 'utf8')
|
||||
expect(cmd).toContain('if not defined PATH goto :orca_path_walked')
|
||||
expect(cmd).toContain('if not defined orca_clean_path goto :orca_candidates_walked')
|
||||
for (const [guard, loop] of [
|
||||
['if not defined PATH goto :orca_path_walked', 'for %%P in ("%PATH:;='],
|
||||
[
|
||||
'if not defined orca_clean_path goto :orca_candidates_walked',
|
||||
'for %%P in ("%orca_clean_path:;='
|
||||
]
|
||||
] as const) {
|
||||
expectOrdered(cmd, guard, loop)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('never bakes an ambient interpreter lookup on Windows', () => {
|
||||
// Why: the POSIX tombstone is written on Windows too (Git Bash and WSL panes run it), but no
|
||||
// absolute candidate exists to a Windows process and PATH there is ';'-separated, so the
|
||||
// search cannot succeed — without this the fallback reopened the interpreter hijack.
|
||||
expect(resolvePosixTombstoneInterpreter(undefined, [], 'win32')).toBe('/bin/bash')
|
||||
expect(resolvePosixTombstoneInterpreter('C:\\Git\\bin;C:\\Windows', [], 'win32')).toBe(
|
||||
'/bin/bash'
|
||||
)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user