mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 08:03:14 +00:00
Merge remote-tracking branch 'origin/main' into fix/remote-desktop-viewer-pty-width
# Conflicts: # src/main/runtime/rpc/methods/terminal.ts
This commit is contained in:
@@ -36,7 +36,7 @@
|
||||
|
||||
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.26](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
|
||||
Pair with your desktop app to monitor and steer your agents from your phone.
|
||||
|
||||
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [Download APK 0.0.26](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk)
|
||||
- **Android:** [Download APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2736,6 +2736,151 @@
|
||||
],
|
||||
"demotionRule": "Cannot promote if success is based only on absence of visible artifacts."
|
||||
},
|
||||
{
|
||||
"id": "terminal-query.mobile-view-authority",
|
||||
"title": "Mobile xterm answers live terminal queries exactly once without replay or floor side effects",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-runtime",
|
||||
"layer": "mobile-runtime-rpc-contract",
|
||||
"surfaces": [
|
||||
"mobile terminal WebView",
|
||||
"terminal query authority",
|
||||
"runtime RPC",
|
||||
"mobile subscription replay",
|
||||
"multi-mobile input floor"
|
||||
],
|
||||
"platforms": [
|
||||
"macos",
|
||||
"linux",
|
||||
"windows",
|
||||
"mobile"
|
||||
],
|
||||
"providers": [
|
||||
"local",
|
||||
"daemon",
|
||||
"ssh",
|
||||
"remote-runtime"
|
||||
],
|
||||
"coveredPlatforms": [
|
||||
"macos"
|
||||
],
|
||||
"coveredProviders": [],
|
||||
"coverageNotes": "Deterministic local tests execute the exact injected mobile replay/generation gate, the React Native query classifier, stale-subscription sender, server-side single-responder election, query-reply RPC semantics, and live-output capture during async mobile fit. The provider write path is shared, but no live iOS/Android, SSH, WSL, or multi-device run is registered yet.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/8128",
|
||||
"https://github.com/stablyai/orca/pull/8206"
|
||||
],
|
||||
"invariant": "Snapshot and replacement-terminal replay answer no terminal queries; every live query delivered to subscribed mobile views has at most one current responder; a live query queued behind replay is answered after the replay boundary; terminal-generated replies never transfer the user-input floor.",
|
||||
"oracle": "The injected gate emits zero replies during replay, exactly one after its live boundary, and ignores a superseded generation; native routing rejects ordinary input; the sender rejects disconnected or unsubscribed handles; runtime election accepts only the earliest active mobile subscriber and promotes its survivor; terminal.send writes only validated query grammar without mobileTookFloor; legacy binary subscribe captures a query while phone-fit is still pending and emits it after the snapshot.",
|
||||
"commands": [
|
||||
"pnpm --dir mobile exec vitest run --root .. mobile/src/terminal/terminal-webview-query-reply.test.ts mobile/src/terminal/terminal-webview-query-reply-routing.test.ts mobile/src/terminal/mobile-terminal-query-reply.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/mobile-presence-lock.test.ts src/shared/terminal-query-reply.test.ts src/shared/terminal-reply-query-scan.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"mobile/src/terminal/terminal-webview-query-reply.test.ts",
|
||||
"mobile/src/terminal/terminal-webview-query-reply-routing.test.ts",
|
||||
"mobile/src/terminal/mobile-terminal-query-reply.test.ts",
|
||||
"src/main/runtime/rpc/terminal-send.test.ts",
|
||||
"src/main/runtime/rpc/terminal-subscribe-buffer.test.ts",
|
||||
"src/main/runtime/mobile-presence-lock.test.ts",
|
||||
"src/shared/terminal-query-reply.test.ts",
|
||||
"src/shared/terminal-reply-query-scan.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-connection.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "mobile/src/terminal/terminal-webview-query-reply.test.ts",
|
||||
"assertions": [
|
||||
"snapshot replay emits no terminal-data message before the replay boundary",
|
||||
"a live query after the boundary emits exactly one terminal-data message",
|
||||
"a superseded terminal generation cannot regain reply authority",
|
||||
"xterm parser replies remain enabled while its DOM textarea and hardware-key path stay inert"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/terminal-send.test.ts",
|
||||
"assertions": [
|
||||
"only the elected mobile subscriber can write a claimed query reply",
|
||||
"valid replies skip mobileTookFloor",
|
||||
"ordinary bytes and reply payloads combined with enter, interrupt, or guarded-send semantics are rejected"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts",
|
||||
"assertions": [
|
||||
"live terminal data and view authority register before asynchronous mobile fit",
|
||||
"a query received during that await is retained and emitted after the snapshot"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts",
|
||||
"assertions": [
|
||||
"desktop xterm onData replies are dropped while mobile owns the terminal driver",
|
||||
"desktop capability-handler replies outside onData honor the same mobile authority lock"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-10",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm --dir mobile exec vitest run --root .. mobile/src/terminal/terminal-webview-query-reply.test.ts mobile/src/terminal/terminal-webview-query-reply-routing.test.ts mobile/src/terminal/mobile-terminal-query-reply.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 1,
|
||||
"summary": "10 mobile bridge, routing, replay/generation, and stale-subscription tests passed. Before implementation, the WebView listener, native route, and sender tests failed."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-10",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/mobile-presence-lock.test.ts src/shared/terminal-query-reply.test.ts src/shared/terminal-reply-query-scan.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 4,
|
||||
"summary": "69 shared/runtime tests passed. Before implementation, terminal.send took the floor for claimed replies and accepted ordinary claimed bytes."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-10",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 11,
|
||||
"summary": "421 renderer terminal connection tests passed, including desktop onData and capability-handler suppression while mobile owns query authority."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 20,
|
||||
"scope": "mobile and runtime deterministic unit gate"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "New deterministic gate; no soak history yet. Tests use no timing sleeps or external services."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "Observed red before implementation for the missing WebView listener/native route/sender and for RPC floor-taking plus unvalidated claims; all are green after the fix. The later multi-mobile and pre-fit race assertions have green deterministic coverage but no saved intentional-break artifact yet."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "No polling, scans, timers, or output fanout were added. The WebView adds one disposable onData listener; native/RPC work occurs only for xterm data and is grammar-filtered; responder election is O(1) from Map insertion order; pre-fit output uses the existing capped pending-output queue."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Attach an intentional-break artifact for the pre-fit capture and multi-mobile election assertions.",
|
||||
"Run live iOS and Android WebViews against a real querying TUI and verify no software-keyboard regression.",
|
||||
"Exercise two physical/virtual mobile clients on one PTY and observe one reply per query.",
|
||||
"Accumulate the normal soak window with zero unexplained flakes."
|
||||
],
|
||||
"knownGaps": [
|
||||
"No live iOS or Android WebView run is registered.",
|
||||
"No real SSH, WSL, daemon, or two-device provider-contract run is registered.",
|
||||
"Mixed-version pairing is gated by the terminal.query-reply-input.v1 capability (mobile drops replies unless the host advertises it), but no live old-binary pairing run is registered.",
|
||||
"A desktop-to-mobile driver handoff has a bounded double-reply window: the server elects the mobile responder synchronously while the desktop renderer's cached driver map updates via an async event, so an in-flight query can be answered by both until the driver-change event lands."
|
||||
],
|
||||
"demotionRule": "Keep experimental or disable mobile forwarding if the gate flakes without a product bug, live WebView input isolation regresses, or duplicate replies appear under multi-mobile use."
|
||||
},
|
||||
{
|
||||
"id": "terminal-runtime.mobile-stream-budget",
|
||||
"title": "Runtime and mobile terminal streams keep snapshot and live-output bytes bounded",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
|
||||
|
||||
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
|
||||
|
||||
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk)
|
||||
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
|
||||
|
||||
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk)
|
||||
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
|
||||
|
||||
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk)
|
||||
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar.
|
||||
|
||||
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.26](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
|
||||
Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular.
|
||||
|
||||
- **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [Baixar APK 0.0.26](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk)
|
||||
- **Android:** [Baixar APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
与桌面应用配对,用手机监控并指挥你的智能体。
|
||||
|
||||
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk)
|
||||
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -96,20 +96,40 @@ ingestion and an async write; the decision must not be re-read at reply time):
|
||||
stream (CLI `terminal.read`, automation observers) do not suppress — they
|
||||
also do not answer; that bounded no-reply case matches today's behavior.
|
||||
|
||||
Mobile streams preserve that singularity across snapshot startup and multiple
|
||||
views. The mobile WebView suppresses `onData` during snapshot replay, then
|
||||
enables replies at an explicit live-output boundary. Clearing the WebView drops
|
||||
that replay queue and resumes live reply authority immediately. If a live query
|
||||
arrived while the initial snapshot was being built and its output sequence is
|
||||
covered by that snapshot, runtime RPC re-emits only the bounded query sequence
|
||||
after the snapshot; ordinary covered output stays deduplicated. `terminal.send`
|
||||
accepts the resulting `inputKind: query-reply` only from the earliest active
|
||||
phone-fitted mobile subscriber while mobile owns the terminal driver. Earliest
|
||||
is determined by preserved `subscribedAt`, so a soft-leave resubscribe does not
|
||||
change reply ownership; passive desktop-mode watchers are excluded. Peer phones
|
||||
are rejected, the next subscriber is promoted on unsubscribe, and desktop
|
||||
parser and capability handlers stay silent until desktop retakes the driver.
|
||||
Mixed versions: hosts advertise `terminal.query-reply-input.v1`; a mobile
|
||||
client never forwards replies to a host that lacks it, because such hosts
|
||||
strip `inputKind` and would treat the bytes as floor-taking shell input.
|
||||
Residual: a desktop→mobile driver handoff has a bounded double-reply window
|
||||
until the async driver-change event reaches the desktop renderer's cached
|
||||
driver map (`isPtyLocked`).
|
||||
|
||||
Everything the emulator emits outside a forwarding window is discarded, which
|
||||
also swallows unsolicited core emissions (e.g. native 997 color-scheme pushes
|
||||
triggered by option mutations).
|
||||
|
||||
## Reply classes
|
||||
|
||||
| Class | Queries | Answer source |
|
||||
| --- | --- | --- |
|
||||
| Static | DA1 `CSI c` (ConPTY override below), DA2, DSR 5n, XTVERSION, DECRQM unknown → `0`, kitty `CSI ? u` | xterm core constants + kitty flag state |
|
||||
| Model-state | CPR `6n`/`?6n`, DECRPM mode table (?1 ?6 ?7 ?25 mouse ?1004 ?1006 ?1016 ?1049 ?2004 ?2026, insert), DECRQSS DECSTBM/DECSCA/SGR, kitty flags | emulator buffer/mode state — for a hidden pane it is the only state, hence authoritative |
|
||||
| View-attribute | OSC 4/10/11/12 `;?` queries, DSR ?996n | responder parser handlers + renderer attribute push (below); **silent until first push** |
|
||||
| View-attribute (via options) | DECRQSS DECSCUSR, DECRQM 12 | xterm core, from pushed `cursorStyle`/`cursorBlink` emulator options |
|
||||
| Silent | XTWINOPS, XTGETTCAP, ?15n/?25n/?26n/?53n | nobody, visible or hidden |
|
||||
| Mode 2031 | DECSET 2031 subscribe | unchanged in Phase 5: main emits the `2031-subscribe` fact, the renderer replies (`handleHiddenMode2031SubscribeFact`, `pty-connection.ts`; parked watcher fact callback). Emulator-native 2031/997 output is suppressed by the forwarding guard |
|
||||
| Class | Queries | Answer source |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Static | DA1 `CSI c` (ConPTY override below), DA2, DSR 5n, XTVERSION, DECRQM unknown → `0`, kitty `CSI ? u` | xterm core constants + kitty flag state |
|
||||
| Model-state | CPR `6n`/`?6n`, DECRPM mode table (?1 ?6 ?7 ?25 mouse ?1004 ?1006 ?1016 ?1049 ?2004 ?2026, insert), DECRQSS DECSTBM/DECSCA/SGR, kitty flags | emulator buffer/mode state — for a hidden pane it is the only state, hence authoritative |
|
||||
| View-attribute | OSC 4/10/11/12 `;?` queries, DSR ?996n | responder parser handlers + renderer attribute push (below); **silent until first push** |
|
||||
| View-attribute (via options) | DECRQSS DECSCUSR, DECRQM 12 | xterm core, from pushed `cursorStyle`/`cursorBlink` emulator options |
|
||||
| Silent | XTWINOPS, XTGETTCAP, ?15n/?25n/?26n/?53n | nobody, visible or hidden |
|
||||
| Mode 2031 | DECSET 2031 subscribe | unchanged in Phase 5: main emits the `2031-subscribe` fact, the renderer replies (`handleHiddenMode2031SubscribeFact`, `pty-connection.ts`; parked watcher fact callback). Emulator-native 2031/997 output is suppressed by the forwarding guard |
|
||||
|
||||
### View-attribute bridge
|
||||
|
||||
@@ -237,7 +257,8 @@ any spawn-window drops).
|
||||
the documented ConPTY DA1 variant and the view-attribute parser handlers
|
||||
the headless core cannot serve.
|
||||
6. Remote views keep view authority; main yields whenever a remote view
|
||||
subscriber is attached.
|
||||
subscriber is attached. When multiple desktop/mobile views coexist, the
|
||||
terminal driver and server-side mobile election keep one reply writer.
|
||||
|
||||
**Contract amendment** — `terminal-model-view-contract.md` invariant 6 is
|
||||
replaced by:
|
||||
|
||||
@@ -112,6 +112,8 @@ import {
|
||||
import { dismissTerminalKeyboard } from '../../../../src/terminal/terminal-keyboard-dismiss'
|
||||
import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender'
|
||||
import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response'
|
||||
import { sendMobileTerminalQueryReply } from '../../../../src/terminal/mobile-terminal-query-reply'
|
||||
import { TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY } from '../../../../../src/shared/protocol-version'
|
||||
import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit'
|
||||
import {
|
||||
getTerminalCommandKeyboardType,
|
||||
@@ -2424,10 +2426,13 @@ export default function SessionScreen() {
|
||||
terminalGestureInputInFlightRef.current.clear()
|
||||
}, [connState])
|
||||
|
||||
const hostQueryReplyInputSupportedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || connState !== 'connected') {
|
||||
setBrowserScreencastSupported(null)
|
||||
setAgentSessionHistorySupported(null)
|
||||
hostQueryReplyInputSupportedRef.current = false
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
@@ -2444,11 +2449,16 @@ export default function SessionScreen() {
|
||||
setAgentSessionHistorySupported(
|
||||
status.capabilities?.includes(MOBILE_AI_VAULT_CAPABILITY) === true
|
||||
)
|
||||
// Why: hosts without this capability strip inputKind from terminal.send,
|
||||
// so a forwarded xterm reply would become floor-stealing shell input.
|
||||
hostQueryReplyInputSupportedRef.current =
|
||||
status.capabilities?.includes(TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY) === true
|
||||
})
|
||||
.catch(() => {
|
||||
if (!stale) {
|
||||
setBrowserScreencastSupported(false)
|
||||
setAgentSessionHistorySupported(false)
|
||||
hostQueryReplyInputSupportedRef.current = false
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
@@ -3531,6 +3541,18 @@ export default function SessionScreen() {
|
||||
[allowTerminalGestureInput, client, connState, enqueueTerminalGestureInput]
|
||||
)
|
||||
|
||||
const handleTerminalQueryReply = useCallback((handle: string, bytes: string) => {
|
||||
void sendMobileTerminalQueryReply({
|
||||
bytes,
|
||||
client: clientRef.current,
|
||||
clientId: deviceTokenRef.current,
|
||||
connected: connStateRef.current === 'connected',
|
||||
handle,
|
||||
hostSupportsQueryReplyInput: hostQueryReplyInputSupportedRef.current,
|
||||
subscribedTerminals: terminalUnsubsRef.current
|
||||
})
|
||||
}, [])
|
||||
|
||||
async function handleClearTerminal(target: Terminal) {
|
||||
if (!client) {
|
||||
return
|
||||
@@ -4800,6 +4822,7 @@ export default function SessionScreen() {
|
||||
onKeyboardAvoidanceMetrics={handleKeyboardAvoidanceMetrics}
|
||||
onHaptic={handleHaptic}
|
||||
onTerminalInput={handleTerminalInput}
|
||||
onTerminalQueryReply={handleTerminalQueryReply}
|
||||
onTerminalTap={handleTerminalTap}
|
||||
onFileTap={handleFileTap}
|
||||
onOpenUrl={handleTerminalOpenUrl}
|
||||
|
||||
@@ -23,6 +23,7 @@ type TerminalPaneViewProps = {
|
||||
onKeyboardAvoidanceMetrics: (handle: string, metrics: TerminalKeyboardAvoidanceMetrics) => void
|
||||
onHaptic: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
|
||||
onTerminalInput: (handle: string, bytes: string) => void
|
||||
onTerminalQueryReply: (handle: string, bytes: string) => void
|
||||
onTerminalTap: (handle: string) => void
|
||||
onFileTap: (handle: string, pathText: string, line: number | null, column: number | null) => void
|
||||
onOpenUrl: (handle: string, url: string) => void
|
||||
@@ -44,6 +45,7 @@ export function TerminalPaneView({
|
||||
onKeyboardAvoidanceMetrics,
|
||||
onHaptic,
|
||||
onTerminalInput,
|
||||
onTerminalQueryReply,
|
||||
onTerminalTap,
|
||||
onFileTap,
|
||||
onOpenUrl,
|
||||
@@ -80,6 +82,7 @@ export function TerminalPaneView({
|
||||
onKeyboardAvoidanceMetrics={(m) => onKeyboardAvoidanceMetrics(handle, m)}
|
||||
onHaptic={onHaptic}
|
||||
onTerminalInput={(bytes) => onTerminalInput(handle, bytes)}
|
||||
onTerminalQueryReply={(bytes) => onTerminalQueryReply(handle, bytes)}
|
||||
onTerminalTap={() => onTerminalTap(handle)}
|
||||
onFileTap={(pathText, line, column) => onFileTap(handle, pathText, line, column)}
|
||||
onOpenUrl={(url) => onOpenUrl(handle, url)}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useRef, useCallback, forwardRef, useImperativeHandle, useEffect, useMemo } from 'react'
|
||||
import { Platform, View } from 'react-native'
|
||||
import { WebView } from 'react-native-webview'
|
||||
import type { WebViewMessageEvent } from 'react-native-webview'
|
||||
import { WebView, type WebViewMessageEvent } from 'react-native-webview'
|
||||
import type { TerminalOscLinkRange } from './terminal-osc-link-ranges'
|
||||
import type { TerminalWebViewHandle, TerminalWebViewProps } from './terminal-webview-contract'
|
||||
import {
|
||||
@@ -13,6 +12,7 @@ import { useTerminalWebReadyWatchdog } from './terminal-webview-ready-watchdog'
|
||||
import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html'
|
||||
import type { TerminalWebViewCommand } from './terminal-webview-messages'
|
||||
import { createTerminalWebViewPendingMessages } from './terminal-webview-pending-messages'
|
||||
import { routeTerminalQueryReply } from './terminal-webview-query-reply-routing'
|
||||
|
||||
type Props = TerminalWebViewProps
|
||||
|
||||
@@ -32,6 +32,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
onKeyboardAvoidanceMetrics,
|
||||
onHaptic,
|
||||
onTerminalInput,
|
||||
onTerminalQueryReply,
|
||||
onTerminalTap,
|
||||
onFileTap,
|
||||
onOpenUrl,
|
||||
@@ -115,6 +116,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
routeTerminalQueryReply(msg, onTerminalQueryReply)
|
||||
|
||||
if (msg.type === 'web-ready') {
|
||||
confirmWebReady(true)
|
||||
@@ -228,6 +230,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
onKeyboardAvoidanceMetrics,
|
||||
onHaptic,
|
||||
onTerminalInput,
|
||||
onTerminalQueryReply,
|
||||
onTerminalTap,
|
||||
onFileTap,
|
||||
onOpenUrl,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { sendMobileTerminalQueryReply } from './mobile-terminal-query-reply'
|
||||
|
||||
function createClient() {
|
||||
return {
|
||||
sendRequest: vi.fn().mockResolvedValue({
|
||||
id: '1',
|
||||
ok: true,
|
||||
result: { send: { accepted: true, bytesWritten: 6 } },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
} as unknown as Pick<RpcClient, 'sendRequest'>
|
||||
}
|
||||
|
||||
describe('sendMobileTerminalQueryReply', () => {
|
||||
it('sends a subscribed terminal reply immediately with additive RPC metadata', async () => {
|
||||
const client = createClient()
|
||||
|
||||
await expect(
|
||||
sendMobileTerminalQueryReply({
|
||||
bytes: '\x1b[3;4R',
|
||||
client,
|
||||
clientId: 'mobile-1',
|
||||
connected: true,
|
||||
handle: 'terminal-1',
|
||||
hostSupportsQueryReplyInput: true,
|
||||
subscribedTerminals: new Set(['terminal-1'])
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledWith('terminal.send', {
|
||||
terminal: 'terminal-1',
|
||||
text: '\x1b[3;4R',
|
||||
enter: false,
|
||||
inputKind: 'query-reply',
|
||||
client: { id: 'mobile-1', type: 'mobile' }
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['disconnected', false, new Set(['terminal-1']), '\x1b[3;4R', 'terminal-1'],
|
||||
['unsubscribed', true, new Set(), '\x1b[3;4R', 'terminal-1'],
|
||||
['stale handle', true, new Set(['terminal-2']), '\x1b[3;4R', 'terminal-1'],
|
||||
['ordinary input', true, new Set(['terminal-1']), 'a', 'terminal-1']
|
||||
])('does not send %s data', async (_case, connected, subscribedTerminals, bytes, handle) => {
|
||||
const client = createClient()
|
||||
|
||||
await expect(
|
||||
sendMobileTerminalQueryReply({
|
||||
bytes,
|
||||
client,
|
||||
clientId: 'mobile-1',
|
||||
connected,
|
||||
handle,
|
||||
hostSupportsQueryReplyInput: true,
|
||||
subscribedTerminals
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
expect(client.sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: hosts without terminal.query-reply-input.v1 strip inputKind (zod drops
|
||||
// unknown keys) and would treat the reply as floor-taking shell input.
|
||||
it('does not send to a host that has not advertised query-reply input support', async () => {
|
||||
const client = createClient()
|
||||
|
||||
await expect(
|
||||
sendMobileTerminalQueryReply({
|
||||
bytes: '\x1b[3;4R',
|
||||
client,
|
||||
clientId: 'mobile-1',
|
||||
connected: true,
|
||||
handle: 'terminal-1',
|
||||
hostSupportsQueryReplyInput: false,
|
||||
subscribedTerminals: new Set(['terminal-1'])
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
expect(client.sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { isTerminalQueryReply } from '../../../src/shared/terminal-query-reply'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { isTerminalSendRpcAccepted } from './terminal-send-rpc-response'
|
||||
|
||||
type TerminalSubscriptionRegistry = {
|
||||
has: (handle: string) => boolean
|
||||
}
|
||||
|
||||
type MobileTerminalQueryReplyOptions = {
|
||||
bytes: string
|
||||
client: Pick<RpcClient, 'sendRequest'> | null
|
||||
clientId: string | null
|
||||
connected: boolean
|
||||
handle: string
|
||||
hostSupportsQueryReplyInput: boolean
|
||||
subscribedTerminals: TerminalSubscriptionRegistry
|
||||
}
|
||||
|
||||
export function sendMobileTerminalQueryReply({
|
||||
bytes,
|
||||
client,
|
||||
clientId,
|
||||
connected,
|
||||
handle,
|
||||
hostSupportsQueryReplyInput,
|
||||
subscribedTerminals
|
||||
}: MobileTerminalQueryReplyOptions): Promise<boolean> {
|
||||
// Why: every subscribed mobile xterm suppresses main's responder, including
|
||||
// hidden panes, so ownership follows the subscription rather than focus.
|
||||
// Hosts without terminal.query-reply-input.v1 strip inputKind and would take
|
||||
// reply bytes as floor-stealing shell input, so drop (pre-fix behavior).
|
||||
if (
|
||||
!client ||
|
||||
!connected ||
|
||||
!hostSupportsQueryReplyInput ||
|
||||
!subscribedTerminals.has(handle) ||
|
||||
!isTerminalQueryReply(bytes)
|
||||
) {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
|
||||
return client
|
||||
.sendRequest('terminal.send', {
|
||||
terminal: handle,
|
||||
text: bytes,
|
||||
enter: false,
|
||||
inputKind: 'query-reply',
|
||||
...(clientId ? { client: { id: clientId, type: 'mobile' as const } } : {})
|
||||
})
|
||||
.then(isTerminalSendRpcAccepted, () => false)
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export type TerminalSelectionEvents = {
|
||||
onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void
|
||||
onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
|
||||
onTerminalInput?: (bytes: string) => void
|
||||
onTerminalQueryReply?: (bytes: string) => void
|
||||
onTerminalTap?: () => void
|
||||
// Tap landed on a detected file path; RN resolves + opens it.
|
||||
onFileTap?: (pathText: string, line: number | null, column: number | null) => void
|
||||
|
||||
@@ -8,6 +8,7 @@ import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.gen
|
||||
import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-injected'
|
||||
import { TERMINAL_TAP_DISPATCH_JS } from './terminal-webview-tap-dispatch-injected'
|
||||
import { TERMINAL_WEBVIEW_THEME_JS } from './terminal-webview-theme-injected'
|
||||
import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected'
|
||||
import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap'
|
||||
import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected'
|
||||
|
||||
@@ -215,7 +216,7 @@ window.onerror = function(msg) {
|
||||
var CLAUDE_STATUS_DOT_PATTERN = new RegExp(CLAUDE_STATUS_DOT + '[' + TEXT_PRESENTATION_SELECTOR + EMOJI_PRESENTATION_SELECTOR + ']*', 'g');
|
||||
var statusDotPendingSelector = false;
|
||||
var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096;
|
||||
var term = null;
|
||||
var term = null; ${TERMINAL_QUERY_REPLY_JS}
|
||||
var scrollIndicator = document.getElementById('scroll-indicator');
|
||||
var scrollThumb = document.getElementById('scroll-thumb');
|
||||
var scrollIndicatorHideTimer = null;
|
||||
@@ -599,6 +600,10 @@ ${TERMINAL_WEBVIEW_THEME_JS}
|
||||
writeQueue.push(normalizeStatusDotPresentation(data));
|
||||
}
|
||||
|
||||
function enqueueWriteBoundary(callback) {
|
||||
writeQueue.push(callback);
|
||||
}
|
||||
|
||||
function nextQueuedWrite() {
|
||||
if (writeQueueHead >= writeQueue.length) {
|
||||
resetWriteQueue();
|
||||
@@ -644,6 +649,7 @@ ${TERMINAL_WEBVIEW_THEME_JS}
|
||||
if (!ready || !term || writesDraining || gen !== terminalGeneration) return;
|
||||
var next = nextQueuedWrite();
|
||||
if (typeof next !== 'string') {
|
||||
if (typeof next === 'function') return next(), pumpWrites(gen);
|
||||
var callbacks = afterDrainCallbacks;
|
||||
afterDrainCallbacks = [];
|
||||
for (var i = 0; i < callbacks.length; i++) callbacks[i]();
|
||||
@@ -675,6 +681,9 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
|
||||
var scrollAnchorRows = prevB ? Math.max(0, (prevB.baseY || 0) - (prevB.viewportY || 0)) : -1;
|
||||
terminalGeneration++;
|
||||
var gen = terminalGeneration;
|
||||
// Why: snapshot replay can contain old queries whose replies must never
|
||||
// re-enter the live PTY. Each replacement terminal earns authority anew.
|
||||
resetTerminalDataReplyAuthority();
|
||||
cancelWebglContextRecovery();
|
||||
webglAddon = null;
|
||||
ready = false;
|
||||
@@ -731,7 +740,9 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
|
||||
fontWeight: '300',
|
||||
fontWeightBold: '500',
|
||||
scrollback: 5000,
|
||||
disableStdin: true,
|
||||
// Why: xterm suppresses parser-generated query replies when disableStdin
|
||||
// is true. Native accepts only validated reply grammars from onData.
|
||||
disableStdin: false,
|
||||
cursorBlink: false,
|
||||
cursorStyle: 'bar',
|
||||
cursorInactiveStyle: 'none',
|
||||
@@ -749,6 +760,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
|
||||
resetEvictionCounter();
|
||||
cancelSelect();
|
||||
attachTermObservers();
|
||||
attachTerminalQueryReplyBridge(term, gen);
|
||||
|
||||
requestAnimationFrame(function() {
|
||||
if (gen !== terminalGeneration) return;
|
||||
@@ -943,7 +955,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
|
||||
write(msg.data);
|
||||
} else if (msg.type === 'clear') {
|
||||
terminalGeneration++;
|
||||
resetWriteQueue();
|
||||
resetWriteQueue(); resumeTerminalDataReplyAuthority(); // Why: clear drops the replay boundary.
|
||||
statusDotPendingSelector = false;
|
||||
afterDrainCallbacks = [];
|
||||
writesDraining = false;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Kept as one injectable unit so tests execute the same replay/generation gate
|
||||
// that the WebView document runs, rather than a TypeScript reimplementation.
|
||||
export const TERMINAL_QUERY_REPLY_JS = `
|
||||
var terminalDataRepliesEnabled = false;
|
||||
|
||||
function resetTerminalDataReplyAuthority() {
|
||||
terminalDataRepliesEnabled = false;
|
||||
}
|
||||
|
||||
function resumeTerminalDataReplyAuthority() {
|
||||
terminalDataRepliesEnabled = true;
|
||||
}
|
||||
|
||||
function forwardTerminalDataReply(data) {
|
||||
if (terminalDataRepliesEnabled) notify({ type: 'terminal-data', bytes: data });
|
||||
}
|
||||
|
||||
function enqueueTerminalDataReplyBoundary(gen) {
|
||||
enqueueWriteBoundary(function() {
|
||||
if (gen === terminalGeneration) terminalDataRepliesEnabled = true;
|
||||
});
|
||||
}
|
||||
|
||||
function attachTerminalQueryReplyBridge(term, gen) {
|
||||
// Why: parser replies require stdin enabled, but mobile input is owned by
|
||||
// native controls. Keep xterm's textarea inert for touch/hardware keys.
|
||||
try {
|
||||
term.attachCustomKeyEventHandler(function() { return false; });
|
||||
if (term.textarea) {
|
||||
term.textarea.readOnly = true;
|
||||
term.textarea.tabIndex = -1;
|
||||
term.textarea.setAttribute('inputmode', 'none');
|
||||
}
|
||||
} catch (e) {}
|
||||
try {
|
||||
termObserverDisposables.push(term.onData(function(data) {
|
||||
forwardTerminalDataReply(data);
|
||||
}));
|
||||
} catch (e) {}
|
||||
// Why: live output can queue before initial replay finishes. Enable replies
|
||||
// at the replay boundary so those live queries are answered, never replayed ones.
|
||||
enqueueTerminalDataReplyBoundary(gen);
|
||||
}
|
||||
`
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TerminalWebView } from './TerminalWebView'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Platform: { OS: 'ios' },
|
||||
StyleSheet: {
|
||||
absoluteFillObject: {
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0
|
||||
},
|
||||
create: (styles: unknown) => styles
|
||||
},
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('react-native-webview', async () => {
|
||||
const React = await import('react')
|
||||
const WebView = React.forwardRef((props: Record<string, unknown>, _ref) =>
|
||||
React.createElement('WebView', props)
|
||||
)
|
||||
return { WebView, default: WebView }
|
||||
})
|
||||
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
RefreshCw: 'RefreshCw'
|
||||
}))
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
function postWebViewMessage(payload: Record<string, unknown>): void {
|
||||
if (!renderer) {
|
||||
throw new Error('TerminalWebView did not render')
|
||||
}
|
||||
const webView = renderer.root.findByType('WebView')
|
||||
act(() => {
|
||||
webView.props.onMessage({ nativeEvent: { data: JSON.stringify(payload) } })
|
||||
})
|
||||
}
|
||||
|
||||
describe('TerminalWebView query reply routing', () => {
|
||||
afterEach(() => {
|
||||
if (renderer) {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('routes only complete terminal query replies to native', () => {
|
||||
const onTerminalQueryReply = vi.fn()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
act(() => {
|
||||
renderer = create(createElement(TerminalWebView, { onTerminalQueryReply }))
|
||||
})
|
||||
|
||||
postWebViewMessage({ type: 'terminal-data', bytes: '\x1b[3;4R' })
|
||||
postWebViewMessage({ type: 'terminal-data', bytes: 'a' })
|
||||
postWebViewMessage({ type: 'terminal-data', bytes: '\x1b[A' })
|
||||
|
||||
expect(onTerminalQueryReply).toHaveBeenCalledTimes(1)
|
||||
expect(onTerminalQueryReply).toHaveBeenCalledWith('\x1b[3;4R')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { isTerminalQueryReply } from '../../../src/shared/terminal-query-reply'
|
||||
|
||||
export function routeTerminalQueryReply(
|
||||
message: Record<string, unknown>,
|
||||
onTerminalQueryReply: ((bytes: string) => void) | undefined
|
||||
): void {
|
||||
if (
|
||||
message.type === 'terminal-data' &&
|
||||
typeof message.bytes === 'string' &&
|
||||
isTerminalQueryReply(message.bytes)
|
||||
) {
|
||||
onTerminalQueryReply?.(message.bytes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html'
|
||||
import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected'
|
||||
|
||||
type QueryReplyGate = {
|
||||
forward: (data: string) => void
|
||||
queueBoundary: (generation: number) => void
|
||||
reset: () => void
|
||||
resume: () => void
|
||||
setGeneration: (generation: number) => void
|
||||
}
|
||||
|
||||
function createQueryReplyGate(notify: (message: unknown) => void): {
|
||||
gate: QueryReplyGate
|
||||
queuedBoundaries: Array<() => void>
|
||||
} {
|
||||
const queuedBoundaries: Array<() => void> = []
|
||||
const factory = new Function(
|
||||
'notify',
|
||||
'enqueueWriteBoundary',
|
||||
`var terminalGeneration = 0;
|
||||
${TERMINAL_QUERY_REPLY_JS}
|
||||
return {
|
||||
forward: forwardTerminalDataReply,
|
||||
queueBoundary: enqueueTerminalDataReplyBoundary,
|
||||
reset: resetTerminalDataReplyAuthority,
|
||||
resume: resumeTerminalDataReplyAuthority,
|
||||
setGeneration: function(next) { terminalGeneration = next; }
|
||||
};`
|
||||
) as (
|
||||
notify: (message: unknown) => void,
|
||||
enqueueBoundary: (callback: () => void) => void
|
||||
) => QueryReplyGate
|
||||
const gate = factory(notify, (callback) => queuedBoundaries.push(callback))
|
||||
return { gate, queuedBoundaries }
|
||||
}
|
||||
|
||||
describe('mobile terminal query replies', () => {
|
||||
it('forwards xterm-generated data only after initial replay drains', () => {
|
||||
const listenerIndex = XTERM_WEBVIEW_SOURCE.html.indexOf('term.onData(function(data)')
|
||||
const enableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf(
|
||||
'attachTerminalQueryReplyBridge(term, gen)',
|
||||
listenerIndex
|
||||
)
|
||||
const notifyIndex = XTERM_WEBVIEW_SOURCE.html.indexOf(
|
||||
'forwardTerminalDataReply(data)',
|
||||
listenerIndex
|
||||
)
|
||||
|
||||
expect(listenerIndex).toBeGreaterThan(-1)
|
||||
expect(enableIndex).toBeGreaterThan(listenerIndex)
|
||||
expect(notifyIndex).toBeGreaterThan(listenerIndex)
|
||||
expect(XTERM_WEBVIEW_SOURCE.html).toContain('disableStdin: false')
|
||||
expect(XTERM_WEBVIEW_SOURCE.html).toContain(
|
||||
'term.attachCustomKeyEventHandler(function() { return false; })'
|
||||
)
|
||||
expect(XTERM_WEBVIEW_SOURCE.html).toContain('term.textarea.readOnly = true')
|
||||
})
|
||||
|
||||
it('mutes a replacement terminal until its own replay drains', () => {
|
||||
const initIndex = XTERM_WEBVIEW_SOURCE.html.indexOf('function init(cols, rows, initialData')
|
||||
const disableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf(
|
||||
'resetTerminalDataReplyAuthority()',
|
||||
initIndex
|
||||
)
|
||||
const enableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf(
|
||||
'attachTerminalQueryReplyBridge(term, gen)',
|
||||
disableIndex
|
||||
)
|
||||
|
||||
expect(initIndex).toBeGreaterThan(-1)
|
||||
expect(disableIndex).toBeGreaterThan(initIndex)
|
||||
expect(enableIndex).toBeGreaterThan(disableIndex)
|
||||
})
|
||||
|
||||
it('suppresses replay, then forwards live queries queued behind its boundary', () => {
|
||||
const messages: unknown[] = []
|
||||
const { gate, queuedBoundaries } = createQueryReplyGate((message) => messages.push(message))
|
||||
gate.setGeneration(1)
|
||||
gate.reset()
|
||||
gate.queueBoundary(1)
|
||||
|
||||
gate.forward('\x1b[1;1R')
|
||||
expect(messages).toEqual([])
|
||||
|
||||
queuedBoundaries[0]?.()
|
||||
gate.forward('\x1b[2;2R')
|
||||
expect(messages).toEqual([{ type: 'terminal-data', bytes: '\x1b[2;2R' }])
|
||||
})
|
||||
|
||||
it('does not let a superseded generation reclaim reply authority', () => {
|
||||
const messages: unknown[] = []
|
||||
const { gate, queuedBoundaries } = createQueryReplyGate((message) => messages.push(message))
|
||||
gate.setGeneration(1)
|
||||
gate.queueBoundary(1)
|
||||
gate.setGeneration(2)
|
||||
gate.reset()
|
||||
|
||||
queuedBoundaries[0]?.()
|
||||
gate.forward('\x1b[1;1R')
|
||||
expect(messages).toEqual([])
|
||||
})
|
||||
|
||||
it('restores reply authority when clear discards the replay boundary', () => {
|
||||
const messages: unknown[] = []
|
||||
const { gate, queuedBoundaries } = createQueryReplyGate((message) => messages.push(message))
|
||||
gate.setGeneration(1)
|
||||
gate.reset()
|
||||
gate.queueBoundary(1)
|
||||
|
||||
// A clear drops all queued writes, including the replay boundary.
|
||||
queuedBoundaries.length = 0
|
||||
gate.resume()
|
||||
gate.forward('\x1b[3;4R')
|
||||
|
||||
expect(messages).toEqual([{ type: 'terminal-data', bytes: '\x1b[3;4R' }])
|
||||
const clearStart = XTERM_WEBVIEW_SOURCE.html.indexOf("} else if (msg.type === 'clear') {")
|
||||
const clearEnd = XTERM_WEBVIEW_SOURCE.html.indexOf(
|
||||
"} else if (msg.type === 'measure')",
|
||||
clearStart
|
||||
)
|
||||
expect(XTERM_WEBVIEW_SOURCE.html.slice(clearStart, clearEnd)).toContain(
|
||||
'resumeTerminalDataReplyAuthority()'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import os from 'node:os'
|
||||
import { app } from 'electron'
|
||||
import {
|
||||
sanitizeCrashReportString,
|
||||
type CrashReportDiagnosticBundle
|
||||
} from '../../shared/crash-reporting'
|
||||
import { collectDiagnosticBundle, getDiagnosticsStatus } from '../observability'
|
||||
import { resolveDiagnosticOrcaChannel } from '../observability/diagnostic-upload-endpoint'
|
||||
import type { FeedbackDiagnosticBundleAttachment, FeedbackSubmitResult } from '../ipc/feedback'
|
||||
|
||||
const CRASH_REPORT_LOG_LOOKBACK_MINUTES = 3 * 24 * 60
|
||||
|
||||
export type CrashDiagnosticBundleAttachment = {
|
||||
readonly diagnosticBundle: CrashReportDiagnosticBundle
|
||||
readonly feedbackDiagnosticBundle?: FeedbackDiagnosticBundleAttachment
|
||||
}
|
||||
|
||||
function formatUnknownError(error: unknown): string {
|
||||
return sanitizeCrashReportString(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
|
||||
function skippedCrashDiagnosticBundle(): CrashDiagnosticBundleAttachment {
|
||||
return {
|
||||
diagnosticBundle: {
|
||||
status: 'not_uploaded',
|
||||
reason: 'diagnostic log upload skipped by user'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectCrashDiagnosticBundleAttachment(): CrashDiagnosticBundleAttachment {
|
||||
const status = getDiagnosticsStatus()
|
||||
if (!status.bundleEnabled) {
|
||||
return {
|
||||
diagnosticBundle: {
|
||||
status: 'not_uploaded',
|
||||
reason: status.disabledReason ?? 'diagnostic bundle collection is disabled'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let bundle: ReturnType<typeof collectDiagnosticBundle>
|
||||
try {
|
||||
bundle = collectDiagnosticBundle({
|
||||
appVersion: app.getVersion(),
|
||||
platform: os.platform(),
|
||||
arch: os.arch(),
|
||||
osRelease: os.release(),
|
||||
orcaChannel: resolveDiagnosticOrcaChannel(),
|
||||
// Why: Help > Report Crash is often used after relaunch, long after the
|
||||
// default 30 minute support bundle window would miss the failure context.
|
||||
lookbackMinutes: CRASH_REPORT_LOG_LOOKBACK_MINUTES
|
||||
})
|
||||
} catch (error) {
|
||||
return { diagnosticBundle: { status: 'not_uploaded', reason: formatUnknownError(error) } }
|
||||
}
|
||||
|
||||
return {
|
||||
diagnosticBundle: {
|
||||
status: 'attached',
|
||||
bundleSubmissionId: bundle.bundleSubmissionId,
|
||||
bytes: bundle.bytes,
|
||||
spanCount: bundle.spanCount
|
||||
},
|
||||
feedbackDiagnosticBundle: {
|
||||
bundleSubmissionId: bundle.bundleSubmissionId,
|
||||
content: bundle.payload,
|
||||
bytes: bundle.bytes,
|
||||
spanCount: bundle.spanCount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function prepareCrashDiagnosticBundle(
|
||||
includeDiagnosticLogs: boolean
|
||||
): CrashDiagnosticBundleAttachment {
|
||||
return includeDiagnosticLogs
|
||||
? collectCrashDiagnosticBundleAttachment()
|
||||
: skippedCrashDiagnosticBundle()
|
||||
}
|
||||
|
||||
export function diagnosticBundleForReportOnlyRetry(
|
||||
attachment: CrashDiagnosticBundleAttachment
|
||||
): CrashReportDiagnosticBundle | undefined {
|
||||
const bundle = attachment.feedbackDiagnosticBundle
|
||||
if (!bundle) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
status: 'not_uploaded',
|
||||
reason: 'diagnostic log attachment could not be sent; report retried without logs',
|
||||
bundleSubmissionId: bundle.bundleSubmissionId,
|
||||
bytes: bundle.bytes,
|
||||
spanCount: bundle.spanCount
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveSubmittedDiagnosticBundle(
|
||||
attachment: CrashDiagnosticBundleAttachment,
|
||||
result: FeedbackSubmitResult
|
||||
): CrashReportDiagnosticBundle {
|
||||
const bundle = attachment.feedbackDiagnosticBundle
|
||||
if (!bundle) {
|
||||
return attachment.diagnosticBundle
|
||||
}
|
||||
const failure =
|
||||
result.diagnosticBundleFailure ??
|
||||
(!result.ok ? { status: result.status, error: result.error } : undefined)
|
||||
if (!failure) {
|
||||
return attachment.diagnosticBundle
|
||||
}
|
||||
return {
|
||||
status: 'not_uploaded',
|
||||
reason: `diagnostic log attachment failed: ${formatUnknownError(failure.error)}`,
|
||||
bundleSubmissionId: bundle.bundleSubmissionId,
|
||||
bytes: bundle.bytes,
|
||||
spanCount: bundle.spanCount
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatCrashReportCopyText } from './crash-report-copy-text'
|
||||
|
||||
describe('formatCrashReportCopyText', () => {
|
||||
it('appends only sanitized report and diagnostic omission failure fields', () => {
|
||||
const text = formatCrashReportCopyText('[Crash Report]', {
|
||||
error: 'proxy failed at C:\\Users\\alice\\Orca',
|
||||
diagnosticContext: {
|
||||
status: 'not_uploaded',
|
||||
reason: 'timeout token=super-secret-value\n- Forged field: hidden',
|
||||
internalEndpointError: 'must-not-cross-copy-boundary'
|
||||
},
|
||||
internalTransportFailure: 'must-not-cross-copy-boundary'
|
||||
})
|
||||
|
||||
expect(text).toContain('Submission failure:')
|
||||
expect(text).toContain('Report error: proxy failed at [redacted-path]')
|
||||
expect(text).toContain('Diagnostic logs not uploaded: timeout token=[redacted]')
|
||||
expect(text).not.toContain('\n- Forged field')
|
||||
expect(text).not.toContain('alice')
|
||||
expect(text).not.toContain('must-not-cross-copy-boundary')
|
||||
})
|
||||
|
||||
it('includes a sanitized uploaded ticket without accepting unrelated fields', () => {
|
||||
const text = formatCrashReportCopyText('[Crash Report]', {
|
||||
error: 'report link failed',
|
||||
diagnosticContext: {
|
||||
status: 'uploaded',
|
||||
ticketId: 'ticket-123',
|
||||
bundleSubmissionId: 'not-allow-listed'
|
||||
}
|
||||
})
|
||||
|
||||
expect(text).toContain('Diagnostic ticket uploaded but not linked: ticket-123')
|
||||
expect(text).not.toContain('not-allow-listed')
|
||||
})
|
||||
|
||||
it('leaves the report unchanged when failure context is invalid', () => {
|
||||
expect(formatCrashReportCopyText('[Crash Report]', { error: '' })).toBe('[Crash Report]')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
sanitizeCrashReportString,
|
||||
type CrashReportCopySubmissionFailure
|
||||
} from '../../shared/crash-reporting'
|
||||
|
||||
function sanitizedNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
const sanitized = sanitizeCrashReportString(value.replace(/[\r\n]+/g, ' ')).trim()
|
||||
return sanitized || null
|
||||
}
|
||||
|
||||
function normalizeSubmissionFailure(value: unknown): CrashReportCopySubmissionFailure | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null
|
||||
}
|
||||
const record = value as Record<string, unknown>
|
||||
const error = sanitizedNonEmptyString(record.error)
|
||||
if (!error) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rawContext = record.diagnosticContext
|
||||
if (!rawContext || typeof rawContext !== 'object' || Array.isArray(rawContext)) {
|
||||
return { error }
|
||||
}
|
||||
const context = rawContext as Record<string, unknown>
|
||||
if (context.status === 'uploaded') {
|
||||
const ticketId = sanitizedNonEmptyString(context.ticketId)
|
||||
return ticketId ? { error, diagnosticContext: { status: 'uploaded', ticketId } } : { error }
|
||||
}
|
||||
if (context.status === 'not_uploaded') {
|
||||
const reason = sanitizedNonEmptyString(context.reason)
|
||||
return reason ? { error, diagnosticContext: { status: 'not_uploaded', reason } } : { error }
|
||||
}
|
||||
return { error }
|
||||
}
|
||||
|
||||
export function formatCrashReportCopyText(baseText: string, submissionFailure: unknown): string {
|
||||
const failure = normalizeSubmissionFailure(submissionFailure)
|
||||
if (!failure) {
|
||||
return baseText
|
||||
}
|
||||
|
||||
const lines = [baseText, '', 'Submission failure:', `- Report error: ${failure.error}`]
|
||||
if (failure.diagnosticContext?.status === 'uploaded') {
|
||||
lines.push(`- Diagnostic ticket uploaded but not linked: ${failure.diagnosticContext.ticketId}`)
|
||||
} else if (failure.diagnosticContext?.status === 'not_uploaded') {
|
||||
lines.push(`- Diagnostic logs not uploaded: ${failure.diagnosticContext.reason}`)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
@@ -1,7 +1,17 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { grantDirAclAsyncMock } = vi.hoisted(() => ({
|
||||
grantDirAclAsyncMock: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
vi.mock('../win32-utils', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return { ...actual, grantDirAclAsync: grantDirAclAsyncMock }
|
||||
})
|
||||
|
||||
import { CrashReportStore } from './crash-report-store'
|
||||
import type { CrashReportCreateInput } from '../../shared/crash-reporting'
|
||||
|
||||
@@ -38,6 +48,9 @@ function input(reason = 'crashed'): CrashReportCreateInput {
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
grantDirAclAsyncMock.mockReset()
|
||||
grantDirAclAsyncMock.mockResolvedValue(undefined)
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
@@ -134,4 +147,72 @@ describe('CrashReportStore', () => {
|
||||
|
||||
await expect(store.listRecent()).resolves.toHaveLength(5)
|
||||
})
|
||||
|
||||
it('waits for an in-flight crash write before reading the pending report', async () => {
|
||||
const { store } = await createStore()
|
||||
|
||||
const recordPromise = store.record(input())
|
||||
const pendingPromise = store.getLatestPending()
|
||||
const report = await recordPromise
|
||||
|
||||
await expect(pendingPromise).resolves.toMatchObject({ id: report.id, status: 'pending' })
|
||||
})
|
||||
|
||||
it('repairs the Windows userData ACL and retries a denied crash write', async () => {
|
||||
const { store, filePath } = await createStore()
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
const permissionError = Object.assign(new Error('permission denied'), { code: 'EPERM' })
|
||||
const writeFileSpy = vi.spyOn(fs, 'writeFile').mockRejectedValueOnce(permissionError)
|
||||
|
||||
const report = await store.record(input())
|
||||
|
||||
expect(grantDirAclAsyncMock).toHaveBeenCalledWith(path.dirname(filePath))
|
||||
expect(writeFileSpy).toHaveBeenCalledTimes(2)
|
||||
await expect(store.getLatestPending()).resolves.toMatchObject({ id: report.id })
|
||||
})
|
||||
|
||||
it.each(['EPERM', 'EACCES', 'EBUSY'] as const)(
|
||||
'recovers a pending report after a transient Windows %s read failure',
|
||||
async (code) => {
|
||||
const { store, filePath } = await createStore()
|
||||
const report = await store.record(input())
|
||||
const reloaded = new CrashReportStore(filePath)
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
const readError = Object.assign(new Error('temporary read failure'), { code })
|
||||
const readFileSpy = vi.spyOn(fs, 'readFile').mockRejectedValueOnce(readError)
|
||||
|
||||
await expect(reloaded.getLatestPending()).resolves.toMatchObject({ id: report.id })
|
||||
|
||||
expect(readFileSpy).toHaveBeenCalledTimes(2)
|
||||
if (code === 'EBUSY') {
|
||||
expect(grantDirAclAsyncMock).not.toHaveBeenCalled()
|
||||
} else {
|
||||
expect(grantDirAclAsyncMock).toHaveBeenCalledOnce()
|
||||
expect(grantDirAclAsyncMock).toHaveBeenCalledWith(path.dirname(filePath))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('retries a transient Windows rename lock', async () => {
|
||||
const { store } = await createStore()
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
const busyError = Object.assign(new Error('file busy'), { code: 'EBUSY' })
|
||||
const renameSpy = vi.spyOn(fs, 'rename').mockRejectedValueOnce(busyError)
|
||||
|
||||
await expect(store.record(input())).resolves.toMatchObject({ status: 'pending' })
|
||||
|
||||
expect(renameSpy).toHaveBeenCalledTimes(2)
|
||||
expect(grantDirAclAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes its temp file after a terminal write failure', async () => {
|
||||
const { store, filePath } = await createStore()
|
||||
const ioError = Object.assign(new Error('disk error'), { code: 'EIO' })
|
||||
vi.spyOn(fs, 'rename').mockRejectedValueOnce(ioError)
|
||||
|
||||
await expect(store.record(input())).rejects.toBe(ioError)
|
||||
|
||||
const entries = await fs.readdir(path.dirname(filePath))
|
||||
expect(entries.filter((entry) => entry.endsWith('.tmp'))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { app } from 'electron'
|
||||
import { grantDirAclAsync, isPermissionError } from '../win32-utils'
|
||||
import {
|
||||
formatCrashReportText,
|
||||
sanitizeCrashReportBreadcrumbs,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
|
||||
const MAX_REPORTS = 5
|
||||
const RELATED_CRASH_WINDOW_MS = 5_000
|
||||
const WINDOWS_FILE_OPERATION_RETRY_DELAYS_MS = [50, 100, 150, 200, 250]
|
||||
|
||||
type CrashReportFile = {
|
||||
reports: CrashReportRecord[]
|
||||
@@ -37,6 +39,46 @@ function isRelatedCrashEvent(anchor: CrashReportRecord, candidate: CrashReportRe
|
||||
)
|
||||
}
|
||||
|
||||
function isRetryableWindowsFileOperationError(error: unknown): boolean {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
return code === 'EPERM' || code === 'EACCES' || code === 'EBUSY'
|
||||
}
|
||||
|
||||
async function wait(delayMs: number): Promise<void> {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, delayMs))
|
||||
}
|
||||
|
||||
async function runCrashReportFileOperationWithWindowsRecovery<T>(
|
||||
directory: string,
|
||||
operation: () => Promise<T>
|
||||
): Promise<T> {
|
||||
let repairedAcl = false
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
return await operation()
|
||||
} catch (error) {
|
||||
if (
|
||||
process.platform !== 'win32' ||
|
||||
!isRetryableWindowsFileOperationError(error) ||
|
||||
attempt >= WINDOWS_FILE_OPERATION_RETRY_DELAYS_MS.length
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
if (!repairedAcl && isPermissionError(error)) {
|
||||
repairedAcl = true
|
||||
try {
|
||||
// Why: Chromium can reset userData ACLs before startup capture or
|
||||
// recovery, so both the crash write and next prompt must repair it.
|
||||
await grantDirAclAsync(directory)
|
||||
} catch {
|
||||
// The bounded retry below still handles transient file locks.
|
||||
}
|
||||
}
|
||||
await wait(WINDOWS_FILE_OPERATION_RETRY_DELAYS_MS[attempt])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class CrashReportStore {
|
||||
private writeChain = Promise.resolve()
|
||||
|
||||
@@ -135,7 +177,9 @@ export class CrashReportStore {
|
||||
mutate: (reports: CrashReportRecord[]) => Promise<{ reports: CrashReportRecord[]; result: T }>
|
||||
): Promise<T> {
|
||||
const run = this.writeChain.then(async () => {
|
||||
const reports = await this.readReports()
|
||||
// Why: awaiting writeChain from inside its own callback would deadlock;
|
||||
// this writer already has exclusive ownership and can read disk directly.
|
||||
const reports = await this.readReportsFromDisk()
|
||||
const { reports: nextReports, result } = await mutate(reports)
|
||||
await this.writeReports(nextReports)
|
||||
return result
|
||||
@@ -148,8 +192,18 @@ export class CrashReportStore {
|
||||
}
|
||||
|
||||
private async readReports(): Promise<CrashReportRecord[]> {
|
||||
// Why: renderer recovery can query the one-shot startup prompt while the
|
||||
// crash write is still in flight. Wait so a successful capture is visible.
|
||||
await this.writeChain
|
||||
return this.readReportsFromDisk()
|
||||
}
|
||||
|
||||
private async readReportsFromDisk(): Promise<CrashReportRecord[]> {
|
||||
try {
|
||||
const raw = await fs.readFile(this.filePath, 'utf8')
|
||||
const raw = await runCrashReportFileOperationWithWindowsRecovery(
|
||||
path.dirname(this.filePath),
|
||||
() => fs.readFile(this.filePath, 'utf8')
|
||||
)
|
||||
const parsed = JSON.parse(raw) as Partial<CrashReportFile>
|
||||
return Array.isArray(parsed.reports) ? parsed.reports.slice(0, MAX_REPORTS) : []
|
||||
} catch (error) {
|
||||
@@ -161,9 +215,18 @@ export class CrashReportStore {
|
||||
}
|
||||
|
||||
private async writeReports(reports: CrashReportRecord[]): Promise<void> {
|
||||
await fs.mkdir(path.dirname(this.filePath), { recursive: true })
|
||||
const directory = path.dirname(this.filePath)
|
||||
const tmpPath = `${this.filePath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`
|
||||
await fs.writeFile(tmpPath, `${JSON.stringify({ reports }, null, 2)}${os.EOL}`, 'utf8')
|
||||
await fs.rename(tmpPath, this.filePath)
|
||||
try {
|
||||
await runCrashReportFileOperationWithWindowsRecovery(directory, async () => {
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
await fs.writeFile(tmpPath, `${JSON.stringify({ reports }, null, 2)}${os.EOL}`, 'utf8')
|
||||
await fs.rename(tmpPath, this.filePath)
|
||||
})
|
||||
} finally {
|
||||
// Why: disk-full and terminal rename failures must not accumulate a new
|
||||
// orphaned multi-report temp file after every crash.
|
||||
await fs.rm(tmpPath, { force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { clearCrashBreadcrumbsForTest, getCrashBreadcrumbSnapshot } from './crash-breadcrumb-store'
|
||||
import { recordDurableCrashBreadcrumb } from './durable-crash-breadcrumb'
|
||||
import { _resetTracerForTests, setActiveSink, type TracerSink } from '../observability/tracer'
|
||||
|
||||
type CapturingSink = TracerSink & { records: unknown[]; flushMock: ReturnType<typeof vi.fn> }
|
||||
|
||||
function capturingSink(): CapturingSink {
|
||||
const records: unknown[] = []
|
||||
const flushMock = vi.fn()
|
||||
return {
|
||||
records,
|
||||
flushMock,
|
||||
push: (record) => records.push(record),
|
||||
flush: flushMock,
|
||||
close: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
let sink: CapturingSink
|
||||
|
||||
beforeEach(() => {
|
||||
sink = capturingSink()
|
||||
setActiveSink(sink)
|
||||
clearCrashBreadcrumbsForTest()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
_resetTracerForTests()
|
||||
clearCrashBreadcrumbsForTest()
|
||||
})
|
||||
|
||||
describe('recordDurableCrashBreadcrumb', () => {
|
||||
it('records, traces, and flushes a sanitized crash breadcrumb', () => {
|
||||
recordDurableCrashBreadcrumb('process_gone_suppressed', {
|
||||
source: 'renderer',
|
||||
path: 'C:\\Users\\alice\\project'
|
||||
})
|
||||
|
||||
expect(getCrashBreadcrumbSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'process_gone_suppressed',
|
||||
data: { source: 'renderer', path: '[redacted-path]' }
|
||||
})
|
||||
])
|
||||
expect(sink.records).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'crash.breadcrumb',
|
||||
attributes: expect.objectContaining({
|
||||
kind: 'crash-breadcrumb',
|
||||
'breadcrumb.name': 'process_gone_suppressed'
|
||||
}),
|
||||
exit: { _tag: 'Success' }
|
||||
})
|
||||
])
|
||||
expect(sink.flushMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('records persistence failures as failed, flushed spans', () => {
|
||||
recordDurableCrashBreadcrumb(
|
||||
'crash_report_persist_failed',
|
||||
{ errorCode: 'EPERM' },
|
||||
'EPERM at C:\\Users\\alice\\AppData\\Roaming\\Orca'
|
||||
)
|
||||
|
||||
expect(sink.records).toEqual([
|
||||
expect.objectContaining({
|
||||
exit: expect.objectContaining({ _tag: 'Failure', cause: expect.stringContaining('EPERM') })
|
||||
})
|
||||
])
|
||||
expect(JSON.stringify(sink.records)).not.toContain('alice')
|
||||
expect(sink.flushMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
sanitizeCrashReportDetails,
|
||||
sanitizeCrashReportString,
|
||||
type CrashReportBreadcrumbData
|
||||
} from '../../shared/crash-reporting'
|
||||
import { flushActiveSink, startSpan } from '../observability/tracer'
|
||||
import { recordCrashBreadcrumb } from './crash-breadcrumb-store'
|
||||
|
||||
export function recordDurableCrashBreadcrumb(
|
||||
name: string,
|
||||
data?: CrashReportBreadcrumbData,
|
||||
failureCause?: string
|
||||
): void {
|
||||
const sanitizedName = sanitizeCrashReportString(name)
|
||||
const sanitizedData = data ? sanitizeCrashReportDetails(data) : undefined
|
||||
recordCrashBreadcrumb(sanitizedName, sanitizedData)
|
||||
|
||||
const span = startSpan('crash.breadcrumb', {
|
||||
attributes: {
|
||||
kind: 'crash-breadcrumb',
|
||||
'breadcrumb.name': sanitizedName,
|
||||
...(sanitizedData ? { 'breadcrumb.data': sanitizedData } : {})
|
||||
}
|
||||
})
|
||||
if (failureCause) {
|
||||
span.fail(sanitizeCrashReportString(failureCause, 1_000))
|
||||
} else {
|
||||
span.end()
|
||||
}
|
||||
// Why: these breadcrumbs explain a missing crash record; losing one to the
|
||||
// normal trace batching window would recreate the diagnostic blind spot.
|
||||
flushActiveSink()
|
||||
}
|
||||
@@ -11,6 +11,27 @@ describe('ProcessGoneDedupe', () => {
|
||||
expect(dedupe.shouldRecord(key, 3_000)).toBe(true)
|
||||
})
|
||||
|
||||
it('allows an immediate retry after a failed claim is released', () => {
|
||||
const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 })
|
||||
const claim = dedupe.tryClaim('renderer', 1_000)
|
||||
|
||||
expect(claim).not.toBeNull()
|
||||
expect(dedupe.tryClaim('renderer', 1_001)).toBeNull()
|
||||
dedupe.release(claim!)
|
||||
expect(dedupe.tryClaim('renderer', 1_001)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not release a newer claim when an older persistence attempt fails late', () => {
|
||||
const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 })
|
||||
const oldClaim = dedupe.tryClaim('renderer', 1_000)
|
||||
const currentClaim = dedupe.tryClaim('renderer', 3_000)
|
||||
|
||||
expect(oldClaim).not.toBeNull()
|
||||
expect(currentClaim).not.toBeNull()
|
||||
dedupe.release(oldClaim!)
|
||||
expect(dedupe.tryClaim('renderer', 3_001)).toBeNull()
|
||||
})
|
||||
|
||||
it('prunes stale keys outside the dedupe window', () => {
|
||||
const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 })
|
||||
|
||||
|
||||
@@ -6,10 +6,19 @@ type ProcessGoneDedupeOptions = {
|
||||
maxKeys?: number
|
||||
}
|
||||
|
||||
export type ProcessGoneDedupeClaim = {
|
||||
readonly key: string
|
||||
}
|
||||
|
||||
type ProcessGoneDedupeEntry = {
|
||||
readonly recordedAt: number
|
||||
readonly claim: ProcessGoneDedupeClaim
|
||||
}
|
||||
|
||||
export class ProcessGoneDedupe {
|
||||
private readonly windowMs: number
|
||||
private readonly maxKeys: number
|
||||
private readonly recentKeys = new Map<string, number>()
|
||||
private readonly recentKeys = new Map<string, ProcessGoneDedupeEntry>()
|
||||
|
||||
constructor(options: ProcessGoneDedupeOptions = {}) {
|
||||
this.windowMs = options.windowMs ?? DEFAULT_PROCESS_GONE_DEDUPE_WINDOW_MS
|
||||
@@ -17,19 +26,33 @@ export class ProcessGoneDedupe {
|
||||
}
|
||||
|
||||
shouldRecord(key: string, now = Date.now()): boolean {
|
||||
return this.tryClaim(key, now) !== null
|
||||
}
|
||||
|
||||
tryClaim(key: string, now = Date.now()): ProcessGoneDedupeClaim | null {
|
||||
this.prune(now)
|
||||
|
||||
const previous = this.recentKeys.get(key)
|
||||
if (previous !== undefined && now - previous < this.windowMs) {
|
||||
return false
|
||||
if (previous && now - previous.recordedAt < this.windowMs) {
|
||||
return null
|
||||
}
|
||||
|
||||
const claim = { key }
|
||||
// Why: process-gone tuples come from Electron and can vary by exit code;
|
||||
// keep the short dedupe window without retaining stale tuples forever.
|
||||
this.recentKeys.delete(key)
|
||||
this.recentKeys.set(key, now)
|
||||
this.recentKeys.set(key, { recordedAt: now, claim })
|
||||
this.prune(now)
|
||||
return true
|
||||
return claim
|
||||
}
|
||||
|
||||
release(claim: ProcessGoneDedupeClaim): void {
|
||||
const current = this.recentKeys.get(claim.key)
|
||||
// Why: an old failed write must not erase a newer claim for the same
|
||||
// renderer after the dedupe window expires or bounded entries are evicted.
|
||||
if (current?.claim === claim) {
|
||||
this.recentKeys.delete(claim.key)
|
||||
}
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
@@ -37,8 +60,8 @@ export class ProcessGoneDedupe {
|
||||
}
|
||||
|
||||
private prune(now: number): void {
|
||||
for (const [key, recordedAt] of this.recentKeys) {
|
||||
if (now - recordedAt >= this.windowMs) {
|
||||
for (const [key, entry] of this.recentKeys) {
|
||||
if (now - entry.recordedAt >= this.windowMs) {
|
||||
this.recentKeys.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ describe('process gone diagnostics', () => {
|
||||
processType: 'Utility',
|
||||
reason: 'killed',
|
||||
exitCode: 1,
|
||||
expectedTeardown: 'app-shutdown',
|
||||
details: {
|
||||
name: 'Network Service',
|
||||
serviceName: 'network.mojom.NetworkService',
|
||||
@@ -82,6 +83,7 @@ describe('process gone diagnostics', () => {
|
||||
processType: 'Utility',
|
||||
reason: 'killed',
|
||||
exitCode: 1,
|
||||
expectedTeardown: 'app-shutdown',
|
||||
name: 'Network Service',
|
||||
serviceName: 'network.mojom.NetworkService'
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type CrashReportBreadcrumbData,
|
||||
type CrashReportDetailValue
|
||||
} from '../../shared/crash-reporting'
|
||||
import type { ExpectedTeardownScope } from './process-gone-classification'
|
||||
|
||||
type ProcessMetricLike = {
|
||||
pid?: unknown
|
||||
@@ -124,19 +125,22 @@ export function buildSuppressedProcessGoneBreadcrumbData({
|
||||
processType,
|
||||
reason,
|
||||
exitCode,
|
||||
expectedTeardown,
|
||||
details
|
||||
}: {
|
||||
source: 'renderer' | 'child'
|
||||
processType: string
|
||||
reason: string
|
||||
exitCode: number | null
|
||||
expectedTeardown: ExpectedTeardownScope
|
||||
details: Record<string, unknown>
|
||||
}): CrashReportBreadcrumbData {
|
||||
const breadcrumb: CrashReportBreadcrumbData = {
|
||||
source,
|
||||
processType,
|
||||
reason,
|
||||
exitCode
|
||||
exitCode,
|
||||
expectedTeardown
|
||||
}
|
||||
const name = safeString(details.name)
|
||||
if (name) {
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getVersion: () => '1.2.3-test',
|
||||
getAppMetrics: () => []
|
||||
}
|
||||
}))
|
||||
|
||||
import { clearCrashBreadcrumbsForTest, getCrashBreadcrumbSnapshot } from './crash-breadcrumb-store'
|
||||
import { ProcessGoneDedupe } from './process-gone-dedupe'
|
||||
import { recordProcessGoneCrash, type ProcessGoneCrashEvent } from './process-gone-recorder'
|
||||
import { _resetTracerForTests, setActiveSink, type TracerSink } from '../observability/tracer'
|
||||
|
||||
type CapturingSink = TracerSink & { records: unknown[]; flushMock: ReturnType<typeof vi.fn> }
|
||||
|
||||
function capturingSink(): CapturingSink {
|
||||
const records: unknown[] = []
|
||||
const flushMock = vi.fn()
|
||||
return {
|
||||
records,
|
||||
flushMock,
|
||||
push: (record) => records.push(record),
|
||||
flush: flushMock,
|
||||
close: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function event(overrides: Partial<ProcessGoneCrashEvent> = {}): ProcessGoneCrashEvent {
|
||||
return {
|
||||
source: 'renderer',
|
||||
processType: 'renderer',
|
||||
reason: 'crashed',
|
||||
exitCode: 5,
|
||||
expectedTeardown: 'none',
|
||||
details: { processType: 'renderer' },
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
let sink: CapturingSink
|
||||
|
||||
beforeEach(() => {
|
||||
sink = capturingSink()
|
||||
setActiveSink(sink)
|
||||
clearCrashBreadcrumbsForTest()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
_resetTracerForTests()
|
||||
clearCrashBreadcrumbsForTest()
|
||||
})
|
||||
|
||||
describe('recordProcessGoneCrash', () => {
|
||||
it('durably records when the crash report store is unavailable', () => {
|
||||
recordProcessGoneCrash(null, event(), new ProcessGoneDedupe())
|
||||
|
||||
expect(getCrashBreadcrumbSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'crash_report_store_unavailable',
|
||||
data: expect.objectContaining({
|
||||
source: 'renderer',
|
||||
expectedTeardown: 'none'
|
||||
})
|
||||
})
|
||||
])
|
||||
expect(sink.records).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'crash.breadcrumb',
|
||||
exit: expect.objectContaining({ _tag: 'Failure' })
|
||||
})
|
||||
])
|
||||
expect(sink.flushMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('durably records why an expected renderer teardown was suppressed', () => {
|
||||
const record = vi.fn()
|
||||
|
||||
recordProcessGoneCrash(
|
||||
{ record } as never,
|
||||
event({ reason: 'killed', exitCode: 1, expectedTeardown: 'renderer-reload' }),
|
||||
new ProcessGoneDedupe()
|
||||
)
|
||||
|
||||
expect(record).not.toHaveBeenCalled()
|
||||
expect(getCrashBreadcrumbSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'process_gone_suppressed',
|
||||
data: expect.objectContaining({ expectedTeardown: 'renderer-reload' })
|
||||
})
|
||||
])
|
||||
expect(sink.records).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'crash.breadcrumb',
|
||||
attributes: expect.objectContaining({
|
||||
'breadcrumb.name': 'process_gone_suppressed'
|
||||
})
|
||||
})
|
||||
])
|
||||
expect(sink.flushMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('persists a report and flushes the process-gone trace before recovery', async () => {
|
||||
const record = vi.fn().mockResolvedValue({ id: 'report-1' })
|
||||
|
||||
recordProcessGoneCrash({ record } as never, event(), new ProcessGoneDedupe())
|
||||
|
||||
await vi.waitFor(() => expect(record).toHaveBeenCalledOnce())
|
||||
expect(record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ source: 'renderer', reason: 'crashed', exitCode: 5 })
|
||||
)
|
||||
expect(sink.records).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'electron.process_gone',
|
||||
exit: expect.objectContaining({ _tag: 'Failure' })
|
||||
})
|
||||
])
|
||||
expect(sink.flushMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('still persists the report when the forced trace flush fails', async () => {
|
||||
const record = vi.fn().mockResolvedValue({ id: 'report-1' })
|
||||
sink.flushMock.mockImplementation(() => {
|
||||
throw new Error('trace disk unavailable')
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
recordProcessGoneCrash({ record } as never, event(), new ProcessGoneDedupe())
|
||||
).not.toThrow()
|
||||
await vi.waitFor(() => expect(record).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
it('still persists the report when the trace sink handoff fails', async () => {
|
||||
const record = vi.fn().mockResolvedValue({ id: 'report-1' })
|
||||
sink.push = () => {
|
||||
throw new Error('trace rotation failed')
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
recordProcessGoneCrash({ record } as never, event(), new ProcessGoneDedupe())
|
||||
).not.toThrow()
|
||||
await vi.waitFor(() => expect(record).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
it('durably records a sanitized crash-report persistence failure', async () => {
|
||||
const persistError = Object.assign(
|
||||
new Error('EPERM at C:\\Users\\alice\\AppData\\Roaming\\Orca\\crash-reports.json'),
|
||||
{ code: 'EPERM' }
|
||||
)
|
||||
const record = vi.fn().mockRejectedValue(persistError)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
recordProcessGoneCrash({ record } as never, event(), new ProcessGoneDedupe())
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getCrashBreadcrumbSnapshot()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'crash_report_persist_failed',
|
||||
data: expect.objectContaining({ errorCode: 'EPERM' })
|
||||
})
|
||||
])
|
||||
)
|
||||
})
|
||||
expect(sink.records).toHaveLength(2)
|
||||
expect(sink.records[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'crash.breadcrumb',
|
||||
exit: expect.objectContaining({ _tag: 'Failure' })
|
||||
})
|
||||
)
|
||||
expect(JSON.stringify(sink.records)).not.toContain('alice')
|
||||
expect(sink.flushMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps null persistence rejections inside the fail-open diagnostic path', async () => {
|
||||
const record = vi.fn().mockRejectedValue(null)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
recordProcessGoneCrash({ record } as never, event(), new ProcessGoneDedupe())
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(getCrashBreadcrumbSnapshot()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'crash_report_persist_failed',
|
||||
data: expect.objectContaining({ errorName: 'object', errorMessage: 'null' })
|
||||
})
|
||||
])
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('allows the same renderer crash to retry after persistence fails', async () => {
|
||||
const record = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('disk unavailable'))
|
||||
.mockResolvedValueOnce({ id: 'report-2' })
|
||||
const dedupe = new ProcessGoneDedupe()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
recordProcessGoneCrash({ record } as never, event(), dedupe)
|
||||
await vi.waitFor(() =>
|
||||
expect(getCrashBreadcrumbSnapshot()).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ name: 'crash_report_persist_failed' })])
|
||||
)
|
||||
)
|
||||
recordProcessGoneCrash({ record } as never, event(), dedupe)
|
||||
|
||||
await vi.waitFor(() => expect(record).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
import os from 'node:os'
|
||||
import { app } from 'electron'
|
||||
import { isCrashReportReason, sanitizeCrashReportString } from '../../shared/crash-reporting'
|
||||
import type { CrashReportStore } from './crash-report-store'
|
||||
import { getCrashBreadcrumbSnapshot } from './crash-breadcrumb-store'
|
||||
import { recordDurableCrashBreadcrumb } from './durable-crash-breadcrumb'
|
||||
import {
|
||||
shouldRecordProcessGoneCrash,
|
||||
type ExpectedTeardownScope,
|
||||
type ProcessGoneSource
|
||||
} from './process-gone-classification'
|
||||
import {
|
||||
buildProcessGoneCrashDetails,
|
||||
buildSuppressedProcessGoneBreadcrumbData
|
||||
} from './process-gone-diagnostics'
|
||||
import {
|
||||
getProcessGoneDedupeKey,
|
||||
processGoneDedupe,
|
||||
type ProcessGoneDedupe
|
||||
} from './process-gone-dedupe'
|
||||
import { flushActiveSink, startSpan } from '../observability/tracer'
|
||||
|
||||
export type ProcessGoneCrashEvent = {
|
||||
source: ProcessGoneSource
|
||||
processType: string
|
||||
reason: string
|
||||
exitCode: number | null
|
||||
expectedTeardown: ExpectedTeardownScope
|
||||
details: Record<string, unknown>
|
||||
}
|
||||
|
||||
type CrashReportRecorderStore = Pick<CrashReportStore, 'record'>
|
||||
|
||||
function processGoneBreadcrumbData(event: ProcessGoneCrashEvent) {
|
||||
return buildSuppressedProcessGoneBreadcrumbData(event)
|
||||
}
|
||||
|
||||
function persistFailureData(event: ProcessGoneCrashEvent, error: unknown) {
|
||||
const errorCode =
|
||||
typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string'
|
||||
? error.code
|
||||
: undefined
|
||||
return {
|
||||
...processGoneBreadcrumbData(event),
|
||||
errorName: error instanceof Error ? error.name : typeof error,
|
||||
errorMessage: sanitizeCrashReportString(error instanceof Error ? error.message : String(error)),
|
||||
...(errorCode ? { errorCode } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function recordProcessGoneCrash(
|
||||
store: CrashReportRecorderStore | null,
|
||||
event: ProcessGoneCrashEvent,
|
||||
dedupe: ProcessGoneDedupe = processGoneDedupe
|
||||
): void {
|
||||
if (!isCrashReportReason(event.reason)) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!shouldRecordProcessGoneCrash({
|
||||
source: event.source,
|
||||
processType: event.processType,
|
||||
serviceName:
|
||||
typeof event.details.serviceName === 'string' ? event.details.serviceName : undefined,
|
||||
reason: event.reason,
|
||||
exitCode: event.exitCode,
|
||||
expectedTeardown: event.expectedTeardown
|
||||
})
|
||||
) {
|
||||
recordDurableCrashBreadcrumb('process_gone_suppressed', processGoneBreadcrumbData(event))
|
||||
return
|
||||
}
|
||||
if (!store) {
|
||||
recordDurableCrashBreadcrumb(
|
||||
'crash_report_store_unavailable',
|
||||
processGoneBreadcrumbData(event),
|
||||
'Crash report store unavailable'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const key = getProcessGoneDedupeKey(event.source, event.processType, event.reason, event.exitCode)
|
||||
const claim = dedupe.tryClaim(key)
|
||||
if (!claim) {
|
||||
return
|
||||
}
|
||||
const crashDetails = buildProcessGoneCrashDetails(event.details)
|
||||
const breadcrumbs = getCrashBreadcrumbSnapshot()
|
||||
const span = startSpan('electron.process_gone', {
|
||||
attributes: {
|
||||
'crash.source': event.source,
|
||||
'crash.process_type': event.processType,
|
||||
'crash.reason': event.reason,
|
||||
...(event.exitCode !== null ? { 'crash.exit_code': event.exitCode } : {}),
|
||||
'app.version': app.getVersion(),
|
||||
platform: process.platform,
|
||||
osRelease: os.release(),
|
||||
arch: process.arch,
|
||||
electronVersion: process.versions.electron,
|
||||
chromeVersion: process.versions.chrome,
|
||||
details: crashDetails,
|
||||
breadcrumbs
|
||||
}
|
||||
})
|
||||
// Why: a renderer crash can be followed by another process exit before the
|
||||
// trace batch window closes, so make the primary signal durable immediately.
|
||||
span.fail(
|
||||
`${event.source} process gone: ${event.processType} ${event.reason} (${event.exitCode ?? 'unknown'})`
|
||||
)
|
||||
flushActiveSink()
|
||||
|
||||
void store
|
||||
.record({
|
||||
source: event.source,
|
||||
processType: event.processType,
|
||||
reason: event.reason,
|
||||
exitCode: event.exitCode,
|
||||
appVersion: app.getVersion(),
|
||||
platform: process.platform,
|
||||
osRelease: os.release(),
|
||||
arch: process.arch,
|
||||
electronVersion: process.versions.electron ?? 'unknown',
|
||||
chromeVersion: process.versions.chrome ?? 'unknown',
|
||||
details: crashDetails,
|
||||
breadcrumbs
|
||||
})
|
||||
.catch((error) => {
|
||||
dedupe.release(claim)
|
||||
console.error('[crash-reporting] Failed to persist crash report:', error)
|
||||
const data = persistFailureData(event, error)
|
||||
recordDurableCrashBreadcrumb(
|
||||
'crash_report_persist_failed',
|
||||
data,
|
||||
`${String(data.errorName)}: ${String(data.errorMessage)}`
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -2291,6 +2291,7 @@ describe('createPtySubprocess', () => {
|
||||
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo',
|
||||
env: {
|
||||
ORCA_TERMINAL_HANDLE: 'term_wsl',
|
||||
ORCA_HERMES_STARTUP_QUERY: 'line one\nline two',
|
||||
WSLENV: 'FOO/u'
|
||||
}
|
||||
})
|
||||
@@ -2317,7 +2318,12 @@ describe('createPtySubprocess', () => {
|
||||
// Why: the daemon inherits optional agent-hook env in development. This
|
||||
// test owns only the terminal handle and Powerlevel10k WSLENV contract.
|
||||
expect(spawnCall[2].env.WSLENV?.split(':')).toEqual(
|
||||
expect.arrayContaining(['FOO/u', 'ORCA_TERMINAL_HANDLE/u', POWERLEVEL10K_WIZARD_DISABLE_ENV])
|
||||
expect.arrayContaining([
|
||||
'FOO/u',
|
||||
'ORCA_TERMINAL_HANDLE/u',
|
||||
'ORCA_HERMES_STARTUP_QUERY',
|
||||
POWERLEVEL10K_WIZARD_DISABLE_ENV
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ import { isShellProcess } from '../../shared/shell-process-detection'
|
||||
import { parsePtySessionId } from './pty-session-id'
|
||||
import { getAgentForegroundContextPaths } from '../providers/agent-foreground-context-paths'
|
||||
import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd'
|
||||
import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query'
|
||||
|
||||
const PANE_IDENTITY_ENV_KEYS = [
|
||||
'ORCA_PANE_KEY',
|
||||
@@ -717,6 +718,11 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
||||
// through Windows wsl.exe; non-default env vars need WSLENV import.
|
||||
addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR'])
|
||||
}
|
||||
if (env[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) {
|
||||
// Why: the startup wrapper expands this only inside WSL; wsl.exe
|
||||
// otherwise drops custom Windows environment variables.
|
||||
addWslEnvKeys(env, [ORCA_HERMES_STARTUP_QUERY_ENV])
|
||||
}
|
||||
} else if (codexHomeWslInfo || isWslCodexHomeForHost(env.CODEX_HOME)) {
|
||||
// Why: WSL-managed Codex homes are Linux paths. Windows Codex cannot use
|
||||
// them. ORCA_CODEX_HOME must go too because shell-ready scripts restore
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./runner', () => ({
|
||||
gitExecFileAsync: gitExecFileAsyncMock,
|
||||
gitExecFileSync: vi.fn()
|
||||
}))
|
||||
|
||||
import { getBaseRefDefault } from './repo'
|
||||
|
||||
describe('getBaseRefDefault async subprocess bounds', () => {
|
||||
beforeEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
})
|
||||
|
||||
it('bounds every local probe and degrades a timeout to no default', async () => {
|
||||
gitExecFileAsyncMock.mockRejectedValue(new Error('git timed out.'))
|
||||
|
||||
await expect(getBaseRefDefault('/repo')).resolves.toBeNull()
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalled()
|
||||
for (const [, options] of gitExecFileAsyncMock.mock.calls) {
|
||||
expect(options).toEqual({ cwd: '/repo', timeout: 15_000 })
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves the same timeout when routing probes through WSL', async () => {
|
||||
gitExecFileAsyncMock.mockRejectedValue(new Error('git timed out.'))
|
||||
|
||||
await expect(
|
||||
getBaseRefDefault('\\\\wsl.localhost\\Ubuntu\\repo', { wslDistro: 'Ubuntu' })
|
||||
).resolves.toBeNull()
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalled()
|
||||
for (const [, options] of gitExecFileAsyncMock.mock.calls) {
|
||||
expect(options).toEqual({
|
||||
cwd: '\\\\wsl.localhost\\Ubuntu\\repo',
|
||||
timeout: 15_000,
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
+20
-3
@@ -15,6 +15,13 @@ type LocalGitExecOptions = {
|
||||
wslDistro?: string
|
||||
}
|
||||
|
||||
type LocalDefaultBaseRefGitOptions = {
|
||||
cwd: string
|
||||
wslDistro?: string
|
||||
}
|
||||
|
||||
const DEFAULT_BASE_REF_PROBE_TIMEOUT_MS = 15_000
|
||||
|
||||
type GitRepoProbeResult = 'repo' | 'not-repo' | 'indeterminate'
|
||||
type GitMarkerScanResult = { status: 'valid'; rootPath: string } | { status: 'absent' | 'invalid' }
|
||||
|
||||
@@ -651,13 +658,23 @@ export async function resolveDefaultBaseRefViaExec(exec: GitExec): Promise<strin
|
||||
return resolveDefaultBaseRefFromProbes((ref) => hasGitRefViaExec(exec, ref))
|
||||
}
|
||||
|
||||
export function resolveDefaultBaseRefWithLocalGit(
|
||||
options: LocalDefaultBaseRefGitOptions
|
||||
): Promise<string | null> {
|
||||
return resolveDefaultBaseRefViaExec((argv) =>
|
||||
gitExecFileAsync(argv, {
|
||||
...options,
|
||||
// Why: async avoids main-thread stalls, but dead local/WSL filesystems still need a bound.
|
||||
timeout: DEFAULT_BASE_REF_PROBE_TIMEOUT_MS
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async function getDefaultBaseRefAsync(
|
||||
path: string,
|
||||
options: LocalGitExecOptions = {}
|
||||
): Promise<string | null> {
|
||||
return resolveDefaultBaseRefViaExec((argv) =>
|
||||
gitExecFileAsync(argv, gitExecOptions(path, options))
|
||||
)
|
||||
return resolveDefaultBaseRefWithLocalGit(gitExecOptions(path, options))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+8
-85
@@ -26,7 +26,6 @@ import { closeAllWatchers } from './ipc/filesystem-watcher'
|
||||
import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher'
|
||||
import { registerCoreHandlers } from './ipc/register-core-handlers'
|
||||
import { initObservability, shutdownObservability } from './observability'
|
||||
import { startSpan } from './observability/tracer'
|
||||
import { registerMobileHandlers } from './ipc/mobile'
|
||||
import { initTelemetry, shutdownTelemetry, trackAppOpenedOnce, track } from './telemetry/client'
|
||||
import { classifyError } from './telemetry/classify-error'
|
||||
@@ -159,28 +158,21 @@ import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headles
|
||||
import { AgentAwakeService } from './agent-awake-service'
|
||||
import { registerSystemResumeBroadcast } from './system-resume-broadcast'
|
||||
import {
|
||||
getCrashBreadcrumbSnapshot,
|
||||
recordCoalescedCrashBreadcrumb,
|
||||
recordCrashBreadcrumb
|
||||
} from './crash-reporting/crash-breadcrumb-store'
|
||||
import { CrashReportStore } from './crash-reporting/crash-report-store'
|
||||
import {
|
||||
shouldRecordProcessGoneCrash,
|
||||
shouldRecoverRendererAfterProcessGone,
|
||||
type ExpectedTeardownScope
|
||||
} from './crash-reporting/process-gone-classification'
|
||||
import {
|
||||
buildProcessGoneCrashDetails,
|
||||
buildSuppressedProcessGoneBreadcrumbData
|
||||
} from './crash-reporting/process-gone-diagnostics'
|
||||
import { getProcessGoneDedupeKey, processGoneDedupe } from './crash-reporting/process-gone-dedupe'
|
||||
import { recordProcessGoneCrash as recordProcessGoneCrashEvent } from './crash-reporting/process-gone-recorder'
|
||||
import {
|
||||
advanceSyntheticTitleSpinnerEntries,
|
||||
type SyntheticTitleSpinnerEntry
|
||||
} from './synthetic-title-spinner'
|
||||
import { shouldSendSyntheticTitleFrame } from './synthetic-title-visibility'
|
||||
import { shouldCopySyntheticTitleFrameToPtyData } from './synthetic-title-frame-routing'
|
||||
import { isCrashReportReason } from '../shared/crash-reporting'
|
||||
import {
|
||||
getSyntheticAgentTitleProfile,
|
||||
shouldDriveSyntheticAgentTitleFromHook,
|
||||
@@ -833,14 +825,6 @@ function openMainWindow(): BrowserWindow {
|
||||
webContentsId
|
||||
)
|
||||
},
|
||||
shouldRecordRendererCrash: (details, webContentsId) =>
|
||||
shouldRecordProcessGoneCrash({
|
||||
source: 'renderer',
|
||||
processType: 'renderer',
|
||||
reason: details.reason,
|
||||
exitCode: details.exitCode ?? null,
|
||||
expectedTeardown: getExpectedTeardownScope(webContentsId)
|
||||
}),
|
||||
shouldRecoverRenderer: (details, webContentsId) =>
|
||||
shouldRecoverRendererAfterProcessGone({
|
||||
reason: details.reason,
|
||||
@@ -1229,75 +1213,14 @@ function recordProcessGoneCrash(
|
||||
details: Record<string, unknown>,
|
||||
webContentsId?: number
|
||||
): void {
|
||||
if (!crashReports || !isCrashReportReason(reason)) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!shouldRecordProcessGoneCrash({
|
||||
source,
|
||||
processType,
|
||||
serviceName: typeof details.serviceName === 'string' ? details.serviceName : undefined,
|
||||
reason,
|
||||
exitCode,
|
||||
expectedTeardown: getExpectedTeardownScope(webContentsId)
|
||||
})
|
||||
) {
|
||||
recordCrashBreadcrumb(
|
||||
'process_gone_suppressed',
|
||||
buildSuppressedProcessGoneBreadcrumbData({
|
||||
source,
|
||||
processType,
|
||||
reason,
|
||||
exitCode,
|
||||
details
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
const key = getProcessGoneDedupeKey(source, processType, reason, exitCode)
|
||||
if (!processGoneDedupe.shouldRecord(key)) {
|
||||
return
|
||||
}
|
||||
const crashDetails = buildProcessGoneCrashDetails(details)
|
||||
const span = startSpan('electron.process_gone', {
|
||||
attributes: {
|
||||
'crash.source': source,
|
||||
'crash.process_type': processType,
|
||||
'crash.reason': reason,
|
||||
...(exitCode !== null ? { 'crash.exit_code': exitCode } : {}),
|
||||
'app.version': app.getVersion(),
|
||||
platform: process.platform,
|
||||
osRelease: os.release(),
|
||||
arch: process.arch,
|
||||
electronVersion: process.versions.electron,
|
||||
chromeVersion: process.versions.chrome,
|
||||
details: crashDetails,
|
||||
breadcrumbs: getCrashBreadcrumbSnapshot()
|
||||
}
|
||||
recordProcessGoneCrashEvent(crashReports, {
|
||||
source,
|
||||
processType,
|
||||
reason,
|
||||
exitCode,
|
||||
expectedTeardown: getExpectedTeardownScope(webContentsId),
|
||||
details
|
||||
})
|
||||
// Why: renderer/child crashes belong in the local trace lane so the
|
||||
// diagnostic bundle has the same process-gone signal as the startup prompt.
|
||||
span.fail(`${source} process gone: ${processType} ${reason} (${exitCode ?? 'unknown'})`)
|
||||
void crashReports
|
||||
.record({
|
||||
source,
|
||||
processType,
|
||||
reason,
|
||||
exitCode,
|
||||
appVersion: app.getVersion(),
|
||||
platform: process.platform,
|
||||
osRelease: os.release(),
|
||||
arch: process.arch,
|
||||
electronVersion: process.versions.electron,
|
||||
chromeVersion: process.versions.chrome,
|
||||
details: crashDetails,
|
||||
// Why: breadcrumbs stay memory-only during normal operation. Persist a
|
||||
// snapshot only after Electron reports a crash-like process exit.
|
||||
breadcrumbs: getCrashBreadcrumbSnapshot()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[crash-reporting] Failed to persist crash report:', error)
|
||||
})
|
||||
}
|
||||
|
||||
function shutdownWatchersOnce(): Promise<void> {
|
||||
|
||||
@@ -185,6 +185,42 @@ describe('registerCrashReportingHandlers', () => {
|
||||
expect(listRecent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('copies sanitized submission and diagnostic omission failures for a captured report', async () => {
|
||||
const pending = report('pending', 'crash-copy-failure')
|
||||
registerCrashReportingHandlers({
|
||||
getById: vi.fn(async () => pending),
|
||||
dismiss: vi.fn(),
|
||||
markSent: vi.fn(),
|
||||
markDismissedSent: vi.fn(),
|
||||
listRecent: vi.fn(async () => [pending]),
|
||||
record: vi.fn(),
|
||||
formatDiagnosticText: vi.fn()
|
||||
} as never)
|
||||
|
||||
const result = await handlers.get('crashReports:copyLatestDiagnostics')?.(null, {
|
||||
reportId: pending.id,
|
||||
notes: 'current notes',
|
||||
submissionFailure: {
|
||||
error: 'fallback failed at C:\\Users\\alice\\Orca',
|
||||
diagnosticContext: {
|
||||
status: 'not_uploaded',
|
||||
reason: 'attachment token=super-secret-value',
|
||||
internalEndpointError: 'must-not-cross-copy-boundary'
|
||||
},
|
||||
diagnosticBundleFailure: 'must-not-cross-copy-boundary'
|
||||
}
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: true })
|
||||
const copiedText = String(clipboardWriteTextMock.mock.calls[0]?.[0])
|
||||
expect(copiedText).toContain('Report ID: crash-copy-failure')
|
||||
expect(copiedText).toContain('Submission failure:')
|
||||
expect(copiedText).toContain('Report error: fallback failed at [redacted-path]')
|
||||
expect(copiedText).toContain('Diagnostic logs not uploaded: attachment token=[redacted]')
|
||||
expect(copiedText).not.toContain('alice')
|
||||
expect(copiedText).not.toContain('must-not-cross-copy-boundary')
|
||||
})
|
||||
|
||||
it('returns dismissed unsent reports for the manual Help menu entry', async () => {
|
||||
const dismissed = report('dismissed', 'crash-help-menu')
|
||||
registerCrashReportingHandlers({
|
||||
@@ -244,7 +280,8 @@ describe('registerCrashReportingHandlers', () => {
|
||||
content: diagnosticBundle().payload,
|
||||
bytes: 25,
|
||||
spanCount: 1
|
||||
}
|
||||
},
|
||||
feedbackWithoutDiagnosticBundle: expect.stringContaining('Status: not uploaded')
|
||||
})
|
||||
expect(markSent).toHaveBeenCalledWith(pending.id)
|
||||
})
|
||||
@@ -294,7 +331,8 @@ describe('registerCrashReportingHandlers', () => {
|
||||
content: diagnosticBundle().payload,
|
||||
bytes: 25,
|
||||
spanCount: 1
|
||||
}
|
||||
},
|
||||
feedbackWithoutDiagnosticBundle: expect.stringContaining('Status: not uploaded')
|
||||
})
|
||||
expect(submitFeedbackMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ feedback: expect.stringContaining('Status: attached') })
|
||||
@@ -347,6 +385,52 @@ describe('registerCrashReportingHandlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('marks the report sent when transport retries successfully without diagnostic logs', async () => {
|
||||
const pending = report('pending', 'crash-degraded')
|
||||
const sent = report('sent', pending.id)
|
||||
const markSent = vi.fn(async () => sent)
|
||||
submitFeedbackMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
diagnosticBundleFailure: { status: 413, error: 'status 413' }
|
||||
})
|
||||
registerCrashReportingHandlers({
|
||||
getById: vi.fn(async () => pending),
|
||||
dismiss: vi.fn(),
|
||||
markSent,
|
||||
markDismissedSent: vi.fn(),
|
||||
listRecent: vi.fn(async () => [pending]),
|
||||
record: vi.fn(),
|
||||
formatDiagnosticText: vi.fn()
|
||||
} as never)
|
||||
|
||||
const result = await handlers.get('crashReports:submit')?.(null, {
|
||||
reportId: pending.id,
|
||||
notes: 'manual report',
|
||||
submitAnonymously: true,
|
||||
githubLogin: null,
|
||||
githubEmail: null
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
report: sent,
|
||||
diagnosticBundle: {
|
||||
status: 'not_uploaded',
|
||||
reason: 'diagnostic log attachment failed: status 413',
|
||||
bundleSubmissionId: 'bundleabcdefghijklmnop',
|
||||
bytes: 25,
|
||||
spanCount: 1
|
||||
}
|
||||
})
|
||||
expect(markSent).toHaveBeenCalledWith(pending.id)
|
||||
expect(submitFeedbackMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
feedback: expect.stringContaining('Status: attached'),
|
||||
feedbackWithoutDiagnosticBundle: expect.stringContaining('Status: not uploaded')
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('submits the crash report without logs when the user excludes diagnostic logs', async () => {
|
||||
registerCrashReportingHandlers({
|
||||
getById: vi.fn(async () => null),
|
||||
@@ -459,7 +543,8 @@ describe('registerCrashReportingHandlers', () => {
|
||||
content: diagnosticBundle().payload,
|
||||
bytes: 25,
|
||||
spanCount: 1
|
||||
}
|
||||
},
|
||||
feedbackWithoutDiagnosticBundle: expect.stringContaining('Status: not uploaded')
|
||||
})
|
||||
expect(markDismissedSent).toHaveBeenCalledWith(dismissed.id)
|
||||
})
|
||||
@@ -492,8 +577,9 @@ describe('registerCrashReportingHandlers', () => {
|
||||
const markSent = vi.fn()
|
||||
submitFeedbackMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
error: 'status 500'
|
||||
status: null,
|
||||
error: 'report-only network failed',
|
||||
diagnosticBundleFailure: { status: 500, error: 'status 500' }
|
||||
})
|
||||
registerCrashReportingHandlers({
|
||||
getById: vi.fn(async () => pending),
|
||||
@@ -514,10 +600,18 @@ describe('registerCrashReportingHandlers', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
status: 500,
|
||||
error: 'status 500',
|
||||
report: pending
|
||||
status: null,
|
||||
error: 'report-only network failed',
|
||||
report: pending,
|
||||
diagnosticBundle: {
|
||||
status: 'not_uploaded',
|
||||
reason: 'diagnostic log attachment failed: status 500',
|
||||
bundleSubmissionId: 'bundleabcdefghijklmnop',
|
||||
bytes: 25,
|
||||
spanCount: 1
|
||||
}
|
||||
})
|
||||
expect(result).not.toHaveProperty('diagnosticBundleFailure')
|
||||
expect(submitFeedbackMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
diagnosticBundle: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import os from 'node:os'
|
||||
import { app, clipboard, ipcMain } from 'electron'
|
||||
import {
|
||||
type CrashReportBreadcrumbData,
|
||||
type CrashReportCopyDiagnosticsArgs,
|
||||
type CrashReportDiagnosticBundle,
|
||||
type ReactErrorBoundaryReportArgs,
|
||||
type ReactErrorBoundaryReportResult,
|
||||
@@ -20,14 +21,17 @@ import {
|
||||
getCrashBreadcrumbSnapshot,
|
||||
recordCrashBreadcrumb
|
||||
} from '../crash-reporting/crash-breadcrumb-store'
|
||||
import { collectDiagnosticBundle, getDiagnosticsStatus } from '../observability'
|
||||
import { resolveDiagnosticOrcaChannel } from '../observability/diagnostic-upload-endpoint'
|
||||
import { startSpan } from '../observability/tracer'
|
||||
import type { FeedbackDiagnosticBundleAttachment } from './feedback'
|
||||
import {
|
||||
diagnosticBundleForReportOnlyRetry,
|
||||
prepareCrashDiagnosticBundle,
|
||||
resolveSubmittedDiagnosticBundle
|
||||
} from '../crash-reporting/crash-feedback-diagnostic-bundle'
|
||||
import {
|
||||
assertClipboardTextWriteWithinLimit,
|
||||
isClipboardTextWriteTooLargeError
|
||||
} from '../../shared/clipboard-text'
|
||||
import { formatCrashReportCopyText } from '../crash-reporting/crash-report-copy-text'
|
||||
|
||||
const inFlightSubmissions = new Set<string>()
|
||||
const submittedReportIds = new Set<string>()
|
||||
@@ -37,7 +41,6 @@ const RENDERER_ERROR_DEDUPE_MS = 10 * 60 * 1000
|
||||
const MAX_RENDERER_ERROR_KEY_AGE_MS = RENDERER_ERROR_DEDUPE_MS * 2
|
||||
const MAX_RECENT_RENDERER_ERROR_REPORT_KEYS = 256
|
||||
const MAX_SUBMITTED_REPORT_IDS = 256
|
||||
const CRASH_REPORT_LOG_LOOKBACK_MINUTES = 3 * 24 * 60
|
||||
|
||||
const REACT_ERROR_BOUNDARY_SURFACES = new Set<ReactErrorBoundaryReportArgs['surface']>([
|
||||
'app-root',
|
||||
@@ -52,11 +55,6 @@ const REACT_ERROR_BOUNDARY_SURFACES = new Set<ReactErrorBoundaryReportArgs['surf
|
||||
'rich-markdown-editor'
|
||||
])
|
||||
|
||||
type CrashDiagnosticBundleAttachment = {
|
||||
readonly diagnosticBundle: CrashReportDiagnosticBundle
|
||||
readonly feedbackDiagnosticBundle?: FeedbackDiagnosticBundleAttachment
|
||||
}
|
||||
|
||||
function stringField(value: unknown, maxLength: number): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined
|
||||
@@ -302,10 +300,6 @@ function recordRendererBreadcrumbTrace(
|
||||
span.end()
|
||||
}
|
||||
|
||||
function formatUnknownError(error: unknown): string {
|
||||
return sanitizeCrashReportString(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
|
||||
function buildUncapturedCrashReportText(
|
||||
notes: string | undefined,
|
||||
diagnosticBundle?: CrashReportDiagnosticBundle
|
||||
@@ -325,58 +319,6 @@ function buildUncapturedCrashReportText(
|
||||
)
|
||||
}
|
||||
|
||||
function skippedCrashDiagnosticBundle(): CrashDiagnosticBundleAttachment {
|
||||
return {
|
||||
diagnosticBundle: {
|
||||
status: 'not_uploaded',
|
||||
reason: 'diagnostic log upload skipped by user'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectCrashDiagnosticBundleAttachment(): CrashDiagnosticBundleAttachment {
|
||||
const status = getDiagnosticsStatus()
|
||||
if (!status.bundleEnabled) {
|
||||
return {
|
||||
diagnosticBundle: {
|
||||
status: 'not_uploaded',
|
||||
reason: status.disabledReason ?? 'diagnostic bundle collection is disabled'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let bundle: ReturnType<typeof collectDiagnosticBundle>
|
||||
try {
|
||||
bundle = collectDiagnosticBundle({
|
||||
appVersion: app.getVersion(),
|
||||
platform: os.platform(),
|
||||
arch: os.arch(),
|
||||
osRelease: os.release(),
|
||||
orcaChannel: resolveDiagnosticOrcaChannel(),
|
||||
// Why: Help > Report Crash is often used after relaunch, long after the
|
||||
// default 30 minute support bundle window would miss the failure context.
|
||||
lookbackMinutes: CRASH_REPORT_LOG_LOOKBACK_MINUTES
|
||||
})
|
||||
} catch (error) {
|
||||
return { diagnosticBundle: { status: 'not_uploaded', reason: formatUnknownError(error) } }
|
||||
}
|
||||
|
||||
return {
|
||||
diagnosticBundle: {
|
||||
status: 'attached',
|
||||
bundleSubmissionId: bundle.bundleSubmissionId,
|
||||
bytes: bundle.bytes,
|
||||
spanCount: bundle.spanCount
|
||||
},
|
||||
feedbackDiagnosticBundle: {
|
||||
bundleSubmissionId: bundle.bundleSubmissionId,
|
||||
content: bundle.payload,
|
||||
bytes: bundle.bytes,
|
||||
spanCount: bundle.spanCount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function registerCrashReportingHandlers(store: CrashReportStore): void {
|
||||
ipcMain.removeHandler('crashReports:getLatestPending')
|
||||
ipcMain.handle('crashReports:getLatestPending', () => getLatestPendingReport(store))
|
||||
@@ -412,15 +354,16 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void {
|
||||
ipcMain.removeHandler('crashReports:copyLatestDiagnostics')
|
||||
ipcMain.handle(
|
||||
'crashReports:copyLatestDiagnostics',
|
||||
async (_event, args?: { reportId?: string; notes?: string }) => {
|
||||
async (_event, args?: CrashReportCopyDiagnosticsArgs) => {
|
||||
const report = await getRequestedCrashReport(store, args)
|
||||
if (!report) {
|
||||
clipboard.writeText(buildUncapturedCrashReportText(args?.notes))
|
||||
return { ok: true as const }
|
||||
}
|
||||
const baseText = report
|
||||
? formatCrashReportText(report, args?.notes)
|
||||
: buildUncapturedCrashReportText(args?.notes)
|
||||
try {
|
||||
clipboard.writeText(
|
||||
assertClipboardTextWriteWithinLimit(formatCrashReportText(report, args?.notes))
|
||||
assertClipboardTextWriteWithinLimit(
|
||||
formatCrashReportCopyText(baseText, args?.submissionFailure)
|
||||
)
|
||||
)
|
||||
} catch (error) {
|
||||
if (isClipboardTextWriteTooLargeError(error)) {
|
||||
@@ -448,11 +391,9 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void {
|
||||
async (_event, args: CrashReportSubmitArgs): Promise<CrashReportSubmitResult> => {
|
||||
const report = await getRequestedCrashReport(store, args)
|
||||
if (!report) {
|
||||
const diagnosticUpload =
|
||||
args.includeDiagnosticLogs === false
|
||||
? skippedCrashDiagnosticBundle()
|
||||
: collectCrashDiagnosticBundleAttachment()
|
||||
const diagnosticUpload = prepareCrashDiagnosticBundle(args.includeDiagnosticLogs !== false)
|
||||
const diagnosticBundle = diagnosticUpload.diagnosticBundle
|
||||
const reportOnlyDiagnosticBundle = diagnosticBundleForReportOnlyRetry(diagnosticUpload)
|
||||
const result = await submitFeedback({
|
||||
feedback: buildUncapturedCrashReportText(args.notes, diagnosticBundle),
|
||||
submissionType: 'crash',
|
||||
@@ -460,14 +401,26 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void {
|
||||
githubLogin: args.githubLogin,
|
||||
githubEmail: args.githubEmail,
|
||||
...(diagnosticUpload.feedbackDiagnosticBundle
|
||||
? { diagnosticBundle: diagnosticUpload.feedbackDiagnosticBundle }
|
||||
? {
|
||||
diagnosticBundle: diagnosticUpload.feedbackDiagnosticBundle,
|
||||
feedbackWithoutDiagnosticBundle: buildUncapturedCrashReportText(
|
||||
args.notes,
|
||||
reportOnlyDiagnosticBundle
|
||||
)
|
||||
}
|
||||
: {})
|
||||
})
|
||||
const submittedDiagnosticBundle = resolveSubmittedDiagnosticBundle(diagnosticUpload, result)
|
||||
return result.ok
|
||||
? { ok: true, report: null, diagnosticBundle }
|
||||
? { ok: true, report: null, diagnosticBundle: submittedDiagnosticBundle }
|
||||
: {
|
||||
...result,
|
||||
report: null
|
||||
// Why: the transport-only attachment failure may contain raw
|
||||
// endpoint detail; only its sanitized bundle reason crosses IPC.
|
||||
ok: false,
|
||||
status: result.status,
|
||||
error: result.error,
|
||||
report: null,
|
||||
diagnosticBundle: submittedDiagnosticBundle
|
||||
}
|
||||
}
|
||||
const canSubmitDismissedReport = Boolean(args.reportId && report.status === 'dismissed')
|
||||
@@ -491,11 +444,9 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void {
|
||||
|
||||
inFlightSubmissions.add(report.id)
|
||||
try {
|
||||
const diagnosticUpload =
|
||||
args.includeDiagnosticLogs === false
|
||||
? skippedCrashDiagnosticBundle()
|
||||
: collectCrashDiagnosticBundleAttachment()
|
||||
const diagnosticUpload = prepareCrashDiagnosticBundle(args.includeDiagnosticLogs !== false)
|
||||
const diagnosticBundle = diagnosticUpload.diagnosticBundle
|
||||
const reportOnlyDiagnosticBundle = diagnosticBundleForReportOnlyRetry(diagnosticUpload)
|
||||
const result = await submitFeedback({
|
||||
feedback: formatCrashReportText(report, args.notes, diagnosticBundle),
|
||||
submissionType: 'crash',
|
||||
@@ -503,13 +454,26 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void {
|
||||
githubLogin: args.githubLogin,
|
||||
githubEmail: args.githubEmail,
|
||||
...(diagnosticUpload.feedbackDiagnosticBundle
|
||||
? { diagnosticBundle: diagnosticUpload.feedbackDiagnosticBundle }
|
||||
? {
|
||||
diagnosticBundle: diagnosticUpload.feedbackDiagnosticBundle,
|
||||
feedbackWithoutDiagnosticBundle: formatCrashReportText(
|
||||
report,
|
||||
args.notes,
|
||||
reportOnlyDiagnosticBundle
|
||||
)
|
||||
}
|
||||
: {})
|
||||
})
|
||||
const submittedDiagnosticBundle = resolveSubmittedDiagnosticBundle(diagnosticUpload, result)
|
||||
if (!result.ok) {
|
||||
return {
|
||||
...result,
|
||||
report
|
||||
// Why: keep the renderer contract allow-listed instead of leaking
|
||||
// the transport's internal diagnosticBundleFailure object.
|
||||
ok: false,
|
||||
status: result.status,
|
||||
error: result.error,
|
||||
report,
|
||||
diagnosticBundle: submittedDiagnosticBundle
|
||||
}
|
||||
}
|
||||
rememberSubmittedReportId(report.id)
|
||||
@@ -518,21 +482,37 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void {
|
||||
// Why: startup prompts are dismissed before the user can send from
|
||||
// the still-open dialog, so successful uploads must update storage.
|
||||
const sent = await store.markDismissedSent(report.id)
|
||||
return { ok: true, report: sent ?? { ...report, status: 'sent' }, diagnosticBundle }
|
||||
return {
|
||||
ok: true,
|
||||
report: sent ?? { ...report, status: 'sent' },
|
||||
diagnosticBundle: submittedDiagnosticBundle
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[crash-reporting] Failed to mark dismissed crash report sent:', error)
|
||||
return { ok: true, report: { ...report, status: 'sent' }, diagnosticBundle }
|
||||
return {
|
||||
ok: true,
|
||||
report: { ...report, status: 'sent' },
|
||||
diagnosticBundle: submittedDiagnosticBundle
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
const sent = await store.markSent(report.id)
|
||||
return { ok: true, report: sent ?? { ...report, status: 'sent' }, diagnosticBundle }
|
||||
return {
|
||||
ok: true,
|
||||
report: sent ?? { ...report, status: 'sent' },
|
||||
diagnosticBundle: submittedDiagnosticBundle
|
||||
}
|
||||
} catch (error) {
|
||||
// Why: the upstream submission already succeeded. A local persistence
|
||||
// failure must not present as upload failure or invite duplicate sends
|
||||
// during this app session.
|
||||
console.error('[crash-reporting] Failed to mark crash report sent:', error)
|
||||
return { ok: true, report: { ...report, status: 'sent' }, diagnosticBundle }
|
||||
return {
|
||||
ok: true,
|
||||
report: { ...report, status: 'sent' },
|
||||
diagnosticBundle: submittedDiagnosticBundle
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
inFlightSubmissions.delete(report.id)
|
||||
|
||||
@@ -22,9 +22,34 @@ function okResponse(): Response {
|
||||
return { ok: true, status: 200 } as unknown as Response
|
||||
}
|
||||
|
||||
function postedBody(): Record<string, unknown> {
|
||||
const init = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined
|
||||
return JSON.parse(String(init?.body)) as Record<string, unknown>
|
||||
function errorResponse(status: number): Response {
|
||||
return { ok: false, status } as unknown as Response
|
||||
}
|
||||
|
||||
function requestInit(callIndex = 0): RequestInit {
|
||||
return fetchMock.mock.calls[callIndex]?.[1] as RequestInit
|
||||
}
|
||||
|
||||
function postedBody(callIndex = 0): Record<string, unknown> {
|
||||
return JSON.parse(String(requestInit(callIndex).body)) as Record<string, unknown>
|
||||
}
|
||||
|
||||
function diagnosticSubmitArgs(): Parameters<typeof submitFeedback>[0] {
|
||||
return {
|
||||
feedback: '[Crash Report]\n\nDiagnostic log:\n- Status: attached',
|
||||
feedbackWithoutDiagnosticBundle:
|
||||
'[Crash Report]\n\nDiagnostic log:\n- Status: not uploaded\n- Reason: attachment failed',
|
||||
submissionType: 'crash',
|
||||
submitAnonymously: true,
|
||||
githubLogin: null,
|
||||
githubEmail: null,
|
||||
diagnosticBundle: {
|
||||
bundleSubmissionId: 'bundleabcdefghijklmnop',
|
||||
content: '{"type":"bundle-header"}\n',
|
||||
bytes: 25,
|
||||
spanCount: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('submitFeedback', () => {
|
||||
@@ -139,6 +164,118 @@ describe('submitFeedback', () => {
|
||||
expect(JSON.parse(String(feedbackInit?.body))).not.toHaveProperty('diagnosticBundle')
|
||||
})
|
||||
|
||||
it('retries a rejected diagnostic attachment as report-only JSON on the website API', async () => {
|
||||
fetchMock.mockResolvedValueOnce(errorResponse(413)).mockResolvedValueOnce(okResponse())
|
||||
|
||||
await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({
|
||||
ok: true,
|
||||
diagnosticBundleFailure: { status: 413, error: 'status 413' }
|
||||
})
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe('https://www.onorca.dev/v1/feedback')
|
||||
expect(requestInit(0).body).toBeInstanceOf(FormData)
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe('https://www.onorca.dev/v1/feedback')
|
||||
expect(requestInit(1).headers).toEqual({
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
expect(postedBody(1)).toMatchObject({
|
||||
feedback:
|
||||
'[Crash Report]\n\nDiagnostic log:\n- Status: not uploaded\n- Reason: attachment failed',
|
||||
submissionType: 'crash'
|
||||
})
|
||||
expect(postedBody(1)).not.toHaveProperty('diagnosticBundle')
|
||||
})
|
||||
|
||||
it('retries a diagnostic attachment server error as report-only JSON on the fallback API', async () => {
|
||||
fetchMock.mockResolvedValueOnce(errorResponse(500)).mockResolvedValueOnce(okResponse())
|
||||
|
||||
await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({
|
||||
ok: true,
|
||||
diagnosticBundleFailure: { status: 500, error: 'status 500' }
|
||||
})
|
||||
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe('https://api.onorca.dev/v1/feedback')
|
||||
expect(requestInit(1).headers).toEqual({ 'Content-Type': 'application/json' })
|
||||
expect(postedBody(1)).not.toHaveProperty('diagnosticBundle')
|
||||
})
|
||||
|
||||
it('retries a diagnostic attachment network error as report-only JSON on the fallback API', async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error('attachment network failed'))
|
||||
fetchMock.mockResolvedValueOnce(okResponse())
|
||||
|
||||
await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({
|
||||
ok: true,
|
||||
diagnosticBundleFailure: { status: null, error: 'attachment network failed' }
|
||||
})
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe('https://api.onorca.dev/v1/feedback')
|
||||
expect(requestInit(1).body).not.toBeInstanceOf(FormData)
|
||||
expect(postedBody(1)).not.toHaveProperty('diagnosticBundle')
|
||||
})
|
||||
|
||||
it('allows 60 seconds for a diagnostic attachment before retrying report-only JSON', async () => {
|
||||
vi.useFakeTimers()
|
||||
fetchMock.mockImplementationOnce((_url: string, init?: RequestInit) => {
|
||||
return new Promise((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => reject(new Error('runtime abort text')))
|
||||
})
|
||||
})
|
||||
fetchMock.mockResolvedValueOnce(okResponse())
|
||||
const result = submitFeedback(diagnosticSubmitArgs())
|
||||
|
||||
await vi.advanceTimersByTimeAsync(59_999)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
|
||||
await expect(result).resolves.toEqual({
|
||||
ok: true,
|
||||
diagnosticBundleFailure: { status: null, error: 'request timed out after 60 seconds' }
|
||||
})
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe('https://api.onorca.dev/v1/feedback')
|
||||
expect(postedBody(1)).not.toHaveProperty('diagnosticBundle')
|
||||
})
|
||||
|
||||
it('retries a proxy-rejected diagnostic attachment as website JSON', async () => {
|
||||
fetchMock.mockResolvedValueOnce(errorResponse(403)).mockResolvedValueOnce(okResponse())
|
||||
|
||||
await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({
|
||||
ok: true,
|
||||
diagnosticBundleFailure: { status: 403, error: 'status 403' }
|
||||
})
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe('https://www.onorca.dev/v1/feedback')
|
||||
expect(requestInit(1).body).not.toBeInstanceOf(FormData)
|
||||
})
|
||||
|
||||
it.each([401, 409, 429])(
|
||||
'does not retry a diagnostic attachment rejected with status %s',
|
||||
async (status) => {
|
||||
fetchMock.mockResolvedValueOnce(errorResponse(status))
|
||||
|
||||
await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({
|
||||
ok: false,
|
||||
status,
|
||||
error: `status ${status}`
|
||||
})
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves attachment and report-only failures when the degraded retry fails', async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error('attachment network failed'))
|
||||
fetchMock.mockRejectedValueOnce(new Error('report-only network failed'))
|
||||
|
||||
await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({
|
||||
ok: false,
|
||||
status: null,
|
||||
error: 'report-only network failed',
|
||||
diagnosticBundleFailure: { status: null, error: 'attachment network failed' }
|
||||
})
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('falls back when the primary feedback request stalls', async () => {
|
||||
vi.useFakeTimers()
|
||||
fetchMock.mockImplementation((url: string, init?: RequestInit) => {
|
||||
@@ -184,7 +321,7 @@ describe('submitFeedback', () => {
|
||||
await expect(Promise.race([result, Promise.resolve('pending')])).resolves.toEqual({
|
||||
ok: false,
|
||||
status: null,
|
||||
error: 'fallback aborted'
|
||||
error: 'request timed out after 10 seconds'
|
||||
})
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
+107
-4
@@ -9,7 +9,11 @@ import { app, ipcMain, net } from 'electron'
|
||||
const FEEDBACK_API_URL = 'https://www.onorca.dev/v1/feedback'
|
||||
const FEEDBACK_API_FALLBACK_URL = 'https://api.onorca.dev/v1/feedback'
|
||||
const FEEDBACK_REQUEST_TIMEOUT_MS = 10_000
|
||||
const FEEDBACK_ATTACHMENT_REQUEST_TIMEOUT_MS = 60_000
|
||||
const DIAGNOSTIC_BUNDLE_CONTENT_TYPE = 'application/x-ndjson'
|
||||
// Why: corporate filters can reject multipart with 403 while allowing the
|
||||
// small JSON report, so content-shaped failures should shed the attachment.
|
||||
const DIAGNOSTIC_BUNDLE_JSON_RETRY_STATUSES = new Set([400, 403, 408, 413, 415, 422])
|
||||
|
||||
export type FeedbackSubmissionType = 'feedback' | 'crash'
|
||||
|
||||
@@ -39,13 +43,21 @@ type FeedbackSubmitBody = {
|
||||
diagnosticBundle?: FeedbackDiagnosticBundleAttachment
|
||||
}
|
||||
|
||||
export type FeedbackRequestFailure = {
|
||||
status: number | null
|
||||
error: string
|
||||
}
|
||||
|
||||
export type FeedbackSubmitResult =
|
||||
| { ok: true }
|
||||
| { ok: false; status: number | null; error: string }
|
||||
| { ok: true; diagnosticBundleFailure?: FeedbackRequestFailure }
|
||||
| ({ ok: false } & FeedbackRequestFailure & {
|
||||
diagnosticBundleFailure?: FeedbackRequestFailure
|
||||
})
|
||||
|
||||
type InternalFeedbackSubmitArgs = FeedbackSubmitArgs & {
|
||||
submissionType?: FeedbackSubmissionType
|
||||
diagnosticBundle?: FeedbackDiagnosticBundleAttachment
|
||||
feedbackWithoutDiagnosticBundle?: string
|
||||
}
|
||||
|
||||
// Why: the Slack notification and any follow-up investigation need to know
|
||||
@@ -74,11 +86,15 @@ function buildSubmitBody(args: InternalFeedbackSubmitArgs): FeedbackSubmitBody {
|
||||
}
|
||||
}
|
||||
|
||||
async function postFeedback(url: string, body: FeedbackSubmitBody): Promise<Response> {
|
||||
async function postFeedback(
|
||||
url: string,
|
||||
body: FeedbackSubmitBody,
|
||||
timeoutMs = FEEDBACK_REQUEST_TIMEOUT_MS
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController()
|
||||
// Why: a silent feedback endpoint should not leave IPC or crash-report
|
||||
// submission flows pending forever.
|
||||
const timeout = setTimeout(() => controller.abort(), FEEDBACK_REQUEST_TIMEOUT_MS)
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs)
|
||||
try {
|
||||
const init: RequestInit = {
|
||||
method: 'POST',
|
||||
@@ -86,6 +102,13 @@ async function postFeedback(url: string, body: FeedbackSubmitBody): Promise<Resp
|
||||
signal: controller.signal
|
||||
}
|
||||
return await net.fetch(url, init)
|
||||
} catch (error) {
|
||||
// Why: Electron and Node use different AbortError messages. Normalize our
|
||||
// client deadline so support logs explain which request budget expired.
|
||||
if (controller.signal.aborted) {
|
||||
throw new Error(`request timed out after ${timeoutMs / 1000} seconds`)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
@@ -140,6 +163,14 @@ function messageFromError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function responseFailure(response: Response): FeedbackRequestFailure {
|
||||
return { status: response.status, error: `status ${response.status}` }
|
||||
}
|
||||
|
||||
function errorFailure(error: unknown): FeedbackRequestFailure {
|
||||
return { status: null, error: messageFromError(error) }
|
||||
}
|
||||
|
||||
async function submitFallbackFeedback(
|
||||
body: FeedbackSubmitBody,
|
||||
primaryError?: unknown
|
||||
@@ -163,10 +194,82 @@ async function submitFallbackFeedback(
|
||||
}
|
||||
}
|
||||
|
||||
function diagnosticRetryUrl(status: number): string | null {
|
||||
if (DIAGNOSTIC_BUNDLE_JSON_RETRY_STATUSES.has(status)) {
|
||||
return FEEDBACK_API_URL
|
||||
}
|
||||
if (status === 404 || status >= 500) {
|
||||
return FEEDBACK_API_FALLBACK_URL
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function submitFeedbackWithoutDiagnosticBundle(
|
||||
url: string,
|
||||
body: FeedbackSubmitBody,
|
||||
diagnosticBundleFailure: FeedbackRequestFailure
|
||||
): Promise<FeedbackSubmitResult> {
|
||||
try {
|
||||
const response = await postFeedback(url, body)
|
||||
if (response.ok) {
|
||||
return { ok: true, diagnosticBundleFailure }
|
||||
}
|
||||
return { ok: false, ...responseFailure(response), diagnosticBundleFailure }
|
||||
} catch (error) {
|
||||
return { ok: false, ...errorFailure(error), diagnosticBundleFailure }
|
||||
}
|
||||
}
|
||||
|
||||
async function submitFeedbackWithDiagnosticBundle(
|
||||
body: FeedbackSubmitBody,
|
||||
bodyWithoutDiagnosticBundle: FeedbackSubmitBody | null
|
||||
): Promise<FeedbackSubmitResult> {
|
||||
try {
|
||||
// Why: diagnostic bundles can approach 4 MiB and need more upload time than
|
||||
// the small JSON report-only path, especially on constrained connections.
|
||||
const response = await postFeedback(
|
||||
FEEDBACK_API_URL,
|
||||
body,
|
||||
FEEDBACK_ATTACHMENT_REQUEST_TIMEOUT_MS
|
||||
)
|
||||
if (response.ok) {
|
||||
return { ok: true }
|
||||
}
|
||||
const failure = responseFailure(response)
|
||||
if (bodyWithoutDiagnosticBundle) {
|
||||
const retryUrl = diagnosticRetryUrl(response.status)
|
||||
if (retryUrl) {
|
||||
return submitFeedbackWithoutDiagnosticBundle(retryUrl, bodyWithoutDiagnosticBundle, failure)
|
||||
}
|
||||
}
|
||||
return { ok: false, ...failure }
|
||||
} catch (error) {
|
||||
const failure = errorFailure(error)
|
||||
return bodyWithoutDiagnosticBundle
|
||||
? submitFeedbackWithoutDiagnosticBundle(
|
||||
FEEDBACK_API_FALLBACK_URL,
|
||||
bodyWithoutDiagnosticBundle,
|
||||
failure
|
||||
)
|
||||
: { ok: false, ...failure }
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitFeedback(
|
||||
args: InternalFeedbackSubmitArgs
|
||||
): Promise<FeedbackSubmitResult> {
|
||||
const body = buildSubmitBody(args)
|
||||
if (body.diagnosticBundle) {
|
||||
const bodyWithoutDiagnosticBundle =
|
||||
args.feedbackWithoutDiagnosticBundle !== undefined
|
||||
? buildSubmitBody({
|
||||
...args,
|
||||
feedback: args.feedbackWithoutDiagnosticBundle,
|
||||
diagnosticBundle: undefined
|
||||
})
|
||||
: null
|
||||
return submitFeedbackWithDiagnosticBundle(body, bodyWithoutDiagnosticBundle)
|
||||
}
|
||||
try {
|
||||
const res = await postFeedback(FEEDBACK_API_URL, body)
|
||||
if (res.ok) {
|
||||
|
||||
@@ -27,7 +27,11 @@ import type {
|
||||
import { getPRForBranch } from '../github/client'
|
||||
import { listWorktrees, addWorktree, addSparseWorktree } from '../git/worktree'
|
||||
import type { AddWorktreeOptions, AddWorktreeResult } from '../git/worktree'
|
||||
import { getBranchConflictKind, resolveDefaultBaseRefViaExec } from '../git/repo'
|
||||
import {
|
||||
getBranchConflictKind,
|
||||
resolveDefaultBaseRefViaExec,
|
||||
resolveDefaultBaseRefWithLocalGit
|
||||
} from '../git/repo'
|
||||
import { resolveLocalGitUsername } from '../git/git-username'
|
||||
import { hasCommitObjectViaGitExec } from '../git/commit-object-ref'
|
||||
import { resolveWorktreeCreateBase } from '../worktree-create-base'
|
||||
@@ -1999,8 +2003,7 @@ export async function createLocalWorktree(
|
||||
const baseBranch = await resolveWorktreeCreateBase({
|
||||
requestedBaseBranch: args.baseBranch,
|
||||
repoWorktreeBaseRef: repo.worktreeBaseRef,
|
||||
resolveDefaultBaseRef: () =>
|
||||
resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions)),
|
||||
resolveDefaultBaseRef: () => resolveDefaultBaseRefWithLocalGit(localGitExecOptions),
|
||||
isBaseUsable: async (baseBranchCandidate) => {
|
||||
if (runtime) {
|
||||
const remoteTrackingBase = await runtime.resolveRemoteTrackingBase(
|
||||
|
||||
@@ -10,6 +10,7 @@ const {
|
||||
removeWorktreeMock,
|
||||
resolveLocalGitUsernameMock,
|
||||
getDefaultBaseRefMock,
|
||||
resolveDefaultBaseRefWithLocalGitMock,
|
||||
resolveDefaultBaseRefViaExecMock,
|
||||
getBranchConflictKindMock,
|
||||
getPRForBranchMock,
|
||||
@@ -38,6 +39,7 @@ const {
|
||||
removeWorktreeMock: vi.fn(),
|
||||
resolveLocalGitUsernameMock: vi.fn(),
|
||||
getDefaultBaseRefMock: vi.fn(),
|
||||
resolveDefaultBaseRefWithLocalGitMock: vi.fn(),
|
||||
resolveDefaultBaseRefViaExecMock: vi.fn(),
|
||||
getBranchConflictKindMock: vi.fn(),
|
||||
getPRForBranchMock: vi.fn(),
|
||||
@@ -84,6 +86,7 @@ vi.mock('../git/runner', () => ({
|
||||
|
||||
vi.mock('../git/repo', () => ({
|
||||
getDefaultBaseRef: getDefaultBaseRefMock,
|
||||
resolveDefaultBaseRefWithLocalGit: resolveDefaultBaseRefWithLocalGitMock,
|
||||
resolveDefaultBaseRefViaExec: resolveDefaultBaseRefViaExecMock,
|
||||
getBranchConflictKind: getBranchConflictKindMock
|
||||
}))
|
||||
@@ -164,6 +167,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
|
||||
removeWorktreeMock.mockReset()
|
||||
resolveLocalGitUsernameMock.mockReset()
|
||||
getDefaultBaseRefMock.mockReset()
|
||||
resolveDefaultBaseRefWithLocalGitMock.mockReset()
|
||||
resolveDefaultBaseRefViaExecMock.mockReset()
|
||||
getBranchConflictKindMock.mockReset()
|
||||
getPRForBranchMock.mockReset()
|
||||
@@ -230,6 +234,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
|
||||
store.setWorktreeMeta.mockReturnValue({})
|
||||
resolveLocalGitUsernameMock.mockResolvedValue('')
|
||||
getDefaultBaseRefMock.mockReturnValue('origin/main')
|
||||
resolveDefaultBaseRefWithLocalGitMock.mockResolvedValue('origin/main')
|
||||
resolveDefaultBaseRefViaExecMock.mockResolvedValue('origin/main')
|
||||
getBranchConflictKindMock.mockResolvedValue(null)
|
||||
getPRForBranchMock.mockResolvedValue(null)
|
||||
|
||||
@@ -28,7 +28,8 @@ const {
|
||||
removeWorktreeMock,
|
||||
forceDeleteLocalBranchMock,
|
||||
resolveLocalGitUsernameMock,
|
||||
getDefaultBaseRefMock,
|
||||
getBaseRefDefaultMock,
|
||||
resolveDefaultBaseRefWithLocalGitMock,
|
||||
resolveDefaultBaseRefViaExecMock,
|
||||
getDefaultRemoteMock,
|
||||
getBranchConflictKindMock,
|
||||
@@ -77,7 +78,8 @@ const {
|
||||
removeWorktreeMock: vi.fn(),
|
||||
forceDeleteLocalBranchMock: vi.fn(),
|
||||
resolveLocalGitUsernameMock: vi.fn(),
|
||||
getDefaultBaseRefMock: vi.fn(),
|
||||
getBaseRefDefaultMock: vi.fn(),
|
||||
resolveDefaultBaseRefWithLocalGitMock: vi.fn(),
|
||||
resolveDefaultBaseRefViaExecMock: vi.fn(),
|
||||
getDefaultRemoteMock: vi.fn(),
|
||||
getBranchConflictKindMock: vi.fn(),
|
||||
@@ -130,7 +132,8 @@ vi.mock('../git/runner', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../git/repo', () => ({
|
||||
getDefaultBaseRef: getDefaultBaseRefMock,
|
||||
getBaseRefDefault: getBaseRefDefaultMock,
|
||||
resolveDefaultBaseRefWithLocalGit: resolveDefaultBaseRefWithLocalGitMock,
|
||||
resolveDefaultBaseRefViaExec: resolveDefaultBaseRefViaExecMock,
|
||||
getDefaultRemote: getDefaultRemoteMock,
|
||||
getBranchConflictKind: getBranchConflictKindMock
|
||||
@@ -300,7 +303,8 @@ describe('registerWorktreeHandlers', () => {
|
||||
removeWorktreeMock,
|
||||
forceDeleteLocalBranchMock,
|
||||
resolveLocalGitUsernameMock,
|
||||
getDefaultBaseRefMock,
|
||||
getBaseRefDefaultMock,
|
||||
resolveDefaultBaseRefWithLocalGitMock,
|
||||
resolveDefaultBaseRefViaExecMock,
|
||||
getDefaultRemoteMock,
|
||||
getBranchConflictKindMock,
|
||||
@@ -401,7 +405,8 @@ describe('registerWorktreeHandlers', () => {
|
||||
])
|
||||
store.getAllWorktreeLineage.mockReturnValue({})
|
||||
resolveLocalGitUsernameMock.mockResolvedValue('')
|
||||
getDefaultBaseRefMock.mockReturnValue('origin/main')
|
||||
getBaseRefDefaultMock.mockResolvedValue('origin/main')
|
||||
resolveDefaultBaseRefWithLocalGitMock.mockResolvedValue('origin/main')
|
||||
resolveDefaultBaseRefViaExecMock.mockResolvedValue('origin/main')
|
||||
getDefaultRemoteMock.mockResolvedValue('origin')
|
||||
getBranchConflictKindMock.mockResolvedValue(null)
|
||||
@@ -525,6 +530,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
|
||||
await handlers['worktrees:prefetchCreateBase'](null, { repoId: 'repo-1' })
|
||||
|
||||
expect(getBaseRefDefaultMock).toHaveBeenCalledWith('/workspace/repo')
|
||||
expect(runtimeStub.resolveRemoteTrackingBase).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'origin/master'
|
||||
@@ -2050,12 +2056,6 @@ describe('registerWorktreeHandlers', () => {
|
||||
|
||||
it('routes local worktree creation through the selected WSL project runtime', async () => {
|
||||
mockSelectedWslProjectRuntime()
|
||||
resolveDefaultBaseRefViaExecMock.mockImplementation(
|
||||
async (exec: (args: string[]) => Promise<{ stdout: string }>) => {
|
||||
await exec(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'])
|
||||
return 'origin/main'
|
||||
}
|
||||
)
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/repo',
|
||||
@@ -2087,10 +2087,10 @@ describe('registerWorktreeHandlers', () => {
|
||||
false,
|
||||
{ wslDistro: 'Ubuntu' }
|
||||
)
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'],
|
||||
{ cwd: '/workspace/repo', wslDistro: 'Ubuntu' }
|
||||
)
|
||||
expect(resolveDefaultBaseRefWithLocalGitMock).toHaveBeenCalledWith({
|
||||
cwd: '/workspace/repo',
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
expect(getBranchConflictKindMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'improve-dashboard',
|
||||
@@ -4980,7 +4980,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
// no origin/main, no origin/master, and no local main/master), we must
|
||||
// fail loudly with a message that prompts the user to pick a base
|
||||
// branch, not hand a non-existent ref to `git worktree add`.
|
||||
resolveDefaultBaseRefViaExecMock.mockResolvedValue(null)
|
||||
resolveDefaultBaseRefWithLocalGitMock.mockResolvedValue(null)
|
||||
store.getRepo.mockReturnValue({
|
||||
id: 'repo-1',
|
||||
path: '/workspace/repo',
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
_resetTracerForTests,
|
||||
flushActiveSink,
|
||||
getActiveSpanContext,
|
||||
setActiveSink,
|
||||
startSpan,
|
||||
@@ -69,6 +70,23 @@ describe('tracer — basic span lifecycle', () => {
|
||||
expect(r.exit.cause).toBe('user-cancelled')
|
||||
})
|
||||
|
||||
it('keeps forced sink flush failures from escaping crash boundaries', () => {
|
||||
sink.flush = () => {
|
||||
throw new Error('disk unavailable')
|
||||
}
|
||||
|
||||
expect(() => flushActiveSink()).not.toThrow()
|
||||
})
|
||||
|
||||
it('keeps sink handoff failures from escaping instrumented operations', async () => {
|
||||
sink.push = () => {
|
||||
throw new Error('trace rotation failed')
|
||||
}
|
||||
|
||||
expect(() => startSpan('test').fail('boom')).not.toThrow()
|
||||
await expect(withSpan('async-test', async () => 'result')).resolves.toBe('result')
|
||||
})
|
||||
|
||||
it('end() is idempotent — second call is a no-op', () => {
|
||||
const span = startSpan('test')
|
||||
span.end()
|
||||
|
||||
@@ -112,6 +112,17 @@ export function setActiveSink(sink: TracerSink | null): void {
|
||||
activeSink = sink
|
||||
}
|
||||
|
||||
/** Force records already handed to the tracer onto disk. Reserve this for
|
||||
* crash boundaries where the process may not survive the normal batch window. */
|
||||
export function flushActiveSink(): void {
|
||||
try {
|
||||
activeSink?.flush()
|
||||
} catch {
|
||||
// Why: a disk/rotation failure in optional local diagnostics must never
|
||||
// turn a recoverable renderer crash into a main-process crash.
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the current parent context, or `undefined` if we are at the top of
|
||||
* the trace tree. Renderer-IPC entry points capture this, embed it in
|
||||
* span-event attributes (so cross-process spans can be visually linked
|
||||
@@ -221,7 +232,12 @@ export function startSpan(
|
||||
// Wrap in a `type: 'effect-span'` envelope so the NDJSON file is
|
||||
// compatible with Effect-style span output. Effect-oriented consumers
|
||||
// (the LGTM stack, jq cookbooks) can read the file unchanged.
|
||||
activeSink?.push({ type: 'effect-span', ...redacted })
|
||||
try {
|
||||
activeSink?.push({ type: 'effect-span', ...redacted })
|
||||
} catch {
|
||||
// Why: tracing is optional; a local file rotation failure must not turn
|
||||
// the instrumented operation into an application failure.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -675,7 +675,8 @@ describe('LocalPtyProvider', () => {
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'
|
||||
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo',
|
||||
env: { ORCA_HERMES_STARTUP_QUERY: 'line one\nline two' }
|
||||
})
|
||||
} finally {
|
||||
if (savedCodexHome === undefined) {
|
||||
@@ -693,8 +694,12 @@ describe('LocalPtyProvider', () => {
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
expect(spawnCall[0]).toBe('wsl.exe')
|
||||
expect(spawnCall[2].env.ORCA_TERMINAL_HANDLE).toBe('term_wsl')
|
||||
expect(spawnCall[2].env.WSLENV).toBe(
|
||||
'ORCA_TERMINAL_HANDLE/u:POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD'
|
||||
expect(spawnCall[2].env.WSLENV?.split(':')).toEqual(
|
||||
expect.arrayContaining([
|
||||
'ORCA_TERMINAL_HANDLE/u',
|
||||
'ORCA_HERMES_STARTUP_QUERY',
|
||||
POWERLEVEL10K_WIZARD_DISABLE_ENV
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process
|
||||
import { readWindowsConptyProcessIds } from './windows-conpty-process-membership'
|
||||
import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery'
|
||||
import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from './pty-default-cwd'
|
||||
import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query'
|
||||
|
||||
const PANE_IDENTITY_ENV_KEYS = [
|
||||
'ORCA_PANE_KEY',
|
||||
@@ -567,6 +568,11 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
// through Windows wsl.exe; non-default env vars need WSLENV import.
|
||||
addWslEnvKeys(finalEnv, ['CLAUDE_CONFIG_DIR'])
|
||||
}
|
||||
if (finalEnv[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) {
|
||||
// Why: the startup wrapper expands this only inside WSL; wsl.exe
|
||||
// otherwise drops custom Windows environment variables.
|
||||
addWslEnvKeys(finalEnv, [ORCA_HERMES_STARTUP_QUERY_ENV])
|
||||
}
|
||||
} else if (codexHomeWslInfo || isWslCodexHomeForHost(finalEnv.CODEX_HOME)) {
|
||||
// Why: WSL-managed Codex homes are Linux paths. Windows Codex cannot use
|
||||
// them. ORCA_CODEX_HOME must go too because shell-ready scripts restore
|
||||
|
||||
@@ -243,6 +243,50 @@ describe('mobile presence lock — multi-mobile semantics', () => {
|
||||
beforeEach(() => vi.useFakeTimers())
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('elects one query responder and promotes the survivor on unsubscribe', async () => {
|
||||
const { runtime } = createRuntime()
|
||||
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
|
||||
await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 })
|
||||
|
||||
expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-A')).toBe(true)
|
||||
expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(false)
|
||||
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'phone-A')
|
||||
expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(true)
|
||||
|
||||
await runtime.reclaimTerminalForDesktop('pty-1')
|
||||
expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the earliest subscriber authoritative after a soft-leave resubscribe', async () => {
|
||||
const { runtime } = createRuntime()
|
||||
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'phone-A')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 })
|
||||
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
|
||||
|
||||
expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-A')).toBe(true)
|
||||
expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(false)
|
||||
})
|
||||
|
||||
it('excludes an older passive desktop-mode subscriber from reply authority', async () => {
|
||||
const { runtime } = createRuntime()
|
||||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||||
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
runtime.markMobileActor('pty-1', 'phone-B')
|
||||
runtime.setMobileDisplayMode('pty-1', 'auto')
|
||||
await runtime.applyMobileDisplayMode('pty-1')
|
||||
|
||||
expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-A')).toBe(false)
|
||||
expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(true)
|
||||
})
|
||||
|
||||
it('most-recent actor wins active phone-fit dims (B subscribes after A)', async () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
|
||||
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
runHook,
|
||||
shouldRunSetupForCreate
|
||||
} from '../hooks'
|
||||
import { getBranchConflictKind, getDefaultBaseRef } from '../git/repo'
|
||||
import { getBaseRefDefault, getBranchConflictKind } from '../git/repo'
|
||||
import type { OrchestrationDb } from './orchestration/db'
|
||||
import type { MessagePriority, MessageRow, MessageType } from './orchestration/types'
|
||||
import {
|
||||
@@ -521,16 +521,23 @@ vi.mock('../github/issues', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Why: the CLI create-worktree path calls getDefaultBaseRef to resolve a
|
||||
// fallback base branch. Real resolution shells out to `git` against the
|
||||
// test's fabricated repo path, which has no refs, so we stub it to a
|
||||
// predictable 'origin/main'. The runtime no longer silently fabricates this
|
||||
// default, so tests that want the legacy behavior must express it via the mock.
|
||||
// Why: CLI worktree creation resolves a default against fabricated repo paths
|
||||
// in these tests, so keep the async resolver deterministic.
|
||||
vi.mock('../git/repo', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
const actualGetBaseRefDefault = actual.getBaseRefDefault as (
|
||||
path: string,
|
||||
options?: { wslDistro?: string }
|
||||
) => Promise<string | null>
|
||||
return {
|
||||
...actual,
|
||||
getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'),
|
||||
// Why: fabricated local test repos need a deterministic default, while
|
||||
// WSL coverage must still exercise the real async Git-options path.
|
||||
getBaseRefDefault: vi
|
||||
.fn()
|
||||
.mockImplementation((path: string, options?: { wslDistro?: string }) =>
|
||||
options?.wslDistro ? actualGetBaseRefDefault(path, options) : Promise.resolve('origin/main')
|
||||
),
|
||||
getBranchConflictKind: vi.fn().mockResolvedValue(null)
|
||||
}
|
||||
})
|
||||
@@ -2857,7 +2864,7 @@ describe('OrcaRuntimeService', () => {
|
||||
suggestLocalBaseRefUpdate: true
|
||||
}
|
||||
)
|
||||
expect(getDefaultBaseRef).toHaveBeenCalled()
|
||||
expect(getBaseRefDefault).toHaveBeenCalled()
|
||||
} finally {
|
||||
getReposSpy.mockRestore()
|
||||
gitSpy.mockRestore()
|
||||
@@ -5363,7 +5370,7 @@ describe('OrcaRuntimeService', () => {
|
||||
|
||||
it('treats SSH worktree drift as unknown without local git probes', async () => {
|
||||
vi.mocked(listWorktrees).mockClear()
|
||||
vi.mocked(getDefaultBaseRef).mockClear()
|
||||
vi.mocked(getBaseRefDefault).mockClear()
|
||||
const remoteStore = {
|
||||
...store,
|
||||
getRepos: () => [
|
||||
@@ -5399,7 +5406,7 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
|
||||
expect(gitProvider.listWorktrees).toHaveBeenCalledWith('/remote/repo')
|
||||
expect(getDefaultBaseRef).not.toHaveBeenCalled()
|
||||
expect(getBaseRefDefault).not.toHaveBeenCalled()
|
||||
expect(listWorktrees).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -5465,7 +5472,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
expect(asyncGitSpy).toHaveBeenCalledWith(
|
||||
['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'],
|
||||
wslGitOptions
|
||||
{ ...wslGitOptions, timeout: 15_000 }
|
||||
)
|
||||
expect(asyncGitSpy).toHaveBeenCalledWith(['remote'], wslGitOptions)
|
||||
expect(asyncGitSpy).toHaveBeenCalledWith(
|
||||
@@ -26473,6 +26480,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
expect(gitSpy).toHaveBeenCalledWith(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], {
|
||||
cwd: TEST_REPO_PATH,
|
||||
timeout: 15_000,
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
expect(gitSpy).toHaveBeenCalledWith(
|
||||
@@ -27140,6 +27148,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
expect(gitSpy).toHaveBeenCalledWith(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], {
|
||||
cwd: TEST_REPO_PATH,
|
||||
timeout: 15_000,
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
expect(getBranchConflictKind).toHaveBeenCalledWith(
|
||||
|
||||
@@ -600,7 +600,6 @@ import type {
|
||||
} from '../../shared/github-project-types'
|
||||
import {
|
||||
getBaseRefDefault,
|
||||
getDefaultBaseRef,
|
||||
getDefaultRemote,
|
||||
getBranchConflictKind,
|
||||
isGitRepo,
|
||||
@@ -611,6 +610,7 @@ import {
|
||||
parseAndFilterSearchRefDetails,
|
||||
parseRemoteCount,
|
||||
resolveDefaultBaseRefViaExec,
|
||||
resolveDefaultBaseRefWithLocalGit,
|
||||
buildSearchBaseRefsArgv,
|
||||
isForEachRefExcludeUnsupportedError,
|
||||
mergeBaseRefSearchResultGroups,
|
||||
@@ -6497,6 +6497,31 @@ export class OrcaRuntimeService {
|
||||
return (this.mobileSubscribers.get(ptyId)?.size ?? 0) > 0
|
||||
}
|
||||
|
||||
isMobileTerminalQueryReplyAuthority(ptyId: string, clientId: string): boolean {
|
||||
// Why: a passive phone watching desktop-sized output must not race the
|
||||
// desktop xterm. Mobile becomes reply authority only with the mobile floor.
|
||||
if (this.getDriver(ptyId).kind !== 'mobile') {
|
||||
return false
|
||||
}
|
||||
const subscribers = this.mobileSubscribers.get(ptyId)
|
||||
if (!subscribers) {
|
||||
return false
|
||||
}
|
||||
// Why: soft-leave resubscribe preserves the original subscription time but
|
||||
// reinserts the record. Elect fitted responders from that stable age, not
|
||||
// mutable Map order or passive desktop-mode watchers.
|
||||
let earliest: { clientId: string; subscribedAt: number } | null = null
|
||||
for (const subscriber of subscribers.values()) {
|
||||
if (!subscriber.wasResizedToPhone) {
|
||||
continue
|
||||
}
|
||||
if (earliest === null || subscriber.subscribedAt < earliest.subscribedAt) {
|
||||
earliest = subscriber
|
||||
}
|
||||
}
|
||||
return earliest?.clientId === clientId
|
||||
}
|
||||
|
||||
subscribeToFitOverrideChanges(
|
||||
ptyId: string,
|
||||
listener: (event: {
|
||||
@@ -14670,8 +14695,8 @@ export class OrcaRuntimeService {
|
||||
repoWorktreeBaseRef: repo.worktreeBaseRef,
|
||||
resolveDefaultBaseRef: () =>
|
||||
hasLocalWorktreeGitOptions
|
||||
? resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions))
|
||||
: Promise.resolve(getDefaultBaseRef(repo.path)),
|
||||
? resolveDefaultBaseRefWithLocalGit(localGitExecOptions)
|
||||
: getBaseRefDefault(repo.path),
|
||||
isBaseUsable: async (baseBranchCandidate) => {
|
||||
const remoteTrackingBase = await this.resolveRemoteTrackingBase(
|
||||
repo.path,
|
||||
@@ -14702,10 +14727,8 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
})
|
||||
if (!baseBranch) {
|
||||
// Why: getDefaultBaseRef returns null when no suitable ref exists.
|
||||
// Don't fabricate 'origin/main' — passing it to addWorktree would
|
||||
// produce an opaque git failure. Surface a clear error so the CLI
|
||||
// caller can pick an explicit --base ref.
|
||||
// Why: a null default means no suitable ref exists; fail clearly instead
|
||||
// of handing Git a fabricated origin/main ref.
|
||||
throw new Error(
|
||||
'Could not resolve a default base ref for this repo. Pass an explicit --base and try again.'
|
||||
)
|
||||
|
||||
@@ -26,6 +26,13 @@ import {
|
||||
} from '../../../../shared/terminal-input'
|
||||
import { measureClipboardTextByteLength } from '../../../../shared/clipboard-text'
|
||||
import { isTuiAgent } from '../../../../shared/tui-agent-config'
|
||||
import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply'
|
||||
import {
|
||||
EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE,
|
||||
scanTerminalReplyQuerySequences,
|
||||
type TerminalReplyQuerySequence,
|
||||
type TerminalReplyQueryScanState
|
||||
} from '../../../../shared/terminal-reply-query-scan'
|
||||
import {
|
||||
MOBILE_SNAPSHOT_BYTE_BUDGET,
|
||||
MOBILE_SUBSCRIBE_SCROLLBACK_ROWS
|
||||
@@ -42,6 +49,7 @@ const TERMINAL_MULTIPLEX_ACK_TOTAL_HIGH_WATER_BYTES = 2 * 1024 * 1024
|
||||
// Why: pending output is held for later binary frames, so cap the encoded
|
||||
// payload bytes rather than UTF-16 code units.
|
||||
const TERMINAL_MULTIPLEX_PENDING_MAX_BYTES = 256 * 1024
|
||||
const TERMINAL_QUERY_REPLAY_MAX_CHARS = 16 * 1024
|
||||
let nextTerminalStreamId = 1
|
||||
|
||||
type SnapshotFrameOptions = {
|
||||
@@ -380,6 +388,29 @@ function getOutputAfterSnapshotSeq(
|
||||
return chunk.data.slice(snapshotSeq - chunkStartSeq)
|
||||
}
|
||||
|
||||
function stripSnapshotBoundaryQuerySuffixes(
|
||||
data: string,
|
||||
dataStartSeq: number,
|
||||
snapshotSeq: number,
|
||||
queries: TerminalReplyQuerySequence[]
|
||||
): string {
|
||||
let output = ''
|
||||
let offset = 0
|
||||
for (const query of queries) {
|
||||
if (query.startSeq >= snapshotSeq || query.endSeq <= snapshotSeq) {
|
||||
continue
|
||||
}
|
||||
const removeStart = Math.max(0, query.startSeq - dataStartSeq)
|
||||
const removeEnd = Math.min(data.length, query.endSeq - dataStartSeq)
|
||||
if (removeEnd <= offset || removeStart >= data.length) {
|
||||
continue
|
||||
}
|
||||
output += data.slice(offset, removeStart)
|
||||
offset = removeEnd
|
||||
}
|
||||
return output + data.slice(offset)
|
||||
}
|
||||
|
||||
function appendAckPendingOutput(
|
||||
stream: TerminalMultiplexStream,
|
||||
chunk: TerminalOutputFrameChunk
|
||||
@@ -731,6 +762,9 @@ const TerminalSend = TerminalHandle.extend({
|
||||
enter: z.unknown().optional(),
|
||||
interrupt: z.unknown().optional(),
|
||||
requireAgentStatus: z.enum(['sendable']).optional(),
|
||||
// Why: terminal-generated replies are valid input bytes but are not a user
|
||||
// action that should transfer the shared terminal floor.
|
||||
inputKind: z.enum(['query-reply']).optional(),
|
||||
// Why: identifies the caller for the driver state machine. Optional for
|
||||
// backward compatibility with older mobile clients (server falls back to
|
||||
// the most recent mobile actor when absent). New mobile builds populate
|
||||
@@ -1025,13 +1059,40 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
defineMethod({
|
||||
name: 'terminal.send',
|
||||
params: TerminalSend,
|
||||
handler: async (params, { runtime }) => {
|
||||
handler: async (params, { runtime, clientId }) => {
|
||||
await assertTerminalSendTextWithinLimit(params.text)
|
||||
const queryReplyClientId = clientId ?? params.client?.id
|
||||
if (
|
||||
params.inputKind === 'query-reply' &&
|
||||
(!params.text ||
|
||||
!isTerminalQueryReply(params.text) ||
|
||||
params.enter === true ||
|
||||
params.interrupt === true ||
|
||||
params.requireAgentStatus !== undefined ||
|
||||
params.client?.type !== 'mobile' ||
|
||||
!queryReplyClientId ||
|
||||
(clientId !== undefined && params.client.id !== clientId))
|
||||
) {
|
||||
throw new InvalidArgumentError('Invalid terminal query reply')
|
||||
}
|
||||
// Why: guarded resolution — a stale handle must fail with
|
||||
// terminal_handle_stale (clients recover by re-deriving the handle)
|
||||
// instead of evaluating driver/lock state against the wrong PTY (#7718).
|
||||
const leaf = runtime.resolveLiveLeafForHandle(params.terminal)
|
||||
const driver = leaf?.ptyId ? runtime.getDriver(leaf.ptyId) : null
|
||||
if (
|
||||
params.inputKind === 'query-reply' &&
|
||||
leaf?.ptyId &&
|
||||
!runtime.isMobileTerminalQueryReplyAuthority(leaf.ptyId, queryReplyClientId!)
|
||||
) {
|
||||
return {
|
||||
send: {
|
||||
handle: params.terminal,
|
||||
accepted: false,
|
||||
bytesWritten: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
if (leaf?.ptyId && isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) {
|
||||
return {
|
||||
send: {
|
||||
@@ -1166,7 +1227,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
// the most recent actor. Clientless sends are old mobile builds, so use
|
||||
// the current mobile driver as their compatibility identity.
|
||||
const mobileFloorClientId = resolveMobileFloorClientId(driver, params.client)
|
||||
if (leaf?.ptyId && mobileFloorClientId) {
|
||||
if (params.inputKind !== 'query-reply' && leaf?.ptyId && mobileFloorClientId) {
|
||||
await runtime.mobileTookFloor(leaf.ptyId, mobileFloorClientId)
|
||||
}
|
||||
return { send: result }
|
||||
@@ -2426,6 +2487,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
let desktopClaimTail: Promise<boolean> = Promise.resolve(true)
|
||||
let pendingOutputBytes = 0
|
||||
let pendingOutputOverflowed = false
|
||||
let pendingQueryScanState: TerminalReplyQueryScanState = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE
|
||||
const pendingQuerySequences: TerminalReplyQuerySequence[] = []
|
||||
let pendingQueryChars = 0
|
||||
let pendingQueryOverflowed = false
|
||||
let unsubscribeData = (): void => {}
|
||||
let unsubscribeResize = (): void => {}
|
||||
let unsubscribeFit = (): void => {}
|
||||
@@ -2600,6 +2665,57 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
.catch(() => false)
|
||||
}
|
||||
}) ?? (() => {})
|
||||
const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
if (buffering) {
|
||||
const rawLength = meta?.rawLength
|
||||
if (
|
||||
typeof meta?.seq === 'number' &&
|
||||
typeof rawLength === 'number' &&
|
||||
rawLength === data.length
|
||||
) {
|
||||
const scan = scanTerminalReplyQuerySequences(
|
||||
data,
|
||||
meta.seq - rawLength,
|
||||
pendingQueryScanState
|
||||
)
|
||||
pendingQueryScanState = scan.state
|
||||
for (const query of scan.queries) {
|
||||
if (pendingQueryChars + query.data.length > TERMINAL_QUERY_REPLAY_MAX_CHARS) {
|
||||
pendingQueryOverflowed = true
|
||||
break
|
||||
}
|
||||
pendingQuerySequences.push(query)
|
||||
pendingQueryChars += query.data.length
|
||||
}
|
||||
} else {
|
||||
pendingQueryScanState = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE
|
||||
}
|
||||
const remainingBudget = Math.max(
|
||||
1,
|
||||
TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - pendingOutputBytes
|
||||
)
|
||||
const measurement = measureTerminalStreamByteLength(data, {
|
||||
stopAfterBytes: remainingBudget
|
||||
})
|
||||
pendingOutput.push({ data, bytes: measurement.byteLength, meta })
|
||||
pendingOutputBytes += measurement.byteLength
|
||||
const trimmed = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes)
|
||||
pendingOutputBytes = trimmed.bytes
|
||||
pendingOutputOverflowed ||= trimmed.overflowed
|
||||
return
|
||||
}
|
||||
outputBatcher?.push(data, meta)
|
||||
})
|
||||
// Why: live bytes must be captured before mobile fit awaits. Registering
|
||||
// mobile presence first would suppress main while no view held the query.
|
||||
const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId)
|
||||
unsubscribeData = () => {
|
||||
releaseViewSubscriber()
|
||||
unsubscribeStreamData()
|
||||
}
|
||||
// Server-side auto-fit: resize PTY to phone dims before serializing scrollback
|
||||
try {
|
||||
if (isMobile && clientId) {
|
||||
@@ -2614,59 +2730,6 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
return
|
||||
}
|
||||
|
||||
const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
if (buffering) {
|
||||
const remainingBudget = Math.max(
|
||||
1,
|
||||
TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - pendingOutputBytes
|
||||
)
|
||||
const measurement = measureTerminalStreamByteLength(data, {
|
||||
stopAfterBytes: remainingBudget
|
||||
})
|
||||
pendingOutput.push({ data, bytes: measurement.byteLength, meta })
|
||||
pendingOutputBytes += measurement.byteLength
|
||||
const trimmed = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes)
|
||||
pendingOutputBytes = trimmed.bytes
|
||||
pendingOutputOverflowed ||= trimmed.overflowed
|
||||
return
|
||||
}
|
||||
outputBatcher?.push(data, meta)
|
||||
})
|
||||
// Why: binary subscribe streams feed remote xterm views (mobile and
|
||||
// binary-capable desktop clients) that answer queries with view
|
||||
// authority; the main model responder yields while attached.
|
||||
const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId)
|
||||
unsubscribeData = () => {
|
||||
releaseViewSubscriber()
|
||||
unsubscribeStreamData()
|
||||
}
|
||||
|
||||
if (
|
||||
clientId &&
|
||||
params.client &&
|
||||
registeredRemoteDesktopDriver &&
|
||||
pendingRemoteDesktopViewport
|
||||
) {
|
||||
const viewport = pendingRemoteDesktopViewport
|
||||
pendingRemoteDesktopViewport = null
|
||||
await updateViewportForClient(
|
||||
runtime,
|
||||
ptyId,
|
||||
remoteDesktopSubscriptionKey,
|
||||
params.client,
|
||||
viewport,
|
||||
'desktop',
|
||||
'register',
|
||||
!supportsDesktopViewportClaims
|
||||
)
|
||||
}
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
|
||||
let read = await runtime.readTerminal(params.terminal)
|
||||
let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile)
|
||||
if (closed) {
|
||||
@@ -2697,7 +2760,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
// See docs/mobile-terminal-layout-state-machine.md.
|
||||
const layoutSeq = runtime.getLayout(ptyId)?.seq
|
||||
const snapshotFrameSeq = serialized?.seq ?? layoutSeq
|
||||
const snapshotOutputSeq = serialized?.seq
|
||||
// Why: recovery snapshots advance output coverage past the initial
|
||||
// snapshot seq; query replay and boundary trims must track the seq
|
||||
// that actually covered the buffered chunks or a query absorbed by a
|
||||
// recovery snapshot gets zero replies.
|
||||
let snapshotOutputSeq = serialized?.seq
|
||||
emit({
|
||||
type: 'subscribed',
|
||||
streamId,
|
||||
@@ -2784,12 +2851,43 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
const trimmed = trimPendingOutputCoveredBySnapshot(pendingOutput, recovery.seq)
|
||||
pendingOutput = trimmed.chunks
|
||||
pendingOutputBytes = trimmed.bytes
|
||||
snapshotOutputSeq = recovery.seq
|
||||
}
|
||||
buffering = false
|
||||
const bufferedOutput = pendingOutput.splice(0)
|
||||
const queryReplayData = pendingQueryOverflowed
|
||||
? ''
|
||||
: pendingQuerySequences
|
||||
.filter(
|
||||
(query) =>
|
||||
initialOutputOverflowed ||
|
||||
(typeof snapshotOutputSeq === 'number' && query.startSeq < snapshotOutputSeq)
|
||||
)
|
||||
.map((query) => query.data)
|
||||
.join('')
|
||||
if (queryReplayData) {
|
||||
// Why: serialized snapshots omit control queries, yet their output seq
|
||||
// can trim the live chunk. Replay only the query after snapshot so the
|
||||
// mobile xterm answers once while ordinary output stays deduplicated.
|
||||
outputBatcher.push(queryReplayData)
|
||||
}
|
||||
if (!initialOutputOverflowed) {
|
||||
for (const item of bufferedOutput) {
|
||||
const uncoveredData = getOutputAfterSnapshotSeq(item, snapshotOutputSeq)
|
||||
let uncoveredData = getOutputAfterSnapshotSeq(item, snapshotOutputSeq)
|
||||
if (
|
||||
uncoveredData &&
|
||||
uncoveredData !== item.data &&
|
||||
typeof snapshotOutputSeq === 'number' &&
|
||||
typeof item.meta?.seq === 'number' &&
|
||||
typeof item.meta.rawLength === 'number'
|
||||
) {
|
||||
uncoveredData = stripSnapshotBoundaryQuerySuffixes(
|
||||
uncoveredData,
|
||||
snapshotOutputSeq,
|
||||
snapshotOutputSeq,
|
||||
pendingQuerySequences
|
||||
)
|
||||
}
|
||||
if (uncoveredData) {
|
||||
outputBatcher.push(uncoveredData, item.meta)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ import type { RpcRequest } from './core'
|
||||
import type { OrcaRuntimeService } from '../orca-runtime'
|
||||
import { TERMINAL_METHODS } from './methods/terminal'
|
||||
import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../../shared/clipboard-text'
|
||||
import {
|
||||
RUNTIME_CAPABILITIES,
|
||||
TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY
|
||||
} from '../../../shared/protocol-version'
|
||||
import {
|
||||
TERMINAL_INPUT_MAX_BYTES,
|
||||
TERMINAL_INPUT_TOO_LARGE_ERROR
|
||||
@@ -209,6 +213,148 @@ describe('terminal send RPC', () => {
|
||||
expect(runtime.mobileTookFloor).toHaveBeenCalledWith('pty-1', 'mobile-1')
|
||||
})
|
||||
|
||||
it('writes a validated terminal query reply without taking the mobile floor', async () => {
|
||||
const runtime = stubRuntime({
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }),
|
||||
sendTerminal: vi.fn().mockResolvedValue({
|
||||
handle: 'terminal-1',
|
||||
accepted: true,
|
||||
bytesWritten: 6
|
||||
}),
|
||||
isMobileTerminalQueryReplyAuthority: vi.fn().mockReturnValue(true),
|
||||
mobileTookFloor: vi.fn()
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('terminal.send', {
|
||||
terminal: 'terminal-1',
|
||||
text: '\x1b[3;4R',
|
||||
enter: false,
|
||||
inputKind: 'query-reply',
|
||||
client: { id: 'mobile-1', type: 'mobile' }
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.ok).toBe(true)
|
||||
expect(runtime.sendTerminal).toHaveBeenCalledWith(
|
||||
'terminal-1',
|
||||
{ text: '\x1b[3;4R', enter: false, interrupt: false },
|
||||
{ beforeWrite: undefined }
|
||||
)
|
||||
expect(runtime.mobileTookFloor).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts a query reply from only the elected mobile subscriber', async () => {
|
||||
const runtime = stubRuntime({
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }),
|
||||
isMobileTerminalQueryReplyAuthority: vi.fn(
|
||||
(_ptyId: string, clientId: string) => clientId === 'mobile-1'
|
||||
),
|
||||
sendTerminal: vi.fn().mockResolvedValue({
|
||||
handle: 'terminal-1',
|
||||
accepted: true,
|
||||
bytesWritten: 6
|
||||
}),
|
||||
mobileTookFloor: vi.fn()
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const makeReply = (clientId: string) =>
|
||||
dispatcher.dispatch(
|
||||
makeRequest('terminal.send', {
|
||||
terminal: 'terminal-1',
|
||||
text: '\x1b[3;4R',
|
||||
inputKind: 'query-reply',
|
||||
client: { id: clientId, type: 'mobile' }
|
||||
})
|
||||
)
|
||||
const [winner, peer] = await Promise.all([makeReply('mobile-1'), makeReply('mobile-2')])
|
||||
|
||||
expect(winner).toMatchObject({ ok: true, result: { send: { accepted: true } } })
|
||||
expect(peer).toMatchObject({ ok: true, result: { send: { accepted: false } } })
|
||||
expect(runtime.sendTerminal).toHaveBeenCalledTimes(1)
|
||||
expect(runtime.mobileTookFloor).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects ordinary input that claims to be a terminal query reply', async () => {
|
||||
const runtime = stubRuntime({
|
||||
resolveLiveLeafForHandle: vi.fn(),
|
||||
sendTerminal: vi.fn(),
|
||||
mobileTookFloor: vi.fn()
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('terminal.send', {
|
||||
terminal: 'terminal-1',
|
||||
text: 'a',
|
||||
inputKind: 'query-reply',
|
||||
client: { id: 'mobile-1', type: 'mobile' }
|
||||
})
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
|
||||
expect(runtime.resolveLiveLeafForHandle).not.toHaveBeenCalled()
|
||||
expect(runtime.sendTerminal).not.toHaveBeenCalled()
|
||||
expect(runtime.mobileTookFloor).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['enter', { enter: true }],
|
||||
['interrupt', { interrupt: true }],
|
||||
['agent guard', { requireAgentStatus: 'sendable' }]
|
||||
])('rejects a terminal query reply combined with %s semantics', async (_case, extra) => {
|
||||
const runtime = stubRuntime({
|
||||
resolveLiveLeafForHandle: vi.fn(),
|
||||
sendTerminal: vi.fn()
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('terminal.send', {
|
||||
terminal: 'terminal-1',
|
||||
text: '\x1b[3;4R',
|
||||
inputKind: 'query-reply',
|
||||
client: { id: 'mobile-1', type: 'mobile' },
|
||||
...extra
|
||||
})
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
|
||||
expect(runtime.resolveLiveLeafForHandle).not.toHaveBeenCalled()
|
||||
expect(runtime.sendTerminal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects query replies that spoof a different authenticated mobile client', async () => {
|
||||
const replies: string[] = []
|
||||
const runtime = stubRuntime({
|
||||
resolveLiveLeafForHandle: vi.fn(),
|
||||
sendTerminal: vi.fn()
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
await dispatcher.dispatchStreaming(
|
||||
makeRequest('terminal.send', {
|
||||
terminal: 'terminal-1',
|
||||
text: '\x1b[3;4R',
|
||||
inputKind: 'query-reply',
|
||||
client: { id: 'spoofed-mobile', type: 'mobile' }
|
||||
}),
|
||||
(reply) => replies.push(reply),
|
||||
{ clientId: 'authenticated-mobile' }
|
||||
)
|
||||
|
||||
expect(JSON.parse(replies[0]!)).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'invalid_argument' }
|
||||
})
|
||||
expect(runtime.resolveLiveLeafForHandle).not.toHaveBeenCalled()
|
||||
expect(runtime.sendTerminal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects oversized terminal send text before runtime dispatch', async () => {
|
||||
const secret = 'terminal-send-secret'
|
||||
const runtime = stubRuntime({
|
||||
@@ -475,4 +621,9 @@ describe('terminal send RPC', () => {
|
||||
expect(response.result).toEqual({ restored: true })
|
||||
expect(runtime.reclaimTerminalForDesktop).toHaveBeenCalledWith('pty-1')
|
||||
})
|
||||
// Why: mobile gates reply forwarding on this static capability; removing it
|
||||
// would silently re-break mobile query answering against current hosts.
|
||||
it('advertises query-reply input support to mobile clients', () => {
|
||||
expect(RUNTIME_CAPABILITIES).toContain(TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -79,6 +79,95 @@ describe('terminal subscribe buffering', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('captures live queries before awaiting mobile fit and delivers them after the snapshot', async () => {
|
||||
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
|
||||
const cleanups = new Map<string, () => void>()
|
||||
let dataListener:
|
||||
| ((data: string, meta?: { seq?: number; rawLength?: number }) => void)
|
||||
| undefined
|
||||
let resolveMobileSubscribe: () => void = () => {}
|
||||
const registerRemoteTerminalViewSubscriber = vi.fn(() => vi.fn())
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
handleMobileSubscribe: vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveMobileSubscribe = () => resolve(true)
|
||||
})
|
||||
),
|
||||
handleMobileUnsubscribe: vi.fn(),
|
||||
subscribeToTerminalData: vi.fn((_ptyId, listener) => {
|
||||
dataListener = listener
|
||||
return vi.fn()
|
||||
}),
|
||||
registerRemoteTerminalViewSubscriber,
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ data: 'snapshot', cols: 80, rows: 24, seq: 4 }),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }),
|
||||
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
isTerminalAlternateScreen: vi.fn().mockReturnValue(false),
|
||||
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
|
||||
cleanups.set(id, cleanup)
|
||||
}),
|
||||
cleanupSubscription: vi.fn((id: string) => {
|
||||
cleanups.get(id)?.()
|
||||
cleanups.delete(id)
|
||||
}),
|
||||
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {}))
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const dispatchPromise = dispatcher.dispatchStreaming(
|
||||
makeRequest('terminal.subscribe', {
|
||||
terminal: 'terminal-1',
|
||||
client: { id: 'phone-1', type: 'mobile' },
|
||||
capabilities: { terminalBinaryStream: 1 }
|
||||
}),
|
||||
vi.fn(),
|
||||
{
|
||||
connectionId: 'conn-phone',
|
||||
sendBinary: (bytes) => {
|
||||
binaryFrames.push(bytes)
|
||||
},
|
||||
registerBinaryStreamHandler: vi.fn(() => vi.fn())
|
||||
}
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(runtime.handleMobileSubscribe).toHaveBeenCalled())
|
||||
expect(dataListener).toBeDefined()
|
||||
expect(registerRemoteTerminalViewSubscriber).toHaveBeenCalledWith('pty-1')
|
||||
dataListener?.('\x1b[6n', { seq: 4, rawLength: 4 })
|
||||
resolveMobileSubscribe()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
binaryFrames.some((bytes) => {
|
||||
const frame = decodeTerminalStreamFrame(bytes)
|
||||
return (
|
||||
frame?.opcode === TerminalStreamOpcode.Output &&
|
||||
decodeTerminalStreamText(frame.payload) === '\x1b[6n'
|
||||
)
|
||||
})
|
||||
).toBe(true)
|
||||
)
|
||||
const queryOutputFrames = binaryFrames.filter((bytes) => {
|
||||
const frame = decodeTerminalStreamFrame(bytes)
|
||||
return (
|
||||
frame?.opcode === TerminalStreamOpcode.Output &&
|
||||
decodeTerminalStreamText(frame.payload) === '\x1b[6n'
|
||||
)
|
||||
})
|
||||
expect(queryOutputFrames).toHaveLength(1)
|
||||
|
||||
runtime.cleanupSubscription('terminal-1:phone-1')
|
||||
await dispatchPromise
|
||||
})
|
||||
|
||||
it('marks scrollback-only subscribed previews truncated when the uncursored read is limited', async () => {
|
||||
const messages: string[] = []
|
||||
const runtime = stubRuntime({
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { execFileMock, execFileSyncMock } = vi.hoisted(() => ({
|
||||
execFileMock: vi.fn(),
|
||||
execFileSyncMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: execFileMock,
|
||||
execFileSync: execFileSyncMock
|
||||
}))
|
||||
|
||||
type ExecCallback = (error: Error | null, stdout: string, stderr: string) => void
|
||||
|
||||
const originalUsername = process.env.USERNAME
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
execFileMock.mockReset()
|
||||
execFileSyncMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalUsername === undefined) {
|
||||
delete process.env.USERNAME
|
||||
} else {
|
||||
process.env.USERNAME = originalUsername
|
||||
}
|
||||
})
|
||||
|
||||
describe('grantDirAclAsync', () => {
|
||||
it('keeps icacls off the synchronous main-process path', async () => {
|
||||
process.env.USERNAME = 'alice'
|
||||
let complete: ExecCallback | undefined
|
||||
execFileMock.mockImplementation(
|
||||
(_command: string, _args: string[], _options: unknown, callback: ExecCallback) => {
|
||||
complete = callback
|
||||
return {} as never
|
||||
}
|
||||
)
|
||||
const { getIcaclsExePath, grantDirAclAsync } = await import('./win32-utils')
|
||||
|
||||
const pending = grantDirAclAsync('C:\\Users\\alice\\Orca')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(execFileSyncMock).not.toHaveBeenCalled()
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
getIcaclsExePath(),
|
||||
['C:\\Users\\alice\\Orca', '/grant:r', 'alice:(OI)(CI)(F)'],
|
||||
{ encoding: 'utf-8', windowsHide: true, timeout: 10_000 },
|
||||
expect.any(Function)
|
||||
)
|
||||
complete?.(null, '', '')
|
||||
await expect(pending).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves a missing username through asynchronous whoami before icacls', async () => {
|
||||
delete process.env.USERNAME
|
||||
execFileMock
|
||||
.mockImplementationOnce(
|
||||
(_command: string, _args: string[], _options: unknown, callback: ExecCallback) => {
|
||||
callback(null, '"DOMAIN\\alice","S-1-5-21-123"\r\n', '')
|
||||
return {} as never
|
||||
}
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
(_command: string, _args: string[], _options: unknown, callback: ExecCallback) => {
|
||||
callback(null, '', '')
|
||||
return {} as never
|
||||
}
|
||||
)
|
||||
const { getWhoamiExePath, grantDirAclAsync } = await import('./win32-utils')
|
||||
|
||||
await grantDirAclAsync('C:\\Orca')
|
||||
|
||||
expect(execFileMock.mock.calls[0]?.slice(0, 3)).toEqual([
|
||||
getWhoamiExePath(),
|
||||
['/user', '/fo', 'csv', '/nh'],
|
||||
{ encoding: 'utf-8', windowsHide: true, timeout: 5000 }
|
||||
])
|
||||
expect(execFileMock.mock.calls[1]?.[1]).toEqual([
|
||||
'C:\\Orca',
|
||||
'/grant:r',
|
||||
'*S-1-5-21-123:(OI)(CI)(F)'
|
||||
])
|
||||
expect(execFileSyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not overwrite a concurrent cached identity with a late async result', async () => {
|
||||
delete process.env.USERNAME
|
||||
let completeWhoami: ExecCallback | undefined
|
||||
execFileMock
|
||||
.mockImplementationOnce(
|
||||
(_command: string, _args: string[], _options: unknown, callback: ExecCallback) => {
|
||||
completeWhoami = callback
|
||||
return {} as never
|
||||
}
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
(_command: string, _args: string[], _options: unknown, callback: ExecCallback) => {
|
||||
callback(null, '', '')
|
||||
return {} as never
|
||||
}
|
||||
)
|
||||
execFileSyncMock.mockReturnValue('"DOMAIN\\sync","S-1-5-21-456"\r\n')
|
||||
const { grantDirAclAsync, resolveCurrentWindowsIdentity } = await import('./win32-utils')
|
||||
|
||||
const pending = grantDirAclAsync('C:\\Orca')
|
||||
await Promise.resolve()
|
||||
expect(resolveCurrentWindowsIdentity()).toBe('*S-1-5-21-456')
|
||||
completeWhoami?.(null, '"DOMAIN\\async","S-1-5-21-789"\r\n', '')
|
||||
await pending
|
||||
|
||||
expect(execFileMock.mock.calls[1]?.[1]).toEqual([
|
||||
'C:\\Orca',
|
||||
'/grant:r',
|
||||
'*S-1-5-21-456:(OI)(CI)(F)'
|
||||
])
|
||||
})
|
||||
|
||||
it('retries identity resolution after a transient whoami failure', async () => {
|
||||
delete process.env.USERNAME
|
||||
execFileMock
|
||||
.mockImplementationOnce(
|
||||
(_command: string, _args: string[], _options: unknown, callback: ExecCallback) => {
|
||||
callback(new Error('whoami timed out'), '', '')
|
||||
return {} as never
|
||||
}
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
(_command: string, _args: string[], _options: unknown, callback: ExecCallback) => {
|
||||
callback(null, '"DOMAIN\\alice","S-1-5-21-123"\r\n', '')
|
||||
return {} as never
|
||||
}
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
(_command: string, _args: string[], _options: unknown, callback: ExecCallback) => {
|
||||
callback(null, '', '')
|
||||
return {} as never
|
||||
}
|
||||
)
|
||||
const { getWhoamiExePath, grantDirAclAsync } = await import('./win32-utils')
|
||||
|
||||
await grantDirAclAsync('C:\\Orca')
|
||||
await grantDirAclAsync('C:\\Orca')
|
||||
|
||||
expect(
|
||||
execFileMock.mock.calls.filter(([command]) => command === getWhoamiExePath())
|
||||
).toHaveLength(2)
|
||||
expect(execFileMock.mock.calls[2]?.[1]).toEqual([
|
||||
'C:\\Orca',
|
||||
'/grant:r',
|
||||
'*S-1-5-21-123:(OI)(CI)(F)'
|
||||
])
|
||||
expect(execFileSyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
+105
-14
@@ -1,7 +1,23 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { execFile, execFileSync, type ExecFileOptionsWithStringEncoding } from 'node:child_process'
|
||||
import { delimiter, join } from 'node:path'
|
||||
import { existsSync } from 'node:fs'
|
||||
|
||||
function execFileWithoutBlocking(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: ExecFileOptionsWithStringEncoding
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(command, args, options, (error, stdout) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve(stdout)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Full path to icacls.exe. Electron's main process may have a stripped PATH
|
||||
* that excludes System32, causing bare `icacls` to throw ENOENT.
|
||||
@@ -10,6 +26,11 @@ export function getIcaclsExePath(): string {
|
||||
return `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\icacls.exe`
|
||||
}
|
||||
|
||||
/** Absolute path because service-launched Electron can omit System32 from PATH. */
|
||||
export function getWhoamiExePath(): string {
|
||||
return `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\whoami.exe`
|
||||
}
|
||||
|
||||
/**
|
||||
* Full path to cmd.exe, respecting the ComSpec convention used elsewhere in
|
||||
* the codebase (hooks.ts, repo.ts, ssh-connection-utils.ts).
|
||||
@@ -78,13 +99,10 @@ export function isPermissionError(error: unknown): boolean {
|
||||
// and hardened envs where USERNAME is unset. Fall back to the SID via
|
||||
// `whoami /user` (same strategy as runtime-metadata.ts), which is authoritative
|
||||
// and always available on Windows. Cached because it never changes in-process.
|
||||
let cachedIdentity: string | null | undefined
|
||||
let cachedIdentity: string | undefined
|
||||
let pendingIdentityResolution: Promise<string | null> | null = null
|
||||
|
||||
export function resolveCurrentWindowsIdentity(): string | null {
|
||||
return resolveCurrentIdentity()
|
||||
}
|
||||
|
||||
function resolveCurrentIdentity(): string | null {
|
||||
function cachedOrEnvironmentIdentity(): string | undefined {
|
||||
if (cachedIdentity !== undefined) {
|
||||
return cachedIdentity
|
||||
}
|
||||
@@ -92,20 +110,75 @@ function resolveCurrentIdentity(): string | null {
|
||||
cachedIdentity = process.env.USERNAME
|
||||
return cachedIdentity
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function identityFromWhoamiOutput(output: string): string | null {
|
||||
// CSV columns: "DOMAIN\\user","S-1-5-21-..."
|
||||
const sidMatch = /"(S-[\d-]+)"\s*$/.exec(output.trim())
|
||||
return sidMatch ? `*${sidMatch[1]}` : null
|
||||
}
|
||||
|
||||
export function resolveCurrentWindowsIdentity(): string | null {
|
||||
return resolveCurrentIdentity()
|
||||
}
|
||||
|
||||
function resolveCurrentIdentity(): string | null {
|
||||
const knownIdentity = cachedOrEnvironmentIdentity()
|
||||
if (knownIdentity !== undefined) {
|
||||
return knownIdentity
|
||||
}
|
||||
try {
|
||||
const output = execFileSync('whoami', ['/user', '/fo', 'csv', '/nh'], {
|
||||
const output = execFileSync(getWhoamiExePath(), ['/user', '/fo', 'csv', '/nh'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
windowsHide: true,
|
||||
timeout: 5000
|
||||
}).trim()
|
||||
// CSV columns: "DOMAIN\\user","S-1-5-21-..."
|
||||
const sidMatch = /"(S-[\d-]+)"\s*$/.exec(output)
|
||||
cachedIdentity = sidMatch ? `*${sidMatch[1]}` : null
|
||||
})
|
||||
const resolvedIdentity = identityFromWhoamiOutput(output)
|
||||
if (resolvedIdentity) {
|
||||
cachedIdentity = resolvedIdentity
|
||||
}
|
||||
return resolvedIdentity
|
||||
} catch {
|
||||
cachedIdentity = null
|
||||
return null
|
||||
}
|
||||
return cachedIdentity
|
||||
}
|
||||
|
||||
async function resolveCurrentIdentityAsync(): Promise<string | null> {
|
||||
const knownIdentity = cachedOrEnvironmentIdentity()
|
||||
if (knownIdentity !== undefined) {
|
||||
return knownIdentity
|
||||
}
|
||||
if (!pendingIdentityResolution) {
|
||||
pendingIdentityResolution = (async () => {
|
||||
try {
|
||||
const stdout = await execFileWithoutBlocking(
|
||||
getWhoamiExePath(),
|
||||
['/user', '/fo', 'csv', '/nh'],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
windowsHide: true,
|
||||
timeout: 5000
|
||||
}
|
||||
)
|
||||
// Why: a synchronous caller may resolve identity while async whoami
|
||||
// is in flight; its authoritative cached result must win the race.
|
||||
const resolvedIdentity = identityFromWhoamiOutput(stdout)
|
||||
if (cachedIdentity === undefined && resolvedIdentity) {
|
||||
cachedIdentity = resolvedIdentity
|
||||
}
|
||||
return cachedIdentity ?? resolvedIdentity
|
||||
} catch {
|
||||
// Why: transient service/PATH failures must not permanently disable
|
||||
// ACL repair for every later crash attempt in this process.
|
||||
return cachedIdentity ?? null
|
||||
}
|
||||
})().finally(() => {
|
||||
pendingIdentityResolution = null
|
||||
})
|
||||
}
|
||||
return pendingIdentityResolution
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,6 +211,24 @@ export function grantDirAcl(dirPath: string, options?: { recursive?: boolean }):
|
||||
})
|
||||
}
|
||||
|
||||
export async function grantDirAclAsync(dirPath: string): Promise<void> {
|
||||
const identity = await resolveCurrentIdentityAsync()
|
||||
if (!identity) {
|
||||
return
|
||||
}
|
||||
// Why: crash recovery runs on Electron's main thread; an asynchronous
|
||||
// icacls child keeps its worst-case timeout from freezing every window.
|
||||
await execFileWithoutBlocking(
|
||||
getIcaclsExePath(),
|
||||
[dirPath, '/grant:r', `${identity}:(OI)(CI)(F)`],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
windowsHide: true,
|
||||
timeout: 10_000
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve spawn parameters for a command that may be a Windows batch script.
|
||||
*
|
||||
|
||||
@@ -2390,7 +2390,7 @@ describe('createMainWindow', () => {
|
||||
expect(onRendererProcessGone).toHaveBeenCalledWith(details, 142)
|
||||
})
|
||||
|
||||
it('passes the renderer webContents id through crash classification callbacks', () => {
|
||||
it('passes the renderer webContents id through crash recording and recovery callbacks', () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const windowHandlers: Record<string, (...args: any[]) => void> = {}
|
||||
@@ -2423,13 +2423,11 @@ describe('createMainWindow', () => {
|
||||
return browserWindowInstance
|
||||
})
|
||||
const onRendererProcessGone = vi.fn()
|
||||
const shouldRecordRendererCrash = vi.fn(() => true)
|
||||
const shouldRecoverRenderer = vi.fn(() => true)
|
||||
|
||||
try {
|
||||
createMainWindow(null, {
|
||||
onRendererProcessGone,
|
||||
shouldRecordRendererCrash,
|
||||
shouldRecoverRenderer
|
||||
})
|
||||
|
||||
@@ -2437,7 +2435,6 @@ describe('createMainWindow', () => {
|
||||
windowHandlers['render-process-gone']?.({} as never, details)
|
||||
vi.advanceTimersByTime(250)
|
||||
|
||||
expect(shouldRecordRendererCrash).toHaveBeenCalledWith(details, 424)
|
||||
expect(onRendererProcessGone).toHaveBeenCalledWith(details, 424)
|
||||
expect(shouldRecoverRenderer).toHaveBeenCalledWith(details, 424)
|
||||
} finally {
|
||||
@@ -2445,9 +2442,10 @@ describe('createMainWindow', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not notify the crash recorder for an expected renderer teardown', () => {
|
||||
it('forwards expected renderer teardowns so the recorder can diagnose suppression', () => {
|
||||
const windowHandlers: Record<string, (...args: any[]) => void> = {}
|
||||
const webContents = {
|
||||
id: 142,
|
||||
on: vi.fn((event, handler) => {
|
||||
windowHandlers[event] = handler
|
||||
}),
|
||||
@@ -2476,10 +2474,7 @@ describe('createMainWindow', () => {
|
||||
})
|
||||
const onRendererProcessGone = vi.fn()
|
||||
|
||||
createMainWindow(null, {
|
||||
onRendererProcessGone,
|
||||
shouldRecordRendererCrash: () => false
|
||||
})
|
||||
createMainWindow(null, { onRendererProcessGone })
|
||||
|
||||
windowHandlers['render-process-gone']?.(
|
||||
{} as never,
|
||||
@@ -2489,7 +2484,10 @@ describe('createMainWindow', () => {
|
||||
} as Electron.RenderProcessGoneDetails
|
||||
)
|
||||
|
||||
expect(onRendererProcessGone).not.toHaveBeenCalled()
|
||||
expect(onRendererProcessGone).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ reason: 'killed', exitCode: 15 }),
|
||||
expect.any(Number)
|
||||
)
|
||||
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
@@ -111,13 +111,6 @@ type CreateMainWindowOptions = {
|
||||
details: Electron.RenderProcessGoneDetails,
|
||||
webContentsId: number
|
||||
) => void
|
||||
/** Returns true when a renderer loss should be reported as a crash. Why:
|
||||
* intentional reload/update/quit paths can emit crash-like `killed`
|
||||
* renderer exits, but surfacing those as crash reports is noise. */
|
||||
shouldRecordRendererCrash?: (
|
||||
details: Electron.RenderProcessGoneDetails,
|
||||
webContentsId: number
|
||||
) => boolean
|
||||
/** Returns true when Orca should reload after an unexpected renderer loss.
|
||||
* Why: update relaunch and app quit intentionally tear down child
|
||||
* processes; recovering those paths can fight Electron's shutdown. */
|
||||
@@ -713,10 +706,9 @@ export function createMainWindow(
|
||||
resetShortcutRecorderFocus()
|
||||
// Why: macOS can report BrowserWindow teardown as renderer `killed`/SIGKILL
|
||||
// after a confirmed close; that is window lifecycle noise, not a crash.
|
||||
if (
|
||||
!windowClosing &&
|
||||
opts?.shouldRecordRendererCrash?.(details, rendererWebContentsId) !== false
|
||||
) {
|
||||
if (!windowClosing) {
|
||||
// Why: the recorder owns crash classification and durable suppression
|
||||
// diagnostics; filtering here made expected-teardown evidence unreachable.
|
||||
opts?.onRendererProcessGone?.(details, rendererWebContentsId)
|
||||
}
|
||||
if (!windowClosing) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { isFolderRepo } from '../shared/repo-kind'
|
||||
import type { Repo } from '../shared/types'
|
||||
import { hasLocalCommitObject, isFullGitObjectId } from './git/commit-object-ref'
|
||||
import { hasWorktreeBaseCommitRef } from './git/worktree-base-ref-probe'
|
||||
import { getDefaultBaseRef } from './git/repo'
|
||||
import { getBaseRefDefault } from './git/repo'
|
||||
import { getSshGitProvider } from './providers/ssh-git-dispatch'
|
||||
import { prefetchRemoteWorktreeCreateBase } from './ipc/worktree-remote'
|
||||
import { resolveWorktreeCreateBase } from './worktree-create-base'
|
||||
@@ -48,7 +48,7 @@ async function prefetchLocalWorktreeCreateBase(
|
||||
const resolvedBaseBranch = await resolveWorktreeCreateBase({
|
||||
requestedBaseBranch: baseBranch,
|
||||
repoWorktreeBaseRef: repo.worktreeBaseRef,
|
||||
resolveDefaultBaseRef: async () => getDefaultBaseRef(repo.path),
|
||||
resolveDefaultBaseRef: () => getBaseRefDefault(repo.path),
|
||||
isBaseUsable: async (baseBranchCandidate) => {
|
||||
const remoteTrackingBase = await runtime.resolveRemoteTrackingBase(
|
||||
repo.path,
|
||||
|
||||
@@ -316,6 +316,7 @@ import type { ShellOpenLocalPathResult } from '../shared/shell-open-types'
|
||||
import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills'
|
||||
import type {
|
||||
CrashReportBreadcrumbData,
|
||||
CrashReportCopyDiagnosticsArgs,
|
||||
CrashReportRecord,
|
||||
CrashReportSubmitArgs,
|
||||
CrashReportSubmitResult,
|
||||
@@ -1398,10 +1399,9 @@ export type PreloadApi = {
|
||||
) => Promise<ReactErrorBoundaryReportResult>
|
||||
recordBreadcrumb: (args: { name: string; data?: CrashReportBreadcrumbData }) => void
|
||||
submit: (args: CrashReportSubmitArgs) => Promise<CrashReportSubmitResult>
|
||||
copyLatestDiagnostics: (args?: {
|
||||
reportId?: string
|
||||
notes?: string
|
||||
}) => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
copyLatestDiagnostics: (
|
||||
args?: CrashReportCopyDiagnosticsArgs
|
||||
) => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
}
|
||||
export: ExportApi
|
||||
gh: {
|
||||
|
||||
@@ -212,6 +212,7 @@ import type {
|
||||
} from '../shared/localhost-worktree-labels'
|
||||
import type {
|
||||
CrashReportBreadcrumbData,
|
||||
CrashReportCopyDiagnosticsArgs,
|
||||
CrashReportSubmitArgs,
|
||||
CrashReportSubmitResult,
|
||||
ReactErrorBoundaryReportArgs,
|
||||
@@ -1151,7 +1152,7 @@ const api = {
|
||||
ipcRenderer.send('crashReports:recordBreadcrumb', args),
|
||||
submit: (args: CrashReportSubmitArgs): Promise<CrashReportSubmitResult> =>
|
||||
ipcRenderer.invoke('crashReports:submit', args),
|
||||
copyLatestDiagnostics: (args?: { reportId?: string; notes?: string }) =>
|
||||
copyLatestDiagnostics: (args?: CrashReportCopyDiagnosticsArgs) =>
|
||||
ipcRenderer.invoke('crashReports:copyLatestDiagnostics', args)
|
||||
},
|
||||
|
||||
|
||||
@@ -16,10 +16,18 @@ import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import {
|
||||
formatCrashReportText,
|
||||
isReactErrorBoundaryReport,
|
||||
type CrashReportDiagnosticBundle,
|
||||
type CrashReportRecord
|
||||
} from '../../../../shared/crash-reporting'
|
||||
import type { GitHubViewer } from '../../../../shared/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
CRASH_REPORT_SUBMIT_FAILURE_TOAST_ID,
|
||||
getCrashReportCopySubmissionFailure,
|
||||
getCrashReportSubmitFailureNotice,
|
||||
getCrashReportSubmitWarningNotice
|
||||
} from './crash-report-submit-notice'
|
||||
import { useCrashReportCopy } from './use-crash-report-copy'
|
||||
|
||||
function formatSummary(report: CrashReportRecord): string {
|
||||
if (isReactErrorBoundaryReport(report)) {
|
||||
@@ -88,6 +96,7 @@ export function CrashReportDialogSurface({
|
||||
() => (report ? formatCrashReportText(report, deferredNotes) : ''),
|
||||
[deferredNotes, report]
|
||||
)
|
||||
const copyCrashReportDetails = useCrashReportCopy(report, notes)
|
||||
|
||||
const clearViewer = useCallback((): void => {
|
||||
viewerRequestIdRef.current += 1
|
||||
@@ -121,17 +130,25 @@ export function CrashReportDialogSurface({
|
||||
loadViewerForOpenDialog()
|
||||
}, [clearViewer, loadViewerForOpenDialog, open])
|
||||
|
||||
const handleCopy = async (): Promise<void> => {
|
||||
const result = await window.api.crashReports.copyLatestDiagnostics(
|
||||
report ? { reportId: report.id, notes } : { notes }
|
||||
)
|
||||
if (!result.ok) {
|
||||
toast.error(result.error)
|
||||
return
|
||||
}
|
||||
toast.success(
|
||||
translate('auto.components.crash.report.CrashReportDialog.8b8473c544', 'Crash report copied.')
|
||||
)
|
||||
const showSubmitFailure = (
|
||||
error: unknown,
|
||||
diagnosticBundle?: CrashReportDiagnosticBundle
|
||||
): void => {
|
||||
const failure = { error, ...(diagnosticBundle ? { diagnosticBundle } : {}) }
|
||||
const notice = getCrashReportSubmitFailureNotice(failure, includeDiagnosticLogs)
|
||||
const copyFailure = getCrashReportCopySubmissionFailure(failure)
|
||||
toast.error(notice.title, {
|
||||
id: CRASH_REPORT_SUBMIT_FAILURE_TOAST_ID,
|
||||
description: notice.description,
|
||||
duration: Infinity,
|
||||
dismissible: true,
|
||||
action: {
|
||||
label: notice.actionLabel,
|
||||
onClick: () => {
|
||||
void copyCrashReportDetails(copyFailure)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const dismissReportIfNeeded = async (): Promise<void> => {
|
||||
@@ -164,22 +181,7 @@ export function CrashReportDialogSurface({
|
||||
githubEmail: null
|
||||
})
|
||||
if (!result.ok) {
|
||||
if (result.diagnosticBundle?.status === 'uploaded') {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.crash.report.CrashReportDialog.b2e36f53a1',
|
||||
'Failed to send crash report. Diagnostic ticket {{value0}} was uploaded but not linked.',
|
||||
{ value0: result.diagnosticBundle.ticketId }
|
||||
)
|
||||
)
|
||||
} else {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.crash.report.CrashReportDialog.56a3dfa283',
|
||||
'Failed to send crash report.'
|
||||
)
|
||||
)
|
||||
}
|
||||
showSubmitFailure(result.error, result.diagnosticBundle)
|
||||
console.error('Failed to submit crash report:', result.error)
|
||||
return
|
||||
}
|
||||
@@ -188,17 +190,21 @@ export function CrashReportDialogSurface({
|
||||
}
|
||||
onReportChange(result.report)
|
||||
setNotes('')
|
||||
toast.success(
|
||||
translate('auto.components.crash.report.CrashReportDialog.8e24fe4f75', 'Crash report sent.')
|
||||
)
|
||||
toast.dismiss(CRASH_REPORT_SUBMIT_FAILURE_TOAST_ID)
|
||||
const warningNotice = getCrashReportSubmitWarningNotice(result, includeDiagnosticLogs)
|
||||
if (warningNotice) {
|
||||
toast.warning(warningNotice.title, { description: warningNotice.description })
|
||||
} else {
|
||||
toast.success(
|
||||
translate(
|
||||
'auto.components.crash.report.CrashReportDialog.8e24fe4f75',
|
||||
'Crash report sent.'
|
||||
)
|
||||
)
|
||||
}
|
||||
onOpenChange(false)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.crash.report.CrashReportDialog.56a3dfa283',
|
||||
'Failed to send crash report.'
|
||||
)
|
||||
)
|
||||
showSubmitFailure(error)
|
||||
console.error('Failed to submit crash report:', error)
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
@@ -304,7 +310,13 @@ export function CrashReportDialogSurface({
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleCopy} disabled={loading}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void copyCrashReportDetails()}
|
||||
disabled={loading}
|
||||
>
|
||||
<Clipboard className="size-3.5" />
|
||||
{translate('auto.components.crash.report.CrashReportDialog.50b00dc327', 'Copy Details')}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getCrashReportCopySubmissionFailure,
|
||||
getCrashReportSubmitFailureNotice,
|
||||
getCrashReportSubmitWarningNotice
|
||||
} from './crash-report-submit-notice'
|
||||
|
||||
describe('crash report submit notices', () => {
|
||||
it('builds an allow-listed copy context with the exact sanitized failure reasons', () => {
|
||||
expect(
|
||||
getCrashReportCopySubmissionFailure({
|
||||
error: 'request failed at C:\\Users\\alice\\Orca',
|
||||
diagnosticBundle: {
|
||||
status: 'not_uploaded',
|
||||
reason: 'attachment timeout token=super-secret-value'
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
error: 'request failed at [redacted-path]',
|
||||
diagnosticContext: {
|
||||
status: 'not_uploaded',
|
||||
reason: 'attachment timeout token=[redacted]'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the returned error and points attached-log failures to the no-log retry', () => {
|
||||
const notice = getCrashReportSubmitFailureNotice({ error: 'status 413' }, true)
|
||||
|
||||
expect(notice).toEqual({
|
||||
title: "Crash report wasn't sent",
|
||||
description:
|
||||
'status 413. Uncheck "Attach recent diagnostic logs" and try again, or copy the details.',
|
||||
actionLabel: 'Copy Details'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses connection guidance when diagnostic logs were already excluded', () => {
|
||||
const notice = getCrashReportSubmitFailureNotice({ error: 'network unreachable' }, false)
|
||||
|
||||
expect(notice.description).toBe(
|
||||
'network unreachable. Check your connection and try again, or copy the details.'
|
||||
)
|
||||
expect(notice.description).not.toContain('Uncheck')
|
||||
})
|
||||
|
||||
it('preserves an uploaded diagnostic ticket alongside the core failure', () => {
|
||||
const notice = getCrashReportSubmitFailureNotice(
|
||||
{
|
||||
error: 'status 502',
|
||||
diagnosticBundle: {
|
||||
status: 'uploaded',
|
||||
ticketId: 'ticket-123',
|
||||
bundleSubmissionId: 'bundle-123',
|
||||
bytes: 42,
|
||||
spanCount: 2
|
||||
}
|
||||
},
|
||||
true
|
||||
)
|
||||
|
||||
expect(notice.description).toContain('status 502.')
|
||||
expect(notice.description).toContain(
|
||||
'Diagnostic ticket ticket-123 was uploaded but not linked.'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows both failures when the attachment was already omitted', () => {
|
||||
const notice = getCrashReportSubmitFailureNotice(
|
||||
{
|
||||
error: 'fallback host unreachable',
|
||||
diagnosticBundle: {
|
||||
status: 'not_uploaded',
|
||||
reason: 'diagnostic log attachment failed: request timed out after 60 seconds'
|
||||
}
|
||||
},
|
||||
true
|
||||
)
|
||||
|
||||
expect(notice.description).toContain('fallback host unreachable.')
|
||||
expect(notice.description).toContain(
|
||||
'Diagnostic logs were not attached: diagnostic log attachment failed: request timed out after 60 seconds.'
|
||||
)
|
||||
expect(notice.description).toContain('Check your connection')
|
||||
expect(notice.description).not.toContain('Uncheck')
|
||||
})
|
||||
|
||||
it('safely surfaces Error messages and ignores unhelpful rejected values', () => {
|
||||
expect(
|
||||
getCrashReportSubmitFailureNotice({ error: new Error('IPC channel closed') }, false)
|
||||
.description
|
||||
).toContain('IPC channel closed.')
|
||||
expect(
|
||||
getCrashReportSubmitFailureNotice({ error: { unexpected: true } }, false).description
|
||||
).toContain('The crash report request failed before it returned a reason.')
|
||||
})
|
||||
|
||||
it('redacts paths and tokens before displaying transport errors', () => {
|
||||
const token = `ghp_${'a'.repeat(30)}`
|
||||
const notice = getCrashReportSubmitFailureNotice(
|
||||
{
|
||||
error: `request failed at C:\\Users\\alice\\Orca\\crash-reports.json token=${token}`
|
||||
},
|
||||
false
|
||||
)
|
||||
|
||||
expect(notice.description).not.toContain('alice')
|
||||
expect(notice.description).not.toContain(token)
|
||||
expect(notice.description).toContain('[redacted')
|
||||
})
|
||||
|
||||
it('warns only when an opted-in diagnostic bundle was not attached', () => {
|
||||
const result = {
|
||||
ok: true as const,
|
||||
report: null,
|
||||
diagnosticBundle: {
|
||||
status: 'not_uploaded' as const,
|
||||
reason: 'bundle collection failed'
|
||||
}
|
||||
}
|
||||
|
||||
expect(getCrashReportSubmitWarningNotice(result, true)).toEqual({
|
||||
title: 'Crash report sent without diagnostic logs',
|
||||
description: 'Diagnostic logs were not attached: bundle collection failed'
|
||||
})
|
||||
expect(getCrashReportSubmitWarningNotice(result, false)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
sanitizeCrashReportString,
|
||||
type CrashReportCopySubmissionFailure,
|
||||
type CrashReportDiagnosticBundle,
|
||||
type CrashReportSubmitResult
|
||||
} from '../../../../shared/crash-reporting'
|
||||
|
||||
export const CRASH_REPORT_SUBMIT_FAILURE_TOAST_ID = 'crash-report-submit-failure'
|
||||
|
||||
export type CrashReportSubmitFailureNotice = {
|
||||
title: string
|
||||
description: string
|
||||
actionLabel: string
|
||||
}
|
||||
|
||||
export type CrashReportSubmitWarningNotice = {
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
type CrashReportSubmitFailureLike = {
|
||||
error: unknown
|
||||
diagnosticBundle?: CrashReportDiagnosticBundle
|
||||
}
|
||||
|
||||
function normalizedFailureMessage(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''
|
||||
const sanitized = sanitizeCrashReportString(message).trim()
|
||||
return (
|
||||
sanitized ||
|
||||
translate(
|
||||
'auto.components.crash.report.submit.notice.unknownError',
|
||||
'The crash report request failed before it returned a reason.'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function asSentence(message: string): string {
|
||||
return /[.!?]$/.test(message) ? message : `${message}.`
|
||||
}
|
||||
|
||||
export function getCrashReportCopySubmissionFailure(
|
||||
failure: CrashReportSubmitFailureLike
|
||||
): CrashReportCopySubmissionFailure {
|
||||
const diagnosticContext =
|
||||
failure.diagnosticBundle?.status === 'uploaded'
|
||||
? {
|
||||
status: 'uploaded' as const,
|
||||
ticketId: sanitizeCrashReportString(failure.diagnosticBundle.ticketId)
|
||||
}
|
||||
: failure.diagnosticBundle?.status === 'not_uploaded'
|
||||
? {
|
||||
status: 'not_uploaded' as const,
|
||||
reason: normalizedFailureMessage(failure.diagnosticBundle.reason)
|
||||
}
|
||||
: undefined
|
||||
return {
|
||||
error: normalizedFailureMessage(failure.error),
|
||||
...(diagnosticContext ? { diagnosticContext } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function getCrashReportSubmitFailureNotice(
|
||||
failure: CrashReportSubmitFailureLike,
|
||||
includeDiagnosticLogs: boolean
|
||||
): CrashReportSubmitFailureNotice {
|
||||
const ticketDetail =
|
||||
failure.diagnosticBundle?.status === 'uploaded'
|
||||
? translate(
|
||||
'auto.components.crash.report.submit.notice.ticketUploaded',
|
||||
'Diagnostic ticket {{value0}} was uploaded but not linked.',
|
||||
{ value0: sanitizeCrashReportString(failure.diagnosticBundle.ticketId) }
|
||||
)
|
||||
: null
|
||||
const omittedDetail =
|
||||
failure.diagnosticBundle?.status === 'not_uploaded'
|
||||
? asSentence(
|
||||
translate(
|
||||
'auto.components.crash.report.submit.notice.diagnosticsReason',
|
||||
'Diagnostic logs were not attached: {{value0}}',
|
||||
{ value0: normalizedFailureMessage(failure.diagnosticBundle.reason) }
|
||||
)
|
||||
)
|
||||
: null
|
||||
const attachmentAlreadyOmitted = failure.diagnosticBundle?.status === 'not_uploaded'
|
||||
const recovery =
|
||||
includeDiagnosticLogs && !attachmentAlreadyOmitted
|
||||
? translate(
|
||||
'auto.components.crash.report.submit.notice.uncheckDiagnostics',
|
||||
'Uncheck "Attach recent diagnostic logs" and try again, or copy the details.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.crash.report.submit.notice.checkConnection',
|
||||
'Check your connection and try again, or copy the details.'
|
||||
)
|
||||
|
||||
return {
|
||||
title: translate(
|
||||
'auto.components.crash.report.submit.notice.notSent',
|
||||
"Crash report wasn't sent"
|
||||
),
|
||||
description: [
|
||||
asSentence(normalizedFailureMessage(failure.error)),
|
||||
ticketDetail,
|
||||
omittedDetail,
|
||||
recovery
|
||||
]
|
||||
.filter((part): part is string => Boolean(part))
|
||||
.join(' '),
|
||||
actionLabel: translate('auto.components.crash.report.submit.notice.copyDetails', 'Copy Details')
|
||||
}
|
||||
}
|
||||
|
||||
export function getCrashReportSubmitWarningNotice(
|
||||
result: Extract<CrashReportSubmitResult, { ok: true }>,
|
||||
includeDiagnosticLogs: boolean
|
||||
): CrashReportSubmitWarningNotice | null {
|
||||
if (!includeDiagnosticLogs || result.diagnosticBundle?.status !== 'not_uploaded') {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
title: translate(
|
||||
'auto.components.crash.report.submit.notice.sentWithoutDiagnostics',
|
||||
'Crash report sent without diagnostic logs'
|
||||
),
|
||||
description: translate(
|
||||
'auto.components.crash.report.submit.notice.diagnosticsReason',
|
||||
'Diagnostic logs were not attached: {{value0}}',
|
||||
{ value0: normalizedFailureMessage(result.diagnosticBundle.reason) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { toast } from 'sonner'
|
||||
import type {
|
||||
CrashReportCopySubmissionFailure,
|
||||
CrashReportRecord
|
||||
} from '../../../../shared/crash-reporting'
|
||||
import { CRASH_REPORT_COPY_FAILURE_TOAST_ID, useCrashReportCopy } from './use-crash-report-copy'
|
||||
|
||||
const copyLatestDiagnostics = vi.fn()
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
dismiss: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
function report(id = 'crash-1'): CrashReportRecord {
|
||||
return {
|
||||
id,
|
||||
createdAt: '2026-05-16T01:00:00.000Z',
|
||||
status: 'pending',
|
||||
source: 'renderer',
|
||||
processType: 'renderer',
|
||||
reason: 'crashed',
|
||||
exitCode: 5,
|
||||
appVersion: '1.0.0',
|
||||
platform: 'win32',
|
||||
osRelease: 'test',
|
||||
arch: 'x64',
|
||||
electronVersion: '41',
|
||||
chromeVersion: '141',
|
||||
details: {}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
copyLatestDiagnostics.mockResolvedValue({ ok: true })
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { crashReports: { copyLatestDiagnostics } }
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('useCrashReportCopy', () => {
|
||||
it('uses the latest edited notes from an older failure-toast action', async () => {
|
||||
const failure: CrashReportCopySubmissionFailure = {
|
||||
error: 'report request timed out',
|
||||
diagnosticContext: { status: 'not_uploaded', reason: 'logs timed out' }
|
||||
}
|
||||
const { result, rerender } = renderHook(({ notes }) => useCrashReportCopy(report(), notes), {
|
||||
initialProps: { notes: 'notes at submit' }
|
||||
})
|
||||
const failureToastCopyAction = result.current
|
||||
|
||||
rerender({ notes: 'notes edited while waiting' })
|
||||
await act(async () => failureToastCopyAction(failure))
|
||||
|
||||
expect(copyLatestDiagnostics).toHaveBeenCalledWith({
|
||||
reportId: 'crash-1',
|
||||
notes: 'notes edited while waiting',
|
||||
submissionFailure: failure
|
||||
})
|
||||
})
|
||||
|
||||
it("does not combine an older report action with a newer report's notes", async () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ currentReport, notes }) => useCrashReportCopy(currentReport, notes),
|
||||
{
|
||||
initialProps: {
|
||||
currentReport: report('crash-a'),
|
||||
notes: 'latest notes for A'
|
||||
}
|
||||
}
|
||||
)
|
||||
const reportACopyAction = result.current
|
||||
|
||||
rerender({ currentReport: report('crash-b'), notes: 'private notes for B' })
|
||||
await act(async () => reportACopyAction())
|
||||
|
||||
expect(copyLatestDiagnostics).toHaveBeenCalledWith({
|
||||
reportId: 'crash-a',
|
||||
notes: 'latest notes for A'
|
||||
})
|
||||
expect(JSON.stringify(copyLatestDiagnostics.mock.calls)).not.toContain('private notes for B')
|
||||
})
|
||||
|
||||
it('keeps a returned copy failure visible with its safe reason', async () => {
|
||||
copyLatestDiagnostics.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: 'Crash diagnostics are too large to copy safely.'
|
||||
})
|
||||
const { result } = renderHook(() => useCrashReportCopy(report(), 'current notes'))
|
||||
|
||||
await act(async () => result.current())
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Crash report details could not be copied.', {
|
||||
id: CRASH_REPORT_COPY_FAILURE_TOAST_ID,
|
||||
description: 'Crash diagnostics are too large to copy safely.',
|
||||
duration: Infinity,
|
||||
dismissible: true
|
||||
})
|
||||
expect(toast.success).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows a safe sticky error toast when copy IPC rejects', async () => {
|
||||
copyLatestDiagnostics.mockRejectedValueOnce(
|
||||
new Error('renderer destroyed with token=must-not-be-shown')
|
||||
)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { result } = renderHook(() => useCrashReportCopy(report(), 'current notes'))
|
||||
|
||||
await act(async () => result.current())
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Crash report details could not be copied.', {
|
||||
id: CRASH_REPORT_COPY_FAILURE_TOAST_ID,
|
||||
duration: Infinity,
|
||||
dismissible: true
|
||||
})
|
||||
expect(JSON.stringify(vi.mocked(toast.error).mock.calls)).not.toContain('must-not-be-shown')
|
||||
expect(toast.success).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type {
|
||||
CrashReportCopySubmissionFailure,
|
||||
CrashReportRecord
|
||||
} from '../../../../shared/crash-reporting'
|
||||
|
||||
export const CRASH_REPORT_COPY_FAILURE_TOAST_ID = 'crash-report-copy-failure'
|
||||
|
||||
function showCopyFailure(description?: string): void {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.crash.report.copy.copyFailed',
|
||||
'Crash report details could not be copied.'
|
||||
),
|
||||
{
|
||||
id: CRASH_REPORT_COPY_FAILURE_TOAST_ID,
|
||||
...(description ? { description } : {}),
|
||||
duration: Infinity,
|
||||
dismissible: true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function useCrashReportCopy(
|
||||
report: CrashReportRecord | null,
|
||||
notes: string
|
||||
): (submissionFailure?: CrashReportCopySubmissionFailure) => Promise<void> {
|
||||
const reportId = report?.id ?? null
|
||||
const notesRef = useRef({ reportId, value: notes })
|
||||
// Why: a submission toast can outlive the render that created it while the
|
||||
// user edits or changes reports; keep live notes scoped to that report.
|
||||
if (notesRef.current.reportId === reportId) {
|
||||
notesRef.current.value = notes
|
||||
} else {
|
||||
notesRef.current = { reportId, value: notes }
|
||||
}
|
||||
const reportNotes = notesRef.current
|
||||
|
||||
return useCallback(
|
||||
async (submissionFailure?: CrashReportCopySubmissionFailure): Promise<void> => {
|
||||
try {
|
||||
const result = await window.api.crashReports.copyLatestDiagnostics({
|
||||
...(report ? { reportId: report.id } : {}),
|
||||
notes: reportNotes.value,
|
||||
...(submissionFailure ? { submissionFailure } : {})
|
||||
})
|
||||
if (!result.ok) {
|
||||
showCopyFailure(result.error)
|
||||
return
|
||||
}
|
||||
toast.dismiss(CRASH_REPORT_COPY_FAILURE_TOAST_ID)
|
||||
toast.success(
|
||||
translate(
|
||||
'auto.components.crash.report.CrashReportDialog.8b8473c544',
|
||||
'Crash report copied.'
|
||||
)
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Failed to copy crash report details:', error)
|
||||
// Why: Sonner closes an action toast when clicked, so a sticky generic
|
||||
// replacement keeps the failure actionable without exposing raw IPC detail.
|
||||
showCopyFailure()
|
||||
}
|
||||
},
|
||||
[report, reportNotes]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { MarkdownDocument } from '../../../../shared/types'
|
||||
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
|
||||
import {
|
||||
getMarkdownDocumentListRequestKey,
|
||||
requestSharedMarkdownDocumentList
|
||||
} from './markdown-document-list-request'
|
||||
|
||||
function context(overrides: Partial<RuntimeFileOperationArgs> = {}): RuntimeFileOperationArgs {
|
||||
return {
|
||||
settings: { activeRuntimeEnvironmentId: null },
|
||||
worktreeId: 'worktree-1',
|
||||
worktreePath: '/repo',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>
|
||||
reject: (error: Error) => void
|
||||
resolve: (value: T) => void
|
||||
} {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: Error) => void
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
return { promise, reject, resolve }
|
||||
}
|
||||
|
||||
describe('shared Markdown document list requests', () => {
|
||||
it('collapses concurrent pane scans and releases the result after settlement', async () => {
|
||||
const pending = deferred<MarkdownDocument[]>()
|
||||
const load = vi.fn(() => pending.promise)
|
||||
const requests = Array.from({ length: 200 }, () =>
|
||||
requestSharedMarkdownDocumentList(context(), '/repo', {}, load)
|
||||
)
|
||||
|
||||
expect(load).toHaveBeenCalledTimes(1)
|
||||
expect(new Set(requests).size).toBe(1)
|
||||
|
||||
const documents = [{ filePath: '/repo/README.md', relativePath: 'README.md' }]
|
||||
pending.resolve(documents as MarkdownDocument[])
|
||||
await expect(Promise.all(requests)).resolves.toEqual(
|
||||
Array.from({ length: 200 }, () => documents)
|
||||
)
|
||||
|
||||
const nextDocuments = [{ filePath: '/repo/notes.md', relativePath: 'notes.md' }]
|
||||
load.mockResolvedValueOnce(nextDocuments as MarkdownDocument[])
|
||||
await expect(requestSharedMarkdownDocumentList(context(), '/repo', {}, load)).resolves.toBe(
|
||||
nextDocuments
|
||||
)
|
||||
expect(load).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('starts a fresh scan after a mutation instead of joining an older snapshot', async () => {
|
||||
const stale = deferred<MarkdownDocument[]>()
|
||||
const fresh = deferred<MarkdownDocument[]>()
|
||||
const load = vi.fn().mockReturnValueOnce(stale.promise).mockReturnValueOnce(fresh.promise)
|
||||
|
||||
const initialRequest = requestSharedMarkdownDocumentList(context(), '/repo', {}, load)
|
||||
const mutationRefresh = requestSharedMarkdownDocumentList(
|
||||
context(),
|
||||
'/repo',
|
||||
{ requireFresh: true },
|
||||
load
|
||||
)
|
||||
|
||||
expect(load).toHaveBeenCalledTimes(2)
|
||||
expect(mutationRefresh).not.toBe(initialRequest)
|
||||
|
||||
const freshDocuments = [{ filePath: '/repo/new.md', relativePath: 'new.md' }]
|
||||
fresh.resolve(freshDocuments as MarkdownDocument[])
|
||||
await expect(mutationRefresh).resolves.toBe(freshDocuments)
|
||||
|
||||
const staleDocuments = [{ filePath: '/repo/old.md', relativePath: 'old.md' }]
|
||||
stale.resolve(staleDocuments as MarkdownDocument[])
|
||||
await expect(initialRequest).resolves.toBe(staleDocuments)
|
||||
})
|
||||
|
||||
it('allows an ordinary retry after an older scan stops settling', async () => {
|
||||
const stale = deferred<MarkdownDocument[]>()
|
||||
const retry = deferred<MarkdownDocument[]>()
|
||||
const load = vi.fn().mockReturnValueOnce(stale.promise).mockReturnValueOnce(retry.promise)
|
||||
const now = vi.spyOn(performance, 'now').mockReturnValue(1_000)
|
||||
|
||||
try {
|
||||
const initialRequest = requestSharedMarkdownDocumentList(context(), '/stuck', {}, load)
|
||||
now.mockReturnValue(31_001)
|
||||
const retryRequest = requestSharedMarkdownDocumentList(context(), '/stuck', {}, load)
|
||||
|
||||
expect(load).toHaveBeenCalledTimes(2)
|
||||
expect(retryRequest).not.toBe(initialRequest)
|
||||
|
||||
retry.resolve([])
|
||||
stale.resolve([])
|
||||
await expect(retryRequest).resolves.toEqual([])
|
||||
await expect(initialRequest).resolves.toEqual([])
|
||||
} finally {
|
||||
now.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('evicts an abandoned scan for a route that is never requested again', async () => {
|
||||
const abandoned = deferred<MarkdownDocument[]>()
|
||||
const load = vi.fn().mockReturnValueOnce(abandoned.promise).mockResolvedValueOnce([])
|
||||
const now = vi.spyOn(performance, 'now').mockReturnValue(1_000)
|
||||
|
||||
try {
|
||||
const abandonedRequest = requestSharedMarkdownDocumentList(
|
||||
context(),
|
||||
'/abandoned-only',
|
||||
{},
|
||||
load
|
||||
)
|
||||
now.mockReturnValue(31_001)
|
||||
// A request for a different route sweeps the expired entry so the map
|
||||
// does not pin the abandoned promise for the renderer's lifetime.
|
||||
await expect(
|
||||
requestSharedMarkdownDocumentList(context(), '/other-route', {}, load)
|
||||
).resolves.toEqual([])
|
||||
|
||||
load.mockResolvedValueOnce([])
|
||||
await expect(
|
||||
requestSharedMarkdownDocumentList(context(), '/abandoned-only', {}, load)
|
||||
).resolves.toEqual([])
|
||||
expect(load).toHaveBeenCalledTimes(3)
|
||||
|
||||
abandoned.resolve([])
|
||||
await expect(abandonedRequest).resolves.toEqual([])
|
||||
} finally {
|
||||
now.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps runtime routes isolated and encodes unusual paths losslessly', () => {
|
||||
const local = context()
|
||||
const ssh = context({ connectionId: 'ssh-1' })
|
||||
const runtime = context({ settings: { activeRuntimeEnvironmentId: ' runtime-1 ' } })
|
||||
const unusualRoot = '/repo\nwith-newline'
|
||||
const windowsRoot = 'C:\\repo\\docs'
|
||||
const uncRoot = '\\\\server\\share\\repo'
|
||||
|
||||
expect(getMarkdownDocumentListRequestKey(local, unusualRoot)).not.toBe(
|
||||
getMarkdownDocumentListRequestKey(ssh, unusualRoot)
|
||||
)
|
||||
expect(getMarkdownDocumentListRequestKey(ssh, unusualRoot)).not.toBe(
|
||||
getMarkdownDocumentListRequestKey(runtime, unusualRoot)
|
||||
)
|
||||
expect(JSON.parse(getMarkdownDocumentListRequestKey(local, unusualRoot)).at(-1)).toBe(
|
||||
unusualRoot
|
||||
)
|
||||
expect(JSON.parse(getMarkdownDocumentListRequestKey(local, windowsRoot)).at(-1)).toBe(
|
||||
windowsRoot
|
||||
)
|
||||
expect(JSON.parse(getMarkdownDocumentListRequestKey(local, uncRoot)).at(-1)).toBe(uncRoot)
|
||||
})
|
||||
|
||||
it('clears rejected requests so a retry can run', async () => {
|
||||
const failure = deferred<MarkdownDocument[]>()
|
||||
const load = vi.fn(() => failure.promise)
|
||||
const first = requestSharedMarkdownDocumentList(context(), '/retry', {}, load)
|
||||
const second = requestSharedMarkdownDocumentList(context(), '/retry', {}, load)
|
||||
|
||||
failure.reject(new Error('offline'))
|
||||
await expect(first).rejects.toThrow('offline')
|
||||
await expect(second).rejects.toThrow('offline')
|
||||
|
||||
load.mockResolvedValueOnce([])
|
||||
await expect(requestSharedMarkdownDocumentList(context(), '/retry', {}, load)).resolves.toEqual(
|
||||
[]
|
||||
)
|
||||
expect(load).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { MarkdownDocument } from '../../../../shared/types'
|
||||
import {
|
||||
listRuntimeMarkdownDocuments,
|
||||
type RuntimeFileOperationArgs
|
||||
} from '@/runtime/runtime-file-client'
|
||||
|
||||
type MarkdownDocumentListLoader = (
|
||||
context: RuntimeFileOperationArgs,
|
||||
rootPath: string
|
||||
) => Promise<MarkdownDocument[]>
|
||||
|
||||
type MarkdownDocumentListRequestOptions = {
|
||||
requireFresh?: boolean
|
||||
}
|
||||
|
||||
type InFlightMarkdownDocumentList = {
|
||||
request: Promise<MarkdownDocument[]>
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
const MARKDOWN_DOCUMENT_LIST_JOIN_WINDOW_MS = 30_000
|
||||
const inFlightMarkdownDocumentLists = new Map<string, InFlightMarkdownDocumentList>()
|
||||
|
||||
export function getMarkdownDocumentListRequestKey(
|
||||
context: RuntimeFileOperationArgs,
|
||||
rootPath: string
|
||||
): string {
|
||||
return JSON.stringify([
|
||||
context.settings?.activeRuntimeEnvironmentId?.trim() ?? '',
|
||||
context.connectionId ?? '',
|
||||
context.worktreeId ?? '',
|
||||
context.worktreePath ?? '',
|
||||
rootPath
|
||||
])
|
||||
}
|
||||
|
||||
export function requestSharedMarkdownDocumentList(
|
||||
context: RuntimeFileOperationArgs,
|
||||
rootPath: string,
|
||||
options: MarkdownDocumentListRequestOptions = {},
|
||||
load: MarkdownDocumentListLoader = listRuntimeMarkdownDocuments
|
||||
): Promise<MarkdownDocument[]> {
|
||||
const now = performance.now()
|
||||
// Why: a never-settling scan for a route that is never requested again would
|
||||
// otherwise pin its map entry (and captured context) for the renderer's life.
|
||||
for (const [staleKey, entry] of inFlightMarkdownDocumentLists) {
|
||||
if (now - entry.startedAt >= MARKDOWN_DOCUMENT_LIST_JOIN_WINDOW_MS) {
|
||||
inFlightMarkdownDocumentLists.delete(staleKey)
|
||||
}
|
||||
}
|
||||
|
||||
const key = getMarkdownDocumentListRequestKey(context, rootPath)
|
||||
const existing = inFlightMarkdownDocumentLists.get(key)
|
||||
if (existing && !options.requireFresh) {
|
||||
return existing.request
|
||||
}
|
||||
|
||||
// Why: split Markdown panes mount together and otherwise launch identical
|
||||
// whole-worktree local/SSH scans; mutation refreshes bypass older snapshots.
|
||||
const request = load(context, rootPath).finally(() => {
|
||||
if (inFlightMarkdownDocumentLists.get(key)?.request === request) {
|
||||
inFlightMarkdownDocumentLists.delete(key)
|
||||
}
|
||||
})
|
||||
// Why: local and UNC filesystem calls have no timeout, so an abandoned scan
|
||||
// must not suppress every ordinary retry for the renderer's lifetime.
|
||||
inFlightMarkdownDocumentLists.set(key, { request, startedAt: now })
|
||||
return request
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { MarkdownDocument } from '../../../../shared/types'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { listRuntimeMarkdownDocuments, statRuntimePath } from '@/runtime/runtime-file-client'
|
||||
import { statRuntimePath } from '@/runtime/runtime-file-client'
|
||||
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
|
||||
import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor'
|
||||
import {
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
resolveMarkdownDocLink
|
||||
} from './markdown-doc-links'
|
||||
import { selectMarkdownDocumentWorktreePath } from './markdown-document-worktree-path-selector'
|
||||
import { requestSharedMarkdownDocumentList } from './markdown-document-list-request'
|
||||
|
||||
type OpenMarkdownDocumentOptions = {
|
||||
anchor?: string | null
|
||||
@@ -52,43 +53,47 @@ export function useMarkdownDocuments(
|
||||
|
||||
const connectionId = getConnectionId(worktreeId)
|
||||
|
||||
const refreshMarkdownDocuments = useCallback(async (): Promise<void> => {
|
||||
if (!worktreeId || !worktreePath) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = requestRef.current + 1
|
||||
requestRef.current = requestId
|
||||
try {
|
||||
const documents = await listRuntimeMarkdownDocuments(
|
||||
{
|
||||
settings: settingsForRuntimeOwner(
|
||||
useAppStore.getState().settings,
|
||||
activeFile.runtimeEnvironmentId
|
||||
),
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
connectionId: connectionId ?? undefined
|
||||
},
|
||||
worktreePath
|
||||
)
|
||||
if (requestRef.current !== requestId) {
|
||||
const refreshMarkdownDocuments = useCallback(
|
||||
async (requireFresh = false): Promise<void> => {
|
||||
if (!worktreeId || !worktreePath) {
|
||||
return
|
||||
}
|
||||
setMarkdownDocumentsByWorktree((prev) => ({
|
||||
...prev,
|
||||
[worktreeId]: documents
|
||||
}))
|
||||
} catch (err) {
|
||||
console.error('Failed to list markdown documents:', err)
|
||||
if (requestRef.current === requestId) {
|
||||
|
||||
const requestId = requestRef.current + 1
|
||||
requestRef.current = requestId
|
||||
try {
|
||||
const documents = await requestSharedMarkdownDocumentList(
|
||||
{
|
||||
settings: settingsForRuntimeOwner(
|
||||
useAppStore.getState().settings,
|
||||
activeFile.runtimeEnvironmentId
|
||||
),
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
connectionId: connectionId ?? undefined
|
||||
},
|
||||
worktreePath,
|
||||
{ requireFresh }
|
||||
)
|
||||
if (requestRef.current !== requestId) {
|
||||
return
|
||||
}
|
||||
setMarkdownDocumentsByWorktree((prev) => ({
|
||||
...prev,
|
||||
[worktreeId]: []
|
||||
[worktreeId]: documents
|
||||
}))
|
||||
} catch (err) {
|
||||
console.error('Failed to list markdown documents:', err)
|
||||
if (requestRef.current === requestId) {
|
||||
setMarkdownDocumentsByWorktree((prev) => ({
|
||||
...prev,
|
||||
[worktreeId]: []
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [activeFile.runtimeEnvironmentId, connectionId, worktreeId, worktreePath])
|
||||
},
|
||||
[activeFile.runtimeEnvironmentId, connectionId, worktreeId, worktreePath]
|
||||
)
|
||||
|
||||
const openMarkdownDocument = useCallback(
|
||||
async (
|
||||
@@ -112,11 +117,11 @@ export function useMarkdownDocuments(
|
||||
document.filePath
|
||||
)
|
||||
if (stats.isDirectory) {
|
||||
await refreshMarkdownDocuments()
|
||||
await refreshMarkdownDocuments(true)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
await refreshMarkdownDocuments()
|
||||
await refreshMarkdownDocuments(true)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -174,7 +179,7 @@ export function useMarkdownDocuments(
|
||||
)
|
||||
|
||||
const mdSave = useCallback(
|
||||
(content: string) => onSave(content).then(() => refreshMarkdownDocuments()),
|
||||
(content: string) => onSave(content).then(() => refreshMarkdownDocuments(true)),
|
||||
[onSave, refreshMarkdownDocuments]
|
||||
)
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ const IOS_CHANNEL_COPY: Record<IosChannel, InstallCopy> = {
|
||||
|
||||
const ANDROID_COPY: InstallCopy = {
|
||||
ctaLabel: 'Download APK',
|
||||
url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk'
|
||||
url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk'
|
||||
}
|
||||
|
||||
export function getInstallCopy(platform: Platform, iosChannel: IosChannel): InstallCopy {
|
||||
|
||||
@@ -191,4 +191,35 @@ describe('useRuntimeFileListForWorktree', () => {
|
||||
|
||||
expect(cancelRuntimeFileListMock).toHaveBeenCalledWith(listContext, listRequest.requestToken)
|
||||
})
|
||||
|
||||
it('does not restart the scan when unrelated ownership metadata changes', async () => {
|
||||
const workspaceKey = folderWorkspaceKey('folder-workspace-1')
|
||||
useAppStore.setState({
|
||||
folderWorkspaces: [makeFolderWorkspace({ connectionId: 'ssh-1' })],
|
||||
projectGroups: [makeProjectGroup({ connectionId: 'ssh-1' })],
|
||||
repos: [],
|
||||
worktreesByRepo: {}
|
||||
} as Partial<AppState>)
|
||||
|
||||
await renderProbe({ enabled: true, onState: () => {}, worktreeId: workspaceKey })
|
||||
await waitForListRuntimeFilesCall()
|
||||
|
||||
await act(async () => {
|
||||
useAppStore.setState({
|
||||
repos: [
|
||||
{
|
||||
id: 'unrelated-repo',
|
||||
path: '/tmp/unrelated',
|
||||
displayName: 'Unrelated',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0
|
||||
}
|
||||
]
|
||||
} as Partial<AppState>)
|
||||
})
|
||||
await flushEffects()
|
||||
|
||||
expect(listRuntimeFilesMock).toHaveBeenCalledTimes(1)
|
||||
expect(cancelRuntimeFileListMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: quick-open file lists are fetched over local or SSH runtime IPC, so loading/error/results track the request lifecycle. */
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import type { Worktree } from '../../../shared/types'
|
||||
import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path'
|
||||
import { getConnectionIdFromState } from '@/lib/connection-context'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner'
|
||||
import { cancelRuntimeFileList, listRuntimeFiles } from '@/runtime/runtime-file-client'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useWorktreesForRepo } from '@/store/selectors'
|
||||
import type { FileExplorerOperationOwner } from '@/components/right-sidebar/file-explorer-types'
|
||||
import {
|
||||
getFileExplorerOperationOwnerFromState,
|
||||
getFileExplorerOwnerUnresolvedMessage,
|
||||
getFileExplorerOperationRoute
|
||||
} from '@/components/right-sidebar/file-explorer-operation-owner'
|
||||
|
||||
export type RuntimeFileListState = {
|
||||
files: string[]
|
||||
loading: boolean
|
||||
loadError: string | null
|
||||
operationOwner?: FileExplorerOperationOwner
|
||||
}
|
||||
|
||||
export function cleanRuntimeFileListError(error: unknown): string {
|
||||
@@ -106,6 +112,9 @@ export function useRuntimeFileListForWorktree({
|
||||
const [files, setFiles] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [listedOperationOwner, setListedOperationOwner] = useState<FileExplorerOperationOwner>({
|
||||
kind: 'unresolved'
|
||||
})
|
||||
const lastRequestKeyRef = useRef('')
|
||||
|
||||
const target = useMemo(
|
||||
@@ -114,9 +123,28 @@ export function useRuntimeFileListForWorktree({
|
||||
)
|
||||
const { excludeRequest } = target
|
||||
|
||||
const connectionId = useAppStore(
|
||||
(state) => getConnectionIdFromState(state, worktreeId) ?? undefined
|
||||
const operationOwnerState = useAppStore(
|
||||
useShallow((state) => ({
|
||||
settings: state.settings,
|
||||
repos: state.repos,
|
||||
worktreesByRepo: state.worktreesByRepo,
|
||||
detectedWorktreesByRepo: state.detectedWorktreesByRepo,
|
||||
folderWorkspaces: state.folderWorkspaces,
|
||||
projectGroups: state.projectGroups,
|
||||
restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey
|
||||
}))
|
||||
)
|
||||
const operationOwner = useMemo(
|
||||
() => getFileExplorerOperationOwnerFromState(operationOwnerState, worktreeId),
|
||||
[operationOwnerState, worktreeId]
|
||||
)
|
||||
const operationOwnerKey = JSON.stringify(operationOwner)
|
||||
const operationOwnerRef = useRef(operationOwner)
|
||||
operationOwnerRef.current = operationOwner
|
||||
const operationRoute = getFileExplorerOperationRoute(operationOwner)
|
||||
const operationRouteAvailable = operationRoute !== null
|
||||
const connectionId = operationRoute?.connectionId
|
||||
const runtimeEnvironmentId = operationRoute?.settings.activeRuntimeEnvironmentId ?? null
|
||||
const activeTargetStatus = useAppStore((state) =>
|
||||
connectionId ? state.sshConnectionStates.get(connectionId)?.status : undefined
|
||||
)
|
||||
@@ -126,19 +154,21 @@ export function useRuntimeFileListForWorktree({
|
||||
activeTargetStatus === 'reconnecting'
|
||||
const requestKey = useMemo(
|
||||
() =>
|
||||
`${worktreePath ?? ''}\n${connectionId ?? ''}\n${excludeRequest.key}\n${activeTargetStatus ?? ''}`,
|
||||
[activeTargetStatus, connectionId, excludeRequest.key, worktreePath]
|
||||
`${worktreePath ?? ''}\n${operationOwnerKey}\n${excludeRequest.key}\n${activeTargetStatus ?? ''}`,
|
||||
[activeTargetStatus, excludeRequest.key, operationOwnerKey, worktreePath]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setLoading(false)
|
||||
setListedOperationOwner({ kind: 'unresolved' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!target.canList || !worktreeId || !worktreePath) {
|
||||
if (!target.canList || !worktreeId || !worktreePath || !operationRouteAvailable) {
|
||||
setFiles([])
|
||||
setLoadError(null)
|
||||
setListedOperationOwner({ kind: 'unresolved' })
|
||||
setLoadError(operationRouteAvailable ? null : getFileExplorerOwnerUnresolvedMessage())
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
@@ -154,10 +184,9 @@ export function useRuntimeFileListForWorktree({
|
||||
|
||||
const excludePaths = excludeRequest.paths.length > 0 ? excludeRequest.paths : undefined
|
||||
const requestToken = createBrowserUuid()
|
||||
const requestOperationOwner = operationOwnerRef.current
|
||||
const requestContext = {
|
||||
// Why: Quick Open lists files for the selected workspace. It must
|
||||
// follow that workspace's owner host, not the globally focused host.
|
||||
settings: getSettingsForWorktreeRuntimeOwner(useAppStore.getState(), worktreeId),
|
||||
settings: { activeRuntimeEnvironmentId: runtimeEnvironmentId },
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
connectionId
|
||||
@@ -171,6 +200,7 @@ export function useRuntimeFileListForWorktree({
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setFiles(result)
|
||||
setListedOperationOwner(requestOperationOwner)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -193,7 +223,23 @@ export function useRuntimeFileListForWorktree({
|
||||
// 30s timeout ("Could not load files for this workspace").
|
||||
cancelRuntimeFileList(requestContext, requestToken)
|
||||
}
|
||||
}, [connectionId, enabled, excludeRequest, requestKey, target.canList, worktreeId, worktreePath])
|
||||
}, [
|
||||
enabled,
|
||||
excludeRequest,
|
||||
connectionId,
|
||||
operationOwnerKey,
|
||||
operationRouteAvailable,
|
||||
requestKey,
|
||||
runtimeEnvironmentId,
|
||||
target.canList,
|
||||
worktreeId,
|
||||
worktreePath
|
||||
])
|
||||
|
||||
return { files, loading: loading || connectionPending, loadError }
|
||||
return {
|
||||
files,
|
||||
loading: loading || connectionPending,
|
||||
loadError,
|
||||
operationOwner: listedOperationOwner
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +163,7 @@ function FileExplorerFiles(): React.JSX.Element {
|
||||
hasNameFilter
|
||||
? {
|
||||
query: nameFilterQuery,
|
||||
operationOwner: nameFilterFiles.operationOwner,
|
||||
relativePaths: nameFilterQueryTooLarge
|
||||
? []
|
||||
: nameFilterFiles.loading && nameFilterFiles.files.length === 0
|
||||
@@ -174,6 +175,7 @@ function FileExplorerFiles(): React.JSX.Element {
|
||||
hasNameFilter,
|
||||
nameFilterFiles.files,
|
||||
nameFilterFiles.loading,
|
||||
nameFilterFiles.operationOwner,
|
||||
nameFilterQuery,
|
||||
nameFilterQueryTooLarge
|
||||
]
|
||||
|
||||
@@ -38,6 +38,7 @@ export function buildSourceControlAgentDeliveryPlan({
|
||||
detectedAgents,
|
||||
disabledAgents: useAppStore.getState().settings?.disabledTuiAgents,
|
||||
cmdOverrides: useAppStore.getState().settings?.agentCmdOverrides,
|
||||
terminalWindowsShell: useAppStore.getState().settings?.terminalWindowsShell,
|
||||
platform: launchPlatform,
|
||||
isRemote
|
||||
})
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { joinPath, normalizeRelativePath } from '@/lib/path'
|
||||
import type { DirEntry } from '../../../../shared/types'
|
||||
import { readRuntimeDirectory } from '@/runtime/runtime-file-client'
|
||||
import type { FileExplorerOperationOwner, TreeNode } from './file-explorer-types'
|
||||
import { shouldIncludeFileExplorerEntry } from './file-explorer-entries'
|
||||
import {
|
||||
getFileExplorerOperationOwner,
|
||||
getFileExplorerOwnerUnresolvedMessage,
|
||||
getFileExplorerOperationRoute
|
||||
} from './file-explorer-operation-owner'
|
||||
|
||||
export type FileExplorerDirectoryListing = {
|
||||
entries: DirEntry[]
|
||||
operationOwner: FileExplorerOperationOwner
|
||||
}
|
||||
|
||||
export function fileExplorerEntriesToTreeNodes(
|
||||
entries: DirEntry[],
|
||||
dirPath: string,
|
||||
depth: number,
|
||||
worktreePath: string | null,
|
||||
operationOwner: FileExplorerOperationOwner
|
||||
): TreeNode[] {
|
||||
return entries.filter(shouldIncludeFileExplorerEntry).map((entry) => {
|
||||
const path = joinPath(dirPath, entry.name)
|
||||
return {
|
||||
name: entry.name,
|
||||
path,
|
||||
relativePath: worktreePath
|
||||
? normalizeRelativePath(path.slice(worktreePath.length + 1))
|
||||
: entry.name,
|
||||
isDirectory: entry.isDirectory,
|
||||
isSymlink: entry.isSymlink,
|
||||
depth: depth + 1,
|
||||
operationOwner
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function readFileExplorerDirectory(
|
||||
activeWorktreeId: string | null | undefined,
|
||||
worktreePath: string | null,
|
||||
dirPath: string
|
||||
): Promise<FileExplorerDirectoryListing> {
|
||||
const operationOwner = getFileExplorerOperationOwner(activeWorktreeId)
|
||||
const route = getFileExplorerOperationRoute(operationOwner)
|
||||
if (!route) {
|
||||
throw new Error(getFileExplorerOwnerUnresolvedMessage())
|
||||
}
|
||||
const entries = await readRuntimeDirectory(
|
||||
{
|
||||
settings: route.settings,
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath,
|
||||
connectionId: route.connectionId
|
||||
},
|
||||
dirPath
|
||||
)
|
||||
return { entries, operationOwner }
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { joinPath, normalizeRelativePath } from '@/lib/path'
|
||||
import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text'
|
||||
import type { TreeNode } from './file-explorer-types'
|
||||
import type { FileExplorerOperationOwner, TreeNode } from './file-explorer-types'
|
||||
import {
|
||||
createFileExplorerRowProjectionFromParts,
|
||||
type FileExplorerRowProjection
|
||||
@@ -12,6 +12,7 @@ import { isPathIgnored } from './status-display'
|
||||
export type FileExplorerNameFilterProjectionSource = {
|
||||
query: string
|
||||
relativePaths: readonly string[] | null
|
||||
operationOwner?: FileExplorerOperationOwner
|
||||
}
|
||||
|
||||
export const FILE_EXPLORER_NAME_FILTER_QUERY_MAX_BYTES = 2 * 1024
|
||||
@@ -133,14 +134,16 @@ function createSyntheticNode(
|
||||
relativePath: string,
|
||||
name: string,
|
||||
depth: number,
|
||||
isDirectory: boolean
|
||||
isDirectory: boolean,
|
||||
operationOwner: FileExplorerOperationOwner | undefined
|
||||
): TreeNode {
|
||||
return {
|
||||
name,
|
||||
path: joinPath(worktreePath, relativePath),
|
||||
relativePath,
|
||||
isDirectory,
|
||||
depth
|
||||
depth,
|
||||
operationOwner
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +200,14 @@ export function createNameFilteredFileExplorerProjection({
|
||||
let entry = currentChildren.get(name)
|
||||
if (!entry) {
|
||||
entry = {
|
||||
node: createSyntheticNode(worktreePath, currentRelativePath, name, index, isDirectory),
|
||||
node: createSyntheticNode(
|
||||
worktreePath,
|
||||
currentRelativePath,
|
||||
name,
|
||||
index,
|
||||
isDirectory,
|
||||
nameFilter.operationOwner
|
||||
),
|
||||
children: new Map()
|
||||
}
|
||||
currentChildren.set(name, entry)
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { getConnectionIdFromState } from '@/lib/connection-context'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers'
|
||||
import type { AppState } from '@/store/types'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
parseExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
getExplicitRuntimeEnvironmentIdForWorktree,
|
||||
getSettingsForWorktreeRuntimeOwner
|
||||
} from '@/lib/worktree-runtime-owner'
|
||||
import type { FileExplorerOperationOwner } from './file-explorer-types'
|
||||
|
||||
export type FileExplorerOperationRoute = {
|
||||
settings: { activeRuntimeEnvironmentId: string | null }
|
||||
connectionId?: string
|
||||
}
|
||||
|
||||
type FileExplorerOwnerState = Pick<
|
||||
AppState,
|
||||
| 'settings'
|
||||
| 'repos'
|
||||
| 'worktreesByRepo'
|
||||
| 'detectedWorktreesByRepo'
|
||||
| 'folderWorkspaces'
|
||||
| 'projectGroups'
|
||||
| 'restoredRuntimeHostIdByWorkspaceSessionKey'
|
||||
>
|
||||
|
||||
export function getFileExplorerOperationOwnerFromState(
|
||||
state: FileExplorerOwnerState,
|
||||
worktreeId: string | null | undefined
|
||||
): FileExplorerOperationOwner {
|
||||
const parsedWorkspace = worktreeId ? parseWorkspaceKey(worktreeId) : null
|
||||
if (worktreeId && parsedWorkspace?.type !== 'folder') {
|
||||
const exactHostIds = getExactWorktreeHostIds(state, worktreeId)
|
||||
if (exactHostIds.size > 1) {
|
||||
return { kind: 'unresolved' }
|
||||
}
|
||||
const exactHostId = exactHostIds.values().next().value
|
||||
if (exactHostId) {
|
||||
return operationOwnerFromHostId(exactHostId)
|
||||
}
|
||||
|
||||
const repoId = getRepoIdFromWorktreeId(worktreeId)
|
||||
const repoHostIds = new Set(
|
||||
state.repos.filter((repo) => repo.id === repoId).map(getRepoExecutionHostId)
|
||||
)
|
||||
if (repoHostIds.size > 1) {
|
||||
return { kind: 'unresolved' }
|
||||
}
|
||||
}
|
||||
|
||||
const connectionId = getConnectionIdFromState(state, worktreeId ?? null)
|
||||
const explicitRuntimeEnvironmentId = getExplicitRuntimeEnvironmentIdForWorktree(state, worktreeId)
|
||||
// Why: global runtime focus is not ownership evidence while SSH/local
|
||||
// metadata is unresolved; destructive actions must wait for explicit provenance.
|
||||
if (connectionId === undefined && explicitRuntimeEnvironmentId === null) {
|
||||
return { kind: 'unresolved' }
|
||||
}
|
||||
const settings = getSettingsForWorktreeRuntimeOwner(state, worktreeId)
|
||||
// Why: inferred SSH ownership outranks global runtime focus, but an explicit
|
||||
// workspace runtime still owns its files.
|
||||
const runtimeEnvironmentId =
|
||||
connectionId && explicitRuntimeEnvironmentId === null
|
||||
? null
|
||||
: settings.activeRuntimeEnvironmentId?.trim()
|
||||
if (runtimeEnvironmentId) {
|
||||
return { kind: 'runtime', environmentId: runtimeEnvironmentId }
|
||||
}
|
||||
if (connectionId === undefined) {
|
||||
return { kind: 'unresolved' }
|
||||
}
|
||||
return connectionId ? { kind: 'ssh', connectionId } : { kind: 'local' }
|
||||
}
|
||||
|
||||
export function getFileExplorerOperationOwner(
|
||||
worktreeId: string | null | undefined
|
||||
): FileExplorerOperationOwner {
|
||||
return getFileExplorerOperationOwnerFromState(useAppStore.getState(), worktreeId)
|
||||
}
|
||||
|
||||
export function getFileExplorerOperationRoute(
|
||||
owner: FileExplorerOperationOwner
|
||||
): FileExplorerOperationRoute | null {
|
||||
switch (owner.kind) {
|
||||
case 'local':
|
||||
return { settings: { activeRuntimeEnvironmentId: null } }
|
||||
case 'ssh':
|
||||
return {
|
||||
settings: { activeRuntimeEnvironmentId: null },
|
||||
connectionId: owner.connectionId
|
||||
}
|
||||
case 'runtime':
|
||||
return { settings: { activeRuntimeEnvironmentId: owner.environmentId } }
|
||||
case 'unresolved':
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getFileExplorerOwnerUnresolvedMessage(): string {
|
||||
return translate(
|
||||
'auto.components.right.sidebar.fileExplorerOperationOwner.unresolved',
|
||||
"Couldn't determine which host owns this workspace. Check the connection and try again."
|
||||
)
|
||||
}
|
||||
|
||||
function getExactWorktreeHostIds(
|
||||
state: Pick<AppState, 'worktreesByRepo' | 'detectedWorktreesByRepo'>,
|
||||
worktreeId: string
|
||||
): Set<ExecutionHostId> {
|
||||
const hostIds = new Set<ExecutionHostId>()
|
||||
for (const worktrees of Object.values(state.worktreesByRepo)) {
|
||||
for (const worktree of worktrees) {
|
||||
if (worktree.id === worktreeId && worktree.hostId) {
|
||||
hostIds.add(worktree.hostId)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const result of Object.values(state.detectedWorktreesByRepo)) {
|
||||
for (const worktree of result.worktrees) {
|
||||
if (worktree.id === worktreeId && worktree.hostId) {
|
||||
hostIds.add(worktree.hostId)
|
||||
}
|
||||
}
|
||||
}
|
||||
return hostIds
|
||||
}
|
||||
|
||||
function operationOwnerFromHostId(hostId: ExecutionHostId): FileExplorerOperationOwner {
|
||||
const parsed = parseExecutionHostId(hostId)
|
||||
switch (parsed?.kind) {
|
||||
case 'local':
|
||||
return { kind: 'local' }
|
||||
case 'ssh':
|
||||
return { kind: 'ssh', connectionId: parsed.targetId }
|
||||
case 'runtime':
|
||||
return { kind: 'runtime', environmentId: parsed.environmentId }
|
||||
case undefined:
|
||||
return { kind: 'unresolved' }
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -15,6 +15,7 @@ describe('right sidebar file/git runtime ownership boundaries', () => {
|
||||
'src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts',
|
||||
'src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts',
|
||||
'src/renderer/src/components/right-sidebar/useFileDuplicate.ts',
|
||||
'src/renderer/src/components/right-sidebar/useFileDeletion.ts',
|
||||
'src/renderer/src/components/right-sidebar/use-file-explorer-ignored-paths.ts',
|
||||
'src/renderer/src/components/right-sidebar/useGitStatusPolling.ts',
|
||||
'src/renderer/src/components/right-sidebar/useFileSearchRunner.ts',
|
||||
@@ -23,7 +24,7 @@ describe('right sidebar file/git runtime ownership boundaries', () => {
|
||||
const text = source(path)
|
||||
|
||||
expect(text).toMatch(
|
||||
/getRightSidebarWorktreeRuntimeSettings|getSettingsForWorktreeRuntimeOwner/
|
||||
/getRightSidebarWorktreeRuntimeSettings|getSettingsForWorktreeRuntimeOwner|getFileExplorerOperationOwner|getFileExplorerOperationRoute/
|
||||
)
|
||||
expect(text).not.toContain('settings: useAppStore.getState().settings')
|
||||
expect(text).not.toContain('const settings = useAppStore.getState().settings')
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
export type FileExplorerOperationOwner =
|
||||
| { kind: 'local' }
|
||||
| { kind: 'ssh'; connectionId: string }
|
||||
| { kind: 'runtime'; environmentId: string }
|
||||
| { kind: 'unresolved' }
|
||||
|
||||
export type TreeNode = {
|
||||
name: string
|
||||
path: string
|
||||
@@ -5,6 +11,8 @@ export type TreeNode = {
|
||||
isDirectory: boolean
|
||||
isSymlink?: boolean
|
||||
depth: number
|
||||
/** Host snapshot that produced this node. Destructive actions fail closed without it. */
|
||||
operationOwner?: FileExplorerOperationOwner
|
||||
}
|
||||
|
||||
export type DirCache = {
|
||||
|
||||
@@ -3,11 +3,10 @@ import { toast } from 'sonner'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useConfirmationDialog } from '@/components/confirmation-dialog'
|
||||
import { dirname } from '@/lib/path'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
|
||||
import { findWorktreeById } from '@/store/slices/worktree-helpers'
|
||||
import { isPathEqualOrDescendant } from './file-explorer-paths'
|
||||
import type { TreeNode } from './file-explorer-types'
|
||||
import { getFileExplorerOperationRoute } from './file-explorer-operation-owner'
|
||||
import {
|
||||
requestEditorFileSave,
|
||||
requestEditorSaveQuiesce
|
||||
@@ -15,7 +14,6 @@ import {
|
||||
import { commitFileExplorerOp } from './fileExplorerUndoRedo'
|
||||
import {
|
||||
deleteRuntimePath,
|
||||
isRemoteRuntimeFileOperation,
|
||||
readRuntimeFileContent,
|
||||
writeRuntimeFile
|
||||
} from '@/runtime/runtime-file-client'
|
||||
@@ -50,6 +48,10 @@ export function useFileDeletion({
|
||||
}: UseFileDeletionParams): UseFileDeletionResult {
|
||||
const confirm = useConfirmationDialog()
|
||||
const deleteShortcutLabel = useShortcutLabel('fileExplorer.delete')
|
||||
const unresolvedDeleteOwnerError = translate(
|
||||
'auto.components.right.sidebar.useFileDeletion.8b8ee9d22f',
|
||||
"Couldn't determine which host owns this file. Check the workspace connection and try again."
|
||||
)
|
||||
// Why: track in-flight deletes per-path so repeated Del presses on the same
|
||||
// node don't issue duplicate IPC calls; the map is a ref to avoid re-renders.
|
||||
const inFlightRef = useRef<Set<string>>(new Set())
|
||||
@@ -61,21 +63,28 @@ export function useFileDeletion({
|
||||
}
|
||||
inFlightRef.current.add(node.path)
|
||||
|
||||
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
|
||||
const state = useAppStore.getState()
|
||||
const worktree = activeWorktreeId
|
||||
? findWorktreeById(state.worktreesByRepo, activeWorktreeId)
|
||||
: null
|
||||
const fileContext = {
|
||||
settings: state.settings,
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath: worktree?.path ?? null,
|
||||
connectionId
|
||||
}
|
||||
const isRemote =
|
||||
connectionId !== undefined || isRemoteRuntimeFileOperation(fileContext, node.path)
|
||||
const operationOwner = node.operationOwner ?? { kind: 'unresolved' as const }
|
||||
const operationRoute = getFileExplorerOperationRoute(operationOwner)
|
||||
// Why: treat every non-local owner (ssh, runtime, unresolved) as remote
|
||||
// for confirm/error copy, then fail closed below when the route is null
|
||||
// so an unresolved owner never reaches local filesystem authorization.
|
||||
const isRemote = operationOwner.kind !== 'local'
|
||||
|
||||
try {
|
||||
if (!operationRoute) {
|
||||
throw new Error(unresolvedDeleteOwnerError)
|
||||
}
|
||||
// Why: cached nodes can outlive host hydration changes; preserve the
|
||||
// listing-time owner so deletion cannot jump to a same-path file elsewhere.
|
||||
const state = useAppStore.getState()
|
||||
const worktree = activeWorktreeId ? state.getKnownWorktreeById(activeWorktreeId) : null
|
||||
const connectionId = operationRoute.connectionId
|
||||
const fileContext = {
|
||||
settings: operationRoute.settings,
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath: worktree?.path ?? null,
|
||||
connectionId
|
||||
}
|
||||
// Why: remote deletes bypass OS Trash, and undo cannot recover
|
||||
// directories or unreadable files.
|
||||
if (isRemote) {
|
||||
@@ -206,7 +215,15 @@ export function useFileDeletion({
|
||||
inFlightRef.current.delete(node.path)
|
||||
}
|
||||
},
|
||||
[activeWorktreeId, closeFile, confirm, isWindows, openFiles, refreshDir]
|
||||
[
|
||||
activeWorktreeId,
|
||||
closeFile,
|
||||
confirm,
|
||||
isWindows,
|
||||
openFiles,
|
||||
refreshDir,
|
||||
unresolvedDeleteOwnerError
|
||||
]
|
||||
)
|
||||
|
||||
const requestDelete = useCallback(
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('refreshFileExplorerExpandedDirs', () => {
|
||||
'/repo/src': [entry('index.ts')],
|
||||
'/repo/docs': [entry('guide.md')]
|
||||
}
|
||||
return entriesByPath[dirPath] ?? []
|
||||
return { entries: entriesByPath[dirPath] ?? [], operationOwner: { kind: 'local' as const } }
|
||||
})
|
||||
|
||||
const refreshed = await refreshFileExplorerExpandedDirs({
|
||||
@@ -115,9 +115,9 @@ describe('refreshFileExplorerExpandedDirs', () => {
|
||||
...prev,
|
||||
'/repo/src': newerSrcCache
|
||||
}))
|
||||
return [entry('stale.ts')]
|
||||
return { entries: [entry('stale.ts')], operationOwner: { kind: 'local' as const } }
|
||||
}
|
||||
return [entry('guide.md')]
|
||||
return { entries: [entry('guide.md')], operationOwner: { kind: 'local' as const } }
|
||||
})
|
||||
|
||||
const refreshed = await refreshFileExplorerExpandedDirs({
|
||||
@@ -158,10 +158,10 @@ describe('refreshFileExplorerExpandedDirs', () => {
|
||||
})
|
||||
const readDirectory = vi.fn(async (dirPath: string) => {
|
||||
if (dirPath === '/repo/src') {
|
||||
return [entry('stale.ts')]
|
||||
return { entries: [entry('stale.ts')], operationOwner: { kind: 'local' as const } }
|
||||
}
|
||||
await docsGate
|
||||
return [entry('guide.md')]
|
||||
return { entries: [entry('guide.md')], operationOwner: { kind: 'local' as const } }
|
||||
})
|
||||
|
||||
const refreshPromise = refreshFileExplorerExpandedDirs({
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { joinPath, normalizeRelativePath } from '@/lib/path'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import type { DirEntry } from '../../../../shared/types'
|
||||
import type { DirCache, TreeNode } from './file-explorer-types'
|
||||
import type { DirCache } from './file-explorer-types'
|
||||
import { splitPathSegments } from './path-tree'
|
||||
import { shouldIncludeFileExplorerEntry } from './file-explorer-entries'
|
||||
import { readRuntimeDirectory, statRuntimePath } from '@/runtime/runtime-file-client'
|
||||
import { statRuntimePath } from '@/runtime/runtime-file-client'
|
||||
import {
|
||||
createFileExplorerDirLoadTracker,
|
||||
type FileExplorerDirLoadTracker
|
||||
} from './file-explorer-dir-load-tracker'
|
||||
import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner'
|
||||
import {
|
||||
getFileExplorerOperationOwner,
|
||||
getFileExplorerOwnerUnresolvedMessage,
|
||||
getFileExplorerOperationRoute
|
||||
} from './file-explorer-operation-owner'
|
||||
import {
|
||||
fileExplorerEntriesToTreeNodes,
|
||||
readFileExplorerDirectory,
|
||||
type FileExplorerDirectoryListing
|
||||
} from './file-explorer-directory-listing'
|
||||
|
||||
type UseFileExplorerTreeResult = {
|
||||
dirCache: Record<string, DirCache>
|
||||
@@ -40,45 +45,7 @@ type RefreshFileExplorerExpandedDirsParams = {
|
||||
worktreePath: string
|
||||
dirLoadTracker: FileExplorerDirLoadTracker
|
||||
setDirCache: Dispatch<SetStateAction<Record<string, DirCache>>>
|
||||
readDirectory: (dirPath: string) => Promise<DirEntry[]>
|
||||
}
|
||||
|
||||
function entriesToTreeNodes(
|
||||
entries: DirEntry[],
|
||||
dirPath: string,
|
||||
depth: number,
|
||||
worktreePath: string | null
|
||||
): TreeNode[] {
|
||||
return entries.filter(shouldIncludeFileExplorerEntry).map((entry) => {
|
||||
const path = joinPath(dirPath, entry.name)
|
||||
return {
|
||||
name: entry.name,
|
||||
path,
|
||||
relativePath: worktreePath
|
||||
? normalizeRelativePath(path.slice(worktreePath.length + 1))
|
||||
: entry.name,
|
||||
isDirectory: entry.isDirectory,
|
||||
isSymlink: entry.isSymlink,
|
||||
depth: depth + 1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function readWorktreeDirectory(
|
||||
activeWorktreeId: string | null | undefined,
|
||||
worktreePath: string | null,
|
||||
dirPath: string
|
||||
): Promise<DirEntry[]> {
|
||||
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
|
||||
return readRuntimeDirectory(
|
||||
{
|
||||
settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId),
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath,
|
||||
connectionId
|
||||
},
|
||||
dirPath
|
||||
)
|
||||
readDirectory: (dirPath: string) => Promise<FileExplorerDirectoryListing>
|
||||
}
|
||||
|
||||
export async function refreshFileExplorerExpandedDirs({
|
||||
@@ -115,7 +82,7 @@ export async function refreshFileExplorerExpandedDirs({
|
||||
uniqueDirs.map(async ({ dirPath, depth }) => {
|
||||
const loadToken = loadTokens.get(dirPath)!
|
||||
try {
|
||||
const entries = await readDirectory(dirPath)
|
||||
const listing = await readDirectory(dirPath)
|
||||
if (!dirLoadTracker.isCurrent(loadToken)) {
|
||||
return { current: false as const }
|
||||
}
|
||||
@@ -123,7 +90,13 @@ export async function refreshFileExplorerExpandedDirs({
|
||||
current: true as const,
|
||||
dirPath,
|
||||
cache: {
|
||||
children: entriesToTreeNodes(entries, dirPath, depth, worktreePath),
|
||||
children: fileExplorerEntriesToTreeNodes(
|
||||
listing.entries,
|
||||
dirPath,
|
||||
depth,
|
||||
worktreePath,
|
||||
listing.operationOwner
|
||||
),
|
||||
loading: false
|
||||
}
|
||||
}
|
||||
@@ -197,14 +170,20 @@ export function useFileExplorerTree(
|
||||
}
|
||||
}))
|
||||
try {
|
||||
const entries = await readWorktreeDirectory(activeWorktreeId, worktreePath, dirPath)
|
||||
const listing = await readFileExplorerDirectory(activeWorktreeId, worktreePath, dirPath)
|
||||
if (!dirLoadTrackerRef.current.isCurrent(loadToken)) {
|
||||
return false
|
||||
}
|
||||
if (depth === -1) {
|
||||
setRootError(null)
|
||||
}
|
||||
const children = entriesToTreeNodes(entries, dirPath, depth, worktreePath)
|
||||
const children = fileExplorerEntriesToTreeNodes(
|
||||
listing.entries,
|
||||
dirPath,
|
||||
depth,
|
||||
worktreePath,
|
||||
listing.operationOwner
|
||||
)
|
||||
setDirCache((prev) => ({ ...prev, [dirPath]: { children, loading: false } }))
|
||||
return true
|
||||
} catch (error) {
|
||||
@@ -247,13 +226,17 @@ export function useFileExplorerTree(
|
||||
|
||||
const statPath = useCallback(
|
||||
async (path: string) => {
|
||||
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
|
||||
const operationOwner = getFileExplorerOperationOwner(activeWorktreeId)
|
||||
const route = getFileExplorerOperationRoute(operationOwner)
|
||||
if (!route) {
|
||||
throw new Error(getFileExplorerOwnerUnresolvedMessage())
|
||||
}
|
||||
return statRuntimePath(
|
||||
{
|
||||
settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId),
|
||||
settings: route.settings,
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath,
|
||||
connectionId
|
||||
connectionId: route.connectionId
|
||||
},
|
||||
path
|
||||
)
|
||||
@@ -286,7 +269,7 @@ export function useFileExplorerTree(
|
||||
worktreePath,
|
||||
dirLoadTracker: dirLoadTrackerRef.current,
|
||||
setDirCache,
|
||||
readDirectory: (dirPath) => readWorktreeDirectory(activeWorktreeId, worktreePath, dirPath)
|
||||
readDirectory: (dirPath) => readFileExplorerDirectory(activeWorktreeId, worktreePath, dirPath)
|
||||
})
|
||||
}, [activeWorktreeId, expanded, loadDir, worktreePath])
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ export { getMobileSettingsPaneSearchEntries }
|
||||
|
||||
const ORCA_IOS_APP_STORE_URL = 'https://apps.apple.com/app/orca-ide/id6766130217'
|
||||
const ORCA_ANDROID_APK_URL =
|
||||
'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk'
|
||||
'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk'
|
||||
|
||||
export function MobileSettingsPane(): React.JSX.Element {
|
||||
const showMobileButton = useAppStore((s) => s.settings?.showMobileButton !== false)
|
||||
|
||||
@@ -23,6 +23,7 @@ vi.mock('@/lib/new-workspace', async (importOriginal) => {
|
||||
})
|
||||
|
||||
import {
|
||||
buildFolderWorkspaceLinkedStartupPlan,
|
||||
getFolderWorkspaceAgentLaunchPlatform,
|
||||
submitFolderWorkspaceCreate
|
||||
} from './folder-workspace-composer-submit'
|
||||
@@ -633,3 +634,27 @@ describe('submitFolderWorkspaceCreate', () => {
|
||||
expect(mocks.activateAndRevealFolderWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildFolderWorkspaceLinkedStartupPlan', () => {
|
||||
it('uses cmd quoting for configured arguments on local Windows', () => {
|
||||
const plan = buildFolderWorkspaceLinkedStartupPlan({
|
||||
agent: 'hermes',
|
||||
linkedWorkItem: {
|
||||
provider: 'github',
|
||||
type: 'issue',
|
||||
number: 42,
|
||||
title: 'Restore linked quick-create',
|
||||
url: 'https://github.com/stablyai/orca/issues/42',
|
||||
repoId: 'repo-1'
|
||||
},
|
||||
note: '',
|
||||
agentCmdOverrides: {},
|
||||
agentArgs: '--provider "value with space"',
|
||||
platform: 'win32',
|
||||
shell: 'cmd',
|
||||
isRemote: false
|
||||
})
|
||||
|
||||
expect(plan?.launchCommand).toBe('hermes --tui "--provider" "value with space"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,6 +17,8 @@ import { TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config'
|
||||
import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path'
|
||||
import type { FolderWorkspace, ProjectGroup, TuiAgent } from '../../../../shared/types'
|
||||
import { isWslUncPath } from '../../../../shared/wsl-paths'
|
||||
import { resolveLocalWindowsAgentStartupShell } from '../../../../shared/windows-terminal-shell'
|
||||
import type { AgentStartupShell } from '../../../../shared/tui-agent-startup-shell'
|
||||
import type { LaunchSource } from '../../../../shared/telemetry-events'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import {
|
||||
@@ -44,6 +46,7 @@ type SubmitFolderWorkspaceCreateParams = {
|
||||
agentCmdOverrides: Record<string, string> | undefined
|
||||
agentArgs?: string | null
|
||||
agentEnv?: Record<string, string>
|
||||
terminalWindowsShell?: string | null
|
||||
isRemote?: boolean
|
||||
launchSource?: LaunchSource
|
||||
runtimeEnvironmentId?: string | null
|
||||
@@ -61,7 +64,7 @@ export function getFolderWorkspaceAgentLaunchPlatform(
|
||||
return parentPath && isWslUncPath(parentPath) ? 'linux' : CLIENT_PLATFORM
|
||||
}
|
||||
|
||||
function buildFolderWorkspaceLinkedStartupPlan(args: {
|
||||
export function buildFolderWorkspaceLinkedStartupPlan(args: {
|
||||
agent: TuiAgent
|
||||
linkedWorkItem: LinkedWorkItemSummary
|
||||
note: string
|
||||
@@ -69,6 +72,7 @@ function buildFolderWorkspaceLinkedStartupPlan(args: {
|
||||
agentArgs?: string | null
|
||||
agentEnv?: Record<string, string>
|
||||
platform: NodeJS.Platform
|
||||
shell?: AgentStartupShell
|
||||
isRemote: boolean
|
||||
}): AgentStartupPlan | null {
|
||||
const { prompt, draftPrompt } = resolveQuickCreateLinkedWorkItemPrompt(
|
||||
@@ -84,6 +88,7 @@ function buildFolderWorkspaceLinkedStartupPlan(args: {
|
||||
agentArgs: args.agentArgs,
|
||||
agentEnv: args.agentEnv,
|
||||
platform: args.platform,
|
||||
shell: args.shell,
|
||||
isRemote: args.isRemote
|
||||
})
|
||||
: null
|
||||
@@ -110,6 +115,7 @@ function buildFolderWorkspaceLinkedStartupPlan(args: {
|
||||
agentArgs: args.agentArgs,
|
||||
agentEnv: args.agentEnv,
|
||||
platform: args.platform,
|
||||
shell: args.shell,
|
||||
isRemote: args.isRemote,
|
||||
allowEmptyPromptLaunch: true
|
||||
})
|
||||
@@ -153,6 +159,7 @@ export async function submitFolderWorkspaceCreate({
|
||||
agentCmdOverrides,
|
||||
agentArgs,
|
||||
agentEnv,
|
||||
terminalWindowsShell,
|
||||
launchSource = 'sidebar',
|
||||
runtimeEnvironmentId = null,
|
||||
createFolderWorkspace,
|
||||
@@ -168,6 +175,11 @@ export async function submitFolderWorkspaceCreate({
|
||||
// Why: an SSH folder group runs the plain `orca` relay shim, so the Linux-only
|
||||
// `orca-ide` rename must not be applied for remote launches.
|
||||
const launchIsRemote = Boolean(projectGroup.connectionId)
|
||||
const launchShell = resolveLocalWindowsAgentStartupShell({
|
||||
platform: launchPlatform,
|
||||
isRemote: launchIsRemote,
|
||||
terminalWindowsShell
|
||||
})
|
||||
const startupPlan =
|
||||
quickAgent && linkedWorkItem
|
||||
? buildFolderWorkspaceLinkedStartupPlan({
|
||||
@@ -178,6 +190,7 @@ export async function submitFolderWorkspaceCreate({
|
||||
agentArgs,
|
||||
agentEnv,
|
||||
platform: launchPlatform,
|
||||
shell: launchShell,
|
||||
isRemote: launchIsRemote
|
||||
})
|
||||
: quickAgent
|
||||
@@ -188,6 +201,7 @@ export async function submitFolderWorkspaceCreate({
|
||||
agentArgs,
|
||||
agentEnv,
|
||||
platform: launchPlatform,
|
||||
shell: launchShell,
|
||||
isRemote: launchIsRemote,
|
||||
allowEmptyPromptLaunch: true
|
||||
})
|
||||
|
||||
@@ -4297,10 +4297,21 @@ describe('connectPanePty', () => {
|
||||
}
|
||||
// Simulate xterm answering a TUI's DA1 query while the phone owns the PTY.
|
||||
;(onDataHandler as unknown as (data: string) => void)('\x1b[?1;2c')
|
||||
// Capability handlers are registered outside onData and must honor the
|
||||
// same mobile query-authority lock.
|
||||
const csiCalls = (
|
||||
pane.terminal.parser.registerCsiHandler as unknown as {
|
||||
mock: { calls: [{ final: string }, (params: (number | number[])[]) => boolean][] }
|
||||
}
|
||||
).mock.calls
|
||||
const da1Handler = csiCalls.find(([id]) => id.final === 'c')?.[1]
|
||||
expect(da1Handler).toBeTypeOf('function')
|
||||
da1Handler?.([])
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(window.api.runtime.restoreTerminalFit).not.toHaveBeenCalled()
|
||||
expect(transport.sendInput).not.toHaveBeenCalled()
|
||||
expect(transport.sendInputImmediate).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
setDriverForPty(ptyId, { kind: 'idle' })
|
||||
}
|
||||
@@ -17714,7 +17725,12 @@ describe('connectPanePty', () => {
|
||||
)
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(window.api.pty.confirmForegroundProcess).toHaveBeenCalledTimes(3)
|
||||
// The bounded ladder runs at least its initial read plus two retries; an
|
||||
// incidental droid reconfirm can add one more, so assert the floor, not an
|
||||
// exact count (matches the warm-reattach-to-shell sibling test above).
|
||||
expect(
|
||||
vi.mocked(window.api.pty.confirmForegroundProcess).mock.calls.length
|
||||
).toBeGreaterThanOrEqual(3)
|
||||
expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({
|
||||
agent: null,
|
||||
shellForeground: true
|
||||
|
||||
@@ -3256,6 +3256,14 @@ export function connectPanePty(
|
||||
const transport = runtimeEnvironmentId
|
||||
? createRemoteRuntimePtyTransport(runtimeEnvironmentId, transportOptions)
|
||||
: createIpcPtyTransport(transportOptions)
|
||||
const canSendDesktopQueryReply = (): boolean => {
|
||||
const ptyId = transport.getPtyId()
|
||||
return !ptyId || !isPtyLocked(ptyId)
|
||||
}
|
||||
// Why: parser/capability handlers bypass the ordinary onData guard. Keep
|
||||
// desktop silent while the elected mobile xterm owns query replies.
|
||||
const sendDesktopQueryReplyImmediate = (data: string): boolean =>
|
||||
canSendDesktopQueryReply() && transport.sendInputImmediate(data)
|
||||
// Why (gate mode only): for gate-managed PTYs this fact is the SOLE 2031
|
||||
// responder — visible, hidden, marked or not. Conditioning the reply on the
|
||||
// hidden mark double-fired (mark set + bytes delivered live via interest →
|
||||
@@ -3273,7 +3281,7 @@ export function connectPanePty(
|
||||
)
|
||||
// Why immediate: a mode-2031 query reply must beat the remote input debounce
|
||||
// or it can miss the querying program's read window (#7329).
|
||||
transport.sendInputImmediate(mode2031SequenceFor(mode))
|
||||
sendDesktopQueryReplyImmediate(mode2031SequenceFor(mode))
|
||||
// Why: register the subscription exactly like the xterm CSI handler
|
||||
// would — without the registry entry, later theme flips never push the
|
||||
// CSI 997 update and the TUI keeps a stale theme after reveal.
|
||||
@@ -3287,13 +3295,13 @@ export function connectPanePty(
|
||||
// Why: OSC 10/11 + DA1 replies must beat the querying program's raw-mode
|
||||
// read window; the remote transport's input debounce would corrupt them
|
||||
// (#7329), so send immediately.
|
||||
sendInput: (data) => transport.sendInputImmediate(data),
|
||||
sendInput: sendDesktopQueryReplyImmediate,
|
||||
isReplaying: () => isPaneReplaying(deps.replayingPanesRef, pane.id),
|
||||
...(isNativeWindowsConpty ? { da1Response: CONPTY_DA1_RESPONSE } : {})
|
||||
})
|
||||
const respondToTerminalPixelSizeQueries = createTerminalPixelSizeQueryResponder(
|
||||
pane.terminal,
|
||||
(data) => transport.sendInputImmediate(data)
|
||||
sendDesktopQueryReplyImmediate
|
||||
)
|
||||
|
||||
const claimViewportForUserActivity = (): void => {
|
||||
@@ -3361,7 +3369,7 @@ export function connectPanePty(
|
||||
// isTerminalQueryReply (it requires length >= 3 and a full reply grammar),
|
||||
// so a real keystroke never reaches this branch.
|
||||
if (isTerminalQueryReply(data)) {
|
||||
transport.sendInputImmediate(data)
|
||||
sendDesktopQueryReplyImmediate(data)
|
||||
return
|
||||
}
|
||||
const intent = pendingTerminalInputIntent
|
||||
@@ -5120,7 +5128,7 @@ export function connectPanePty(
|
||||
// Why: hidden snapshot-backed panes skip xterm.write for PTY bytes. Answer
|
||||
// immediately so the reply cannot outlive the program's read window.
|
||||
deps.paneMode2031Ref.current.set(pane.id, true)
|
||||
transport.sendInputImmediate(mode2031SequenceFor(mode))
|
||||
sendDesktopQueryReplyImmediate(mode2031SequenceFor(mode))
|
||||
deps.paneLastThemeModeRef.current.set(pane.id, mode)
|
||||
recordHiddenMode2031Reply()
|
||||
}
|
||||
@@ -5291,8 +5299,10 @@ export function connectPanePty(
|
||||
// Why: Codex's startup palette probe has a 100 ms budget. Answer
|
||||
// hidden color queries directly and immediately so neither renderer
|
||||
// scheduling nor the remote input debounce (#7329) can miss it.
|
||||
sendTerminalOscColorQueryReplies(extracted.oscColorQueryData, pane.terminal, (reply) =>
|
||||
transport.sendInputImmediate(reply)
|
||||
sendTerminalOscColorQueryReplies(
|
||||
extracted.oscColorQueryData,
|
||||
pane.terminal,
|
||||
sendDesktopQueryReplyImmediate
|
||||
)
|
||||
}
|
||||
if (extracted.statelessQueryData) {
|
||||
@@ -5411,8 +5421,10 @@ export function connectPanePty(
|
||||
}
|
||||
const extracted = extractHiddenStartupRendererQueryData(data, '')
|
||||
if (extracted.oscColorQueryData) {
|
||||
sendTerminalOscColorQueryReplies(extracted.oscColorQueryData, pane.terminal, (reply) =>
|
||||
transport.sendInputImmediate(reply)
|
||||
sendTerminalOscColorQueryReplies(
|
||||
extracted.oscColorQueryData,
|
||||
pane.terminal,
|
||||
sendDesktopQueryReplyImmediate
|
||||
)
|
||||
}
|
||||
let unansweredQueryData = ''
|
||||
@@ -5426,9 +5438,9 @@ export function connectPanePty(
|
||||
const buffer = pane.terminal.buffer.active
|
||||
const row = Math.min(buffer.cursorY + 1, pane.terminal.rows)
|
||||
const col = Math.min(buffer.cursorX + 1, pane.terminal.cols)
|
||||
transport.sendInputImmediate(`\x1b[${row};${col}R`)
|
||||
sendDesktopQueryReplyImmediate(`\x1b[${row};${col}R`)
|
||||
} else if (sequence === '\x1b[c' || sequence === '\x1b[0c') {
|
||||
transport.sendInputImmediate(DEFAULT_DA1_RESPONSE)
|
||||
sendDesktopQueryReplyImmediate(DEFAULT_DA1_RESPONSE)
|
||||
} else {
|
||||
unansweredQueryData += sequence
|
||||
}
|
||||
@@ -6382,7 +6394,7 @@ export function connectPanePty(
|
||||
pane.terminal,
|
||||
// Why: OSC color reply — immediate so the remote debounce cannot delay
|
||||
// it past the querying program's read window (#7329).
|
||||
(reply) => transport.sendInputImmediate(reply)
|
||||
sendDesktopQueryReplyImmediate
|
||||
)
|
||||
}
|
||||
const restoreAppliesToCurrentPty =
|
||||
|
||||
@@ -22,6 +22,7 @@ import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
|
||||
import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup'
|
||||
import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../shared/tui-agent-selection'
|
||||
import { repoIsRemote } from '../../../shared/agent-launch-remote'
|
||||
import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell'
|
||||
import {
|
||||
resolveTuiAgentLaunchArgs,
|
||||
resolveTuiAgentLaunchEnv
|
||||
@@ -780,6 +781,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
// Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only
|
||||
// `orca-ide` rename must not be applied to remote launch commands.
|
||||
const selectedRepoIsRemote = selectedRepo ? repoIsRemote(selectedRepo) : false
|
||||
const selectedRepoStartupShell = resolveLocalWindowsAgentStartupShell({
|
||||
platform: selectedRepoAgentLaunchPlatform,
|
||||
isRemote: selectedRepoIsRemote,
|
||||
terminalWindowsShell: settings?.terminalWindowsShell
|
||||
})
|
||||
const selectedRepoProjectId =
|
||||
selectedWorkspaceTarget.status === 'ready' ? selectedWorkspaceTarget.target.projectId : null
|
||||
const selectedProjectId = selectedProjectGroup
|
||||
@@ -3344,6 +3350,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
? resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs)
|
||||
: undefined,
|
||||
agentEnv: agent ? resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv) : undefined,
|
||||
terminalWindowsShell: settings?.terminalWindowsShell,
|
||||
isRemote: folderTargetIsRemote,
|
||||
launchSource: telemetrySource === 'onboarding' ? 'onboarding' : 'new_workspace_composer',
|
||||
runtimeEnvironmentId: folderTargetRuntimeEnvironmentId,
|
||||
@@ -3399,6 +3406,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
settings?.agentDefaultArgs,
|
||||
settings?.agentDefaultEnv,
|
||||
settings?.autoRenameBranchFromWork,
|
||||
settings?.terminalWindowsShell,
|
||||
telemetrySource
|
||||
]
|
||||
)
|
||||
@@ -3598,6 +3606,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
agentArgs: resolveTuiAgentLaunchArgs(tuiAgent, settings?.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(tuiAgent, settings?.agentDefaultEnv),
|
||||
platform: selectedRepoAgentLaunchPlatform,
|
||||
shell: selectedRepoStartupShell,
|
||||
isRemote: selectedRepoIsRemote
|
||||
})
|
||||
const shouldSeedInitialAgentStatus =
|
||||
@@ -3769,6 +3778,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
selectedRepo,
|
||||
selectedRepoAgentLaunchPlatform,
|
||||
selectedRepoIsRemote,
|
||||
selectedRepoStartupShell,
|
||||
selectedRepoIsGit,
|
||||
selectedRepoRequiresConnection,
|
||||
showProjectRequiredError,
|
||||
@@ -4015,6 +4025,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv),
|
||||
platform: selectedRepoAgentLaunchPlatform,
|
||||
shell: selectedRepoStartupShell,
|
||||
isRemote: selectedRepoIsRemote
|
||||
})
|
||||
|
||||
@@ -4039,6 +4050,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv),
|
||||
platform: selectedRepoAgentLaunchPlatform,
|
||||
shell: selectedRepoStartupShell,
|
||||
isRemote: selectedRepoIsRemote,
|
||||
allowEmptyPromptLaunch: true
|
||||
})
|
||||
@@ -4214,6 +4226,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
selectedRepo,
|
||||
selectedRepoAgentLaunchPlatform,
|
||||
selectedRepoIsRemote,
|
||||
selectedRepoStartupShell,
|
||||
selectedRepoIsGit,
|
||||
selectedRepoSettings,
|
||||
selectedRepoRequiresConnection,
|
||||
|
||||
@@ -9849,6 +9849,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"fileExplorerOperationOwner": {
|
||||
"unresolved": "Couldn't determine which host owns this workspace. Check the connection and try again."
|
||||
},
|
||||
"useFileDeletion": {
|
||||
"72691dfebc": "Failed to {{value0}} '{{value1}}'.",
|
||||
"96affe1302": "'{{value0}}' moved to {{value1}}",
|
||||
@@ -9857,7 +9860,8 @@
|
||||
"a76c74f105": "destructive",
|
||||
"92276aceb7": "Delete",
|
||||
"7fb9435c86": "This permanently deletes the directory and its contents on the remote host. This cannot be undone.",
|
||||
"23e98f192f": "This permanently deletes the file on the remote host. This cannot be undone."
|
||||
"23e98f192f": "This permanently deletes the file on the remote host. This cannot be undone.",
|
||||
"8b8ee9d22f": "Couldn't determine which host owns this file. Check the workspace connection and try again."
|
||||
},
|
||||
"useFileExplorerHandlers": {
|
||||
"32cd9fd991": "Cannot open symlink target"
|
||||
@@ -11905,6 +11909,21 @@
|
||||
"ead6fc0510": "No automatic crash report was captured. You can still send details and include recent diagnostic logs when available.",
|
||||
"b082f27490": "Attach recent diagnostic logs",
|
||||
"e59f0b9427": "Sends a capped redacted log bundle with the report."
|
||||
},
|
||||
"submit": {
|
||||
"notice": {
|
||||
"unknownError": "The crash report request failed before it returned a reason.",
|
||||
"ticketUploaded": "Diagnostic ticket {{value0}} was uploaded but not linked.",
|
||||
"uncheckDiagnostics": "Uncheck \"Attach recent diagnostic logs\" and try again, or copy the details.",
|
||||
"checkConnection": "Check your connection and try again, or copy the details.",
|
||||
"notSent": "Crash report wasn't sent",
|
||||
"copyDetails": "Copy Details",
|
||||
"sentWithoutDiagnostics": "Crash report sent without diagnostic logs",
|
||||
"diagnosticsReason": "Diagnostic logs were not attached: {{value0}}"
|
||||
}
|
||||
},
|
||||
"copy": {
|
||||
"copyFailed": "Crash report details could not be copied."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -9849,6 +9849,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"fileExplorerOperationOwner": {
|
||||
"unresolved": "No se pudo determinar qué host posee este espacio de trabajo. Comprueba la conexión e inténtalo de nuevo."
|
||||
},
|
||||
"useFileDeletion": {
|
||||
"72691dfebc": "No se pudo {{value0}} '{{value1}}'.",
|
||||
"96affe1302": "'{{value0}}' se movió a {{value1}}",
|
||||
@@ -9857,7 +9860,8 @@
|
||||
"a76c74f105": "destructivo",
|
||||
"92276aceb7": "Eliminar",
|
||||
"7fb9435c86": "Esto elimina permanentemente el directorio y su contenido en el host remoto. Esto no se puede deshacer.",
|
||||
"23e98f192f": "Esto elimina permanentemente el archivo en el host remoto. Esto no se puede deshacer."
|
||||
"23e98f192f": "Esto elimina permanentemente el archivo en el host remoto. Esto no se puede deshacer.",
|
||||
"8b8ee9d22f": "No se pudo determinar qué host posee este archivo. Comprueba la conexión del espacio de trabajo e inténtalo de nuevo."
|
||||
},
|
||||
"useFileExplorerHandlers": {
|
||||
"32cd9fd991": "No se puede abrir el destino del enlace simbólico"
|
||||
@@ -11905,6 +11909,21 @@
|
||||
"ead6fc0510": "No se capturó ningún informe de fallo automático. Aun así puedes enviar detalles e incluir logs de diagnóstico recientes cuando estén disponibles.",
|
||||
"b082f27490": "Adjuntar logs de diagnóstico recientes",
|
||||
"e59f0b9427": "Envía un paquete limitado de logs redactados junto con el informe."
|
||||
},
|
||||
"submit": {
|
||||
"notice": {
|
||||
"unknownError": "The crash report request failed before it returned a reason.",
|
||||
"ticketUploaded": "Diagnostic ticket {{value0}} was uploaded but not linked.",
|
||||
"uncheckDiagnostics": "Uncheck \"Attach recent diagnostic logs\" and try again, or copy the details.",
|
||||
"checkConnection": "Check your connection and try again, or copy the details.",
|
||||
"notSent": "Crash report wasn't sent",
|
||||
"copyDetails": "Copy Details",
|
||||
"sentWithoutDiagnostics": "Crash report sent without diagnostic logs",
|
||||
"diagnosticsReason": "Diagnostic logs were not attached: {{value0}}"
|
||||
}
|
||||
},
|
||||
"copy": {
|
||||
"copyFailed": "No se pudieron copiar los detalles del informe de fallo."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -9849,6 +9849,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"fileExplorerOperationOwner": {
|
||||
"unresolved": "このワークスペースを所有しているホストを特定できませんでした。接続を確認して、もう一度お試しください。"
|
||||
},
|
||||
"useFileDeletion": {
|
||||
"72691dfebc": "{{value0}} '{{value1}}' に失敗しました。",
|
||||
"96affe1302": "「{{value0}}」は {{value1}} に移動されました",
|
||||
@@ -9857,7 +9860,8 @@
|
||||
"a76c74f105": "破壊的な",
|
||||
"92276aceb7": "削除",
|
||||
"7fb9435c86": "This permanently deletes the directory and its contents on the remote host. This cannot be undone.",
|
||||
"23e98f192f": "This permanently deletes the file on the remote host. This cannot be undone."
|
||||
"23e98f192f": "This permanently deletes the file on the remote host. This cannot be undone.",
|
||||
"8b8ee9d22f": "このファイルを所有しているホストを特定できませんでした。ワークスペースの接続を確認して、もう一度お試しください。"
|
||||
},
|
||||
"useFileExplorerHandlers": {
|
||||
"32cd9fd991": "シンボリックリンクターゲットを開けません"
|
||||
@@ -11905,6 +11909,21 @@
|
||||
"ead6fc0510": "No automatic crash report was captured. You can still send details and include recent diagnostic logs when available.",
|
||||
"b082f27490": "Attach recent diagnostic logs",
|
||||
"e59f0b9427": "Sends a capped redacted log bundle with the report."
|
||||
},
|
||||
"submit": {
|
||||
"notice": {
|
||||
"unknownError": "The crash report request failed before it returned a reason.",
|
||||
"ticketUploaded": "Diagnostic ticket {{value0}} was uploaded but not linked.",
|
||||
"uncheckDiagnostics": "Uncheck \"Attach recent diagnostic logs\" and try again, or copy the details.",
|
||||
"checkConnection": "Check your connection and try again, or copy the details.",
|
||||
"notSent": "Crash report wasn't sent",
|
||||
"copyDetails": "Copy Details",
|
||||
"sentWithoutDiagnostics": "Crash report sent without diagnostic logs",
|
||||
"diagnosticsReason": "Diagnostic logs were not attached: {{value0}}"
|
||||
}
|
||||
},
|
||||
"copy": {
|
||||
"copyFailed": "クラッシュレポートの詳細をコピーできませんでした。"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -9849,6 +9849,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"fileExplorerOperationOwner": {
|
||||
"unresolved": "이 워크스페이스를 소유한 호스트를 확인할 수 없습니다. 연결을 확인한 후 다시 시도하세요."
|
||||
},
|
||||
"useFileDeletion": {
|
||||
"72691dfebc": "{{value0}} '{{value1}}'에 실패했습니다.",
|
||||
"96affe1302": "'{{value0}}'이(가) {{value1}}(으)로 이동되었습니다.",
|
||||
@@ -9857,7 +9860,8 @@
|
||||
"a76c74f105": "파괴적",
|
||||
"92276aceb7": "삭제",
|
||||
"7fb9435c86": "원격 호스트의 디렉터리와 그 안의 내용을 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다.",
|
||||
"23e98f192f": "원격 호스트의 파일을 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다."
|
||||
"23e98f192f": "원격 호스트의 파일을 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다.",
|
||||
"8b8ee9d22f": "이 파일을 소유한 호스트를 확인할 수 없습니다. 워크스페이스 연결을 확인한 후 다시 시도하세요."
|
||||
},
|
||||
"useFileExplorerHandlers": {
|
||||
"32cd9fd991": "심볼릭 링크 대상을 열 수 없습니다"
|
||||
@@ -11905,6 +11909,21 @@
|
||||
"ead6fc0510": "자동 크래시 보고서가 캡처되지 않았습니다. 그래도 세부 정보를 보내고, 가능한 경우 최근 진단 로그를 포함할 수 있습니다.",
|
||||
"b082f27490": "최근 진단 로그 첨부",
|
||||
"e59f0b9427": "보고서와 함께 크기가 제한되고 민감 정보가 제거된 로그 묶음을 보냅니다."
|
||||
},
|
||||
"submit": {
|
||||
"notice": {
|
||||
"unknownError": "The crash report request failed before it returned a reason.",
|
||||
"ticketUploaded": "Diagnostic ticket {{value0}} was uploaded but not linked.",
|
||||
"uncheckDiagnostics": "Uncheck \"Attach recent diagnostic logs\" and try again, or copy the details.",
|
||||
"checkConnection": "Check your connection and try again, or copy the details.",
|
||||
"notSent": "Crash report wasn't sent",
|
||||
"copyDetails": "Copy Details",
|
||||
"sentWithoutDiagnostics": "Crash report sent without diagnostic logs",
|
||||
"diagnosticsReason": "Diagnostic logs were not attached: {{value0}}"
|
||||
}
|
||||
},
|
||||
"copy": {
|
||||
"copyFailed": "충돌 보고서 세부 정보를 복사하지 못했습니다."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -12734,7 +12753,7 @@
|
||||
"confirm": {
|
||||
"move": {
|
||||
"title": "프로젝트를 이동하시겠습니까?",
|
||||
"description": "{{repoName}}을(를) {{targetName}}(으)로 이동합니다. Orca가 {{repoName}}을(를) {{activeProfileName}}에서 제거한 뒤 파일은 유지하고 {{targetName}}(으)로 다시 시작합니다.",
|
||||
"description": "{{repoName}}을(를) {{targetName}}(으)로 이동합니다. Orca가 해당 프로젝트를 {{activeProfileName}}에서 제거한 뒤 파일은 유지하고 {{targetName}}(으)로 다시 시작합니다.",
|
||||
"action": "프로젝트 이동"
|
||||
},
|
||||
"copy": {
|
||||
|
||||
@@ -9849,6 +9849,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"fileExplorerOperationOwner": {
|
||||
"unresolved": "无法确定哪个主机拥有此工作区。请检查连接后重试。"
|
||||
},
|
||||
"useFileDeletion": {
|
||||
"72691dfebc": "无法 {{value0}}“{{value1}}”。",
|
||||
"96affe1302": "“{{value0}}”已移至 {{value1}}",
|
||||
@@ -9857,7 +9860,8 @@
|
||||
"a76c74f105": "destructive",
|
||||
"92276aceb7": "删除",
|
||||
"7fb9435c86": "这会永久删除远程主机上的目录及其内容。此操作无法撤销。",
|
||||
"23e98f192f": "这会永久删除远程主机上的文件。此操作无法撤销。"
|
||||
"23e98f192f": "这会永久删除远程主机上的文件。此操作无法撤销。",
|
||||
"8b8ee9d22f": "无法确定哪个主机拥有此文件。请检查工作区连接后重试。"
|
||||
},
|
||||
"useFileExplorerHandlers": {
|
||||
"32cd9fd991": "无法打开符号链接目标"
|
||||
@@ -11905,6 +11909,21 @@
|
||||
"ead6fc0510": "没有捕获到自动崩溃报告。你仍然可以发送详情,并在可用时包含最近的诊断日志。",
|
||||
"b082f27490": "附加最近的诊断日志",
|
||||
"e59f0b9427": "随报告发送经过删减且有大小限制的日志包。"
|
||||
},
|
||||
"submit": {
|
||||
"notice": {
|
||||
"unknownError": "The crash report request failed before it returned a reason.",
|
||||
"ticketUploaded": "Diagnostic ticket {{value0}} was uploaded but not linked.",
|
||||
"uncheckDiagnostics": "Uncheck \"Attach recent diagnostic logs\" and try again, or copy the details.",
|
||||
"checkConnection": "Check your connection and try again, or copy the details.",
|
||||
"notSent": "Crash report wasn't sent",
|
||||
"copyDetails": "Copy Details",
|
||||
"sentWithoutDiagnostics": "Crash report sent without diagnostic logs",
|
||||
"diagnosticsReason": "Diagnostic logs were not attached: {{value0}}"
|
||||
}
|
||||
},
|
||||
"copy": {
|
||||
"copyFailed": "无法复制崩溃报告详细信息。"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export function runBestEffortAgentBackgroundCleanups(...actions: (() => void)[]): void {
|
||||
for (const action of actions) {
|
||||
try {
|
||||
action()
|
||||
} catch {
|
||||
// Preserve the launch/setup error that triggered cleanup.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import type { TaskSourceContext, WorkspaceRunContext } from '../../../shared/tas
|
||||
import type { AgentStartedTelemetry } from '@/lib/worktree-activation'
|
||||
import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host'
|
||||
import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection'
|
||||
import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell'
|
||||
|
||||
export type GitHubWorkItemBackgroundStoreSnapshot = {
|
||||
repos: readonly Repo[]
|
||||
@@ -43,6 +44,7 @@ export type GitHubWorkItemBackgroundStoreSnapshot = {
|
||||
| 'agentCmdOverrides'
|
||||
| 'agentDefaultArgs'
|
||||
| 'agentDefaultEnv'
|
||||
| 'terminalWindowsShell'
|
||||
>
|
||||
>
|
||||
| null
|
||||
@@ -185,6 +187,11 @@ export function buildGitHubWorkItemStartupPlan(args: {
|
||||
// Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only
|
||||
// `orca-ide` rename must not be applied for remote launches.
|
||||
const isRemote = repoIsRemote(repo)
|
||||
const shell = resolveLocalWindowsAgentStartupShell({
|
||||
platform,
|
||||
isRemote,
|
||||
terminalWindowsShell: store.settings?.terminalWindowsShell
|
||||
})
|
||||
const draftLaunchPlan = draftPrompt
|
||||
? buildAgentDraftLaunchPlan({
|
||||
agent,
|
||||
@@ -193,6 +200,7 @@ export function buildGitHubWorkItemStartupPlan(args: {
|
||||
agentArgs: resolveTuiAgentLaunchArgs(agent, store.settings?.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(agent, store.settings?.agentDefaultEnv),
|
||||
platform,
|
||||
shell,
|
||||
isRemote
|
||||
})
|
||||
: null
|
||||
@@ -215,6 +223,7 @@ export function buildGitHubWorkItemStartupPlan(args: {
|
||||
agentArgs: resolveTuiAgentLaunchArgs(agent, store.settings?.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(agent, store.settings?.agentDefaultEnv),
|
||||
platform,
|
||||
shell,
|
||||
isRemote,
|
||||
allowEmptyPromptLaunch: true
|
||||
})
|
||||
|
||||
@@ -22,6 +22,7 @@ const mockSubscribeToPtyExit = vi.fn()
|
||||
const mockPasteDraftWhenAgentReady = vi.fn()
|
||||
const mockMarkTrusted = vi.fn()
|
||||
const mockDispatchEvent = vi.fn()
|
||||
const mockGetAgentLaunchPlatformForRepo = vi.fn<() => NodeJS.Platform>()
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
||||
|
||||
function expectStablePaneSpawn(): string {
|
||||
@@ -94,6 +95,10 @@ vi.mock('@/lib/agent-paste-draft', () => ({
|
||||
pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/agent-launch-platform', () => ({
|
||||
getAgentLaunchPlatformForRepo: mockGetAgentLaunchPlatformForRepo
|
||||
}))
|
||||
|
||||
vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({
|
||||
registerEagerPtyBuffer: mockRegisterEagerPtyBuffer,
|
||||
subscribeToPtyExit: mockSubscribeToPtyExit
|
||||
@@ -108,6 +113,7 @@ describe('launchAgentBackgroundSession', () => {
|
||||
resetRemoteRuntimeTerminalMultiplexersForTests()
|
||||
clearRuntimeCompatibilityCacheForTests()
|
||||
vi.clearAllMocks()
|
||||
mockGetAgentLaunchPlatformForRepo.mockReturnValue('linux')
|
||||
mockRuntimeEnvironmentTransportCall.mockImplementation(
|
||||
(args) =>
|
||||
createCompatibleRuntimeStatusResponseIfNeeded(args) ?? mockRuntimeEnvironmentCall(args)
|
||||
@@ -492,6 +498,62 @@ describe('launchAgentBackgroundSession', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('passes Hermes automation prompts through the native startup query', async () => {
|
||||
const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
|
||||
|
||||
await launchAgentBackgroundSession({
|
||||
agent: 'hermes',
|
||||
worktreeId: 'wt-1',
|
||||
prompt: 'run the automation'
|
||||
})
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: expect.stringContaining('ORCA_HERMES_STARTUP_QUERY'),
|
||||
env: expect.objectContaining({ ORCA_HERMES_STARTUP_QUERY: 'run the automation' })
|
||||
})
|
||||
)
|
||||
expect(mockPasteDraftWhenAgentReady).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the configured cmd shell for Windows Hermes background launches', async () => {
|
||||
mockGetAgentLaunchPlatformForRepo.mockReturnValue('win32')
|
||||
Object.assign(state.settings, { terminalWindowsShell: 'cmd.exe' })
|
||||
const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
|
||||
|
||||
await launchAgentBackgroundSession({
|
||||
agent: 'hermes',
|
||||
worktreeId: 'wt-1',
|
||||
prompt: 'run the automation'
|
||||
})
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: expect.stringContaining('powershell.exe -NoProfile -EncodedCommand'),
|
||||
env: expect.objectContaining({ ORCA_HERMES_STARTUP_QUERY: 'run the automation' })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards Hermes startup queries through SSH command transport', async () => {
|
||||
state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }]
|
||||
const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
|
||||
|
||||
await launchAgentBackgroundSession({
|
||||
agent: 'hermes',
|
||||
worktreeId: 'wt-1',
|
||||
prompt: 'remote automation prompt'
|
||||
})
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: expect.stringContaining('ORCA_HERMES_STARTUP_QUERY'),
|
||||
connectionId: 'ssh-1',
|
||||
env: expect.objectContaining({ ORCA_HERMES_STARTUP_QUERY: 'remote automation prompt' })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('injects fast startup commands into SSH background sessions after shell output arrives', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
||||
@@ -38,14 +38,8 @@ import type { RuntimeTerminalCreate } from '../../../shared/runtime-types'
|
||||
import { createSshBackgroundStartupDelivery } from '@/lib/ssh-background-startup-delivery'
|
||||
import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startup-delivery'
|
||||
import { isMainTerminalSideEffectAuthorityForPty } from '@/components/terminal-pane/terminal-side-effect-facts-handler'
|
||||
|
||||
function runBestEffortCleanup(action: () => void): void {
|
||||
try {
|
||||
action()
|
||||
} catch {
|
||||
// Preserve the launch/setup error that triggered cleanup.
|
||||
}
|
||||
}
|
||||
import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell'
|
||||
import { runBestEffortAgentBackgroundCleanups } from '@/lib/agent-background-session-cleanup'
|
||||
|
||||
export async function launchAgentBackgroundSession(
|
||||
args: LaunchAgentBackgroundSessionArgs
|
||||
@@ -80,6 +74,11 @@ export async function launchAgentBackgroundSession(
|
||||
// Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only
|
||||
// `orca-ide` rename must not be applied for remote launches.
|
||||
const isRemote = repo ? repoIsRemote(repo) : false
|
||||
const startupShell = resolveLocalWindowsAgentStartupShell({
|
||||
platform: launchPlatform,
|
||||
isRemote,
|
||||
terminalWindowsShell: store.settings?.terminalWindowsShell
|
||||
})
|
||||
const trimmedPrompt = prompt?.trim() ?? ''
|
||||
const hasPrompt = trimmedPrompt.length > 0
|
||||
const isFollowupPath = TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start'
|
||||
@@ -92,6 +91,7 @@ export async function launchAgentBackgroundSession(
|
||||
agentArgs,
|
||||
agentEnv,
|
||||
platform: launchPlatform,
|
||||
shell: startupShell,
|
||||
isRemote,
|
||||
allowEmptyPromptLaunch: !hasPrompt || isFollowupPath
|
||||
})
|
||||
@@ -311,12 +311,11 @@ export async function launchAgentBackgroundSession(
|
||||
// Why: terminal creation and stream subscription are separate remote calls.
|
||||
// A failure between them must not strand an invisible runtime terminal.
|
||||
exitHandled = true
|
||||
runBestEffortCleanup(unsubscribeExit)
|
||||
runBestEffortCleanup(unsubscribeData)
|
||||
runBestEffortCleanup(() => eagerPtyBuffer?.dispose())
|
||||
runBestEffortCleanup(() => sshStartupDelivery.clear())
|
||||
runBestEffortCleanup(() => store.clearTabPtyId(tab.id, ptyId))
|
||||
runBestEffortCleanup(() => store.clearAgentLaunchConfig(paneKey))
|
||||
runBestEffortAgentBackgroundCleanups(unsubscribeExit, unsubscribeData)
|
||||
runBestEffortAgentBackgroundCleanups(() => eagerPtyBuffer?.dispose())
|
||||
runBestEffortAgentBackgroundCleanups(() => sshStartupDelivery.clear())
|
||||
runBestEffortAgentBackgroundCleanups(() => store.clearTabPtyId(tab.id, ptyId))
|
||||
runBestEffortAgentBackgroundCleanups(() => store.clearAgentLaunchConfig(paneKey))
|
||||
if (ptyId) {
|
||||
try {
|
||||
if (runtimeTarget.kind === 'environment' && runtimeTerminalHandle) {
|
||||
@@ -330,7 +329,7 @@ export async function launchAgentBackgroundSession(
|
||||
// Best-effort close; retiring the invalid hidden tab must still proceed.
|
||||
}
|
||||
}
|
||||
runBestEffortCleanup(() => store.closeTab(tab.id, { recordInteraction: false }))
|
||||
runBestEffortAgentBackgroundCleanups(() => store.closeTab(tab.id, { recordInteraction: false }))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
|
||||
import { openMobileEmulatorTab } from './open-mobile-emulator-tab'
|
||||
import {
|
||||
consumePrelaunchedSimulatorSession,
|
||||
isManualSimulatorLaunchPending
|
||||
isManualSimulatorLaunchPending,
|
||||
resetSimulatorLaunchCoordinationForTests
|
||||
} from './simulator-launch-coordination'
|
||||
import { cancelPendingSimulatorPaneShutdown } from './simulator-pane-shutdown-scheduler'
|
||||
import { ensureSimulatorTab, getSimulatorTabForWorktree } from './ensure-simulator-tab'
|
||||
@@ -62,7 +63,7 @@ describe('openMobileEmulatorTab', () => {
|
||||
vi.mocked(getSimulatorTabForWorktree).mockReset()
|
||||
vi.mocked(getSimulatorTabForWorktree).mockReturnValue(null)
|
||||
vi.mocked(toast.error).mockReset()
|
||||
consumePrelaunchedSimulatorSession('wt-1')
|
||||
resetSimulatorLaunchCoordinationForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { EmulatorStreamInfo } from '@/components/emulator-pane/emulator-pane-types'
|
||||
import {
|
||||
consumePrelaunchedSimulatorSession,
|
||||
getPrelaunchedSimulatorSessionCountForTests,
|
||||
rememberPrelaunchedSimulatorSession,
|
||||
resetSimulatorLaunchCoordinationForTests
|
||||
} from './simulator-launch-coordination'
|
||||
|
||||
function streamInfo(id: string): EmulatorStreamInfo {
|
||||
return {
|
||||
deviceUdid: id,
|
||||
streamUrl: `http://127.0.0.1:3100/${id}/stream.mjpeg`,
|
||||
wsUrl: `ws://127.0.0.1:3100/${id}/ws`
|
||||
}
|
||||
}
|
||||
|
||||
describe('simulator-launch-coordination', () => {
|
||||
beforeEach(() => {
|
||||
resetSimulatorLaunchCoordinationForTests()
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetSimulatorLaunchCoordinationForTests()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('expires unconsumed sessions at the TTL boundary', () => {
|
||||
rememberPrelaunchedSimulatorSession('wt-fresh', streamInfo('device-fresh'))
|
||||
rememberPrelaunchedSimulatorSession('wt-stale', streamInfo('device-stale'))
|
||||
|
||||
vi.advanceTimersByTime(29_999)
|
||||
|
||||
expect(consumePrelaunchedSimulatorSession('wt-fresh')).toMatchObject({
|
||||
deviceUdid: 'device-fresh'
|
||||
})
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
|
||||
expect(consumePrelaunchedSimulatorSession('wt-stale')).toBeNull()
|
||||
expect(getPrelaunchedSimulatorSessionCountForTests()).toBe(0)
|
||||
})
|
||||
|
||||
it('measures handoff age independently of wall-clock corrections', () => {
|
||||
vi.setSystemTime(100_000)
|
||||
rememberPrelaunchedSimulatorSession('wt-rollback', streamInfo('device-rollback'))
|
||||
|
||||
vi.setSystemTime(0)
|
||||
vi.advanceTimersByTime(30_000)
|
||||
|
||||
expect(consumePrelaunchedSimulatorSession('wt-rollback')).toBeNull()
|
||||
|
||||
rememberPrelaunchedSimulatorSession('wt-forward', streamInfo('device-forward'))
|
||||
vi.setSystemTime(1_000_000)
|
||||
|
||||
expect(consumePrelaunchedSimulatorSession('wt-forward')).toMatchObject({
|
||||
deviceUdid: 'device-forward'
|
||||
})
|
||||
})
|
||||
|
||||
it('caps prelaunched simulator sessions by oldest worktree key', () => {
|
||||
for (let index = 0; index < 16; index += 1) {
|
||||
rememberPrelaunchedSimulatorSession(`wt-${index}`, streamInfo(`device-${index}`))
|
||||
}
|
||||
|
||||
rememberPrelaunchedSimulatorSession('wt-16', streamInfo('device-16'))
|
||||
|
||||
expect(consumePrelaunchedSimulatorSession('wt-0')).toBeNull()
|
||||
expect(consumePrelaunchedSimulatorSession('wt-1')).toMatchObject({
|
||||
deviceUdid: 'device-1'
|
||||
})
|
||||
expect(consumePrelaunchedSimulatorSession('wt-16')).toMatchObject({
|
||||
deviceUdid: 'device-16'
|
||||
})
|
||||
})
|
||||
|
||||
it('treats a refreshed worktree key as the most recently remembered', () => {
|
||||
for (let index = 0; index < 16; index += 1) {
|
||||
rememberPrelaunchedSimulatorSession(`wt-${index}`, streamInfo(`device-${index}`))
|
||||
}
|
||||
|
||||
rememberPrelaunchedSimulatorSession('wt-0', streamInfo('device-refreshed'))
|
||||
rememberPrelaunchedSimulatorSession('wt-16', streamInfo('device-16'))
|
||||
|
||||
expect(consumePrelaunchedSimulatorSession('wt-1')).toBeNull()
|
||||
expect(consumePrelaunchedSimulatorSession('wt-0')).toMatchObject({
|
||||
deviceUdid: 'device-refreshed'
|
||||
})
|
||||
expect(consumePrelaunchedSimulatorSession('wt-16')).toMatchObject({
|
||||
deviceUdid: 'device-16'
|
||||
})
|
||||
})
|
||||
|
||||
it('stays bounded through prolonged unconsumed-session churn', () => {
|
||||
for (let index = 0; index < 5_000; index += 1) {
|
||||
rememberPrelaunchedSimulatorSession(`wt-${index}`, streamInfo(`device-${index}`))
|
||||
expect(getPrelaunchedSimulatorSessionCountForTests()).toBeLessThanOrEqual(16)
|
||||
}
|
||||
|
||||
expect(consumePrelaunchedSimulatorSession('wt-4983')).toBeNull()
|
||||
expect(consumePrelaunchedSimulatorSession('wt-4984')).toMatchObject({
|
||||
deviceUdid: 'device-4984'
|
||||
})
|
||||
expect(consumePrelaunchedSimulatorSession('wt-4999')).toMatchObject({
|
||||
deviceUdid: 'device-4999'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,31 @@ export const EMULATOR_MANUAL_LAUNCH_STARTED_EVENT = 'orca:emulator-launch-starte
|
||||
export const EMULATOR_MANUAL_LAUNCH_FAILED_EVENT = 'orca:emulator-launch-failed'
|
||||
|
||||
const manualLaunchesByWorktree = new Set<string>()
|
||||
const prelaunchedSessionsByWorktree = new Map<string, EmulatorStreamInfo>()
|
||||
type PrelaunchedSimulatorSession = {
|
||||
info: EmulatorStreamInfo
|
||||
rememberedAt: number
|
||||
}
|
||||
|
||||
// Why: prelaunch info is a short-lived handoff to the pane; if the pane never
|
||||
// consumes it, stale stream URLs should not stick to removed worktree ids.
|
||||
const PRELAUNCHED_SIMULATOR_SESSION_TTL_MS = 30_000
|
||||
const PRELAUNCHED_SIMULATOR_SESSION_MAX = 16
|
||||
const prelaunchedSessionsByWorktree = new Map<string, PrelaunchedSimulatorSession>()
|
||||
|
||||
function prunePrelaunchedSimulatorSessions(now = performance.now()): void {
|
||||
for (const [worktreeId, entry] of prelaunchedSessionsByWorktree) {
|
||||
if (now - entry.rememberedAt >= PRELAUNCHED_SIMULATOR_SESSION_TTL_MS) {
|
||||
prelaunchedSessionsByWorktree.delete(worktreeId)
|
||||
}
|
||||
}
|
||||
while (prelaunchedSessionsByWorktree.size > PRELAUNCHED_SIMULATOR_SESSION_MAX) {
|
||||
const oldest = prelaunchedSessionsByWorktree.keys().next().value
|
||||
if (oldest === undefined) {
|
||||
return
|
||||
}
|
||||
prelaunchedSessionsByWorktree.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
export function beginManualSimulatorLaunch(worktreeId: string): void {
|
||||
manualLaunchesByWorktree.add(worktreeId)
|
||||
@@ -25,15 +49,33 @@ export function rememberPrelaunchedSimulatorSession(
|
||||
if (!info?.streamUrl && !info?.wsUrl) {
|
||||
return
|
||||
}
|
||||
prelaunchedSessionsByWorktree.set(worktreeId, info)
|
||||
// Why: the TTL measures elapsed handoff age, so wall-clock corrections must
|
||||
// not resurrect stale stream metadata or discard a fresh handoff.
|
||||
const rememberedAt = performance.now()
|
||||
prelaunchedSessionsByWorktree.delete(worktreeId)
|
||||
prelaunchedSessionsByWorktree.set(worktreeId, {
|
||||
info,
|
||||
rememberedAt
|
||||
})
|
||||
prunePrelaunchedSimulatorSessions(rememberedAt)
|
||||
}
|
||||
|
||||
export function consumePrelaunchedSimulatorSession(worktreeId: string): EmulatorStreamInfo | null {
|
||||
const info = prelaunchedSessionsByWorktree.get(worktreeId) ?? null
|
||||
prunePrelaunchedSimulatorSessions()
|
||||
const info = prelaunchedSessionsByWorktree.get(worktreeId)?.info ?? null
|
||||
prelaunchedSessionsByWorktree.delete(worktreeId)
|
||||
return info
|
||||
}
|
||||
|
||||
export function resetSimulatorLaunchCoordinationForTests(): void {
|
||||
manualLaunchesByWorktree.clear()
|
||||
prelaunchedSessionsByWorktree.clear()
|
||||
}
|
||||
|
||||
export function getPrelaunchedSimulatorSessionCountForTests(): number {
|
||||
return prelaunchedSessionsByWorktree.size
|
||||
}
|
||||
|
||||
export function dispatchManualSimulatorLaunchStarted(worktreeId: string): void {
|
||||
dispatchManualSimulatorLaunchEvent(EMULATOR_MANUAL_LAUNCH_STARTED_EVENT, { worktreeId })
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user