mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/mobile-i18n-audit
# Conflicts: # mobile/app/h/[hostId]/edit.tsx
This commit is contained in:
@@ -9,6 +9,7 @@ on:
|
||||
- 'config/scripts/build-windows-cli-launcher.mjs'
|
||||
- 'config/scripts/build-windows-cli-launcher.test.mjs'
|
||||
- 'config/scripts/computer-e2e-workflow.test.mjs'
|
||||
- 'config/scripts/computer-use-modifier-safety.test.mjs'
|
||||
- 'config/scripts/computer-use-skill-guidance.test.mjs'
|
||||
- 'config/scripts/computer-use-smoke.mjs'
|
||||
- 'config/scripts/computer-use-smoke.test.mjs'
|
||||
@@ -84,6 +85,7 @@ jobs:
|
||||
config/scripts/build-windows-cli-launcher.test.mjs
|
||||
src/main/ssh/ssh-remote-cli-launcher.test.ts
|
||||
config/scripts/computer-e2e-workflow.test.mjs
|
||||
config/scripts/computer-use-modifier-safety.test.mjs
|
||||
config/scripts/computer-use-skill-guidance.test.mjs
|
||||
config/scripts/computer-use-smoke.test.mjs
|
||||
src/main/computer/computer-provider-lifecycle.test.ts
|
||||
|
||||
@@ -21,6 +21,7 @@ type OutputChunk = Rollup.OutputChunk
|
||||
const PLAIN_NODE_ENTRY_NAMES = [
|
||||
'daemon-entry',
|
||||
'parcel-watcher-process-entry',
|
||||
'main-thread-hang-watchdog-entry',
|
||||
'computer-sidecar',
|
||||
'agent-hooks/managed-agent-hook-controls',
|
||||
'codex/codex-app-server-grant-entry'
|
||||
|
||||
@@ -153,6 +153,7 @@ module.exports = {
|
||||
'out/main/plugin-host-entry.js',
|
||||
'out/main/computer-sidecar.js',
|
||||
'out/main/parcel-watcher-process-entry.js',
|
||||
'out/main/main-thread-hang-watchdog-entry.js',
|
||||
'out/main/chunks/**',
|
||||
'resources/**',
|
||||
'node_modules/ws/**',
|
||||
|
||||
+406
-17
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-07-28",
|
||||
"updatedAt": "2026-07-29",
|
||||
"policy": {
|
||||
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
|
||||
"blockingPromotion": {
|
||||
@@ -705,6 +705,102 @@
|
||||
],
|
||||
"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.websocket-heartbeat-cadence",
|
||||
"title": "Runtime WebSockets enter an owned shared heartbeat cadence",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "runtime-platform",
|
||||
"layer": "websocket-transport-lifecycle",
|
||||
"surfaces": [
|
||||
"headed paired runtime",
|
||||
"headless orca serve",
|
||||
"web and mobile runtime clients"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["remote-runtime"],
|
||||
"coverageNotes": "Deterministic transport tests cover listener-before-probe ownership, first- and later-socket shared cadence, responsive and unresponsive cleanup, pause/resume, connection caps, pre-auth expiry, and close/error races. Headed paired-runtime and headless serve validation passed on macOS; live Linux and Windows heartbeat evidence remains uncollected.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/11298",
|
||||
"https://github.com/stablyai/orca/pull/11300"
|
||||
],
|
||||
"invariant": "Every accepted runtime socket installs message, pong, close, and error ownership before any heartbeat probe. With uninterrupted timer delivery, the first socket that arms an idle heartbeat is probed immediately and an unresponsive socket is reaped within one interval. Later sockets join the existing shared cadence without another timer or immediate sweep and are reaped within two intervals. Responsive sockets survive, pause recovery grants a fresh probe, and close or error-to-close releases connection listeners and timers.",
|
||||
"oracle": "With one fake clock and exact socket identities, accept the first socket at 0 ms and require an immediate owned probe plus reaping at 100 ms when unresponsive. Keep a responsive first socket, accept an unresponsive later socket at 50 ms, require the same shared timer, its first probe at 100 ms, no early reap, and termination at 200 ms. Inject synchronous message, pong, close, and error events, then require exact heartbeat membership and zero retained timers/listeners after final close. Production transport tests independently cover real socket round trips, pre-auth and capacity bounds, revocation, shutdown, and half-open cleanup.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/ws-transport-accept-order.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/ws-transport.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/runtime/rpc/ws-transport-accept-order.test.ts",
|
||||
"src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts",
|
||||
"src/main/runtime/rpc/ws-transport.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/runtime/rpc/ws-transport-accept-order.test.ts",
|
||||
"assertions": [
|
||||
"the first synchronous probe observes message, pong, close, and error ownership",
|
||||
"the first unresponsive socket is reaped at one interval",
|
||||
"a later socket keeps the original shared timer, is first probed on the shared tick, and is reaped within two intervals",
|
||||
"final close releases heartbeat membership, listeners, and timers"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts",
|
||||
"assertions": [
|
||||
"one missed probe reaps only the unresponsive client",
|
||||
"event-loop resume grants clients a fresh probe"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/ws-transport.test.ts",
|
||||
"assertions": [
|
||||
"heartbeat arming and shutdown follow accepted connection membership",
|
||||
"pre-auth, raw TCP, and accepted WebSocket resource bounds remain enforced",
|
||||
"error and close races finalize membership once"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-29",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/ws-transport-accept-order.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/ws-transport.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 0.91,
|
||||
"summary": "Three runtime transport files and 35 tests passed with deterministic first- and later-socket cadence, exact listener/timer ownership, pause recovery, security bounds, and cleanup."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 2,
|
||||
"scope": "focused runtime WebSocket heartbeat and transport tests"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "Deterministic fake-clock and local socket coverage passed on macOS; CI soak history is not yet available."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "On latest main and with the listener-order fix disabled, the first synchronous probe observed no message, pong, close, or error owner. The structural listener-order fix passed those assertions. The published delayed-first-sweep alternative missed the first-socket one-interval cleanup bound. A later-socket oracle now separately pins the intended shared cadence at a 100 ms first probe and 200 ms reap after acceptance at 50 ms."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "The transport retains exactly one shared interval, performs one O(N) sweep per tick, and adds no per-socket heartbeat timer, polling, subprocess, network request, or immediate later-socket sweep. Each accepted socket retains only its existing lifecycle listeners and pre-auth timer; deterministic assertions preserve the shared timer identity and bound final cleanup to zero timers."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect at least 100 consecutive CI or soak passes or 14 days without an unexplained flake.",
|
||||
"Collect live Linux and Windows paired-runtime heartbeat evidence.",
|
||||
"Add a multi-client live fanout run that confirms shared-cadence cleanup under connection churn."
|
||||
],
|
||||
"knownGaps": [
|
||||
"Live paired-runtime validation currently covers macOS only.",
|
||||
"The later-socket bound is deterministic fake-clock coverage; live scheduler jitter is not measured.",
|
||||
"Multi-client churn and sleep/wake fanout remain live-test gaps."
|
||||
],
|
||||
"demotionRule": "Demote if any probe can run before lifecycle ownership, if more than one shared heartbeat timer is armed, if first-socket cleanup exceeds one interval, if later-socket cleanup exceeds two intervals, or if close/error cleanup retains listeners or timers."
|
||||
},
|
||||
{
|
||||
"id": "editor.live-log-append-stability",
|
||||
"title": "Long live session logs retain their Monaco viewport while appending",
|
||||
@@ -2971,6 +3067,277 @@
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote to protection none if output differs across replay slices, header-only restores consume a replay slot, retained payload bytes exceed the cap, or the focused gate flakes."
|
||||
},
|
||||
{
|
||||
"id": "terminal-performance.remote-hidden-retention-budget",
|
||||
"title": "Paired terminals park client renderers while host PTYs preserve bounded history",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-runtime",
|
||||
"layer": "paired-headed-and-headless-runtime",
|
||||
"surfaces": [
|
||||
"paired remote terminal first paint",
|
||||
"paired remote terminal ordinary parking",
|
||||
"paired remote terminal bounded scrollback restore",
|
||||
"stalled paired terminal stream recovery",
|
||||
"hidden remote worktree retention",
|
||||
"remote terminal reveal and input",
|
||||
"manual server disconnect"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["paired-runtime", "ssh"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["paired-runtime"],
|
||||
"coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, ordinary-park five client xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore 5,000 rows on the original PTY including output produced while parked and continued input/output. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, mixed-version paired hosts, live SSH, and production-scale paired hosts remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/8652",
|
||||
"https://github.com/stablyai/orca/pull/10625"
|
||||
],
|
||||
"invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Hosts without the capability must retain the existing limit/TTL force-parking fallback. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence must first probe authoritative state and replace only if that probe times out. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.",
|
||||
"oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api.test.ts src/main/ipc/runtime-environments.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts",
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_DISABLE_PAIRED_TERMINAL_PARKING=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-stall-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-parked-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/runtime/orca-runtime.test.ts",
|
||||
"src/renderer/src/web/web-preload-api.test.ts",
|
||||
"src/main/ipc/runtime-environments.test.ts",
|
||||
"src/main/runtime/rpc/terminal-multiplex.test.ts",
|
||||
"src/main/runtime/rpc/terminal-subscribe-buffer.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-connection.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts",
|
||||
"src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts",
|
||||
"src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts",
|
||||
"src/renderer/src/runtime/runtime-client-events.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts",
|
||||
"tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts",
|
||||
"tests/e2e/paired-remote-terminal-stall-recovery.spec.ts",
|
||||
"tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts",
|
||||
"tests/e2e/paired-remote-terminal-retention-memory.spec.ts",
|
||||
"tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts",
|
||||
"tests/e2e/terminal-parked-memory.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "tests/e2e/paired-remote-terminal-stall-recovery.spec.ts",
|
||||
"assertions": [
|
||||
"restarts one ACK-starved paired terminal stream without replacing its PTY"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts",
|
||||
"assertions": ["recovers an ACK-starved stream from an isolated headless Orca host"]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts",
|
||||
"assertions": [
|
||||
"paints a paired remote terminal when only its retained text tail overflowed",
|
||||
"legacy paired hosts retain the lossy hidden-manager budget fallback"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/paired-remote-terminal-retention-memory.spec.ts",
|
||||
"assertions": [
|
||||
"ordinary-parks paired terminals and restores authoritative host scrollback"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts",
|
||||
"assertions": ["ordinary-parks paired terminals against an isolated headless Orca host"]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/terminal-parked-memory.spec.ts",
|
||||
"assertions": [
|
||||
"releases un-parkable hidden worktree buffers only once the retention budget engages"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts",
|
||||
"assertions": [
|
||||
"treats a host-backed paired PTY as settled despite activation residue",
|
||||
"preserves real startup work and non-paired activation guards",
|
||||
"force-parks the least-recently-hidden candidates beyond the retention limit"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/orca-runtime.test.ts",
|
||||
"assertions": [
|
||||
"forwards facts over the shared client-event stream without a desktop renderer",
|
||||
"prefers provider history over a partial headless mirror for requested snapshots",
|
||||
"falls back to the available mirror when authoritative provider history is unavailable",
|
||||
"bounds a hung authoritative provider acquisition and reuses its fallback"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/terminal-multiplex.test.ts",
|
||||
"assertions": [
|
||||
"binary first paint remains valid when only retained history was truncated",
|
||||
"admits 128 active streams, rejects the 129th, and reuses released capacity",
|
||||
"reserves PTY wait capacity independently from active streams"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts",
|
||||
"assertions": ["consumes host facts without a raw terminal stream for paired PTYs"]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts",
|
||||
"assertions": ["restores configured paired scrollback after an ordinary park reveal"]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts",
|
||||
"assertions": [
|
||||
"selects only reachable hosts advertising the paired parking contract",
|
||||
"accepts paired ptys only for the exact snapshot-capable owner",
|
||||
"rejects paired, fail-open, foreign, and null ptys without capability evidence"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts",
|
||||
"assertions": [
|
||||
"keeps a mounted HUB mirror alive when the old stream ends before the replacement snapshot"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts",
|
||||
"assertions": ["starts a fact watcher for snapshot-capable paired PTYs"]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts",
|
||||
"assertions": [
|
||||
"retains a continuing watcher and title while reconciling a reminted split leaf"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts",
|
||||
"assertions": [
|
||||
"restarts only the stream whose renderer delivery credit never settles",
|
||||
"probes then restarts a stream when an entered command receives no frames",
|
||||
"keeps a silent responsive stream after its authoritative snapshot probe",
|
||||
"classifies a capacity rejection followed by end as recoverable transport pressure"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/web/web-preload-api.test.ts",
|
||||
"assertions": [
|
||||
"keeps pairing while manual disconnect fences passive reconnects",
|
||||
"fences a web runtime response that completes after manual disconnect",
|
||||
"returns a disconnect envelope when a queued active runtime call disconnects",
|
||||
"returns a disconnect envelope when a queued selected environment call disconnects"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-29",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api.test.ts src/main/ipc/runtime-environments.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 19.54,
|
||||
"summary": "Seventeen focused files passed 1,938 tests with one existing skip, including 128 active streams, retryable rejection at 129, released-slot snapshot recovery, bounded liveness probes, and balanced subscriber cleanup."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-29",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 11.4,
|
||||
"summary": "The headed paired-server scenario painted a truncated retained history after reload; the capability-disabled legacy fallback scenario is skipped unless explicitly selected."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-29",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-stall-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 18,
|
||||
"summary": "The byte-identical headed oracle exhausted one paired stream, proved host/client divergence, then repainted the marker while preserving the original PTY and tab."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-29",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 14.5,
|
||||
"summary": "Six real paired worktrees and host PTYs ordinary-parked five client xterms with the lossy budget disabled, released at least 55% of staged cells within heap and timer-lag budgets, then restored requested row 4,000, parked-time output, and live I/O on the original PTY."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-29",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 14.4,
|
||||
"summary": "An isolated `orca serve` host and paired web renderer exhausted one terminal stream, recovered it, and preserved the original PTY and tab without exposing readiness or pairing material."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-29",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 14.2,
|
||||
"summary": "The isolated headless `orca serve` host passed the byte-identical six-PTY ordinary-parking, memory/lag, deep-history, same-PTY, parked-output, and post-reveal I/O oracle."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-29",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_DISABLE_PAIRED_TERMINAL_PARKING=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 18.6,
|
||||
"summary": "A host without terminal.paired-parking.v1 preserved the existing lossy limit-one fallback, restored retained output, and continued PTY input/output."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 360,
|
||||
"scope": "focused units plus isolated headed paired-runtime and renderer-memory scenarios"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "The paired headed oracle is deterministic locally but has no CI or soak history yet."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The six-PTY ordinary-parking oracle was red in two independently observed stages: the parked paired remount first used detached attach and restored only rows 5,963–5,999, then the requested snapshot still lost to the current-screen replay until park reveals explicitly entered the capability-gated reattach coordinator. The candidate is green on headed and headless hosts, while disabling terminal.paired-parking.v1 makes the byte-identical oracle fail at its capability precondition and leaves the separately tested legacy lossy fallback green. With stalled-stream recovery disabled, the host cursor advanced but the client remained frozen; enabling it repainted the marker with the same PTY and tab. Reverting the active-stream limit to 64 makes the exact 128-stream contract fail at 64; restoring 128 admits every intended stream, rejects 129, and reattaches it after one slot releases."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Capable paired hosts use ordinary parking: the host PTY and bounded 5,000-row provider model remain, while five hidden client xterms and their raw per-PTY multiplex streams are destroyed. Parked side effects share the singleton runtime client-event stream. Headed and headless oracles require exact reduction from six managers to one, at least 55% staged xterm-cell release, no more than 16 MiB heap growth, and under 500 ms timer drift. Legacy hosts retain the prior 12-worktree/45-minute lossy force-parking policy. Stream recovery adds no polling and is scoped to one stream."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect 100 consecutive CI passes or 14 days of soak history for the headed and headless parking commands.",
|
||||
"Run the paired topology on Windows and Linux and add one live SSH retention run.",
|
||||
"Run the stream-stall oracle with a v1.4.160-rc.3 host and v1.4.160-rc.4 client.",
|
||||
"Add a production-scale paired run with dozens of worktrees and explicit event-loop and renderer-memory budgets.",
|
||||
"Keep the budget-off control, exact manager count, row-4,000 reveal marker, parked-time output, and post-reveal PTY input in both topology runs."
|
||||
],
|
||||
"knownGaps": [
|
||||
"Paired reveal restores at most the requested 5,000 rows; older history is intentionally unavailable.",
|
||||
"The scaled paired scenarios use six worktrees and ordinary-park five; they prove real host-backed eviction and buffer release, not a 100-worktree soak.",
|
||||
"Display-off reveal latency is a separate atlas and viewport-reflow class and is not covered by this gate.",
|
||||
"Historical field trace archives are still required to order replay-wedge events against renderer heartbeat loss and deduplicate archived records.",
|
||||
"The live windows-issues incident proves a healthy host PTY and dead existing client stream, but lacks per-stream ACK counters; ACK starvation is reproduced, not yet proven as that field incident's unique boundary."
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote to protection none if paired first paint is blank, passive work reconnects after manual disconnect, a capable hidden paired manager survives ordinary parking, a legacy host bypasses its fallback, reveal loses requested bounded history or live PTY identity, retained buffer cells do not fall, or either headed/headless scenario flakes."
|
||||
},
|
||||
{
|
||||
"id": "terminal-performance.no-hot-list-sessions",
|
||||
"title": "Hot terminal interactions do not call global PTY session listing",
|
||||
@@ -3085,46 +3452,67 @@
|
||||
"id": "terminal-observability.lifecycle-breadcrumbs",
|
||||
"title": "Terminal lifecycle anomalies enter crash diagnostics as compact breadcrumbs",
|
||||
"maturity": "experimental",
|
||||
"protection": "none",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-runtime",
|
||||
"layer": "renderer-observability",
|
||||
"surfaces": [
|
||||
"terminal lifecycle",
|
||||
"reattach",
|
||||
"restore",
|
||||
"replay-wedge identity",
|
||||
"provider ownership",
|
||||
"diagnostics bundle"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
|
||||
"coveredPlatforms": [],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": [],
|
||||
"coverageNotes": "Registered gap on main. The crash-breadcrumb recording and its test exist only on the pending reliability stack. It registers here with its owning split PR.",
|
||||
"coverageNotes": "Renderer unit coverage proves replay-guard lost-completion and certified-wedge events carry correlatable tab, worktree, durable leaf, pane, and redacted PTY identity without exposing path-bearing values. Full provider lifecycle attribution and diagnostics-bundle artifact proof remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/6800",
|
||||
"https://github.com/stablyai/orca/issues/6773"
|
||||
],
|
||||
"invariant": "Terminal lifecycle anomalies around reattach, restore, provider ownership, stale liveness, and fallback routing must leave compact, deduped, privacy-safe breadcrumbs in crash diagnostics so future reports can be attributed from evidence.",
|
||||
"oracle": "The current executable slice calls warnTerminalLifecycleAnomaly with terminal identity, provider, PTY id, binding epoch, and reason, then asserts the existing console warning is preserved and a compact terminal_lifecycle_anomaly crash breadcrumb is recorded once per lifecycle identity. Full pane transition traces and diagnostics-bundle artifact proof remain follow-ups.",
|
||||
"commands": [],
|
||||
"testFiles": [],
|
||||
"assertionRefs": [],
|
||||
"evidenceRuns": [],
|
||||
"invariant": "Terminal lifecycle anomalies around reattach, restore, replay wedges, provider ownership, stale liveness, and fallback routing must leave compact, deduped, privacy-safe breadcrumbs in crash diagnostics so future reports can be attributed from evidence. Replay anomalies must distinguish pane managers and PTYs without recording path-bearing worktree or session identities.",
|
||||
"oracle": "Drop a replay write completion while allowing its FIFO probe to parse, then require the lost-completion breadcrumb to include pane ID, stable hashes for tab/worktree/leaf identity, and a path-redacted PTY ID. Existing wedge tests require both lost-completion and certified-dead paths to record their distinct event names. Full pane transition traces and diagnostics-bundle artifact proof remain follow-ups.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/replay-guard.test.ts"
|
||||
],
|
||||
"testFiles": ["src/renderer/src/components/terminal-pane/replay-guard.test.ts"],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/replay-guard.test.ts",
|
||||
"assertions": [
|
||||
"records correlatable replay identity without exposing worktree or PTY paths",
|
||||
"releases after the probe itself never parses (wedged pipeline) and reports it"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-29",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/replay-guard.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 0.201,
|
||||
"summary": "The focused file passed 29 tests; replay anomaly breadcrumbs preserved event classification while adding hashed tab/worktree/leaf correlation and a path-redacted PTY identity."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 10,
|
||||
"scope": "renderer observability unit test"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "Focused unit slice passed locally once; no CI soak history yet."
|
||||
"evidence": "The replay-guard unit slice passed locally once; no CI soak history yet."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "Tests would fail if lifecycle anomalies stopped recording crash breadcrumbs or stopped deduping repeated identities. Needs diagnostics-bundle artifact proof and full transition-trace evidence before promotion."
|
||||
"evidence": "Tests fail if replay anomalies stop recording their event name, omit pane/terminal correlation, or expose the fixture's path-bearing worktree and PTY prefixes. Needs diagnostics-bundle artifact proof and full transition-trace evidence before promotion."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Breadcrumb recording is deduped and capped by the existing lifecycle anomaly guard; full trace buffers must include size and event-count caps before promotion."
|
||||
"evidence": "Replay identity hashing is synchronous and only runs when replay writes are queued; it adds no polling or provider calls. Breadcrumb storage remains bounded by the existing crash reporter. Full trace buffers must include size and event-count caps before promotion."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Add full compact pane lifecycle trace buffer with event-count caps.",
|
||||
@@ -3132,10 +3520,10 @@
|
||||
"Add forbidden-transition tests for stale close, unknown owner fallback, and stuck zero-size panes."
|
||||
],
|
||||
"knownGaps": [
|
||||
"No executable coverage on main yet; the slice lives on the pending fix-terminal-reliability stack.",
|
||||
"Current command records anomaly breadcrumbs only, not a full pane lifecycle state-machine trace.",
|
||||
"Current command does not prove crash/diagnostics bundle export includes the breadcrumb.",
|
||||
"Current command does not assert forbidden transitions across live Electron/provider flows."
|
||||
"The replay identity schema has not yet been exercised in a live Electron/provider failure.",
|
||||
"Current coverage records anomaly breadcrumbs only, not a full pane lifecycle state-machine trace.",
|
||||
"Current coverage does not prove crash/diagnostics bundle export includes the breadcrumb.",
|
||||
"Current coverage does not assert forbidden transitions across live Electron/provider flows."
|
||||
],
|
||||
"demotionRule": "Cannot promote if diagnostics are console-only, unbounded, or missing from support artifacts."
|
||||
},
|
||||
@@ -3995,6 +4383,7 @@
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts",
|
||||
"assertions": [
|
||||
"startup waits for hydration before mounting terminal panes while degraded mode remains interactive",
|
||||
"only an explicit local execution host can defer cold activation",
|
||||
"SSH, remote-runtime, and unresolved owners remain eager"
|
||||
]
|
||||
|
||||
@@ -47,6 +47,7 @@ describe('computer-use e2e workflow', () => {
|
||||
expect(triggerPaths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'config/scripts/computer-e2e-workflow.test.mjs',
|
||||
'config/scripts/computer-use-modifier-safety.test.mjs',
|
||||
'config/scripts/computer-use-skill-guidance.test.mjs',
|
||||
'config/scripts/computer-use-smoke.mjs',
|
||||
'config/scripts/computer-use-smoke.test.mjs',
|
||||
@@ -72,6 +73,7 @@ describe('computer-use e2e workflow', () => {
|
||||
const regressionRun = nativeSmokeRuns.find((run) => run.includes('pnpm vitest run'))
|
||||
const expectedRegressionFiles = [
|
||||
'config/scripts/computer-e2e-workflow.test.mjs',
|
||||
'config/scripts/computer-use-modifier-safety.test.mjs',
|
||||
'config/scripts/computer-use-skill-guidance.test.mjs',
|
||||
'config/scripts/computer-use-smoke.test.mjs',
|
||||
'src/main/computer/computer-provider-lifecycle.test.ts',
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
|
||||
function source(path) {
|
||||
return readFileSync(join(projectDir, path), 'utf8')
|
||||
}
|
||||
|
||||
function sourceBetween(contents, startMarker, endMarker) {
|
||||
const start = contents.indexOf(startMarker)
|
||||
const end = contents.indexOf(endMarker, start + startMarker.length)
|
||||
if (start < 0 || end < 0) {
|
||||
throw new Error(`Missing source boundary: ${startMarker} → ${endMarker}`)
|
||||
}
|
||||
return contents.slice(start, end)
|
||||
}
|
||||
|
||||
describe('computer-use modifier safety', () => {
|
||||
it('uses mouse-event flags instead of held modifier keys on macOS', () => {
|
||||
const macOS = source('native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift')
|
||||
const clickInput = sourceBetween(macOS, 'static func click(', 'static func scroll(')
|
||||
const mouseInput = sourceBetween(
|
||||
macOS,
|
||||
'private static func mouse(',
|
||||
'private static func keyEvent('
|
||||
)
|
||||
|
||||
expect(mouseInput).toContain('event.flags = flags')
|
||||
expect(clickInput.match(/flags: flags/gu)).toHaveLength(3)
|
||||
expect(clickInput).not.toContain('down: true')
|
||||
})
|
||||
|
||||
it('submits each modified Windows click in a closed, timed SendInput batch', () => {
|
||||
const windows = source('native/computer-use-windows/runtime.ps1')
|
||||
const modifiedClick = sourceBetween(
|
||||
windows,
|
||||
'public static void SendModifiedClick',
|
||||
'private static INPUT KeyboardInput'
|
||||
)
|
||||
const mouseClick = sourceBetween(
|
||||
windows,
|
||||
'function Send-OrcaMouseClick',
|
||||
'function Send-OrcaDrag'
|
||||
)
|
||||
|
||||
expect(modifiedClick).toContain('SendInput((uint)values.Length, values')
|
||||
expect(modifiedClick).toContain('SendInput((uint)releaseValues.Length, releaseValues')
|
||||
expect(modifiedClick).toContain('if (sent != (uint)values.Length)')
|
||||
expect(modifiedClick).toContain('releases.Add(MouseInput(mouseInput, mouseUp))')
|
||||
expect(modifiedClick).not.toContain('int count')
|
||||
expect(mouseClick).toMatch(
|
||||
/for \(\$i = 0; \$i -lt \$clickCount; \$i\+\+\) \{\s+\[OrcaDesktopWin32\]::SendModifiedClick\(/
|
||||
)
|
||||
expect(mouseClick).toContain('if ($i + 1 -lt $clickCount) { Start-Sleep -Milliseconds 35 }')
|
||||
expect(windows).not.toContain('keybd_event')
|
||||
})
|
||||
|
||||
it('keeps Linux modifier release in the xdotool sequence and a fallback', () => {
|
||||
const linux = source('native/computer-use-linux/runtime.py')
|
||||
const modifiedClick = sourceBetween(linux, 'def modified_click_at(', 'def scroll_at(')
|
||||
|
||||
expect(modifiedClick).toContain('command.extend(["keyup", modifier])')
|
||||
expect(modifiedClick).toContain('is_wayland')
|
||||
expect(modifiedClick).toContain('modified clicks require xdotool on an X11 session')
|
||||
expect(modifiedClick).toContain('finally:')
|
||||
expect(modifiedClick).toContain('check=False')
|
||||
expect(modifiedClick).toContain('timeout=5')
|
||||
expect(modifiedClick).toContain('timeout=2')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BUNDLED_SKILL_GUIDES } from '../../src/cli/bundled-skill-guides'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
// Why: computer-use now ships a hybrid discovery stub, so its version-sensitive command
|
||||
@@ -8,6 +9,7 @@ const projectDir = resolve(import.meta.dirname, '../..')
|
||||
// installable stub projection is checked separately below.
|
||||
const guidePath = join(projectDir, 'skill-guides', 'computer-use.md')
|
||||
const stubPath = join(projectDir, 'skills', 'computer-use', 'SKILL.md')
|
||||
const bundledGuide = BUNDLED_SKILL_GUIDES.find((guide) => guide.name === 'computer-use')?.markdown
|
||||
|
||||
describe('computer-use skill guidance', () => {
|
||||
it('keeps web-app targeting on the computer-use surface', () => {
|
||||
@@ -44,6 +46,15 @@ describe('computer-use skill guidance', () => {
|
||||
expect(skill).toContain('`result.snapshot.treeText`')
|
||||
expect(skill).not.toContain('`result.elements`')
|
||||
})
|
||||
|
||||
it('requires atomic modifier-click actions in the source and bundled guide', () => {
|
||||
expect(bundledGuide).toBeDefined()
|
||||
|
||||
for (const skill of [readFileSync(guidePath, 'utf8'), bundledGuide]) {
|
||||
expect(skill).toContain('click --modifiers <chord>')
|
||||
expect(skill).toContain('Never synthesize separate modifier-down and modifier-up commands')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('computer-use install stub', () => {
|
||||
|
||||
@@ -166,9 +166,11 @@ describe('electron-builder config', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the worker-thread hang watchdog inside app.asar', () => {
|
||||
expect(electronBuilderConfig.asarUnpack).not.toContain(
|
||||
'out/main/main-thread-hang-watchdog-entry.js'
|
||||
// Why: the watchdog only arms in packaged builds, and its ELECTRON_RUN_AS_NODE
|
||||
// fork resolves the entry from app.asar.unpacked — inside the asar it never runs.
|
||||
it('unpacks the forked main-thread hang-watchdog entry', () => {
|
||||
expect(electronBuilderConfig.asarUnpack).toEqual(
|
||||
expect.arrayContaining(['out/main/main-thread-hang-watchdog-entry.js'])
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -208,8 +208,9 @@ export const electronViteConfig: UserConfig = {
|
||||
// Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults
|
||||
// can't take down the main process (issue #7547).
|
||||
'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'),
|
||||
// Why: a worker thread survives the macOS 26 AppKit main-thread deadlock
|
||||
// without paying for another Electron process.
|
||||
// Why: forked with ELECTRON_RUN_AS_NODE so it survives a deadlocked
|
||||
// main thread (macOS 26 AppKit scene-update deadlock) and can record
|
||||
// the stall for the next launch to report.
|
||||
'main-thread-hang-watchdog-entry': resolve(
|
||||
'src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts'
|
||||
),
|
||||
|
||||
@@ -15,12 +15,8 @@ import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { ChevronLeft } from 'lucide-react-native'
|
||||
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
|
||||
import { loadHosts, updateHostNameAndEndpoint } from '../../../src/transport/host-store'
|
||||
import {
|
||||
displayHostEndpoint,
|
||||
endpointPort,
|
||||
endpointScheme,
|
||||
normalizeHostEndpoint
|
||||
} from '../../../src/transport/host-endpoint'
|
||||
import { displayHostEndpoint } from '../../../src/transport/host-endpoint'
|
||||
import { resolveHostEndpointEdit } from '../../../src/transport/host-endpoint-edit'
|
||||
import { useForceReconnect, usePrimeHosts } from '../../../src/transport/client-context'
|
||||
import type { HostProfile } from '../../../src/transport/types'
|
||||
import { t } from '@/i18n/mobile-i18n'
|
||||
@@ -69,27 +65,24 @@ export default function EditHostScreen() {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const fallbackPort = host ? endpointPort(host.endpoint) : undefined
|
||||
const fallbackScheme = host ? endpointScheme(host.endpoint) : 'ws'
|
||||
|
||||
const normalizedEndpoint = useMemo(
|
||||
() => normalizeHostEndpoint(address, { fallbackPort, fallbackScheme }),
|
||||
[address, fallbackPort, fallbackScheme]
|
||||
const endpointEdit = useMemo(
|
||||
() => (host ? resolveHostEndpointEdit(host.endpoint, address) : null),
|
||||
[address, host]
|
||||
)
|
||||
|
||||
const nameTrimmed = name.trim()
|
||||
const nameChanged = host != null && nameTrimmed.length > 0 && nameTrimmed !== host.name
|
||||
const endpointChanged =
|
||||
host != null && normalizedEndpoint.ok && normalizedEndpoint.endpoint !== host.endpoint
|
||||
const endpointChanged = endpointEdit?.kind === 'changed'
|
||||
const canSave =
|
||||
host != null &&
|
||||
endpointEdit != null &&
|
||||
nameTrimmed.length > 0 &&
|
||||
normalizedEndpoint.ok &&
|
||||
endpointEdit.kind !== 'invalid' &&
|
||||
(nameChanged || endpointChanged) &&
|
||||
!saving
|
||||
|
||||
async function handleSave() {
|
||||
if (!host || !hostId || savingRef.current) {
|
||||
if (!host || !hostId || !endpointEdit || savingRef.current) {
|
||||
return
|
||||
}
|
||||
const nextName = name.trim()
|
||||
@@ -97,14 +90,14 @@ export default function EditHostScreen() {
|
||||
setSaveError(t('m.7UECNUc'))
|
||||
return
|
||||
}
|
||||
if (!normalizedEndpoint.ok) {
|
||||
setSaveError(normalizedEndpoint.error)
|
||||
if (endpointEdit.kind === 'invalid') {
|
||||
setSaveError(endpointEdit.error)
|
||||
return
|
||||
}
|
||||
|
||||
const willRename = nextName !== host.name
|
||||
const willUpdateEndpoint = normalizedEndpoint.endpoint !== host.endpoint
|
||||
if (!willRename && !willUpdateEndpoint) {
|
||||
const nextEndpoint = endpointEdit.kind === 'changed' ? endpointEdit.endpoint : undefined
|
||||
if (!willRename && nextEndpoint === undefined) {
|
||||
router.back()
|
||||
return
|
||||
}
|
||||
@@ -118,7 +111,7 @@ export default function EditHostScreen() {
|
||||
// other, and a host removed mid-edit throws instead of no-oping.
|
||||
await updateHostNameAndEndpoint(host.id, {
|
||||
...(willRename ? { name: nextName } : {}),
|
||||
...(willUpdateEndpoint ? { endpoint: normalizedEndpoint.endpoint } : {})
|
||||
...(nextEndpoint !== undefined ? { endpoint: nextEndpoint } : {})
|
||||
})
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : t('m.sI26LXo'))
|
||||
@@ -141,7 +134,7 @@ export default function EditHostScreen() {
|
||||
setSaving(false)
|
||||
router.back()
|
||||
|
||||
if (willUpdateEndpoint) {
|
||||
if (nextEndpoint !== undefined) {
|
||||
// Why: reconnect is a follow-on side effect of a save that already
|
||||
// committed — its failure or a hang must not be reported as a save
|
||||
// failure or block navigating back.
|
||||
@@ -241,12 +234,12 @@ export default function EditHostScreen() {
|
||||
/>
|
||||
<Text style={styles.hint}>{t('m.dYplni8')}</Text>
|
||||
|
||||
{normalizedEndpoint.ok ? (
|
||||
{endpointEdit == null ? null : endpointEdit.kind !== 'invalid' ? (
|
||||
<Text style={styles.preview} numberOfLines={2}>
|
||||
{t('m.eB5hKE4', { value0: normalizedEndpoint.endpoint })}
|
||||
{t('m.eB5hKE4', { value0: endpointEdit.endpoint })}
|
||||
</Text>
|
||||
) : address.trim().length > 0 ? (
|
||||
<Text style={styles.previewError}>{normalizedEndpoint.error}</Text>
|
||||
<Text style={styles.previewError}>{endpointEdit.error}</Text>
|
||||
) : null}
|
||||
|
||||
{saveError ? <Text style={styles.errorText}>{saveError}</Text> : null}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { handleMockFilePreviewRequest } from './mock-server-file-preview-data'
|
||||
import { handleMockGitRequest } from './mock-server-git-state'
|
||||
import { handleMockAccountRequest } from './mock-server-account-rpc'
|
||||
import { handleMockNativeChatRequest } from './mock-server-native-chat-scenario'
|
||||
import { handleMockSessionTabsRequest } from './mock-server-session-tabs-fixture'
|
||||
import {
|
||||
createMockTerminals,
|
||||
FAKE_SCROLLBACK,
|
||||
@@ -124,7 +125,8 @@ export function handleRequest(
|
||||
handleMockGitRequest(request, respond, success) ||
|
||||
handleMockFilePreviewRequest(request, respond, success, error) ||
|
||||
handleMockAccountRequest(request, respond, success, error) ||
|
||||
handleMockNativeChatRequest(request, respond, success, error, ws)
|
||||
handleMockNativeChatRequest(request, respond, success, error, ws) ||
|
||||
handleMockSessionTabsRequest(request, respond, success, terminalListWorktreeId)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types'
|
||||
import type { RpcRequest, RpcResponse } from './mock-server-rpc-handlers'
|
||||
|
||||
// Why: the client's snapshot-acceptance gate keys on the publisher epoch, so it
|
||||
// must stay stable for the process and change on restart like a real publisher —
|
||||
// hence a uuid, not a clock read two restarts could land on.
|
||||
// The `mobile-local:` prefix is reserved for phone-local writes — never use it.
|
||||
const PUBLICATION_EPOCH = `mock-server:${randomUUID()}`
|
||||
const GROUP_ID = 'group-1'
|
||||
const PARENT_TAB_ID = 'tab-1'
|
||||
// The host only ever publishes terminal-layout UUIDs here; pane-key parsing
|
||||
// rejects any other shape, so a placeholder would mask pane-attribution bugs.
|
||||
const LEAF_ID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
|
||||
// The host publishes terminal surfaces as `${parentTabId}::${leafId}`.
|
||||
const SURFACE_TAB_ID = `${PARENT_TAB_ID}::${LEAF_ID}`
|
||||
|
||||
/** One ready terminal tab bound to the `term-1` fixture. Mirrors the full
|
||||
* `session.tabs.list` contract so mock-server repros of tab, split-pane, and
|
||||
* pane-attribution bugs aren't shape-incomplete. */
|
||||
function createMockSessionTabs(worktreeId: string): RuntimeMobileSessionTabsResult {
|
||||
return {
|
||||
worktree: worktreeId,
|
||||
publicationEpoch: PUBLICATION_EPOCH,
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: GROUP_ID,
|
||||
activeTabId: SURFACE_TAB_ID,
|
||||
activeTabType: 'terminal',
|
||||
// Groups track top-level tabs, so they carry parentTabId, not surface ids.
|
||||
tabGroups: [
|
||||
{
|
||||
id: GROUP_ID,
|
||||
activeTabId: PARENT_TAB_ID,
|
||||
tabOrder: [PARENT_TAB_ID],
|
||||
recentTabIds: [PARENT_TAB_ID]
|
||||
}
|
||||
],
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: SURFACE_TAB_ID,
|
||||
title: 'zsh',
|
||||
parentTabId: PARENT_TAB_ID,
|
||||
leafId: LEAF_ID,
|
||||
status: 'ready',
|
||||
terminal: 'term-1',
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/** Default session-tabs backend: without it the session screen hangs on
|
||||
* 'Loading tabs'. Returns false for methods it does not own. */
|
||||
export function handleMockSessionTabsRequest(
|
||||
request: RpcRequest,
|
||||
respond: (response: RpcResponse) => void,
|
||||
success: (id: string, result: unknown, streaming?: boolean) => RpcResponse,
|
||||
// Shared with `terminal.list` so both surfaces agree on which worktree an
|
||||
// absent or `id:`-prefixed selector means.
|
||||
resolveWorktreeId: (selector: unknown) => string | undefined
|
||||
): boolean {
|
||||
if (request.method !== 'session.tabs.list') {
|
||||
return false
|
||||
}
|
||||
const worktreeId = resolveWorktreeId(request.params?.worktree) ?? 'mock'
|
||||
respond(success(request.id, createMockSessionTabs(worktreeId)))
|
||||
return true
|
||||
}
|
||||
@@ -149,8 +149,13 @@ describe('edit host handleSave', () => {
|
||||
})
|
||||
|
||||
it('rename-only save updates only the name and does not reconnect', async () => {
|
||||
const storedEndpoint = 'wss://Desk.Example.com/%6Fruntime?route=%72ed'
|
||||
dependencies.loadHosts.mockResolvedValueOnce([{ ...HOST_FIXTURE, endpoint: storedEndpoint }])
|
||||
const renderer = await renderEditHostRoute()
|
||||
setFieldValue(renderer, 'Address', ' wss://%64esk.example.com:443 ')
|
||||
setFieldValue(renderer, 'Name', 'Home Desk')
|
||||
|
||||
expect(findText(renderer, `Connects to ${storedEndpoint}`)).toBe(true)
|
||||
await pressSave(renderer)
|
||||
|
||||
expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledWith('host-1', {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { WebSocket } from 'ws'
|
||||
import {
|
||||
handleRequest,
|
||||
type RpcRequest,
|
||||
type RpcResponse
|
||||
} from '../scripts/mock-server-rpc-handlers'
|
||||
|
||||
function callRpc(method: string, params?: Record<string, unknown>): RpcResponse {
|
||||
let response: RpcResponse | undefined
|
||||
const request: RpcRequest = { id: 'request-1', method, ...(params ? { params } : {}) }
|
||||
handleRequest(
|
||||
request,
|
||||
(nextResponse) => {
|
||||
response = nextResponse
|
||||
},
|
||||
{} as WebSocket
|
||||
)
|
||||
expect(response).toBeDefined()
|
||||
return response!
|
||||
}
|
||||
|
||||
function listSessionTabs(worktree: string): RpcResponse {
|
||||
return callRpc('session.tabs.list', { worktree })
|
||||
}
|
||||
|
||||
describe('mock server session tabs fixture', () => {
|
||||
it('returns a contract-complete terminal surface for the requested worktree', () => {
|
||||
const response = listSessionTabs('id:repo-1::worktree-1')
|
||||
|
||||
expect(response.result).toEqual({
|
||||
worktree: 'repo-1::worktree-1',
|
||||
publicationEpoch: expect.stringMatching(/^mock-server:/),
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: 'tab-1::f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||
activeTabType: 'terminal',
|
||||
tabGroups: [
|
||||
{
|
||||
id: 'group-1',
|
||||
activeTabId: 'tab-1',
|
||||
tabOrder: ['tab-1'],
|
||||
recentTabIds: ['tab-1']
|
||||
}
|
||||
],
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'tab-1::f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||
title: 'zsh',
|
||||
parentTabId: 'tab-1',
|
||||
leafId: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||
status: 'ready',
|
||||
terminal: 'term-1',
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('passes a bare worktree selector through unprefixed', () => {
|
||||
const response = listSessionTabs('repo-1::worktree-1')
|
||||
|
||||
expect((response.result as { worktree: string }).worktree).toBe('repo-1::worktree-1')
|
||||
})
|
||||
|
||||
it('falls back to the same worktree terminal.list uses when no selector is sent', () => {
|
||||
const terminals = callRpc('terminal.list').result as {
|
||||
terminals: { worktreeId: string }[]
|
||||
}
|
||||
const expected = terminals.terminals[0]?.worktreeId
|
||||
expect(expected).toBeTruthy()
|
||||
|
||||
const tabs = callRpc('session.tabs.list').result as { worktree: string }
|
||||
expect(tabs.worktree).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { displayHostEndpoint } from './host-endpoint'
|
||||
import { resolveHostEndpointEdit } from './host-endpoint-edit'
|
||||
|
||||
function persistedAfterEdit(stored: string, input = displayHostEndpoint(stored)) {
|
||||
const edit = resolveHostEndpointEdit(stored, input)
|
||||
return edit.kind === 'changed' ? edit.endpoint : stored
|
||||
}
|
||||
|
||||
describe('resolveHostEndpointEdit', () => {
|
||||
it.each([
|
||||
['implicit secure DNS proxy', 'wss://desk.example.com'],
|
||||
['explicit secure default', 'wss://desk.example.com:443'],
|
||||
['explicit secure custom', 'wss://desk.example.com:8443'],
|
||||
['implicit secure IPv6 proxy', 'wss://[2001:db8::1]'],
|
||||
['explicit secure IPv6 default', 'wss://[2001:db8::1]:443'],
|
||||
['implicit LAN websocket', 'ws://desk.local'],
|
||||
['explicit websocket default', 'ws://desk.local:80'],
|
||||
['explicit websocket custom', 'ws://desk.local:6768'],
|
||||
['secure path-routed proxy', 'wss://desk.example.com/orca'],
|
||||
['secure query-routed proxy', 'wss://desk.example.com/orca?route=runtime'],
|
||||
['legacy bare hostname', 'desk.local'],
|
||||
['legacy bare host and port', 'desk.local:7777'],
|
||||
['legacy unparsable endpoint', 'not-a-url']
|
||||
])('preserves an untouched %s endpoint', (_label, stored) => {
|
||||
const edit = resolveHostEndpointEdit(stored, displayHostEndpoint(stored))
|
||||
|
||||
expect(edit).toEqual({ kind: 'unchanged', endpoint: stored })
|
||||
expect(persistedAfterEdit(stored)).toBe(stored)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['wss://old.example.com', 'new.example.com', 'wss://new.example.com:443'],
|
||||
['wss://old.example.com:8443', 'new.example.com', 'wss://new.example.com:8443'],
|
||||
['ws://old.local', 'new.local', 'ws://new.local:6768'],
|
||||
['ws://old.local:80', 'new.local', 'ws://new.local:80'],
|
||||
['wss://[2001:db8::1]', '[2001:db8::2]', 'wss://[2001:db8::2]:443']
|
||||
])('uses the current endpoint semantics for an address edit', (stored, input, expected) => {
|
||||
const edit = resolveHostEndpointEdit(stored, input)
|
||||
|
||||
expect(edit).toEqual({ kind: 'changed', endpoint: expected })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['surrounding whitespace', ' desk.example.com '],
|
||||
['hostname case', 'DESK.EXAMPLE.COM'],
|
||||
['explicit default port', 'desk.example.com:443'],
|
||||
['percent-encoded hostname', 'wss://%64esk.example.com:443']
|
||||
])('preserves routed endpoint bytes for equivalent %s', (_label, input) => {
|
||||
const stored = 'wss://Desk.Example.com/%6Fruntime?route=%72ed'
|
||||
|
||||
expect(resolveHostEndpointEdit(stored, input)).toEqual({
|
||||
kind: 'unchanged',
|
||||
endpoint: stored
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves the hidden route when the authority changes', () => {
|
||||
expect(
|
||||
resolveHostEndpointEdit('wss://old.example.com/%6Fruntime?route=%72ed', 'new.example.com')
|
||||
).toEqual({
|
||||
kind: 'changed',
|
||||
endpoint: 'wss://new.example.com:443/%6Fruntime?route=%72ed'
|
||||
})
|
||||
})
|
||||
|
||||
it('discriminates invalid input without a candidate endpoint', () => {
|
||||
expect(
|
||||
resolveHostEndpointEdit('wss://desk.example.com/orca', 'https://desk.example.com')
|
||||
).toEqual({
|
||||
kind: 'invalid',
|
||||
error: 'Use ws:// or wss:// (or host:port).'
|
||||
})
|
||||
})
|
||||
|
||||
it('stays stable through repeated name-only edits', () => {
|
||||
const stored = 'wss://desk.example.com/orca?route=runtime'
|
||||
const once = persistedAfterEdit(stored)
|
||||
|
||||
expect(once).toBe(stored)
|
||||
expect(persistedAfterEdit(once)).toBe(stored)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
displayHostEndpoint,
|
||||
endpointPort,
|
||||
endpointScheme,
|
||||
normalizeHostEndpoint
|
||||
} from './host-endpoint'
|
||||
|
||||
export type HostEndpointEditResolution =
|
||||
| { kind: 'unchanged'; endpoint: string }
|
||||
| { kind: 'changed'; endpoint: string }
|
||||
| { kind: 'invalid'; error: string }
|
||||
|
||||
export function resolveHostEndpointEdit(
|
||||
storedEndpoint: string,
|
||||
input: string
|
||||
): HostEndpointEditResolution {
|
||||
const displayedEndpoint = displayHostEndpoint(storedEndpoint)
|
||||
if (input.trim() === displayedEndpoint) {
|
||||
return { kind: 'unchanged', endpoint: storedEndpoint }
|
||||
}
|
||||
|
||||
const fallbackScheme = endpointScheme(storedEndpoint)
|
||||
const fallbackPort =
|
||||
endpointPort(storedEndpoint) ?? (fallbackScheme === 'wss' ? '443' : undefined)
|
||||
const options = { fallbackPort, fallbackScheme }
|
||||
const normalizedInput = normalizeHostEndpoint(input, options)
|
||||
if (!normalizedInput.ok) {
|
||||
return { kind: 'invalid', error: normalizedInput.error }
|
||||
}
|
||||
|
||||
const normalizedDisplay = normalizeHostEndpoint(displayedEndpoint, options)
|
||||
if (
|
||||
sameEndpointAuthority(normalizedInput.endpoint, storedEndpoint) ||
|
||||
(normalizedDisplay.ok &&
|
||||
sameEndpointAuthority(normalizedInput.endpoint, normalizedDisplay.endpoint))
|
||||
) {
|
||||
return { kind: 'unchanged', endpoint: storedEndpoint }
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'changed',
|
||||
endpoint: normalizedInput.endpoint + endpointRouteSuffix(storedEndpoint)
|
||||
}
|
||||
}
|
||||
|
||||
function sameEndpointAuthority(left: string, right: string): boolean {
|
||||
try {
|
||||
return new URL(left).origin === new URL(right).origin
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function endpointRouteSuffix(endpoint: string): string {
|
||||
const match = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/?#]*(.*)$/.exec(endpoint)
|
||||
return match?.[1] ?? ''
|
||||
}
|
||||
@@ -822,19 +822,60 @@ def require_non_empty_string(value, name):
|
||||
return str(value)
|
||||
|
||||
|
||||
def click_at(x, y, button, count):
|
||||
def click_at(x, y, button, count, modifiers=None):
|
||||
button = (button or "left").lower()
|
||||
buttons = {"left": ("b1p", "b1r"), "right": ("b3p", "b3r"), "middle": ("b2p", "b2r")}
|
||||
if button not in buttons:
|
||||
raise RuntimeError(f"unsupported mouse button: {button}")
|
||||
parsed_count = require_positive_integer(1 if count is None else count, "click_count")
|
||||
modifier_keys = click_modifier_keys(modifiers)
|
||||
if modifier_keys:
|
||||
modified_click_at(x, y, button, parsed_count, modifier_keys)
|
||||
return
|
||||
down, up = buttons[button]
|
||||
for _ in range(require_positive_integer(1 if count is None else count, "click_count")):
|
||||
for _ in range(parsed_count):
|
||||
Atspi.generate_mouse_event(round(x), round(y), "abs")
|
||||
Atspi.generate_mouse_event(round(x), round(y), down)
|
||||
time.sleep(0.03)
|
||||
Atspi.generate_mouse_event(round(x), round(y), up)
|
||||
|
||||
|
||||
def click_modifier_keys(raw):
|
||||
if raw is None:
|
||||
return []
|
||||
aliases = {
|
||||
"ctrl": "ctrl", "control": "ctrl", "cmdorctrl": "ctrl", "commandorcontrol": "ctrl",
|
||||
"shift": "shift", "alt": "alt", "option": "alt",
|
||||
"meta": "super", "super": "super", "win": "super", "cmd": "super", "command": "super",
|
||||
}
|
||||
parts = [part.strip().lower() for part in str(raw).split("+")]
|
||||
if not parts or any(not part or part not in aliases for part in parts):
|
||||
raise RuntimeError("click modifiers require modifier keys only")
|
||||
return list(dict.fromkeys(aliases[part] for part in parts))
|
||||
|
||||
|
||||
def modified_click_at(x, y, button, count, modifier_keys):
|
||||
xdotool = shutil.which("xdotool")
|
||||
is_wayland = os.environ.get("XDG_SESSION_TYPE", "").lower() == "wayland"
|
||||
if not xdotool or is_wayland:
|
||||
raise RuntimeError("modified clicks require xdotool on an X11 session")
|
||||
button_number = {"left": "1", "middle": "2", "right": "3"}[button]
|
||||
command = [xdotool, "mousemove", "--sync", str(round(x)), str(round(y))]
|
||||
for modifier in modifier_keys:
|
||||
command.extend(["keydown", modifier])
|
||||
command.extend(["click", "--repeat", str(count), "--delay", "35", button_number])
|
||||
for modifier in reversed(modifier_keys):
|
||||
command.extend(["keyup", modifier])
|
||||
try:
|
||||
subprocess.run(command, check=True, timeout=5)
|
||||
finally:
|
||||
subprocess.run(
|
||||
[xdotool, *[item for modifier in reversed(modifier_keys) for item in ("keyup", modifier)]],
|
||||
check=False,
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
|
||||
def scroll_at(x, y, direction, pages):
|
||||
if direction is None or str(direction).strip() == "":
|
||||
raise RuntimeError("direction is required")
|
||||
@@ -1024,12 +1065,14 @@ def run_operation(operation):
|
||||
if operation.get("click_count") is not None
|
||||
else 1
|
||||
)
|
||||
handled = operation.get("mouse_button", "left") == "left" and click_count <= 1 and perform_action(node, preferred)
|
||||
has_modifiers = bool(str(operation.get("modifiers") or "").strip())
|
||||
handled = not has_modifiers and operation.get("mouse_button", "left") == "left" and click_count <= 1 and perform_action(node, preferred)
|
||||
if not handled:
|
||||
click_at(
|
||||
*screen_point(bounds, saved, operation.get("x"), operation.get("y"), node),
|
||||
operation.get("mouse_button", "left"),
|
||||
click_count,
|
||||
operation.get("modifiers"),
|
||||
)
|
||||
action = {"path": "synthetic", "actionName": None, "fallbackReason": "actionUnsupported"}
|
||||
else:
|
||||
|
||||
@@ -729,12 +729,13 @@ final class Provider {
|
||||
let snapshot = try currentSnapshot(params: params)
|
||||
let button = params["mouseButton"]?.string ?? "left"
|
||||
let count = try positiveInteger(params["clickCount"]?.number, defaultValue: 1, name: "clickCount")
|
||||
let modifiers = try KeyMap.parseModifiers(params["modifiers"]?.string)
|
||||
// Why: agents expect a click into a target app to make the next
|
||||
// keyboard action safe, even when the click uses an AX action path.
|
||||
recoverWindow(snapshot.app)
|
||||
if let elementIndex = try optionalInteger(params, "elementIndex") {
|
||||
let record = try element(snapshot, elementIndex)
|
||||
if count <= 1, let actionName = try performClickAction(record: record, mouseButton: button) {
|
||||
if modifiers.isEmpty, count <= 1, let actionName = try performClickAction(record: record, mouseButton: button) {
|
||||
return actionMetadata(path: "accessibility", actionName: actionName)
|
||||
}
|
||||
if let point = center(record.localFrame, in: snapshot.windowBounds) {
|
||||
@@ -742,7 +743,8 @@ final class Provider {
|
||||
pid: snapshot.app.pid,
|
||||
at: point,
|
||||
button: mouseButton(button),
|
||||
count: count
|
||||
count: count,
|
||||
modifiers: modifiers
|
||||
)
|
||||
return actionMetadata(path: "synthetic", fallbackReason: "actionUnsupported")
|
||||
}
|
||||
@@ -753,7 +755,8 @@ final class Provider {
|
||||
pid: snapshot.app.pid,
|
||||
at: point,
|
||||
button: mouseButton(button),
|
||||
count: count
|
||||
count: count,
|
||||
modifiers: modifiers
|
||||
)
|
||||
return actionMetadata(path: "synthetic")
|
||||
}
|
||||
@@ -2282,14 +2285,23 @@ private func resizePng(_ image: CGImage, scale: CGFloat) -> BoundedPNG? {
|
||||
}
|
||||
|
||||
private enum Input {
|
||||
static func click(pid: pid_t, at point: CGPoint, button: MouseButton, count: Int) throws {
|
||||
static func click(
|
||||
pid: pid_t,
|
||||
at point: CGPoint,
|
||||
button: MouseButton,
|
||||
count: Int,
|
||||
modifiers: [KeyModifier]
|
||||
) throws {
|
||||
guard let source = CGEventSource(stateID: .combinedSessionState) else {
|
||||
throw ProviderError.coded("accessibility_error", "failed to create event source")
|
||||
}
|
||||
let flags = modifiers.reduce(into: CGEventFlags()) { result, modifier in
|
||||
result.insert(modifier.flag)
|
||||
}
|
||||
for _ in 0..<max(count, 1) {
|
||||
try mouse(.mouseMoved, source: source, point: point, button: button.cgButton, pid: pid)
|
||||
try mouse(button.downEvent, source: source, point: point, button: button.cgButton, pid: pid)
|
||||
try mouse(button.upEvent, source: source, point: point, button: button.cgButton, pid: pid)
|
||||
try mouse(.mouseMoved, source: source, point: point, button: button.cgButton, flags: flags, pid: pid)
|
||||
try mouse(button.downEvent, source: source, point: point, button: button.cgButton, flags: flags, pid: pid)
|
||||
try mouse(button.upEvent, source: source, point: point, button: button.cgButton, flags: flags, pid: pid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2343,16 +2355,20 @@ private enum Input {
|
||||
static func pressKey(_ key: String, pid: pid_t) throws {
|
||||
let parsed = try KeyMap.parse(key)
|
||||
var flags = CGEventFlags()
|
||||
var pressedModifiers: [KeyModifier] = []
|
||||
defer {
|
||||
for modifier in pressedModifiers.reversed() {
|
||||
flags.remove(modifier.flag)
|
||||
try? keyEvent(modifier.keyCode, down: false, flags: flags, pid: pid)
|
||||
}
|
||||
}
|
||||
for modifier in parsed.modifiers {
|
||||
flags.insert(modifier.flag)
|
||||
try keyEvent(modifier.keyCode, down: true, flags: flags, pid: pid)
|
||||
pressedModifiers.append(modifier)
|
||||
}
|
||||
try keyEvent(parsed.keyCode, down: true, flags: flags, pid: pid)
|
||||
try keyEvent(parsed.keyCode, down: false, flags: flags, pid: pid)
|
||||
for modifier in parsed.modifiers.reversed() {
|
||||
try keyEvent(modifier.keyCode, down: false, flags: flags, pid: pid)
|
||||
flags.remove(modifier.flag)
|
||||
}
|
||||
}
|
||||
|
||||
static func pasteText(_ text: String, pid: pid_t) throws {
|
||||
@@ -2377,10 +2393,18 @@ private enum Input {
|
||||
try pressKey("cmd+v", pid: pid)
|
||||
}
|
||||
|
||||
private static func mouse(_ type: CGEventType, source: CGEventSource, point: CGPoint, button: CGMouseButton, pid: pid_t) throws {
|
||||
private static func mouse(
|
||||
_ type: CGEventType,
|
||||
source: CGEventSource,
|
||||
point: CGPoint,
|
||||
button: CGMouseButton,
|
||||
flags: CGEventFlags = [],
|
||||
pid: pid_t
|
||||
) throws {
|
||||
guard let event = CGEvent(mouseEventSource: source, mouseType: type, mouseCursorPosition: point, mouseButton: button) else {
|
||||
throw ProviderError.coded("accessibility_error", "failed to create mouse event")
|
||||
}
|
||||
event.flags = flags
|
||||
event.postToPid(pid)
|
||||
}
|
||||
|
||||
@@ -2497,16 +2521,9 @@ private enum KeyMap {
|
||||
var modifiers: [KeyModifier] = []
|
||||
var keyName: String?
|
||||
for part in parts {
|
||||
switch part {
|
||||
case "cmd", "command", "meta", "super", "cmdorctrl", "commandorcontrol":
|
||||
modifiers.append(KeyModifier(keyCode: 55, flag: .maskCommand))
|
||||
case "ctrl", "control":
|
||||
modifiers.append(KeyModifier(keyCode: 59, flag: .maskControl))
|
||||
case "alt", "option":
|
||||
modifiers.append(KeyModifier(keyCode: 58, flag: .maskAlternate))
|
||||
case "shift":
|
||||
modifiers.append(KeyModifier(keyCode: 56, flag: .maskShift))
|
||||
default:
|
||||
if let modifier = modifier(part) {
|
||||
modifiers.append(modifier)
|
||||
} else {
|
||||
keyName = part
|
||||
}
|
||||
}
|
||||
@@ -2516,6 +2533,38 @@ private enum KeyMap {
|
||||
return ParsedKey(keyCode: keyCode, modifiers: modifiers)
|
||||
}
|
||||
|
||||
static func parseModifiers(_ spec: String?) throws -> [KeyModifier] {
|
||||
guard let spec else {
|
||||
return []
|
||||
}
|
||||
let parts = spec.split(separator: "+", omittingEmptySubsequences: false)
|
||||
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
|
||||
guard !parts.isEmpty, !parts.contains(where: \.isEmpty) else {
|
||||
throw ProviderError.coded("invalid_argument", "click modifiers require modifier keys only")
|
||||
}
|
||||
return try parts.map { part in
|
||||
guard let modifier = modifier(part) else {
|
||||
throw ProviderError.coded("invalid_argument", "unsupported click modifier '\(part)'")
|
||||
}
|
||||
return modifier
|
||||
}
|
||||
}
|
||||
|
||||
private static func modifier(_ part: String) -> KeyModifier? {
|
||||
switch part {
|
||||
case "cmd", "command", "meta", "super", "win", "cmdorctrl", "commandorcontrol":
|
||||
return KeyModifier(keyCode: 55, flag: .maskCommand)
|
||||
case "ctrl", "control":
|
||||
return KeyModifier(keyCode: 59, flag: .maskControl)
|
||||
case "alt", "option":
|
||||
return KeyModifier(keyCode: 58, flag: .maskAlternate)
|
||||
case "shift":
|
||||
return KeyModifier(keyCode: 56, flag: .maskShift)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static let codes: [String: CGKeyCode] = [
|
||||
"a": 0, "s": 1, "d": 2, "f": 3, "h": 4, "g": 5, "z": 6, "x": 7, "c": 8, "v": 9,
|
||||
"b": 11, "q": 12, "w": 13, "e": 14, "r": 15, "y": 16, "t": 17, "1": 18, "2": 19,
|
||||
@@ -2530,12 +2579,9 @@ private enum KeyMap {
|
||||
}
|
||||
|
||||
private final class AgentRuntime: NSObject, NSApplicationDelegate {
|
||||
private static let unclaimedSessionDeadline: TimeInterval = 30
|
||||
|
||||
private let socketPath: String
|
||||
private let token: String?
|
||||
private var listener: SocketListener?
|
||||
private var unclaimedSessionTimeout: DispatchWorkItem?
|
||||
|
||||
init(socketPath: String, token: String?) {
|
||||
self.socketPath = socketPath
|
||||
@@ -2544,31 +2590,9 @@ private final class AgentRuntime: NSObject, NSApplicationDelegate {
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
do {
|
||||
let timeout = DispatchWorkItem {
|
||||
fputs("computer-use agent received no authenticated session before its deadline\n", stderr)
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
unclaimedSessionTimeout = timeout
|
||||
let listener = try SocketListener(
|
||||
socketPath: socketPath,
|
||||
token: token,
|
||||
onSessionClaimed: {
|
||||
DispatchQueue.main.async {
|
||||
timeout.cancel()
|
||||
}
|
||||
},
|
||||
onSessionClosed: {
|
||||
DispatchQueue.main.async {
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
}
|
||||
)
|
||||
let listener = try SocketListener(socketPath: socketPath, token: token)
|
||||
self.listener = listener
|
||||
listener.start()
|
||||
DispatchQueue.main.asyncAfter(
|
||||
deadline: .now() + Self.unclaimedSessionDeadline,
|
||||
execute: timeout
|
||||
)
|
||||
} catch {
|
||||
fputs("failed to start computer-use socket: \(error)\n", stderr)
|
||||
NSApp.terminate(nil)
|
||||
@@ -2576,8 +2600,6 @@ private final class AgentRuntime: NSObject, NSApplicationDelegate {
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
unclaimedSessionTimeout?.cancel()
|
||||
unclaimedSessionTimeout = nil
|
||||
listener?.stop()
|
||||
}
|
||||
}
|
||||
@@ -3483,25 +3505,14 @@ private final class ButtonTarget: NSObject {
|
||||
private final class SocketListener: @unchecked Sendable {
|
||||
private let socketPath: String
|
||||
private let token: String?
|
||||
private let onSessionClaimed: () -> Void
|
||||
private let onSessionClosed: () -> Void
|
||||
private let provider = Provider()
|
||||
private let providerLock = NSLock()
|
||||
private let sessionLock = NSLock()
|
||||
private var sessionOwnership = AgentSessionOwnership()
|
||||
private var socketFd: Int32 = -1
|
||||
private var isStopped = false
|
||||
|
||||
init(
|
||||
socketPath: String,
|
||||
token: String?,
|
||||
onSessionClaimed: @escaping () -> Void,
|
||||
onSessionClosed: @escaping () -> Void
|
||||
) throws {
|
||||
init(socketPath: String, token: String?) throws {
|
||||
self.socketPath = socketPath
|
||||
self.token = token
|
||||
self.onSessionClaimed = onSessionClaimed
|
||||
self.onSessionClosed = onSessionClosed
|
||||
try bindSocket()
|
||||
}
|
||||
|
||||
@@ -3583,39 +3594,15 @@ private final class SocketListener: @unchecked Sendable {
|
||||
}
|
||||
|
||||
private func handleConnection(_ fd: Int32) {
|
||||
defer {
|
||||
sessionLock.lock()
|
||||
let shouldTerminate = sessionOwnership.disconnect(fd)
|
||||
sessionLock.unlock()
|
||||
close(fd)
|
||||
if shouldTerminate {
|
||||
onSessionClosed()
|
||||
}
|
||||
}
|
||||
defer { close(fd) }
|
||||
let authorizedPeer = peerProcessId(fd).map(isAuthorizedAgentPeer) == true
|
||||
let decoder = JSONDecoder()
|
||||
var registrationComplete = false
|
||||
while let line = readLine(from: fd) {
|
||||
guard let data = line.data(using: .utf8),
|
||||
let request = try? decoder.decode(Request.self, from: data)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
if !registrationComplete {
|
||||
sessionLock.lock()
|
||||
let registration = sessionOwnership.registerConnection(
|
||||
fd,
|
||||
authenticated: isAuthenticated(
|
||||
request: request,
|
||||
authorizedPeer: authorizedPeer
|
||||
)
|
||||
)
|
||||
sessionLock.unlock()
|
||||
registrationComplete = registration != .rejected
|
||||
if registration == .claimed {
|
||||
onSessionClaimed()
|
||||
}
|
||||
}
|
||||
let response = handleRequest(
|
||||
provider: provider,
|
||||
lock: providerLock,
|
||||
@@ -3626,12 +3613,6 @@ private final class SocketListener: @unchecked Sendable {
|
||||
writeJSON(response, to: fd)
|
||||
}
|
||||
}
|
||||
|
||||
private func isAuthenticated(request: Request, authorizedPeer: Bool) -> Bool {
|
||||
guard authorizedPeer else { return false }
|
||||
guard let token else { return true }
|
||||
return request.token == token
|
||||
}
|
||||
}
|
||||
|
||||
private func existingPathMode(_ path: String) -> mode_t? {
|
||||
@@ -3764,7 +3745,7 @@ private func handleRequest(
|
||||
if let expectedToken, request.token != expectedToken {
|
||||
return ["id": request.id, "ok": false, "error": ["code": "permission_denied", "message": "invalid computer-use agent token"]]
|
||||
}
|
||||
if !authorizedPeer {
|
||||
if expectedToken != nil && !authorizedPeer {
|
||||
return ["id": request.id, "ok": false, "error": ["code": "permission_denied", "message": "computer-use agent peer is not authorized"]]
|
||||
}
|
||||
if request.method == "terminate" {
|
||||
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
public enum AgentSessionRegistration: Sendable, Equatable {
|
||||
case rejected
|
||||
case sessionReleased
|
||||
case joined
|
||||
case claimed
|
||||
}
|
||||
|
||||
public struct AgentSessionOwnership: Sendable {
|
||||
private var authenticatedConnections: Set<Int32> = []
|
||||
private var wasClaimed = false
|
||||
private var wasReleased = false
|
||||
|
||||
public init() {}
|
||||
|
||||
public mutating func registerConnection(
|
||||
_ connection: Int32,
|
||||
authenticated: Bool
|
||||
) -> AgentSessionRegistration {
|
||||
guard authenticated else { return .rejected }
|
||||
guard !wasReleased else { return .sessionReleased }
|
||||
let inserted = authenticatedConnections.insert(connection).inserted
|
||||
guard inserted, !wasClaimed else { return .joined }
|
||||
wasClaimed = true
|
||||
return .claimed
|
||||
}
|
||||
|
||||
public mutating func disconnect(_ connection: Int32) -> Bool {
|
||||
guard authenticatedConnections.remove(connection) != nil else { return false }
|
||||
let released = wasClaimed && authenticatedConnections.isEmpty
|
||||
if released {
|
||||
wasReleased = true
|
||||
}
|
||||
return released
|
||||
}
|
||||
}
|
||||
+28
@@ -18,4 +18,32 @@ final class AgentEntrypointSourceSafetyTests: XCTestCase {
|
||||
XCTAssertFalse(source.contains("unlink(tokenPath)"))
|
||||
XCTAssertFalse(source.contains("unlink(socketPath)"))
|
||||
}
|
||||
|
||||
func testSyntheticModifiersHaveGuaranteedReleaseAndModifiedClicksUseFlags() throws {
|
||||
let source = try agentEntrypointSource()
|
||||
|
||||
XCTAssertTrue(source.contains("var pressedModifiers: [KeyModifier] = []"))
|
||||
XCTAssertTrue(source.contains(
|
||||
"""
|
||||
defer {
|
||||
for modifier in pressedModifiers.reversed() {
|
||||
flags.remove(modifier.flag)
|
||||
try? keyEvent(modifier.keyCode, down: false, flags: flags, pid: pid)
|
||||
"""
|
||||
))
|
||||
XCTAssertTrue(source.contains("event.flags = flags\n event.postToPid(pid)"))
|
||||
}
|
||||
|
||||
private func agentEntrypointSource() throws -> String {
|
||||
let testFile = URL(fileURLWithPath: #filePath)
|
||||
let packageRoot = testFile
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
let mainPath = packageRoot
|
||||
.appendingPathComponent("Sources")
|
||||
.appendingPathComponent("OrcaComputerUseMacOS")
|
||||
.appendingPathComponent("main.swift")
|
||||
return try String(contentsOf: mainPath, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
import OrcaComputerUseMacOSCore
|
||||
import XCTest
|
||||
|
||||
final class AgentSessionOwnershipTests: XCTestCase {
|
||||
func testUnclaimedDisconnectDoesNotTerminateAgent() {
|
||||
var ownership = AgentSessionOwnership()
|
||||
|
||||
XCTAssertFalse(ownership.disconnect(12))
|
||||
}
|
||||
|
||||
func testUnauthenticatedConnectionCannotClaimOrRetainAgent() {
|
||||
var ownership = AgentSessionOwnership()
|
||||
|
||||
XCTAssertEqual(ownership.registerConnection(12, authenticated: false), .rejected)
|
||||
XCTAssertFalse(ownership.disconnect(12))
|
||||
}
|
||||
|
||||
func testLastAuthenticatedDisconnectTerminatesAgent() {
|
||||
var ownership = AgentSessionOwnership()
|
||||
|
||||
XCTAssertEqual(ownership.registerConnection(12, authenticated: true), .claimed)
|
||||
XCTAssertTrue(ownership.disconnect(12))
|
||||
}
|
||||
|
||||
func testAgentWaitsForEveryAuthenticatedConnectionToClose() {
|
||||
var ownership = AgentSessionOwnership()
|
||||
|
||||
XCTAssertEqual(ownership.registerConnection(12, authenticated: true), .claimed)
|
||||
XCTAssertEqual(ownership.registerConnection(13, authenticated: true), .joined)
|
||||
XCTAssertFalse(ownership.disconnect(12))
|
||||
XCTAssertTrue(ownership.disconnect(13))
|
||||
}
|
||||
|
||||
func testDuplicateRegistrationDoesNotRetainAgent() {
|
||||
var ownership = AgentSessionOwnership()
|
||||
|
||||
XCTAssertEqual(ownership.registerConnection(12, authenticated: true), .claimed)
|
||||
XCTAssertEqual(ownership.registerConnection(12, authenticated: true), .joined)
|
||||
XCTAssertTrue(ownership.disconnect(12))
|
||||
}
|
||||
|
||||
func testReleasedSessionCannotBeReclaimed() {
|
||||
var ownership = AgentSessionOwnership()
|
||||
|
||||
XCTAssertEqual(ownership.registerConnection(12, authenticated: true), .claimed)
|
||||
XCTAssertTrue(ownership.disconnect(12))
|
||||
XCTAssertEqual(ownership.registerConnection(13, authenticated: true), .sessionReleased)
|
||||
XCTAssertFalse(ownership.disconnect(13))
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ Add-Type -AssemblyName System.Windows.Forms
|
||||
|
||||
Add-Type -TypeDefinition @"
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
public static class OrcaDesktopWin32 {
|
||||
@@ -33,6 +34,39 @@ public static class OrcaDesktopWin32 {
|
||||
public int Y;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct INPUT {
|
||||
public uint type;
|
||||
public INPUTUNION data;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Explicit)]
|
||||
public struct INPUTUNION {
|
||||
[FieldOffset(0)]
|
||||
public MOUSEINPUT mouse;
|
||||
[FieldOffset(0)]
|
||||
public KEYBDINPUT keyboard;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct MOUSEINPUT {
|
||||
public int dx;
|
||||
public int dy;
|
||||
public uint mouseData;
|
||||
public uint flags;
|
||||
public uint time;
|
||||
public UIntPtr extraInfo;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct KEYBDINPUT {
|
||||
public ushort virtualKey;
|
||||
public ushort scanCode;
|
||||
public uint flags;
|
||||
public uint time;
|
||||
public UIntPtr extraInfo;
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool GetWindowRect(IntPtr hwnd, out RECT rect);
|
||||
|
||||
@@ -56,6 +90,54 @@ public static class OrcaDesktopWin32 {
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern void mouse_event(uint dwFlags, uint dx, uint dy, int dwData, UIntPtr dwExtraInfo);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern uint SendInput(uint count, INPUT[] inputs, int size);
|
||||
|
||||
public static void SendModifiedClick(byte[] modifiers, uint mouseDown, uint mouseUp) {
|
||||
const uint keyboardInput = 1;
|
||||
const uint mouseInput = 0;
|
||||
const uint keyUp = 0x0002;
|
||||
var inputs = new List<INPUT>();
|
||||
foreach (var modifier in modifiers) {
|
||||
inputs.Add(KeyboardInput(keyboardInput, modifier, 0));
|
||||
}
|
||||
inputs.Add(MouseInput(mouseInput, mouseDown));
|
||||
inputs.Add(MouseInput(mouseInput, mouseUp));
|
||||
for (var index = modifiers.Length - 1; index >= 0; index--) {
|
||||
inputs.Add(KeyboardInput(keyboardInput, modifiers[index], keyUp));
|
||||
}
|
||||
var values = inputs.ToArray();
|
||||
var sent = SendInput((uint)values.Length, values, Marshal.SizeOf(typeof(INPUT)));
|
||||
if (sent != (uint)values.Length) {
|
||||
var releases = new List<INPUT>();
|
||||
releases.Add(MouseInput(mouseInput, mouseUp));
|
||||
for (var index = modifiers.Length - 1; index >= 0; index--) {
|
||||
releases.Add(KeyboardInput(keyboardInput, modifiers[index], keyUp));
|
||||
}
|
||||
var releaseValues = releases.ToArray();
|
||||
SendInput((uint)releaseValues.Length, releaseValues, Marshal.SizeOf(typeof(INPUT)));
|
||||
throw new InvalidOperationException("SendInput did not complete the modified click");
|
||||
}
|
||||
}
|
||||
|
||||
private static INPUT KeyboardInput(uint type, byte virtualKey, uint flags) {
|
||||
return new INPUT {
|
||||
type = type,
|
||||
data = new INPUTUNION {
|
||||
keyboard = new KEYBDINPUT { virtualKey = virtualKey, flags = flags }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static INPUT MouseInput(uint type, uint flags) {
|
||||
return new INPUT {
|
||||
type = type,
|
||||
data = new INPUTUNION {
|
||||
mouse = new MOUSEINPUT { flags = flags }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
"@
|
||||
|
||||
@@ -912,7 +994,7 @@ function Get-OrcaElementScreenPoint($Element) {
|
||||
$null
|
||||
}
|
||||
|
||||
function Send-OrcaMouseClick([IntPtr]$WindowHandle, [int]$ScreenX, [int]$ScreenY, [string]$Button, [int]$Count) {
|
||||
function Send-OrcaMouseClick([IntPtr]$WindowHandle, [int]$ScreenX, [int]$ScreenY, [string]$Button, [int]$Count, [string]$Modifiers) {
|
||||
[void][OrcaDesktopWin32]::SetForegroundWindow($WindowHandle)
|
||||
[void][OrcaDesktopWin32]::SetCursorPos($ScreenX, $ScreenY)
|
||||
$buttonName = if ([string]::IsNullOrWhiteSpace($Button)) { "left" } else { $Button.ToLowerInvariant() }
|
||||
@@ -923,10 +1005,23 @@ function Send-OrcaMouseClick([IntPtr]$WindowHandle, [int]$ScreenX, [int]$ScreenY
|
||||
default { throw "unsupported mouse button: $Button" }
|
||||
}
|
||||
|
||||
for ($i = 0; $i -lt (Get-OrcaPositiveInteger $Count "click_count"); $i++) {
|
||||
[OrcaDesktopWin32]::mouse_event($down, 0, 0, 0, [UIntPtr]::Zero)
|
||||
Start-Sleep -Milliseconds 35
|
||||
[OrcaDesktopWin32]::mouse_event($up, 0, 0, 0, [UIntPtr]::Zero)
|
||||
$modifierKeys = @(Get-OrcaClickModifierVirtualKeys $Modifiers)
|
||||
$clickCount = Get-OrcaPositiveInteger $Count "click_count"
|
||||
if ($modifierKeys.Count -eq 0) {
|
||||
for ($i = 0; $i -lt $clickCount; $i++) {
|
||||
[OrcaDesktopWin32]::mouse_event($down, 0, 0, 0, [UIntPtr]::Zero)
|
||||
Start-Sleep -Milliseconds 35
|
||||
[OrcaDesktopWin32]::mouse_event($up, 0, 0, 0, [UIntPtr]::Zero)
|
||||
}
|
||||
return
|
||||
}
|
||||
for ($i = 0; $i -lt $clickCount; $i++) {
|
||||
[OrcaDesktopWin32]::SendModifiedClick(
|
||||
[byte[]]$modifierKeys,
|
||||
[uint32]$down,
|
||||
[uint32]$up
|
||||
)
|
||||
if ($i + 1 -lt $clickCount) { Start-Sleep -Milliseconds 35 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -990,6 +1085,16 @@ function Get-OrcaModifierVirtualKey([string]$Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
function Get-OrcaClickModifierVirtualKeys([string]$Modifiers) {
|
||||
if ([string]::IsNullOrWhiteSpace($Modifiers)) { return @() }
|
||||
$parts = @($Modifiers.Split("+") | ForEach-Object { $_.Trim() })
|
||||
$emptyParts = @($parts | Where-Object { [string]::IsNullOrWhiteSpace($_) })
|
||||
if ($parts.Count -eq 0 -or $emptyParts.Count -gt 0) {
|
||||
throw "Click modifiers require modifier keys only"
|
||||
}
|
||||
@($parts | ForEach-Object { Get-OrcaModifierVirtualKey $_ })
|
||||
}
|
||||
|
||||
function Send-OrcaHotkey([IntPtr]$WindowHandle, [string]$KeySpec) {
|
||||
$parts = @($KeySpec.Split("+") | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
||||
if ($parts.Count -eq 0) { throw "Unsupported key: $KeySpec" }
|
||||
@@ -1106,13 +1211,14 @@ function Invoke-OrcaOperation($Operation) {
|
||||
Restore-OrcaWindow $process
|
||||
$handledByPattern = $false
|
||||
$clickCount = Get-OrcaPositiveInteger $Operation.click_count "click_count"
|
||||
if ($null -ne $element -and $Operation.mouse_button -ne "right" -and $Operation.mouse_button -ne "middle" -and $clickCount -le 1) {
|
||||
$hasModifiers = -not [string]::IsNullOrWhiteSpace([string]$Operation.modifiers)
|
||||
if (-not $hasModifiers -and $null -ne $element -and $Operation.mouse_button -ne "right" -and $Operation.mouse_button -ne "middle" -and $clickCount -le 1) {
|
||||
$handledByPattern = Invoke-OrcaPrimaryAction $element
|
||||
}
|
||||
if (-not $handledByPattern) {
|
||||
$point = Get-OrcaElementScreenPoint $element
|
||||
if ($null -eq $point) { $point = Get-OrcaScreenPoint $Operation $windowFrame }
|
||||
Send-OrcaMouseClick $handle $point.x $point.y $Operation.mouse_button $clickCount
|
||||
Send-OrcaMouseClick $handle $point.x $point.y $Operation.mouse_button $clickCount $Operation.modifiers
|
||||
$action = [pscustomobject]@{ path = "synthetic"; actionName = $null; fallbackReason = "actionUnsupported" }
|
||||
} else {
|
||||
$action = [pscustomobject]@{ path = "accessibility"; actionName = "primaryAction"; fallbackReason = $null }
|
||||
|
||||
@@ -112,6 +112,7 @@
|
||||
"bench:startup": "pnpm run ensure:electron-runtime && node tools/benchmarks/startup-time-bench.mjs",
|
||||
"bench:daemon-coldstart": "pnpm run ensure:electron-runtime && node tools/benchmarks/daemon-coldstart-bench.mjs",
|
||||
"bench:main-thread-jank": "pnpm run ensure:electron-runtime && node tools/benchmarks/main-thread-jank-bench.mjs",
|
||||
"bench:worktree-deletion": "node tools/benchmarks/worktree-deletion-dev-bench.mjs",
|
||||
"bench:zustand-selector-fanout": "node config/scripts/zustand-selector-fanout-benchmark.mjs",
|
||||
"bench:multi-workspace-typing": "pnpm run ensure:electron-runtime && node config/scripts/run-multi-workspace-typing-bench.mjs",
|
||||
"bench:cold-park-reveal": "pnpm run ensure:electron-runtime && node tools/benchmarks/terminal-cold-park-reveal-bench.mjs",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -69,6 +69,7 @@ ORCA computer get-app-state --app <app> --json
|
||||
ORCA computer get-app-state --app <app> --restore-window --json
|
||||
ORCA computer click --app <app> --element-index <index> --json
|
||||
ORCA computer click --app <app> --x 100 --y 100 --json
|
||||
ORCA computer click --app <app> --x 100 --y 100 --modifiers CmdOrCtrl+Shift --json
|
||||
ORCA computer perform-secondary-action --app <app> --element-index <index> --action <name> --json
|
||||
ORCA computer set-value --app <app> --element-index <index> --value "text" --json
|
||||
ORCA computer type-text --app <app> --text "text" --json
|
||||
@@ -95,6 +96,7 @@ printf '%s' "$TEXT" | ORCA computer set-value --app <app> --element-index <index
|
||||
- After any UI-changing action, use the returned state or rerun `get-app-state` before choosing the next element index.
|
||||
- Use `type-text` only after focusing a field and confirming the app has a focused text receiver; synthetic keyboard delivery is reported as unverified, so inspect the returned state before assuming text landed.
|
||||
- Use `press-key` for single/navigation keys such as Return, Escape, Tab, and arrows. Use `hotkey` only for one modifier chord plus one key, such as `CmdOrCtrl+A` or `CmdOrCtrl+Shift+P`; prefer `CmdOrCtrl+...` for cross-platform combos.
|
||||
- Use `click --modifiers <chord>` for modifier-clicks. Never synthesize separate modifier-down and modifier-up commands around a click; interruption can leave a modifier logically held.
|
||||
- Some actions work in background apps, but this is app-dependent. If success does not change the UI, refresh state and choose a more semantic action or restore/focus the window.
|
||||
- Prefer `set-value` for text fields that expose values; it can report verified value writes when the provider can read the refreshed value.
|
||||
- Coordinates are window-local; use coordinates from the latest screenshot/state for the same target window.
|
||||
|
||||
@@ -143,12 +143,12 @@ ORCA worktree create --name task --setup skip --json
|
||||
ORCA worktree create --name task --run-hooks --json
|
||||
```
|
||||
|
||||
- `--agent <id>` launches that agent **in the first terminal** (Orca docs: *"`--agent` launches the selected agent in the first terminal"*); `--prompt <text>` sends initial work to it. Known ids include `claude`, `codex`, `omp`, `pi`, `grok`, and other installed TUI agents.
|
||||
- `--agent <id>` launches that agent **in the first terminal** (Orca docs: _"`--agent` launches the selected agent in the first terminal"_); `--prompt <text>` sends initial work to it. Known ids include `claude`, `codex`, `omp`, `pi`, `grok`, and other installed TUI agents.
|
||||
- **Prefer agent-first create for agent workers.** `orca worktree create --agent <id> --prompt "..."` puts the agent in the worktree's first terminal without adding a separate fallback shell for that worker. Repo setup or default-terminal settings may still add tabs or splits. Without configured default tabs, the bare-create fallback shell plus a later `terminal create --command <agent>` is an anti-pattern for ordinary agent worktrees — use `--agent` instead of “create worktree, then open agent.” Configured default tabs are intentional surfaces; never treat one as disposable without verifying that it is an unused shell.
|
||||
- After create, use exactly one agent handle: `startupTerminal.handle` from the create response when present, or the matching result from `orca terminal list --worktree id:<repoId>::<newWorktreePath> --json` (or `name:<displayName>`) when the response omits it. If a handle later returns `terminal_handle_stale`, re-list it; never dual-send to old and replacement handles.
|
||||
- `--setup run|skip|inherit` controls repo setup hooks. Default is `inherit`, which follows the repo's setup policy.
|
||||
- `--run-hooks` is a legacy alias for `--setup run`; it also reveals/activates the new worktree.
|
||||
- `--agent`, `--activate`, and `--run-hooks` reveal the new worktree. Plain create stays in the background.
|
||||
- `--activate` and `--run-hooks` reveal the new worktree. `--agent` alone stays in the background.
|
||||
- Let Orca choose setup terminal placement from repo settings, including tab vs split behavior. Do not manually create extra setup terminals when `--agent` already owns the first tab.
|
||||
- If an older installed CLI rejects `--agent`, `--prompt`, or `--setup`, create the worktree normally, then run `orca terminal create --worktree <selector> --command "<requested-agent>"` and `orca terminal send` if a prompt is needed. This can leave a fallback shell when no default tabs are configured; close it only after confirming it is unused.
|
||||
- `worktree create` creates a new checkout. For a fresh agent in the **current** checkout (no new worktree), use `orca terminal create --worktree active --command "codex" --json` — that path does not create a second worktree shell.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
computerUseClickModifiersValidationMessage,
|
||||
computerUseHotkeyValidationMessage,
|
||||
computerUsePressKeyValidationMessage
|
||||
} from '../../shared/computer-use-key-spec'
|
||||
@@ -60,16 +61,26 @@ export function getComputerClickActionFlags(flags: Map<string, string | boolean>
|
||||
y?: number
|
||||
clickCount?: number
|
||||
mouseButton?: string
|
||||
modifiers?: string
|
||||
} {
|
||||
const rawModifiers = flags.get('modifiers')
|
||||
const modifiers = typeof rawModifiers === 'string' ? rawModifiers : undefined
|
||||
const result = {
|
||||
elementIndex: getOptionalNonNegativeIntegerFlag(flags, 'element-index'),
|
||||
x: getOptionalNumberFlag(flags, 'x'),
|
||||
y: getOptionalNumberFlag(flags, 'y'),
|
||||
clickCount: getOptionalPositiveIntegerFlag(flags, 'click-count'),
|
||||
mouseButton: getOptionalStringFlag(flags, 'mouse-button')
|
||||
mouseButton: getOptionalStringFlag(flags, 'mouse-button'),
|
||||
modifiers
|
||||
}
|
||||
validateElementOrCoordinates('Click', result.elementIndex, result.x, result.y)
|
||||
validateMouseButton(result.mouseButton)
|
||||
if (modifiers !== undefined) {
|
||||
const message = computerUseClickModifiersValidationMessage(modifiers)
|
||||
if (message) {
|
||||
throw new RuntimeClientError('invalid_argument', message)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,8 @@ describe('orca computer action CLI routing', () => {
|
||||
'Finder',
|
||||
'--element-index',
|
||||
'3',
|
||||
'--modifiers',
|
||||
'CmdOrCtrl+Shift',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo/src'
|
||||
@@ -73,6 +75,7 @@ describe('orca computer action CLI routing', () => {
|
||||
y: undefined,
|
||||
clickCount: undefined,
|
||||
mouseButton: undefined,
|
||||
modifiers: 'CmdOrCtrl+Shift',
|
||||
noScreenshot: undefined
|
||||
})
|
||||
})
|
||||
|
||||
@@ -289,6 +289,43 @@ describe('orca computer action CLI validation', () => {
|
||||
vi.mocked(console.error).mockClear()
|
||||
process.exitCode = undefined
|
||||
|
||||
await main(
|
||||
[
|
||||
'computer',
|
||||
'click',
|
||||
'--app',
|
||||
'Finder',
|
||||
'--element-index',
|
||||
'1',
|
||||
'--modifiers',
|
||||
'CmdOrCtrl+A'
|
||||
],
|
||||
'/tmp/repo/src'
|
||||
)
|
||||
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(console.error).mock.calls[0][0]).toContain(
|
||||
'Click modifiers accept modifier keys only'
|
||||
)
|
||||
expect(process.exitCode).toBe(1)
|
||||
|
||||
vi.mocked(console.error).mockClear()
|
||||
process.exitCode = undefined
|
||||
|
||||
await main(
|
||||
['computer', 'click', '--app', 'Finder', '--element-index', '1', '--modifiers', ''],
|
||||
'/tmp/repo/src'
|
||||
)
|
||||
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(console.error).mock.calls[0][0]).toContain(
|
||||
'Click modifiers accept modifier keys only'
|
||||
)
|
||||
expect(process.exitCode).toBe(1)
|
||||
|
||||
vi.mocked(console.error).mockClear()
|
||||
process.exitCode = undefined
|
||||
|
||||
await main(
|
||||
['computer', 'scroll', '--app', 'Finder', '--element-index', '1', '--direction', 'diagonal'],
|
||||
'/tmp/repo/src'
|
||||
|
||||
@@ -72,6 +72,14 @@ describe('orca computer observation CLI handlers', () => {
|
||||
expect(pressKeyOutput).toContain('Single key, e.g. Return, Escape, Tab, Left, or PageUp')
|
||||
})
|
||||
|
||||
it('documents atomic modified clicks', async () => {
|
||||
await main(['computer', 'click', '--help'], '/tmp/repo')
|
||||
|
||||
const output = vi.mocked(console.log).mock.calls[0][0]
|
||||
expect(output).toContain('--modifiers <chord>')
|
||||
expect(output).toContain('Modifier keys held only for this click')
|
||||
})
|
||||
|
||||
it('passes list-apps through without resolving a worktree', async () => {
|
||||
queueFixtures(callMock, okFixture('req_apps', { apps: [] }))
|
||||
|
||||
|
||||
@@ -237,8 +237,7 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
|
||||
...linearIssueLink,
|
||||
comment: getOptionalStringFlag(flags, 'comment'),
|
||||
runHooks: flags.get('run-hooks') === true,
|
||||
activate:
|
||||
flags.get('activate') === true || flags.get('run-hooks') === true || Boolean(startupAgent),
|
||||
activate: flags.get('activate') === true || flags.get('run-hooks') === true,
|
||||
...(setupDecision ? { setupDecision } : {}),
|
||||
parentWorktree: explicitParentWorktree,
|
||||
...(explicitParentWorkspace ? { parentWorkspace: explicitParentWorkspace } : {}),
|
||||
|
||||
@@ -523,6 +523,7 @@ export function formatFlagHelp(flag: string): string {
|
||||
limit: '--limit <n> Maximum number of rows to return',
|
||||
mode: '--mode <mode> Mode such as edit, diff, or both',
|
||||
'mouse-button': '--mouse-button <btn> Mouse button: left, right, or middle',
|
||||
modifiers: '--modifiers <chord> Modifier keys held only for this click',
|
||||
name: '--name <name> Name for the new worktree or automation',
|
||||
'no-parent': '--no-parent Force no parent lineage for unrelated work',
|
||||
'no-screenshot': '--no-screenshot Skip screenshot capture after the operation',
|
||||
|
||||
@@ -2826,7 +2826,7 @@ describe('orca cli worktree awareness', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('passes agent prompt and setup policy through worktree.create', async () => {
|
||||
it('starts an agent worktree in the background unless activation is explicit', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]),
|
||||
@@ -2864,7 +2864,7 @@ describe('orca cli worktree awareness', () => {
|
||||
linkedIssue: undefined,
|
||||
comment: undefined,
|
||||
runHooks: false,
|
||||
activate: true,
|
||||
activate: false,
|
||||
setupDecision: 'run',
|
||||
parentWorktree: undefined,
|
||||
cwdParentWorktree: 'id:repo-1::/tmp/repo',
|
||||
@@ -2876,7 +2876,7 @@ describe('orca cli worktree awareness', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('infers the repo from the current worktree on worktree.create', async () => {
|
||||
it('infers the repo and honors explicit activation on worktree.create', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]),
|
||||
@@ -2898,6 +2898,7 @@ describe('orca cli worktree awareness', () => {
|
||||
'codex',
|
||||
'--prompt',
|
||||
'hi',
|
||||
'--activate',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo/src'
|
||||
|
||||
@@ -44,16 +44,17 @@ export const COMPUTER_COMMAND_SPECS: CommandSpec[] = [
|
||||
},
|
||||
{
|
||||
path: ['computer', 'click'],
|
||||
summary: 'Click an app element or window coordinate',
|
||||
summary: 'Click an app element or window coordinate, optionally with modifiers',
|
||||
usage:
|
||||
'orca computer click --app <app> (--element-index <n> | --x <x> --y <y>) [--window-id <id> | --window-index <n>] [--click-count <n>] [--mouse-button <left|right|middle>] [--restore-window] [--no-screenshot] [--json]',
|
||||
'orca computer click --app <app> (--element-index <n> | --x <x> --y <y>) [--window-id <id> | --window-index <n>] [--click-count <n>] [--mouse-button <left|right|middle>] [--modifiers <modifier-chord>] [--restore-window] [--no-screenshot] [--json]',
|
||||
allowedFlags: [
|
||||
...COMPUTER_ACTION_FLAGS,
|
||||
'element-index',
|
||||
'x',
|
||||
'y',
|
||||
'click-count',
|
||||
'mouse-button'
|
||||
'mouse-button',
|
||||
'modifiers'
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const processMocks = vi.hoisted(() => ({
|
||||
spawn: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFileSync: vi.fn(),
|
||||
spawn: processMocks.spawn
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => 'C:\\orca-review-11407' }
|
||||
}))
|
||||
|
||||
vi.mock('../codex-cli/command', () => ({
|
||||
resolveClaudeCommand: () => 'claude.exe'
|
||||
}))
|
||||
|
||||
vi.mock('./keychain', () => ({
|
||||
deleteActiveClaudeKeychainCredentialsStrict: vi.fn(),
|
||||
deleteManagedClaudeKeychainCredentials: vi.fn(),
|
||||
readActiveClaudeKeychainCredentials: vi.fn(),
|
||||
readActiveClaudeKeychainCredentialsStrict: vi.fn(),
|
||||
readManagedClaudeKeychainCredentials: vi.fn(),
|
||||
writeActiveClaudeKeychainCredentials: vi.fn(),
|
||||
writeManagedClaudeKeychainCredentials: vi.fn()
|
||||
}))
|
||||
|
||||
type CommandConfig = {
|
||||
windowsPath: string
|
||||
linuxPath: string | null
|
||||
wslDistro: string | null
|
||||
}
|
||||
|
||||
type FakeChild = EventEmitter & {
|
||||
stdin: PassThrough
|
||||
stdout: PassThrough
|
||||
stderr: PassThrough
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
pid: number
|
||||
}
|
||||
|
||||
type CommandRunner = {
|
||||
runClaudeCommand(
|
||||
args: string[],
|
||||
config: CommandConfig,
|
||||
timeoutMs: number,
|
||||
options?: { keepStdinOpen?: boolean; signal?: AbortSignal }
|
||||
): Promise<string>
|
||||
}
|
||||
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
|
||||
}
|
||||
|
||||
function createChild(): FakeChild {
|
||||
const child = new EventEmitter() as FakeChild
|
||||
child.stdin = new PassThrough()
|
||||
child.stdout = new PassThrough()
|
||||
child.stderr = new PassThrough()
|
||||
child.kill = vi.fn()
|
||||
child.pid = 11407
|
||||
return child
|
||||
}
|
||||
|
||||
async function createRunner(): Promise<CommandRunner> {
|
||||
const { ClaudeAccountService } = await import('./service')
|
||||
return new ClaudeAccountService({} as never, {} as never, {} as never) as unknown as CommandRunner
|
||||
}
|
||||
|
||||
async function flushPromiseCallbacks(): Promise<void> {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
describe('native Windows Claude login completion oracle', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
processMocks.spawn.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
}
|
||||
})
|
||||
|
||||
it('settles exact native Windows auth login on exit exactly once and cleans resources', async () => {
|
||||
setPlatform('win32')
|
||||
const child = createChild()
|
||||
const destroyStdin = vi.spyOn(child.stdin, 'destroy')
|
||||
const destroyStdout = vi.spyOn(child.stdout, 'destroy')
|
||||
const destroyStderr = vi.spyOn(child.stderr, 'destroy')
|
||||
const abortController = new AbortController()
|
||||
const removeAbortListener = vi.spyOn(abortController.signal, 'removeEventListener')
|
||||
processMocks.spawn.mockReturnValue(child)
|
||||
const runner = await createRunner()
|
||||
let completions = 0
|
||||
const command = runner
|
||||
.runClaudeCommand(
|
||||
['auth', 'login', '--claudeai'],
|
||||
{ windowsPath: 'C:\\isolated-auth', linuxPath: null, wslDistro: null },
|
||||
1000,
|
||||
{ keepStdinOpen: true, signal: abortController.signal }
|
||||
)
|
||||
.then(() => {
|
||||
completions += 1
|
||||
})
|
||||
|
||||
child.emit('exit', 0)
|
||||
await flushPromiseCallbacks()
|
||||
|
||||
try {
|
||||
expect(completions).toBe(1)
|
||||
expect(child.listenerCount('error')).toBe(0)
|
||||
expect(child.listenerCount('exit')).toBe(0)
|
||||
expect(child.listenerCount('close')).toBe(0)
|
||||
expect(child.stdout.listenerCount('data')).toBe(0)
|
||||
expect(child.stderr.listenerCount('data')).toBe(0)
|
||||
expect(destroyStdin).toHaveBeenCalledTimes(1)
|
||||
expect(destroyStdout).toHaveBeenCalledTimes(1)
|
||||
expect(destroyStderr).toHaveBeenCalledTimes(1)
|
||||
expect(removeAbortListener).toHaveBeenCalledTimes(1)
|
||||
|
||||
child.emit('close', 0)
|
||||
await vi.advanceTimersByTimeAsync(1001)
|
||||
|
||||
expect(completions).toBe(1)
|
||||
expect(child.kill).not.toHaveBeenCalled()
|
||||
expect(destroyStdin).toHaveBeenCalledTimes(1)
|
||||
expect(destroyStdout).toHaveBeenCalledTimes(1)
|
||||
expect(destroyStderr).toHaveBeenCalledTimes(1)
|
||||
expect(removeAbortListener).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
child.emit('close', 0)
|
||||
await command
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'non-Windows auth login',
|
||||
platform: 'linux' as const,
|
||||
args: ['auth', 'login', '--claudeai'],
|
||||
config: { windowsPath: '/isolated-auth', linuxPath: null, wslDistro: null }
|
||||
},
|
||||
{
|
||||
name: 'unrelated native Windows command containing login',
|
||||
platform: 'win32' as const,
|
||||
args: ['config', 'login'],
|
||||
config: { windowsPath: 'C:\\isolated-auth', linuxPath: null, wslDistro: null }
|
||||
},
|
||||
{
|
||||
name: 'WSL auth login',
|
||||
platform: 'win32' as const,
|
||||
args: ['auth', 'login', '--claudeai'],
|
||||
config: {
|
||||
windowsPath: 'C:\\isolated-auth',
|
||||
linuxPath: '/home/orca/isolated-auth',
|
||||
wslDistro: 'Ubuntu-Oracle'
|
||||
}
|
||||
}
|
||||
])('preserves close completion for $name', async ({ platform, args, config }) => {
|
||||
setPlatform(platform)
|
||||
const child = createChild()
|
||||
processMocks.spawn.mockReturnValue(child)
|
||||
const runner = await createRunner()
|
||||
let completions = 0
|
||||
const command = runner.runClaudeCommand(args, config, 1000).then(() => {
|
||||
completions += 1
|
||||
})
|
||||
|
||||
child.emit('exit', 0)
|
||||
await flushPromiseCallbacks()
|
||||
|
||||
expect(completions).toBe(0)
|
||||
expect(child.listenerCount('close')).toBe(1)
|
||||
|
||||
child.emit('close', 0)
|
||||
await command
|
||||
|
||||
expect(completions).toBe(1)
|
||||
expect(child.listenerCount('close')).toBe(0)
|
||||
expect(child.kill).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -967,6 +967,13 @@ export class ClaudeAccountService {
|
||||
rejectPromise(new Error('Claude command failed to open output streams.'))
|
||||
return
|
||||
}
|
||||
const completesOnExit =
|
||||
process.platform === 'win32' &&
|
||||
configDir.linuxPath === null &&
|
||||
configDir.wslDistro === null &&
|
||||
args[0] === 'auth' &&
|
||||
args[1] === 'login'
|
||||
const completionEvent = completesOnExit ? 'exit' : 'close'
|
||||
|
||||
let settled = false
|
||||
let output = ''
|
||||
@@ -990,11 +997,15 @@ export class ClaudeAccountService {
|
||||
stdout.off('data', appendOutput)
|
||||
stderr.off('data', appendOutput)
|
||||
child.off('error', onError)
|
||||
child.off('close', onClose)
|
||||
child.off(completionEvent, onDone)
|
||||
options?.signal?.removeEventListener('abort', onAbort)
|
||||
if (options?.keepStdinOpen) {
|
||||
child.stdin?.destroy()
|
||||
}
|
||||
if (completesOnExit) {
|
||||
stdout.destroy()
|
||||
stderr.destroy()
|
||||
}
|
||||
}
|
||||
const settle = (callback: () => void): void => {
|
||||
if (settled) {
|
||||
@@ -1062,7 +1073,7 @@ export class ClaudeAccountService {
|
||||
}
|
||||
settle(() => rejectPromise(error))
|
||||
}
|
||||
const onClose = (code: number | null): void => {
|
||||
const onDone = (code: number | null): void => {
|
||||
if (terminationPending) {
|
||||
return
|
||||
}
|
||||
@@ -1085,7 +1096,9 @@ export class ClaudeAccountService {
|
||||
stdout.on('data', appendOutput)
|
||||
stderr.on('data', appendOutput)
|
||||
child.on('error', onError)
|
||||
child.on('close', onClose)
|
||||
// Native Windows browsers can inherit these pipes and indefinitely delay close.
|
||||
child.on(completionEvent, onDone)
|
||||
|
||||
if (options?.signal?.aborted) {
|
||||
onAbort()
|
||||
} else {
|
||||
|
||||
@@ -1,17 +1,63 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ClaudeUsagePersistedState } from './types'
|
||||
import type * as Scanner from './scanner'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: vi.fn(() => '/tmp/orca-test-userdata')
|
||||
const { getPathMock, writeOpens, writeGate } = vi.hoisted(() => ({
|
||||
getPathMock: vi.fn(() => '/tmp/orca-test-userdata'),
|
||||
// Why only mode 'w': the durable write also opens the directory read-only to fsync it, so counting
|
||||
// every open would hide a regression back to multiple full-cache rewrites per scan.
|
||||
writeOpens: { value: 0, inFlight: 0, maxConcurrent: 0 },
|
||||
writeGate: {
|
||||
blocked: false,
|
||||
waiters: [] as (() => void)[]
|
||||
}
|
||||
}))
|
||||
|
||||
import { ClaudeUsageStore } from './store'
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: getPathMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async () => {
|
||||
const actual = await vi.importActual<typeof FsPromises>('node:fs/promises')
|
||||
return {
|
||||
...actual,
|
||||
open: (async (...args: Parameters<typeof actual.open>) => {
|
||||
if (args[1] !== 'w') {
|
||||
return actual.open(...args)
|
||||
}
|
||||
writeOpens.value += 1
|
||||
writeOpens.inFlight += 1
|
||||
writeOpens.maxConcurrent = Math.max(writeOpens.maxConcurrent, writeOpens.inFlight)
|
||||
try {
|
||||
if (writeGate.blocked) {
|
||||
await new Promise<void>((resolve) => writeGate.waiters.push(resolve))
|
||||
}
|
||||
return await actual.open(...args)
|
||||
} finally {
|
||||
writeOpens.inFlight -= 1
|
||||
}
|
||||
}) as typeof actual.open
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./scanner', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof Scanner>()),
|
||||
scanClaudeUsageFiles: vi.fn()
|
||||
}))
|
||||
|
||||
import { ClaudeUsageStore, initClaudeUsagePath } from './store'
|
||||
import { scanClaudeUsageFiles } from './scanner'
|
||||
|
||||
function createStoreWithState(state: Partial<ClaudeUsagePersistedState>): ClaudeUsageStore {
|
||||
const store = new ClaudeUsageStore({
|
||||
getRepos: () => [],
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getWorktreeMeta: () => undefined
|
||||
} as never)
|
||||
|
||||
@@ -34,11 +80,32 @@ function createStoreWithState(state: Partial<ClaudeUsagePersistedState>): Claude
|
||||
}
|
||||
|
||||
describe('ClaudeUsageStore', () => {
|
||||
let tempUserData: string
|
||||
|
||||
beforeEach(() => {
|
||||
tempUserData = mkdtempSync(join(tmpdir(), 'orca-claude-usage-store-'))
|
||||
getPathMock.mockReturnValue(tempUserData)
|
||||
initClaudeUsagePath()
|
||||
writeOpens.value = 0
|
||||
writeOpens.inFlight = 0
|
||||
writeOpens.maxConcurrent = 0
|
||||
writeGate.blocked = false
|
||||
writeGate.waiters = []
|
||||
vi.mocked(scanClaudeUsageFiles).mockReset()
|
||||
vi.mocked(scanClaudeUsageFiles).mockResolvedValue({
|
||||
processedFiles: [],
|
||||
sessions: [],
|
||||
dailyAggregates: []
|
||||
})
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-04-09T12:00:00.000-04:00'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
rmSync(tempUserData, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('reports no data for Orca scope when only non-Orca usage exists', async () => {
|
||||
const store = createStoreWithState({
|
||||
sessions: [
|
||||
@@ -549,4 +616,96 @@ describe('ClaudeUsageStore', () => {
|
||||
|
||||
expect(refreshMock).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('persists setEnabled via async durable write without leaving tmp files', async () => {
|
||||
const store = createStoreWithState({
|
||||
schemaVersion: 5,
|
||||
scanState: {
|
||||
enabled: false,
|
||||
lastScanStartedAt: null,
|
||||
lastScanCompletedAt: null,
|
||||
lastScanError: null
|
||||
}
|
||||
})
|
||||
|
||||
await store.setEnabled(true)
|
||||
|
||||
expect(writeOpens.value).toBe(1)
|
||||
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
|
||||
const persisted = JSON.parse(
|
||||
readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')
|
||||
)
|
||||
expect(persisted.scanState.enabled).toBe(true)
|
||||
// Pretty-print preserved for human inspection of the analytics cache.
|
||||
expect(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).toContain('\n')
|
||||
})
|
||||
|
||||
it('vetoes a stale concurrent async write so the newer snapshot wins', async () => {
|
||||
const store = createStoreWithState({
|
||||
schemaVersion: 5,
|
||||
scanState: {
|
||||
enabled: true,
|
||||
lastScanStartedAt: null,
|
||||
lastScanCompletedAt: null,
|
||||
lastScanError: null
|
||||
}
|
||||
})
|
||||
const internals = store as unknown as {
|
||||
writeToDisk: () => Promise<void>
|
||||
state: ClaudeUsagePersistedState
|
||||
}
|
||||
|
||||
writeGate.blocked = true
|
||||
const first = internals.writeToDisk()
|
||||
await vi.waitFor(() => expect(writeGate.waiters.length).toBe(1))
|
||||
|
||||
internals.state.scanState.enabled = false
|
||||
writeGate.blocked = false
|
||||
const second = internals.writeToDisk()
|
||||
writeGate.waiters.splice(0).forEach((resolve) => resolve())
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(
|
||||
JSON.parse(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).scanState
|
||||
.enabled
|
||||
).toBe(false)
|
||||
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
|
||||
// Serialized, so the superseded write can be skipped safely rather than racing the newer one.
|
||||
expect(writeOpens.maxConcurrent).toBe(1)
|
||||
})
|
||||
|
||||
it('persists a successful refresh with one full-cache write', async () => {
|
||||
const store = createStoreWithState({
|
||||
schemaVersion: 5,
|
||||
scanState: {
|
||||
enabled: true,
|
||||
lastScanStartedAt: null,
|
||||
lastScanCompletedAt: null,
|
||||
lastScanError: null
|
||||
}
|
||||
})
|
||||
|
||||
await store.refresh(true)
|
||||
|
||||
// Why exactly one: scan start used to rewrite the whole 20 MB cache before any result changed.
|
||||
expect(writeOpens.value).toBe(1)
|
||||
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
|
||||
expect(
|
||||
JSON.parse(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).scanState
|
||||
).toMatchObject({
|
||||
lastScanStartedAt: new Date('2026-04-09T12:00:00.000-04:00').getTime(),
|
||||
lastScanCompletedAt: new Date('2026-04-09T12:00:00.000-04:00').getTime(),
|
||||
lastScanError: null
|
||||
})
|
||||
})
|
||||
|
||||
it('sweeps a usage temp file orphaned by a crash between write and rename', async () => {
|
||||
const orphan = join(tempUserData, 'orca-claude-usage.json.999.1.abc.tmp')
|
||||
writeFileSync(orphan, '{}')
|
||||
|
||||
createStoreWithState({})
|
||||
await vi.waitFor(() =>
|
||||
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* eslint-disable max-lines -- Why: this store is the single main-process owner for Claude usage persistence, scan gating, and query semantics. Keeping those policy decisions together avoids split-brain range/scope logic across multiple files. */
|
||||
import { app } from 'electron'
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer'
|
||||
import type {
|
||||
ClaudeUsageBreakdownKind,
|
||||
ClaudeUsageBreakdownRow,
|
||||
@@ -331,6 +332,9 @@ export class ClaudeUsageStore {
|
||||
private state: ClaudeUsagePersistedState
|
||||
private readonly store: Store
|
||||
private scanPromise: Promise<void> | null = null
|
||||
// Why: the 20 MB usage JSON must not block the Electron main thread; the writer serializes writes
|
||||
// and vetoes superseded renames.
|
||||
private readonly writer = new UsageCacheSnapshotWriter('[claude-usage]', getClaudeUsageFile)
|
||||
|
||||
constructor(store: Store) {
|
||||
this.store = store
|
||||
@@ -375,23 +379,19 @@ export class ClaudeUsageStore {
|
||||
}
|
||||
}
|
||||
|
||||
private writeToDisk(): void {
|
||||
const usageFile = getClaudeUsageFile()
|
||||
const dir = dirname(usageFile)
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
// Why: scans can refresh while the app is in active use. Use the same
|
||||
// atomic temp-file pattern as the main store so a crash or concurrent write
|
||||
// cannot leave a truncated analytics file as the common failure mode.
|
||||
const tmpFile = `${usageFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
|
||||
writeFileSync(tmpFile, JSON.stringify(this.state, null, 2), 'utf-8')
|
||||
renameSync(tmpFile, usageFile)
|
||||
private writeToDisk(): Promise<void> {
|
||||
// Pretty-print preserved: humans inspect this analytics cache on disk.
|
||||
return this.writer.write(() => JSON.stringify(this.state, null, 2))
|
||||
}
|
||||
|
||||
/** Await queued cache writes so quit does not drop the final snapshot. */
|
||||
flush(): Promise<void> {
|
||||
return this.writer.flush()
|
||||
}
|
||||
|
||||
async setEnabled(enabled: boolean): Promise<ClaudeUsageScanState> {
|
||||
this.state.scanState.enabled = enabled
|
||||
this.writeToDisk()
|
||||
await this.writeToDisk()
|
||||
return this.getScanState()
|
||||
}
|
||||
|
||||
@@ -441,8 +441,11 @@ export class ClaudeUsageStore {
|
||||
|
||||
this.state.scanState.lastScanStartedAt = Date.now()
|
||||
this.state.scanState.lastScanError = null
|
||||
this.writeToDisk()
|
||||
|
||||
// Why no write here: persisting scan-start would rewrite the whole multi-MB cache before a single
|
||||
// result changed. The completion/failure write below persists the same fields.
|
||||
|
||||
// Why: assign scanPromise before any await so concurrent refresh shares one scan.
|
||||
this.scanPromise = (async () => {
|
||||
try {
|
||||
const repos = this.store.getRepos()
|
||||
@@ -458,10 +461,12 @@ export class ClaudeUsageStore {
|
||||
this.state.worktreeFingerprint = worktreeFingerprint
|
||||
this.state.scanState.lastScanCompletedAt = Date.now()
|
||||
this.state.scanState.lastScanError = null
|
||||
this.writeToDisk()
|
||||
// Why swallow: persistence is a cache concern. A disk failure must not turn a good scan into
|
||||
// a scan error and reject refresh() for every query caller; writeToDisk already logs it.
|
||||
await this.writeToDisk().catch(() => {})
|
||||
} catch (error) {
|
||||
this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error)
|
||||
this.writeToDisk()
|
||||
await this.writeToDisk().catch(() => {})
|
||||
} finally {
|
||||
this.scanPromise = null
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/* eslint-disable max-lines */
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as Fs from 'node:fs'
|
||||
import type {
|
||||
CodexUsageDailyAggregate,
|
||||
CodexUsagePersistedFile,
|
||||
@@ -11,8 +11,15 @@ import type {
|
||||
CodexUsageSession
|
||||
} from './types'
|
||||
|
||||
const { getPathMock } = vi.hoisted(() => ({
|
||||
getPathMock: vi.fn(() => '/tmp/orca-test-userdata')
|
||||
const { getPathMock, writeOpens, writeGate } = vi.hoisted(() => ({
|
||||
getPathMock: vi.fn(() => '/tmp/orca-test-userdata'),
|
||||
// Why only mode 'w': the durable write also opens the directory read-only to fsync it, so counting
|
||||
// every open would hide a regression back to multiple full-cache rewrites per scan.
|
||||
writeOpens: { value: 0, inFlight: 0, maxConcurrent: 0 },
|
||||
writeGate: {
|
||||
blocked: false,
|
||||
waiters: [] as (() => void)[]
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
@@ -21,11 +28,26 @@ vi.mock('electron', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('fs', async () => {
|
||||
const actual = await vi.importActual<typeof Fs>('fs')
|
||||
vi.mock('node:fs/promises', async () => {
|
||||
const actual = await vi.importActual<typeof FsPromises>('node:fs/promises')
|
||||
return {
|
||||
...actual,
|
||||
writeFileSync: vi.fn(actual.writeFileSync)
|
||||
open: (async (...args: Parameters<typeof actual.open>) => {
|
||||
if (args[1] !== 'w') {
|
||||
return actual.open(...args)
|
||||
}
|
||||
writeOpens.value += 1
|
||||
writeOpens.inFlight += 1
|
||||
writeOpens.maxConcurrent = Math.max(writeOpens.maxConcurrent, writeOpens.inFlight)
|
||||
try {
|
||||
if (writeGate.blocked) {
|
||||
await new Promise<void>((resolve) => writeGate.waiters.push(resolve))
|
||||
}
|
||||
return await actual.open(...args)
|
||||
} finally {
|
||||
writeOpens.inFlight -= 1
|
||||
}
|
||||
}) as typeof actual.open
|
||||
}
|
||||
})
|
||||
|
||||
@@ -97,7 +119,11 @@ describe('CodexUsageStore', () => {
|
||||
tempUserData = mkdtempSync(join(tmpdir(), 'orca-codex-usage-store-'))
|
||||
getPathMock.mockReturnValue(tempUserData)
|
||||
initCodexUsagePath()
|
||||
vi.mocked(writeFileSync).mockClear()
|
||||
writeOpens.value = 0
|
||||
writeOpens.inFlight = 0
|
||||
writeOpens.maxConcurrent = 0
|
||||
writeGate.blocked = false
|
||||
writeGate.waiters = []
|
||||
vi.mocked(scanCodexUsageFiles).mockReset()
|
||||
vi.mocked(scanCodexUsageFiles).mockResolvedValue(createEmptyScanResult())
|
||||
vi.useFakeTimers()
|
||||
@@ -109,7 +135,7 @@ describe('CodexUsageStore', () => {
|
||||
rmSync(tempUserData, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('persists a successful refresh with one compact disk write', async () => {
|
||||
it('persists a successful refresh with one compact async disk write', async () => {
|
||||
const store = createStoreWithState({
|
||||
schemaVersion: 5,
|
||||
scanState: {
|
||||
@@ -122,7 +148,9 @@ describe('CodexUsageStore', () => {
|
||||
|
||||
await store.refresh(true)
|
||||
|
||||
expect(writeFileSync).toHaveBeenCalledTimes(1)
|
||||
// Why exactly one: a refresh that rewrites the whole 60 MB cache twice is the regression this guards.
|
||||
expect(writeOpens.value).toBe(1)
|
||||
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
|
||||
const persistedJson = readFileSync(join(tempUserData, 'orca-codex-usage.json'), 'utf-8')
|
||||
expect(persistedJson).toBe(JSON.stringify(JSON.parse(persistedJson)))
|
||||
expect(persistedJson).not.toContain('\n')
|
||||
@@ -155,13 +183,57 @@ describe('CodexUsageStore', () => {
|
||||
lastScanStartedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(),
|
||||
lastScanError: null
|
||||
})
|
||||
expect(writeFileSync).not.toHaveBeenCalled()
|
||||
expect(writeOpens.value).toBe(0)
|
||||
|
||||
pendingScan.resolve(createEmptyScanResult())
|
||||
await refreshPromise
|
||||
|
||||
expect(store.getScanState().isScanning).toBe(false)
|
||||
expect(writeFileSync).toHaveBeenCalledTimes(1)
|
||||
expect(writeOpens.value).toBe(1)
|
||||
})
|
||||
|
||||
it('vetoes a stale concurrent async write so the newer snapshot wins without leaking tmp files', async () => {
|
||||
const store = createStoreWithState({
|
||||
schemaVersion: 5,
|
||||
scanState: {
|
||||
enabled: true,
|
||||
lastScanStartedAt: null,
|
||||
lastScanCompletedAt: null,
|
||||
lastScanError: null
|
||||
}
|
||||
})
|
||||
const internals = store as unknown as {
|
||||
writeToDisk: () => Promise<void>
|
||||
state: CodexUsagePersistedState
|
||||
}
|
||||
|
||||
writeGate.blocked = true
|
||||
const first = internals.writeToDisk()
|
||||
await vi.waitFor(() => expect(writeGate.waiters.length).toBe(1))
|
||||
|
||||
internals.state.scanState.enabled = false
|
||||
writeGate.blocked = false
|
||||
const second = internals.writeToDisk()
|
||||
writeGate.waiters.splice(0).forEach((resolve) => resolve())
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(
|
||||
JSON.parse(readFileSync(join(tempUserData, 'orca-codex-usage.json'), 'utf-8')).scanState
|
||||
.enabled
|
||||
).toBe(false)
|
||||
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
|
||||
// Serialized, so the superseded write can be skipped safely rather than racing the newer one.
|
||||
expect(writeOpens.maxConcurrent).toBe(1)
|
||||
})
|
||||
|
||||
it('sweeps a usage temp file orphaned by a crash between write and rename', async () => {
|
||||
const orphan = join(tempUserData, 'orca-codex-usage.json.999.1.abc.tmp')
|
||||
writeFileSync(orphan, '{}')
|
||||
|
||||
createStoreWithState({})
|
||||
await vi.waitFor(() =>
|
||||
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
|
||||
)
|
||||
})
|
||||
|
||||
it('reports no data for Orca scope when only non-Orca Codex usage exists', async () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* eslint-disable max-lines -- Why: this store owns Codex analytics persistence, scan policy, and renderer query semantics. Keeping them together prevents the Codex range/scope rules from drifting away from the scanner’s event model. */
|
||||
import { app } from 'electron'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer'
|
||||
import type {
|
||||
CodexUsageBreakdownKind,
|
||||
CodexUsageBreakdownRow,
|
||||
@@ -366,6 +367,9 @@ export class CodexUsageStore {
|
||||
private state: CodexUsagePersistedState
|
||||
private readonly store: Store
|
||||
private scanPromise: Promise<void> | null = null
|
||||
// Why: the 60 MB usage JSON must not block the Electron main thread; the writer serializes writes
|
||||
// and vetoes superseded renames.
|
||||
private readonly writer = new UsageCacheSnapshotWriter('[codex-usage]', getCodexUsageFile)
|
||||
|
||||
constructor(store: Store) {
|
||||
this.store = store
|
||||
@@ -393,20 +397,19 @@ export class CodexUsageStore {
|
||||
}
|
||||
}
|
||||
|
||||
private writeToDisk(): void {
|
||||
const usageFile = getCodexUsageFile()
|
||||
const dir = dirname(usageFile)
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
const tmpFile = `${usageFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
|
||||
writeFileSync(tmpFile, JSON.stringify(this.state), 'utf-8')
|
||||
renameSync(tmpFile, usageFile)
|
||||
private writeToDisk(): Promise<void> {
|
||||
// Compact: this cache reaches 60 MB, and pretty-printing it costs main-thread time per scan.
|
||||
return this.writer.write(() => JSON.stringify(this.state))
|
||||
}
|
||||
|
||||
/** Await queued cache writes so quit does not drop the final snapshot. */
|
||||
flush(): Promise<void> {
|
||||
return this.writer.flush()
|
||||
}
|
||||
|
||||
async setEnabled(enabled: boolean): Promise<CodexUsageScanState> {
|
||||
this.state.scanState.enabled = enabled
|
||||
this.writeToDisk()
|
||||
await this.writeToDisk()
|
||||
return this.getScanState()
|
||||
}
|
||||
|
||||
@@ -456,7 +459,8 @@ export class CodexUsageStore {
|
||||
|
||||
this.state.scanState.lastScanStartedAt = Date.now()
|
||||
this.state.scanState.lastScanError = null
|
||||
// Why: start-only writes rewrite the full usage cache before scan results change.
|
||||
// Why no write here: persisting scan-start would rewrite the whole multi-MB cache before a single
|
||||
// result changed. The completion/failure write below persists the same fields.
|
||||
|
||||
this.scanPromise = (async () => {
|
||||
try {
|
||||
@@ -473,10 +477,12 @@ export class CodexUsageStore {
|
||||
this.state.worktreeFingerprint = worktreeFingerprint
|
||||
this.state.scanState.lastScanCompletedAt = Date.now()
|
||||
this.state.scanState.lastScanError = null
|
||||
this.writeToDisk()
|
||||
// Why swallow: persistence is a cache concern. A disk failure must not turn a good scan into
|
||||
// a scan error and reject refresh() for every query caller; writeToDisk already logs it.
|
||||
await this.writeToDisk().catch(() => {})
|
||||
} catch (error) {
|
||||
this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error)
|
||||
this.writeToDisk()
|
||||
await this.writeToDisk().catch(() => {})
|
||||
} finally {
|
||||
this.scanPromise = null
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
computerUseClickModifiersValidationMessage,
|
||||
computerUseHotkeyValidationMessage,
|
||||
computerUsePressKeyValidationMessage
|
||||
} from '../../shared/computer-use-key-spec'
|
||||
@@ -27,6 +28,7 @@ export async function validateComputerProviderActionParams(
|
||||
validateElementOrCoordinates('Click', params)
|
||||
validatePositiveInteger(params, 'clickCount')
|
||||
validateMouseButton(params)
|
||||
validateClickModifiers(params)
|
||||
return app
|
||||
case 'performSecondaryAction':
|
||||
requireNonNegativeInteger(params, 'elementIndex')
|
||||
@@ -159,6 +161,17 @@ function validateMouseButton(params: Record<string, unknown>): void {
|
||||
}
|
||||
}
|
||||
|
||||
function validateClickModifiers(params: Record<string, unknown>): void {
|
||||
if (params.modifiers === undefined) {
|
||||
return
|
||||
}
|
||||
const modifiers = requireNonEmptyString(params, 'modifiers')
|
||||
const message = computerUseClickModifiersValidationMessage(modifiers)
|
||||
if (message) {
|
||||
throw new RuntimeClientError('invalid_argument', message)
|
||||
}
|
||||
}
|
||||
|
||||
function validateScrollDirection(params: Record<string, unknown>): void {
|
||||
const direction = requireNonEmptyString(params, 'direction')
|
||||
if (direction !== 'up' && direction !== 'down' && direction !== 'left' && direction !== 'right') {
|
||||
|
||||
@@ -186,10 +186,18 @@ describe('DesktopScriptProviderClient actions', () => {
|
||||
ok: true,
|
||||
capabilities: sampleCapabilities()
|
||||
})
|
||||
mockBridgeResponse({
|
||||
ok: true,
|
||||
snapshot: sampleBridgeSnapshot('Text Editor', 'initial')
|
||||
})
|
||||
mockBridgeResponse(
|
||||
{
|
||||
ok: true,
|
||||
snapshot: sampleBridgeSnapshot('Text Editor', 'initial')
|
||||
},
|
||||
(operation) => {
|
||||
expect(operation).toMatchObject({
|
||||
tool: 'click',
|
||||
modifiers: 'CmdOrCtrl+Shift'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const client = await createDesktopScriptProviderClient('linux', '/tmp/runtime.py')
|
||||
|
||||
@@ -197,6 +205,7 @@ describe('DesktopScriptProviderClient actions', () => {
|
||||
const result = await client.action('click', {
|
||||
app: 'Text Editor',
|
||||
elementIndex: 0,
|
||||
modifiers: 'CmdOrCtrl+Shift',
|
||||
noScreenshot: true
|
||||
})
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ export function mapBridgeError(message: string): RuntimeClientError {
|
||||
return new RuntimeClientError('app_blocked', text)
|
||||
}
|
||||
if (
|
||||
/unsupported capability|hotkey.*require|paste_text requires|GDK is required for non-character key synthesis/i.test(
|
||||
/unsupported capability|hotkey.*require|paste_text requires|modified clicks require xdotool|GDK is required for non-character key synthesis/i.test(
|
||||
text
|
||||
)
|
||||
) {
|
||||
|
||||
@@ -152,6 +152,7 @@ export class DesktopScriptProviderClient {
|
||||
to_y: optionalNumberParam(params, 'toY'),
|
||||
click_count: optionalNumberParam(params, 'clickCount'),
|
||||
mouse_button: optionalStringParam(params, 'mouseButton'),
|
||||
modifiers: optionalStringParam(params, 'modifiers'),
|
||||
action: optionalStringParam(params, 'action'),
|
||||
direction: optionalStringParam(params, 'direction'),
|
||||
pages: optionalNumberParam(params, 'pages'),
|
||||
|
||||
@@ -134,6 +134,7 @@ export type BridgeRequest = {
|
||||
to_y?: number
|
||||
click_count?: number
|
||||
mouse_button?: string
|
||||
modifiers?: string
|
||||
action?: string
|
||||
direction?: string
|
||||
pages?: number
|
||||
|
||||
@@ -278,6 +278,16 @@ describe('MacOSNativeProviderClient', () => {
|
||||
code: 'invalid_argument',
|
||||
message: expect.stringContaining('Click requires')
|
||||
})
|
||||
await expect(
|
||||
client.action('click', {
|
||||
app: 'TextEdit',
|
||||
elementIndex: 0,
|
||||
modifiers: 'CmdOrCtrl+A'
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'invalid_argument',
|
||||
message: expect.stringContaining('Click modifiers accept modifier keys only')
|
||||
})
|
||||
await expect(client.action('typeText', { app: 'TextEdit', text: '' })).rejects.toMatchObject({
|
||||
code: 'invalid_argument',
|
||||
message: expect.stringContaining('Missing text')
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join } from 'node:path'
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { HistoryManager } from './history-manager'
|
||||
import { flushPendingSessionTreeRemovals } from './terminal-history-session-tombstone'
|
||||
import type { TerminalSnapshot, TerminalModes } from './types'
|
||||
import { getHistorySessionDirName } from './history-paths'
|
||||
import {
|
||||
@@ -58,6 +60,8 @@ describe('HistoryManager', () => {
|
||||
|
||||
afterEach(async () => {
|
||||
await mgr.dispose()
|
||||
// Detached tombstone reclaims outlive the test that queued them; settle before the fixture goes.
|
||||
await flushPendingSessionTreeRemovals()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
@@ -486,4 +490,45 @@ describe('HistoryManager', () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('large session cleanup responsiveness', () => {
|
||||
it('tombstones a large session tree instead of walking it on the teardown path', async () => {
|
||||
const sessionId = 'bulky'
|
||||
await mgr.openSession(sessionId, { cwd: '/tmp', cols: 80, rows: 24 })
|
||||
const sessionDir = join(dir, getHistorySessionDirName(sessionId))
|
||||
// Enough entries that a recursive walk would dominate removeSession's duration.
|
||||
for (let i = 0; i < 3_000; i++) {
|
||||
writeFileSync(join(sessionDir, `chunk-${i}.log`), `payload-${i}`)
|
||||
}
|
||||
|
||||
// Why structural and not a turn count: worktree teardown awaits removeSession once per terminal,
|
||||
// so the contract is that the awaited half only renames. The tree surviving under .pending-delete
|
||||
// right after the await can only happen if the reclaim was detached.
|
||||
await mgr.removeSession(sessionId)
|
||||
|
||||
expect(existsSync(sessionDir)).toBe(false)
|
||||
const tombstones = readdirSync(join(dir, '.pending-delete'))
|
||||
expect(tombstones).toHaveLength(1)
|
||||
expect(readdirSync(join(dir, '.pending-delete', tombstones[0])).length).toBeGreaterThan(0)
|
||||
|
||||
await flushPendingSessionTreeRemovals()
|
||||
expect(readdirSync(join(dir, '.pending-delete'))).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('reclaims tombstones left by a quit mid-removal on the next construction', async () => {
|
||||
const sessionId = 'leftover'
|
||||
await mgr.openSession(sessionId, { cwd: '/tmp', cols: 80, rows: 24 })
|
||||
await mgr.removeSession(sessionId)
|
||||
await flushPendingSessionTreeRemovals()
|
||||
|
||||
const orphan = join(dir, '.pending-delete', 'orphaned-tombstone')
|
||||
mkdirSync(orphan, { recursive: true })
|
||||
writeFileSync(join(orphan, 'output.log'), 'stranded')
|
||||
|
||||
new HistoryManager(dir)
|
||||
await flushPendingSessionTreeRemovals()
|
||||
|
||||
expect(existsSync(orphan)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { join } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdirSync, writeFileSync, existsSync, rmSync, unlinkSync } from 'node:fs'
|
||||
import { mkdirSync, writeFileSync, existsSync, unlinkSync } from 'node:fs'
|
||||
import { getHistorySessionDirName } from './history-paths'
|
||||
import {
|
||||
fingerprintTerminalHistorySession,
|
||||
hasTerminalHistoryRecoveryProtection,
|
||||
quarantineTerminalHistorySession,
|
||||
removeTerminalHistoryQuarantines,
|
||||
type ActiveHistoryRecoveryFreeze,
|
||||
type HistoryRecoveryFreeze
|
||||
} from './terminal-history-recovery-quarantine'
|
||||
import {
|
||||
removeTerminalHistorySessionTrees,
|
||||
schedulePendingSessionTreeRemovals
|
||||
} from './terminal-history-session-tombstone'
|
||||
import { TerminalHistorySessionWriter } from './terminal-history-session-writer'
|
||||
import {
|
||||
readTerminalHistoryMetaFromDir,
|
||||
@@ -43,6 +46,8 @@ export class HistoryManager {
|
||||
) {
|
||||
this.onWriteError = opts?.onWriteError
|
||||
this.checkpointMaxBytes = opts?.checkpointMaxBytes ?? TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES
|
||||
// Why: a quit between tombstone and reclaim leaves the tree on disk; nothing else rescans the queue.
|
||||
schedulePendingSessionTreeRemovals(this.basePath)
|
||||
}
|
||||
|
||||
async openSession(sessionId: string, opts: OpenSessionOptions): Promise<void> {
|
||||
@@ -270,16 +275,11 @@ export class HistoryManager {
|
||||
async removeSession(sessionId: string): Promise<void> {
|
||||
this.writers.delete(sessionId)
|
||||
this.disabledSessions.delete(sessionId)
|
||||
const activeFreeze = this.recoveryFreezes.get(sessionId)
|
||||
if (activeFreeze) {
|
||||
this.recoveryFreezes.delete(sessionId)
|
||||
}
|
||||
this.recoveryFreezes.delete(sessionId)
|
||||
await this.mutations.wait(sessionId)
|
||||
rmSync(join(this.basePath, getHistorySessionDirName(sessionId)), {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
removeTerminalHistoryQuarantines(this.basePath, sessionId)
|
||||
// Why tombstoned: writer handles are closed by here, so the trees only have to become unreachable —
|
||||
// they reach hundreds of MB and every terminal a worktree delete tears down awaits this.
|
||||
await removeTerminalHistorySessionTrees(this.basePath, sessionId)
|
||||
}
|
||||
|
||||
isSessionDisabled(sessionId: string): boolean {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
hasTerminalHistoryRecoveryProtection,
|
||||
isTerminalHistoryQuarantineEntry
|
||||
} from './terminal-history-recovery-quarantine'
|
||||
import { isTerminalHistoryPendingDeleteEntry } from './terminal-history-session-tombstone'
|
||||
import {
|
||||
readTerminalHistoryMeta,
|
||||
type SessionMeta,
|
||||
@@ -195,7 +196,10 @@ export class HistoryReader {
|
||||
if (!entry.isDirectory()) {
|
||||
continue
|
||||
}
|
||||
if (isTerminalHistoryQuarantineEntry(entry.name)) {
|
||||
if (
|
||||
isTerminalHistoryQuarantineEntry(entry.name) ||
|
||||
isTerminalHistoryPendingDeleteEntry(entry.name)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
let sessionId: string
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
unlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
@@ -90,10 +89,3 @@ export function quarantineTerminalHistorySession(
|
||||
renameSync(sessionDir, quarantineDir)
|
||||
return quarantineDir
|
||||
}
|
||||
|
||||
export function removeTerminalHistoryQuarantines(basePath: string, sessionId: string): void {
|
||||
rmSync(getTerminalHistoryQuarantineOwnerDir(basePath, sessionId), {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { removeHostTreeMock } = vi.hoisted(() => ({
|
||||
removeHostTreeMock: vi.fn<(dir: string) => Promise<void>>()
|
||||
}))
|
||||
|
||||
vi.mock('../host-tree-removal', () => ({
|
||||
removeHostTree: removeHostTreeMock
|
||||
}))
|
||||
|
||||
import { getHistorySessionDirName } from './history-paths'
|
||||
import {
|
||||
cancelPendingSessionTreeRemovalRetries,
|
||||
removeTerminalHistorySessionTrees,
|
||||
SESSION_TREE_REMOVAL_RETRY_DELAYS_MS
|
||||
} from './terminal-history-session-tombstone'
|
||||
|
||||
/** Mirrors the worktree-level tombstone retry: a session tree whose rm fails must be re-queued
|
||||
* in-process instead of waiting for the next HistoryManager construction. */
|
||||
describe('tombstoned session tree removal retries', () => {
|
||||
let basePath: string
|
||||
|
||||
beforeEach(() => {
|
||||
basePath = mkdtempSync(join(tmpdir(), 'orca-session-tombstone-retry-'))
|
||||
removeHostTreeMock.mockReset()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cancelPendingSessionTreeRemovalRetries()
|
||||
vi.useRealTimers()
|
||||
rmSync(basePath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function seedSession(sessionId: string): void {
|
||||
const dir = join(basePath, getHistorySessionDirName(sessionId))
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(join(dir, 'segment-0.log'), 'x')
|
||||
}
|
||||
|
||||
it('re-queues a tombstone whose removal failed, then stops after the last attempt', async () => {
|
||||
seedSession('session-busy')
|
||||
removeHostTreeMock.mockRejectedValue(
|
||||
Object.assign(new Error('resource busy'), { code: 'EBUSY' })
|
||||
)
|
||||
|
||||
await removeTerminalHistorySessionTrees(basePath, 'session-busy')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(removeHostTreeMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
for (const [index, retryDelayMs] of SESSION_TREE_REMOVAL_RETRY_DELAYS_MS.entries()) {
|
||||
await vi.advanceTimersByTimeAsync(retryDelayMs)
|
||||
expect(removeHostTreeMock).toHaveBeenCalledTimes(index + 2)
|
||||
}
|
||||
|
||||
// Bounded: a permanently wedged tree waits for the next construction's drain.
|
||||
await vi.advanceTimersByTimeAsync(60 * 60_000)
|
||||
expect(removeHostTreeMock).toHaveBeenCalledTimes(
|
||||
SESSION_TREE_REMOVAL_RETRY_DELAYS_MS.length + 1
|
||||
)
|
||||
expect(removeHostTreeMock).toHaveBeenLastCalledWith(expect.stringContaining('.pending-delete'))
|
||||
})
|
||||
|
||||
it('does not re-arm a retry after the removal succeeds', async () => {
|
||||
seedSession('session-clean')
|
||||
removeHostTreeMock.mockResolvedValue(undefined)
|
||||
|
||||
await removeTerminalHistorySessionTrees(basePath, 'session-clean')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await vi.advanceTimersByTimeAsync(SESSION_TREE_REMOVAL_RETRY_DELAYS_MS[0])
|
||||
|
||||
expect(removeHostTreeMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
// Why: worktree teardown stops every terminal one by one, and each stop used to await a full recursive
|
||||
// delete of that session's history tree (hundreds of MB). Rename the tree into a tombstone queue instead
|
||||
// so the stop-and-wait path is metadata-only, and drain the queue off the critical path.
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { existsSync, mkdirSync, readdirSync, renameSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { removeHostTree } from '../host-tree-removal'
|
||||
import { getHistorySessionDirName } from './history-paths'
|
||||
import { getTerminalHistoryQuarantineOwnerDir } from './terminal-history-recovery-quarantine'
|
||||
|
||||
const PENDING_DELETE_DIR_NAME = '.pending-delete'
|
||||
|
||||
const pendingSessionTreeRemovals = new Map<string, Promise<void>>()
|
||||
// Why: a tombstone whose rm fails once (Windows EBUSY under AV) would otherwise sit on disk until the
|
||||
// next HistoryManager construction. Bounded so a genuinely wedged tree stops burning timers.
|
||||
export const SESSION_TREE_REMOVAL_RETRY_DELAYS_MS = [30_000, 120_000]
|
||||
const sessionTreeRemovalAttempts = new Map<string, number>()
|
||||
const sessionTreeRemovalRetryTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
export function isTerminalHistoryPendingDeleteEntry(name: string): boolean {
|
||||
return name === PENDING_DELETE_DIR_NAME
|
||||
}
|
||||
|
||||
function getPendingDeleteRoot(basePath: string): string {
|
||||
return join(basePath, PENDING_DELETE_DIR_NAME)
|
||||
}
|
||||
|
||||
function tombstoneSessionTree(basePath: string, dir: string): string | null {
|
||||
const pendingRoot = getPendingDeleteRoot(basePath)
|
||||
try {
|
||||
mkdirSync(pendingRoot, { recursive: true })
|
||||
const tombstone = join(pendingRoot, randomUUID())
|
||||
renameSync(dir, tombstone)
|
||||
return tombstone
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSessionTreeRemovalRetry(dir: string): void {
|
||||
const attempt = sessionTreeRemovalAttempts.get(dir) ?? 0
|
||||
const retryDelayMs = SESSION_TREE_REMOVAL_RETRY_DELAYS_MS[attempt]
|
||||
if (retryDelayMs === undefined) {
|
||||
// Out of in-process attempts: the tombstone stays queued for the next construction's drain.
|
||||
sessionTreeRemovalAttempts.delete(dir)
|
||||
return
|
||||
}
|
||||
sessionTreeRemovalAttempts.set(dir, attempt + 1)
|
||||
const timer = setTimeout(() => {
|
||||
sessionTreeRemovalRetryTimers.delete(dir)
|
||||
scheduleSessionTreeRemoval(dir)
|
||||
}, retryDelayMs)
|
||||
timer.unref?.()
|
||||
sessionTreeRemovalRetryTimers.set(dir, timer)
|
||||
}
|
||||
|
||||
function scheduleSessionTreeRemoval(dir: string): void {
|
||||
if (pendingSessionTreeRemovals.has(dir)) {
|
||||
return
|
||||
}
|
||||
// A rescan (startup drain) hitting the same tombstone supersedes its pending retry.
|
||||
const pendingRetry = sessionTreeRemovalRetryTimers.get(dir)
|
||||
if (pendingRetry) {
|
||||
clearTimeout(pendingRetry)
|
||||
sessionTreeRemovalRetryTimers.delete(dir)
|
||||
}
|
||||
const removal = removeHostTree(dir)
|
||||
.then(() => {
|
||||
sessionTreeRemovalAttempts.delete(dir)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.warn(
|
||||
`[history] Failed to delete tombstoned session tree: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
scheduleSessionTreeRemovalRetry(dir)
|
||||
})
|
||||
.finally(() => {
|
||||
if (pendingSessionTreeRemovals.get(dir) === removal) {
|
||||
pendingSessionTreeRemovals.delete(dir)
|
||||
}
|
||||
})
|
||||
pendingSessionTreeRemovals.set(dir, removal)
|
||||
}
|
||||
|
||||
/** Remove everything a session owns on disk — its history tree and any quarantined generations —
|
||||
* without holding the caller for the recursive walks. Both are unreachable once this resolves;
|
||||
* only the reclaim is detached. */
|
||||
export async function removeTerminalHistorySessionTrees(
|
||||
basePath: string,
|
||||
sessionId: string
|
||||
): Promise<void> {
|
||||
for (const dir of [
|
||||
join(basePath, getHistorySessionDirName(sessionId)),
|
||||
getTerminalHistoryQuarantineOwnerDir(basePath, sessionId)
|
||||
]) {
|
||||
await removeSessionOwnedTree(basePath, dir)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSessionOwnedTree(basePath: string, dir: string): Promise<void> {
|
||||
if (!existsSync(dir)) {
|
||||
return
|
||||
}
|
||||
const tombstone = tombstoneSessionTree(basePath, dir)
|
||||
if (tombstone) {
|
||||
scheduleSessionTreeRemoval(tombstone)
|
||||
return
|
||||
}
|
||||
// Rename blocked (open handles under Windows AV); remove in place so the tree is still gone on return.
|
||||
await removeHostTree(dir)
|
||||
}
|
||||
|
||||
/** Queue tombstones left by a crash or quit mid-removal. Cheap no-op when the queue never formed. */
|
||||
export function schedulePendingSessionTreeRemovals(basePath: string): void {
|
||||
const pendingRoot = getPendingDeleteRoot(basePath)
|
||||
try {
|
||||
for (const entry of readdirSync(pendingRoot)) {
|
||||
scheduleSessionTreeRemoval(join(pendingRoot, entry))
|
||||
}
|
||||
} catch {
|
||||
// Missing or unreadable queue is non-fatal; the next construction rescans.
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop every armed retry timer so a fixture teardown cannot resurrect a removal. Tests only. */
|
||||
export function cancelPendingSessionTreeRemovalRetries(): void {
|
||||
for (const timer of sessionTreeRemovalRetryTimers.values()) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
sessionTreeRemovalRetryTimers.clear()
|
||||
sessionTreeRemovalAttempts.clear()
|
||||
}
|
||||
|
||||
/** Await the in-flight tombstone removals. Tests only — production reclaims off the critical path and
|
||||
* re-queues whatever a quit interrupted on the next HistoryManager construction. */
|
||||
export async function flushPendingSessionTreeRemovals(): Promise<void> {
|
||||
while (pendingSessionTreeRemovals.size > 0) {
|
||||
await Promise.all(pendingSessionTreeRemovals.values())
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@
|
||||
// hour's loss; fsync stops it from happening.
|
||||
|
||||
import { closeSync, fsyncSync, openSync, renameSync, writeFileSync } from 'node:fs'
|
||||
import { open, rename } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
import { open, readdir, rename, rm } from 'node:fs/promises'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
|
||||
/**
|
||||
* fsync a directory so a rename within it is durable. Best-effort by design: Windows cannot open a
|
||||
@@ -57,16 +57,72 @@ export async function writeFileDurable(
|
||||
finalPath: string,
|
||||
payload: string
|
||||
): Promise<void> {
|
||||
const handle = await open(tmpPath, 'w')
|
||||
await writeFileDurableIfCurrent(tmpPath, finalPath, payload, () => true)
|
||||
}
|
||||
|
||||
/**
|
||||
* `writeFileDurable` with a commit veto: `isCurrent` is consulted after the fsync and before the
|
||||
* rename so a writer that was superseded mid-write doesn't publish a stale snapshot. Returns whether
|
||||
* the rename happened; the temp file is removed on every path that doesn't commit, so a multi-MB
|
||||
* payload can't orphan itself.
|
||||
*/
|
||||
export async function writeFileDurableIfCurrent(
|
||||
tmpPath: string,
|
||||
finalPath: string,
|
||||
payload: string,
|
||||
isCurrent: () => boolean
|
||||
): Promise<boolean> {
|
||||
let renamed = false
|
||||
try {
|
||||
await handle.writeFile(payload, 'utf-8')
|
||||
// Why: fsync BEFORE rename. A rename that lands first can expose a zero-length file.
|
||||
await handle.sync()
|
||||
const handle = await open(tmpPath, 'w')
|
||||
try {
|
||||
await handle.writeFile(payload, 'utf-8')
|
||||
// Why: fsync BEFORE rename. A rename that lands first can expose a zero-length file.
|
||||
await handle.sync()
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
if (!isCurrent()) {
|
||||
return false
|
||||
}
|
||||
await rename(tmpPath, finalPath)
|
||||
renamed = true
|
||||
await syncDirectory(dirname(finalPath))
|
||||
return true
|
||||
} finally {
|
||||
await handle.close()
|
||||
if (!renamed) {
|
||||
await rm(tmpPath, { force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Temp path for a durable write. Shared shape so `removeStaleDurableWriteTempFiles` can reclaim orphans. */
|
||||
export function durableWriteTempPath(finalPath: string): string {
|
||||
return `${finalPath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
|
||||
}
|
||||
|
||||
/**
|
||||
* Sweep temp files orphaned by a death between write and rename — for multi-MB payloads they would
|
||||
* otherwise accumulate forever. Racing another instance's in-flight save at worst loses that save,
|
||||
* the trade already accepted for rename-based atomicity. This process's own temps are skipped: a
|
||||
* `<file>.<our pid>.*.tmp` seen during a sweep is a live write, and deleting it fails its rename.
|
||||
*/
|
||||
export async function removeStaleDurableWriteTempFiles(finalPath: string): Promise<void> {
|
||||
const directory = dirname(finalPath)
|
||||
const prefix = `${basename(finalPath)}.`
|
||||
const ownPrefix = `${prefix}${process.pid}.`
|
||||
try {
|
||||
const names = await readdir(directory)
|
||||
await Promise.all(
|
||||
names
|
||||
.filter(
|
||||
(name) => name.startsWith(prefix) && name.endsWith('.tmp') && !name.startsWith(ownPrefix)
|
||||
)
|
||||
.map((name) => rm(join(directory, name), { force: true }).catch(() => {}))
|
||||
)
|
||||
} catch {
|
||||
// Directory missing or unreadable — nothing to sweep.
|
||||
}
|
||||
await rename(tmpPath, finalPath)
|
||||
await syncDirectory(dirname(finalPath))
|
||||
}
|
||||
|
||||
/** Synchronous counterpart for quit and crash paths that cannot await. */
|
||||
|
||||
@@ -30,6 +30,51 @@ export function normalizeGitUsername(value: string): string {
|
||||
return localPart.replace(/^\d+\+/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosted account logins used as branch-prefix segments must be single-path
|
||||
* tokens. Reject multi-line / JSON error bodies from a failed `gh api user`
|
||||
* (rate-limit 403 still prints JSON on stdout) so they never become branch names.
|
||||
*/
|
||||
export function isPlausibleHostedLogin(value: string): boolean {
|
||||
// GitHub usernames: 1–39 chars, alphanumerics and single hyphens, no leading/trailing hyphen.
|
||||
return (
|
||||
/^[A-Za-z0-9]$/.test(value) ||
|
||||
(/^[A-Za-z0-9][A-Za-z0-9-]{0,37}[A-Za-z0-9]$/.test(value) && !value.includes('--'))
|
||||
)
|
||||
}
|
||||
|
||||
// Not a check-ref-format rule: a login becomes one slash-free branch component,
|
||||
// which a loose ref stores as a single filename (255-byte cap on ext4/APFS/NTFS).
|
||||
// The ASCII-only charset below makes character count equal byte count.
|
||||
const MAX_BRANCH_SAFE_LOGIN_LENGTH = 255
|
||||
|
||||
/**
|
||||
* Provider-agnostic branch-safe token: GitLab/Bitbucket/self-hosted logins may
|
||||
* carry `_`/`.` and run longer than GitHub's 39-char limit, so the strict
|
||||
* GitHub rule must not gate explicitly configured usernames.
|
||||
*/
|
||||
export function isBranchSafeHostedLogin(value: string): boolean {
|
||||
if (value.length > MAX_BRANCH_SAFE_LOGIN_LENGTH) {
|
||||
return false
|
||||
}
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)) {
|
||||
return false
|
||||
}
|
||||
// Dot placements git check-ref-format rejects; `.lock` is case-sensitive there too.
|
||||
return !value.includes('..') && !value.endsWith('.') && !value.endsWith('.lock')
|
||||
}
|
||||
|
||||
function normalizeHostedLogin(value: string): string {
|
||||
const normalized = normalizeGitUsername(value)
|
||||
return normalized && isPlausibleHostedLogin(normalized) ? normalized : ''
|
||||
}
|
||||
|
||||
/** Explicit `github.user`/`user.username` config is provider-agnostic; only reject non-tokens. */
|
||||
function normalizeConfiguredLogin(value: string): string {
|
||||
const normalized = normalizeGitUsername(value)
|
||||
return normalized && isBranchSafeHostedLogin(normalized) ? normalized : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* A resolved username plus whether every probe on the way to it completed.
|
||||
* Non-authoritative '' (a probe timed out) must not overwrite a previously
|
||||
@@ -46,7 +91,7 @@ export async function getSshGitUsername(
|
||||
for (const key of EXPLICIT_USERNAME_CONFIG_KEYS) {
|
||||
try {
|
||||
const { stdout } = await provider.exec(['config', '--get', key], repoPath)
|
||||
const username = normalizeGitUsername(stdout)
|
||||
const username = normalizeConfiguredLogin(stdout)
|
||||
if (username) {
|
||||
return username
|
||||
}
|
||||
@@ -130,7 +175,7 @@ function parseGhAuthStatusLogin(output: string): string {
|
||||
|
||||
async function probeGhLoginOnce(): Promise<GhLoginOutcome> {
|
||||
const api = await runGhLoginProbe(['api', 'user', '-q', '.login'])
|
||||
const apiLogin = normalizeGitUsername(api.stdout.trim())
|
||||
const apiLogin = normalizeHostedLogin(api.stdout)
|
||||
if (apiLogin) {
|
||||
return { login: apiLogin, timedOut: false }
|
||||
}
|
||||
@@ -144,7 +189,7 @@ async function probeGhLoginOnce(): Promise<GhLoginOutcome> {
|
||||
return { login: '', timedOut: true }
|
||||
}
|
||||
const output = `${status.stdout}\n${status.stderr}`
|
||||
return { login: normalizeGitUsername(parseGhAuthStatusLogin(output)), timedOut: false }
|
||||
return { login: normalizeHostedLogin(parseGhAuthStatusLogin(output)), timedOut: false }
|
||||
}
|
||||
|
||||
async function getGhLoginOutcome(): Promise<GhLoginOutcome> {
|
||||
@@ -270,7 +315,8 @@ export async function resolveLocalGitUsernameDetailed(
|
||||
cwd: repoPath,
|
||||
timeout: LOCAL_GIT_READ_TIMEOUT_MS
|
||||
})
|
||||
const username = normalizeGitUsername(stdout)
|
||||
// Why: config can hold free-form strings; only branch-safe logins become prefixes.
|
||||
const username = normalizeConfiguredLogin(stdout)
|
||||
if (username) {
|
||||
return { username, authoritative: true }
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock('./runner', async () => {
|
||||
})
|
||||
|
||||
import {
|
||||
isBranchSafeHostedLogin,
|
||||
resolveLocalGitUsername,
|
||||
resolveLocalGitUsernameDetailed,
|
||||
resetGhLoginCacheForTests
|
||||
@@ -21,11 +22,43 @@ import {
|
||||
|
||||
function makeExecError(
|
||||
message: string,
|
||||
extra: { code?: string; killed?: boolean; signal?: string; stderr?: string } = {}
|
||||
extra: {
|
||||
code?: string
|
||||
killed?: boolean
|
||||
signal?: string
|
||||
stdout?: string
|
||||
stderr?: string
|
||||
} = {}
|
||||
): Error {
|
||||
return Object.assign(new Error(message), { stdout: '', stderr: '', ...extra })
|
||||
}
|
||||
|
||||
describe('isBranchSafeHostedLogin', () => {
|
||||
it.each(['demo', 'demo-user', 'demo_user', 'demo.user', 'a', 'FOO.LOCK'])(
|
||||
'accepts %s',
|
||||
(login) => {
|
||||
expect(isBranchSafeHostedLogin(login)).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['foo.', 'foo..bar', 'foo.lock', 'a.b.lock', '.foo', '-foo', 'foo bar'])(
|
||||
'rejects the git check-ref-format-invalid %s',
|
||||
(login) => {
|
||||
expect(isBranchSafeHostedLogin(login)).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
// Length is Orca's defensive bound, not a check-ref-format rule: a login is one
|
||||
// branch component, so a loose ref stores it as a single 255-byte-max filename.
|
||||
it('accepts long provider-agnostic logins up to the loose-ref filename cap', () => {
|
||||
expect(isBranchSafeHostedLogin('a'.repeat(255))).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects logins past the loose-ref filename cap', () => {
|
||||
expect(isBranchSafeHostedLogin('a'.repeat(256))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveLocalGitUsername', () => {
|
||||
let gitConfig: Record<string, string>
|
||||
let originRemoteUrl: string | undefined
|
||||
@@ -98,6 +131,15 @@ describe('resolveLocalGitUsername', () => {
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls through config values git rejects as branch components', async () => {
|
||||
originRemoteUrl = 'https://github.com/stablyai/orca.git'
|
||||
gitConfig['github.user'] = 'foo.lock'
|
||||
gitConfig['user.username'] = 'foo..bar'
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
|
||||
|
||||
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('gh-demo')
|
||||
})
|
||||
|
||||
it('uses GitHub CLI login for GitHub remotes instead of repo-local author identity', async () => {
|
||||
originRemoteUrl = 'https://github.com/stablyai/orca.git'
|
||||
gitConfig['user.email'] = 'demo@example.com'
|
||||
@@ -160,6 +202,19 @@ describe('resolveLocalGitUsername', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores rate-limit JSON bodies from gh api user so they never become branch prefixes', async () => {
|
||||
originRemoteUrl = 'https://github.com/stablyai/orca.git'
|
||||
const rateLimitJson = JSON.stringify({
|
||||
message: 'API rate limit exceeded for user ID 6427696',
|
||||
status: '403'
|
||||
})
|
||||
ghExecFileAsyncMock
|
||||
.mockRejectedValueOnce(makeExecError('gh api failed', { stdout: rateLimitJson }))
|
||||
.mockRejectedValueOnce(makeExecError('gh auth status failed'))
|
||||
|
||||
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('')
|
||||
})
|
||||
|
||||
it('skips auth status fallback when GitHub CLI API lookup times out', async () => {
|
||||
originRemoteUrl = 'https://github.com/stablyai/orca.git'
|
||||
ghExecFileAsyncMock.mockRejectedValueOnce(
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
refreshBranchCleanupTargetRefs
|
||||
} from '../../shared/git-branch-cleanup'
|
||||
import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref'
|
||||
import { withSpan } from '../observability/tracer'
|
||||
import type {
|
||||
GitWorktreeInfo,
|
||||
LocalBaseRefRefreshResult,
|
||||
@@ -1157,6 +1158,19 @@ async function performRemoveWorktree(
|
||||
return {}
|
||||
}
|
||||
|
||||
// Why its own span: branch cleanup can reach the network (`fetch --prune`), so a stall here reads as
|
||||
// `git worktree remove` being slow unless it is timed separately.
|
||||
return withSpan('worktree.remove.branch_delete', () =>
|
||||
deleteBranchAfterWorktreeRemoval(repoPath, branchName, branchHead, options)
|
||||
)
|
||||
}
|
||||
|
||||
async function deleteBranchAfterWorktreeRemoval(
|
||||
repoPath: string,
|
||||
branchName: string,
|
||||
branchHead: string,
|
||||
options: RemoveWorktreeOptions
|
||||
): Promise<RemoveWorktreeResult> {
|
||||
try {
|
||||
// Why: also drop the now-orphaned branch so delete-worktree leaves none; `-d` (not `-D`) preserves
|
||||
// unmerged work, and forceBranchDelete opts into `-D` for failed-creation rollback.
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createHangWatchdogDetectionLoop } from './hang-watchdog-detection-loop'
|
||||
import { createHangWatchdogChildLoop } from './hang-watchdog-child-loop'
|
||||
|
||||
const TIMEOUT_MS = 45_000
|
||||
const CHECK_INTERVAL_MS = 5_000
|
||||
@@ -8,7 +8,7 @@ function loopWithClock(startAt = 0) {
|
||||
let now = startAt
|
||||
const onHangDetected = vi.fn()
|
||||
const onHangResolved = vi.fn()
|
||||
const loop = createHangWatchdogDetectionLoop({
|
||||
const loop = createHangWatchdogChildLoop({
|
||||
timeoutMs: TIMEOUT_MS,
|
||||
checkIntervalMs: CHECK_INTERVAL_MS,
|
||||
now: () => now,
|
||||
@@ -18,7 +18,7 @@ function loopWithClock(startAt = 0) {
|
||||
return { loop, onHangDetected, onHangResolved, advance: (ms: number) => (now += ms) }
|
||||
}
|
||||
|
||||
describe('createHangWatchdogDetectionLoop', () => {
|
||||
describe('createHangWatchdogChildLoop', () => {
|
||||
it('does not fire while heartbeats keep arriving', () => {
|
||||
const { loop, onHangDetected, advance } = loopWithClock()
|
||||
for (let i = 0; i < 100; i++) {
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
export type HangWatchdogDetectionLoopConfig = {
|
||||
export type HangWatchdogChildLoopConfig = {
|
||||
timeoutMs: number
|
||||
checkIntervalMs: number
|
||||
now: () => number
|
||||
@@ -7,14 +7,14 @@ export type HangWatchdogDetectionLoopConfig = {
|
||||
onHangResolved: (unresponsiveMs: number) => void
|
||||
}
|
||||
|
||||
export type HangWatchdogDetectionLoop = {
|
||||
export type HangWatchdogChildLoop = {
|
||||
recordHeartbeat: () => void
|
||||
tick: () => void
|
||||
}
|
||||
|
||||
export function createHangWatchdogDetectionLoop(
|
||||
config: HangWatchdogDetectionLoopConfig
|
||||
): HangWatchdogDetectionLoop {
|
||||
export function createHangWatchdogChildLoop(
|
||||
config: HangWatchdogChildLoopConfig
|
||||
): HangWatchdogChildLoop {
|
||||
let lastHeartbeatAt = config.now()
|
||||
let lastTickAt = config.now()
|
||||
let detected = false
|
||||
@@ -0,0 +1,17 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export function resolveHangWatchdogEntryPath(
|
||||
appPath: string,
|
||||
isPackaged: boolean,
|
||||
pathExists: (candidate: string) => boolean = existsSync
|
||||
): string {
|
||||
// Why: ELECTRON_RUN_AS_NODE bypasses asar integration, so the packaged entry must be forked from app.asar.unpacked.
|
||||
const basePath = isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath
|
||||
const adjacentBuildEntry = join(basePath, 'main-thread-hang-watchdog-entry.js')
|
||||
// Why: electron-vite's unpackaged appPath is already out/main; appending out/main again would silently disable the watchdog in dev and E2E builds.
|
||||
if (!isPackaged && pathExists(adjacentBuildEntry)) {
|
||||
return adjacentBuildEntry
|
||||
}
|
||||
return join(basePath, 'out', 'main', 'main-thread-hang-watchdog-entry.js')
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolveHangWatchdogWorkerPath } from './hang-watchdog-worker-path'
|
||||
|
||||
describe('resolveHangWatchdogWorkerPath', () => {
|
||||
it('resolves packaged workers inside app.asar', () => {
|
||||
const appPath = join('/apps', 'orca', 'app.asar')
|
||||
expect(resolveHangWatchdogWorkerPath(appPath, true)).toBe(
|
||||
join(appPath, 'out', 'main', 'main-thread-hang-watchdog-entry.js')
|
||||
)
|
||||
})
|
||||
|
||||
it('uses an adjacent entry when the dev app path is out/main', () => {
|
||||
const appPath = join('/repo', 'out', 'main')
|
||||
const adjacent = join(appPath, 'main-thread-hang-watchdog-entry.js')
|
||||
const pathExists = vi.fn((candidate: string) => candidate === adjacent)
|
||||
expect(resolveHangWatchdogWorkerPath(appPath, false, pathExists)).toBe(adjacent)
|
||||
})
|
||||
|
||||
it('resolves through out/main from a dev project root', () => {
|
||||
const appPath = join('/repo', 'orca')
|
||||
expect(resolveHangWatchdogWorkerPath(appPath, false, () => false)).toBe(
|
||||
join(appPath, 'out', 'main', 'main-thread-hang-watchdog-entry.js')
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,14 +0,0 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export function resolveHangWatchdogWorkerPath(
|
||||
appPath: string,
|
||||
isPackaged: boolean,
|
||||
pathExists: (candidate: string) => boolean = existsSync
|
||||
): string {
|
||||
const adjacentBuildEntry = join(appPath, 'main-thread-hang-watchdog-entry.js')
|
||||
if (!isPackaged && pathExists(adjacentBuildEntry)) {
|
||||
return adjacentBuildEntry
|
||||
}
|
||||
return join(appPath, 'out', 'main', 'main-thread-hang-watchdog-entry.js')
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
export const HANG_WATCHDOG_HEARTBEAT_INTERVAL_MS = 2_000
|
||||
export const HANG_WATCHDOG_TIMEOUT_MS = 45_000
|
||||
export const HANG_WATCHDOG_CHECK_INTERVAL_MS = 5_000
|
||||
|
||||
export type HangWatchdogWorkerData = {
|
||||
parentPid: number
|
||||
markerPath: string
|
||||
timeoutMs: number
|
||||
checkIntervalMs: number
|
||||
}
|
||||
|
||||
export type MainToHangWatchdogWorkerMessage = { type: 'heartbeat' } | { type: 'shutdown' }
|
||||
@@ -67,6 +67,9 @@ describe('recordHangObservation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Why: this is the safety contract of the whole PR. Observing a hang must never kill, relaunch,
|
||||
// or exit — a false positive would SIGKILL a live main thread mid-write. If a future change
|
||||
// reintroduces recovery, this test must fail loudly rather than ship silently.
|
||||
it('never spawns, signals, or exits', () => {
|
||||
recordHangObservation({
|
||||
parentPid: 4242,
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { isMainThread, parentPort, workerData } from 'node:worker_threads'
|
||||
import { createHangWatchdogDetectionLoop } from './hang-watchdog-detection-loop'
|
||||
// Forked with ELECTRON_RUN_AS_NODE from the main process. Watches heartbeats from the main
|
||||
// thread; if they stop (e.g. the macOS 26 AppKit scene-update deadlock) it records a marker so
|
||||
// the next launch can report the stall, and rewrites it if the heartbeats come back.
|
||||
//
|
||||
// Why no kill: a deadlocked main thread has already lost whatever sat in the persistence debounce
|
||||
// window, so killing it recovers nothing the user could not recover by force-quitting. A false
|
||||
// positive, though, would SIGKILL a live main thread that was merely blocked — most plausibly on
|
||||
// I/O, i.e. exactly when writes are in flight. All of the downside sits in the misfire, so this
|
||||
// measures first: `selfRecovered` counts the stalls a killer would have gotten wrong.
|
||||
//
|
||||
// Must never import electron.
|
||||
import { createHangWatchdogChildLoop } from './hang-watchdog-child-loop'
|
||||
import { writeHangDetectionMarker } from './hang-detection-marker'
|
||||
import type {
|
||||
HangWatchdogWorkerData,
|
||||
MainToHangWatchdogWorkerMessage
|
||||
} from './hang-watchdog-worker-protocol'
|
||||
|
||||
// Observation only: a false positive must never kill a live main thread mid-write.
|
||||
const DEFAULT_TIMEOUT_MS = 45_000
|
||||
const DEFAULT_CHECK_INTERVAL_MS = 5_000
|
||||
|
||||
export function recordHangObservation(options: {
|
||||
parentPid: number
|
||||
markerPath: string
|
||||
@@ -28,63 +36,37 @@ export function recordHangObservation(options: {
|
||||
}
|
||||
}
|
||||
|
||||
function runWatchdog(config: HangWatchdogWorkerData): void {
|
||||
const port = parentPort
|
||||
if (!port) {
|
||||
return
|
||||
}
|
||||
const loop = createHangWatchdogDetectionLoop({
|
||||
timeoutMs: config.timeoutMs,
|
||||
checkIntervalMs: config.checkIntervalMs,
|
||||
function runWatchdog(parentPid: number): void {
|
||||
const markerPath = process.env.ORCA_HANG_WATCHDOG_MARKER_PATH ?? ''
|
||||
const timeoutMs = Number(process.env.ORCA_HANG_WATCHDOG_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS
|
||||
const checkIntervalMs =
|
||||
Number(process.env.ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS) || DEFAULT_CHECK_INTERVAL_MS
|
||||
|
||||
const loop = createHangWatchdogChildLoop({
|
||||
timeoutMs,
|
||||
checkIntervalMs,
|
||||
now: () => Date.now(),
|
||||
onHangDetected: (unresponsiveMs) =>
|
||||
recordHangObservation({
|
||||
parentPid: config.parentPid,
|
||||
markerPath: config.markerPath,
|
||||
unresponsiveMs,
|
||||
selfRecovered: false
|
||||
}),
|
||||
recordHangObservation({ parentPid, markerPath, unresponsiveMs, selfRecovered: false }),
|
||||
// Why: rewriting the marker keeps one observation per stall rather than two rows to reconcile.
|
||||
onHangResolved: (unresponsiveMs) =>
|
||||
recordHangObservation({
|
||||
parentPid: config.parentPid,
|
||||
markerPath: config.markerPath,
|
||||
unresponsiveMs,
|
||||
selfRecovered: true
|
||||
})
|
||||
recordHangObservation({ parentPid, markerPath, unresponsiveMs, selfRecovered: true })
|
||||
})
|
||||
|
||||
let checkTimer: ReturnType<typeof setInterval> | null = setInterval(
|
||||
() => loop.tick(),
|
||||
config.checkIntervalMs
|
||||
)
|
||||
port.on('message', (message: MainToHangWatchdogWorkerMessage) => {
|
||||
if (message.type === 'heartbeat') {
|
||||
process.on('message', (message) => {
|
||||
const type = (message as { type?: string } | null)?.type
|
||||
if (type === 'heartbeat') {
|
||||
loop.recordHeartbeat()
|
||||
} else if (message.type === 'shutdown') {
|
||||
if (checkTimer) {
|
||||
clearInterval(checkTimer)
|
||||
checkTimer = null
|
||||
}
|
||||
port.close()
|
||||
} else if (type === 'shutdown') {
|
||||
process.exit(0)
|
||||
}
|
||||
})
|
||||
// Why: a normal parent exit closes the IPC channel; the watchdog must not outlive it and misfire.
|
||||
process.on('disconnect', () => process.exit(0))
|
||||
setInterval(() => loop.tick(), checkIntervalMs)
|
||||
}
|
||||
|
||||
function isHangWatchdogWorkerData(value: unknown): value is HangWatchdogWorkerData {
|
||||
const data = value as Partial<HangWatchdogWorkerData> | null
|
||||
return (
|
||||
!!data &&
|
||||
Number.isInteger(data.parentPid) &&
|
||||
(data.parentPid ?? 0) > 0 &&
|
||||
typeof data.markerPath === 'string' &&
|
||||
Number.isFinite(data.timeoutMs) &&
|
||||
(data.timeoutMs ?? 0) > 0 &&
|
||||
Number.isFinite(data.checkIntervalMs) &&
|
||||
(data.checkIntervalMs ?? 0) > 0
|
||||
)
|
||||
}
|
||||
|
||||
if (!isMainThread && isHangWatchdogWorkerData(workerData)) {
|
||||
runWatchdog(workerData)
|
||||
const configuredParentPid = Number(process.env.ORCA_HANG_WATCHDOG_PARENT_PID)
|
||||
if (Number.isInteger(configuredParentPid) && configuredParentPid > 0) {
|
||||
runWatchdog(configuredParentPid)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const { workerState, appMock } = vi.hoisted(() => ({
|
||||
workerState: {
|
||||
calls: [] as unknown[][],
|
||||
instance: null as object | null,
|
||||
error: null as Error | null
|
||||
},
|
||||
const { forkMock, appMock } = vi.hoisted(() => ({
|
||||
forkMock: vi.fn(),
|
||||
appMock: {
|
||||
isPackaged: true,
|
||||
getAppPath: vi.fn(() => '/apps/orca/app.asar'),
|
||||
@@ -14,16 +9,8 @@ const { workerState, appMock } = vi.hoisted(() => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('node:worker_threads', () => ({
|
||||
Worker: class WorkerMock {
|
||||
constructor(...args: unknown[]) {
|
||||
workerState.calls.push(args)
|
||||
if (workerState.error) {
|
||||
throw workerState.error
|
||||
}
|
||||
return workerState.instance as WorkerMock
|
||||
}
|
||||
}
|
||||
vi.mock('node:child_process', () => ({
|
||||
fork: forkMock
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
@@ -42,33 +29,28 @@ function withPlatform<T>(platform: NodeJS.Platform, run: () => T): T {
|
||||
}
|
||||
}
|
||||
|
||||
function fakeWorker() {
|
||||
function fakeChild() {
|
||||
return {
|
||||
postMessage: vi.fn(),
|
||||
unref: vi.fn(),
|
||||
connected: true,
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn(),
|
||||
once: vi.fn()
|
||||
send: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
kill: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
describe('installMainThreadHangWatchdog', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
workerState.calls = []
|
||||
workerState.instance = null
|
||||
workerState.error = null
|
||||
forkMock.mockReset()
|
||||
appMock.on.mockReset()
|
||||
appMock.isPackaged = true
|
||||
delete process.env.ORCA_HANG_WATCHDOG_FORCE
|
||||
delete process.env.ORCA_HANG_WATCHDOG_TIMEOUT_MS
|
||||
delete process.env.ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
delete process.env.ORCA_HANG_WATCHDOG_FORCE
|
||||
delete process.env.ORCA_HANG_WATCHDOG_TIMEOUT_MS
|
||||
delete process.env.ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS
|
||||
})
|
||||
|
||||
it('is a no-op off macOS', () => {
|
||||
@@ -78,7 +60,7 @@ describe('installMainThreadHangWatchdog', () => {
|
||||
expect(
|
||||
withPlatform('linux', () => installMainThreadHangWatchdog({ userDataPath: '/ud' }))
|
||||
).toBeNull()
|
||||
expect(workerState.calls).toHaveLength(0)
|
||||
expect(forkMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is a no-op in unpackaged builds unless forced', () => {
|
||||
@@ -87,98 +69,62 @@ describe('installMainThreadHangWatchdog', () => {
|
||||
withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' }))
|
||||
).toBeNull()
|
||||
process.env.ORCA_HANG_WATCHDOG_FORCE = '1'
|
||||
const worker = fakeWorker()
|
||||
workerState.instance = worker
|
||||
const child = fakeChild()
|
||||
forkMock.mockReturnValue(child)
|
||||
expect(
|
||||
withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' }))
|
||||
).not.toBeNull()
|
||||
delete process.env.ORCA_HANG_WATCHDOG_FORCE
|
||||
})
|
||||
|
||||
it('starts a worker with pid, marker, and timing config', () => {
|
||||
const worker = fakeWorker()
|
||||
workerState.instance = worker
|
||||
it('forks the watchdog as plain Node with pid, bundle, and marker config', () => {
|
||||
const child = fakeChild()
|
||||
forkMock.mockReturnValue(child)
|
||||
const handle = withPlatform('darwin', () =>
|
||||
installMainThreadHangWatchdog({ userDataPath: '/ud' })
|
||||
)
|
||||
expect(handle).not.toBeNull()
|
||||
const [workerPath, rawOptions] = workerState.calls[0]
|
||||
const options = rawOptions as {
|
||||
name: string
|
||||
workerData: {
|
||||
parentPid: number
|
||||
markerPath: string
|
||||
timeoutMs: number
|
||||
checkIntervalMs: number
|
||||
}
|
||||
}
|
||||
expect(workerPath).toBe(
|
||||
join('/apps/orca/app.asar', 'out', 'main', 'main-thread-hang-watchdog-entry.js')
|
||||
)
|
||||
expect(options.name).toBe('orca-main-thread-hang-watchdog')
|
||||
expect(options.workerData).toMatchObject({
|
||||
parentPid: process.pid,
|
||||
markerPath: join('/ud', 'main-thread-hang.json'),
|
||||
timeoutMs: 45_000,
|
||||
checkIntervalMs: 5_000
|
||||
})
|
||||
expect(worker.unref).toHaveBeenCalled()
|
||||
const [, , options] = forkMock.mock.calls[0]
|
||||
expect(options.env.ELECTRON_RUN_AS_NODE).toBe('1')
|
||||
expect(options.env.ORCA_HANG_WATCHDOG_PARENT_PID).toBe(String(process.pid))
|
||||
expect(options.env.ORCA_HANG_WATCHDOG_MARKER_PATH).toContain('/ud')
|
||||
})
|
||||
|
||||
it('sends heartbeats on an interval and shutdown on stop', () => {
|
||||
const worker = fakeWorker()
|
||||
workerState.instance = worker
|
||||
it('sends heartbeats on an interval and shutdown+disconnect on stop', () => {
|
||||
const child = fakeChild()
|
||||
forkMock.mockReturnValue(child)
|
||||
const handle = withPlatform('darwin', () =>
|
||||
installMainThreadHangWatchdog({ userDataPath: '/ud' })
|
||||
)
|
||||
vi.advanceTimersByTime(6_000)
|
||||
const heartbeats = worker.postMessage.mock.calls.filter(([m]) => m.type === 'heartbeat')
|
||||
const heartbeats = child.send.mock.calls.filter(([m]) => m.type === 'heartbeat')
|
||||
expect(heartbeats.length).toBe(3)
|
||||
|
||||
handle?.stop()
|
||||
expect(worker.postMessage.mock.calls.some(([m]) => m.type === 'shutdown')).toBe(true)
|
||||
expect(child.send.mock.calls.some(([m]) => m.type === 'shutdown')).toBe(true)
|
||||
expect(child.disconnect).toHaveBeenCalled()
|
||||
|
||||
// Why: quit fires will-quit twice; a second stop must not resend or throw.
|
||||
handle?.stop()
|
||||
const shutdowns = worker.postMessage.mock.calls.filter(([m]) => m.type === 'shutdown')
|
||||
const shutdowns = child.send.mock.calls.filter(([m]) => m.type === 'shutdown')
|
||||
expect(shutdowns.length).toBe(1)
|
||||
|
||||
vi.advanceTimersByTime(10_000)
|
||||
const heartbeatsAfterStop = worker.postMessage.mock.calls.filter(
|
||||
([m]) => m.type === 'heartbeat'
|
||||
)
|
||||
const heartbeatsAfterStop = child.send.mock.calls.filter(([m]) => m.type === 'heartbeat')
|
||||
expect(heartbeatsAfterStop.length).toBe(3)
|
||||
})
|
||||
|
||||
it('passes test timing overrides to the worker', () => {
|
||||
process.env.ORCA_HANG_WATCHDOG_TIMEOUT_MS = '900'
|
||||
process.env.ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS = '100'
|
||||
workerState.instance = fakeWorker()
|
||||
withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' }))
|
||||
const options = workerState.calls[0][1] as {
|
||||
workerData: { timeoutMs: number; checkIntervalMs: number }
|
||||
}
|
||||
expect(options.workerData).toMatchObject({ timeoutMs: 900, checkIntervalMs: 100 })
|
||||
})
|
||||
|
||||
it('registers stop on will-quit', () => {
|
||||
workerState.instance = fakeWorker()
|
||||
const child = fakeChild()
|
||||
forkMock.mockReturnValue(child)
|
||||
withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' }))
|
||||
expect(appMock.on).toHaveBeenCalledWith('will-quit', expect.any(Function))
|
||||
})
|
||||
|
||||
it('stops heartbeat work when the worker exits', () => {
|
||||
const worker = fakeWorker()
|
||||
workerState.instance = worker
|
||||
withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' }))
|
||||
const exitListener = worker.once.mock.calls.find(([event]) => event === 'exit')?.[1]
|
||||
expect(exitListener).toEqual(expect.any(Function))
|
||||
exitListener()
|
||||
vi.advanceTimersByTime(6_000)
|
||||
expect(worker.postMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns null and stays inert when worker construction fails', () => {
|
||||
workerState.error = new Error('worker failure')
|
||||
it('returns null and stays inert when the fork itself fails', () => {
|
||||
forkMock.mockImplementation(() => {
|
||||
throw new Error('spawn failure')
|
||||
})
|
||||
expect(
|
||||
withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' }))
|
||||
).toBeNull()
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import { fork, type ChildProcess } from 'node:child_process'
|
||||
import { app } from 'electron'
|
||||
import { resolveHangWatchdogEntryPath } from './hang-watchdog-entry-path'
|
||||
import { hangDetectionMarkerPath } from './hang-detection-marker'
|
||||
import { resolveHangWatchdogWorkerPath } from './hang-watchdog-worker-path'
|
||||
import {
|
||||
HANG_WATCHDOG_CHECK_INTERVAL_MS,
|
||||
HANG_WATCHDOG_HEARTBEAT_INTERVAL_MS,
|
||||
HANG_WATCHDOG_TIMEOUT_MS,
|
||||
type HangWatchdogWorkerData,
|
||||
type MainToHangWatchdogWorkerMessage
|
||||
} from './hang-watchdog-worker-protocol'
|
||||
|
||||
const HEARTBEAT_INTERVAL_MS = 2_000
|
||||
|
||||
export type MainThreadHangWatchdogHandle = {
|
||||
stop: () => void
|
||||
worker: Worker
|
||||
}
|
||||
|
||||
function positiveTiming(value: string | undefined, fallback: number): number {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
|
||||
child: ChildProcess
|
||||
}
|
||||
|
||||
// Why: macOS 26 scene-backed AppKit windows can deadlock the main thread inside
|
||||
// FrontBoardServices with no crash and no event loop left to self-report. A plain-Node sibling
|
||||
// process watches heartbeats and records the stall so the next launch can report it. It observes
|
||||
// only — see main-thread-hang-watchdog-entry.ts for why it does not kill the parent.
|
||||
export function installMainThreadHangWatchdog(options: {
|
||||
userDataPath: string
|
||||
}): MainThreadHangWatchdogHandle | null {
|
||||
@@ -30,57 +24,58 @@ export function installMainThreadHangWatchdog(options: {
|
||||
if (!app.isPackaged && process.env.ORCA_HANG_WATCHDOG_FORCE !== '1') {
|
||||
return null
|
||||
}
|
||||
const workerPath = resolveHangWatchdogWorkerPath(app.getAppPath(), app.isPackaged)
|
||||
const workerData: HangWatchdogWorkerData = {
|
||||
parentPid: process.pid,
|
||||
markerPath: hangDetectionMarkerPath(options.userDataPath),
|
||||
timeoutMs: positiveTiming(process.env.ORCA_HANG_WATCHDOG_TIMEOUT_MS, HANG_WATCHDOG_TIMEOUT_MS),
|
||||
checkIntervalMs: positiveTiming(
|
||||
process.env.ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS,
|
||||
HANG_WATCHDOG_CHECK_INTERVAL_MS
|
||||
)
|
||||
}
|
||||
let worker: Worker
|
||||
const entryPath = resolveHangWatchdogEntryPath(app.getAppPath(), app.isPackaged)
|
||||
let child: ChildProcess
|
||||
try {
|
||||
// Why: the worker survives an AppKit main-thread deadlock without another Electron process.
|
||||
worker = new Worker(workerPath, {
|
||||
name: 'orca-main-thread-hang-watchdog',
|
||||
workerData
|
||||
child = fork(entryPath, [], {
|
||||
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
ORCA_HANG_WATCHDOG_PARENT_PID: String(process.pid),
|
||||
ORCA_HANG_WATCHDOG_MARKER_PATH: hangDetectionMarkerPath(options.userDataPath)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[hang-watchdog] failed to start watchdog worker:', error)
|
||||
console.error('[hang-watchdog] failed to fork watchdog process:', error)
|
||||
return null
|
||||
}
|
||||
worker.on('error', (error) => {
|
||||
console.error('[hang-watchdog] watchdog worker failed:', error)
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
console.error('[hang-watchdog]', String(chunk).trimEnd())
|
||||
})
|
||||
let stopped = false
|
||||
const postMessage = (message: MainToHangWatchdogWorkerMessage): void => {
|
||||
if (stopped && message.type === 'heartbeat') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
worker.postMessage(message)
|
||||
} catch {
|
||||
// The worker already exited.
|
||||
}
|
||||
}
|
||||
// Why: the watchdog is a safety net — if it dies, run without it; a restart loop here must never affect the app.
|
||||
child.on('error', () => {})
|
||||
const heartbeatTimer = setInterval(() => {
|
||||
postMessage({ type: 'heartbeat' })
|
||||
}, HANG_WATCHDOG_HEARTBEAT_INTERVAL_MS)
|
||||
if (child.connected) {
|
||||
try {
|
||||
child.send({ type: 'heartbeat' }, () => {})
|
||||
} catch {
|
||||
// Channel raced closed between the check and the send.
|
||||
}
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL_MS)
|
||||
let stopped = false
|
||||
const stop = (): void => {
|
||||
if (stopped) {
|
||||
return
|
||||
}
|
||||
stopped = true
|
||||
clearInterval(heartbeatTimer)
|
||||
postMessage({ type: 'shutdown' })
|
||||
if (child.connected) {
|
||||
try {
|
||||
child.send({ type: 'shutdown' }, () => {})
|
||||
} catch {
|
||||
// Already disconnecting.
|
||||
}
|
||||
}
|
||||
// Why: disconnect guarantees the child observes parent shutdown and exits instead of misreading quit as a hang.
|
||||
try {
|
||||
child.disconnect()
|
||||
} catch {
|
||||
// Channel already closed.
|
||||
}
|
||||
}
|
||||
worker.once('exit', () => {
|
||||
stopped = true
|
||||
clearInterval(heartbeatTimer)
|
||||
})
|
||||
worker.unref()
|
||||
// Why: will-quit fires twice during quit; stop is idempotent.
|
||||
app.on('will-quit', stop)
|
||||
return { stop, worker }
|
||||
return { stop, child }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Why: every recursive host delete Orca performs (worktrees, terminal history, quarantined recovery
|
||||
// generations) hits the same Windows stickiness — AV/indexers/late handle releases surface transient
|
||||
// EBUSY/ENOTEMPTY/EPERM on a tree Node just emptied. One helper so no call site forgets the retries.
|
||||
|
||||
import type { RmOptions } from 'node:fs'
|
||||
import { rm } from 'node:fs/promises'
|
||||
import { win32 } from 'node:path'
|
||||
import { setTimeout as delay } from 'node:timers/promises'
|
||||
|
||||
const WINDOWS_REMOVE_RETRY_DELAYS_MS = [250, 500, 1_000, 2_000]
|
||||
const WINDOWS_RM_MAX_RETRIES = 8
|
||||
const WINDOWS_RM_RETRY_DELAY_MS = 150
|
||||
|
||||
export function toHostRemovalPath(targetPath: string): string {
|
||||
// Why: Git for Windows can fail long recursive deletes even after Orca has
|
||||
// proven the worktree target; Node's host deletion should use Win32 long paths.
|
||||
return process.platform === 'win32' ? win32.toNamespacedPath(targetPath) : targetPath
|
||||
}
|
||||
|
||||
function getHostRemovalOptions(): RmOptions {
|
||||
const base = { recursive: true, force: true }
|
||||
if (process.platform !== 'win32') {
|
||||
return base
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
// Why: large Windows trees commonly surface transient ENOTEMPTY/EPERM while
|
||||
// Node walks and removes nested directories.
|
||||
maxRetries: WINDOWS_RM_MAX_RETRIES,
|
||||
retryDelay: WINDOWS_RM_RETRY_DELAY_MS
|
||||
}
|
||||
}
|
||||
|
||||
function isTransientWindowsRemovalError(error: unknown): boolean {
|
||||
if (process.platform !== 'win32' || typeof error !== 'object' || error === null) {
|
||||
return false
|
||||
}
|
||||
const code = 'code' in error && typeof error.code === 'string' ? error.code : undefined
|
||||
if (code && ['EBUSY', 'ENOTEMPTY', 'EPERM'].includes(code)) {
|
||||
return true
|
||||
}
|
||||
const message = 'message' in error && typeof error.message === 'string' ? error.message : ''
|
||||
return /directory not empty|resource busy|operation not permitted/i.test(message)
|
||||
}
|
||||
|
||||
/** Recursively remove a host directory tree, retrying the transient Windows failures. */
|
||||
export async function removeHostTree(targetPath: string): Promise<void> {
|
||||
const removalPath = toHostRemovalPath(targetPath)
|
||||
const retryDelays = process.platform === 'win32' ? WINDOWS_REMOVE_RETRY_DELAYS_MS : []
|
||||
const rmOptions = getHostRemovalOptions()
|
||||
let attempt = 0
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
await rm(removalPath, rmOptions)
|
||||
return
|
||||
} catch (error) {
|
||||
if (attempt >= retryDelays.length || !isTransientWindowsRemovalError(error)) {
|
||||
throw error
|
||||
}
|
||||
// Why: Git/Node recursive deletes on Windows can observe a just-emptied
|
||||
// directory before antivirus/indexers/handles release it.
|
||||
await delay(retryDelays[attempt])
|
||||
attempt += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-1
@@ -80,6 +80,7 @@ import { DesktopRelayService } from './runtime/relay/desktop-relay-service'
|
||||
import type { RelayBrokerStatus } from './runtime/relay/relay-session-broker'
|
||||
import { awaitRuntimeFileWatcherUnsubscribes } from './runtime/orca-runtime-files'
|
||||
import { clearRuntimeMetadataIfOwned } from './runtime/runtime-metadata'
|
||||
import { scheduleAllPendingHistoryTreeRemovals } from './terminal-history-deletion'
|
||||
import { ensureMainI18n, setMainPluginLanguagePacks, setMainUiLanguage } from './i18n/main-i18n'
|
||||
import {
|
||||
getNextDefaultOnAppearanceSettingValue,
|
||||
@@ -2791,6 +2792,9 @@ void app.whenReady().then(async () => {
|
||||
}
|
||||
// Why: headless serve never opens a renderer, so arm scheduled automation dispatch here.
|
||||
automations.start()
|
||||
// Why: serve deletes worktrees too, and the history GC that normally drains delete tombstones is
|
||||
// armed from the main window — without this, a quit mid-removal leaks the tree until a desktop launch.
|
||||
scheduleAllPendingHistoryTreeRemovals()
|
||||
await printServeReady(serveOptions)
|
||||
return
|
||||
}
|
||||
@@ -2917,6 +2921,13 @@ app.on('will-quit', (e) => {
|
||||
killAllPty()
|
||||
const watcherShutdown = shutdownWatchersOnce()
|
||||
store?.flush()
|
||||
// Why: usage-cache writes are queued off the main thread, so a quit right after setEnabled or a
|
||||
// scan completion would drop the final snapshot. Captured before any await; joins the barrier below.
|
||||
const usageCacheFlush = Promise.all([
|
||||
claudeUsage?.flush(),
|
||||
codexUsage?.flush(),
|
||||
openCodeUsage?.flush()
|
||||
]).then(() => {})
|
||||
|
||||
// Why: preventDefault to await disconnectDaemon's async checkpoint writes (else data lost); guard prevents an infinite quit loop on the re-fired will-quit.
|
||||
if (!daemonDisconnectDone) {
|
||||
@@ -2950,7 +2961,8 @@ app.on('will-quit', (e) => {
|
||||
{ name: 'runtime-rpc', promise: rpcStopAndClear },
|
||||
{ name: 'watchers', promise: watcherShutdown },
|
||||
{ name: 'emulator', promise: emulatorShutdown },
|
||||
{ name: 'plugin-hosts', promise: pluginHostShutdown }
|
||||
{ name: 'plugin-hosts', promise: pluginHostShutdown },
|
||||
{ name: 'usage-cache', promise: usageCacheFlush }
|
||||
])
|
||||
.then((pendingTeardowns) => {
|
||||
if (pendingTeardowns.length > 0) {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
MAX_FEEDBACK_IMAGE_BYTES,
|
||||
MAX_FEEDBACK_IMAGE_COUNT,
|
||||
appendFeedbackImagesToFormData,
|
||||
feedbackImageFilename,
|
||||
isSupportedFeedbackImageContentType,
|
||||
validateFeedbackImages
|
||||
} from './feedback-image-attachments'
|
||||
|
||||
function image(contentType: string, bytes = 4): { contentType: string; data: Uint8Array } {
|
||||
return { contentType, data: new Uint8Array(bytes).fill(1) }
|
||||
}
|
||||
|
||||
describe('isSupportedFeedbackImageContentType', () => {
|
||||
it.each(['image/png', 'image/jpeg', 'image/webp', 'image/gif'])('accepts %s', (contentType) => {
|
||||
expect(isSupportedFeedbackImageContentType(contentType)).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['image/bmp', 'image/svg+xml', 'application/pdf', ''])('rejects %s', (contentType) => {
|
||||
expect(isSupportedFeedbackImageContentType(contentType)).toBe(false)
|
||||
})
|
||||
|
||||
// Why: the allow-list is an object literal, so a prototype member must not
|
||||
// pass as a content type and get spliced into the upload filename.
|
||||
it.each(['constructor', '__proto__', 'toString', 'valueOf', 'hasOwnProperty'])(
|
||||
'rejects the inherited member %s',
|
||||
(contentType) => {
|
||||
expect(isSupportedFeedbackImageContentType(contentType)).toBe(false)
|
||||
expect(validateFeedbackImages([image(contentType)])).toBe('Unsupported image type.')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('feedbackImageFilename', () => {
|
||||
it.each([
|
||||
['image/png', 'feedback-image-1.png'],
|
||||
['image/jpeg', 'feedback-image-1.jpg'],
|
||||
['image/webp', 'feedback-image-1.webp'],
|
||||
['image/gif', 'feedback-image-1.gif']
|
||||
])('names a %s attachment %s', (contentType, expected) => {
|
||||
expect(feedbackImageFilename(0, contentType)).toBe(expected)
|
||||
})
|
||||
|
||||
it('numbers attachments from one', () => {
|
||||
expect(feedbackImageFilename(3, 'image/png')).toBe('feedback-image-4.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendFeedbackImagesToFormData', () => {
|
||||
it('frames only the exact typed-array view', async () => {
|
||||
const backing = new Uint8Array([0, 1, 2, 3])
|
||||
const view = backing.subarray(1, 3)
|
||||
const formData = new FormData()
|
||||
|
||||
appendFeedbackImagesToFormData(formData, [{ contentType: 'image/png', data: view }])
|
||||
|
||||
const blob = formData.get('feedbackImage')
|
||||
expect(blob).toBeInstanceOf(Blob)
|
||||
expect(new Uint8Array(await (blob as Blob).arrayBuffer())).toEqual(new Uint8Array([1, 2]))
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateFeedbackImages', () => {
|
||||
it('accepts a full batch of supported images', () => {
|
||||
expect(
|
||||
validateFeedbackImages(
|
||||
Array.from({ length: MAX_FEEDBACK_IMAGE_COUNT }, () => image('image/png'))
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects more than the supported count', () => {
|
||||
expect(
|
||||
validateFeedbackImages(
|
||||
Array.from({ length: MAX_FEEDBACK_IMAGE_COUNT + 1 }, () => image('image/png'))
|
||||
)
|
||||
).toBe(`Attach ${MAX_FEEDBACK_IMAGE_COUNT} images or fewer.`)
|
||||
})
|
||||
|
||||
it('rejects malformed attachment collections and byte payloads', () => {
|
||||
expect(validateFeedbackImages('image/png')).toBe('Image attachments must be a list.')
|
||||
expect(validateFeedbackImages([null])).toBe('Invalid image attachment.')
|
||||
expect(validateFeedbackImages([{ contentType: 'image/png', data: '8388608' }])).toBe(
|
||||
'Invalid image attachment bytes.'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an empty attachment', () => {
|
||||
expect(validateFeedbackImages([image('image/png', 0)])).toBe('Image attachment is empty.')
|
||||
})
|
||||
|
||||
it('rejects an attachment over the byte cap', () => {
|
||||
expect(validateFeedbackImages([image('image/png', MAX_FEEDBACK_IMAGE_BYTES + 1)])).toBe(
|
||||
`Each image must be ${MAX_FEEDBACK_IMAGE_BYTES} bytes or fewer.`
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
import { readFetchResponseJsonWithinLimit } from '../../shared/fetch-response-body'
|
||||
|
||||
// Why: mirrors the server allow-list. Slack picks a renderer from the filename
|
||||
// extension, so every accepted type needs one.
|
||||
const FEEDBACK_IMAGE_EXTENSIONS: Record<string, string> = {
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/webp': 'webp',
|
||||
'image/gif': 'gif'
|
||||
}
|
||||
|
||||
export const MAX_FEEDBACK_IMAGE_COUNT = 4
|
||||
export const MAX_FEEDBACK_IMAGE_BYTES = 8 * 1024 * 1024
|
||||
export const MAX_FEEDBACK_IMAGE_RESPONSE_BYTES = 64 * 1024
|
||||
export const FEEDBACK_IMAGE_FORM_FIELD = 'feedbackImage'
|
||||
|
||||
export type FeedbackImageAttachment = {
|
||||
contentType: string
|
||||
data: Uint8Array
|
||||
}
|
||||
|
||||
export function isSupportedFeedbackImageContentType(contentType: string): boolean {
|
||||
// Why: `in` walks the prototype chain, so "constructor" and "__proto__" would
|
||||
// clear the allow-list and name the upload after an inherited member.
|
||||
return Object.hasOwn(FEEDBACK_IMAGE_EXTENSIONS, contentType)
|
||||
}
|
||||
|
||||
export function getSupportedFeedbackImageContentTypes(): string[] {
|
||||
return Object.keys(FEEDBACK_IMAGE_EXTENSIONS)
|
||||
}
|
||||
|
||||
export function feedbackImageFilename(index: number, contentType: string): string {
|
||||
return `feedback-image-${index + 1}.${FEEDBACK_IMAGE_EXTENSIONS[contentType]}`
|
||||
}
|
||||
|
||||
/** Defence in depth: the renderer validates first, but this channel is reachable directly. */
|
||||
export function validateFeedbackImages(images: unknown): string | null {
|
||||
if (!Array.isArray(images)) {
|
||||
return 'Image attachments must be a list.'
|
||||
}
|
||||
if (images.length > MAX_FEEDBACK_IMAGE_COUNT) {
|
||||
return `Attach ${MAX_FEEDBACK_IMAGE_COUNT} images or fewer.`
|
||||
}
|
||||
for (const image of images) {
|
||||
if (!image || typeof image !== 'object') {
|
||||
return 'Invalid image attachment.'
|
||||
}
|
||||
if (typeof image.contentType !== 'string') {
|
||||
return 'Invalid image attachment content type.'
|
||||
}
|
||||
if (!isSupportedFeedbackImageContentType(image.contentType)) {
|
||||
return 'Unsupported image type.'
|
||||
}
|
||||
if (!(image.data instanceof Uint8Array)) {
|
||||
return 'Invalid image attachment bytes.'
|
||||
}
|
||||
if (image.data.byteLength === 0) {
|
||||
return 'Image attachment is empty.'
|
||||
}
|
||||
if (image.data.byteLength > MAX_FEEDBACK_IMAGE_BYTES) {
|
||||
return `Each image must be ${MAX_FEEDBACK_IMAGE_BYTES} bytes or fewer.`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function appendFeedbackImagesToFormData(
|
||||
formData: FormData,
|
||||
images: FeedbackImageAttachment[]
|
||||
): void {
|
||||
for (const [index, image] of images.entries()) {
|
||||
formData.append(
|
||||
FEEDBACK_IMAGE_FORM_FIELD,
|
||||
new Blob([image.data as BlobPart], { type: image.contentType }),
|
||||
feedbackImageFilename(index, image.contentType)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomic servers omit the image field after both the text and images land. */
|
||||
export async function readFeedbackImagesDelivered(response: Response): Promise<boolean> {
|
||||
try {
|
||||
const parsed: unknown = await readFetchResponseJsonWithinLimit(
|
||||
response,
|
||||
MAX_FEEDBACK_IMAGE_RESPONSE_BYTES
|
||||
)
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
return false
|
||||
}
|
||||
if ('imagesDelivered' in parsed) {
|
||||
return (parsed as { imagesDelivered?: unknown }).imagesDelivered === true
|
||||
}
|
||||
return (parsed as { ok?: unknown }).ok === true
|
||||
} catch {}
|
||||
return false
|
||||
}
|
||||
@@ -16,6 +16,7 @@ vi.mock('electron', () => ({
|
||||
net: { fetch: (...args: unknown[]) => fetchMock(...args) }
|
||||
}))
|
||||
|
||||
import { MAX_FEEDBACK_IMAGE_RESPONSE_BYTES } from './feedback-image-attachments'
|
||||
import { registerFeedbackHandlers, submitFeedback } from './feedback'
|
||||
|
||||
function okResponse(): Response {
|
||||
@@ -392,4 +393,219 @@ describe('submitFeedback', () => {
|
||||
githubEmail: null
|
||||
})
|
||||
})
|
||||
|
||||
describe('image attachments', () => {
|
||||
function pngImage(bytes = 8): { contentType: string; data: Uint8Array } {
|
||||
return { contentType: 'image/png', data: new Uint8Array(bytes).fill(1) }
|
||||
}
|
||||
|
||||
function imageSubmitArgs(
|
||||
images: { contentType: string; data: Uint8Array }[]
|
||||
): Parameters<typeof submitFeedback>[0] {
|
||||
return {
|
||||
feedback: 'images attached',
|
||||
submissionType: 'feedback',
|
||||
githubLogin: 'someone',
|
||||
githubEmail: null,
|
||||
images
|
||||
}
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return Response.json(body, { status: 202 })
|
||||
}
|
||||
|
||||
it('sends attached images as multipart form parts', async () => {
|
||||
await submitFeedback(imageSubmitArgs([pngImage(), pngImage()]))
|
||||
|
||||
const body = requestInit().body as FormData
|
||||
expect(body).toBeInstanceOf(FormData)
|
||||
expect(body.getAll('feedbackImage')).toHaveLength(2)
|
||||
expect(body.get('feedback')).toBe('images attached')
|
||||
// Why: multipart must not lose the enrichment fields the JSON lane sends.
|
||||
expect(body.get('submissionType')).toBe('feedback')
|
||||
expect(body.get('appVersion')).toBe('1.2.3-test')
|
||||
})
|
||||
|
||||
it('keeps the JSON lane when nothing is attached', async () => {
|
||||
await submitFeedback(imageSubmitArgs([]))
|
||||
|
||||
expect(requestInit().body).not.toBeInstanceOf(FormData)
|
||||
expect(postedBody().feedback).toBe('images attached')
|
||||
})
|
||||
|
||||
it('reports partial delivery when the server could not attach the images', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ ok: true, imagesDelivered: false }))
|
||||
|
||||
await expect(submitFeedback(imageSubmitArgs([pngImage()]))).resolves.toEqual({
|
||||
ok: true,
|
||||
imagesDelivered: false
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts the production atomic-success response when it omits the image result', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ ok: true }))
|
||||
|
||||
await expect(submitFeedback(imageSubmitArgs([pngImage()]))).resolves.toEqual({
|
||||
ok: true,
|
||||
imagesDelivered: true
|
||||
})
|
||||
})
|
||||
|
||||
it('reports unconfirmed delivery for a settled non-JSON 2xx', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 202,
|
||||
json: async () => {
|
||||
throw new SyntaxError('Unexpected token')
|
||||
}
|
||||
} as unknown as Response)
|
||||
|
||||
await expect(submitFeedback(imageSubmitArgs([pngImage()]))).resolves.toEqual({
|
||||
ok: true,
|
||||
imagesDelivered: false
|
||||
})
|
||||
})
|
||||
|
||||
it('reports unconfirmed delivery when the response body aborts before the deadline', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 202,
|
||||
json: async () => {
|
||||
throw new TypeError('terminated')
|
||||
}
|
||||
} as unknown as Response)
|
||||
|
||||
await expect(submitFeedback(imageSubmitArgs([pngImage()]))).resolves.toEqual({
|
||||
ok: true,
|
||||
imagesDelivered: false
|
||||
})
|
||||
expect(requestInit().signal).toMatchObject({ aborted: false })
|
||||
})
|
||||
|
||||
it('bounds the image-delivery response body', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response('x'.repeat(MAX_FEEDBACK_IMAGE_RESPONSE_BYTES + 1), { status: 202 })
|
||||
)
|
||||
|
||||
await expect(submitFeedback(imageSubmitArgs([pngImage()]))).resolves.toEqual({
|
||||
ok: true,
|
||||
imagesDelivered: false
|
||||
})
|
||||
})
|
||||
|
||||
it('fails a stalled delivery response body at the attachment timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
fetchMock.mockImplementation((_url: string, init?: RequestInit) =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 202,
|
||||
json: () =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => reject(new Error('body aborted')))
|
||||
})
|
||||
} as unknown as Response)
|
||||
)
|
||||
|
||||
const result = submitFeedback(imageSubmitArgs([pngImage()]))
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
|
||||
await expect(result).resolves.toEqual({
|
||||
ok: false,
|
||||
status: null,
|
||||
error: 'request timed out after 60 seconds'
|
||||
})
|
||||
expect(requestInit().signal).toMatchObject({ aborted: true })
|
||||
})
|
||||
|
||||
it('rejects unsupported image types before any request is made', async () => {
|
||||
const result = await submitFeedback(
|
||||
imageSubmitArgs([{ contentType: 'application/pdf', data: new Uint8Array(4) }])
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: the renderer screens types first, so this lane only matters for a
|
||||
// renderer invoking the channel directly — the case the handler guards.
|
||||
it('rejects a prototype member posing as a content type over IPC', async () => {
|
||||
registerFeedbackHandlers()
|
||||
const result = (await handlers.get('feedback:submit')?.(null, {
|
||||
feedback: 'images attached',
|
||||
githubLogin: null,
|
||||
githubEmail: null,
|
||||
images: [{ contentType: 'constructor', data: new Uint8Array(4).fill(1) }]
|
||||
})) as { ok: boolean; error?: string }
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'Unsupported image type.' })
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects malformed IPC bytes before typed-array normalization', async () => {
|
||||
registerFeedbackHandlers()
|
||||
const result = (await handlers.get('feedback:submit')?.(null, {
|
||||
feedback: 'images attached',
|
||||
githubLogin: null,
|
||||
githubEmail: null,
|
||||
images: [{ contentType: 'image/png', data: '8388608' }]
|
||||
})) as { ok: boolean; error?: string }
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'Invalid image attachment bytes.' })
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects oversized IPC batches before normalizing their entries', async () => {
|
||||
registerFeedbackHandlers()
|
||||
const result = (await handlers.get('feedback:submit')?.(null, {
|
||||
feedback: 'images attached',
|
||||
githubLogin: null,
|
||||
githubEmail: null,
|
||||
images: Array.from({ length: 5 }, () => ({
|
||||
contentType: 'image/png',
|
||||
data: '8388608'
|
||||
}))
|
||||
})) as { ok: boolean; error?: string }
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'Attach 4 images or fewer.' })
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects more images than the supported count', async () => {
|
||||
const result = await submitFeedback(
|
||||
imageSubmitArgs(Array.from({ length: 5 }, () => pngImage()))
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fail a crash report over images it was never going to send', async () => {
|
||||
// Why: the crash lane discards images, so validating them there would
|
||||
// abort a crash report the user needs delivered.
|
||||
await submitFeedback({
|
||||
...diagnosticSubmitArgs(),
|
||||
images: Array.from({ length: 9 }, () => ({
|
||||
contentType: 'application/pdf',
|
||||
data: new Uint8Array(0)
|
||||
}))
|
||||
} as Parameters<typeof submitFeedback>[0])
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
const body = requestInit().body as FormData
|
||||
expect(body.getAll('feedbackImage')).toHaveLength(0)
|
||||
expect(body.get('submissionType')).toBe('crash')
|
||||
})
|
||||
|
||||
it('drops images from crash submissions', async () => {
|
||||
await submitFeedback({
|
||||
...diagnosticSubmitArgs(),
|
||||
images: [pngImage()]
|
||||
} as Parameters<typeof submitFeedback>[0])
|
||||
|
||||
const body = requestInit().body as FormData
|
||||
expect(body.getAll('feedbackImage')).toHaveLength(0)
|
||||
expect(body.get('diagnosticBundleSubmissionId')).toBe('bundleabcdefghijklmnop')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+98
-28
@@ -1,5 +1,13 @@
|
||||
import os from 'node:os'
|
||||
import { app, ipcMain, net } from 'electron'
|
||||
import {
|
||||
appendFeedbackImagesToFormData,
|
||||
readFeedbackImagesDelivered,
|
||||
validateFeedbackImages,
|
||||
type FeedbackImageAttachment
|
||||
} from './feedback-image-attachments'
|
||||
|
||||
export type { FeedbackImageAttachment }
|
||||
|
||||
// Why: the production Mac build loads the renderer from a file:// origin, so a
|
||||
// cross-origin POST from fetch() triggers a CORS preflight that the feedback
|
||||
@@ -21,6 +29,7 @@ export type FeedbackSubmitArgs = {
|
||||
submitAnonymously?: boolean
|
||||
githubLogin: string | null
|
||||
githubEmail: string | null
|
||||
images?: FeedbackImageAttachment[]
|
||||
}
|
||||
|
||||
export type FeedbackDiagnosticBundleAttachment = {
|
||||
@@ -40,6 +49,7 @@ type FeedbackSubmitBody = {
|
||||
osRelease: string
|
||||
arch: string
|
||||
diagnosticBundle?: FeedbackDiagnosticBundleAttachment
|
||||
images?: FeedbackImageAttachment[]
|
||||
}
|
||||
|
||||
export type FeedbackRequestFailure = {
|
||||
@@ -48,7 +58,12 @@ export type FeedbackRequestFailure = {
|
||||
}
|
||||
|
||||
export type FeedbackSubmitResult =
|
||||
| { ok: true; diagnosticBundleFailure?: FeedbackRequestFailure }
|
||||
| {
|
||||
ok: true
|
||||
diagnosticBundleFailure?: FeedbackRequestFailure
|
||||
/** Absent when nothing was attached; false when the text landed but the images did not. */
|
||||
imagesDelivered?: boolean
|
||||
}
|
||||
| ({ ok: false } & FeedbackRequestFailure & {
|
||||
diagnosticBundleFailure?: FeedbackRequestFailure
|
||||
})
|
||||
@@ -81,18 +96,21 @@ function buildSubmitBody(args: InternalFeedbackSubmitArgs): FeedbackSubmitBody {
|
||||
arch: process.arch,
|
||||
...(args.submissionType === 'crash' && args.diagnosticBundle
|
||||
? { diagnosticBundle: args.diagnosticBundle }
|
||||
: {})
|
||||
: {}),
|
||||
// Why: images are a feedback-only affordance; crash reports already carry
|
||||
// diagnostic bundles and the server rejects images on that lane.
|
||||
...(args.submissionType !== 'crash' && args.images?.length ? { images: args.images } : {})
|
||||
}
|
||||
}
|
||||
|
||||
async function postFeedback(
|
||||
url: string,
|
||||
body: FeedbackSubmitBody,
|
||||
timeoutMs = FEEDBACK_REQUEST_TIMEOUT_MS
|
||||
timeoutMs = FEEDBACK_REQUEST_TIMEOUT_MS,
|
||||
readResponse?: (response: Response) => Promise<void>
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController()
|
||||
// Why: a silent feedback endpoint should not leave IPC or crash-report
|
||||
// submission flows pending forever.
|
||||
// Why: a silent endpoint must not leave feedback IPC pending forever.
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs)
|
||||
try {
|
||||
const init: RequestInit = {
|
||||
@@ -100,10 +118,18 @@ async function postFeedback(
|
||||
...feedbackRequestBodyInit(body),
|
||||
signal: controller.signal
|
||||
}
|
||||
return await net.fetch(url, init)
|
||||
const response = await net.fetch(url, init)
|
||||
if (readResponse) {
|
||||
await readResponse(response)
|
||||
}
|
||||
// Why: a response parser may tolerate malformed legacy bodies, but it must
|
||||
// not turn the deadline's aborted body into a confirmed delivery.
|
||||
if (controller.signal.aborted) {
|
||||
throw new Error(`request timed out after ${timeoutMs / 1000} seconds`)
|
||||
}
|
||||
return response
|
||||
} catch (error) {
|
||||
// Why: Electron and Node use different AbortError messages. Normalize our
|
||||
// client deadline so support logs explain which request budget expired.
|
||||
// Why: Electron and Node report AbortError differently; keep deadline logs stable.
|
||||
if (controller.signal.aborted) {
|
||||
throw new Error(`request timed out after ${timeoutMs / 1000} seconds`)
|
||||
}
|
||||
@@ -114,7 +140,7 @@ async function postFeedback(
|
||||
}
|
||||
|
||||
function feedbackRequestBodyInit(body: FeedbackSubmitBody): Pick<RequestInit, 'body' | 'headers'> {
|
||||
if (!body.diagnosticBundle) {
|
||||
if (!body.diagnosticBundle && !body.images?.length) {
|
||||
return {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
@@ -130,22 +156,25 @@ function feedbackRequestBodyInit(body: FeedbackSubmitBody): Pick<RequestInit, 'b
|
||||
appendFeedbackFormField(formData, 'platform', body.platform)
|
||||
appendFeedbackFormField(formData, 'osRelease', body.osRelease)
|
||||
appendFeedbackFormField(formData, 'arch', body.arch)
|
||||
appendFeedbackFormField(
|
||||
formData,
|
||||
'diagnosticBundleSubmissionId',
|
||||
body.diagnosticBundle.bundleSubmissionId
|
||||
)
|
||||
appendFeedbackFormField(formData, 'diagnosticBundleBytes', String(body.diagnosticBundle.bytes))
|
||||
appendFeedbackFormField(
|
||||
formData,
|
||||
'diagnosticBundleSpanCount',
|
||||
String(body.diagnosticBundle.spanCount)
|
||||
)
|
||||
formData.append(
|
||||
'diagnosticBundleFile',
|
||||
new Blob([body.diagnosticBundle.content], { type: DIAGNOSTIC_BUNDLE_CONTENT_TYPE }),
|
||||
`orca-diagnostics-${body.diagnosticBundle.bundleSubmissionId}.ndjson`
|
||||
)
|
||||
if (body.diagnosticBundle) {
|
||||
appendFeedbackFormField(
|
||||
formData,
|
||||
'diagnosticBundleSubmissionId',
|
||||
body.diagnosticBundle.bundleSubmissionId
|
||||
)
|
||||
appendFeedbackFormField(formData, 'diagnosticBundleBytes', String(body.diagnosticBundle.bytes))
|
||||
appendFeedbackFormField(
|
||||
formData,
|
||||
'diagnosticBundleSpanCount',
|
||||
String(body.diagnosticBundle.spanCount)
|
||||
)
|
||||
formData.append(
|
||||
'diagnosticBundleFile',
|
||||
new Blob([body.diagnosticBundle.content], { type: DIAGNOSTIC_BUNDLE_CONTENT_TYPE }),
|
||||
`orca-diagnostics-${body.diagnosticBundle.bundleSubmissionId}.ndjson`
|
||||
)
|
||||
}
|
||||
appendFeedbackImagesToFormData(formData, body.images ?? [])
|
||||
|
||||
// Why: multipart avoids JSON-escaping a near-cap NDJSON bundle over the
|
||||
// backend request limit while still submitting one feedback request.
|
||||
@@ -253,7 +282,37 @@ async function submitFeedbackWithDiagnosticBundle(
|
||||
export async function submitFeedback(
|
||||
args: InternalFeedbackSubmitArgs
|
||||
): Promise<FeedbackSubmitResult> {
|
||||
// Why: buildSubmitBody drops images on the crash lane, so validating them
|
||||
// there would abort a crash report over attachments it never meant to send.
|
||||
if (args.submissionType !== 'crash' && args.images !== undefined) {
|
||||
const imageError = validateFeedbackImages(args.images)
|
||||
if (imageError) {
|
||||
return { ok: false, status: null, error: imageError }
|
||||
}
|
||||
}
|
||||
const body = buildSubmitBody(args)
|
||||
if (body.images?.length) {
|
||||
try {
|
||||
let imagesDelivered = true
|
||||
const response = await postFeedback(
|
||||
FEEDBACK_API_URL,
|
||||
body,
|
||||
FEEDBACK_ATTACHMENT_REQUEST_TIMEOUT_MS,
|
||||
async (nextResponse) => {
|
||||
imagesDelivered = nextResponse.ok ? await readFeedbackImagesDelivered(nextResponse) : true
|
||||
}
|
||||
)
|
||||
if (response.ok) {
|
||||
return { ok: true, imagesDelivered }
|
||||
}
|
||||
// Why: the text lane retries 5xx, this one does not. Replaying up to
|
||||
// 32 MiB of attachments on a flaky link costs more than it saves, and the
|
||||
// dialog keeps the draft and thumbnails so the user can resend.
|
||||
return { ok: false, ...responseFailure(response) }
|
||||
} catch (error) {
|
||||
return { ok: false, ...errorFailure(error) }
|
||||
}
|
||||
}
|
||||
if (body.diagnosticBundle) {
|
||||
const bodyWithoutDiagnosticBundle =
|
||||
args.feedbackWithoutDiagnosticBundle !== undefined
|
||||
@@ -283,9 +342,20 @@ export async function submitFeedback(
|
||||
|
||||
export function registerFeedbackHandlers(): void {
|
||||
ipcMain.removeHandler('feedback:submit')
|
||||
ipcMain.handle('feedback:submit', (_event, args: FeedbackSubmitArgs) =>
|
||||
ipcMain.handle('feedback:submit', (_event, args: FeedbackSubmitArgs) => {
|
||||
// Why: validate the raw clone before normalization so a tiny hostile value
|
||||
// cannot become a large main-process typed-array allocation.
|
||||
if (args.images !== undefined) {
|
||||
const imageError = validateFeedbackImages(args.images)
|
||||
if (imageError) {
|
||||
return { ok: false, status: null, error: imageError }
|
||||
}
|
||||
}
|
||||
// Why: crash submissions are main-only. A compromised renderer can invoke
|
||||
// this channel directly, so force the public feedback lane at the boundary.
|
||||
submitFeedback({ ...args, submissionType: 'feedback' })
|
||||
)
|
||||
return submitFeedback({
|
||||
...args,
|
||||
submissionType: 'feedback'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as ParcelWatcherProcess from './parcel-watcher-process'
|
||||
|
||||
const { handleMock } = vi.hoisted(() => ({
|
||||
handleMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: handleMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
stat: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@parcel/watcher', () => ({
|
||||
subscribe: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./filesystem-watcher-wsl', () => ({
|
||||
createWslWatcher: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./parcel-watcher-process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof ParcelWatcherProcess>()
|
||||
return {
|
||||
...actual,
|
||||
subscribeViaWatcherProcess: vi.fn(actual.subscribeViaWatcherProcess)
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||||
getSshFilesystemProvider: vi.fn(),
|
||||
onSshFilesystemProviderRegistered: () => () => {}
|
||||
}))
|
||||
|
||||
import {
|
||||
closeAllWatchers,
|
||||
closeLocalWatcherForWorktreePath,
|
||||
registerFilesystemWatcherHandlers
|
||||
} from './filesystem-watcher'
|
||||
import {
|
||||
createWatcherRemovalDeadline,
|
||||
drainBeforeWatcherRemoval,
|
||||
WATCHER_REMOVAL_DRAIN_BUDGET_MS,
|
||||
WATCHER_REMOVAL_FINAL_DRAIN_RESERVE_MS
|
||||
} from './watcher-removal-drain'
|
||||
import { WatcherProcessFailure } from './parcel-watcher-process-failure'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { subscribe as subscribeParcelWatcher } from '@parcel/watcher'
|
||||
import { subscribeViaWatcherProcess } from './parcel-watcher-process'
|
||||
|
||||
type HandlerMap = Record<string, (_event: unknown, args: unknown) => Promise<unknown> | unknown>
|
||||
|
||||
describe('local filesystem watcher removal deadline', () => {
|
||||
const handlers: HandlerMap = {}
|
||||
|
||||
beforeEach(async () => {
|
||||
handleMock.mockReset()
|
||||
vi.mocked(stat).mockReset()
|
||||
vi.mocked(subscribeParcelWatcher).mockReset()
|
||||
vi.mocked(subscribeViaWatcherProcess).mockClear()
|
||||
for (const key of Object.keys(handlers)) {
|
||||
delete handlers[key]
|
||||
}
|
||||
handleMock.mockImplementation((channel, handler) => {
|
||||
handlers[channel] = handler
|
||||
})
|
||||
registerFilesystemWatcherHandlers()
|
||||
await closeAllWatchers()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await closeAllWatchers()
|
||||
})
|
||||
|
||||
it('bounds a wedged watcher install so worktree deletion cannot hang forever', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never)
|
||||
// Why mock the process-backed subscribe: the in-process fallback rejects on abort, so only a
|
||||
// subscribe that ignores the abort signal exercises the deadline rather than the cancel path.
|
||||
// Once, so the wedge cannot leak into the next test.
|
||||
vi.mocked(subscribeViaWatcherProcess).mockImplementationOnce(() => new Promise(() => {}))
|
||||
const sender = {
|
||||
isDestroyed: () => false,
|
||||
send: vi.fn(),
|
||||
once: vi.fn(),
|
||||
id: 1
|
||||
}
|
||||
|
||||
const watchPromise = handlers['fs:watchWorktree'](
|
||||
{ sender },
|
||||
{ worktreePath: '/tmp/repo' }
|
||||
) as Promise<unknown>
|
||||
await vi.waitFor(() => {
|
||||
expect(subscribeViaWatcherProcess).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
let closed = false
|
||||
const closePromise = closeLocalWatcherForWorktreePath('/tmp/repo').then(() => {
|
||||
closed = true
|
||||
})
|
||||
|
||||
// The install drain leaves the final unsubscribe its reserved tail slice.
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
WATCHER_REMOVAL_DRAIN_BUDGET_MS - WATCHER_REMOVAL_FINAL_DRAIN_RESERVE_MS - 1
|
||||
)
|
||||
expect(closed).toBe(false)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await closePromise
|
||||
expect(closed).toBe(true)
|
||||
|
||||
void watchPromise
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds a wedged live unsubscribe so worktree deletion cannot hang forever', async () => {
|
||||
vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never)
|
||||
// The in-process Parcel fallback has no unsubscribe timeout of its own, so only the shared
|
||||
// removal deadline can stop this from hanging delete forever.
|
||||
let resolveUnsubscribe: () => void = () => {}
|
||||
const unsubscribeMock = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveUnsubscribe = resolve
|
||||
})
|
||||
)
|
||||
vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: unsubscribeMock } as never)
|
||||
const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 }
|
||||
|
||||
await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/repo' })
|
||||
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
let closed = false
|
||||
const closePromise = closeLocalWatcherForWorktreePath('/tmp/repo').then(() => {
|
||||
closed = true
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(WATCHER_REMOVAL_DRAIN_BUDGET_MS - 1)
|
||||
expect(unsubscribeMock).toHaveBeenCalledTimes(1)
|
||||
expect(closed).toBe(false)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await closePromise
|
||||
expect(closed).toBe(true)
|
||||
} finally {
|
||||
resolveUnsubscribe()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('spends one shared budget across both drains instead of one per await', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never)
|
||||
vi.mocked(subscribeViaWatcherProcess).mockImplementationOnce(() => new Promise(() => {}))
|
||||
const sender = {
|
||||
isDestroyed: () => false,
|
||||
send: vi.fn(),
|
||||
once: vi.fn(),
|
||||
id: 1
|
||||
}
|
||||
|
||||
const watchPromise = handlers['fs:watchWorktree'](
|
||||
{ sender },
|
||||
{ worktreePath: '/tmp/repo' }
|
||||
) as Promise<unknown>
|
||||
await vi.waitFor(() => {
|
||||
expect(subscribeViaWatcherProcess).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Half the budget is already gone before the close starts; the drain may only spend the rest.
|
||||
const deadline = createWatcherRemovalDeadline()
|
||||
await vi.advanceTimersByTimeAsync(WATCHER_REMOVAL_DRAIN_BUDGET_MS / 2)
|
||||
|
||||
let closed = false
|
||||
const closePromise = closeLocalWatcherForWorktreePath('/tmp/repo', deadline).then(() => {
|
||||
closed = true
|
||||
})
|
||||
// Why assert mid-drain: without this a fresh (unshared) budget would also pass the final check.
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
WATCHER_REMOVAL_DRAIN_BUDGET_MS / 2 - WATCHER_REMOVAL_FINAL_DRAIN_RESERVE_MS - 1
|
||||
)
|
||||
expect(closed).toBe(false)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await closePromise
|
||||
expect(closed).toBe(true)
|
||||
|
||||
void watchPromise
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not let an abandoned unsubscribe poison a later close of the same root', async () => {
|
||||
vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never)
|
||||
let rejectUnsubscribe: (error: unknown) => void = () => {}
|
||||
const unsubscribeMock = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
rejectUnsubscribe = reject
|
||||
})
|
||||
)
|
||||
vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: unsubscribeMock } as never)
|
||||
const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 }
|
||||
await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/repo' })
|
||||
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const closePromise = closeLocalWatcherForWorktreePath('/tmp/repo')
|
||||
await vi.advanceTimersByTimeAsync(WATCHER_REMOVAL_DRAIN_BUDGET_MS)
|
||||
await expect(closePromise).resolves.toBeUndefined()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
|
||||
// The delete this drain guarded already finished; a late native failure is stale news, and
|
||||
// retaining it would leave this root permanently undeletable until the watcher process exits.
|
||||
rejectUnsubscribe(
|
||||
new WatcherProcessFailure(
|
||||
'file watcher process did not exit after termination deadline',
|
||||
'supervisor',
|
||||
'process_unavailable',
|
||||
new Promise<void>(() => {})
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() => expect(unsubscribeMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
await expect(closeLocalWatcherForWorktreePath('/tmp/repo')).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('watcher removal drain budget', () => {
|
||||
it('reserves a tail slice so a slow final unsubscribe is not abandoned at zero', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const deadline = createWatcherRemovalDeadline()
|
||||
const earlyDrain = drainBeforeWatcherRemoval(
|
||||
new Promise(() => {}),
|
||||
deadline,
|
||||
'wedged early drain',
|
||||
{ reserveMs: WATCHER_REMOVAL_FINAL_DRAIN_RESERVE_MS }
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
WATCHER_REMOVAL_DRAIN_BUDGET_MS - WATCHER_REMOVAL_FINAL_DRAIN_RESERVE_MS
|
||||
)
|
||||
await expect(earlyDrain).resolves.toBe('timeout')
|
||||
expect(deadline.remainingMs()).toBe(WATCHER_REMOVAL_FINAL_DRAIN_RESERVE_MS)
|
||||
|
||||
let finishFinalUnsubscribe: () => void = () => {}
|
||||
const finalDrain = drainBeforeWatcherRemoval(
|
||||
new Promise<void>((resolve) => {
|
||||
finishFinalUnsubscribe = resolve
|
||||
}),
|
||||
deadline,
|
||||
'slow final unsubscribe'
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(WATCHER_REMOVAL_FINAL_DRAIN_RESERVE_MS - 1)
|
||||
finishFinalUnsubscribe()
|
||||
|
||||
await expect(finalDrain).resolves.toBe('settled')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,12 @@ import {
|
||||
WatcherChildCapacityError
|
||||
} from './parcel-watcher-child-registry'
|
||||
import { beginWatcherInstall, isWatcherRemovalInProgressError } from './watcher-removal-gate'
|
||||
import {
|
||||
createWatcherRemovalDeadline,
|
||||
drainBeforeWatcherRemoval,
|
||||
WATCHER_REMOVAL_FINAL_DRAIN_RESERVE_MS,
|
||||
type WatcherRemovalDeadline
|
||||
} from './watcher-removal-drain'
|
||||
// Why: suppress high-churn dirs at the watcher level (separate from the File Explorer display filter, which only hides rows).
|
||||
import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOptions } from './filesystem-watcher-ignore'
|
||||
|
||||
@@ -72,6 +78,10 @@ const suspendedLocalWatcherListeners = new Map<
|
||||
let localWatchersClosed = false
|
||||
let localWatcherLifecycleGeneration = 0
|
||||
const failedLocalUnsubscribes = new Map<string, unknown>()
|
||||
// Why: a drain that timed out no longer gates the delete — Git removal already proceeded past it. Its
|
||||
// late failure must not fail-close a *later* close of the same root, which would leave that path
|
||||
// undeletable until the watcher process physically exits.
|
||||
const abandonedLocalUnsubscribes = new WeakSet<Promise<void>>()
|
||||
type LocalWatcherInstallToken = {
|
||||
cancelled: boolean
|
||||
listeners: Map<number, WebContents>
|
||||
@@ -478,12 +488,21 @@ function trackLocalUnsubscribe(rootKey: string, root: WatchedRoot): Promise<void
|
||||
rootUnsubscribes.add(unsubscribePromise)
|
||||
// Why: swallow here to avoid unhandled rejections, but keep the original promise rejected so later destructive cleanup can fail closed.
|
||||
void unsubscribePromise.catch((error: unknown) => {
|
||||
retainLocalWatcherPhysicalFailure(rootKey, error)
|
||||
if (!abandonedLocalUnsubscribes.has(unsubscribePromise)) {
|
||||
retainLocalWatcherPhysicalFailure(rootKey, error)
|
||||
}
|
||||
console.error(`[filesystem-watcher] unsubscribe error for ${rootKey}:`, error)
|
||||
})
|
||||
return unsubscribePromise
|
||||
}
|
||||
|
||||
/** Mark unsubscribes whose drain timed out so their late failures stay out of failedLocalUnsubscribes. */
|
||||
function abandonLocalUnsubscribes(promises: Iterable<Promise<void>>): void {
|
||||
for (const promise of promises) {
|
||||
abandonedLocalUnsubscribes.add(promise)
|
||||
}
|
||||
}
|
||||
|
||||
function retainLocalWatcherPhysicalFailure(rootKey: string, error: unknown): void {
|
||||
if (!isWatcherProcessFailure(error) || !error.physicalExit) {
|
||||
return
|
||||
@@ -780,7 +799,10 @@ function unsubscribe(worktreePath: string, senderId: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeLocalWatcherForWorktreePath(worktreePath: string): Promise<void> {
|
||||
export async function closeLocalWatcherForWorktreePath(
|
||||
worktreePath: string,
|
||||
deadline: WatcherRemovalDeadline = createWatcherRemovalDeadline()
|
||||
): Promise<void> {
|
||||
const { key: rootKey } = localWatcherRoot(worktreePath)
|
||||
const suspended = suspendedLocalWatcherListeners.get(rootKey) ?? {
|
||||
worktreePath,
|
||||
@@ -814,10 +836,45 @@ export async function closeLocalWatcherForWorktreePath(worktreePath: string): Pr
|
||||
inFlight.cancelled = true
|
||||
inFlight.abortController.abort()
|
||||
}
|
||||
await pendingLocalInstallPromises.get(rootKey)
|
||||
// Why: abort alone is not enough if the native subscribe never settles; bound so delete cannot hang the app.
|
||||
const pendingInstall = pendingLocalInstallPromises.get(rootKey)
|
||||
const installDrain = await drainBeforeWatcherRemoval(
|
||||
pendingInstall,
|
||||
deadline,
|
||||
`local watcher install for ${rootKey}`,
|
||||
{ reserveMs: WATCHER_REMOVAL_FINAL_DRAIN_RESERVE_MS }
|
||||
)
|
||||
if (installDrain === 'timeout') {
|
||||
// Why: an abandoned install never runs its own cleanup, so leaving these entries would make every
|
||||
// later watch of this root queue behind the same wedged promise. Identity-checked so a late settle
|
||||
// can't evict a newer install.
|
||||
if (pendingLocalInstallPromises.get(rootKey) === pendingInstall) {
|
||||
pendingLocalInstallPromises.delete(rootKey)
|
||||
}
|
||||
if (inFlight && inFlightLocalInstalls.get(rootKey) === inFlight) {
|
||||
inFlightLocalInstalls.delete(rootKey)
|
||||
}
|
||||
}
|
||||
const pendingUnsubscribes = pendingLocalUnsubscribesByRoot.get(rootKey)
|
||||
if (pendingUnsubscribes) {
|
||||
await Promise.all(Array.from(pendingUnsubscribes))
|
||||
const draining = Array.from(pendingUnsubscribes)
|
||||
const unsubscribeDrain = await drainBeforeWatcherRemoval(
|
||||
// Why the per-promise catch: an already-abandoned unsubscribe belongs to a delete that finished
|
||||
// without it; re-raising its rejection here would fail a later close on stale news.
|
||||
Promise.all(
|
||||
draining.map((unsubscribe) =>
|
||||
abandonedLocalUnsubscribes.has(unsubscribe)
|
||||
? unsubscribe.catch(() => undefined)
|
||||
: unsubscribe
|
||||
)
|
||||
),
|
||||
deadline,
|
||||
`local watcher unsubscribe for ${rootKey}`,
|
||||
{ reserveMs: WATCHER_REMOVAL_FINAL_DRAIN_RESERVE_MS }
|
||||
)
|
||||
if (unsubscribeDrain === 'timeout') {
|
||||
abandonLocalUnsubscribes(draining)
|
||||
}
|
||||
}
|
||||
if (failedLocalUnsubscribes.has(rootKey)) {
|
||||
throw failedLocalUnsubscribes.get(rootKey)
|
||||
@@ -831,7 +888,18 @@ export async function closeLocalWatcherForWorktreePath(worktreePath: string): Pr
|
||||
clearTimeout(root.batch.timer)
|
||||
}
|
||||
watchedRoots.delete(rootKey)
|
||||
await trackLocalUnsubscribe(rootKey, root)
|
||||
// Why: the in-process Parcel fallback has no unsubscribe timeout of its own, so an unbounded await
|
||||
// here would hang delete forever and hold the removal gate. The promise stays tracked in
|
||||
// pendingLocalUnsubscribesByRoot, so a later close still observes its failure.
|
||||
const finalUnsubscribe = trackLocalUnsubscribe(rootKey, root)
|
||||
const finalDrain = await drainBeforeWatcherRemoval(
|
||||
finalUnsubscribe,
|
||||
deadline,
|
||||
`local watcher unsubscribe for ${rootKey}`
|
||||
)
|
||||
if (finalDrain === 'timeout') {
|
||||
abandonLocalUnsubscribes([finalUnsubscribe])
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreLocalWatcherAfterFailedRemoval(worktreePath: string): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { JiraCancellableRequests } from './jira-cancellable-requests'
|
||||
|
||||
describe('JiraCancellableRequests', () => {
|
||||
it('aborts a late run when cancel arrived before registration', async () => {
|
||||
const requests = new JiraCancellableRequests()
|
||||
requests.cancel('req-1')
|
||||
|
||||
const signals: AbortSignal[] = []
|
||||
await expect(
|
||||
requests.run('req-1', async (signal) => {
|
||||
signals.push(signal)
|
||||
if (signal.aborted) {
|
||||
throw Object.assign(new Error('aborted'), { name: 'AbortError' })
|
||||
}
|
||||
return 'ok'
|
||||
})
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(signals).toHaveLength(1)
|
||||
expect(signals[0]?.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('aborts an in-flight run on cancel', async () => {
|
||||
const requests = new JiraCancellableRequests()
|
||||
const resolveBox: { current?: (value: string) => void } = {}
|
||||
const started = new Promise<AbortSignal>((resolve) => {
|
||||
void requests.run('req-2', (signal) => {
|
||||
resolve(signal)
|
||||
return new Promise<string>((taskResolve) => {
|
||||
resolveBox.current = taskResolve
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
taskResolve('aborted')
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const signal = await started
|
||||
expect(signal.aborted).toBe(false)
|
||||
requests.cancel('req-2')
|
||||
expect(signal.aborted).toBe(true)
|
||||
resolveBox.current?.('done')
|
||||
})
|
||||
|
||||
it('ignores blank request ids', async () => {
|
||||
const requests = new JiraCancellableRequests()
|
||||
const task = vi.fn(async () => 'ok')
|
||||
await expect(requests.run(' ', task)).resolves.toBe('ok')
|
||||
requests.cancel(' ')
|
||||
expect(task).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
function normalizeRequestId(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() && value.length <= 128 ? value.trim() : undefined
|
||||
}
|
||||
|
||||
const CANCEL_TOMBSTONE_TTL_MS = 30_000
|
||||
|
||||
/**
|
||||
* Tracks one live AbortController per renderer-supplied request id so a superseded Jira call
|
||||
* releases its slot in the shared request pool instead of draining it.
|
||||
*
|
||||
* Cancel can race ahead of run() (renderer abort before IPC registers). Tombstones keep that
|
||||
* cancel sticky until run starts, so a late registration aborts immediately.
|
||||
*/
|
||||
export class JiraCancellableRequests {
|
||||
private readonly controllers = new Map<string, AbortController>()
|
||||
private readonly cancelTombstones = new Map<string, number>()
|
||||
|
||||
async run<T>(requestId: unknown, task: (signal: AbortSignal) => Promise<T>): Promise<T> {
|
||||
const id = normalizeRequestId(requestId)
|
||||
const controller = new AbortController()
|
||||
if (id) {
|
||||
// Reusing an id means the renderer abandoned its previous attempt.
|
||||
this.controllers.get(id)?.abort()
|
||||
if (this.consumeCancelTombstone(id)) {
|
||||
controller.abort()
|
||||
}
|
||||
this.controllers.set(id, controller)
|
||||
}
|
||||
try {
|
||||
return await task(controller.signal)
|
||||
} finally {
|
||||
if (id && this.controllers.get(id) === controller) {
|
||||
this.controllers.delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cancel(requestId: unknown): void {
|
||||
const id = normalizeRequestId(requestId)
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
this.pruneExpiredTombstones()
|
||||
const live = this.controllers.get(id)
|
||||
if (live) {
|
||||
live.abort()
|
||||
return
|
||||
}
|
||||
// Re-delete first: Map.set keeps an existing key's original position, which would
|
||||
// strand a stale expiry ahead of newer ones and stall the front-to-back sweep.
|
||||
this.cancelTombstones.delete(id)
|
||||
this.cancelTombstones.set(id, Date.now() + CANCEL_TOMBSTONE_TTL_MS)
|
||||
}
|
||||
|
||||
// Why: a cancel whose run() never arrives would otherwise leak its tombstone forever.
|
||||
// Expiries only increase and Map keeps insertion order, so stop at the first live entry.
|
||||
private pruneExpiredTombstones(): void {
|
||||
const now = Date.now()
|
||||
for (const [id, expiresAt] of this.cancelTombstones) {
|
||||
if (expiresAt > now) {
|
||||
break
|
||||
}
|
||||
this.cancelTombstones.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
private consumeCancelTombstone(id: string): boolean {
|
||||
const until = this.cancelTombstones.get(id)
|
||||
if (until === undefined) {
|
||||
return false
|
||||
}
|
||||
this.cancelTombstones.delete(id)
|
||||
return until > Date.now()
|
||||
}
|
||||
}
|
||||
+40
-2
@@ -1,10 +1,12 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { connect, disconnect, getStatus, selectSite, testConnection } from '../jira/client'
|
||||
import { _resetPreflightCache } from './preflight'
|
||||
import { JiraCancellableRequests } from './jira-cancellable-requests'
|
||||
import {
|
||||
addIssueComment,
|
||||
createIssue,
|
||||
getIssue,
|
||||
getIssueSummary,
|
||||
getIssueComments,
|
||||
getProjectStatusOrder,
|
||||
listAssignableUsers,
|
||||
@@ -26,6 +28,8 @@ import type {
|
||||
} from '../../shared/types'
|
||||
|
||||
const VALID_FILTERS = new Set<JiraIssueFilter>(['assigned', 'reported', 'all', 'done'])
|
||||
const issueSummaryRequests = new JiraCancellableRequests()
|
||||
const searchRequests = new JiraCancellableRequests()
|
||||
|
||||
function normalizeSiteId(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined
|
||||
@@ -117,20 +121,33 @@ export function registerJiraHandlers(): void {
|
||||
return getStatus()
|
||||
})
|
||||
|
||||
ipcMain.handle('jira:readStatus', async () => {
|
||||
return getStatus()
|
||||
})
|
||||
|
||||
ipcMain.handle('jira:testConnection', async (_event, args?: { siteId?: string }) => {
|
||||
return testConnection(normalizeSiteId(args?.siteId))
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'jira:searchIssues',
|
||||
async (_event, args: { jql: string; limit?: number; siteId?: JiraSiteSelection }) => {
|
||||
async (
|
||||
_event,
|
||||
args: { jql: string; limit?: number; siteId?: JiraSiteSelection; requestId?: string }
|
||||
) => {
|
||||
if (typeof args?.jql !== 'string') {
|
||||
return []
|
||||
}
|
||||
return searchIssues(args.jql, clampLimit(args.limit), normalizeSiteSelection(args.siteId))
|
||||
return searchRequests.run(args.requestId, (signal) =>
|
||||
searchIssues(args.jql, clampLimit(args.limit), normalizeSiteSelection(args.siteId), signal)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('jira:cancelSearchIssues', (_event, args: { requestId?: string }) => {
|
||||
searchRequests.cancel(args?.requestId)
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'jira:listIssues',
|
||||
async (
|
||||
@@ -151,6 +168,27 @@ export function registerJiraHandlers(): void {
|
||||
return getIssue(args.key.trim(), normalizeSiteId(args.siteId))
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'jira:lookupIssueSummary',
|
||||
async (_event, args: { key: string; siteId: string; requestId?: string }) => {
|
||||
if (
|
||||
typeof args?.key !== 'string' ||
|
||||
!args.key.trim() ||
|
||||
typeof args?.siteId !== 'string' ||
|
||||
!args.siteId.trim()
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return issueSummaryRequests.run(args.requestId, (signal) =>
|
||||
getIssueSummary(args.key.trim(), args.siteId.trim(), signal)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('jira:cancelIssueSummary', (_event, args: { requestId?: string }) => {
|
||||
issueSummaryRequests.cancel(args?.requestId)
|
||||
})
|
||||
|
||||
ipcMain.handle('jira:createIssue', async (_event, args: JiraCreateIssueArgs) => {
|
||||
if (typeof args?.projectId !== 'string' || !args.projectId.trim()) {
|
||||
return { ok: false, error: 'Project is required.' }
|
||||
|
||||
@@ -1115,6 +1115,7 @@ describe('repos:addRemote', () => {
|
||||
})
|
||||
mockStore.getRepos.mockReset().mockReturnValue([])
|
||||
mockStore.addRepo.mockReset()
|
||||
mockStore.removeProject.mockReset()
|
||||
mockStore.getSshTarget.mockReset()
|
||||
mockStore.updateRepo.mockReset()
|
||||
mockGitProvider.isGitRepoAsync.mockReset()
|
||||
@@ -2066,6 +2067,76 @@ describe('repos:add + repos:clone', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('sets up a folder when the selected project exists only on another host', async () => {
|
||||
const added: Record<string, unknown>[] = []
|
||||
mockStore.getRepos.mockImplementation(() => added)
|
||||
mockStore.addRepo.mockImplementation((repo: Record<string, unknown>) => added.push(repo))
|
||||
mockStore.updateRepo.mockImplementation((id, updates) => {
|
||||
const repo = added.find((entry) => entry.id === id)
|
||||
if (!repo) {
|
||||
return null
|
||||
}
|
||||
Object.assign(repo, updates)
|
||||
return { ...repo }
|
||||
})
|
||||
mockStore.getProjects.mockImplementation(() => {
|
||||
const repo = added.find((entry) => 'upstream' in entry)
|
||||
return repo
|
||||
? [
|
||||
{
|
||||
id: 'github:github.acme.test/acme/orca',
|
||||
displayName: 'Orca',
|
||||
providerIdentity: {
|
||||
provider: 'github',
|
||||
owner: 'acme',
|
||||
repo: 'orca',
|
||||
host: 'github.acme.test'
|
||||
}
|
||||
}
|
||||
]
|
||||
: []
|
||||
})
|
||||
|
||||
const result = await handlers.get('projectHostSetups:setupExistingFolder')!(null, {
|
||||
projectId: 'github:github.acme.test/acme/orca',
|
||||
projectProviderIdentity: {
|
||||
provider: 'github',
|
||||
owner: 'acme',
|
||||
repo: 'orca',
|
||||
host: 'github.acme.test'
|
||||
},
|
||||
hostId: 'local',
|
||||
path: '/tmp/orca-local',
|
||||
kind: 'git'
|
||||
})
|
||||
|
||||
expect(added[0]?.upstream).toEqual({
|
||||
owner: 'acme',
|
||||
repo: 'orca',
|
||||
host: 'github.acme.test'
|
||||
})
|
||||
expect(result).toHaveProperty('project.id', 'github:github.acme.test/acme/orca')
|
||||
})
|
||||
|
||||
it('rolls back a new repo when the supplied identity does not match the project', async () => {
|
||||
const added: Record<string, unknown>[] = []
|
||||
mockStore.getRepos.mockImplementation(() => added)
|
||||
mockStore.addRepo.mockImplementation((repo: Record<string, unknown>) => added.push(repo))
|
||||
|
||||
await expect(
|
||||
handlers.get('projectHostSetups:setupExistingFolder')!(null, {
|
||||
projectId: 'github:acme/orca',
|
||||
projectProviderIdentity: { provider: 'github', owner: 'other', repo: 'orca' },
|
||||
hostId: 'local',
|
||||
path: '/tmp/mismatched-project',
|
||||
kind: 'git'
|
||||
})
|
||||
).rejects.toThrow('Imported folder does not match the selected project identity.')
|
||||
|
||||
expect(mockStore.removeProject).toHaveBeenCalledWith(added[0]?.id)
|
||||
expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prepares and invalidates roots when repos:update changes worktree base path', () => {
|
||||
const updated = {
|
||||
id: 'repo-update-root',
|
||||
|
||||
+99
-55
@@ -37,6 +37,9 @@ import {
|
||||
relativePathInsideRoot
|
||||
} from '../../shared/cross-platform-path'
|
||||
import { isTuiAgent } from '../../shared/tui-agent-config'
|
||||
import { TaskSourceContextSchema } from '../../shared/task-source-context-schema'
|
||||
import { WorkspaceLinkedItemSchema } from '../../shared/workspace-linked-item-schema'
|
||||
import { isWorkspaceLinkedItemSourceContextMatch } from '../../shared/workspace-linked-item-source-context'
|
||||
import { invalidateAuthorizedRootsCache } from './filesystem-auth'
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { access, mkdir, readdir, rm } from 'node:fs/promises'
|
||||
@@ -89,7 +92,10 @@ import type {
|
||||
} from '../../shared/host-repo-catalog-contract'
|
||||
import { detectRepoIconAndUpstream } from '../repo-icon-autodetect'
|
||||
import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identity-enrichment'
|
||||
import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-projection'
|
||||
import {
|
||||
getProjectHostSetupForRepo,
|
||||
getProjectIdForProviderIdentity
|
||||
} from '../../shared/project-host-setup-projection'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
@@ -235,20 +241,23 @@ function alignRepoWithRequestedProject(
|
||||
store: Store,
|
||||
repo: Repo,
|
||||
projectId: string,
|
||||
setupMethod: ProjectHostSetupExistingFolderArgs['setupMethod'] = 'imported-existing-folder'
|
||||
setupMethod: ProjectHostSetupExistingFolderArgs['setupMethod'] = 'imported-existing-folder',
|
||||
requestedProviderIdentity?: ProjectHostSetupExistingFolderArgs['projectProviderIdentity']
|
||||
): ProjectHostSetupResult {
|
||||
let setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo)
|
||||
if (setup.projectId !== projectId) {
|
||||
const project = store.getProjects().find((entry) => entry.id === projectId)
|
||||
if (!project?.providerIdentity || project.providerIdentity.provider !== 'github') {
|
||||
// Why: the selected project can exist only on the source host, so its structured identity travels with the request.
|
||||
const identity = project?.providerIdentity ?? requestedProviderIdentity
|
||||
if (!identity || getProjectIdForProviderIdentity(identity) !== projectId) {
|
||||
throw new Error('Imported folder does not match the selected project identity.')
|
||||
}
|
||||
// Why: stamp the selected project's provider identity when the folder lacks upstream, so projection can merge it.
|
||||
const updated = store.updateRepo(repo.id, {
|
||||
upstream: {
|
||||
owner: project.providerIdentity.owner,
|
||||
repo: project.providerIdentity.repo,
|
||||
...(project.providerIdentity.host ? { host: project.providerIdentity.host } : {})
|
||||
owner: identity.owner,
|
||||
repo: identity.repo,
|
||||
...(identity.host ? { host: identity.host } : {})
|
||||
}
|
||||
})
|
||||
if (!updated) {
|
||||
@@ -801,6 +810,14 @@ const ProjectGroupMoveProjectArgs = z.object({
|
||||
|
||||
const ProjectHostSetupExistingFolderIpcArgs = z.object({
|
||||
projectId: z.string().min(1),
|
||||
projectProviderIdentity: z
|
||||
.object({
|
||||
provider: z.literal('github'),
|
||||
owner: z.string().min(1),
|
||||
repo: z.string().min(1),
|
||||
host: z.string().min(1).optional()
|
||||
})
|
||||
.optional(),
|
||||
hostId: z.string().min(1),
|
||||
path: z.string().min(1),
|
||||
kind: z.enum(['git', 'folder']).optional(),
|
||||
@@ -863,47 +880,61 @@ const ProjectHostSetupDeleteIpcArgs = z.object({
|
||||
setupId: z.string().min(1)
|
||||
})
|
||||
|
||||
const FolderWorkspaceLinkedTaskArgs = z
|
||||
.object({
|
||||
provider: z.enum(['github', 'gitlab', 'linear', 'jira']),
|
||||
type: z.enum(['issue', 'pr', 'mr']),
|
||||
number: z.number().finite(),
|
||||
title: z.string().min(1),
|
||||
url: z.string().min(1),
|
||||
linearIdentifier: z.string().min(1).optional(),
|
||||
jiraIdentifier: z.string().min(1).optional(),
|
||||
repoId: z.string().min(1).optional()
|
||||
})
|
||||
.nullable()
|
||||
const FolderWorkspaceLinkedTaskArgs = WorkspaceLinkedItemSchema.nullable()
|
||||
|
||||
const FolderWorkspaceCreateArgs = z.object({
|
||||
projectGroupId: z.string().min(1),
|
||||
name: z.string().optional(),
|
||||
folderPath: z.string().nullable().optional(),
|
||||
connectionId: z.string().nullable().optional(),
|
||||
linkedTask: FolderWorkspaceLinkedTaskArgs.optional(),
|
||||
createdWithAgent: z.string().refine(isTuiAgent).optional(),
|
||||
pendingFirstAgentMessageRename: z.boolean().optional()
|
||||
})
|
||||
function assertFolderWorkspaceLinkedSourceContextMatch(
|
||||
value: {
|
||||
linkedTask?: z.infer<typeof FolderWorkspaceLinkedTaskArgs>
|
||||
linkedTaskSourceContext?: z.infer<typeof TaskSourceContextSchema> | null
|
||||
},
|
||||
ctx: z.RefinementCtx
|
||||
): void {
|
||||
if (
|
||||
value.linkedTask &&
|
||||
value.linkedTaskSourceContext &&
|
||||
!isWorkspaceLinkedItemSourceContextMatch(value.linkedTask, value.linkedTaskSourceContext)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Linked task and source context identities must match'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const FolderWorkspaceCreateArgs = z
|
||||
.object({
|
||||
projectGroupId: z.string().min(1),
|
||||
name: z.string().optional(),
|
||||
folderPath: z.string().nullable().optional(),
|
||||
connectionId: z.string().nullable().optional(),
|
||||
linkedTask: FolderWorkspaceLinkedTaskArgs.optional(),
|
||||
linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(),
|
||||
createdWithAgent: z.string().refine(isTuiAgent).optional(),
|
||||
pendingFirstAgentMessageRename: z.boolean().optional()
|
||||
})
|
||||
.superRefine(assertFolderWorkspaceLinkedSourceContextMatch)
|
||||
|
||||
const FolderWorkspaceUpdateArgs = z.object({
|
||||
folderWorkspaceId: z.string().min(1),
|
||||
updates: z.object({
|
||||
name: z.string().optional(),
|
||||
folderPath: z.string().optional(),
|
||||
linkedTask: FolderWorkspaceLinkedTaskArgs.optional(),
|
||||
comment: z.string().optional(),
|
||||
isArchived: z.boolean().optional(),
|
||||
isUnread: z.boolean().optional(),
|
||||
isPinned: z.boolean().optional(),
|
||||
sortOrder: z.number().finite().optional(),
|
||||
manualOrder: z.number().finite().optional(),
|
||||
workspaceStatus: z.string().optional(),
|
||||
createdWithAgent: z.string().refine(isTuiAgent).optional(),
|
||||
pendingFirstAgentMessageRename: z.boolean().optional(),
|
||||
firstAgentMessageRenameError: z.string().nullable().optional(),
|
||||
lastActivityAt: z.number().finite().optional()
|
||||
})
|
||||
updates: z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
folderPath: z.string().optional(),
|
||||
linkedTask: FolderWorkspaceLinkedTaskArgs.optional(),
|
||||
linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(),
|
||||
comment: z.string().optional(),
|
||||
isArchived: z.boolean().optional(),
|
||||
isUnread: z.boolean().optional(),
|
||||
isPinned: z.boolean().optional(),
|
||||
sortOrder: z.number().finite().optional(),
|
||||
manualOrder: z.number().finite().optional(),
|
||||
workspaceStatus: z.string().optional(),
|
||||
createdWithAgent: z.string().refine(isTuiAgent).optional(),
|
||||
pendingFirstAgentMessageRename: z.boolean().optional(),
|
||||
firstAgentMessageRenameError: z.string().nullable().optional(),
|
||||
lastActivityAt: z.number().finite().optional()
|
||||
})
|
||||
.superRefine(assertFolderWorkspaceLinkedSourceContextMatch)
|
||||
})
|
||||
|
||||
const FolderWorkspaceSelectorArgs = z.object({
|
||||
@@ -1378,11 +1409,6 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
if (!parsedHost) {
|
||||
throw new Error(`Unsupported host: ${args.hostId}`)
|
||||
}
|
||||
const existingProject = store.getProjects().find((project) => project.id === args.projectId)
|
||||
if (!existingProject) {
|
||||
throw new Error(`Project not found: ${args.projectId}`)
|
||||
}
|
||||
|
||||
const result =
|
||||
parsedHost.kind === 'local'
|
||||
? await addLocalRepoFromPath(store, args.path, args.kind)
|
||||
@@ -1400,15 +1426,26 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
if ('error' in result) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
let aligned: ProjectHostSetupResult
|
||||
try {
|
||||
aligned = alignRepoWithRequestedProject(
|
||||
store,
|
||||
result.repo,
|
||||
args.projectId,
|
||||
args.setupMethod,
|
||||
args.projectProviderIdentity
|
||||
)
|
||||
} catch (err) {
|
||||
// Why: an import that cannot be linked must not leave a new repo registration or authorization root behind.
|
||||
if (!result.alreadyExisted) {
|
||||
store.removeProject(result.repo.id)
|
||||
invalidateAuthorizedRootsCache()
|
||||
}
|
||||
throw err
|
||||
}
|
||||
invalidateAuthorizedRootsCache()
|
||||
notifyReposChanged(mainWindow)
|
||||
emitRepoAdded('folder_picker', result.alreadyExisted)
|
||||
const aligned = alignRepoWithRequestedProject(
|
||||
store,
|
||||
result.repo,
|
||||
args.projectId,
|
||||
args.setupMethod
|
||||
)
|
||||
if (result.alreadyExisted) {
|
||||
await prepareLocalWorktreeRootForRepo(store, aligned.repo)
|
||||
}
|
||||
@@ -2036,6 +2073,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
_event,
|
||||
args: {
|
||||
repoId: string
|
||||
hostId?: ExecutionHostId
|
||||
updates: Partial<
|
||||
Pick<
|
||||
Repo,
|
||||
@@ -2177,7 +2215,13 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
updates.sourceControlAi = normalizedSourceControlAi
|
||||
}
|
||||
}
|
||||
const updated = store.updateRepo(args.repoId, updates)
|
||||
const hostId = args.hostId ? normalizeExecutionHostId(args.hostId) : null
|
||||
if (args.hostId && !hostId) {
|
||||
return null
|
||||
}
|
||||
const updated = hostId
|
||||
? store.updateRepo(args.repoId, updates, hostId)
|
||||
: store.updateRepo(args.repoId, updates)
|
||||
if (updated) {
|
||||
if ('worktreeBasePath' in updates) {
|
||||
void prepareLocalWorktreeRootForRepo(store, updated)
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import {
|
||||
addEnvironmentFromPairingCode,
|
||||
listEnvironments,
|
||||
removeEnvironment,
|
||||
resolveEnvironment
|
||||
} from '../../shared/runtime-environment-store'
|
||||
import {
|
||||
redactRuntimeEnvironment,
|
||||
type PublicKnownRuntimeEnvironment
|
||||
} from '../../shared/runtime-environments'
|
||||
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
|
||||
import type { RuntimeStatus } from '../../shared/runtime-types'
|
||||
import type { Store } from '../persistence'
|
||||
import { closeRemoteRuntimeRequestConnection } from './runtime-environment-request-connections'
|
||||
import {
|
||||
callRuntimeEnvironment,
|
||||
clearSharedControlSupport,
|
||||
getRuntimeEnvironmentStatus
|
||||
} from './runtime-environment-transport-routing'
|
||||
|
||||
const manuallyDisconnectedEnvironmentIds = new Set<string>()
|
||||
|
||||
function manuallyDisconnectedResponse(
|
||||
environment: ReturnType<typeof resolveEnvironment>
|
||||
): RuntimeRpcResponse<never> {
|
||||
return {
|
||||
id: 'runtime.manualDisconnect',
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'runtime_manually_disconnected',
|
||||
message: 'Runtime environment is manually disconnected.'
|
||||
},
|
||||
_meta: { runtimeId: environment.runtimeId }
|
||||
}
|
||||
}
|
||||
|
||||
export function isRuntimeEnvironmentManuallyDisconnected(environmentId: string): boolean {
|
||||
return manuallyDisconnectedEnvironmentIds.has(environmentId)
|
||||
}
|
||||
|
||||
type ConnectivityHandlerOptions = {
|
||||
store: Store
|
||||
getUserDataPath: () => string
|
||||
invalidateTransport: (environmentId: string) => void
|
||||
}
|
||||
|
||||
export function registerRuntimeEnvironmentConnectivityHandlers({
|
||||
store,
|
||||
getUserDataPath,
|
||||
invalidateTransport
|
||||
}: ConnectivityHandlerOptions): void {
|
||||
ipcMain.handle('runtimeEnvironments:list', () =>
|
||||
listEnvironments(getUserDataPath()).map(redactRuntimeEnvironment)
|
||||
)
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:addFromPairingCode',
|
||||
(
|
||||
_event,
|
||||
args: { name: string; pairingCode: string }
|
||||
): { environment: PublicKnownRuntimeEnvironment } => {
|
||||
const environment = addEnvironmentFromPairingCode(getUserDataPath(), args)
|
||||
manuallyDisconnectedEnvironmentIds.delete(environment.id)
|
||||
return { environment: redactRuntimeEnvironment(environment) }
|
||||
}
|
||||
)
|
||||
ipcMain.handle('runtimeEnvironments:resolve', (_event, args: { selector: string }) =>
|
||||
redactRuntimeEnvironment(resolveEnvironment(getUserDataPath(), args.selector))
|
||||
)
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:remove',
|
||||
(_event, args: { selector: string }): { removed: PublicKnownRuntimeEnvironment } => {
|
||||
const environment = resolveEnvironment(getUserDataPath(), args.selector)
|
||||
if (store.getSettings().activeRuntimeEnvironmentId === environment.id) {
|
||||
throw new Error('Choose another Active Server in Advanced before removing this server.')
|
||||
}
|
||||
const removed = removeEnvironment(getUserDataPath(), args.selector)
|
||||
manuallyDisconnectedEnvironmentIds.delete(removed.id)
|
||||
invalidateTransport(removed.id)
|
||||
closeLegacySelectorTransport(args.selector, removed.id)
|
||||
return { removed: redactRuntimeEnvironment(removed) }
|
||||
}
|
||||
)
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:disconnect',
|
||||
(_event, args: { selector: string }): { disconnected: PublicKnownRuntimeEnvironment } => {
|
||||
const environment = resolveEnvironment(getUserDataPath(), args.selector)
|
||||
manuallyDisconnectedEnvironmentIds.add(environment.id)
|
||||
invalidateTransport(environment.id)
|
||||
closeLegacySelectorTransport(args.selector, environment.id)
|
||||
return { disconnected: redactRuntimeEnvironment(environment) }
|
||||
}
|
||||
)
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:connect',
|
||||
async (
|
||||
_event,
|
||||
args: { selector: string; timeoutMs?: number }
|
||||
): Promise<RuntimeRpcResponse<RuntimeStatus>> => {
|
||||
const environment = resolveEnvironment(getUserDataPath(), args.selector)
|
||||
manuallyDisconnectedEnvironmentIds.delete(environment.id)
|
||||
return getRuntimeEnvironmentStatus(getUserDataPath(), environment.id, args.timeoutMs)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function registerRuntimeEnvironmentPassiveHandlers(getUserDataPath: () => string): void {
|
||||
registerPassiveStatusHandler(getUserDataPath)
|
||||
registerPassiveCallHandler(getUserDataPath)
|
||||
}
|
||||
|
||||
function closeLegacySelectorTransport(selector: string, environmentId: string): void {
|
||||
if (selector === environmentId) {
|
||||
return
|
||||
}
|
||||
closeRemoteRuntimeRequestConnection(selector)
|
||||
clearSharedControlSupport(selector)
|
||||
}
|
||||
|
||||
function registerPassiveStatusHandler(getUserDataPath: () => string): void {
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:getStatus',
|
||||
async (
|
||||
_event,
|
||||
args: { selector: string; timeoutMs?: number }
|
||||
): Promise<RuntimeRpcResponse<RuntimeStatus>> => {
|
||||
const environment = resolveEnvironment(getUserDataPath(), args.selector)
|
||||
if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) {
|
||||
return manuallyDisconnectedResponse(environment)
|
||||
}
|
||||
const response = await getRuntimeEnvironmentStatus(
|
||||
getUserDataPath(),
|
||||
environment.id,
|
||||
args.timeoutMs
|
||||
)
|
||||
return isRuntimeEnvironmentManuallyDisconnected(environment.id)
|
||||
? manuallyDisconnectedResponse(environment)
|
||||
: response
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function registerPassiveCallHandler(getUserDataPath: () => string): void {
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:call',
|
||||
async (
|
||||
_event,
|
||||
args: {
|
||||
selector: string
|
||||
method: string
|
||||
params?: unknown
|
||||
timeoutMs?: number
|
||||
expectedEnvironmentPairingRevision?: number
|
||||
}
|
||||
): Promise<RuntimeRpcResponse<unknown>> => {
|
||||
const environment = resolveEnvironment(getUserDataPath(), args.selector)
|
||||
if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) {
|
||||
return manuallyDisconnectedResponse(environment)
|
||||
}
|
||||
const response = await callRuntimeEnvironment(
|
||||
getUserDataPath(),
|
||||
environment.id,
|
||||
args.method,
|
||||
args.params,
|
||||
args.timeoutMs,
|
||||
args.expectedEnvironmentPairingRevision
|
||||
)
|
||||
return isRuntimeEnvironmentManuallyDisconnected(environment.id)
|
||||
? manuallyDisconnectedResponse(environment)
|
||||
: response
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -135,6 +135,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
'runtimeEnvironments:resolve',
|
||||
'runtimeEnvironments:remove',
|
||||
'runtimeEnvironments:disconnect',
|
||||
'runtimeEnvironments:connect',
|
||||
'runtimeEnvironments:retryConnectionsNow',
|
||||
'runtimeEnvironments:getStatus',
|
||||
'runtimeEnvironments:call',
|
||||
@@ -155,6 +156,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
'runtimeEnvironments:resolve',
|
||||
'runtimeEnvironments:remove',
|
||||
'runtimeEnvironments:disconnect',
|
||||
'runtimeEnvironments:connect',
|
||||
'runtimeEnvironments:getStatus',
|
||||
'runtimeEnvironments:call',
|
||||
'runtimeEnvironments:subscribe',
|
||||
@@ -230,6 +232,12 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
|
||||
it('disconnects a saved runtime without removing it', async () => {
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
sendRemoteRuntimeRequestMock.mockResolvedValue({
|
||||
id: 'status',
|
||||
ok: true,
|
||||
result: { runtimeId: 'runtime-remote' },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
|
||||
const add = handler<
|
||||
{ name: string; pairingCode: string },
|
||||
@@ -250,6 +258,39 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
|
||||
const list = handler<undefined, { id: string; name: string }[]>('runtimeEnvironments:list')
|
||||
expect(await list(null, undefined)).toMatchObject([{ id: added.environment.id, name: 'desk' }])
|
||||
|
||||
const getStatus = handler<{ selector: string }, { ok: boolean; error?: { code: string } }>(
|
||||
'runtimeEnvironments:getStatus'
|
||||
)
|
||||
await expect(getStatus(null, { selector: 'desk' })).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'runtime_manually_disconnected' }
|
||||
})
|
||||
const call = handler<
|
||||
{ selector: string; method: string },
|
||||
{ ok: boolean; error?: { code: string } }
|
||||
>('runtimeEnvironments:call')
|
||||
await expect(call(null, { selector: 'desk', method: 'repo.list' })).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'runtime_manually_disconnected' }
|
||||
})
|
||||
const subscribe = handler<{ selector: string; method: string }, { subscriptionId: string }>(
|
||||
'runtimeEnvironments:subscribe'
|
||||
)
|
||||
await expect(
|
||||
subscribe(null, { selector: 'desk', method: 'terminal.multiplex' })
|
||||
).rejects.toThrow('runtime_manually_disconnected')
|
||||
expect(sendRemoteRuntimeRequestMock).not.toHaveBeenCalled()
|
||||
expect(subscribeRemoteRuntimeRequestMock).not.toHaveBeenCalled()
|
||||
|
||||
const connect = handler<{ selector: string }, { ok: boolean; result?: { runtimeId: string } }>(
|
||||
'runtimeEnvironments:connect'
|
||||
)
|
||||
await expect(connect(null, { selector: 'desk' })).resolves.toMatchObject({
|
||||
ok: true,
|
||||
result: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('marks environments owned by ephemeral VM runtimes in the public list', async () => {
|
||||
@@ -1155,9 +1196,12 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
{ disconnected: { id: string; name: string } }
|
||||
>('runtimeEnvironments:disconnect')
|
||||
await disconnect(null, { selector: 'desk' })
|
||||
const connect = handler<{ selector: string }, { ok: boolean }>('runtimeEnvironments:connect')
|
||||
await connect(null, { selector: 'desk' })
|
||||
await call(null, { selector: 'desk', method: 'repo.list' })
|
||||
|
||||
expect(sendRemoteRuntimeRequestMock.mock.calls.map((call) => call[1])).toEqual([
|
||||
'status.get',
|
||||
'status.get',
|
||||
'status.get'
|
||||
])
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import { app, ipcMain } from 'electron'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
addEnvironmentFromPairingCode,
|
||||
listEnvironments,
|
||||
removeEnvironment,
|
||||
resolveEnvironment
|
||||
} from '../../shared/runtime-environment-store'
|
||||
import {
|
||||
redactRuntimeEnvironment,
|
||||
type PublicKnownRuntimeEnvironment
|
||||
} from '../../shared/runtime-environments'
|
||||
import type { RuntimeStatus } from '../../shared/runtime-types'
|
||||
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
|
||||
import { resolveEnvironment } from '../../shared/runtime-environment-store'
|
||||
import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client'
|
||||
import type { Store } from '../persistence'
|
||||
import {
|
||||
isRuntimeEnvironmentManuallyDisconnected,
|
||||
registerRuntimeEnvironmentConnectivityHandlers,
|
||||
registerRuntimeEnvironmentPassiveHandlers
|
||||
} from './runtime-environment-connectivity-handlers'
|
||||
import { closeRemoteRuntimeRequestConnection } from './runtime-environment-request-connections'
|
||||
import { registerRuntimeEnvironmentRecoveryHandler } from './runtime-environment-recovery-handler'
|
||||
import {
|
||||
@@ -21,9 +15,7 @@ import {
|
||||
getRuntimeEnvironmentTransportGeneration
|
||||
} from './runtime-environment-transport-generation'
|
||||
import {
|
||||
callRuntimeEnvironment,
|
||||
clearSharedControlSupport,
|
||||
getRuntimeEnvironmentStatus,
|
||||
resetSharedControlSupport,
|
||||
subscribeRuntimeEnvironment
|
||||
} from './runtime-environment-transport-routing'
|
||||
@@ -34,6 +26,7 @@ const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [
|
||||
'runtimeEnvironments:resolve',
|
||||
'runtimeEnvironments:remove',
|
||||
'runtimeEnvironments:disconnect',
|
||||
'runtimeEnvironments:connect',
|
||||
'runtimeEnvironments:getStatus',
|
||||
'runtimeEnvironments:call',
|
||||
'runtimeEnvironments:subscribe',
|
||||
@@ -66,11 +59,6 @@ export function invalidateRuntimeEnvironmentTransport(environmentId: string): vo
|
||||
closeSubscriptionsForEnvironment(environmentId)
|
||||
}
|
||||
|
||||
function listPublicRuntimeEnvironments(): PublicKnownRuntimeEnvironment[] {
|
||||
// Why: a corrupt VM store must not break persisted environment listing.
|
||||
return listEnvironments(getUserDataPath()).map(redactRuntimeEnvironment)
|
||||
}
|
||||
|
||||
export function registerRuntimeEnvironmentHandlers(store: Store): void {
|
||||
// Why: keep direct re-registration safe even though register-core-handlers
|
||||
// normally guards this path; otherwise the binary send listener can stack.
|
||||
@@ -80,81 +68,13 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
|
||||
}
|
||||
ipcMain.removeAllListeners('runtimeEnvironments:subscriptionBinary')
|
||||
|
||||
ipcMain.handle('runtimeEnvironments:list', listPublicRuntimeEnvironments)
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:addFromPairingCode',
|
||||
(
|
||||
_event,
|
||||
args: { name: string; pairingCode: string }
|
||||
): { environment: PublicKnownRuntimeEnvironment } => ({
|
||||
environment: redactRuntimeEnvironment(addEnvironmentFromPairingCode(getUserDataPath(), args))
|
||||
})
|
||||
)
|
||||
ipcMain.handle('runtimeEnvironments:resolve', (_event, args: { selector: string }) =>
|
||||
redactRuntimeEnvironment(resolveEnvironment(getUserDataPath(), args.selector))
|
||||
)
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:remove',
|
||||
(_event, args: { selector: string }): { removed: PublicKnownRuntimeEnvironment } => {
|
||||
const environment = resolveEnvironment(getUserDataPath(), args.selector)
|
||||
if (store.getSettings().activeRuntimeEnvironmentId === environment.id) {
|
||||
throw new Error('Choose another Active Server in Advanced before removing this server.')
|
||||
}
|
||||
const removed = removeEnvironment(getUserDataPath(), args.selector)
|
||||
invalidateRuntimeEnvironmentTransport(removed.id)
|
||||
if (args.selector !== removed.id) {
|
||||
closeRemoteRuntimeRequestConnection(args.selector)
|
||||
clearSharedControlSupport(args.selector)
|
||||
}
|
||||
return { removed: redactRuntimeEnvironment(removed) }
|
||||
}
|
||||
)
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:disconnect',
|
||||
(_event, args: { selector: string }): { disconnected: PublicKnownRuntimeEnvironment } => {
|
||||
const environment = resolveEnvironment(getUserDataPath(), args.selector)
|
||||
// Why: disconnect is intentionally non-destructive; it drops live
|
||||
// transport state while keeping the paired server available for later.
|
||||
invalidateRuntimeEnvironmentTransport(environment.id)
|
||||
if (args.selector !== environment.id) {
|
||||
closeRemoteRuntimeRequestConnection(args.selector)
|
||||
clearSharedControlSupport(args.selector)
|
||||
}
|
||||
return { disconnected: redactRuntimeEnvironment(environment) }
|
||||
}
|
||||
)
|
||||
registerRuntimeEnvironmentConnectivityHandlers({
|
||||
store,
|
||||
getUserDataPath,
|
||||
invalidateTransport: invalidateRuntimeEnvironmentTransport
|
||||
})
|
||||
registerRuntimeEnvironmentRecoveryHandler()
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:getStatus',
|
||||
async (
|
||||
_event,
|
||||
args: { selector: string; timeoutMs?: number }
|
||||
): Promise<RuntimeRpcResponse<RuntimeStatus>> => {
|
||||
return getRuntimeEnvironmentStatus(getUserDataPath(), args.selector, args.timeoutMs)
|
||||
}
|
||||
)
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:call',
|
||||
async (
|
||||
_event,
|
||||
args: {
|
||||
selector: string
|
||||
method: string
|
||||
params?: unknown
|
||||
timeoutMs?: number
|
||||
expectedEnvironmentPairingRevision?: number
|
||||
}
|
||||
): Promise<RuntimeRpcResponse<unknown>> => {
|
||||
return callRuntimeEnvironment(
|
||||
getUserDataPath(),
|
||||
args.selector,
|
||||
args.method,
|
||||
args.params,
|
||||
args.timeoutMs,
|
||||
args.expectedEnvironmentPairingRevision
|
||||
)
|
||||
}
|
||||
)
|
||||
registerRuntimeEnvironmentPassiveHandlers(getUserDataPath)
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:subscribe',
|
||||
async (
|
||||
@@ -176,6 +96,9 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
|
||||
throw new Error('Runtime environment subscription id already exists')
|
||||
}
|
||||
const environment = resolveEnvironment(getUserDataPath(), args.selector)
|
||||
if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) {
|
||||
throw new Error('runtime_manually_disconnected')
|
||||
}
|
||||
const pairingRevision = environment.pairingRevision ?? environment.createdAt
|
||||
if (
|
||||
args.expectedEnvironmentPairingRevision !== undefined &&
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Why: worktree deletion has to drain in-flight watcher installs/unsubscribes before Git removes the
|
||||
// tree, and a wedged native subscribe can leave those promises unsettled forever. Independent per-await
|
||||
// timeouts would compose (two close passes x two drains), so one removal shares one absolute deadline.
|
||||
|
||||
export const WATCHER_REMOVAL_DRAIN_BUDGET_MS = 60_000
|
||||
// Why: the final live unsubscribe is the drain that actually releases the native handle. Earlier drains
|
||||
// leave it this slice so a slow-but-finishing unsubscribe on Windows/WSL isn't abandoned at ~0ms left.
|
||||
export const WATCHER_REMOVAL_FINAL_DRAIN_RESERVE_MS = 10_000
|
||||
|
||||
export type WatcherRemovalDeadline = {
|
||||
remainingMs(reserveMs?: number): number
|
||||
}
|
||||
|
||||
export function createWatcherRemovalDeadline(
|
||||
budgetMs: number = WATCHER_REMOVAL_DRAIN_BUDGET_MS
|
||||
): WatcherRemovalDeadline {
|
||||
const expiresAt = Date.now() + budgetMs
|
||||
return {
|
||||
remainingMs: (reserveMs = 0) => Math.max(0, expiresAt - Date.now() - reserveMs)
|
||||
}
|
||||
}
|
||||
|
||||
export type WatcherRemovalDrainOutcome = 'settled' | 'timeout' | 'skipped'
|
||||
|
||||
export type WatcherRemovalDrainOptions = {
|
||||
/** Budget this drain must leave behind for the removal's final unsubscribe. */
|
||||
reserveMs?: number
|
||||
}
|
||||
|
||||
/** Await `promise` until the removal deadline expires. Rejections still propagate so genuine
|
||||
* teardown failures keep failing the delete closed; only an unsettled wait is abandoned. */
|
||||
export async function drainBeforeWatcherRemoval(
|
||||
promise: Promise<unknown> | undefined,
|
||||
deadline: WatcherRemovalDeadline,
|
||||
label: string,
|
||||
options: WatcherRemovalDrainOptions = {}
|
||||
): Promise<WatcherRemovalDrainOutcome> {
|
||||
if (!promise) {
|
||||
return 'skipped'
|
||||
}
|
||||
const waitMs = deadline.remainingMs(options.reserveMs)
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
const settled = promise.then(() => 'settled' as const)
|
||||
const outcome = await Promise.race([
|
||||
settled,
|
||||
new Promise<'timeout'>((resolve) => {
|
||||
timer = setTimeout(() => resolve('timeout'), waitMs)
|
||||
})
|
||||
])
|
||||
if (outcome === 'timeout') {
|
||||
// Why: nobody awaits the abandoned promise anymore, so a late rejection (an aborted native
|
||||
// subscribe finally reporting) would be an unhandled rejection — fatal in the main process.
|
||||
void settled.catch(() => {})
|
||||
// Why log the budget split: production traces need "slow but finishing" separable from "wedged".
|
||||
console.warn(
|
||||
`[watcher-removal] Timed out waiting for ${label} after ${waitMs}ms ` +
|
||||
`(${deadline.remainingMs()}ms of the removal budget left); continuing removal`
|
||||
)
|
||||
}
|
||||
return outcome
|
||||
} finally {
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,47 @@ describe('watcher removal gate', () => {
|
||||
finishLaterInstall()
|
||||
})
|
||||
|
||||
it('drops abandoned install fences so a later removal is not fenced by a wedged install', async () => {
|
||||
beginWatcherInstall('/repo/nested')
|
||||
const removal = acquireWatcherRemovalGate('/repo')
|
||||
let ready = false
|
||||
void removal.ready.then(() => {
|
||||
ready = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(ready).toBe(false)
|
||||
|
||||
removal.abandonPendingInstalls()
|
||||
await removal.ready
|
||||
removal.release()
|
||||
|
||||
const retry = acquireWatcherRemovalGate('/repo')
|
||||
await retry.ready
|
||||
retry.release()
|
||||
})
|
||||
|
||||
it('keeps a fresh install fenced after an unrelated install was abandoned', async () => {
|
||||
const wedged = beginWatcherInstall('/repo')
|
||||
const removal = acquireWatcherRemovalGate('/repo')
|
||||
removal.abandonPendingInstalls()
|
||||
removal.release()
|
||||
// Why: a late finishInstall from the abandoned slot must not release a newer install's fence.
|
||||
const finishFresh = beginWatcherInstall('/repo')
|
||||
wedged()
|
||||
|
||||
const retry = acquireWatcherRemovalGate('/repo')
|
||||
let ready = false
|
||||
void retry.ready.then(() => {
|
||||
ready = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(ready).toBe(false)
|
||||
|
||||
finishFresh()
|
||||
await retry.ready
|
||||
retry.release()
|
||||
})
|
||||
|
||||
it('scopes identical roots to their execution host', async () => {
|
||||
const removal = acquireWatcherRemovalGate('/repo', 'ssh-a')
|
||||
await removal.ready
|
||||
|
||||
@@ -7,16 +7,25 @@ import {
|
||||
WATCHER_REMOVAL_IN_PROGRESS_MESSAGE
|
||||
} from '../../shared/worktree-removal-fence-error'
|
||||
|
||||
// Why: fence slots are identities, not a counter, so a removal that gives up waiting can drop exactly
|
||||
// the installs it waited on without a late finishInstall corrupting the count for a newer install.
|
||||
type WatcherInstallToken = symbol
|
||||
|
||||
type WatcherRemovalGateState = {
|
||||
key: string
|
||||
connectionId: string | null
|
||||
rootPath: string
|
||||
installCount: number
|
||||
installs: Set<WatcherInstallToken>
|
||||
removalCount: number
|
||||
installDrainWaiters: Set<() => void>
|
||||
}
|
||||
|
||||
export type WatcherRemovalGate = {
|
||||
ready: Promise<void>
|
||||
/** Drop the install fence slots this removal waited on. Call only after `ready` timed out: the
|
||||
* installs are presumed wedged, and leaving them counted makes every later removal of this root
|
||||
* wait out the full drain budget again. */
|
||||
abandonPendingInstalls(): void
|
||||
release(): void
|
||||
}
|
||||
|
||||
@@ -76,20 +85,11 @@ function beginRemovalSensitiveInstall(
|
||||
}
|
||||
const key = watcherRemovalGateKey(normalizedRoot, connectionId)
|
||||
const state = states.get(key) ?? createState(key, normalizedRoot, connectionId)
|
||||
state.installCount++
|
||||
let released = false
|
||||
const token: WatcherInstallToken = Symbol(normalizedRoot)
|
||||
state.installs.add(token)
|
||||
return () => {
|
||||
if (released) {
|
||||
return
|
||||
}
|
||||
released = true
|
||||
state.installCount--
|
||||
if (state.installCount === 0) {
|
||||
for (const resolve of state.installDrainWaiters) {
|
||||
resolve()
|
||||
}
|
||||
state.installDrainWaiters.clear()
|
||||
deleteIdleState(key, state)
|
||||
if (state.installs.delete(token) && state.installs.size === 0) {
|
||||
resolveInstallDrain(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,16 +117,30 @@ export function acquireWatcherRemovalGate(
|
||||
state.removalCount++
|
||||
// Why: deleting a parent root must wait for native installs already admitted
|
||||
// under that root, not only installs keyed to the exact same spelling.
|
||||
const drains = matchingHostStates(connectionId)
|
||||
const fenced = matchingHostStates(connectionId)
|
||||
.filter(
|
||||
(candidate) =>
|
||||
candidate.installCount > 0 && isPathInsideOrEqual(normalizedRoot, candidate.rootPath)
|
||||
candidate.installs.size > 0 && isPathInsideOrEqual(normalizedRoot, candidate.rootPath)
|
||||
)
|
||||
.map((candidate) => new Promise<void>((resolve) => candidate.installDrainWaiters.add(resolve)))
|
||||
.map((candidate) => ({ state: candidate, tokens: new Set(candidate.installs) }))
|
||||
const drains = fenced.map(
|
||||
({ state: candidate }) =>
|
||||
new Promise<void>((resolve) => candidate.installDrainWaiters.add(resolve))
|
||||
)
|
||||
const ready = drains.length === 0 ? Promise.resolve() : Promise.all(drains).then(() => undefined)
|
||||
let released = false
|
||||
return {
|
||||
ready,
|
||||
abandonPendingInstalls: () => {
|
||||
for (const { state: candidate, tokens } of fenced) {
|
||||
for (const token of tokens) {
|
||||
candidate.installs.delete(token)
|
||||
}
|
||||
if (candidate.installs.size === 0) {
|
||||
resolveInstallDrain(candidate)
|
||||
}
|
||||
}
|
||||
},
|
||||
release: () => {
|
||||
if (released) {
|
||||
return
|
||||
@@ -161,9 +175,10 @@ function createState(
|
||||
connectionId?: string
|
||||
): WatcherRemovalGateState {
|
||||
const state = {
|
||||
key,
|
||||
connectionId: connectionId ?? null,
|
||||
rootPath,
|
||||
installCount: 0,
|
||||
installs: new Set<WatcherInstallToken>(),
|
||||
removalCount: 0,
|
||||
installDrainWaiters: new Set<() => void>()
|
||||
}
|
||||
@@ -171,8 +186,16 @@ function createState(
|
||||
return state
|
||||
}
|
||||
|
||||
function resolveInstallDrain(state: WatcherRemovalGateState): void {
|
||||
for (const resolve of state.installDrainWaiters) {
|
||||
resolve()
|
||||
}
|
||||
state.installDrainWaiters.clear()
|
||||
deleteIdleState(state.key, state)
|
||||
}
|
||||
|
||||
function deleteIdleState(key: string, state: WatcherRemovalGateState): void {
|
||||
if (state.installCount === 0 && state.removalCount === 0 && states.get(key) === state) {
|
||||
if (state.installs.size === 0 && state.removalCount === 0 && states.get(key) === state) {
|
||||
states.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
assertBranchPrefixValid,
|
||||
getBranchPrefixIssue,
|
||||
normalizeBranchPrefix,
|
||||
selectBranchPrefixInput,
|
||||
type BranchPrefixSettings
|
||||
@@ -49,6 +50,13 @@ export function computeValidatedBranchName(
|
||||
if (prefix === null) {
|
||||
return sanitizedName
|
||||
}
|
||||
assertBranchPrefixValid(prefix)
|
||||
if (getBranchPrefixIssue(prefix) !== null) {
|
||||
// Why: git-username can resolve to garbage (e.g. a rate-limited `gh api` JSON body).
|
||||
// Skip the prefix rather than blocking every worktree create; custom prefixes still fail loudly.
|
||||
if (settings.branchPrefix === 'git-username') {
|
||||
return sanitizedName
|
||||
}
|
||||
assertBranchPrefixValid(prefix)
|
||||
}
|
||||
return `${prefix}/${sanitizedName}`
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ type LinkedWorkItemMetadata = Pick<
|
||||
| 'linkedBitbucketPR'
|
||||
| 'linkedAzureDevOpsPR'
|
||||
| 'linkedGiteaPR'
|
||||
| 'linkedWorkItem'
|
||||
| 'linkedTaskSourceContext'
|
||||
>
|
||||
|
||||
export function getLinkedWorkItemMetadata(meta: WorktreeMeta | undefined): LinkedWorkItemMetadata {
|
||||
@@ -15,6 +17,8 @@ export function getLinkedWorkItemMetadata(meta: WorktreeMeta | undefined): Linke
|
||||
linkedGitLabIssue: meta?.linkedGitLabIssue ?? null,
|
||||
linkedBitbucketPR: meta?.linkedBitbucketPR ?? null,
|
||||
linkedAzureDevOpsPR: meta?.linkedAzureDevOpsPR ?? null,
|
||||
linkedGiteaPR: meta?.linkedGiteaPR ?? null
|
||||
linkedGiteaPR: meta?.linkedGiteaPR ?? null,
|
||||
linkedWorkItem: meta?.linkedWorkItem ?? null,
|
||||
linkedTaskSourceContext: meta?.linkedTaskSourceContext ?? null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,6 +245,16 @@ describe('computeValidatedBranchName', () => {
|
||||
)
|
||||
).toThrow('contains characters git rejects')
|
||||
})
|
||||
|
||||
it('skips an invalid git-username prefix instead of blocking create', () => {
|
||||
expect(
|
||||
computeValidatedBranchName(
|
||||
'feature',
|
||||
{ branchPrefix: 'git-username' },
|
||||
'{\n"message": "API rate limit exceeded"}'
|
||||
)
|
||||
).toBe('feature')
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeWorktreePath', () => {
|
||||
@@ -499,6 +509,8 @@ describe('mergeWorktree', () => {
|
||||
linkedBitbucketPR: null,
|
||||
linkedAzureDevOpsPR: null,
|
||||
linkedGiteaPR: null,
|
||||
linkedWorkItem: null,
|
||||
linkedTaskSourceContext: null,
|
||||
mobileDiffReview: undefined,
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'ssh:openclaw-2',
|
||||
|
||||
@@ -302,6 +302,7 @@ async function spawnLocalStartupAndSetupTerminals(args: {
|
||||
env: sequencedStartup.env,
|
||||
...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}),
|
||||
...(isTuiAgent(createdWithAgent) ? { launchAgent: createdWithAgent } : {}),
|
||||
...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}),
|
||||
startupCommandDelivery: sequencedStartup.startupCommandDelivery,
|
||||
telemetry: sequencedStartup.telemetry,
|
||||
activate: true
|
||||
@@ -1869,6 +1870,10 @@ export async function createRemoteWorktree(
|
||||
? { linkedAzureDevOpsPR: args.linkedAzureDevOpsPR }
|
||||
: {}),
|
||||
...(args.linkedGiteaPR !== undefined ? { linkedGiteaPR: args.linkedGiteaPR } : {}),
|
||||
...(args.linkedWorkItem !== undefined ? { linkedWorkItem: args.linkedWorkItem } : {}),
|
||||
...(args.linkedTaskSourceContext !== undefined
|
||||
? { linkedTaskSourceContext: args.linkedTaskSourceContext }
|
||||
: {}),
|
||||
...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {})
|
||||
}
|
||||
const { worktree } = timing.timeSync('persist_metadata', () => {
|
||||
@@ -2452,6 +2457,10 @@ export async function createLocalWorktree(
|
||||
? { linkedAzureDevOpsPR: args.linkedAzureDevOpsPR }
|
||||
: {}),
|
||||
...(args.linkedGiteaPR !== undefined ? { linkedGiteaPR: args.linkedGiteaPR } : {}),
|
||||
...(args.linkedWorkItem !== undefined ? { linkedWorkItem: args.linkedWorkItem } : {}),
|
||||
...(args.linkedTaskSourceContext !== undefined
|
||||
? { linkedTaskSourceContext: args.linkedTaskSourceContext }
|
||||
: {}),
|
||||
...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {})
|
||||
}
|
||||
const { worktree } = timing.timeSync('persist_metadata', () => {
|
||||
|
||||
@@ -122,7 +122,7 @@ vi.mock('./pty', () => ({
|
||||
getLocalPtyProvider: getLocalPtyProviderMock
|
||||
}))
|
||||
|
||||
vi.mock('../terminal-history', () => ({
|
||||
vi.mock('../terminal-history-deletion', () => ({
|
||||
deleteWorktreeHistoryDir: deleteWorktreeHistoryDirMock
|
||||
}))
|
||||
|
||||
@@ -428,6 +428,10 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
|
||||
})
|
||||
)
|
||||
expect(store.removeWorktreeMeta).toHaveBeenCalledWith('repo-1::C:/workspaces/improve-dashboard')
|
||||
// Windows history lives under a path-derived hash, so scheduling must not regress on this path only.
|
||||
expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(
|
||||
'repo-1::C:/workspaces/improve-dashboard'
|
||||
)
|
||||
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
|
||||
repoId: 'repo-1'
|
||||
})
|
||||
|
||||
@@ -217,7 +217,7 @@ const { deleteWorktreeHistoryDirMock } = vi.hoisted(() => ({
|
||||
deleteWorktreeHistoryDirMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../terminal-history', () => ({
|
||||
vi.mock('../terminal-history-deletion', () => ({
|
||||
deleteWorktreeHistoryDir: deleteWorktreeHistoryDirMock
|
||||
}))
|
||||
|
||||
@@ -258,6 +258,8 @@ import {
|
||||
notifyWorktreesChanged
|
||||
} from './worktree-remote'
|
||||
import { invalidateAuthorizedRootsCache, resolveRegisteredWorktreePath } from './filesystem-auth'
|
||||
import { _resetTracerForTests, setActiveSink } from '../observability/tracer'
|
||||
import type { RedactableSpan } from '../observability/redactor'
|
||||
import {
|
||||
reviewHeadRemoteRefComponent,
|
||||
REVIEW_HEAD_FETCH_TIMEOUT_MS
|
||||
@@ -1136,6 +1138,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
startup: {
|
||||
command: 'claude --prefill test',
|
||||
env: { ORCA_AGENT_MODE: 'direct' },
|
||||
viewMode: 'chat',
|
||||
telemetry: {
|
||||
agent_kind: 'claude',
|
||||
launch_source: 'new_workspace_composer',
|
||||
@@ -1156,6 +1159,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
command: 'claude --prefill test',
|
||||
env: { ORCA_AGENT_MODE: 'direct' },
|
||||
launchAgent: 'claude',
|
||||
viewMode: 'chat',
|
||||
startupCommandDelivery: undefined,
|
||||
telemetry: {
|
||||
agent_kind: 'claude',
|
||||
@@ -7858,6 +7862,73 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('traces the removal as worktree.remove with a stage sub-span tree', async () => {
|
||||
const records: RedactableSpan[] = []
|
||||
setActiveSink({
|
||||
push: (record) => records.push(record as RedactableSpan),
|
||||
flush: () => {},
|
||||
close: () => {}
|
||||
})
|
||||
try {
|
||||
mockKnownFeatureWorktree()
|
||||
getEffectiveHooksMock.mockReturnValue(null)
|
||||
removeWorktreeMock.mockResolvedValue({})
|
||||
|
||||
await handlers['worktrees:remove'](null, { worktreeId: 'repo-1::/workspace/feature-wt' })
|
||||
|
||||
const parent = records.find((record) => record.name === 'worktree.remove')
|
||||
expect(parent).toBeDefined()
|
||||
expect(parent?.attributes).toMatchObject({
|
||||
kind: 'worktree',
|
||||
'worktree.stage': 'remove',
|
||||
'worktree.path': '/workspace/feature-wt'
|
||||
})
|
||||
const stages = records.filter((record) => record.name.startsWith('worktree.remove.'))
|
||||
expect(stages.map((record) => record.name)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'worktree.remove.watcher_gate',
|
||||
'worktree.remove.pty_sweep',
|
||||
'worktree.remove.git_remove',
|
||||
'worktree.remove.metadata_purge',
|
||||
'worktree.remove.cache_invalidation'
|
||||
])
|
||||
)
|
||||
// Stages must hang off the removal span, not float as roots, or a freeze can't be attributed.
|
||||
for (const stage of stages) {
|
||||
expect(stage.parentSpanId).toBe(parent?.spanId)
|
||||
expect(stage.attributes).toMatchObject({ kind: 'worktree', 'worktree.flow': 'local' })
|
||||
}
|
||||
} finally {
|
||||
_resetTracerForTests()
|
||||
}
|
||||
})
|
||||
|
||||
it('traces a local archive hook as flow local, not remote', async () => {
|
||||
const records: RedactableSpan[] = []
|
||||
setActiveSink({
|
||||
push: (record) => records.push(record as RedactableSpan),
|
||||
flush: () => {},
|
||||
close: () => {}
|
||||
})
|
||||
try {
|
||||
mockKnownFeatureWorktree()
|
||||
// The archive hook block is shared by both flows, so a local repo must not land under 'remote'.
|
||||
getEffectiveHooksMock.mockReturnValue({ scripts: { archive: 'pnpm worktree:archive' } })
|
||||
runHookMock.mockResolvedValue({ success: true, output: '' })
|
||||
removeWorktreeMock.mockResolvedValue({})
|
||||
|
||||
await handlers['worktrees:remove'](null, { worktreeId: 'repo-1::/workspace/feature-wt' })
|
||||
|
||||
const archiveStage = records.find((record) => record.name === 'worktree.remove.archive_hook')
|
||||
expect(archiveStage?.attributes).toMatchObject({
|
||||
kind: 'worktree',
|
||||
'worktree.flow': 'local'
|
||||
})
|
||||
} finally {
|
||||
_resetTracerForTests()
|
||||
}
|
||||
})
|
||||
|
||||
it('prunes git worktree tracking when removing an orphaned worktree', async () => {
|
||||
mockKnownFeatureWorktree()
|
||||
const orphanError = Object.assign(new Error('git worktree remove failed'), {
|
||||
@@ -8174,6 +8245,13 @@ describe('registerWorktreeHandlers', () => {
|
||||
expect(removeWorktreeLinkedPathsMock).toHaveBeenCalledWith('/workspace/feature-wt', [
|
||||
'node_modules'
|
||||
])
|
||||
// Why order matters: linked-path deletion is destructive, so PTYs must release every handle
|
||||
// before Windows or WSL filesystem cleanup starts (mirrors the runtime removal path).
|
||||
expect(killAllProcessesForWorktreeMock).toHaveBeenCalled()
|
||||
// Latest PTY sweep vs earliest deletion: a later sweep would mean handles were still open.
|
||||
expect(Math.max(...killAllProcessesForWorktreeMock.mock.invocationCallOrder)).toBeLessThan(
|
||||
Math.min(...removeWorktreeLinkedPathsMock.mock.invocationCallOrder)
|
||||
)
|
||||
})
|
||||
|
||||
it('does not remove a worktree when watcher teardown cannot release it', async () => {
|
||||
@@ -8812,6 +8890,42 @@ describe('registerWorktreeHandlers', () => {
|
||||
expect(hasHooksFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('inspects setup-script imports on the requested host when repo ids collide', async () => {
|
||||
const localRepo = {
|
||||
id: 'repo-shared',
|
||||
path: '/local/repo',
|
||||
displayName: 'local',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0
|
||||
}
|
||||
const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' }
|
||||
const fsProvider = {
|
||||
readFile: vi.fn(async (filePath: string) => {
|
||||
if (filePath === '/remote/repo/.superset/config.json') {
|
||||
return { content: '{"setup":"remote setup"}', isBinary: false }
|
||||
}
|
||||
throw Object.assign(new Error('missing'), { code: 'ENOENT' })
|
||||
}),
|
||||
stat: vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
|
||||
}
|
||||
store.getRepos.mockReturnValue([localRepo, sshRepo])
|
||||
store.getRepo.mockReturnValue(localRepo)
|
||||
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
|
||||
|
||||
await expect(
|
||||
handlers['hooks:inspectSetupScriptImports'](null, {
|
||||
repoId: 'repo-shared',
|
||||
hostId: 'ssh:conn-1'
|
||||
})
|
||||
).resolves.toContainEqual(
|
||||
expect.objectContaining({
|
||||
provider: 'superset',
|
||||
setup: 'remote setup'
|
||||
})
|
||||
)
|
||||
expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/.superset/config.json')
|
||||
})
|
||||
|
||||
it('does not coalesce forget requests for the same id on different hosts', async () => {
|
||||
const localRepo = {
|
||||
id: 'repo-shared',
|
||||
|
||||
+592
-493
File diff suppressed because it is too large
Load Diff
@@ -201,6 +201,23 @@ describe('Jira client credential storage', () => {
|
||||
expect(userAgent).not.toMatch(/Mozilla|Chrome|Safari|AppleWebKit/i)
|
||||
})
|
||||
|
||||
it('removes cancelled queued reads so the request pool recovers', async () => {
|
||||
const jira = await loadClientModule()
|
||||
await Promise.all([jira.acquire(), jira.acquire(), jira.acquire(), jira.acquire()])
|
||||
const controller = new AbortController()
|
||||
const queued = jira.acquire(controller.signal)
|
||||
|
||||
controller.abort()
|
||||
|
||||
await expect(queued).rejects.toMatchObject({ name: 'AbortError' })
|
||||
jira.release()
|
||||
await expect(jira.acquire()).resolves.toBeUndefined()
|
||||
jira.release()
|
||||
jira.release()
|
||||
jira.release()
|
||||
jira.release()
|
||||
})
|
||||
|
||||
it('downloads same-origin attachment URLs without forwarding auth cross-origin', async () => {
|
||||
const jira = await loadClientModule({ encryptionAvailable: true })
|
||||
const client = {
|
||||
|
||||
+44
-11
@@ -32,26 +32,59 @@ const JIRA_API_USER_AGENT = 'Orca'
|
||||
|
||||
const MAX_CONCURRENT = 4
|
||||
let running = 0
|
||||
const queue: (() => void)[] = []
|
||||
type QueuedJiraRequest = {
|
||||
resolve: () => void
|
||||
reject: (error: Error) => void
|
||||
signal?: AbortSignal
|
||||
onAbort: () => void
|
||||
}
|
||||
const queue: QueuedJiraRequest[] = []
|
||||
|
||||
export function acquire(): Promise<void> {
|
||||
function createJiraRequestAbortError(): Error {
|
||||
const error = new Error('Jira request aborted')
|
||||
error.name = 'AbortError'
|
||||
return error
|
||||
}
|
||||
|
||||
export function acquire(signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted) {
|
||||
return Promise.reject(createJiraRequestAbortError())
|
||||
}
|
||||
if (running < MAX_CONCURRENT) {
|
||||
running += 1
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise((resolve) =>
|
||||
queue.push(() => {
|
||||
running += 1
|
||||
resolve()
|
||||
})
|
||||
)
|
||||
return new Promise((resolve, reject) => {
|
||||
const entry: QueuedJiraRequest = {
|
||||
resolve,
|
||||
reject,
|
||||
signal,
|
||||
onAbort: () => {
|
||||
const index = queue.indexOf(entry)
|
||||
if (index < 0) {
|
||||
return
|
||||
}
|
||||
queue.splice(index, 1)
|
||||
reject(createJiraRequestAbortError())
|
||||
}
|
||||
}
|
||||
signal?.addEventListener('abort', entry.onAbort, { once: true })
|
||||
queue.push(entry)
|
||||
})
|
||||
}
|
||||
|
||||
export function release(): void {
|
||||
running -= 1
|
||||
const next = queue.shift()
|
||||
if (next) {
|
||||
next()
|
||||
let next = queue.shift()
|
||||
while (next) {
|
||||
next.signal?.removeEventListener('abort', next.onAbort)
|
||||
if (!next.signal?.aborted) {
|
||||
running += 1
|
||||
next.resolve()
|
||||
return
|
||||
}
|
||||
next.reject(createJiraRequestAbortError())
|
||||
next = queue.shift()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { JiraClientForSite } from './client'
|
||||
import { credentialDecryptionMessage } from '../../shared/integration-credential-errors'
|
||||
import { getJiraSummaryLookupErrorCode } from '../../shared/jira-summary-lookup'
|
||||
|
||||
const {
|
||||
clearTokenMock,
|
||||
@@ -115,6 +116,62 @@ describe('Jira issue operations', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('loads Jira summaries without descriptions, rendered fields, or attachment media', async () => {
|
||||
jiraRequestMock.mockResolvedValueOnce({
|
||||
id: 'issue-1',
|
||||
key: 'ALP-1',
|
||||
fields: {
|
||||
summary: 'Lightweight lookup',
|
||||
project: { id: '10000', key: 'ALP', name: 'Alpha' },
|
||||
issuetype: { id: '10001', name: 'Bug' },
|
||||
status: { id: '1', name: 'To Do', statusCategory: { key: 'new', name: 'To Do' } },
|
||||
labels: [],
|
||||
created: '2026-07-27T00:00:00.000Z',
|
||||
updated: '2026-07-28T11:22:33.000Z'
|
||||
}
|
||||
})
|
||||
const { getIssueSummary } = await import('./issues')
|
||||
await expect(getIssueSummary('ALP-1', 'site-1')).resolves.toMatchObject({
|
||||
key: 'ALP-1',
|
||||
title: 'Lightweight lookup',
|
||||
siteId: 'site-1',
|
||||
createdAt: '2026-07-27T00:00:00.000Z',
|
||||
updatedAt: '2026-07-28T11:22:33.000Z'
|
||||
})
|
||||
const requestPath = String(jiraRequestMock.mock.calls[0]?.[1])
|
||||
const query = new URL(requestPath, 'https://example.atlassian.net').searchParams
|
||||
const fields = query.get('fields')?.split(',') ?? []
|
||||
expect(fields).toEqual(['summary', 'project', 'issuetype', 'status', 'created', 'updated'])
|
||||
expect(query.has('expand')).toBe(false)
|
||||
expect(jiraRequestBinaryMock).not.toHaveBeenCalled()
|
||||
expect(clearTokenMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns typed summary errors without clearing Jira credentials', async () => {
|
||||
const authError = Object.assign(new Error('Unauthorized'), { status: 401 })
|
||||
isAuthErrorMock.mockImplementation((error) => error === authError)
|
||||
jiraRequestMock.mockRejectedValueOnce(authError)
|
||||
const { getIssueSummary } = await import('./issues')
|
||||
const read = getIssueSummary('ALP-1', 'site-1')
|
||||
await expect(read).rejects.toSatisfy(
|
||||
(error: unknown) => getJiraSummaryLookupErrorCode(error) === 'auth'
|
||||
)
|
||||
jiraRequestMock.mockRejectedValueOnce(Object.assign(new Error('Missing'), { status: 404 }))
|
||||
await expect(getIssueSummary('ALP-404', 'site-1')).rejects.toSatisfy(
|
||||
(error: unknown) => getJiraSummaryLookupErrorCode(error) === 'not-found'
|
||||
)
|
||||
expect(clearTokenMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports an explicit disconnected summary scope before issuing a request', async () => {
|
||||
getClientsMock.mockReturnValueOnce([])
|
||||
const { getIssueSummary } = await import('./issues')
|
||||
await expect(getIssueSummary('ALP-1', 'site-1')).rejects.toSatisfy(
|
||||
(error: unknown) => getJiraSummaryLookupErrorCode(error) === 'disconnected'
|
||||
)
|
||||
expect(jiraRequestMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends plain-text bodies and v2 paths for self-hosted issue creation', async () => {
|
||||
getClientsMock.mockReturnValue([makeServerEntry()])
|
||||
jiraRequestMock.mockResolvedValueOnce({ id: '1', key: 'ALP-1', self: '' })
|
||||
|
||||
+130
-23
@@ -48,6 +48,7 @@ import {
|
||||
loadIssueImageAttachments,
|
||||
type MediaResolutionStats
|
||||
} from './attachment-images'
|
||||
import { JiraSummaryLookupError } from '../../shared/jira-summary-lookup'
|
||||
|
||||
const ISSUE_FIELDS = [
|
||||
'summary',
|
||||
@@ -63,9 +64,17 @@ const ISSUE_FIELDS = [
|
||||
'updated'
|
||||
]
|
||||
|
||||
// Why: list/typeahead only need identity + metadata; description ADF parse is the hot-path cost.
|
||||
const ISSUE_LIST_FIELDS = ISSUE_FIELDS.filter((field) => field !== 'description')
|
||||
|
||||
// Why: detail reads need attachment metadata so inline ADF media can be resolved
|
||||
// to downloadable image content; list/search omit this for payload size.
|
||||
const ISSUE_DETAIL_FIELDS = [...ISSUE_FIELDS, 'attachment']
|
||||
// `created`/`updated` are required: mapJiraIssue falls back to "now" when they're absent,
|
||||
// which would silently report the lookup time as the issue's timestamps.
|
||||
const ISSUE_SUMMARY_FIELDS = ['summary', 'project', 'issuetype', 'status', 'created', 'updated']
|
||||
const ISSUE_SUMMARY_TIMEOUT_MS = 30_000
|
||||
const ISSUE_SEARCH_TIMEOUT_MS = 30_000
|
||||
|
||||
type JiraRecord = Record<string, unknown>
|
||||
|
||||
@@ -95,6 +104,51 @@ type JiraIssueSearchFailure = {
|
||||
auth: boolean
|
||||
}
|
||||
|
||||
/** Run against one signal that trips on the caller's abort or the request deadline. */
|
||||
async function withJiraDeadline<T>(
|
||||
signal: AbortSignal | undefined,
|
||||
timeoutMs: number,
|
||||
run: (deadlineSignal: AbortSignal) => Promise<T>
|
||||
): Promise<T> {
|
||||
const controller = new AbortController()
|
||||
const abort = (): void => controller.abort()
|
||||
signal?.addEventListener('abort', abort, { once: true })
|
||||
if (signal?.aborted) {
|
||||
controller.abort()
|
||||
}
|
||||
const timer = setTimeout(abort, timeoutMs)
|
||||
try {
|
||||
return await run(controller.signal)
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
signal?.removeEventListener('abort', abort)
|
||||
}
|
||||
}
|
||||
|
||||
function settleJiraSummaryRead<T>(read: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
if (signal.aborted) {
|
||||
return Promise.reject(new Error('Jira summary lookup aborted'))
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const handleAbort = (): void => {
|
||||
cleanup()
|
||||
reject(new Error('Jira summary lookup aborted'))
|
||||
}
|
||||
const cleanup = (): void => signal.removeEventListener('abort', handleAbort)
|
||||
signal.addEventListener('abort', handleAbort, { once: true })
|
||||
void read.then(
|
||||
(value) => {
|
||||
cleanup()
|
||||
resolve(value)
|
||||
},
|
||||
(error: unknown) => {
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function getErrorStatus(error: unknown): number | null {
|
||||
if (!error || typeof error !== 'object' || !('status' in error)) {
|
||||
return null
|
||||
@@ -474,7 +528,8 @@ function filterToJql(filter: JiraIssueFilter): string {
|
||||
async function searchIssuesForClient(
|
||||
entry: JiraClientForSite,
|
||||
jql: string,
|
||||
limit: number
|
||||
limit: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<JiraIssue[]> {
|
||||
// Server/DC only has the classic /search resource; /search/jql is Cloud-only.
|
||||
const searchPath =
|
||||
@@ -486,8 +541,9 @@ async function searchIssuesForClient(
|
||||
body: JSON.stringify({
|
||||
jql,
|
||||
maxResults: limit,
|
||||
fields: ISSUE_FIELDS
|
||||
})
|
||||
fields: ISSUE_LIST_FIELDS
|
||||
}),
|
||||
signal
|
||||
})
|
||||
return (result.issues ?? []).map((issue) => mapJiraIssue(entry.site, issue))
|
||||
}
|
||||
@@ -503,7 +559,8 @@ export async function listIssues(
|
||||
export async function searchIssues(
|
||||
jql: string,
|
||||
limit = 30,
|
||||
siteId?: JiraSiteSelection | null
|
||||
siteId?: JiraSiteSelection | null,
|
||||
signal?: AbortSignal
|
||||
): Promise<JiraIssue[]> {
|
||||
const entries = getClients(siteId)
|
||||
if (entries.length === 0 || !jql.trim()) {
|
||||
@@ -512,26 +569,33 @@ export async function searchIssues(
|
||||
const safeLimit = clampLimit(limit)
|
||||
const failures: (JiraIssueSearchFailure | undefined)[] = Array.from({ length: entries.length })
|
||||
const surfaceSiteFailure = shouldSurfaceSiteFailure(siteId, entries.length)
|
||||
const results = await Promise.all(
|
||||
entries.map(async (entry, index) => {
|
||||
await acquire()
|
||||
try {
|
||||
return await searchIssuesForClient(entry, jql.trim(), safeLimit)
|
||||
} catch (error) {
|
||||
const authFailure = isAuthError(error)
|
||||
if (authFailure) {
|
||||
clearToken(entry.site.id)
|
||||
const results = await withJiraDeadline(signal, ISSUE_SEARCH_TIMEOUT_MS, (requestSignal) =>
|
||||
Promise.all(
|
||||
entries.map(async (entry, index) => {
|
||||
// Why: queueing on an abandoned search would keep occupying the shared Jira pool.
|
||||
await acquire(requestSignal)
|
||||
try {
|
||||
return await searchIssuesForClient(entry, jql.trim(), safeLimit, requestSignal)
|
||||
} catch (error) {
|
||||
if (requestSignal.aborted) {
|
||||
// Abandoned by the caller: not a site failure, so don't clear tokens or mask a real one.
|
||||
throw error
|
||||
}
|
||||
const authFailure = isAuthError(error)
|
||||
if (authFailure) {
|
||||
clearToken(entry.site.id)
|
||||
}
|
||||
if (surfaceSiteFailure) {
|
||||
throw toIssueSearchFailureError(error)
|
||||
}
|
||||
console.warn('[jira] searchIssues failed:', error)
|
||||
failures[index] = { error: toIssueSearchFailureError(error), auth: authFailure }
|
||||
return [] as JiraIssue[]
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
if (surfaceSiteFailure) {
|
||||
throw toIssueSearchFailureError(error)
|
||||
}
|
||||
console.warn('[jira] searchIssues failed:', error)
|
||||
failures[index] = { error: toIssueSearchFailureError(error), auth: authFailure }
|
||||
return [] as JiraIssue[]
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
})
|
||||
})
|
||||
)
|
||||
)
|
||||
// 'all' fan-out: only surface an error when every connected site failed, so a
|
||||
// partial success (or a genuinely empty result) is not reported as an error.
|
||||
@@ -603,6 +667,49 @@ export async function getIssue(
|
||||
return null
|
||||
}
|
||||
|
||||
export async function getIssueSummary(
|
||||
key: string,
|
||||
siteId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<JiraIssue | null> {
|
||||
let entries: JiraClientForSite[]
|
||||
try {
|
||||
entries = getClients(siteId)
|
||||
} catch (error) {
|
||||
throw new JiraSummaryLookupError('auth', error)
|
||||
}
|
||||
const entry = entries.find((candidate) => candidate.site.id === siteId)
|
||||
if (!entry) {
|
||||
throw new JiraSummaryLookupError('disconnected')
|
||||
}
|
||||
|
||||
return withJiraDeadline(signal, ISSUE_SUMMARY_TIMEOUT_MS, async (requestSignal) => {
|
||||
await acquire(requestSignal)
|
||||
try {
|
||||
const params = new URLSearchParams({ fields: ISSUE_SUMMARY_FIELDS.join(',') })
|
||||
const issue = await settleJiraSummaryRead(
|
||||
jiraRequest<JiraRecord>(
|
||||
entry,
|
||||
`${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}?${params.toString()}`,
|
||||
{ signal: requestSignal }
|
||||
),
|
||||
requestSignal
|
||||
)
|
||||
return mapJiraIssue(entry.site, issue)
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
throw new JiraSummaryLookupError('auth', error)
|
||||
}
|
||||
if (getErrorStatus(error) === 404) {
|
||||
throw new JiraSummaryLookupError('not-found', error)
|
||||
}
|
||||
throw new JiraSummaryLookupError('read-failed', error)
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function createIssue(args: JiraCreateIssueArgs): Promise<JiraCreateIssueResult> {
|
||||
const entry = getClients(args.siteId)[0]
|
||||
if (!entry) {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { JiraClientForSite } from './client'
|
||||
import { getJiraSummaryLookupErrorCode } from '../../shared/jira-summary-lookup'
|
||||
|
||||
const { acquireMock, getClientsMock, jiraRequestMock, releaseMock } = vi.hoisted(() => ({
|
||||
acquireMock: vi.fn().mockResolvedValue(undefined),
|
||||
getClientsMock: vi.fn(),
|
||||
jiraRequestMock: vi.fn(),
|
||||
releaseMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
acquire: (...args: unknown[]) => acquireMock(...args),
|
||||
release: (...args: unknown[]) => releaseMock(...args),
|
||||
apiBasePath: () => '/rest/api/3',
|
||||
clearToken: vi.fn(),
|
||||
getClients: (...args: unknown[]) => getClientsMock(...args),
|
||||
isAuthError: vi.fn().mockReturnValue(false),
|
||||
jiraRequest: (...args: unknown[]) => jiraRequestMock(...args),
|
||||
jiraRequestBinary: vi.fn(),
|
||||
JiraApiError: class JiraApiError extends Error {}
|
||||
}))
|
||||
|
||||
function makeEntry(): JiraClientForSite {
|
||||
return {
|
||||
site: {
|
||||
id: 'site-1',
|
||||
siteUrl: 'https://example.atlassian.net',
|
||||
email: 'ada@example.com',
|
||||
displayName: 'Example Jira',
|
||||
accountId: 'account-1'
|
||||
},
|
||||
authorization: 'Basic token'
|
||||
}
|
||||
}
|
||||
|
||||
describe('Jira issue summary timeout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
getClientsMock.mockReturnValue([makeEntry()])
|
||||
acquireMock.mockResolvedValue(undefined)
|
||||
jiraRequestMock.mockImplementation(() => new Promise(() => {}))
|
||||
})
|
||||
|
||||
it('bounds stalled reads and releases their request slot', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { getIssueSummary } = await import('./issues')
|
||||
const read = getIssueSummary('ALP-1', 'site-1')
|
||||
const rejected = expect(read).rejects.toSatisfy(
|
||||
(error: unknown) => getJiraSummaryLookupErrorCode(error) === 'read-failed'
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
|
||||
await rejected
|
||||
expect(acquireMock).toHaveBeenCalledWith(expect.any(AbortSignal))
|
||||
expect(releaseMock).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { JiraClientForSite } from './client'
|
||||
|
||||
const { acquireMock, clearTokenMock, getClientsMock, isAuthErrorMock, jiraRequestMock } =
|
||||
vi.hoisted(() => ({
|
||||
acquireMock: vi.fn().mockResolvedValue(undefined),
|
||||
clearTokenMock: vi.fn(),
|
||||
getClientsMock: vi.fn(),
|
||||
isAuthErrorMock: vi.fn().mockReturnValue(false),
|
||||
jiraRequestMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
acquire: (...args: unknown[]) => acquireMock(...args),
|
||||
release: vi.fn(),
|
||||
apiBasePath: () => '/rest/api/3',
|
||||
clearToken: (...args: unknown[]) => clearTokenMock(...args),
|
||||
getClients: (...args: unknown[]) => getClientsMock(...args),
|
||||
isAuthError: (...args: unknown[]) => isAuthErrorMock(...args),
|
||||
jiraRequest: (...args: unknown[]) => jiraRequestMock(...args),
|
||||
jiraRequestBinary: vi.fn(),
|
||||
JiraApiError: class JiraApiError extends Error {}
|
||||
}))
|
||||
|
||||
function makeEntry(id: string): JiraClientForSite {
|
||||
return {
|
||||
site: {
|
||||
id,
|
||||
siteUrl: 'https://example.atlassian.net',
|
||||
email: 'ada@example.com',
|
||||
displayName: 'Example Jira',
|
||||
accountId: 'account-1'
|
||||
},
|
||||
authorization: 'Basic token'
|
||||
}
|
||||
}
|
||||
|
||||
describe('Jira search abort', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
isAuthErrorMock.mockReturnValue(false)
|
||||
acquireMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('threads the abort signal through the request pool to every site', async () => {
|
||||
const controller = new AbortController()
|
||||
getClientsMock.mockReturnValue([makeEntry('site-1'), makeEntry('site-2')])
|
||||
jiraRequestMock.mockResolvedValue({ issues: [] })
|
||||
const { searchIssues } = await import('./issues')
|
||||
|
||||
await searchIssues('project = ALP', 20, 'all', controller.signal)
|
||||
|
||||
expect(acquireMock).toHaveBeenCalledTimes(2)
|
||||
expect(acquireMock.mock.calls[0]?.[0]).toBeInstanceOf(AbortSignal)
|
||||
for (const call of jiraRequestMock.mock.calls) {
|
||||
expect(call[2]?.signal).toBeInstanceOf(AbortSignal)
|
||||
expect(JSON.parse(call[2].body).fields).not.toContain('description')
|
||||
}
|
||||
})
|
||||
|
||||
it('does not clear tokens when an abandoned search is aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
isAuthErrorMock.mockReturnValue(true)
|
||||
getClientsMock.mockReturnValue([makeEntry('site-1')])
|
||||
jiraRequestMock.mockRejectedValueOnce(new Error('The operation was aborted'))
|
||||
const { searchIssues } = await import('./issues')
|
||||
|
||||
await expect(searchIssues('project = ALP', 20, 'site-1', controller.signal)).rejects.toThrow(
|
||||
'aborted'
|
||||
)
|
||||
expect(clearTokenMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,16 +1,16 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import type { RmOptions } from 'node:fs'
|
||||
import { lstat, readFile, rm } from 'node:fs/promises'
|
||||
import { win32 } from 'node:path'
|
||||
import { setTimeout as delay } from 'node:timers/promises'
|
||||
import { lstat, readFile } from 'node:fs/promises'
|
||||
import {
|
||||
buildWslLoginShellCommand,
|
||||
escapeWslShCommandForWindows,
|
||||
quotePosixShell
|
||||
} from '../shared/wsl-login-shell-command'
|
||||
import { removeHostTree } from './host-tree-removal'
|
||||
import { toLinuxPath } from './wsl'
|
||||
import type { ReadPath, StatPath } from './worktree-orphan-gitdir-proof'
|
||||
|
||||
export { toHostRemovalPath } from './host-tree-removal'
|
||||
|
||||
export type LocalWorktreeFilesystemOptions = {
|
||||
wslDistro?: string
|
||||
}
|
||||
@@ -26,9 +26,6 @@ type ExecFileTextResult = {
|
||||
}
|
||||
|
||||
const WSL_FILE_OPERATION_TIMEOUT_MS = 30_000
|
||||
const WINDOWS_REMOVE_RETRY_DELAYS_MS = [250, 500, 1_000, 2_000]
|
||||
const WINDOWS_RM_MAX_RETRIES = 8
|
||||
const WINDOWS_RM_RETRY_DELAY_MS = 150
|
||||
|
||||
function shouldUseWslFilesystem(options: LocalWorktreeFilesystemOptions): boolean {
|
||||
return process.platform === 'win32' && !!options.wslDistro?.trim()
|
||||
@@ -132,7 +129,7 @@ export async function removeLocalWorktreePath(
|
||||
): Promise<void> {
|
||||
const distro = options.wslDistro?.trim()
|
||||
if (!shouldUseWslFilesystem(options) || !distro) {
|
||||
await removeHostWorktreePath(targetPath)
|
||||
await removeHostTree(targetPath)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -140,57 +137,3 @@ export async function removeLocalWorktreePath(
|
||||
// Windows cannot delete safely. Run the deletion inside the selected distro.
|
||||
await runWslLoginShellCommand(distro, `rm -rf -- ${quotePosixShell(toLinuxPath(targetPath))}`)
|
||||
}
|
||||
|
||||
async function removeHostWorktreePath(targetPath: string): Promise<void> {
|
||||
const removalPath = toHostRemovalPath(targetPath)
|
||||
const retryDelays = process.platform === 'win32' ? WINDOWS_REMOVE_RETRY_DELAYS_MS : []
|
||||
const rmOptions = getHostRemovalOptions()
|
||||
let attempt = 0
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
await rm(removalPath, rmOptions)
|
||||
return
|
||||
} catch (error) {
|
||||
if (attempt >= retryDelays.length || !isTransientWindowsRemovalError(error)) {
|
||||
throw error
|
||||
}
|
||||
// Why: Git/Node recursive deletes on Windows can observe a just-emptied
|
||||
// directory before antivirus/indexers/handles release it.
|
||||
await delay(retryDelays[attempt])
|
||||
attempt += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getHostRemovalOptions(): RmOptions {
|
||||
const base = { recursive: true, force: true }
|
||||
if (process.platform !== 'win32') {
|
||||
return base
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
// Why: large Windows dependency trees commonly surface transient
|
||||
// ENOTEMPTY/EPERM while Node walks and removes nested directories.
|
||||
maxRetries: WINDOWS_RM_MAX_RETRIES,
|
||||
retryDelay: WINDOWS_RM_RETRY_DELAY_MS
|
||||
}
|
||||
}
|
||||
|
||||
function isTransientWindowsRemovalError(error: unknown): boolean {
|
||||
if (process.platform !== 'win32' || typeof error !== 'object' || error === null) {
|
||||
return false
|
||||
}
|
||||
const code = 'code' in error && typeof error.code === 'string' ? error.code : undefined
|
||||
if (code && ['EBUSY', 'ENOTEMPTY', 'EPERM'].includes(code)) {
|
||||
return true
|
||||
}
|
||||
const message = 'message' in error && typeof error.message === 'string' ? error.message : ''
|
||||
return /directory not empty|resource busy|operation not permitted/i.test(message)
|
||||
}
|
||||
|
||||
export function toHostRemovalPath(targetPath: string): string {
|
||||
// Why: Git for Windows can fail long recursive deletes even after Orca has
|
||||
// proven the worktree target; Node's host deletion should use Win32 long paths.
|
||||
return process.platform === 'win32' ? win32.toNamespacedPath(targetPath) : targetPath
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user