mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
Merge origin/main into manual-worktree-delete
# Conflicts: # src/main/runtime/orca-runtime.ts
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-07-08",
|
||||
"updatedAt": "2026-07-09",
|
||||
"policy": {
|
||||
"maturityLevels": [
|
||||
"experimental",
|
||||
@@ -105,6 +105,134 @@
|
||||
],
|
||||
"demotionRule": "Demote or quarantine if the gate flakes once without a product bug or harness bug filed to the owner."
|
||||
},
|
||||
{
|
||||
"id": "terminal-session.kill-all-surface-cleanup",
|
||||
"title": "Kill all sessions removes only the confirmed terminal surfaces and current bindings",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-runtime",
|
||||
"layer": "renderer-main-contract",
|
||||
"surfaces": [
|
||||
"terminal lifecycle",
|
||||
"terminal tab cleanup",
|
||||
"PTY shutdown",
|
||||
"Manage Sessions",
|
||||
"Resource Manager"
|
||||
],
|
||||
"platforms": [
|
||||
"macos",
|
||||
"linux",
|
||||
"windows"
|
||||
],
|
||||
"providers": [
|
||||
"local",
|
||||
"daemon",
|
||||
"ssh",
|
||||
"wsl",
|
||||
"remote-runtime",
|
||||
"mobile-relay"
|
||||
],
|
||||
"coveredPlatforms": [
|
||||
"macos"
|
||||
],
|
||||
"coveredProviders": [
|
||||
"local",
|
||||
"daemon",
|
||||
"ssh"
|
||||
],
|
||||
"coverageNotes": "Local macOS deterministic evidence covers the renderer snapshot/coordinator, exact local and SSH-shaped PTY request settlement, active-last and pinned terminal-tab routing, component-unmount continuation, and the existing current/legacy daemon management contract. Windows Electron process absence, live SSH/WSL behavior, and remote-runtime/mobile host completion remain explicit gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/8001"
|
||||
],
|
||||
"invariant": "Every terminal surface confirmed in the invoking renderer is force-closed exactly once after daemon management settles, later-created surfaces and non-terminal tabs survive, and exact shutdown requests are limited to deduplicated current non-runtime PTY bindings of the confirmed surfaces.",
|
||||
"oracle": "Snapshot terminal entity IDs before the first await; mutate ownership, active selection, bindings, and tab presence while daemon management is pending; then assert only the immutable targets disappear from both terminal stores, active targets close last with valid editor/browser/deactivated post-state, every captured exact PTY promise settles before callbacks, and no provider inventory sweep or late-tab kill occurs.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts src/renderer/src/components/shared/useDaemonActions.test.tsx src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts src/main/ipc/pty-management.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts",
|
||||
"src/renderer/src/components/shared/useDaemonActions.test.tsx",
|
||||
"src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts",
|
||||
"src/main/ipc/pty-management.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts",
|
||||
"assertions": [
|
||||
"snapshot deduplicates legacy, unified-only, split, multi-worktree, and floating terminal surfaces while excluding editor tabs",
|
||||
"cleanup-time moves, active-worktree switches, rebinding, missing targets, and later-created tabs preserve the confirmation boundary and active-last order",
|
||||
"current exact PTY bindings are deduplicated, remote runtime IDs and stale/late bindings are excluded, and all per-PTY settlements finish before completion",
|
||||
"the production dependency path calls daemon management exactly once and never invokes listSessions for a post-kill sweep",
|
||||
"management rejection and per-close/provider failures do not stop remaining cleanup and produce bounded count/latency diagnostics",
|
||||
"a real 100-tab Zustand fixture records 100 close attempts and exact kills, at least 100 writes, 49 event-loop yields, and no close batch over 50 ms"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/shared/useDaemonActions.test.tsx",
|
||||
"assertions": [
|
||||
"the hook snapshots before onKillAllStart and before coordinator work",
|
||||
"unmounting the invoking component does not revoke cleanup while React callbacks remain mount-gated",
|
||||
"error and settled callbacks run only after coordinator settlement",
|
||||
"closed terminal tabs report success instead of the no-sessions informational state when daemon management reports zero"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts",
|
||||
"assertions": [
|
||||
"force closes pinned terminals without a second confirmation",
|
||||
"closing the last active terminal preserves and activates editor or browser content, otherwise deactivates without auto-spawn"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/pty-management.test.ts",
|
||||
"assertions": [
|
||||
"killAll fires one shutdown for each initial daemon session and polls those initial IDs until empty",
|
||||
"freshly respawned session IDs are excluded from remainingCount",
|
||||
"per-session shutdown rejection does not stop the daemon batch and refused initial sessions remain reported"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-09",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts src/renderer/src/components/shared/useDaemonActions.test.tsx src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts src/main/ipc/pty-management.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 1.31,
|
||||
"summary": "4 test files and 27 tests passed locally, including the existing daemon handler contract and a real 100-terminal Zustand cleanup fixture."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 10,
|
||||
"scope": "focused renderer/main unit and performance-count tests"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "The focused 27-test slice passed locally once; it needs CI and soak history before promotion."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "The initial 100-tab implementation exposed a 145.82 ms close batch and failed the 50 ms budget; two-close event-loop batching reduced the focused run below the budget. Saved CI artifacts and intentional-break evidence for the confirmation-boundary assertions are still needed."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "The coordinator performs one existing management call, zero provider inventory sweeps, one close per present unique target, and at most one exact kill per unique current non-runtime PTY binding. A real 100-tab Zustand fixture asserts 100 close attempts, 100 exact kill calls, at least 100 store writes, 49 event-loop yields, and a maximum two-close batch at or below 50 ms."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Run the focused gate for at least 100 consecutive passes or 14 days across required CI platforms.",
|
||||
"Attach Windows Electron evidence for both entry points, empty and established terminals, later-tab survival, xterm removal, and initial PTY absence after settlement.",
|
||||
"Exercise the SSH fixture and WSL when available, or keep their live process-absence gaps explicit.",
|
||||
"Propagate and align runtime-host tab-close completion before claiming verified remote-runtime shutdown."
|
||||
],
|
||||
"knownGaps": [
|
||||
"Windows Electron screenshots and live process/xterm absence evidence were not produced by this local macOS run.",
|
||||
"Live SSH and WSL process absence, Linux local/daemon behavior, and mobile/relay shutdown remain unproved.",
|
||||
"Runtime-host terminal close is best-effort because closeTerminalTab still discards the existing async host result and its close-intent lifetime is shorter than the possible RPC flow.",
|
||||
"Daemon adapter listing failures remain suppressed by the existing management API, so reported daemon counts are not authoritative verification of every process."
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote to protection none if the gate flakes, permits a late-created tab or unrelated PTY to close, or exceeds the 50 ms maximum close-batch budget without a tracked product or harness bug."
|
||||
},
|
||||
{
|
||||
"id": "terminal-session.startup-cwd-missing-dir-recovery",
|
||||
"title": "Fresh local terminal creation cannot be bricked by a deleted startup cwd",
|
||||
|
||||
+1
-1
@@ -225,7 +225,7 @@ Three scrollbar classes are defined globally in `main.css`:
|
||||
|
||||
- **`.scrollbar-sleek`** — the default thin, neutral scrollbar for sidebars, lists, popovers. Pair with `.scrollbar-sleek-parent` on a hover-target ancestor if you want the thumb to fade in only on parent hover.
|
||||
- **`.scrollbar-editor`** — slightly heavier, used inside Monaco-adjacent surfaces.
|
||||
- **`.worktree-sidebar-scrollbar`** — reserves the gutter but keeps the thumb invisible until the parent (`.scrollbar-sleek-parent`) is hovered. Used only in the worktree sidebar so the chrome stays still.
|
||||
- **`.worktree-sidebar-scrollbar`** — no reserved gutter: paired with `overflow-y-auto`, the scrollbar (and its width) exists only while content actually overflows, so a short list stays flush with the fixed header controls and classic-scrollbar Windows shows no arrow buttons on empty lists. The thumb stays invisible until the parent (`.scrollbar-sleek-parent`) is hovered. Used only in the worktree sidebar.
|
||||
|
||||
Apply one of these to overflow containers; don't write a fourth style.
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
# Kill all sessions also closes terminal tabs
|
||||
|
||||
GitHub: https://github.com/stablyai/orca/issues/8001
|
||||
Branch: `bug-kill-all-sessions-doesnt-kill-terminals`
|
||||
|
||||
## Problem
|
||||
|
||||
Settings → Manage Sessions and Resource Manager expose **Kill all sessions**. The action currently calls only `window.api.pty.management.killAll()`; it does not remove renderer terminal tabs or dispose xterm instances.
|
||||
|
||||
The main handler is narrower than the UI wording:
|
||||
|
||||
- `pty:management:killAll` snapshots sessions from the current and legacy **daemon** adapters, shuts them down in parallel, then polls only those initial IDs.
|
||||
- It does not inventory SSH or runtime-hosted terminals. A degraded local provider is also outside the management adapter set.
|
||||
- The nominal poll sleep budget is 6.5 seconds (65 × 100 ms), but adapter round-trip time is additional. Daemon requests time out after 30 seconds and connection setup has separate 5-second steps, so total latency can be much longer.
|
||||
- Failed adapter listings are dropped by `collectSessions`, so counts are not authoritative when an adapter is unreachable. This applies during polling too: an adapter that listed an initial session and then becomes unreachable can make that session disappear from `remainingCount` and be counted as killed without a confirming listing.
|
||||
|
||||
Renderer exit handling then preserves two important startup-failure states:
|
||||
|
||||
- a sole, freshly spawned pane that exits before any user input; and
|
||||
- a freshly split pane that exits before input or output.
|
||||
|
||||
Those guards are correct for direnv/shell setup failures, but they cannot distinguish an explicit bulk kill. A sole fresh-spawned pane remains if the user never typed, even if it produced output or lived for a while; typed/reattached panes normally close through the existing exit path.
|
||||
|
||||
## Goal and invariant
|
||||
|
||||
After confirmation, every terminal tab that existed in the invoking desktop renderer at that moment is removed, including dead/no-PTY tabs, and its renderer resources are disposed. Terminal tabs created later are not targeted. Initial daemon sessions still receive the existing management shutdown request, while current non-runtime PTY bindings owned by the targeted tabs receive exact per-ID shutdown requests.
|
||||
|
||||
The target is the confirmed terminal **surface ID**, not a liveness inference. If the same targeted tab rebinds before cleanup completes, it is still closed; a newly created tab ID survives.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Changing **Restart daemon**, which intentionally leaves panes available to reopen.
|
||||
- Deleting or sleeping worktrees, closing browser/editor tabs, or killing the daemon process.
|
||||
- Changing the sole-pane/fresh-split startup-failure guards.
|
||||
- Promising an immediate OS working-set drop; GC and allocator behavior are nondeterministic. The deterministic contract is that tabs/xterms are released and observable daemon/exact-binding shutdown-request failures are surfaced. Runtime-host result propagation remains an accepted gap below.
|
||||
- Turning this issue into a new cross-provider global kill API. Exact `pty.kill(id)` calls for current bindings of the confirmed tabs are in scope; inventorying and killing unrelated provider sessions is not.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Snapshot terminal surfaces once
|
||||
|
||||
At confirmation, before `onKillAllStart` and before the first `await`, collect and deduplicate terminal entity IDs from both `tabsByWorktree` and terminal entries in `unifiedTabsByWorktree`. Include the floating-terminal workspace.
|
||||
|
||||
Keep this immutable target set through the async daemon call:
|
||||
|
||||
- a target already closed elsewhere becomes a no-op;
|
||||
- a target moved between groups/worktrees is resolved from current state and still closes;
|
||||
- a terminal created after the snapshot is never closed.
|
||||
|
||||
Do not snapshot all terminal IDs again after the daemon call, and do not retain the confirmation-time worktree as cleanup authority. The target ID is immutable; ownership and active-last ordering are resolved again from current state immediately before cleanup.
|
||||
|
||||
### 2. Keep daemon shutdown semantics, then close the snapshot
|
||||
|
||||
Call the existing `window.api.pty.management.killAll()` once. After it settles, resolve the still-present targets and deduplicate their current `ptyIdsByTabId` entries. These are the renderer's current binding ownership records, not independent liveness proof; do not pull IDs from `tab.ptyId`, deferred SSH restore state, or `terminalLayoutsByTabId.ptyIdsByLeafId`, which can be restore hints rather than current ownership.
|
||||
|
||||
Force-close every snapshotted surface through `closeTerminalTab(id, { force: true })`, then await one existing `window.api.pty.kill(id)` call for each captured non-`remote:` PTY ID. Run both phases for management success, partial success, and IPC rejection because the confirmation explicitly covers closing the terminal surfaces. Use per-target/per-PTY settlement so one unexpected store or provider failure does not stop the remaining cleanup. Already-gone PTYs are successful no-ops in the main handler; other exact-kill rejections are reported separately.
|
||||
|
||||
Close targets outside the cleanup-time `activeWorktreeId` first and targets currently owned by it last. A tab moved into the active worktree or a workspace selected while the daemon call was pending must therefore move to the last partition. Let `closeTerminalTab` choose the existing post-state for the last local terminal: editor, then browser, then `setActiveWorktree(null)`. Do not pre-clear `activeWorktreeId`; the sleep flow needs that ordering because it leaves tab records mounted while clearing PTYs, whereas this flow removes the tabs. Add active-switch and active-last regression tests and only adopt sleep-intent/deactivate-first if those tests prove a respawn.
|
||||
|
||||
This is not one backend call end-to-end. The coordinator issues one daemon-management IPC, one renderer close per unique target, and at most one exact kill IPC per unique current non-runtime PTY binding. The explicit exact kills make local/SSH shutdown requests settle before caller refresh instead of relying on React unmount timing. A mounted transport may race that request and issue an idempotent duplicate during unmount; count that separately in live performance evidence. A still-bound daemon session can receive the exact retry after the management poll, so daemon toast counts remain the management handler's earlier reported snapshot, not final process truth. Runtime-hosted tabs additionally issue one `session.tabs.close` flow per tab.
|
||||
|
||||
The coordinator must live outside the hook (for example, `kill-all-terminal-surfaces.ts`) and must not gate cleanup on `mountedRef`. Navigating away or unmounting the invoking popover/settings component while IPC is pending does not revoke an already confirmed destructive action; only React callbacks and hook state updates remain mount-gated.
|
||||
|
||||
### 3. Do not add a post-kill orphan sweep
|
||||
|
||||
Do not call `pty.listSessions()` and kill everything returned after the management call. Exact kills captured from `ptyIdsByTabId` for the immutable target surfaces are allowed and required; they do not discover or cross into unrelated sessions.
|
||||
|
||||
The management handler already targets daemon orphans in its initial snapshot. A later broad sweep would cross the confirmation boundary by killing local/SSH sessions or fresh sessions created while the existing poll was running. It would also add provider inventory fan-out to a recovery action and conflict with the main handler's initial-ID accounting.
|
||||
|
||||
The existing explicit **Kill orphan terminals** action remains separate.
|
||||
|
||||
### 4. Make completion and failure copy honest
|
||||
|
||||
Have the renderer cleanup return a bounded summary: target count, targets absent at completion, failed close attempts, exact PTY kill requests accepted/rejected, and daemon result/error. Verify absence in both terminal and unified state after each close attempt. Toasts must report renderer cleanup separately from daemon counts; do not show **No sessions running** when terminal tabs were closed.
|
||||
|
||||
Because the management API suppresses adapter-list failures, copy must describe daemon counts as reported, not claim that every daemon process was verified dead. Returning adapter errors is a follow-up unless this PR chooses to widen the API. Exact `pty.kill` rejection counts may be stated as failed shutdown requests, not as proof that the processes remain alive.
|
||||
|
||||
Examples of states, not required wording:
|
||||
|
||||
- all daemon targets were reported exited, exact shutdown requests were accepted, and tabs closed: success;
|
||||
- `remainingCount > 0`: warning that management reported daemon processes not exited before exact binding cleanup;
|
||||
- management IPC rejected: error that tabs closed, with daemon shutdown unverified and any exact PTY kill failures called out;
|
||||
- no daemon sessions and no terminal tabs: existing informational state.
|
||||
|
||||
Run `onKillAllSettled` only after surface removal and exact PTY kill settlement. `closeTerminalTab` returns before React unmount effects run, so surface removal alone is not a sufficient boundary for Resource Manager's `pty.listSessions()` refresh. Keep one confirmation dialog and update its description to say that terminal tabs across workspaces close and unsaved terminal work is lost.
|
||||
|
||||
### 5. Provider limits
|
||||
|
||||
`closeTerminalTab` synchronously prunes host-backed local mirrors but currently returns `void` and discards the promise from `closeWebRuntimeSessionTab`. The underlying helper is already awaitable: its close request has a 15-second timeout and, after success, it awaits an eager list refresh with another 15-second timeout. Its boolean can therefore settle after roughly 30 seconds, while close-intent suppression expires after 10 seconds. This change must not claim verified runtime-host completion unless the bulk helper propagates that existing result and aligns the intent lifetime.
|
||||
|
||||
For this Windows/local bug, runtime-host cleanup is best-effort and an explicit accepted gap. A follow-up may propagate the existing async close result through terminal-tab actions and align the RPC timeout/intent lifetime. SSH must be exercised if the fixture is available; otherwise record live SSH process absence as an accepted gap. No provider-specific local filesystem or process assumptions are added.
|
||||
|
||||
## Data flow
|
||||
|
||||
```text
|
||||
confirm
|
||||
→ snapshot unique terminal surface IDs
|
||||
→ pty.management.killAll() daemon current + legacy; existing polling
|
||||
→ resolve current target ownership + bound PTY IDs
|
||||
→ close snapshotted tabs, active last renderer/xterm cleanup; runtime-host close starts
|
||||
→ await exact non-runtime pty.kill IDs local/SSH acknowledgement; no provider sweep
|
||||
→ toast daemon result + surface/exact-kill summary
|
||||
→ caller refresh
|
||||
```
|
||||
|
||||
## Concurrency and consistency
|
||||
|
||||
| Case | Required behavior |
|
||||
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| No daemon sessions; dead/empty tabs exist | Close target tabs and report tab cleanup, not “No sessions.” |
|
||||
| Daemon session refuses to exit | Close the confirmed tab; report that management saw it remaining before the exact retry. Refresh may expose it as an orphan. |
|
||||
| Adapter listing fails | Close target tabs; do not claim all processes were verified dead. Existing management counts may read as zero. |
|
||||
| Management IPC rejects | Still close confirmed tabs and issue exact current-binding kills; report daemon uncertainty and exact-kill errors. |
|
||||
| New tab/session during the wait | New tab ID survives. No post-snapshot provider sweep. |
|
||||
| Target closes or moves during the wait | Missing target no-ops; moved target closes by current ownership. |
|
||||
| Target tab rebinds to a new PTY | Close it because the confirmed surface, not the old PTY snapshot, is the authority. |
|
||||
| Pinned or split terminal | `force: true` bypasses a second prompt; closing the tab closes all of its panes. |
|
||||
| Active last terminal | Existing close routing selects editor/browser or deactivates; no replacement terminal spawns. |
|
||||
| Two overlapping invocations | Cleanup is idempotent by tab ID. Each main call retains its own initial daemon snapshot/counts. |
|
||||
| Binding changes after exact-ID capture | Surface close still wins; transport unmount kills the replacement, but that late kill is not in the awaited count. |
|
||||
| Runtime-host snapshot races close | Existing close intent suppresses stale snapshots temporarily; host completion remains an accepted gap. |
|
||||
| External/mobile session creation | Sessions created after the main/renderer snapshots are not chased. Existing PTY-exit publication updates mirrors. |
|
||||
|
||||
Orca's production UI currently tracks a single desktop `mainWindow`/renderer. The main daemon action is process-global while renderer cleanup is renderer-local. If multiple desktop windows become supported, this action must be broadcast from main with an action ID and per-window acknowledgement; “shared store probably mirrors it” is not sufficient.
|
||||
|
||||
## Tests and reliability gate
|
||||
|
||||
Add an experimental, `protection: "partial"` `terminal-session.kill-all-surface-cleanup` entry to `config/reliability-gates.jsonc`, owned by `terminal-runtime` at the renderer/main contract layer. The entry must name the focused coordinator and `pty-management` test files/commands and include non-empty assertion refs; otherwise the manifest checker rejects a partial gate.
|
||||
|
||||
- **Invariant:** confirmed terminal surfaces close exactly once; later-created surfaces survive; no broad post-snapshot kill occurs.
|
||||
- **Failure source:** issue #8001 plus the sole-fresh/fresh-split exit guards that intentionally preserve dead startup-failure panes.
|
||||
- **Deterministic oracle:** the initial tab IDs disappear from both terminal/unified state, non-terminal and later-created tabs remain, active state is valid, exact kills are issued only for deduplicated current target bindings, all exact-kill promises settle before the caller callback, and no `pty.listSessions` sweep occurs. Provider inventory absence is live validation, not the renderer-unit oracle.
|
||||
- **Diagnostics:** emit one content-free bounded summary with target/absent/failed-close counts, exact-kill accepted/rejected counts, and the daemon result or error. Runtime-host completion remains unknown until terminal-tab actions propagate the existing async close result.
|
||||
|
||||
Required deterministic coverage:
|
||||
|
||||
- snapshot/dedup across multiple worktrees, unified-only terminals, splits, and floating terminals;
|
||||
- pinned close with `force`, while browser/editor tabs remain;
|
||||
- terminal added after the snapshot survives; missing/moved target behavior;
|
||||
- active-last post-state for editor, browser, and no-other-content cases, including no auto-spawn;
|
||||
- coordinator ordering for success, partial result, zero sessions, and rejected management IPC;
|
||||
- exact binding capture excludes restore hints and `remote:` IDs; late-created tab bindings are not captured;
|
||||
- exactly one management call, zero `pty.listSessions` calls, one close attempt per unique target, and one coordinator-issued exact kill per unique captured non-runtime PTY ID;
|
||||
- invoking component unmount during the management wait does not cancel store/provider cleanup or invoke mount-gated callbacks;
|
||||
- existing `src/main/ipc/pty-management.test.ts` cases remain green.
|
||||
|
||||
Electron validation on Windows must reproduce an empty shell plus an established terminal, invoke each entry point, and prove:
|
||||
|
||||
- target terminal tabs and `.xterm` surfaces are gone;
|
||||
- initial local/daemon PTY IDs are absent after settle, except daemon IDs reported remaining and exact IDs whose shutdown request failed;
|
||||
- a terminal created after confirmation survives;
|
||||
- Resource Manager and Manage Sessions refresh to the same result.
|
||||
|
||||
Planned provider/platform accounting:
|
||||
|
||||
| Row | Planned status |
|
||||
| --- | --- |
|
||||
| Local PTY / daemon / Windows | Covered by deterministic coordinator/main tests plus the required live Electron run. |
|
||||
| macOS / Linux local and daemon | Shared code is covered deterministically; live process absence remains an accepted gap until run there. |
|
||||
| SSH | Exact request/settlement is covered by provider-contract mocks; live remote-process absence is covered only when the SSH fixture runs, otherwise accepted-gap. |
|
||||
| WSL | Uses the local exact-ID path, but real WSL process absence is an accepted gap unless included in the Windows run. |
|
||||
| Remote runtime | Local mirror removal is covered; host completion is accepted-gap while `closeTerminalTab` discards the async result. |
|
||||
| Mobile/relay | No newly created session is chased; shared host-tab disappearance and live relay shutdown remain accepted gaps unless exercised with their fixtures. |
|
||||
|
||||
Screenshots are UI evidence only; they do not prove PTY or memory cleanup.
|
||||
|
||||
## Performance and blast radius
|
||||
|
||||
No interval, watcher, or extra provider listing is added. The existing management call can wait for 65 sleeps plus adapter latency. The coordinator adds at most one existing exact-kill IPC per unique current non-runtime PTY binding; record both those calls and any idempotent unmount duplicates as part of the performance evidence.
|
||||
|
||||
Repeated `closeTerminalTab` calls are not O(tabs): target resolution and store cascades scan/clone multiple maps per close, so worst-case renderer work is superlinear. Measure the synchronous close phase with a representative high-tab fixture (at least 100 terminal tabs). Record close attempts, Zustand writes, duration, and long tasks. If it creates a >50 ms renderer task, add a focused bulk reducer or yield-safe batching rather than declaring the rare action free.
|
||||
|
||||
For resource reclamation, prefer deterministic counts: zero target `TerminalPane`/xterm instances, released listeners/transports, and no initial PTY IDs. A repeated open/kill cycle may supplement this with heap/working-set evidence, but absolute memory deltas are not a stable pass/fail oracle.
|
||||
|
||||
## UI quality bar and screenshots
|
||||
|
||||
No new layout or visual token is needed. Keep the existing shadcn `Dialog`/destructive `Button`, compact typography, dismissal lock while busy, and accurate consequence-first copy. The post-state must never be a blank/crashed TerminalPane or a wall of dead empty panes.
|
||||
|
||||
Required review screenshots:
|
||||
|
||||
1. Resource Manager before kill with an empty terminal and an established terminal visible in the workspace.
|
||||
2. Resource Manager kill-all confirmation with updated close-tabs copy.
|
||||
3. Matched post-kill workspace showing target terminal tabs gone and refreshed counts.
|
||||
4. Manage Sessions confirmation or post-state, proving the second entry point uses the same behavior.
|
||||
|
||||
## Lightweight Eng Review
|
||||
|
||||
- **Scope:** renderer surface cleanup layered on the existing daemon management action; no daemon redesign and no broad orphan/provider sweep.
|
||||
- **Architecture/data flow:** main owns its initial daemon-session shutdown; renderer owns the confirmed terminal-surface snapshot, exact current-binding shutdown, and tab/xterm disposal; runtime-host close remains behind existing routing.
|
||||
- **Failure modes:** partial/rejected daemon kill, exact provider-kill failure, no-session cleanup, pinning, splits, cleanup-time active selection, new/moved/disappeared tabs, same-tab rebind, overlapping actions, invoking-component unmount, stale runtime snapshots, adapter-list uncertainty, and future multi-window ownership are explicit above.
|
||||
- **Tests:** deterministic state/coordinator and main-handler tests plus Windows Electron proof; a new experimental partial reliability gate records provider gaps.
|
||||
- **Performance/blast radius:** one existing management IPC, bounded exact per-PTY kill IPCs, no new listing/polling, but N close cascades require measurement because current helpers are not linear or atomic.
|
||||
- **UI quality bar:** existing dialog primitives and truthful copy; screenshots prove both entry points and the terminal-free post-state, not memory reclamation.
|
||||
- **Residual risks:** daemon adapter listing can stall until client timeouts and failures are treated as empty; terminal-tab actions discard the runtime-host close result and its 10-second intent is shorter than the possible two-request close flow; a binding created after exact-ID capture is killed only by transport unmount; live SSH/WSL/mobile behavior may remain accepted gaps until fixtures prove it.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Add the snapshot/coordinator and deterministic tests.
|
||||
2. Wire it only through `useDaemonActions.runKillAll`; keep the coordinator outside the hook and update toast/dialog copy.
|
||||
3. Add the experimental reliability gate and performance count/latency evidence.
|
||||
4. Run focused tests, typecheck, lint, and max-lines ratchet checks.
|
||||
5. Validate both entry points in Electron on Windows and capture the required screenshots.
|
||||
6. Force-add this doc when staging because `docs/**` is ignored by default.
|
||||
@@ -1,79 +0,0 @@
|
||||
# New Worktree Sidebar Reveal
|
||||
|
||||
## Problem
|
||||
|
||||
Issue https://github.com/stablyai/orca-internal/issues/350 asks that newly created worktrees jump into view in the left sidebar list with no scroll animation.
|
||||
|
||||
Current behavior:
|
||||
|
||||
- `activateAndRevealWorktree(...)` always calls `state.revealWorktreeInSidebar(worktreeId)` with no options.
|
||||
- `revealWorktreeInSidebar` defaults `behavior` to `'smooth'` (`ui.ts`).
|
||||
- `WorktreeList` forwards that behavior to `virtualizer.scrollToIndex(..., { behavior })`.
|
||||
|
||||
Result: off-screen targets animate by default, including freshly created worktrees.
|
||||
|
||||
## Root Cause
|
||||
|
||||
`activateAndRevealWorktree` conflates two intents:
|
||||
|
||||
1. activate existing worktree navigation (smooth reveal is fine);
|
||||
2. activate a just-created worktree (must jump immediately).
|
||||
|
||||
Created-worktree callers cannot currently express reveal intent, so they inherit `'smooth'`.
|
||||
|
||||
## Scope and Non-goals
|
||||
|
||||
- Add an opt-in reveal behavior at activation call sites.
|
||||
- Apply `'auto'` only where the worktree is newly created/added in that flow.
|
||||
- Preserve existing behavior for normal worktree navigation (clicks, keyboard, history nav, palette selection of existing worktrees, port/status-driven activation) unless a caller opts in.
|
||||
- Do not change direct raw reveal paths in IPC handlers that intentionally call `store.revealWorktreeInSidebar(...)` outside `activateAndRevealWorktree` (terminal/editor/mobile focus paths remain smooth).
|
||||
- Do not change sorting/grouping/filter UI, list virtualization strategy, or sidebar styling.
|
||||
|
||||
## Design
|
||||
|
||||
1. Extend `activateAndRevealWorktree` options:
|
||||
- `sidebarRevealBehavior?: PendingSidebarWorktreeReveal['behavior']`.
|
||||
2. In `activateAndRevealWorktree`, call:
|
||||
- `state.revealWorktreeInSidebar(worktreeId, { behavior: opts.sidebarRevealBehavior })` when provided;
|
||||
- otherwise keep `state.revealWorktreeInSidebar(worktreeId)` so default behavior stays unchanged.
|
||||
3. Pass `sidebarRevealBehavior: 'auto'` only from created/added-worktree flows:
|
||||
- `useComposerState` full-create path;
|
||||
- `useComposerState` quick-create path;
|
||||
- `useIpcEvents` `onActivateWorktree` only when the event corresponds to a newly created worktree;
|
||||
- `launch-work-item-direct`;
|
||||
- folder add/create flows that activate a newly-added synthetic folder worktree (`AddRepoCreateStep` folder branch, `NonGitFolderDialog`, and `repos` slice `addNonGitFolder` path).
|
||||
4. Keep existing navigation activations smooth, including:
|
||||
- `AddRepoDialog` / `ProjectAddedDialog` “open primary worktree” actions (these can target pre-existing worktrees, not guaranteed newly created);
|
||||
- all existing `activateAndRevealWorktree(...)` callers that do not opt in.
|
||||
5. Keep `WorktreeList` reveal effect unchanged; it already honors `pendingRevealWorktree.behavior`.
|
||||
|
||||
## Correctness Notes and Edge Cases
|
||||
|
||||
- Repo-filter clearing remains unchanged: `activateAndRevealWorktree` only clears `filterRepoIds` when target repo is excluded.
|
||||
- Other visibility constraints are not auto-cleared. If the target exists but is hidden by other sidebar state, `resolvePendingSidebarReveal(...)` keeps the reveal pending.
|
||||
- The reveal effect uncollapses lineage/group containers before scroll; behavior changes only animation mode, not visibility resolution.
|
||||
- Pending reveal is a single store slot (`pendingRevealWorktree`). Concurrent reveal requests are last-writer-wins; this change should not alter that behavior.
|
||||
- If activation cannot resolve the worktree (`getKnownWorktreeById` miss), behavior remains unchanged (`false`, no reveal queued).
|
||||
- `ui:activateWorktree` is an overloaded IPC used by both creation and non-creation activation paths. The renderer must choose `'auto'` only for create cases (for example, worktree absent before fetch and present after fetch), and keep default smooth reveal for existing-worktree activations.
|
||||
- Multi-window consistency remains per renderer window store; each window applies its own reveal behavior locally.
|
||||
- This change is renderer-only; it does not add main-process coordination and does not make reveal ordering transactional across concurrent async creators.
|
||||
|
||||
## Tests
|
||||
|
||||
Add/adjust focused tests in `worktree-activation` coverage:
|
||||
|
||||
- explicit `sidebarRevealBehavior: 'auto'` is forwarded to `revealWorktreeInSidebar(worktreeId, { behavior: 'auto' })`;
|
||||
- no option still calls `revealWorktreeInSidebar(worktreeId)` (store default remains smooth).
|
||||
|
||||
Add call-site regression tests (recommended, small):
|
||||
|
||||
- one composer create path passes `'auto'`;
|
||||
- one non-created navigation path stays default (no behavior option).
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Add `sidebarRevealBehavior` option in `activateAndRevealWorktree`.
|
||||
2. Update created-worktree callers to pass `'auto'`.
|
||||
3. Add tests above.
|
||||
4. Run targeted Vitest tests, then `pnpm typecheck` and `pnpm lint`.
|
||||
5. Validate in Electron: with sidebar overflow, create a worktree and verify the list jumps to it without smooth animation.
|
||||
@@ -76,6 +76,7 @@ import {
|
||||
import { useMobilePrBranchContext } from '../../../../src/session/use-mobile-pr-branch-context'
|
||||
import { SessionDockColumn } from '../../../../src/session/SessionDockColumn'
|
||||
import type { ConnectionState, RpcFailure, RpcSuccess } from '../../../../src/transport/types'
|
||||
import { headlessActivationNeedsHostRenderer } from '../../../../src/worktree/worktree-activation-result'
|
||||
import { useMobileDictation } from '../../../../src/hooks/use-mobile-dictation'
|
||||
import {
|
||||
triggerMediumImpact,
|
||||
@@ -2690,6 +2691,11 @@ export default function SessionScreen() {
|
||||
timers.push(setTimeout(fn, ms))
|
||||
}
|
||||
void (async () => {
|
||||
const reportActivationOutcome = (response: RpcSuccess | null): void => {
|
||||
if (!disposed && response && headlessActivationNeedsHostRenderer(response.result)) {
|
||||
showToast('Open Orca on the host to wake sleeping agents.', 3000)
|
||||
}
|
||||
}
|
||||
if (client && created !== '1') {
|
||||
// Why: mobile needs host-owned tabs hydrated for this route, but should
|
||||
// not pull other paired clients, especially desktop, into this worktree.
|
||||
@@ -2698,6 +2704,7 @@ export default function SessionScreen() {
|
||||
worktree: `id:${worktreeId}`,
|
||||
notifyClients: false
|
||||
})
|
||||
.then((response) => reportActivationOutcome(response.ok ? response : null))
|
||||
.catch(() => null)
|
||||
}
|
||||
if (disposed) {
|
||||
@@ -2719,12 +2726,13 @@ export default function SessionScreen() {
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
await client
|
||||
const activationResponse = await client
|
||||
.sendRequest('worktree.activate', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
notifyClients: false
|
||||
})
|
||||
.catch(() => null)
|
||||
reportActivationOutcome(activationResponse?.ok ? activationResponse : null)
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
@@ -2740,7 +2748,7 @@ export default function SessionScreen() {
|
||||
clearTimeout(t)
|
||||
}
|
||||
}
|
||||
}, [client, connState, created, fetchSessionTabs, fetchTerminals, worktreeId])
|
||||
}, [client, connState, created, fetchSessionTabs, fetchTerminals, showToast, worktreeId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || connState !== 'connected') {
|
||||
|
||||
@@ -1165,7 +1165,7 @@
|
||||
Changes
|
||||
</button>
|
||||
<button class="segment" data-seg="pr" onclick="switchSeg('pr')">Pull Request</button>
|
||||
<button class="segment" data-seg="history" onclick="switchSeg('history')">History</button>
|
||||
<button class="segment" data-seg="history" onclick="switchSeg('history')">Commits</button>
|
||||
</div>
|
||||
|
||||
<!-- Persistent branch card -->
|
||||
|
||||
@@ -15,7 +15,7 @@ const MAX_TABLE_COLUMNS = 8
|
||||
function openMarkdownUrl(url: string): void {
|
||||
const trimmed = url.trim()
|
||||
if (/^(https?:|mailto:)/i.test(trimmed)) {
|
||||
void Linking.openURL(trimmed)
|
||||
void Linking.openURL(trimmed).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ type Props = {
|
||||
gitStatus: MobileGitStatusResult | null
|
||||
headSha: string | null
|
||||
bottomInset?: number
|
||||
// Hub chrome already shows open-on-web; hide the in-body icon there.
|
||||
showOpenOnWeb?: boolean
|
||||
}
|
||||
|
||||
// Mutation hooks run unconditionally here and gate internally until a PR is ready.
|
||||
@@ -52,17 +54,15 @@ export function MobilePRSidebar({
|
||||
gitBranch,
|
||||
gitStatus,
|
||||
headSha,
|
||||
bottomInset = 0
|
||||
bottomInset = 0,
|
||||
showOpenOnWeb = true
|
||||
}: Props) {
|
||||
const branch = prSidebarRenderBranch(state)
|
||||
// prNumber is 0 until ready; the hook gates on `ready` so it never fires early.
|
||||
const prNumber = state.kind === 'ready' ? state.data.pr.number : 0
|
||||
const prRepo =
|
||||
state.kind === 'ready'
|
||||
? state.data.pr.prRepo
|
||||
? { owner: state.data.pr.prRepo.owner, repo: state.data.pr.prRepo.repo }
|
||||
: null
|
||||
: null
|
||||
// Prefer the stable PRInfo.prRepo reference — cloning owner/repo each render
|
||||
// reallocates and thrash-updates the mutation/comment/title hooks.
|
||||
const prRepo = state.kind === 'ready' ? (state.data.pr.prRepo ?? null) : null
|
||||
const actions = useMobilePrActions({
|
||||
client,
|
||||
connState,
|
||||
@@ -95,6 +95,9 @@ export function MobilePRSidebar({
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: bottomInset }]}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
// Why: root-comment / reply composers sit at the bottom of this scroll
|
||||
// area; without keyboard insets the focused field stays under the keyboard.
|
||||
automaticallyAdjustKeyboardInsets
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<PrSidebarContent
|
||||
@@ -111,6 +114,7 @@ export function MobilePRSidebar({
|
||||
commentActions={commentActions}
|
||||
titleAction={titleAction}
|
||||
triage={triage}
|
||||
showOpenOnWeb={showOpenOnWeb}
|
||||
/>
|
||||
</ScrollView>
|
||||
)
|
||||
@@ -129,7 +133,8 @@ function PrSidebarContent({
|
||||
actions,
|
||||
commentActions,
|
||||
titleAction,
|
||||
triage
|
||||
triage,
|
||||
showOpenOnWeb
|
||||
}: {
|
||||
branch: ReturnType<typeof prSidebarRenderBranch>
|
||||
state: PrSidebarState
|
||||
@@ -144,6 +149,7 @@ function PrSidebarContent({
|
||||
commentActions: MobilePrCommentActions
|
||||
titleAction: MobilePrTitleAction
|
||||
triage: MobilePrAiTriage
|
||||
showOpenOnWeb: boolean
|
||||
}) {
|
||||
if (branch === 'loading') {
|
||||
return (
|
||||
@@ -209,6 +215,7 @@ function PrSidebarContent({
|
||||
titleAction={titleAction}
|
||||
triage={triage}
|
||||
refetch={refetch}
|
||||
showOpenOnWeb={showOpenOnWeb}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -223,7 +230,8 @@ function PrSidebarSections({
|
||||
commentActions,
|
||||
titleAction,
|
||||
triage,
|
||||
refetch
|
||||
refetch,
|
||||
showOpenOnWeb
|
||||
}: {
|
||||
data: Extract<PrSidebarState, { kind: 'ready' }>['data']
|
||||
client: RpcClient | null
|
||||
@@ -233,6 +241,7 @@ function PrSidebarSections({
|
||||
titleAction: MobilePrTitleAction
|
||||
triage: MobilePrAiTriage
|
||||
refetch: () => void
|
||||
showOpenOnWeb: boolean
|
||||
}) {
|
||||
const pr = data.pr
|
||||
// Bind the triage launchers to this PR's data; the prompt builders are pure so
|
||||
@@ -262,19 +271,30 @@ function PrSidebarSections({
|
||||
isBusy: triage.isBusy('resolve-conflicts'),
|
||||
error: triage.error
|
||||
}
|
||||
// One card for identity + actions so the ready PR isn't a stack of thin
|
||||
// duplicate blocks (badge row, title, branches, then another action band).
|
||||
return (
|
||||
<>
|
||||
<PRSidebarHeader pr={data.pr} details={data.details} titleAction={titleAction} />
|
||||
{/* Conflicting-files section mirrors desktop order: directly below the header,
|
||||
before actions/checks. Renders only when the PR has merge conflicts. */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionBody}>
|
||||
<PRSidebarHeader
|
||||
pr={data.pr}
|
||||
details={data.details}
|
||||
titleAction={titleAction}
|
||||
showOpenOnWeb={showOpenOnWeb}
|
||||
bare
|
||||
/>
|
||||
<PRActionsSection
|
||||
pr={data.pr}
|
||||
actions={actions}
|
||||
client={client}
|
||||
worktreeId={worktreeId}
|
||||
onUnlinked={refetch}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
{/* Own titled section when present; null otherwise (no empty chrome). */}
|
||||
<PRConflictingFilesSection pr={data.pr} triage={conflictsTriage} />
|
||||
<PRActionsSection
|
||||
pr={data.pr}
|
||||
actions={actions}
|
||||
client={client}
|
||||
worktreeId={worktreeId}
|
||||
onUnlinked={refetch}
|
||||
/>
|
||||
<PRReviewersSection
|
||||
details={data.details}
|
||||
actions={actions}
|
||||
|
||||
@@ -45,5 +45,8 @@ export function MobilePrComposeSheet({
|
||||
}
|
||||
|
||||
export function openMobilePrUrl(url: string): void {
|
||||
void Linking.openURL(url)
|
||||
// Why: Linking.openURL rejects when iOS/Android can't open the URL (no app,
|
||||
// bad scheme, etc.). Without a catch that surfaces as LogBox "Uncaught
|
||||
// (in promise) Error: Unable to open URL…" over the PR screen.
|
||||
void Linking.openURL(url).catch(() => {})
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@ export function MobilePrViewPanelBody({
|
||||
gitStatus={gitStatus}
|
||||
headSha={headSha}
|
||||
bottomInset={insets.bottom}
|
||||
// Hub header already hosts open-on-web while this segment is active.
|
||||
showOpenOnWeb={false}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { RpcClient } from '../../transport/rpc-client'
|
||||
import type { MobilePrActions } from '../../session/use-mobile-pr-actions'
|
||||
import { unlinkMobilePr } from '../../source-control/mobile-pr-link'
|
||||
import { ConfirmModal } from '../ConfirmModal'
|
||||
import { PRSection } from './PRSection'
|
||||
import { canShowMobilePRAutoMergeControl } from './pr-auto-merge-availability'
|
||||
import { resolveMobilePrMergeMethod, resolvePrActionAvailability } from './pr-actions-state'
|
||||
import { prActionsStyles as styles } from './pr-actions-styles'
|
||||
@@ -25,12 +24,13 @@ type Confirm =
|
||||
| { kind: 'merge'; method: GitHubPRMergeMethod }
|
||||
| { kind: 'state'; state: 'open' | 'closed' }
|
||||
|
||||
// Merge, auto-merge toggle, and close/reopen. Destructive actions route through
|
||||
// ConfirmModal first (R5). The firing row shows a spinner in place of its icon
|
||||
// and disables; other rows stay interactive (uniform visual).
|
||||
// Merge primary; Close/Reopen + Unlink share one secondary row. No section title —
|
||||
// button labels are self-explanatory and a header wasted a full row on mobile.
|
||||
export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }: Props) {
|
||||
const [confirm, setConfirm] = useState<Confirm | null>(null)
|
||||
const [unlinking, setUnlinking] = useState(false)
|
||||
// Local unlink errors — unlink is not routed through the actions engine.
|
||||
const [unlinkError, setUnlinkError] = useState<string | null>(null)
|
||||
|
||||
// Mobile keeps merge one-tap: use the repo default instead of surfacing a
|
||||
// desktop-style method picker in the narrow PR action stack.
|
||||
@@ -41,23 +41,31 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }
|
||||
const mergeBusy = actions.isBusy({ kind: 'merge' })
|
||||
const autoMergeBusy = actions.isBusy({ kind: 'autoMerge' })
|
||||
const stateBusy = actions.isBusy({ kind: 'state' })
|
||||
const unlinkBusy = unlinking || mergeBusy || autoMergeBusy || stateBusy
|
||||
const showAutoMerge =
|
||||
avail.canAutoMerge &&
|
||||
canShowMobilePRAutoMergeControl({
|
||||
...pr,
|
||||
autoMergeEnabled: autoMerge || pr.autoMergeEnabled === true
|
||||
})
|
||||
const showSecondary = avail.canClose || avail.canReopen || avail.canUnlink
|
||||
const actionError = unlinkError ?? actions.error
|
||||
|
||||
const unlink = useCallback(async (): Promise<void> => {
|
||||
if (!client || unlinking) {
|
||||
return
|
||||
}
|
||||
setUnlinking(true)
|
||||
setUnlinkError(null)
|
||||
try {
|
||||
const outcome = await unlinkMobilePr(client, worktreeId)
|
||||
if (outcome.ok) {
|
||||
onUnlinked()
|
||||
} else {
|
||||
setUnlinkError(outcome.error)
|
||||
}
|
||||
} catch (err) {
|
||||
setUnlinkError(err instanceof Error ? err.message : 'Failed to unlink pull request.')
|
||||
} finally {
|
||||
setUnlinking(false)
|
||||
}
|
||||
@@ -89,6 +97,8 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }
|
||||
if (!confirm) {
|
||||
return
|
||||
}
|
||||
// Engine errors take over the shared error line after this; drop unlink text.
|
||||
setUnlinkError(null)
|
||||
if (confirm.kind === 'merge') {
|
||||
actions.merge(confirm.method)
|
||||
} else {
|
||||
@@ -99,8 +109,7 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }
|
||||
const copy = confirmCopy()
|
||||
|
||||
return (
|
||||
<PRSection title="Actions">
|
||||
{/* Merge controls only while the PR can still be merged (open/draft). */}
|
||||
<View style={styles.actionsBlock}>
|
||||
{avail.canMerge ? (
|
||||
<Pressable
|
||||
style={[
|
||||
@@ -108,7 +117,10 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }
|
||||
styles.actionButtonMerge,
|
||||
mergeBusy && styles.actionButtonDisabled
|
||||
]}
|
||||
onPress={() => setConfirm({ kind: 'merge', method: effectiveMethod })}
|
||||
onPress={() => {
|
||||
setUnlinkError(null)
|
||||
setConfirm({ kind: 'merge', method: effectiveMethod })
|
||||
}}
|
||||
disabled={mergeBusy}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Merge pull request"
|
||||
@@ -124,13 +136,15 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
{/* Auto-merge toggle — optimistic, reverts on transient failure. */}
|
||||
{showAutoMerge ? (
|
||||
<View style={styles.toggleRow}>
|
||||
<Text style={styles.toggleLabel}>Auto-merge when ready</Text>
|
||||
<Pressable
|
||||
style={[styles.togglePill, autoMerge && styles.togglePillOn]}
|
||||
onPress={() => actions.setAutoMerge(!autoMerge, effectiveMethod)}
|
||||
onPress={() => {
|
||||
setUnlinkError(null)
|
||||
actions.setAutoMerge(!autoMerge, effectiveMethod)
|
||||
}}
|
||||
disabled={autoMergeBusy}
|
||||
accessibilityRole="switch"
|
||||
accessibilityState={{ checked: autoMerge }}
|
||||
@@ -147,49 +161,59 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Close (open PRs) / Reopen (closed PRs) — confirmed before firing (R5). */}
|
||||
{avail.canClose || avail.canReopen ? (
|
||||
<Pressable
|
||||
style={[styles.actionButton, stateBusy && styles.actionButtonDisabled]}
|
||||
onPress={() => setConfirm({ kind: 'state', state: avail.canClose ? 'closed' : 'open' })}
|
||||
disabled={stateBusy}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={avail.canClose ? 'Close pull request' : 'Reopen pull request'}
|
||||
>
|
||||
{stateBusy ? <ActivityIndicator color={colors.textSecondary} /> : null}
|
||||
<Text
|
||||
style={[styles.actionButtonText, avail.canClose && styles.actionButtonDestructiveText]}
|
||||
>
|
||||
{avail.canClose ? 'Close' : 'Reopen'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{showSecondary ? (
|
||||
<View style={styles.secondaryRow}>
|
||||
{avail.canClose || avail.canReopen ? (
|
||||
<Pressable
|
||||
style={[
|
||||
styles.actionButton,
|
||||
styles.secondaryButton,
|
||||
stateBusy && styles.actionButtonDisabled
|
||||
]}
|
||||
onPress={() => {
|
||||
setUnlinkError(null)
|
||||
setConfirm({ kind: 'state', state: avail.canClose ? 'closed' : 'open' })
|
||||
}}
|
||||
disabled={stateBusy}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={avail.canClose ? 'Close pull request' : 'Reopen pull request'}
|
||||
>
|
||||
{stateBusy ? <ActivityIndicator color={colors.textSecondary} /> : null}
|
||||
<Text
|
||||
style={[
|
||||
styles.actionButtonText,
|
||||
avail.canClose && styles.actionButtonDestructiveText
|
||||
]}
|
||||
>
|
||||
{avail.canClose ? 'Close' : 'Reopen'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{avail.canUnlink ? (
|
||||
<Pressable
|
||||
style={[
|
||||
styles.actionButton,
|
||||
styles.secondaryButton,
|
||||
unlinkBusy && styles.actionButtonDisabled
|
||||
]}
|
||||
onPress={() => void unlink()}
|
||||
disabled={unlinkBusy}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Unlink pull request"
|
||||
>
|
||||
{unlinking ? (
|
||||
<ActivityIndicator color={colors.textSecondary} />
|
||||
) : (
|
||||
<Link2Off size={16} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
)}
|
||||
<Text style={styles.actionButtonText}>Unlink</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Unlink the PR from this worktree. Disabled while another PR mutation is in
|
||||
flight so clearing the link can't race a merge/close refetch. */}
|
||||
{avail.canUnlink ? (
|
||||
<Pressable
|
||||
style={[
|
||||
styles.actionButton,
|
||||
(unlinking || mergeBusy || autoMergeBusy || stateBusy) && styles.actionButtonDisabled
|
||||
]}
|
||||
onPress={() => void unlink()}
|
||||
disabled={unlinking || mergeBusy || autoMergeBusy || stateBusy}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Unlink pull request"
|
||||
>
|
||||
{unlinking ? (
|
||||
<ActivityIndicator color={colors.textSecondary} />
|
||||
) : (
|
||||
<Link2Off size={16} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
)}
|
||||
<Text style={styles.actionButtonText}>Unlink</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{actionError ? <Text style={styles.actionError}>{actionError}</Text> : null}
|
||||
|
||||
{actions.error ? <Text style={styles.actionError}>{actions.error}</Text> : null}
|
||||
|
||||
{/* A Modal is taken out of the flex flow, so it adds no body gap here. */}
|
||||
<ConfirmModal
|
||||
visible={confirm !== null}
|
||||
title={copy.title}
|
||||
@@ -199,6 +223,6 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }
|
||||
onConfirm={runConfirmed}
|
||||
onCancel={() => setConfirm(null)}
|
||||
/>
|
||||
</PRSection>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { GitHubWorkItemDetails, PRState } from '../../../../src/shared/type
|
||||
import type { GitHubPrRepoSlug } from '../../session/github-pr-rpc'
|
||||
import { colors } from '../../theme/mobile-theme'
|
||||
import { canAddRootComment } from '../../session/pr-comment-actions'
|
||||
import { isPrSidebarDetailsPlaceholder } from '../../session/mobile-pr-sidebar-state'
|
||||
import type { MobilePrCommentActions } from '../../session/use-mobile-pr-comment-actions'
|
||||
import { PRSection } from './PRSection'
|
||||
import { CommentMarkdown } from './CommentMarkdown'
|
||||
@@ -50,10 +51,15 @@ const COMMENT_PAGE = 12
|
||||
// review comments, reactions, and collapsible resolved threads.
|
||||
export function PRCommentsSection({ details, prState, prRepo, actions }: Props) {
|
||||
// details is null while phase 2 (the heavy comments/body payload) is still loading.
|
||||
// A synthetic placeholder means phase 2 failed — do not paint that as empty success.
|
||||
const loadingDetails = details === null
|
||||
const detailsFailed = details != null && isPrSidebarDetailsPlaceholder(details)
|
||||
const body = details?.body ?? ''
|
||||
const comments = useMemo(() => details?.comments ?? [], [details])
|
||||
const isPr = details?.item.type === 'pr'
|
||||
const comments = useMemo(
|
||||
() => (details && !isPrSidebarDetailsPlaceholder(details) ? details.comments : []),
|
||||
[details]
|
||||
)
|
||||
const isPr = details != null && !detailsFailed && details.item.type === 'pr'
|
||||
|
||||
// Per-card action bundle (stable callbacks from the hook) — built once so the
|
||||
// memo'd cards don't re-render on unrelated timeline changes.
|
||||
@@ -94,6 +100,10 @@ export function PRCommentsSection({ details, prState, prRepo, actions }: Props)
|
||||
<PRSection title="Description">
|
||||
{loadingDetails ? (
|
||||
<ActivityIndicator color={colors.textSecondary} />
|
||||
) : detailsFailed ? (
|
||||
<Text style={styles.noDescription}>
|
||||
Could not load description. Tap refresh to try again.
|
||||
</Text>
|
||||
) : body.trim() ? (
|
||||
<CommentMarkdown content={body} variant="document" />
|
||||
) : (
|
||||
@@ -113,6 +123,8 @@ export function PRCommentsSection({ details, prState, prRepo, actions }: Props)
|
||||
>
|
||||
{loadingDetails ? (
|
||||
<ActivityIndicator color={colors.textSecondary} />
|
||||
) : detailsFailed ? (
|
||||
<Text style={styles.empty}>Could not load comments. Tap refresh to try again.</Text>
|
||||
) : (
|
||||
<View style={styles.list}>
|
||||
{comments.length === 0 ? (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { colors } from '../../theme/mobile-theme'
|
||||
import type { GitHubWorkItemDetails } from '../../../../src/shared/types'
|
||||
import type { RpcClient } from '../../transport/rpc-client'
|
||||
import type { MobilePrActions } from '../../session/use-mobile-pr-actions'
|
||||
import { isPrSidebarDetailsPlaceholder } from '../../session/mobile-pr-sidebar-state'
|
||||
import { getPRReviewerRows } from './pr-checks-presentation'
|
||||
import { ReviewerPickerDrawer } from './ReviewerPickerDrawer'
|
||||
import { PRSection } from './PRSection'
|
||||
@@ -20,8 +21,17 @@ type Props = {
|
||||
// Requested reviewers + their latest review status, with a picker to request /
|
||||
// remove (optimistic add/remove via the actions hook).
|
||||
export function PRReviewersSection({ details, actions, client, worktreeId }: Props) {
|
||||
// details === null means phase 2 (work-item payload) is still in flight — same
|
||||
// signal Comments uses. Do not treat that as "no reviewers" or the section goes
|
||||
// blank while checks (phase 1) already paint. A synthetic placeholder means
|
||||
// phase 2 failed (no body/comments/reviews landed).
|
||||
const loadingDetails = details === null
|
||||
const detailsFailed = details != null && isPrSidebarDetailsPlaceholder(details)
|
||||
const authoritativeRows = useMemo(
|
||||
() => (details?.item ? getPRReviewerRows(details.item) : []),
|
||||
() =>
|
||||
details?.item && !isPrSidebarDetailsPlaceholder(details)
|
||||
? getPRReviewerRows(details.item)
|
||||
: [],
|
||||
[details]
|
||||
)
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
@@ -46,21 +56,27 @@ export function PRReviewersSection({ details, actions, client, worktreeId }: Pro
|
||||
return author ? [author, ...logins] : logins
|
||||
}, [authoritativeRows, details])
|
||||
|
||||
return (
|
||||
<PRSection
|
||||
title="Reviewers"
|
||||
trailing={
|
||||
<Pressable
|
||||
style={styles.iconButton}
|
||||
onPress={() => setPickerOpen(true)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Add or remove reviewers"
|
||||
>
|
||||
<UserPlus size={16} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
}
|
||||
const addButton = (
|
||||
<Pressable
|
||||
style={styles.iconButton}
|
||||
onPress={() => setPickerOpen(true)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Add or remove reviewers"
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
<UserPlus size={16} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
)
|
||||
|
||||
return (
|
||||
<PRSection title="Reviewers" trailing={addButton}>
|
||||
{loadingDetails ? (
|
||||
<View style={styles.reviewersStatus}>
|
||||
<ActivityIndicator color={colors.textSecondary} />
|
||||
<Text style={styles.emptyText}>Loading reviewers…</Text>
|
||||
</View>
|
||||
) : detailsFailed ? (
|
||||
<Text style={styles.emptyText}>Could not load reviewers. Tap refresh to try again.</Text>
|
||||
) : rows.length === 0 ? (
|
||||
<Text style={styles.emptyText}>No reviewers requested</Text>
|
||||
) : (
|
||||
rows.map((row) => {
|
||||
|
||||
@@ -3,22 +3,28 @@ import { Text, View } from 'react-native'
|
||||
import { mobilePrSidebarStyles as styles } from './mobile-pr-sidebar-styles'
|
||||
|
||||
type Props = {
|
||||
title: string
|
||||
// Optional: omit for self-explanatory sections (e.g. action buttons) so the
|
||||
// header row doesn't waste vertical space on mobile.
|
||||
title?: string
|
||||
// Optional trailing control(s) in the header row (e.g. add-reviewer, checks
|
||||
// summary + rerun). Rendered right-aligned opposite the title.
|
||||
trailing?: ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
// Shared card shell for the titled PR sections (Actions/Reviewers/Checks). Mirrors
|
||||
// the desktop PR page's card-with-header-divider so the sections read consistently.
|
||||
// Shared card shell for PR sections (Actions/Reviewers/Checks). Mirrors the
|
||||
// desktop PR page's card-with-header-divider so the sections read consistently.
|
||||
// Header is omitted when neither title nor trailing is provided.
|
||||
export function PRSection({ title, trailing, children }: Props) {
|
||||
const showHeader = Boolean(title) || trailing != null
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionLabel}>{title}</Text>
|
||||
{trailing ? <View style={styles.sectionHeaderTrailing}>{trailing}</View> : null}
|
||||
</View>
|
||||
{showHeader ? (
|
||||
<View style={styles.sectionHeader}>
|
||||
{title ? <Text style={styles.sectionLabel}>{title}</Text> : null}
|
||||
{trailing ? <View style={styles.sectionHeaderTrailing}>{trailing}</View> : null}
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.sectionBody}>{children}</View>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -16,11 +16,21 @@ type Props = {
|
||||
details: GitHubWorkItemDetails | null
|
||||
// Inline title-edit action; the pencil affordance only shows when the PR is editable.
|
||||
titleAction: MobilePrTitleAction
|
||||
// Hub chrome already surfaces open-on-web; hide the duplicate icon in that case.
|
||||
showOpenOnWeb?: boolean
|
||||
// When true, render without section chrome so identity can share a card with actions.
|
||||
bare?: boolean
|
||||
}
|
||||
|
||||
// Header: state badge (incl. draft — display-only), title, author, head->base.
|
||||
// The title is inline-editable on an open/draft PR (desktop parity).
|
||||
export function PRSidebarHeader({ pr, details, titleAction }: Props) {
|
||||
// Compact identity: state + # + author on one meta row, title, head→base.
|
||||
// # lives only in the meta row (not also after the title) to avoid repetition.
|
||||
export function PRSidebarHeader({
|
||||
pr,
|
||||
details,
|
||||
titleAction,
|
||||
showOpenOnWeb = true,
|
||||
bare = false
|
||||
}: Props) {
|
||||
const item = details?.item
|
||||
const badge = prStateBadge(pr.state)
|
||||
const badgeColor = statusColor(badge.token)
|
||||
@@ -29,14 +39,12 @@ export function PRSidebarHeader({ pr, details, titleAction }: Props) {
|
||||
const baseRef = item?.baseRefName ?? null
|
||||
const headRef = item?.branchName ?? null
|
||||
const editable = canEditPRTitle(pr.state)
|
||||
// Badge, #number, and the flush-right external-link control all open pr.url
|
||||
// (canonical host web URL) in the phone browser.
|
||||
const openPr = pr.url ? () => openMobilePrUrl(pr.url) : undefined
|
||||
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionBody}>
|
||||
<View style={styles.badgeRow}>
|
||||
const body = (
|
||||
<>
|
||||
<View style={styles.metaRow}>
|
||||
<View style={styles.metaLeft}>
|
||||
<Pressable
|
||||
onPress={openPr}
|
||||
disabled={!openPr}
|
||||
@@ -50,50 +58,60 @@ export function PRSidebarHeader({ pr, details, titleAction }: Props) {
|
||||
>
|
||||
<Text style={[styles.badgeText, { color: badgeColor }]}>{badge.label}</Text>
|
||||
</Pressable>
|
||||
{openPr ? (
|
||||
<Pressable
|
||||
onPress={openPr}
|
||||
hitSlop={8}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={`Open pull request #${pr.number} in browser`}
|
||||
style={({ pressed }) => [styles.iconButton, pressed && { opacity: 0.6 }]}
|
||||
>
|
||||
<ExternalLink size={16} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Text
|
||||
style={styles.prMetaStrong}
|
||||
onPress={openPr}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={`Open pull request #${pr.number} on the web`}
|
||||
>
|
||||
#{pr.number}
|
||||
</Text>
|
||||
{author ? <Text style={styles.prMeta}>· {author}</Text> : null}
|
||||
</View>
|
||||
<PRTitle
|
||||
title={title}
|
||||
number={pr.number}
|
||||
editable={editable}
|
||||
openPr={openPr}
|
||||
titleAction={titleAction}
|
||||
/>
|
||||
{author ? <Text style={styles.prMeta}>by {author}</Text> : null}
|
||||
{baseRef && headRef ? (
|
||||
// head -> base reads in merge direction (desktop ChecksPanel parity).
|
||||
<View style={styles.branchRow}>
|
||||
<Text style={styles.branchPill}>{headRef}</Text>
|
||||
<ArrowRight size={12} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
<Text style={styles.branchPill}>{baseRef}</Text>
|
||||
</View>
|
||||
{showOpenOnWeb && openPr ? (
|
||||
<Pressable
|
||||
onPress={openPr}
|
||||
hitSlop={8}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={`Open pull request #${pr.number} in browser`}
|
||||
style={({ pressed }) => [styles.iconButton, pressed && { opacity: 0.6 }]}
|
||||
>
|
||||
<ExternalLink size={16} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
<PRTitle title={title} editable={editable} titleAction={titleAction} />
|
||||
{baseRef && headRef ? (
|
||||
<View style={styles.branchRow}>
|
||||
<Text style={styles.branchPill} numberOfLines={1}>
|
||||
{headRef}
|
||||
</Text>
|
||||
<ArrowRight size={12} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
<Text style={styles.branchPill} numberOfLines={1}>
|
||||
{baseRef}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
|
||||
if (bare) {
|
||||
return <View style={styles.identityBlock}>{body}</View>
|
||||
}
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionBody}>{body}</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function PRTitle({
|
||||
title,
|
||||
number,
|
||||
editable,
|
||||
openPr,
|
||||
titleAction
|
||||
}: {
|
||||
title: string
|
||||
number: number
|
||||
editable: boolean
|
||||
openPr: (() => void) | undefined
|
||||
titleAction: MobilePrTitleAction
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
@@ -119,7 +137,7 @@ function PRTitle({
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<View>
|
||||
<View style={composerStyles.container}>
|
||||
<TextInput
|
||||
style={composerStyles.input}
|
||||
value={draft}
|
||||
@@ -165,17 +183,7 @@ function PRTitle({
|
||||
accessibilityRole={editable ? 'button' : undefined}
|
||||
accessibilityLabel={editable ? 'Edit pull request title' : undefined}
|
||||
>
|
||||
<Text style={styles.prTitle}>
|
||||
{title}{' '}
|
||||
<Text
|
||||
style={styles.prMeta}
|
||||
onPress={openPr}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={`Open pull request #${number} on the web`}
|
||||
>
|
||||
#{number}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text style={styles.prTitle}>{title}</Text>
|
||||
{editable ? (
|
||||
<View style={styles.titleEditButton}>
|
||||
<Pencil size={14} color={colors.textSecondary} strokeWidth={2} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { ActivityIndicator, FlatList, Pressable, Text, TextInput, View } from 'react-native'
|
||||
import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native'
|
||||
import { Check } from 'lucide-react-native'
|
||||
import { colors } from '../../theme/mobile-theme'
|
||||
import type { GitHubAssignableUser } from '../../../../src/shared/types'
|
||||
@@ -26,9 +26,10 @@ type LoadState =
|
||||
| { status: 'error'; message: string }
|
||||
| { status: 'loaded'; users: GitHubAssignableUser[] }
|
||||
|
||||
// A search + FlatList of github.listAssignableUsers in a BottomDrawer (not a new
|
||||
// primitive). Seeded with already-requested/latest reviewers + author at the top.
|
||||
// Optimistic add/remove via onToggle.
|
||||
// Searchable assignable-user list in a BottomDrawer. Mapped rows (not FlatList):
|
||||
// this drawer is opened from the PR ScrollView, and a VirtualizedList nested in
|
||||
// that ScrollView throws and can leave the Reviewers section blank.
|
||||
// Seeded reviewers + author sort first for quick un-request.
|
||||
export function ReviewerPickerDrawer({
|
||||
visible,
|
||||
onClose,
|
||||
@@ -47,16 +48,22 @@ export function ReviewerPickerDrawer({
|
||||
}
|
||||
let cancelled = false
|
||||
setLoad({ status: 'loading' })
|
||||
void fetchAssignableUsers(client, worktreeId).then((outcome) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
setLoad(
|
||||
outcome.ok
|
||||
? { status: 'loaded', users: outcome.result }
|
||||
: { status: 'error', message: outcome.error }
|
||||
)
|
||||
})
|
||||
void fetchAssignableUsers(client, worktreeId)
|
||||
.then((outcome) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
setLoad(
|
||||
outcome.ok
|
||||
? { status: 'loaded', users: outcome.result }
|
||||
: { status: 'error', message: outcome.error }
|
||||
)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setLoad({ status: 'error', message: 'Failed to load people' })
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
@@ -107,15 +114,12 @@ export function ReviewerPickerDrawer({
|
||||
<Text style={styles.emptyText}>No matching people</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
style={styles.pickerList}
|
||||
data={ordered}
|
||||
keyExtractor={(u) => u.login}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
renderItem={({ item }) => {
|
||||
<View style={styles.pickerList}>
|
||||
{ordered.map((item) => {
|
||||
const requested = isRequested(item.login)
|
||||
return (
|
||||
<Pressable
|
||||
key={item.login}
|
||||
style={styles.pickerRow}
|
||||
onPress={() => onToggle(item.login)}
|
||||
accessibilityRole="button"
|
||||
@@ -134,8 +138,8 @@ export function ReviewerPickerDrawer({
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</BottomDrawer>
|
||||
)
|
||||
|
||||
@@ -25,8 +25,7 @@ export const mobilePrSidebarStyles = StyleSheet.create({
|
||||
section: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.borderSubtle,
|
||||
overflow: 'hidden'
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
// Section header row: title + optional trailing control, divided from the body
|
||||
// by a hairline border (desktop `h-10 border-b px-3`).
|
||||
@@ -55,14 +54,25 @@ export const mobilePrSidebarStyles = StyleSheet.create({
|
||||
fontSize: 13,
|
||||
fontWeight: '600'
|
||||
},
|
||||
// Header section: state badge, title, author, base<-head branches.
|
||||
// Badge + trailing open-on-web icon (hub chromeless has no panel chrome for it).
|
||||
badgeRow: {
|
||||
// Identity + actions share one section card (hub redesign): no per-block chrome.
|
||||
identityBlock: {
|
||||
gap: spacing.sm
|
||||
},
|
||||
// State badge + #number + author on one row; open-on-web flush right when shown.
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.sm
|
||||
},
|
||||
metaLeft: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: spacing.xs
|
||||
},
|
||||
badge: {
|
||||
alignSelf: 'flex-start',
|
||||
paddingHorizontal: spacing.sm,
|
||||
@@ -76,6 +86,7 @@ export const mobilePrSidebarStyles = StyleSheet.create({
|
||||
fontWeight: '700'
|
||||
},
|
||||
prTitle: {
|
||||
flex: 1,
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.titleSize,
|
||||
fontWeight: '700',
|
||||
@@ -85,6 +96,12 @@ export const mobilePrSidebarStyles = StyleSheet.create({
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize
|
||||
},
|
||||
// #number in the meta row — stronger than author so the identity scans first.
|
||||
prMetaStrong: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
// Title row: tappable area pairing the title with a trailing edit affordance.
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -104,10 +121,11 @@ export const mobilePrSidebarStyles = StyleSheet.create({
|
||||
gap: spacing.xs
|
||||
},
|
||||
branchPill: {
|
||||
flexShrink: 1,
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.metaSize,
|
||||
fontFamily: typography.monoFamily,
|
||||
backgroundColor: colors.bgPanel,
|
||||
backgroundColor: colors.bgRaised,
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: 2,
|
||||
borderRadius: radii.button
|
||||
@@ -146,6 +164,13 @@ export const mobilePrSidebarStyles = StyleSheet.create({
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize
|
||||
},
|
||||
// Loading / empty status row inside Reviewers (and similar) section bodies.
|
||||
reviewersStatus: {
|
||||
minHeight: 44,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
summaryLabel: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '700'
|
||||
@@ -274,8 +299,10 @@ export const mobilePrSidebarStyles = StyleSheet.create({
|
||||
fontSize: typography.bodySize,
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
// No maxHeight / FlatList: the parent BottomDrawer scrolls this block so we
|
||||
// never nest a VirtualizedList inside the PR page ScrollView.
|
||||
pickerList: {
|
||||
maxHeight: 320
|
||||
gap: 0
|
||||
},
|
||||
pickerRow: {
|
||||
minHeight: 44,
|
||||
|
||||
@@ -5,6 +5,19 @@ import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
|
||||
// line). Split out of mobile-pr-sidebar-styles to keep that file under the
|
||||
// 300-line cap.
|
||||
export const prActionsStyles = StyleSheet.create({
|
||||
// Bare block when identity + actions share one section card.
|
||||
actionsBlock: {
|
||||
gap: spacing.sm
|
||||
},
|
||||
// Close/Reopen + Unlink share a row so secondary actions don't stack full-width.
|
||||
secondaryRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'stretch',
|
||||
gap: spacing.sm
|
||||
},
|
||||
secondaryButton: {
|
||||
flex: 1
|
||||
},
|
||||
// Primary CTA (merge) and secondary action buttons (close/reopen/rerun/add).
|
||||
actionButton: {
|
||||
minHeight: 44,
|
||||
|
||||
@@ -5,7 +5,8 @@ import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
|
||||
// match the PR comment timeline; split out to keep PRCommentComposer focused.
|
||||
export const prCommentComposerStyles = StyleSheet.create({
|
||||
container: {
|
||||
gap: spacing.sm
|
||||
// Input → Cancel/Save needs clear separation (title edit was flush without this).
|
||||
gap: spacing.md
|
||||
},
|
||||
input: {
|
||||
minHeight: 64,
|
||||
|
||||
@@ -172,6 +172,23 @@ describe('readWorkItemDetails', () => {
|
||||
expect(parsed?.item.latestReviews).toEqual([{ login: 'ok', state: null, avatarUrl: null }])
|
||||
})
|
||||
|
||||
it('accepts raw gh latestReviews author.login nesting', () => {
|
||||
const parsed = readWorkItemDetails({
|
||||
item: {
|
||||
id: 'n',
|
||||
type: 'pr',
|
||||
number: 1,
|
||||
state: 'open',
|
||||
latestReviews: [
|
||||
{ author: { login: 'coderabbitai', avatarUrl: 'https://a' }, state: 'COMMENTED' }
|
||||
]
|
||||
}
|
||||
})
|
||||
expect(parsed?.item.latestReviews).toEqual([
|
||||
{ login: 'coderabbitai', state: 'COMMENTED', avatarUrl: 'https://a' }
|
||||
])
|
||||
})
|
||||
|
||||
it('returns null when item is unparseable', () => {
|
||||
expect(readWorkItemDetails({ item: { number: 1 } })).toBeNull()
|
||||
expect(readWorkItemDetails(null)).toBeNull()
|
||||
|
||||
@@ -123,14 +123,23 @@ export function readReviewSummary(value: unknown): GitHubPRReviewSummary | null
|
||||
if (!isRecord(value)) {
|
||||
return null
|
||||
}
|
||||
const login = readString(value.login)
|
||||
// Desktop maps latestReviews to top-level `login`. Raw `gh pr view --json`
|
||||
// keeps nested `author.login` — accept both so mobile never drops reviewers.
|
||||
const nestedAuthor = isRecord(value.author) ? value.author : null
|
||||
const login =
|
||||
readString(value.login) ?? (nestedAuthor ? readString(nestedAuthor.login) : undefined)
|
||||
if (login === undefined) {
|
||||
return null
|
||||
}
|
||||
const avatarUrl =
|
||||
readString(value.avatarUrl) ??
|
||||
(nestedAuthor
|
||||
? (readString(nestedAuthor.avatarUrl) ?? readString(nestedAuthor.avatar_url) ?? null)
|
||||
: null)
|
||||
return {
|
||||
login,
|
||||
state: readString(value.state) ?? null,
|
||||
avatarUrl: readString(value.avatarUrl) ?? null
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -157,6 +157,7 @@ export function resolvePrSidebarDetailsAfterPhase2(args: {
|
||||
}
|
||||
|
||||
// Placeholder details so Description/Comments leave the spinner after a failed phase 2.
|
||||
// The synthetic id is the only id shape we mint — real GitHub node ids never match.
|
||||
export function emptyPrSidebarDetails(pr: PRInfo): GitHubWorkItemDetails {
|
||||
return {
|
||||
item: {
|
||||
@@ -175,6 +176,20 @@ export function emptyPrSidebarDetails(pr: PRInfo): GitHubWorkItemDetails {
|
||||
}
|
||||
}
|
||||
|
||||
// True when phase 2 failed and we synthesized a stand-in (no body/comments/reviews).
|
||||
export function isPrSidebarDetailsPlaceholder(details: GitHubWorkItemDetails): boolean {
|
||||
return details.item.id === `pr-${details.item.number}`
|
||||
}
|
||||
|
||||
// Phase-2 still needs a real payload: null (in flight / not started) or a
|
||||
// synthetic placeholder from a failed fetch. Real details (including empty body)
|
||||
// do not qualify — ensure/retry must not re-pull those.
|
||||
export function prSidebarDetailsNeedFetch(
|
||||
details: GitHubWorkItemDetails | null | undefined
|
||||
): boolean {
|
||||
return details == null || isPrSidebarDetailsPlaceholder(details)
|
||||
}
|
||||
|
||||
// Soft head refresh may restart an in-flight phase-1 load; only skip when there is
|
||||
// no visible/in-flight sidebar work (hidden or terminal error states).
|
||||
export function shouldSoftRefreshPrSidebarOnHeadChange(kind: PrSidebarState['kind']): boolean {
|
||||
|
||||
@@ -41,6 +41,8 @@ describe('mobile session startup', () => {
|
||||
expect(startupEffect.indexOf("sendRequest('worktree.activate'")).toBeLessThan(
|
||||
startupEffect.indexOf('await fetchSessionTabs()')
|
||||
)
|
||||
expect(startupEffect).toContain('headlessActivationNeedsHostRenderer(response.result)')
|
||||
expect(startupEffect).toContain("showToast('Open Orca on the host to wake sleeping agents.'")
|
||||
})
|
||||
|
||||
it('activates an already-selected pending terminal tab after hydration', () => {
|
||||
|
||||
@@ -80,6 +80,51 @@ describe('PrActionsEngine — transport-rejection-normalized outcomes settle cle
|
||||
expect(engine.error).toBe('socket hung up')
|
||||
expect(engine.busy).toBeNull()
|
||||
})
|
||||
|
||||
it('surfaces a refetch throw as error without rejecting the action promise', async () => {
|
||||
const engine = new PrActionsEngine({
|
||||
mutations: {
|
||||
mergePR: async () => ({ ok: true }),
|
||||
setPRAutoMerge: async () => ({ ok: true }),
|
||||
updatePRState: async () => ({ ok: true }),
|
||||
requestReviewers: async () => ({ ok: true }),
|
||||
removeReviewers: async () => ({ ok: true }),
|
||||
rerunChecks: async () => ({ ok: true })
|
||||
},
|
||||
prNumber: 1,
|
||||
refetch: async () => {
|
||||
throw new Error('refresh failed')
|
||||
},
|
||||
onChange: () => {}
|
||||
})
|
||||
await expect(engine.merge()).resolves.toBeUndefined()
|
||||
expect(engine.error).toBe('refresh failed')
|
||||
expect(engine.busy).toBeNull()
|
||||
})
|
||||
|
||||
it('does not notify on no-op setError(null) at action start', async () => {
|
||||
const onChange = vi.fn()
|
||||
const engine = new PrActionsEngine({
|
||||
mutations: {
|
||||
mergePR: async () => ({ ok: true }),
|
||||
setPRAutoMerge: async () => ({ ok: true }),
|
||||
updatePRState: async () => ({ ok: true }),
|
||||
requestReviewers: async () => ({ ok: true }),
|
||||
removeReviewers: async () => ({ ok: true }),
|
||||
rerunChecks: async () => ({ ok: true })
|
||||
},
|
||||
prNumber: 1,
|
||||
refetch: () => {},
|
||||
onChange
|
||||
})
|
||||
// Idle: error is already null. Action start must only notify for busy, not a
|
||||
// redundant clearError path.
|
||||
onChange.mockClear()
|
||||
const p = engine.merge()
|
||||
// First notify is setBusy only (setError(null) no-ops).
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
await p
|
||||
})
|
||||
})
|
||||
|
||||
describe('PrActionsEngine — PR identity changes', () => {
|
||||
|
||||
@@ -129,7 +129,12 @@ export class PrActionsEngine {
|
||||
return f
|
||||
}
|
||||
|
||||
// Why: action start pairs setBusy + setError(null); skip notify when unchanged
|
||||
// so we don't force a full PR panel re-render for free.
|
||||
private setBusy(key: PrActionBusyKey | null): void {
|
||||
if (key === null ? this.busy === null : busyKeyEquals(this.busy, key)) {
|
||||
return
|
||||
}
|
||||
this.busy = key
|
||||
this.cfg.onChange()
|
||||
}
|
||||
@@ -143,6 +148,9 @@ export class PrActionsEngine {
|
||||
}
|
||||
|
||||
private setError(message: string | null): void {
|
||||
if (this.error === message) {
|
||||
return
|
||||
}
|
||||
this.error = message
|
||||
this.cfg.onChange()
|
||||
}
|
||||
@@ -180,7 +188,14 @@ export class PrActionsEngine {
|
||||
}
|
||||
if (outcome.ok) {
|
||||
handlers.onSuccess()
|
||||
await this.cfg.refetch()
|
||||
// Why: void engine.merge() callers are fire-and-forget; refetch must not LogBox.
|
||||
try {
|
||||
await this.cfg.refetch()
|
||||
} catch (err) {
|
||||
if (this.identity === identity) {
|
||||
this.setError(err instanceof Error ? err.message : 'Failed to refresh pull request.')
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
// Both failure classes clear optimism to authoritative; only the message
|
||||
@@ -205,6 +220,10 @@ export class PrActionsEngine {
|
||||
prRepo: cfg.prRepo
|
||||
})
|
||||
await this.settle(identity, outcome, { onSuccess: () => {}, onRevert: () => {} })
|
||||
} catch (err) {
|
||||
if (this.identity === identity) {
|
||||
this.setError(err instanceof Error ? err.message : 'Failed to merge pull request.')
|
||||
}
|
||||
} finally {
|
||||
this.clearBusyIfOwned(identity, { kind: 'merge' })
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import type { GitHubPrReadOutcome } from './github-pr-rpc'
|
||||
import {
|
||||
classifyPrSidebarFailure,
|
||||
emptyPrSidebarDetails,
|
||||
isPrSidebarDetailsPlaceholder,
|
||||
prSidebarDetailsNeedFetch,
|
||||
loadPrSidebarData,
|
||||
loadPrSidebarDetails,
|
||||
resolvePrSidebarDetailsAfterPhase2,
|
||||
@@ -240,6 +242,29 @@ describe('resolvePrSidebarDetailsAfterPhase2', () => {
|
||||
expect(empty.comments).toEqual([])
|
||||
expect(empty.item.number).toBe(7)
|
||||
expect(empty.item.type).toBe('pr')
|
||||
expect(isPrSidebarDetailsPlaceholder(empty)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPrSidebarDetailsPlaceholder / prSidebarDetailsNeedFetch', () => {
|
||||
it('detects the synthetic hyphen id, not the host pr:n shape', () => {
|
||||
const placeholder = emptyPrSidebarDetails(PR)
|
||||
expect(isPrSidebarDetailsPlaceholder(placeholder)).toBe(true)
|
||||
expect(isPrSidebarDetailsPlaceholder(DETAILS)).toBe(false)
|
||||
// Host work-item ids use a colon (`pr:7`); placeholders use a hyphen (`pr-7`).
|
||||
expect(
|
||||
isPrSidebarDetailsPlaceholder({
|
||||
...DETAILS,
|
||||
item: { ...DETAILS.item, id: `pr:${PR.number}` }
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('needs fetch for null and placeholders, not real details', () => {
|
||||
expect(prSidebarDetailsNeedFetch(null)).toBe(true)
|
||||
expect(prSidebarDetailsNeedFetch(undefined)).toBe(true)
|
||||
expect(prSidebarDetailsNeedFetch(emptyPrSidebarDetails(PR))).toBe(true)
|
||||
expect(prSidebarDetailsNeedFetch(DETAILS)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import {
|
||||
loadPrSidebarData,
|
||||
loadPrSidebarDetails,
|
||||
prSidebarDetailsNeedFetch,
|
||||
resolvePrSidebarDetailsAfterPhase2,
|
||||
shouldApplyResult,
|
||||
shouldSoftRefreshPrSidebarOnHeadChange,
|
||||
@@ -103,12 +104,21 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
|
||||
if (!probeReady || !client) {
|
||||
return
|
||||
}
|
||||
void fetchGithubRepoSlug(client, worktreeId).then((outcome) => {
|
||||
if (!cancelled) {
|
||||
setIsGithubRepo(outcome.ok && outcome.result !== null)
|
||||
setRepoProbeLoaded(true)
|
||||
}
|
||||
})
|
||||
void fetchGithubRepoSlug(client, worktreeId)
|
||||
.then((outcome) => {
|
||||
if (!cancelled) {
|
||||
setIsGithubRepo(outcome.ok && outcome.result !== null)
|
||||
setRepoProbeLoaded(true)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Why: sendGithubPrRead already normalizes throws, but a cancelled
|
||||
// unmount + any unexpected rejection must not surface as LogBox.
|
||||
if (!cancelled) {
|
||||
setIsGithubRepo(false)
|
||||
setRepoProbeLoaded(true)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
@@ -228,9 +238,12 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
|
||||
|
||||
// Phase-2 only — used when the hub opens the PR segment after a chip-only load.
|
||||
// Uses detailsSeqRef (not loadSeqRef) so it cannot cancel a concurrent soft phase-1.
|
||||
// Retries synthetic placeholders too: a failed phase-2 installs non-null empty
|
||||
// details so Description/Comments leave the spinner, and without this ensure
|
||||
// would never re-fetch on tab re-open.
|
||||
const ensurePrSidebarDetails = useCallback(async () => {
|
||||
const current = stateRef.current
|
||||
if (current.kind !== 'ready' || current.data.details != null) {
|
||||
if (current.kind !== 'ready' || !prSidebarDetailsNeedFetch(current.data.details)) {
|
||||
return
|
||||
}
|
||||
const deps = buildDeps()
|
||||
@@ -303,7 +316,8 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
|
||||
(state.kind !== 'ready' && state.kind !== 'loading')
|
||||
) {
|
||||
void load({ includeDetails: true })
|
||||
} else if (state.kind === 'ready' && state.data.details == null) {
|
||||
} else if (state.kind === 'ready' && prSidebarDetailsNeedFetch(state.data.details)) {
|
||||
// Retries phase-2 placeholders too (failed details load, not only null).
|
||||
void ensurePrSidebarDetails()
|
||||
}
|
||||
}, [identity, state, load, ensurePrSidebarDetails])
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { SourceControlHubTab } from './mobile-source-control-hub-tab'
|
||||
import { buildMobilePrChipSummary, countUnresolvedReviewThreads } from './mobile-pr-chip-summary'
|
||||
import { isMobileConflictAborting } from './mobile-source-control-conflict-abort'
|
||||
import { useMobilePrSidebarController } from '../session/use-mobile-pr-sidebar-controller'
|
||||
import { prSidebarDetailsNeedFetch } from '../session/mobile-pr-sidebar-state'
|
||||
import { MobilePrViewPanelBody } from '../components/pr-sidebar/MobilePrViewPanel'
|
||||
import { openMobilePrUrl } from '../components/MobilePrComposeSheet'
|
||||
|
||||
@@ -177,8 +178,11 @@ export function MobileSourceControlPanel({
|
||||
// none). Keyed by PR number — not a boolean — so a same-branch PR swap during a
|
||||
// chip-only soft refresh re-arms phase 2 for the new PR instead of leaving its
|
||||
// comments on a forever-spinner (a stale ensure bails on the number mismatch).
|
||||
// Placeholder details (failed phase 2) also count as missing so reopening the
|
||||
// PR tab retries instead of leaving empty Description/Comments forever.
|
||||
const prDetailsMissingFor =
|
||||
prController.prSidebarState.kind === 'ready' && prController.prSidebarState.data.details == null
|
||||
prController.prSidebarState.kind === 'ready' &&
|
||||
prSidebarDetailsNeedFetch(prController.prSidebarState.data.details)
|
||||
? prController.prSidebarState.data.pr.number
|
||||
: null
|
||||
|
||||
@@ -299,6 +303,8 @@ export function MobileSourceControlPanel({
|
||||
// Controller + chip state still live for instant re-open without a full cold start.
|
||||
const showPrBody = ready && activeTab === 'pr'
|
||||
const conflictOperation = status?.conflictOperation ?? null
|
||||
// Git status always reports a conflictOperation enum; 'unknown' means none.
|
||||
const hasActiveConflict = conflictOperation != null && conflictOperation !== 'unknown'
|
||||
const conflictAborting = isMobileConflictAborting(busyAction, conflictOperation)
|
||||
|
||||
return (
|
||||
@@ -313,7 +319,11 @@ export function MobileSourceControlPanel({
|
||||
|
||||
<MobileSourceControlSegments active={activeTab} onSelect={selectTab} />
|
||||
|
||||
{ready ? (
|
||||
{/* Branch card + PR chip are the Changes/Commits glance layer. On the PR
|
||||
tab they duplicate the ready PR body (#, state, checks rollup, branch
|
||||
trajectory), so hide the whole card there and let the PR panel own it —
|
||||
unless a merge/rebase conflict is active, which only this card can abort. */}
|
||||
{ready && (activeTab !== 'pr' || hasActiveConflict) ? (
|
||||
<MobileSourceControlBranchCard
|
||||
branchLabel={branchLabel}
|
||||
syncLabel={syncLabel}
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('buildMobileSourceControlActions', () => {
|
||||
expect(labels.some((l) => l.startsWith('Fast-forward'))).toBe(true)
|
||||
expect(labels).toContain('Rebase onto base')
|
||||
expect(labels).toContain('Switch branch')
|
||||
expect(labels).toContain('History')
|
||||
expect(labels).toContain('Commits')
|
||||
expect(labels).toContain('Create PR')
|
||||
})
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('buildMobileSourceControlActions', () => {
|
||||
const handlers = noopHandlers()
|
||||
const actions = buildMobileSourceControlActions(args({ handlers }))
|
||||
action(actions, 'Switch branch')?.onPress()
|
||||
action(actions, 'History')?.onPress()
|
||||
action(actions, 'Commits')?.onPress()
|
||||
expect(handlers.checkout).toHaveBeenCalled()
|
||||
expect(handlers.history).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -217,7 +217,7 @@ export function buildMobileSourceControlActions(
|
||||
onPress: handlers.checkout
|
||||
},
|
||||
{
|
||||
label: 'History',
|
||||
label: 'Commits',
|
||||
iconKey: 'history',
|
||||
disabled: busy,
|
||||
onPress: handlers.history
|
||||
|
||||
@@ -4,29 +4,29 @@ import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
// Styles for the hub's segmented control and the branch-card PR chip. Split from
|
||||
// mobile-source-control-styles.ts so neither file crosses the line limit.
|
||||
export const hubStyles = StyleSheet.create({
|
||||
// Full-width tab bar under the hub header. Edge-to-edge, no inset track and no
|
||||
// inner padding — segments share height evenly so the control doesn't float in a
|
||||
// pill frame with gaps above/below the active cell.
|
||||
segments: {
|
||||
flexDirection: 'row',
|
||||
marginHorizontal: spacing.lg,
|
||||
marginTop: spacing.sm,
|
||||
padding: 3,
|
||||
borderRadius: radii.button + 2,
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.borderSubtle,
|
||||
gap: 2
|
||||
alignItems: 'stretch',
|
||||
width: '100%',
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
segment: {
|
||||
flex: 1,
|
||||
minHeight: 34,
|
||||
borderRadius: radii.button,
|
||||
minHeight: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.xs
|
||||
paddingHorizontal: spacing.xs,
|
||||
// Reserved so active underline doesn't change layout height.
|
||||
borderBottomWidth: 2,
|
||||
borderBottomColor: 'transparent'
|
||||
},
|
||||
segmentActive: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.borderSubtle
|
||||
borderBottomColor: colors.textPrimary
|
||||
},
|
||||
segmentPressed: {
|
||||
opacity: 0.7
|
||||
|
||||
@@ -13,7 +13,7 @@ export const SOURCE_CONTROL_HUB_TABS: readonly SourceControlHubTab[] = [
|
||||
export const SOURCE_CONTROL_HUB_TAB_LABELS: Record<SourceControlHubTab, string> = {
|
||||
changes: 'Changes',
|
||||
pr: 'Pull Request',
|
||||
history: 'History'
|
||||
history: 'Commits'
|
||||
}
|
||||
|
||||
// Normalize a route param (possibly an array from expo-router, possibly unknown)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { headlessActivationNeedsHostRenderer } from './worktree-activation-result'
|
||||
|
||||
describe('headlessActivationNeedsHostRenderer', () => {
|
||||
it('only reports the explicit sleeping-agent headless outcome', () => {
|
||||
expect(headlessActivationNeedsHostRenderer({ sleepingAgentWake: 'unsupported-headless' })).toBe(
|
||||
true
|
||||
)
|
||||
expect(headlessActivationNeedsHostRenderer({ sleepingAgentWake: 'not-applicable' })).toBe(false)
|
||||
expect(headlessActivationNeedsHostRenderer({ sleepingAgentWake: 'requested' })).toBe(false)
|
||||
expect(headlessActivationNeedsHostRenderer(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
export function headlessActivationNeedsHostRenderer(result: unknown): boolean {
|
||||
return Boolean(
|
||||
result &&
|
||||
typeof result === 'object' &&
|
||||
'sleepingAgentWake' in result &&
|
||||
result.sleepingAgentWake === 'unsupported-headless'
|
||||
)
|
||||
}
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.131-rc.2",
|
||||
"version": "1.4.132-rc.0",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
@@ -82,6 +82,7 @@
|
||||
"test:e2e:terminal-perf:check-report": "node config/scripts/check-terminal-perf-report-budgets.mjs",
|
||||
"test:e2e:terminal-perf:summarize": "node config/scripts/summarize-terminal-perf-report.mjs",
|
||||
"test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs",
|
||||
"test:e2e:source-control-scale": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/source-control-large-file-count.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"win-update-e2e": "node tools/win-update-e2e/run.mjs",
|
||||
"test:e2e:ssh-codex-artifacts-repro": "node config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs",
|
||||
"test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful",
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# Prod Release Scan — output.md
|
||||
|
||||
**Verdict:** ready with accepted P1 risk
|
||||
**Span:** `v1.4.130` (`31378023`) .. `v1.4.131-rc.2` (`6964bfb23`)
|
||||
**Planned release / RC:** `1.4.131-rc.2` (user asked for 1.4.132-rc2; that tag does not exist — scanned confirmed substitute)
|
||||
**Included PRs:** 8 matched (+ `#7567` referenced only in `#7658` title, not a separate PR); unmatched commits: 5
|
||||
**Batches reviewed:** 6 (agent/mobile, codex/speech/WSL, UI defaults, xplat/remoting, perf, security)
|
||||
**Security model:** `codex --model gpt-5.6-sol -c model_reasoning_effort="xhigh"` (W6 + all workers)
|
||||
**Orchestration:** 6 same-worktree tasks injected; `task-list`/`dispatch-show` verified
|
||||
|
||||
---
|
||||
|
||||
## Issue table by PR / commit
|
||||
|
||||
### Has issues
|
||||
|
||||
| Ref | Title | Severity | Issue summary |
|
||||
|-----|-------|----------|---------------|
|
||||
| **#7906** | Wake slept agents when opening a worktree on mobile | **P1** (×3) | (1) Non-passive / manual-sleep wake on an **unmounted** worktree creates a tab with `activate:false` but never background-mounts → **no PTY / resume lost**. (2) Mounted manual-sleep can **double-resume** the same provider session (in-place wake + generic resume). (3) Edge-triggered wake can be **lost** if mobile opens during hibernation kill before `hibernatedWakePtyId` is armed. |
|
||||
| **#7906** | (perf composition) | **P1** | Passive completed wake background-mounts the **entire** worktree → O(all saved tabs) PTY/xterm work, defeating unvisited-worktree mass-spawn guard. |
|
||||
| **#7906** | (headless remoting) | **P1** | Wake is a no-op without a live renderer window; headless `orca serve --mobile-pairing` activates without waking slept agents. |
|
||||
| **#7960** | Write in-Codex setting changes back to `~/.codex` | **P1** (×2–3) | (1) Missing `~/.codex/` parent → `ENOENT` on promote, then mirror **wipes** runtime setting (test pre-creates dir and misses this). (2) `writeFileAtomically` without `mode` widens **0600 → 0644** on promote (reproduced). (3) Host-only promotion path: **WSL Codex never promotes** (WSL prep skips `syncSystemConfigIntoManagedCodexHome`). |
|
||||
| **#7658** | Recover WSL Codex usage RPC refresh | **P2** | Every WSL quota poll uses interactive login shell (`-ilc` + discovery chain); heavy rc cost on 15m poll / double on PTY fallback. |
|
||||
| **#7977** | Disable experimental new worktree card style by default | **P2** | Fresh default is correctly off, but cohort **auto-enabled in v1.4.130** stays on via persisted `true` (no rollback migration). |
|
||||
| **#7845** | Sidebar scrollbar only when overflow | **P2** (docs only) | Behavior OK; `docs/STYLEGUIDE.md` still says gutter is reserved — stale contract. |
|
||||
|
||||
### No issues found (clean for this scan)
|
||||
|
||||
| Ref | Title | Notes |
|
||||
|-----|-------|-------|
|
||||
| **#7860** | Separate pane identity from liveness (OMP tab flicker) | Identity vs liveness separation consistent across tab icon, PTY owner, web mirror; focused-pane owner re-owns Pi-compatible titles; no proven regression. |
|
||||
| **#7932** | Chunk offline audio decoding (ONNX SIGTRAP) | 30s chunk bound + quiet split; worker-side decode; pure chunker tests pass; no malware/native dep delta. Residual: no packaged ONNX runtime on all platforms. |
|
||||
| **#7933** | iOS native keyboard dictation in mobile terminal | Raw field echo; normalize only on send/mirror; no double-send proven. Mobile vitest not run (missing Expo deps in this worktree). |
|
||||
| `775fa956c` | Remove mobile driver overlay preview | Delete-only HTML mock. |
|
||||
| `8adfef4ff` | Update README downloads badge | `4.0m` → `4.1m` only. |
|
||||
| `ff661a7ab` / `41edc493b` / `6964bfb23` | release: v1.4.131-rc.{0,1,2} | Version bumps in `package.json` only. |
|
||||
|
||||
---
|
||||
|
||||
## Findings first
|
||||
|
||||
### P0 — Release Blocking
|
||||
|
||||
None.
|
||||
|
||||
No malware, backdoor, supply-chain implant, lockfile/dependency implant, install-hook change, or updater/signing bypass in this span.
|
||||
|
||||
### P1 — Releasable Only With Explicit Acceptance
|
||||
|
||||
1. **#7906 Mobile wake — unmounted non-passive sleep never gets a PTY**
|
||||
`wake-sleeping-agents-in-background.ts:43-56` only background-mounts for passive completed records; non-passive path creates inactive tab and clears the sleep record without a mounted `TerminalPane`.
|
||||
*Evidence:* `wake-sleeping-agents-in-background.ts`, `resume-sleeping-agent-session.ts:93-118`, `Terminal.tsx:729-778`.
|
||||
|
||||
2. **#7906 Mobile wake — double resume of same provider session**
|
||||
Broadcast in-place wake then generic resume for non-passive mounted records before spawn clears the record.
|
||||
*Evidence:* `wake-sleeping-agents-in-background.ts:37-56`, `pty-connection.ts:6247-6255`, `resume-sleeping-agent-session.ts:279-315`.
|
||||
|
||||
3. **#7906 Mobile wake — race during hibernation kill**
|
||||
Wake before `hibernatedWakePtyId` is armed is edge-triggered and not retried; passive path keeps record without launching.
|
||||
*Evidence:* `pty-connection.ts:2090-2169`, `orca-runtime.ts` fire-and-forget notifier.
|
||||
|
||||
4. **#7906 Perf — whole-worktree background mount**
|
||||
One passive sleep record mounts every terminal tab in the worktree permanently.
|
||||
*Evidence:* `wake-sleeping-agents-in-background.ts:46-54`, `background-terminal-worktree-visibility.ts`, `Terminal.tsx` mount model.
|
||||
|
||||
5. **#7906 Headless mobile pairing — wake no-op**
|
||||
No renderer ⇒ no wake; headless serve still returns activated.
|
||||
*Evidence:* `orca-runtime.ts:12627-12633`, `orca-runtime.test.ts` “deliberate non-goal”, `mobile/scripts/start-emulator-pairing-runtime.mjs`.
|
||||
|
||||
6. **#7960 Missing `~/.codex` directory loses first promoted setting**
|
||||
Promote does not `mkdir` system home; `ENOENT` swallowed; mirror then clears runtime value. Test pre-creates `.codex` and misses the case.
|
||||
*Evidence:* `config-settings-promotion.ts:191-192`, `fs-utils.ts:6-14`, `config-settings-promotion.test.ts:190-191`. Reproduced: missing parent → `ENOENT`.
|
||||
|
||||
7. **#7960 Permission widening on promote (privacy)**
|
||||
Atomic write without mode: `0600` → `0644` under umask `0022` (reproduced). Full user config becomes world-readable to co-tenants when parent dirs are traversable.
|
||||
*Evidence:* `config-settings-promotion.ts:191-192`, `fs-utils.ts:11-14`.
|
||||
|
||||
8. **#7960 WSL never gets promotion**
|
||||
WSL launch/rate-limit prep returns after `syncWslRuntimeForCurrentSelection` and never calls host mirror/promotion. Feature is host-only.
|
||||
*Evidence:* `runtime-home-service.ts:139-150,225-235`.
|
||||
|
||||
### P2 — Nice To Have
|
||||
|
||||
- **#7658** Interactive login shell on every WSL rate-limit poll (`buildWslLoginShellCommand` / `-ilc`).
|
||||
- **#7960** Unchanged baseline still rewritten twice per quota cycle; CRLF insert can mix EOL on new keys.
|
||||
- **#7977** Auto-enabled experimental card cohort not rolled back.
|
||||
- **#7845** STYLEGUIDE scrollbar gutter text out of date.
|
||||
|
||||
---
|
||||
|
||||
## Clean matrix
|
||||
|
||||
| Area | Status |
|
||||
|------|--------|
|
||||
| Security / malware / supply-chain | **Clean of malice.** Lockfile unchanged; package scripts unchanged; version-only package.json. **P1 residual:** mode widen on `~/.codex/config.toml` promote. |
|
||||
| Performance / resource use | **P1** mobile whole-worktree mount; **P2** baseline double-write + WSL interactive poll cost. STT chunking is an improvement. |
|
||||
| macOS / Linux / Windows / WSL / PowerShell | **P1** WSL promotion gap; **P2** WSL interactive poll; CRLF insert. No new hard-coded Mac shortcuts. Real Win/WSL runtime not executed. |
|
||||
| Mobile / paired web / Electron | **P1** mobile wake correctness/composition gaps; #7860 / #7933 clean by source audit. |
|
||||
| Local / SSH / remote / VM / container | Wake path failures apply to local and SSH worktrees once host renderer is involved. Headless serve gap explicit. |
|
||||
| Git providers / shared contracts | Not materially touched. |
|
||||
| Release / package / update path | Version bumps only; no workflow/signing/native rebuild changes. |
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| `git diff --check v1.4.130..v1.4.131-rc.2` | Passed |
|
||||
| Required security grep on non-lockfile span | 2 hits — both TOML `.exec` parsers in #7960; inspected, benign |
|
||||
| `package.json` / lockfile | Version field only; `pnpm-lock.yaml` no diff |
|
||||
| Mode repro (`writeFileSync` without mode) | `before 600` → `after 644` |
|
||||
| Missing parent repro | `ENOENT` |
|
||||
| Worker unit tests (various focused suites) | Reported green by workers (promotion, STT chunker, rate-limit, wake helpers, pane owner, etc.) |
|
||||
| Mobile Expo vitest / device / real WSL / ONNX packaged | **Not run** in this environment |
|
||||
|
||||
---
|
||||
|
||||
## Orchestration provenance
|
||||
|
||||
| Worker | Task | Terminal | Report |
|
||||
|--------|------|----------|--------|
|
||||
| W1 agent/mobile | `task_20d8fd2460cc` | `term_40e66a33-…` | `reports/w1-agent-mobile.md` |
|
||||
| W2 codex/speech/WSL | `task_6e280ce1760e` | `term_59f3ead8-…` | `reports/w2-codex-speech-wsl.md` |
|
||||
| W3 UI/defaults | `task_7140cb2014c9` | `term_4e274a95-…` | `reports/w3-ui-defaults.md` |
|
||||
| W4 xplat/remoting | `task_424816ffe823` | `term_03c7aac8-…` | `reports/w4-xplat-remoting.md` |
|
||||
| W5 perf | `task_1c5ac42d7659` | `term_24778551-…` | `reports/w5-perf.md` |
|
||||
| W6 security | `task_bf5de6f4913a` | `term_ae19a68d-…` | `reports/w6-security.md` |
|
||||
|
||||
All workers: `codex --model gpt-5.6-sol -c model_reasoning_effort="xhigh"`, same worktree, `--inject` dispatch.
|
||||
|
||||
---
|
||||
|
||||
## Residual risk
|
||||
|
||||
- No runtime on physical iOS/Android, Windows+WSL2, or packaged sherpa/ONNX for all platforms.
|
||||
- Lockfile/binary deltas fully inspected: **none**. Install hooks unchanged.
|
||||
- #7906 integration of IPC wake × mounted/unmounted panes × async PTY exit is under-tested relative to risk.
|
||||
- #7960 Windows ACL inheritance and concurrent external `~/.codex` edits not runtime-tested; POSIX mode widen is proven.
|
||||
|
||||
---
|
||||
|
||||
## Bottom line
|
||||
|
||||
**No P0.** Multiple **P1**s cluster on **#7906 (mobile wake)** and **#7960 (Codex settings promotion)**. Ship only if product explicitly accepts those gaps, or fix before stable:
|
||||
|
||||
1. Always background-mount (or otherwise attach) when creating a navigation-suppressed resume tab.
|
||||
2. Deduplicate in-place hibernation wake vs generic resume for the same claim key.
|
||||
3. `mkdir` system `~/.codex` before promote; pass `mode: 0o600` (or preserve existing mode) into `writeFileAtomically`.
|
||||
4. Decide WSL + headless pairing product stance (implement promotion/wake or document as unsupported).
|
||||
|
||||
Clean shippable areas for this span: **#7860**, **#7932**, **#7933**, release version commits, README badge, preview delete; **#7845** functionally clean with docs P2.
|
||||
@@ -0,0 +1,82 @@
|
||||
import { createCipheriv, pbkdf2Sync } from 'node:crypto'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { join } from 'node:path'
|
||||
|
||||
type ChromiumCookieTestRow = {
|
||||
name: string
|
||||
value: string
|
||||
encryptedValue?: Buffer
|
||||
}
|
||||
|
||||
export function createChromiumCookieTestDatabase(
|
||||
databasePath: string,
|
||||
rows: ChromiumCookieTestRow[],
|
||||
options: { journalMode?: 'wal' } = {}
|
||||
): DatabaseSync {
|
||||
mkdirSync(join(databasePath, '..'), { recursive: true })
|
||||
const database = new DatabaseSync(databasePath)
|
||||
if (options.journalMode === 'wal') {
|
||||
database.exec('PRAGMA journal_mode = WAL; PRAGMA wal_autocheckpoint = 0')
|
||||
}
|
||||
database.exec(`
|
||||
CREATE TABLE cookies (
|
||||
creation_utc INTEGER NOT NULL,
|
||||
host_key TEXT NOT NULL,
|
||||
top_frame_site_key TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
encrypted_value BLOB NOT NULL DEFAULT X'',
|
||||
path TEXT NOT NULL,
|
||||
expires_utc INTEGER NOT NULL,
|
||||
is_secure INTEGER NOT NULL,
|
||||
is_httponly INTEGER NOT NULL,
|
||||
samesite INTEGER NOT NULL,
|
||||
source_scheme INTEGER NOT NULL DEFAULT 0,
|
||||
source_port INTEGER NOT NULL DEFAULT -1,
|
||||
last_update_utc INTEGER NOT NULL DEFAULT 0,
|
||||
has_cross_site_ancestor INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(host_key, top_frame_site_key, name, path, source_scheme, source_port)
|
||||
)
|
||||
`)
|
||||
const insert = database.prepare(`
|
||||
INSERT INTO cookies (
|
||||
creation_utc,
|
||||
host_key,
|
||||
top_frame_site_key,
|
||||
name,
|
||||
value,
|
||||
encrypted_value,
|
||||
path,
|
||||
expires_utc,
|
||||
is_secure,
|
||||
is_httponly,
|
||||
samesite,
|
||||
source_scheme,
|
||||
source_port,
|
||||
last_update_utc,
|
||||
has_cross_site_ancestor
|
||||
) VALUES (?, ?, '', ?, ?, ?, '/', 0, 0, 0, 0, 0, -1, ?, 0)
|
||||
`)
|
||||
rows.forEach((row, index) => {
|
||||
insert.run(
|
||||
133_000_000_000_000 + index,
|
||||
'.example.com',
|
||||
row.name,
|
||||
row.value,
|
||||
row.encryptedValue ?? Buffer.alloc(0),
|
||||
0
|
||||
)
|
||||
})
|
||||
return database
|
||||
}
|
||||
|
||||
export function encryptMacChromiumCookie(value: string, password: string): Buffer {
|
||||
const key = pbkdf2Sync(password, 'saltysalt', 1003, 16, 'sha1')
|
||||
const cipher = createCipheriv('aes-128-cbc', key, Buffer.alloc(16, ' '))
|
||||
return Buffer.concat([
|
||||
Buffer.from('v10'),
|
||||
cipher.update(Buffer.from(value, 'latin1')),
|
||||
cipher.final()
|
||||
])
|
||||
}
|
||||
@@ -1,11 +1,34 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as NodeFs from 'node:fs'
|
||||
|
||||
const { sessionFromPartitionMock, dialogShowOpenDialogMock } = vi.hoisted(() => ({
|
||||
const {
|
||||
appGetPathMock,
|
||||
copyFileSyncMock,
|
||||
execFileSyncMock,
|
||||
sessionFromPartitionMock,
|
||||
dialogShowOpenDialogMock
|
||||
} = vi.hoisted(() => ({
|
||||
appGetPathMock: vi.fn(),
|
||||
copyFileSyncMock: vi.fn(),
|
||||
execFileSyncMock: vi.fn(),
|
||||
sessionFromPartitionMock: vi.fn(),
|
||||
dialogShowOpenDialogMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:child_process', () => ({ execFileSync: execFileSyncMock }))
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeFs>()
|
||||
return {
|
||||
...actual,
|
||||
copyFileSync: (...args: Parameters<typeof actual.copyFileSync>) => {
|
||||
copyFileSyncMock(...args)
|
||||
return actual.copyFileSync(...args)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: appGetPathMock },
|
||||
BrowserWindow: { fromWebContents: vi.fn() },
|
||||
dialog: { showOpenDialog: dialogShowOpenDialogMock },
|
||||
session: { fromPartition: sessionFromPartitionMock }
|
||||
@@ -20,10 +43,26 @@ import {
|
||||
type ChromiumCookieColumnInfo,
|
||||
type DetectedBrowser
|
||||
} from './browser-cookie-import'
|
||||
import { writeFileSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import {
|
||||
createChromiumCookieTestDatabase,
|
||||
encryptMacChromiumCookie
|
||||
} from './browser-cookie-import-test-database'
|
||||
import { existsSync, writeFileSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
|
||||
function chromeBrowser(cookiesPath: string): DetectedBrowser {
|
||||
return {
|
||||
family: 'chrome',
|
||||
label: 'Google Chrome',
|
||||
cookiesPath,
|
||||
keychainService: 'Chrome Safe Storage',
|
||||
keychainAccount: 'Chrome',
|
||||
profiles: [{ name: 'Default', directory: 'Default' }],
|
||||
selectedProfile: 'Default'
|
||||
}
|
||||
}
|
||||
|
||||
const LARGE_SAFARI_COOKIE_COUNT = 150_000
|
||||
|
||||
describe('summarizeCookieImportError', () => {
|
||||
@@ -327,6 +366,147 @@ describe('importCookiesFromBrowser Safari', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('importCookiesFromBrowser Chromium', () => {
|
||||
let tmpDir: string
|
||||
let cookiesSetMock: ReturnType<typeof vi.fn>
|
||||
let cookiesRemoveMock: ReturnType<typeof vi.fn>
|
||||
let cookiesFlushStoreMock: ReturnType<typeof vi.fn>
|
||||
let clearStorageDataMock: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'orca-chromium-cookie-test-'))
|
||||
cookiesSetMock = vi.fn().mockResolvedValue(undefined)
|
||||
cookiesRemoveMock = vi.fn().mockResolvedValue(undefined)
|
||||
cookiesFlushStoreMock = vi.fn().mockResolvedValue(undefined)
|
||||
clearStorageDataMock = vi.fn().mockResolvedValue(undefined)
|
||||
appGetPathMock.mockReset()
|
||||
appGetPathMock.mockReturnValue(join(tmpDir, 'userData'))
|
||||
copyFileSyncMock.mockClear()
|
||||
execFileSyncMock.mockReset()
|
||||
execFileSyncMock.mockImplementation(() => {
|
||||
throw new Error('OS credential commands are unavailable in this test')
|
||||
})
|
||||
sessionFromPartitionMock.mockReset()
|
||||
sessionFromPartitionMock.mockReturnValue({
|
||||
cookies: {
|
||||
set: cookiesSetMock,
|
||||
remove: cookiesRemoveMock,
|
||||
flushStore: cookiesFlushStoreMock
|
||||
},
|
||||
clearStorageData: clearStorageDataMock
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('imports from a live Chromium source DB into a Network/Cookies target profile', async () => {
|
||||
const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
|
||||
const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies')
|
||||
// Why: keeping the WAL writer open proves import reads Chromium's live SQLite
|
||||
// state instead of relying on a closed-file snapshot.
|
||||
const sourceDb = createChromiumCookieTestDatabase(
|
||||
sourceCookiesPath,
|
||||
[{ name: 'sid', value: 'source-value' }],
|
||||
{ journalMode: 'wal' }
|
||||
)
|
||||
createChromiumCookieTestDatabase(targetCookiesPath, [
|
||||
{ name: 'old', value: 'target-value' }
|
||||
]).close()
|
||||
|
||||
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
try {
|
||||
expect(existsSync(`${sourceCookiesPath}-wal`)).toBe(true)
|
||||
|
||||
const result = await importCookiesFromBrowser(
|
||||
chromeBrowser(sourceCookiesPath),
|
||||
'persist:test'
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(cookiesSetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
domain: '.example.com',
|
||||
name: 'sid',
|
||||
value: 'source-value'
|
||||
})
|
||||
)
|
||||
expect(execFileSyncMock.mock.calls.some(([command]) => command === 'security')).toBe(false)
|
||||
expect(copyFileSyncMock.mock.calls.some(([source]) => source === sourceCookiesPath)).toBe(
|
||||
false
|
||||
)
|
||||
expect(cookiesRemoveMock).not.toHaveBeenCalled()
|
||||
expect(clearStorageDataMock).toHaveBeenCalledWith({ storages: ['cookies'] })
|
||||
} finally {
|
||||
platformSpy.mockRestore()
|
||||
sourceDb.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the OS key for encrypted Chromium rows', async () => {
|
||||
const password = 'test-password'
|
||||
const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
|
||||
const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies')
|
||||
createChromiumCookieTestDatabase(sourceCookiesPath, [
|
||||
{
|
||||
name: 'sid',
|
||||
value: '',
|
||||
encryptedValue: encryptMacChromiumCookie('encrypted-value', password)
|
||||
}
|
||||
]).close()
|
||||
createChromiumCookieTestDatabase(targetCookiesPath, []).close()
|
||||
execFileSyncMock.mockImplementation((command: string) => {
|
||||
if (command === 'security') {
|
||||
return `${password}\n`
|
||||
}
|
||||
throw new Error(`Unexpected command: ${command}`)
|
||||
})
|
||||
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
|
||||
try {
|
||||
const result = await importCookiesFromBrowser(
|
||||
chromeBrowser(sourceCookiesPath),
|
||||
'persist:test'
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(execFileSyncMock).toHaveBeenCalledWith(
|
||||
'security',
|
||||
expect.any(Array),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(cookiesSetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'sid', value: 'encrypted-value' })
|
||||
)
|
||||
} finally {
|
||||
platformSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('removes staging data when the OS key is unavailable', async () => {
|
||||
const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
|
||||
const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies')
|
||||
createChromiumCookieTestDatabase(sourceCookiesPath, [
|
||||
{ name: 'sid', value: '', encryptedValue: Buffer.from('v10-encrypted') }
|
||||
]).close()
|
||||
createChromiumCookieTestDatabase(targetCookiesPath, []).close()
|
||||
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
|
||||
try {
|
||||
const result = await importCookiesFromBrowser(
|
||||
chromeBrowser(sourceCookiesPath),
|
||||
'persist:test'
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect(readdirSync(join(tmpDir, 'userData', 'cookie-import-staging'))).toEqual([])
|
||||
} finally {
|
||||
platformSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectInstalledBrowsers', () => {
|
||||
it('returns an array of detected browsers', () => {
|
||||
const browsers = detectInstalledBrowsers()
|
||||
|
||||
@@ -78,6 +78,7 @@ import type {
|
||||
} from '../../shared/types'
|
||||
import { browserSessionRegistry } from './browser-session-registry'
|
||||
import { setupClientHintsOverride } from './browser-session-ua'
|
||||
import { resolveChromiumCookiesPath } from './chromium-cookie-path'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Browser detection
|
||||
@@ -192,20 +193,6 @@ function browserRootPath(def: ChromiumBrowserDef): string | null {
|
||||
return join(configHome, def.linuxRoot)
|
||||
}
|
||||
|
||||
// Why: Chromium 96+ moved the cookies DB from <Profile>/Cookies to
|
||||
// <Profile>/Network/Cookies. Try the newer path first, fall back to legacy.
|
||||
function resolveCookiesPath(profileDir: string): string | null {
|
||||
const networkPath = join(profileDir, 'Network', 'Cookies')
|
||||
if (existsSync(networkPath)) {
|
||||
return networkPath
|
||||
}
|
||||
const legacyPath = join(profileDir, 'Cookies')
|
||||
if (existsSync(legacyPath)) {
|
||||
return legacyPath
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function isSafeBrowserProfileDirectory(directory: string): boolean {
|
||||
return (
|
||||
directory.length > 0 &&
|
||||
@@ -373,7 +360,7 @@ export function detectInstalledBrowsers(): DetectedBrowser[] {
|
||||
// Use the first profile with a valid cookies path as the default selection.
|
||||
for (const profile of profiles) {
|
||||
const profileDir = join(root, profile.directory)
|
||||
const cookiesPath = resolveCookiesPath(profileDir)
|
||||
const cookiesPath = resolveChromiumCookiesPath(profileDir)
|
||||
if (cookiesPath) {
|
||||
detected.push({
|
||||
family: browser.family,
|
||||
@@ -434,7 +421,7 @@ export function selectBrowserProfile(
|
||||
return null
|
||||
}
|
||||
const profileDir = join(root, profileDirectory)
|
||||
const cookiesPath = resolveCookiesPath(profileDir)
|
||||
const cookiesPath = resolveChromiumCookiesPath(profileDir)
|
||||
if (!cookiesPath) {
|
||||
return null
|
||||
}
|
||||
@@ -1468,36 +1455,8 @@ export async function importCookiesFromBrowser(
|
||||
return importCookiesFromSafari(browser, targetPartition)
|
||||
}
|
||||
|
||||
// Why: the browser may hold a lock on the Cookies file. Copying to a temp
|
||||
// location avoids lock contention and ensures we read a consistent snapshot.
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-import-'))
|
||||
const tmpCookiesPath = join(tmpDir, 'Cookies')
|
||||
|
||||
try {
|
||||
copyFileSync(browser.cookiesPath, tmpCookiesPath)
|
||||
// Why: when the source browser is running, it uses WAL journal mode. The most
|
||||
// recently written cookies (including fresh auth tokens) may only exist in the
|
||||
// WAL sidecar file, not yet flushed to the main DB. Copying WAL + SHM ensures
|
||||
// our snapshot reflects the browser's current state.
|
||||
for (const suffix of ['-wal', '-shm'] as const) {
|
||||
const sidecar = browser.cookiesPath + suffix
|
||||
if (existsSync(sidecar)) {
|
||||
try {
|
||||
copyFileSync(sidecar, tmpCookiesPath + suffix)
|
||||
} catch {
|
||||
// Why: sidecar copy is best-effort. The main DB alone may still have
|
||||
// enough cookies for a usable session; missing the WAL just means
|
||||
// we might miss the very latest writes.
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Could not copy ${browser.label} cookies database. Try closing ${browser.label} first.`
|
||||
}
|
||||
}
|
||||
// Why: SQLite can coordinate with Chromium's live WAL locks without a pre-copy.
|
||||
const sourceCookiesPath = browser.cookiesPath
|
||||
|
||||
// Why: Electron's cookies.set() API rejects many valid cookie values (binary
|
||||
// bytes > 0x7F etc). Instead, decrypt from the source browser and write
|
||||
@@ -1508,16 +1467,6 @@ export async function importCookiesFromBrowser(
|
||||
// In packaged builds where os_crypt IS active, CookieMonster will re-encrypt
|
||||
// plaintext cookies on its next flush, so this approach is safe in both modes.
|
||||
|
||||
// Why: only Chromium browsers reach this point — Firefox/Safari dispatched above.
|
||||
const sourceKey = getEncryptionKey(browser.keychainService!, browser.keychainAccount!, browser)
|
||||
if (!sourceKey) {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Could not access ${browser.label} encryption key. The OS may have denied access.`
|
||||
}
|
||||
}
|
||||
|
||||
// Why: CookieMonster holds the live DB's data in memory and overwrites it
|
||||
// on flush/shutdown. Writing directly to the live DB is futile. Instead,
|
||||
// copy the live DB to a staging location, populate it there, and let the
|
||||
@@ -1526,13 +1475,14 @@ export async function importCookiesFromBrowser(
|
||||
await targetSession.cookies.flushStore()
|
||||
|
||||
const partitionName = targetPartition.replace('persist:', '')
|
||||
const liveCookiesPath = join(app.getPath('userData'), 'Partitions', partitionName, 'Cookies')
|
||||
const partitionDir = join(app.getPath('userData'), 'Partitions', partitionName)
|
||||
let liveCookiesPath = resolveChromiumCookiesPath(partitionDir)
|
||||
|
||||
// Why: Electron only creates the partition's Cookies SQLite file after the
|
||||
// session has actually stored a cookie. For newly created profiles that have
|
||||
// never been used by a webview, the file won't exist yet. Setting and
|
||||
// removing a throwaway cookie forces Electron to initialize the database.
|
||||
if (!existsSync(liveCookiesPath)) {
|
||||
if (!liveCookiesPath) {
|
||||
try {
|
||||
await targetSession.cookies.set({ url: 'https://localhost', name: '__init', value: '1' })
|
||||
await targetSession.cookies.remove('https://localhost', '__init')
|
||||
@@ -1540,10 +1490,10 @@ export async function importCookiesFromBrowser(
|
||||
} catch {
|
||||
// ignore — the set/remove may fail but flushStore should still create the file
|
||||
}
|
||||
liveCookiesPath = resolveChromiumCookiesPath(partitionDir)
|
||||
}
|
||||
|
||||
if (!existsSync(liveCookiesPath)) {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
if (!liveCookiesPath) {
|
||||
return { ok: false, reason: 'Target cookie database not found. Open a browser tab first.' }
|
||||
}
|
||||
|
||||
@@ -1557,7 +1507,6 @@ export async function importCookiesFromBrowser(
|
||||
mkdirSync(stagingDir, { recursive: true })
|
||||
copyFileSync(liveCookiesPath, stagingCookiesPath)
|
||||
} catch {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
return { ok: false, reason: 'Could not create staging cookie database.' }
|
||||
}
|
||||
|
||||
@@ -1567,7 +1516,7 @@ export async function importCookiesFromBrowser(
|
||||
try {
|
||||
// Why: Chromium stores timestamps as microseconds since 1601, which can exceed
|
||||
// Number.MAX_SAFE_INTEGER (~9e15). readBigInts ensures no precision loss.
|
||||
sourceDb = new DatabaseSync(tmpCookiesPath, { readOnly: true, readBigInts: true })
|
||||
sourceDb = new DatabaseSync(sourceCookiesPath, { readOnly: true, readBigInts: true })
|
||||
stagingDb = new DatabaseSync(stagingCookiesPath)
|
||||
|
||||
const targetColumnInfo = stagingDb
|
||||
@@ -1590,10 +1539,37 @@ export async function importCookiesFromBrowser(
|
||||
if (sourceRows.length === 0) {
|
||||
stagingDb.close()
|
||||
stagingDb = null
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
try {
|
||||
unlinkSync(stagingCookiesPath)
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
return { ok: false, reason: `No cookies found in ${browser.label}.` }
|
||||
}
|
||||
|
||||
const needsSourceKey = sourceRows.some((sourceRow) => {
|
||||
const encRaw = sourceRow.encrypted_value
|
||||
return encRaw instanceof Uint8Array && encRaw.length > 0
|
||||
})
|
||||
const sourceKey = needsSourceKey
|
||||
? getEncryptionKey(browser.keychainService!, browser.keychainAccount!, browser)
|
||||
: null
|
||||
if (needsSourceKey && !sourceKey) {
|
||||
stagingDb.close()
|
||||
stagingDb = null
|
||||
// Why: key denial happens after staging, so failed retries must not leave
|
||||
// one full target database copy behind each time.
|
||||
try {
|
||||
unlinkSync(stagingCookiesPath)
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Could not access ${browser.label} encryption key. The OS may have denied access.`
|
||||
}
|
||||
}
|
||||
|
||||
// Why: Google's integrity cookies (SIDCC, __Secure-*PSIDCC, __Secure-STRP)
|
||||
// are cryptographically bound to the source browser's TLS fingerprint and
|
||||
// environment. Importing them into a different browser causes
|
||||
@@ -1652,7 +1628,7 @@ export async function importCookiesFromBrowser(
|
||||
|
||||
let decryptedValue: Buffer
|
||||
if (encBuf && encBuf.length > 0) {
|
||||
const raw = decryptCookieValueRaw(encBuf, sourceKey)
|
||||
const raw = sourceKey ? decryptCookieValueRaw(encBuf, sourceKey) : null
|
||||
if (!raw) {
|
||||
skipped++
|
||||
continue
|
||||
@@ -1709,7 +1685,6 @@ export async function importCookiesFromBrowser(
|
||||
stagingDb.close()
|
||||
stagingDb = null
|
||||
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
diag(` SQLite staging complete: ${imported} cookies, ${domainSet.size} domains`)
|
||||
|
||||
// Why: clearing the session's in-memory cookie store before loading imported
|
||||
@@ -1795,7 +1770,6 @@ export async function importCookiesFromBrowser(
|
||||
} catch {
|
||||
/* may already be closed */
|
||||
}
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
// Why: if the import fails after the staging DB was created, clean it up
|
||||
// to avoid a stale staged import being applied on the next cold start.
|
||||
try {
|
||||
|
||||
@@ -157,6 +157,31 @@ describe('BrowserSessionRegistry persistence', () => {
|
||||
expect(fsState.present.has('/user-data/Partitions/orca-browser/Cookies')).toBe(true)
|
||||
})
|
||||
|
||||
it('replays pending cookies into an existing Network database', async () => {
|
||||
const stagedPath = '/staged/network-import'
|
||||
const networkPath = '/user-data/Partitions/orca-browser/Network/Cookies'
|
||||
const legacyPath = '/user-data/Partitions/orca-browser/Cookies'
|
||||
const fsState = createFsState()
|
||||
seedMeta(fsState, {
|
||||
defaultSource: null,
|
||||
userAgent: null,
|
||||
pendingCookieDbPath: stagedPath,
|
||||
profiles: []
|
||||
})
|
||||
fsState.files.set(stagedPath, 'imported cookies')
|
||||
fsState.files.set(networkPath, 'old cookies')
|
||||
fsState.present.add(stagedPath)
|
||||
fsState.present.add(networkPath)
|
||||
|
||||
installModuleMocks(fsState)
|
||||
const { browserSessionRegistry } = await import('./browser-session-registry')
|
||||
|
||||
browserSessionRegistry.applyPendingCookieImport()
|
||||
|
||||
expect(fsState.files.get(networkPath)).toBe('imported cookies')
|
||||
expect(fsState.present.has(legacyPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('persists new browser session profiles under the active Orca profile directory', async () => {
|
||||
const fsState = createFsState()
|
||||
const profileMetaPath = '/user-data/profiles/local-work/browser-session-meta.json'
|
||||
|
||||
@@ -26,6 +26,7 @@ import type { BrowserSessionProfile, BrowserSessionProfileScope } from '../../sh
|
||||
import { browserManager } from './browser-manager'
|
||||
import { hasSystemMediaAccess, requestSystemMediaAccess } from './browser-media-access'
|
||||
import { cleanElectronUserAgent, setupClientHintsOverride } from './browser-session-ua'
|
||||
import { resolveChromiumCookiesPath } from './chromium-cookie-path'
|
||||
import { isAutoGrantedBrowserSessionPermission } from './browser-session-permission-policy'
|
||||
import {
|
||||
allowsBrowserWebAuthnPermission,
|
||||
@@ -101,7 +102,10 @@ class BrowserSessionRegistry {
|
||||
|
||||
private static partitionCookiesPath(partition: string): string {
|
||||
const partitionName = partition.replace('persist:', '')
|
||||
return join(app.getPath('userData'), 'Partitions', partitionName, 'Cookies')
|
||||
const partitionDir = join(app.getPath('userData'), 'Partitions', partitionName)
|
||||
// Why: replay must overwrite the same modern or legacy database that the
|
||||
// importing Electron partition already uses.
|
||||
return resolveChromiumCookiesPath(partitionDir) ?? join(partitionDir, 'Cookies')
|
||||
}
|
||||
|
||||
// Why: write-to-temp-then-rename is atomic on all supported platforms.
|
||||
|
||||
@@ -89,6 +89,148 @@ describe('CdpWsProxy', () => {
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('routes Playwright nested browser and page sessions on the proxy path', async () => {
|
||||
const client = await connect(endpoint)
|
||||
|
||||
const primaryPageAttachResponse = await sendAndReceive(client, {
|
||||
id: 31,
|
||||
method: 'Target.attachToTarget',
|
||||
params: { targetId: 'orca-proxy-target', flatten: true }
|
||||
})
|
||||
|
||||
expect(primaryPageAttachResponse).toEqual({
|
||||
id: 31,
|
||||
result: { sessionId: 'orca-proxy-session' }
|
||||
})
|
||||
|
||||
const attachResponse = await sendAndReceive(client, {
|
||||
id: 32,
|
||||
method: 'Target.attachToBrowserTarget',
|
||||
params: {}
|
||||
})
|
||||
|
||||
expect(attachResponse).toEqual({
|
||||
id: 32,
|
||||
result: { sessionId: 'orca-proxy-browser-session' }
|
||||
})
|
||||
expect(getSendCommandMethods(mock)).not.toContain('Target.attachToBrowserTarget')
|
||||
|
||||
const pageAttachResponse = await sendAndReceive(client, {
|
||||
id: 33,
|
||||
method: 'Target.attachToTarget',
|
||||
params: { targetId: 'orca-proxy-target', flatten: true },
|
||||
sessionId: 'orca-proxy-browser-session'
|
||||
})
|
||||
|
||||
expect(pageAttachResponse).toEqual({
|
||||
id: 33,
|
||||
result: { sessionId: 'orca-proxy-session-2' },
|
||||
sessionId: 'orca-proxy-browser-session'
|
||||
})
|
||||
|
||||
const sessionResponse = await sendAndReceive(client, {
|
||||
id: 34,
|
||||
method: 'Runtime.evaluate',
|
||||
params: { expression: 'document.title' },
|
||||
sessionId: 'orca-proxy-session-2'
|
||||
})
|
||||
|
||||
expect(sessionResponse).toEqual({
|
||||
id: 34,
|
||||
result: {},
|
||||
sessionId: 'orca-proxy-session-2'
|
||||
})
|
||||
expect(getSendCommandCalls(mock)).toContainEqual([
|
||||
'Runtime.evaluate',
|
||||
{ expression: 'document.title' }
|
||||
])
|
||||
|
||||
const detachResponse = await sendAndReceive(client, {
|
||||
id: 35,
|
||||
method: 'Target.detachFromTarget',
|
||||
params: { sessionId: 'orca-proxy-session-2' },
|
||||
sessionId: 'orca-proxy-browser-session'
|
||||
})
|
||||
|
||||
expect(detachResponse).toEqual({
|
||||
id: 35,
|
||||
result: {},
|
||||
sessionId: 'orca-proxy-browser-session'
|
||||
})
|
||||
|
||||
const primaryPageResponse = await sendAndReceive(client, {
|
||||
id: 36,
|
||||
method: 'Runtime.evaluate',
|
||||
params: { expression: 'document.URL' },
|
||||
sessionId: 'orca-proxy-session'
|
||||
})
|
||||
|
||||
expect(primaryPageResponse).toEqual({
|
||||
id: 36,
|
||||
result: {},
|
||||
sessionId: 'orca-proxy-session'
|
||||
})
|
||||
|
||||
const browserSessionResponse = await sendAndReceive(client, {
|
||||
id: 37,
|
||||
method: 'Target.getTargets',
|
||||
params: {},
|
||||
sessionId: 'orca-proxy-browser-session'
|
||||
})
|
||||
|
||||
expect(browserSessionResponse).toMatchObject({
|
||||
id: 37,
|
||||
sessionId: 'orca-proxy-browser-session'
|
||||
})
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('preserves Target.attachToTarget and clears the synthetic session on Target.detachFromTarget', async () => {
|
||||
const client = await connect(endpoint)
|
||||
|
||||
const attachResponse = await sendAndReceive(client, {
|
||||
id: 33,
|
||||
method: 'Target.attachToTarget',
|
||||
params: { targetId: 'orca-proxy-target', flatten: true }
|
||||
})
|
||||
|
||||
expect(attachResponse).toEqual({
|
||||
id: 33,
|
||||
result: { sessionId: 'orca-proxy-session' }
|
||||
})
|
||||
|
||||
const detachResponse = await sendAndReceive(client, {
|
||||
id: 34,
|
||||
method: 'Target.detachFromTarget',
|
||||
params: { sessionId: 'orca-proxy-session' }
|
||||
})
|
||||
|
||||
expect(detachResponse).toEqual({ id: 34, result: {} })
|
||||
|
||||
const rootEventPromise = new Promise<Record<string, unknown>>((resolve) => {
|
||||
client.once('message', (data) => resolve(JSON.parse(data.toString())))
|
||||
})
|
||||
mock.emit('message', {}, 'Runtime.executionContextCreated', { context: { id: 1 } })
|
||||
|
||||
const rootEvent = await rootEventPromise
|
||||
expect(rootEvent).toEqual({
|
||||
method: 'Runtime.executionContextCreated',
|
||||
params: { context: { id: 1 } }
|
||||
})
|
||||
|
||||
const reattachResponse = await sendAndReceive(client, {
|
||||
id: 35,
|
||||
method: 'Target.attachToTarget',
|
||||
params: { targetId: 'orca-proxy-target', flatten: true }
|
||||
})
|
||||
|
||||
expect(reattachResponse).toEqual({
|
||||
id: 35,
|
||||
result: { sessionId: 'orca-proxy-session-2' }
|
||||
})
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('returns an error instead of crashing when a command arrives after tab destruction', async () => {
|
||||
const client = await connect(endpoint)
|
||||
mock.destroy()
|
||||
|
||||
@@ -19,6 +19,7 @@ export class CdpWsProxy {
|
||||
private httpServer: Server | null = null
|
||||
private wss: WebSocketServer | null = null
|
||||
private client: WebSocket | null = null
|
||||
private readonly responseSessionIdsByClient = new WeakMap<WebSocket, Map<number, string>>()
|
||||
private detachClientListeners: (() => void) | null = null
|
||||
private port = 0
|
||||
private debuggerMessageHandler: ((...args: unknown[]) => void) | null = null
|
||||
@@ -27,6 +28,10 @@ export class CdpWsProxy {
|
||||
private attached = false
|
||||
// Why: agent-browser filters events by sessionId from Target.attachToTarget.
|
||||
private clientSessionId: string | undefined = undefined
|
||||
private readonly clientSessionIds = new Set<string>()
|
||||
private readonly clientBrowserSessionIds = new Set<string>()
|
||||
private nextClientSessionOrdinal = 0
|
||||
private nextClientBrowserSessionOrdinal = 0
|
||||
private readonly pdfStreams = new CdpPdfStreamStore()
|
||||
|
||||
constructor(private readonly webContents: WebContents) {}
|
||||
@@ -59,7 +64,7 @@ export class CdpWsProxy {
|
||||
const onClose = (): void => {
|
||||
detach()
|
||||
if (this.client === ws) {
|
||||
this.pdfStreams.clear()
|
||||
this.clearClientState()
|
||||
this.client = null
|
||||
}
|
||||
}
|
||||
@@ -110,18 +115,45 @@ export class CdpWsProxy {
|
||||
this.detachClientListeners?.()
|
||||
this.detachClientListeners = null
|
||||
this.client = null
|
||||
// Why: a pending focus belongs to the departing client; never replay it for the next one.
|
||||
this.pendingDomFocusBySession.clear()
|
||||
this.pdfStreams.clear()
|
||||
this.clearClientState()
|
||||
if (client) {
|
||||
this.responseSessionIdsByClient.delete(client)
|
||||
}
|
||||
client?.close()
|
||||
}
|
||||
|
||||
private clearClientState(): void {
|
||||
// Why: session and focus state belongs to one websocket and must not cross client replacement.
|
||||
this.pendingDomFocusBySession.clear()
|
||||
this.pdfStreams.clear()
|
||||
this.clientSessionId = undefined
|
||||
this.clientSessionIds.clear()
|
||||
this.clientBrowserSessionIds.clear()
|
||||
this.nextClientSessionOrdinal = 0
|
||||
this.nextClientBrowserSessionOrdinal = 0
|
||||
}
|
||||
|
||||
private send(payload: unknown, client = this.client): void {
|
||||
const responsePayload = client ? this.addResponseSessionId(payload, client) : payload
|
||||
if (client?.readyState === WebSocket.OPEN) {
|
||||
client.send(JSON.stringify(payload))
|
||||
client.send(JSON.stringify(responsePayload))
|
||||
}
|
||||
}
|
||||
|
||||
private addResponseSessionId(payload: unknown, client: WebSocket): unknown {
|
||||
if (typeof payload !== 'object' || payload === null) {
|
||||
return payload
|
||||
}
|
||||
const clientId = (payload as { id?: unknown }).id
|
||||
if (typeof clientId !== 'number') {
|
||||
return payload
|
||||
}
|
||||
const responseSessionIds = this.responseSessionIdsByClient.get(client)
|
||||
const sessionId = responseSessionIds?.get(clientId)
|
||||
responseSessionIds?.delete(clientId)
|
||||
return sessionId ? { ...payload, sessionId } : payload
|
||||
}
|
||||
|
||||
private sendResult(clientId: number, result: unknown, client = this.client): void {
|
||||
this.send({ id: clientId, result }, client)
|
||||
}
|
||||
@@ -253,6 +285,13 @@ export class CdpWsProxy {
|
||||
return
|
||||
}
|
||||
const clientId = msg.id
|
||||
const responseSessionIds = this.responseSessionIdsByClient.get(client) ?? new Map()
|
||||
if (msg.sessionId) {
|
||||
responseSessionIds.set(clientId, msg.sessionId)
|
||||
} else {
|
||||
responseSessionIds.delete(clientId)
|
||||
}
|
||||
this.responseSessionIdsByClient.set(client, responseSessionIds)
|
||||
|
||||
if (msg.method === 'Target.getTargets') {
|
||||
this.sendResult(clientId, { targetInfos: [this.buildTargetInfo()] }, client)
|
||||
@@ -264,14 +303,30 @@ export class CdpWsProxy {
|
||||
}
|
||||
if (msg.method === 'Target.setDiscoverTargets' || msg.method === 'Target.detachFromTarget') {
|
||||
if (msg.method === 'Target.detachFromTarget') {
|
||||
this.clientSessionId = undefined
|
||||
const detachedSessionId = msg.params?.sessionId
|
||||
if (typeof detachedSessionId === 'string') {
|
||||
this.clientSessionIds.delete(detachedSessionId)
|
||||
this.clientBrowserSessionIds.delete(detachedSessionId)
|
||||
if (detachedSessionId === this.clientSessionId) {
|
||||
this.clientSessionId = this.clientSessionIds.values().next().value
|
||||
}
|
||||
}
|
||||
}
|
||||
this.sendResult(clientId, {}, client)
|
||||
return
|
||||
}
|
||||
if (msg.method === 'Target.attachToBrowserTarget') {
|
||||
// Why: Playwright needs a distinct browser session before it attaches to the selected page.
|
||||
const sessionId = this.nextSyntheticBrowserSessionId()
|
||||
this.clientBrowserSessionIds.add(sessionId)
|
||||
this.sendResult(clientId, { sessionId }, client)
|
||||
return
|
||||
}
|
||||
if (msg.method === 'Target.attachToTarget') {
|
||||
this.clientSessionId = 'orca-proxy-session'
|
||||
this.sendResult(clientId, { sessionId: this.clientSessionId }, client)
|
||||
const sessionId = this.nextSyntheticPageSessionId()
|
||||
this.clientSessionIds.add(sessionId)
|
||||
this.clientSessionId ??= sessionId
|
||||
this.sendResult(clientId, { sessionId }, client)
|
||||
return
|
||||
}
|
||||
if (msg.method === 'Browser.getVersion') {
|
||||
@@ -363,7 +418,24 @@ export class CdpWsProxy {
|
||||
}
|
||||
|
||||
private resolveDebuggerSessionId(msgSessionId?: string): string | undefined {
|
||||
return msgSessionId && msgSessionId !== this.clientSessionId ? msgSessionId : undefined
|
||||
const syntheticSession =
|
||||
(msgSessionId && this.clientSessionIds.has(msgSessionId)) ||
|
||||
(msgSessionId && this.clientBrowserSessionIds.has(msgSessionId))
|
||||
return msgSessionId && !syntheticSession ? msgSessionId : undefined
|
||||
}
|
||||
|
||||
private nextSyntheticPageSessionId(): string {
|
||||
this.nextClientSessionOrdinal += 1
|
||||
return this.nextClientSessionOrdinal === 1
|
||||
? 'orca-proxy-session'
|
||||
: `orca-proxy-session-${this.nextClientSessionOrdinal}`
|
||||
}
|
||||
|
||||
private nextSyntheticBrowserSessionId(): string {
|
||||
this.nextClientBrowserSessionOrdinal += 1
|
||||
return this.nextClientBrowserSessionOrdinal === 1
|
||||
? 'orca-proxy-browser-session'
|
||||
: `orca-proxy-browser-session-${this.nextClientBrowserSessionOrdinal}`
|
||||
}
|
||||
|
||||
private isActiveClient(client: WebSocket): boolean {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
// Why: Chromium 96+ moved the cookie DB under Network, while older profiles
|
||||
// still use the profile root. Every reader and replay writer must agree.
|
||||
export function resolveChromiumCookiesPath(profileDir: string): string | null {
|
||||
const networkPath = join(profileDir, 'Network', 'Cookies')
|
||||
if (existsSync(networkPath)) {
|
||||
return networkPath
|
||||
}
|
||||
const legacyPath = join(profileDir, 'Cookies')
|
||||
return existsSync(legacyPath) ? legacyPath : null
|
||||
}
|
||||
@@ -165,6 +165,319 @@ describe('scanClaudeUsageFiles', () => {
|
||||
expect(second.dailyAggregates[0]?.inputTokens).toBe(999)
|
||||
})
|
||||
|
||||
it('dedupes fork copies when only message.id is present (no requestId)', async () => {
|
||||
const root = await makeClaudeProjectsRoot()
|
||||
const projectDir = join(root, '.claude', 'projects', 'project-a')
|
||||
const originalFile = join(projectDir, 'aaaa-original.jsonl')
|
||||
const forkFile = join(projectDir, 'bbbb-fork.jsonl')
|
||||
|
||||
const turn = (sessionId: string, messageId: string, inputTokens: number): string =>
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
sessionId,
|
||||
timestamp: '2026-04-09T10:00:00.000Z',
|
||||
cwd: '/workspace/repo-a',
|
||||
message: {
|
||||
id: messageId,
|
||||
model: 'claude-sonnet-4-6',
|
||||
usage: { input_tokens: inputTokens, output_tokens: 10 }
|
||||
}
|
||||
})
|
||||
|
||||
await writeFile(originalFile, turn('session-1', 'msg_1', 100))
|
||||
await writeFile(
|
||||
forkFile,
|
||||
[turn('session-2', 'msg_1', 100), turn('session-2', 'msg_2', 50)].join('\n')
|
||||
)
|
||||
|
||||
vi.resetModules()
|
||||
vi.doMock('os', async () => ({
|
||||
...(await vi.importActual<typeof Os>('os')),
|
||||
homedir: () => root
|
||||
}))
|
||||
const { scanClaudeUsageFiles } = await import('./scanner')
|
||||
|
||||
const result = await scanClaudeUsageFiles([])
|
||||
expect(result.dailyAggregates.reduce((sum, row) => sum + row.inputTokens, 0)).toBe(150)
|
||||
expect(result.processedFiles[0]?.ownedDedupeKeys).toEqual(['msg:msg_1'])
|
||||
expect(result.processedFiles[1]?.ownedDedupeKeys).toEqual(['msg:msg_2'])
|
||||
})
|
||||
|
||||
it('dedupes fork copies via uuid when message ids are absent', async () => {
|
||||
const root = await makeClaudeProjectsRoot()
|
||||
const projectDir = join(root, '.claude', 'projects', 'project-a')
|
||||
const originalFile = join(projectDir, 'aaaa-original.jsonl')
|
||||
const forkFile = join(projectDir, 'bbbb-fork.jsonl')
|
||||
|
||||
const turn = (sessionId: string, uuid: string, inputTokens: number): string =>
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
sessionId,
|
||||
uuid,
|
||||
timestamp: '2026-04-09T10:00:00.000Z',
|
||||
cwd: '/workspace/repo-a',
|
||||
message: {
|
||||
model: 'claude-sonnet-4-6',
|
||||
usage: { input_tokens: inputTokens, output_tokens: 10 }
|
||||
}
|
||||
})
|
||||
|
||||
await writeFile(originalFile, turn('session-1', 'uuid-1', 100))
|
||||
await writeFile(
|
||||
forkFile,
|
||||
[turn('session-2', 'uuid-1', 100), turn('session-2', 'uuid-2', 40)].join('\n')
|
||||
)
|
||||
|
||||
vi.resetModules()
|
||||
vi.doMock('os', async () => ({
|
||||
...(await vi.importActual<typeof Os>('os')),
|
||||
homedir: () => root
|
||||
}))
|
||||
const { scanClaudeUsageFiles } = await import('./scanner')
|
||||
|
||||
const result = await scanClaudeUsageFiles([])
|
||||
expect(result.dailyAggregates.reduce((sum, row) => sum + row.inputTokens, 0)).toBe(140)
|
||||
expect(result.processedFiles[0]?.ownedDedupeKeys).toEqual(['uuid:uuid-1'])
|
||||
expect(result.processedFiles[1]?.ownedDedupeKeys).toEqual(['uuid:uuid-2'])
|
||||
})
|
||||
|
||||
it('counts turns copied into forked session files exactly once', async () => {
|
||||
const root = await makeClaudeProjectsRoot()
|
||||
const projectDir = join(root, '.claude', 'projects', 'project-a')
|
||||
const originalFile = join(projectDir, 'aaaa-original.jsonl')
|
||||
const forkFile = join(projectDir, 'bbbb-fork.jsonl')
|
||||
|
||||
const turn = (
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
requestId: string,
|
||||
inputTokens: number
|
||||
): string =>
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
sessionId,
|
||||
timestamp: '2026-04-09T10:00:00.000Z',
|
||||
requestId,
|
||||
cwd: '/workspace/repo-a',
|
||||
message: {
|
||||
id: messageId,
|
||||
model: 'claude-sonnet-4-6',
|
||||
usage: { input_tokens: inputTokens, output_tokens: 10 }
|
||||
}
|
||||
})
|
||||
|
||||
await writeFile(
|
||||
originalFile,
|
||||
[turn('session-1', 'msg_1', 'req_1', 100), turn('session-1', 'msg_2', 'req_2', 200)].join(
|
||||
'\n'
|
||||
)
|
||||
)
|
||||
// Fork copies the original history with preserved message/request IDs but a
|
||||
// rewritten sessionId, then appends its own new turn.
|
||||
await writeFile(
|
||||
forkFile,
|
||||
[
|
||||
turn('session-2', 'msg_1', 'req_1', 100),
|
||||
turn('session-2', 'msg_2', 'req_2', 200),
|
||||
turn('session-2', 'msg_3', 'req_3', 400)
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
vi.resetModules()
|
||||
vi.doMock('os', async () => ({
|
||||
...(await vi.importActual<typeof Os>('os')),
|
||||
homedir: () => root
|
||||
}))
|
||||
const { scanClaudeUsageFiles } = await import('./scanner')
|
||||
|
||||
const result = await scanClaudeUsageFiles([])
|
||||
const totalInput = result.dailyAggregates.reduce(
|
||||
(sum, aggregate) => sum + aggregate.inputTokens,
|
||||
0
|
||||
)
|
||||
|
||||
expect(totalInput).toBe(700)
|
||||
expect(result.processedFiles[0]?.ownedDedupeKeys).toEqual(['msg_1:req_1', 'msg_2:req_2'])
|
||||
expect(result.processedFiles[0]?.hasDeferredClaims).toBe(false)
|
||||
expect(result.processedFiles[1]?.ownedDedupeKeys).toEqual(['msg_3:req_3'])
|
||||
expect(result.processedFiles[1]?.hasDeferredClaims).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps fork dedupe stable when the original file projection is reused from cache', async () => {
|
||||
const root = await makeClaudeProjectsRoot()
|
||||
const projectDir = join(root, '.claude', 'projects', 'project-a')
|
||||
const originalFile = join(projectDir, 'aaaa-original.jsonl')
|
||||
const forkFile = join(projectDir, 'zzzz-fork.jsonl')
|
||||
|
||||
const turn = (
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
requestId: string,
|
||||
inputTokens: number
|
||||
): string =>
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
sessionId,
|
||||
timestamp: '2026-04-09T10:00:00.000Z',
|
||||
requestId,
|
||||
cwd: '/workspace/repo-a',
|
||||
message: {
|
||||
id: messageId,
|
||||
model: 'claude-sonnet-4-6',
|
||||
usage: { input_tokens: inputTokens, output_tokens: 10 }
|
||||
}
|
||||
})
|
||||
|
||||
await writeFile(originalFile, turn('session-1', 'msg_1', 'req_1', 100))
|
||||
|
||||
vi.resetModules()
|
||||
vi.doMock('os', async () => ({
|
||||
...(await vi.importActual<typeof Os>('os')),
|
||||
homedir: () => root
|
||||
}))
|
||||
const { scanClaudeUsageFiles } = await import('./scanner')
|
||||
|
||||
const first = await scanClaudeUsageFiles([])
|
||||
expect(first.processedFiles[0]?.ownedDedupeKeys).toEqual(['msg_1:req_1'])
|
||||
|
||||
// A fork appears later while the original stays unchanged (cache reuse).
|
||||
await writeFile(
|
||||
forkFile,
|
||||
[turn('session-2', 'msg_1', 'req_1', 100), turn('session-2', 'msg_9', 'req_9', 50)].join('\n')
|
||||
)
|
||||
|
||||
const second = await scanClaudeUsageFiles([], first.processedFiles)
|
||||
const totalInput = second.dailyAggregates.reduce(
|
||||
(sum, aggregate) => sum + aggregate.inputTokens,
|
||||
0
|
||||
)
|
||||
|
||||
expect(totalInput).toBe(150)
|
||||
expect(second.processedFiles.map((file) => file.ownedDedupeKeys)).toEqual([
|
||||
['msg_1:req_1'],
|
||||
['msg_9:req_9']
|
||||
])
|
||||
|
||||
// Rescanning with the full cache stays stable.
|
||||
const third = await scanClaudeUsageFiles([], second.processedFiles)
|
||||
expect(third.dailyAggregates.reduce((sum, aggregate) => sum + aggregate.inputTokens, 0)).toBe(
|
||||
150
|
||||
)
|
||||
})
|
||||
|
||||
it('reclaims fork-copied turns when the owning original file is deleted', async () => {
|
||||
const root = await makeClaudeProjectsRoot()
|
||||
const projectDir = join(root, '.claude', 'projects', 'project-a')
|
||||
const originalFile = join(projectDir, 'aaaa-original.jsonl')
|
||||
const forkFile = join(projectDir, 'bbbb-fork.jsonl')
|
||||
|
||||
const turn = (
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
requestId: string,
|
||||
inputTokens: number
|
||||
): string =>
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
sessionId,
|
||||
timestamp: '2026-04-09T10:00:00.000Z',
|
||||
requestId,
|
||||
cwd: '/workspace/repo-a',
|
||||
message: {
|
||||
id: messageId,
|
||||
model: 'claude-sonnet-4-6',
|
||||
usage: { input_tokens: inputTokens, output_tokens: 10 }
|
||||
}
|
||||
})
|
||||
|
||||
await writeFile(originalFile, turn('session-1', 'msg_1', 'req_1', 100))
|
||||
await writeFile(
|
||||
forkFile,
|
||||
[turn('session-2', 'msg_1', 'req_1', 100), turn('session-2', 'msg_9', 'req_9', 50)].join('\n')
|
||||
)
|
||||
|
||||
vi.resetModules()
|
||||
vi.doMock('os', async () => ({
|
||||
...(await vi.importActual<typeof Os>('os')),
|
||||
homedir: () => root
|
||||
}))
|
||||
const { scanClaudeUsageFiles } = await import('./scanner')
|
||||
|
||||
const first = await scanClaudeUsageFiles([])
|
||||
expect(first.dailyAggregates.reduce((sum, aggregate) => sum + aggregate.inputTokens, 0)).toBe(
|
||||
150
|
||||
)
|
||||
expect(first.processedFiles[0]?.ownedDedupeKeys).toEqual(['msg_1:req_1'])
|
||||
expect(first.processedFiles[1]?.ownedDedupeKeys).toEqual(['msg_9:req_9'])
|
||||
|
||||
// Deleting the owner must reparse deferred forks so they can re-claim the
|
||||
// copied turn instead of permanently under-counting.
|
||||
await rm(originalFile)
|
||||
|
||||
const second = await scanClaudeUsageFiles([], first.processedFiles)
|
||||
expect(second.dailyAggregates.reduce((sum, aggregate) => sum + aggregate.inputTokens, 0)).toBe(
|
||||
150
|
||||
)
|
||||
expect(second.processedFiles).toHaveLength(1)
|
||||
expect(second.processedFiles[0]?.ownedDedupeKeys).toEqual(['msg_1:req_1', 'msg_9:req_9'])
|
||||
})
|
||||
|
||||
it('keeps unrelated cached transcripts when a different owner file is deleted', async () => {
|
||||
const root = await makeClaudeProjectsRoot()
|
||||
const projectDir = join(root, '.claude', 'projects', 'project-a')
|
||||
const originalFile = join(projectDir, 'aaaa-original.jsonl')
|
||||
const forkFile = join(projectDir, 'bbbb-fork.jsonl')
|
||||
const unrelatedFile = join(projectDir, 'cccc-unrelated.jsonl')
|
||||
|
||||
const turn = (
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
requestId: string,
|
||||
inputTokens: number
|
||||
): string =>
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
sessionId,
|
||||
timestamp: '2026-04-09T10:00:00.000Z',
|
||||
requestId,
|
||||
cwd: '/workspace/repo-a',
|
||||
message: {
|
||||
id: messageId,
|
||||
model: 'claude-sonnet-4-6',
|
||||
usage: { input_tokens: inputTokens, output_tokens: 10 }
|
||||
}
|
||||
})
|
||||
|
||||
await writeFile(originalFile, turn('session-1', 'msg_1', 'req_1', 100))
|
||||
await writeFile(
|
||||
forkFile,
|
||||
[turn('session-2', 'msg_1', 'req_1', 100), turn('session-2', 'msg_9', 'req_9', 50)].join('\n')
|
||||
)
|
||||
await writeFile(unrelatedFile, turn('session-3', 'msg_u', 'req_u', 25))
|
||||
|
||||
vi.resetModules()
|
||||
vi.doMock('os', async () => ({
|
||||
...(await vi.importActual<typeof Os>('os')),
|
||||
homedir: () => root
|
||||
}))
|
||||
const { scanClaudeUsageFiles } = await import('./scanner')
|
||||
|
||||
const first = await scanClaudeUsageFiles([])
|
||||
const unrelatedBefore = first.processedFiles.find((file) => file.path === unrelatedFile)
|
||||
expect(unrelatedBefore?.hasDeferredClaims).toBe(false)
|
||||
expect(unrelatedBefore?.ownedDedupeKeys).toEqual(['msg_u:req_u'])
|
||||
|
||||
await rm(originalFile)
|
||||
|
||||
const second = await scanClaudeUsageFiles([], first.processedFiles)
|
||||
const unrelatedAfter = second.processedFiles.find((file) => file.path === unrelatedFile)
|
||||
// Unrelated files must keep their cached projection identity (stat-only path).
|
||||
expect(unrelatedAfter).toBe(unrelatedBefore)
|
||||
expect(second.dailyAggregates.reduce((sum, aggregate) => sum + aggregate.inputTokens, 0)).toBe(
|
||||
175
|
||||
)
|
||||
})
|
||||
|
||||
it('canonicalizes repeated cwd paths once per scan file', async () => {
|
||||
const root = await makeClaudeProjectsRoot()
|
||||
const projectDir = join(root, '.claude', 'projects', 'project-a')
|
||||
|
||||
@@ -29,6 +29,8 @@ type ClaudeUsageSourceRecord = {
|
||||
cwd?: string
|
||||
gitBranch?: string
|
||||
requestId?: string
|
||||
/** Stable row id when present; preserved across fork-copied history. */
|
||||
uuid?: string
|
||||
isSidechain?: boolean
|
||||
agentId?: string
|
||||
message?: {
|
||||
@@ -209,9 +211,11 @@ function stripClaudeSourceMetadata(turn: ClaudeUsageParsedSourceTurn): ClaudeUsa
|
||||
}
|
||||
}
|
||||
|
||||
function dedupeClaudeUsageTurns(turns: ClaudeUsageParsedSourceTurn[]): ClaudeUsageParsedTurn[] {
|
||||
function dedupeClaudeUsageTurns(
|
||||
turns: ClaudeUsageParsedSourceTurn[]
|
||||
): ClaudeUsageParsedSourceTurn[] {
|
||||
const dedupeIndexByKey = new Map<string, number>()
|
||||
const deduped: ClaudeUsageParsedTurn[] = []
|
||||
const deduped: ClaudeUsageParsedSourceTurn[] = []
|
||||
|
||||
for (const turn of turns) {
|
||||
if (turn.dedupeKey) {
|
||||
@@ -228,8 +232,7 @@ function dedupeClaudeUsageTurns(turns: ClaudeUsageParsedSourceTurn[]): ClaudeUsa
|
||||
}
|
||||
}
|
||||
|
||||
const stripped = stripClaudeSourceMetadata(turn)
|
||||
deduped.push(stripped)
|
||||
deduped.push({ ...turn })
|
||||
if (turn.dedupeKey) {
|
||||
dedupeIndexByKey.set(turn.dedupeKey, deduped.length - 1)
|
||||
}
|
||||
@@ -273,8 +276,10 @@ function parseClaudeUsageSourceRecord(
|
||||
model: parsed.message?.model ?? null,
|
||||
cwd: parsed.cwd ?? null,
|
||||
gitBranch: parsed.gitBranch ?? null,
|
||||
dedupeKey:
|
||||
parsed.message?.id && parsed.requestId ? `${parsed.message.id}:${parsed.requestId}` : null,
|
||||
// Why: forks rewrite sessionId but keep message/request ids (and usually
|
||||
// uuid). Prefer the strongest stable identity available so ownership still
|
||||
// works when requestId is missing on older or partial rows.
|
||||
dedupeKey: buildClaudeUsageDedupeKey(parsed),
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens,
|
||||
@@ -282,6 +287,22 @@ function parseClaudeUsageSourceRecord(
|
||||
}
|
||||
}
|
||||
|
||||
function buildClaudeUsageDedupeKey(parsed: ClaudeUsageSourceRecord): string | null {
|
||||
const messageId = parsed.message?.id?.trim()
|
||||
const requestId = parsed.requestId?.trim()
|
||||
if (messageId && requestId) {
|
||||
return `${messageId}:${requestId}`
|
||||
}
|
||||
if (messageId) {
|
||||
return `msg:${messageId}`
|
||||
}
|
||||
const uuid = parsed.uuid?.trim()
|
||||
if (uuid) {
|
||||
return `uuid:${uuid}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function parseClaudeUsageRecord(line: string): ClaudeUsageParsedTurn | null {
|
||||
const parsed = parseClaudeUsageSourceRecord(line)
|
||||
return parsed ? stripClaudeSourceMetadata(parsed) : null
|
||||
@@ -302,12 +323,12 @@ export async function parseClaudeUsageFile(filePath: string): Promise<ClaudeUsag
|
||||
}
|
||||
}
|
||||
|
||||
return dedupeClaudeUsageTurns(turns)
|
||||
return dedupeClaudeUsageTurns(turns).map(stripClaudeSourceMetadata)
|
||||
}
|
||||
|
||||
async function readClaudeUsageScanFile(filePath: string): Promise<{
|
||||
processedFile: ClaudeUsageProcessedFile
|
||||
turns: ClaudeUsageParsedTurn[]
|
||||
turns: ClaudeUsageParsedSourceTurn[]
|
||||
}> {
|
||||
const fileStat = await stat(filePath)
|
||||
let lineCount = 0
|
||||
@@ -597,18 +618,6 @@ export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): {
|
||||
}
|
||||
}
|
||||
|
||||
async function parseClaudeUsagePersistedFile(
|
||||
filePath: string,
|
||||
worktreeLookup: Map<string, ClaudeUsageWorktreeRef>
|
||||
): Promise<ClaudeUsagePersistedFile> {
|
||||
const { processedFile, turns } = await readClaudeUsageScanFile(filePath)
|
||||
const attributed = await attributeClaudeUsageTurns(turns, worktreeLookup)
|
||||
return {
|
||||
...processedFile,
|
||||
...aggregateClaudeUsage(attributed)
|
||||
}
|
||||
}
|
||||
|
||||
export async function scanClaudeUsageFiles(
|
||||
worktrees: ClaudeUsageWorktreeRef[],
|
||||
previousProcessedFiles: ClaudeUsagePersistedFile[] = []
|
||||
@@ -619,41 +628,122 @@ export async function scanClaudeUsageFiles(
|
||||
}> {
|
||||
const files = await listClaudeTranscriptFiles()
|
||||
const previousByPath = new Map(previousProcessedFiles.map((file) => [file.path, file]))
|
||||
const processedFiles: ClaudeUsagePersistedFile[] = []
|
||||
const worktreeLookup = await buildWorktreeLookup(worktrees)
|
||||
const sessionsById = new Map<string, ClaudeUsageSession>()
|
||||
const dailyByKey = new Map<string, ClaudeUsageDailyAggregate>()
|
||||
|
||||
const currentPaths = new Set(files)
|
||||
// Why: when a file that owned dedupe keys is deleted, remaining forks still
|
||||
// contain those turns but their caches record them as unowned. Only files
|
||||
// that previously deferred claims can reclaim, so invalidate those — not the
|
||||
// entire transcript corpus (histories can be gigabytes).
|
||||
const lostOwnerPath = previousProcessedFiles.some(
|
||||
(file) =>
|
||||
!currentPaths.has(file.path) &&
|
||||
Array.isArray(file.ownedDedupeKeys) &&
|
||||
file.ownedDedupeKeys.length > 0
|
||||
)
|
||||
|
||||
const reusedByPath = new Map<string, ClaudeUsagePersistedFile>()
|
||||
const pathsToParse: string[] = []
|
||||
for (let index = 0; index < files.length; index += FILE_SCAN_BATCH_SIZE) {
|
||||
const batch = files.slice(index, index + FILE_SCAN_BATCH_SIZE)
|
||||
const results = await Promise.all(
|
||||
const reusable = await Promise.all(
|
||||
batch.map(async (filePath) => {
|
||||
const fileInfo = await getProcessedFileStat(filePath)
|
||||
const previous = previousByPath.get(filePath)
|
||||
// Why: Claude histories can be gigabytes. Unchanged files should pay
|
||||
// only stat cost on refresh while preserving exactly the old projection.
|
||||
// When an owner disappears, only deferred-claim files need reparse.
|
||||
const mustReclaimDeferred = lostOwnerPath && previous?.hasDeferredClaims !== false
|
||||
const canReuse =
|
||||
!mustReclaimDeferred &&
|
||||
previous &&
|
||||
previous.mtimeMs === fileInfo.mtimeMs &&
|
||||
previous.size === fileInfo.size &&
|
||||
Array.isArray(previous.sessions) &&
|
||||
Array.isArray(previous.dailyAggregates)
|
||||
|
||||
return canReuse ? previous : parseClaudeUsagePersistedFile(filePath, worktreeLookup)
|
||||
Array.isArray(previous.dailyAggregates) &&
|
||||
Array.isArray(previous.ownedDedupeKeys) &&
|
||||
typeof previous.hasDeferredClaims === 'boolean'
|
||||
return canReuse ? previous : null
|
||||
})
|
||||
)
|
||||
for (const processed of results) {
|
||||
processedFiles.push(processed)
|
||||
mergeClaudeSessions(sessionsById, processed.sessions)
|
||||
mergeClaudeDailyAggregates(dailyByKey, processed.dailyAggregates)
|
||||
for (const [batchIndex, previous] of reusable.entries()) {
|
||||
if (previous) {
|
||||
reusedByPath.set(batch[batchIndex], previous)
|
||||
} else {
|
||||
pathsToParse.push(batch[batchIndex])
|
||||
}
|
||||
}
|
||||
// Why: transcript scans run in Electron's main process. Small parallel
|
||||
// batches cut independent file I/O without letting Settings stay blocked.
|
||||
if (index + batch.length < files.length) {
|
||||
await yieldToEventLoop()
|
||||
}
|
||||
}
|
||||
|
||||
// Why: resuming or forking a Claude session copies earlier turns — with their
|
||||
// original message/request IDs — into a new transcript file under a new
|
||||
// session id. Per-file dedupe cannot see those copies, so long-lived sessions
|
||||
// get re-counted on every fork (issue #8006). Cross-file ownership counts each
|
||||
// turn for exactly one file; cached files keep the claims they persisted.
|
||||
const turnOwnerByDedupeKey = new Map<string, string>()
|
||||
for (const [filePath, previous] of reusedByPath) {
|
||||
for (const dedupeKey of previous.ownedDedupeKeys) {
|
||||
// First cached claim wins so conflicting projections stay deterministic.
|
||||
if (!turnOwnerByDedupeKey.has(dedupeKey)) {
|
||||
turnOwnerByDedupeKey.set(dedupeKey, filePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parsedByPath = new Map<string, ClaudeUsagePersistedFile>()
|
||||
for (let index = 0; index < pathsToParse.length; index += FILE_SCAN_BATCH_SIZE) {
|
||||
const batch = pathsToParse.slice(index, index + FILE_SCAN_BATCH_SIZE)
|
||||
// Why: transcript scans run in Electron's main process. Small parallel
|
||||
// batches cut independent file I/O without letting Settings stay blocked.
|
||||
const reads = await Promise.all(batch.map((filePath) => readClaudeUsageScanFile(filePath)))
|
||||
for (const [batchIndex, filePath] of batch.entries()) {
|
||||
const { processedFile, turns } = reads[batchIndex]
|
||||
// Why: ownership claims must be sequential in sorted-path order so
|
||||
// rescans assign duplicated turns to the same file deterministically.
|
||||
const ownedTurns: ClaudeUsageParsedTurn[] = []
|
||||
const ownedDedupeKeys: string[] = []
|
||||
let hasDeferredClaims = false
|
||||
for (const turn of turns) {
|
||||
if (turn.dedupeKey) {
|
||||
const owner = turnOwnerByDedupeKey.get(turn.dedupeKey)
|
||||
if (owner !== undefined && owner !== filePath) {
|
||||
hasDeferredClaims = true
|
||||
continue
|
||||
}
|
||||
turnOwnerByDedupeKey.set(turn.dedupeKey, filePath)
|
||||
ownedDedupeKeys.push(turn.dedupeKey)
|
||||
}
|
||||
ownedTurns.push(stripClaudeSourceMetadata(turn))
|
||||
}
|
||||
const attributed = await attributeClaudeUsageTurns(ownedTurns, worktreeLookup)
|
||||
parsedByPath.set(filePath, {
|
||||
...processedFile,
|
||||
...aggregateClaudeUsage(attributed),
|
||||
ownedDedupeKeys,
|
||||
hasDeferredClaims
|
||||
})
|
||||
}
|
||||
if (index + batch.length < pathsToParse.length) {
|
||||
await yieldToEventLoop()
|
||||
}
|
||||
}
|
||||
|
||||
const processedFiles: ClaudeUsagePersistedFile[] = []
|
||||
const sessionsById = new Map<string, ClaudeUsageSession>()
|
||||
const dailyByKey = new Map<string, ClaudeUsageDailyAggregate>()
|
||||
for (const filePath of files) {
|
||||
const processed = reusedByPath.get(filePath) ?? parsedByPath.get(filePath)
|
||||
if (!processed) {
|
||||
continue
|
||||
}
|
||||
processedFiles.push(processed)
|
||||
mergeClaudeSessions(sessionsById, processed.sessions)
|
||||
mergeClaudeDailyAggregates(dailyByKey, processed.dailyAggregates)
|
||||
}
|
||||
|
||||
return {
|
||||
processedFiles,
|
||||
sessions: finalizeClaudeSessions(sessionsById),
|
||||
|
||||
@@ -19,7 +19,10 @@ import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-w
|
||||
import type { ClaudeUsagePersistedState } from './types'
|
||||
import { createWorktreeRefs, getSessionProjectLabel, scanClaudeUsageFiles } from './scanner'
|
||||
|
||||
const SCHEMA_VERSION = 3
|
||||
// Why: v5 widens Claude ownership keys (message-id / uuid fallbacks). Older
|
||||
// caches either lack ownership or used narrower keys and can under/over-count
|
||||
// after fork reclaim (#8006).
|
||||
const SCHEMA_VERSION = 5
|
||||
const STALE_MS = 5 * 60_000
|
||||
const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000
|
||||
|
||||
|
||||
@@ -66,6 +66,14 @@ export type ClaudeUsagePersistedState = {
|
||||
export type ClaudeUsagePersistedFile = ClaudeUsageProcessedFile & {
|
||||
sessions: ClaudeUsageSession[]
|
||||
dailyAggregates: ClaudeUsageDailyAggregate[]
|
||||
/** Dedupe keys (message.id:requestId) this file counted. Forked/resumed
|
||||
* sessions copy earlier turns into new files; ownership keeps each turn
|
||||
* counted by exactly one cached file across incremental scans. */
|
||||
ownedDedupeKeys: string[]
|
||||
/** True when this file saw turns already claimed by another file. When that
|
||||
* owner disappears, only deferred files need reparse to reclaim — not the
|
||||
* entire transcript corpus. */
|
||||
hasDeferredClaims: boolean
|
||||
}
|
||||
|
||||
export type ClaudeUsageParsedTurn = {
|
||||
|
||||
@@ -533,6 +533,74 @@ describe('CodexRuntimeHomeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('promotes WSL in-Codex setting changes on the next Codex launch', async () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
vi.doMock('../codex/wsl-codex-session-bridge', () => ({
|
||||
startWslCodexSessionBridgeInBackground: vi.fn(() => Promise.resolve())
|
||||
}))
|
||||
const wslHome = join(testState.userDataDir, 'wsl-home')
|
||||
vi.doMock('../wsl', () => ({
|
||||
getDefaultWslDistro: () => 'Ubuntu',
|
||||
getWslHome: () => wslHome
|
||||
}))
|
||||
const store = createStore(
|
||||
createSettings({
|
||||
activeCodexManagedAccountId: null,
|
||||
activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } }
|
||||
})
|
||||
)
|
||||
const wslSystemConfigPath = join(wslHome, '.codex', 'config.toml')
|
||||
mkdirSync(join(wslHome, '.codex'), { recursive: true })
|
||||
writeFileSync(wslSystemConfigPath, 'model = "gpt-5"\n', 'utf-8')
|
||||
|
||||
try {
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
const wslRuntimeHomePath = join(
|
||||
wslHome,
|
||||
'.local',
|
||||
'share',
|
||||
'orca',
|
||||
'codex-runtime-home',
|
||||
'home'
|
||||
)
|
||||
|
||||
// First launch seeds the runtime config and records the per-distro baseline.
|
||||
expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
|
||||
wslRuntimeHomePath
|
||||
)
|
||||
const baselinePath = join(wslRuntimeHomePath, '.orca-config-settings-baseline.json')
|
||||
expect(existsSync(baselinePath)).toBe(true)
|
||||
|
||||
// A direct WSL Codex edit wins and is mirrored into Orca's runtime before
|
||||
// the baseline advances, so later in-Orca changes remain promotable.
|
||||
const runtimeConfigPath = join(wslRuntimeHomePath, 'config.toml')
|
||||
writeFileSync(wslSystemConfigPath, 'model = "outside-edit"\n', 'utf-8')
|
||||
service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('model = "outside-edit"\n')
|
||||
expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"outside-edit\\""')
|
||||
|
||||
// Codex now persists a /model change inside Orca's reconciled runtime.
|
||||
writeFileSync(
|
||||
runtimeConfigPath,
|
||||
readFileSync(runtimeConfigPath, 'utf-8').replace('model = "outside-edit"', 'model = "o4"'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
expect(readFileSync(wslSystemConfigPath, 'utf-8')).toBe('model = "o4"\n')
|
||||
// Baseline advances so the promoted value is not re-promoted forever.
|
||||
expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"o4\\""')
|
||||
} finally {
|
||||
vi.doUnmock('../codex/wsl-codex-session-bridge')
|
||||
vi.doUnmock('../wsl')
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('bridges WSL history from a configured per-distro source-home override', async () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
@@ -1892,7 +1960,7 @@ describe('CodexRuntimeHomeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the stable WSL runtime home for WSL system-default rate-limit fetches', async () => {
|
||||
it('reads WSL system-default rate limits from the live system home without materializing', async () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const wslHome = join(testState.userDataDir, 'wsl-home')
|
||||
@@ -1910,10 +1978,21 @@ describe('CodexRuntimeHomeService', () => {
|
||||
try {
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
|
||||
expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
|
||||
join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home')
|
||||
const syncWslRuntime = vi.spyOn(
|
||||
service as unknown as {
|
||||
syncWslRuntimeForCurrentSelection: (target: {
|
||||
runtime: 'wsl'
|
||||
wslDistro?: string | null
|
||||
}) => string | null
|
||||
},
|
||||
'syncWslRuntimeForCurrentSelection'
|
||||
)
|
||||
const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' }
|
||||
const expectedHome = join(wslHome, '.codex')
|
||||
|
||||
expect(service.prepareForRateLimitFetch(target)).toBe(expectedHome)
|
||||
expect(service.prepareForRateLimitFetch(target)).toBe(expectedHome)
|
||||
expect(syncWslRuntime).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
@@ -2041,20 +2120,23 @@ describe('CodexRuntimeHomeService', () => {
|
||||
try {
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
const wslRuntimeHomePath = join(
|
||||
wslHome,
|
||||
'.local',
|
||||
'share',
|
||||
'orca',
|
||||
'codex-runtime-home',
|
||||
'home'
|
||||
)
|
||||
|
||||
expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
|
||||
wslRuntimeHomePath
|
||||
systemCodexHomePath
|
||||
)
|
||||
expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(managedAuth)
|
||||
expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(systemDefaultAuth)
|
||||
const externallyRefreshedAuth = createCodexAuthJson(
|
||||
'wsl@example.com',
|
||||
'acct-wsl',
|
||||
'system-refreshed',
|
||||
3_000
|
||||
)
|
||||
writeFileSync(join(systemCodexHomePath, 'auth.json'), externallyRefreshedAuth, 'utf-8')
|
||||
expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
|
||||
systemCodexHomePath
|
||||
)
|
||||
expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(
|
||||
externallyRefreshedAuth
|
||||
)
|
||||
} finally {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
|
||||
@@ -139,9 +139,9 @@ export class CodexRuntimeHomeService {
|
||||
prepareForCodexLaunch(target?: CodexAccountSelectionTarget): string | null {
|
||||
if (target?.runtime === 'wsl') {
|
||||
const wslTarget = this.resolveWslDefaultTarget(target)
|
||||
const runtimeHomePath =
|
||||
this.syncWslRuntimeForCurrentSelection(wslTarget) ??
|
||||
this.getWslSystemCodexHomePath(wslTarget)
|
||||
const syncedRuntimeHomePath = this.syncWslRuntimeForCurrentSelection(wslTarget)
|
||||
this.syncWslConfigSettingsForLaunch(wslTarget, syncedRuntimeHomePath)
|
||||
const runtimeHomePath = syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget)
|
||||
this.startWslSessionBridgeForLaunch(wslTarget, runtimeHomePath)
|
||||
return runtimeHomePath
|
||||
}
|
||||
@@ -222,13 +222,33 @@ export class CodexRuntimeHomeService {
|
||||
return home ? this.joinWslPath(home, '.codex') : null
|
||||
}
|
||||
|
||||
// Why: WSL needs the same promote-then-mirror transaction as host. It keeps
|
||||
// in-Orca changes while reconciling a newer external ~/.codex edit before
|
||||
// advancing the per-distro baseline. Only runs on a materialized runtime.
|
||||
private syncWslConfigSettingsForLaunch(
|
||||
target: CodexAccountSelectionTarget,
|
||||
runtimeHomePath: string | null
|
||||
): void {
|
||||
if (!runtimeHomePath) {
|
||||
return
|
||||
}
|
||||
const distro =
|
||||
parseWslUncPath(runtimeHomePath)?.distro || target.wslDistro?.trim() || getDefaultWslDistro()
|
||||
if (!distro) {
|
||||
return
|
||||
}
|
||||
const systemHomePath = this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro })
|
||||
if (!systemHomePath || systemHomePath === runtimeHomePath) {
|
||||
return
|
||||
}
|
||||
syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath })
|
||||
}
|
||||
|
||||
prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): string | null {
|
||||
if (target?.runtime === 'wsl') {
|
||||
const wslTarget = this.resolveWslDefaultTarget(target)
|
||||
return (
|
||||
this.syncWslRuntimeForCurrentSelection(wslTarget) ??
|
||||
this.getWslSystemCodexHomePath(wslTarget)
|
||||
)
|
||||
const syncedRuntimeHomePath = this.getPreparedWslRateLimitHomePath(wslTarget)
|
||||
return syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget)
|
||||
}
|
||||
this.syncForCurrentSelection()
|
||||
syncSystemCodexResourcesIntoManagedHome()
|
||||
@@ -503,6 +523,32 @@ export class CodexRuntimeHomeService {
|
||||
return parseWslUncPath(account.managedHomePath) ? account.managedHomePath : null
|
||||
}
|
||||
|
||||
private getPreparedWslRateLimitHomePath(target: CodexAccountSelectionTarget): string | null {
|
||||
const distro = target.wslDistro?.trim()
|
||||
if (distro) {
|
||||
const settings = this.store.getSettings()
|
||||
const selectedAccountId = getSelectedCodexAccountIdForTarget(settings, target)
|
||||
if (selectedAccountId === null) {
|
||||
// Why: the system-default account changes outside Orca (login, logout,
|
||||
// token refresh). Read its real home directly so a cached runtime copy
|
||||
// cannot stay stale; filesystem probing in the fetcher is asynchronous.
|
||||
return this.getWslSystemCodexHomePath(target)
|
||||
}
|
||||
const cachedRuntimeHomePath = this.wslRuntimeHomePathByDistro.get(distro)
|
||||
if (
|
||||
cachedRuntimeHomePath &&
|
||||
this.lastSyncedWslAccountIdByDistro.has(distro) &&
|
||||
this.lastSyncedWslAccountIdByDistro.get(distro) === selectedAccountId
|
||||
) {
|
||||
// Why: RateLimitService resolves provenance twice per poll. Account
|
||||
// changes sync explicitly, so repeated resolution must stay path-only
|
||||
// instead of blocking main on UNC reads and a wsl.exe migration probe.
|
||||
return cachedRuntimeHomePath
|
||||
}
|
||||
}
|
||||
return this.syncWslRuntimeForCurrentSelection(target)
|
||||
}
|
||||
|
||||
private syncWslRuntimeForCurrentSelection(target: CodexAccountSelectionTarget): string | null {
|
||||
if (process.platform !== 'win32') {
|
||||
return null
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { linkSync, lstatSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import {
|
||||
linkSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
unlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type * as NodeOs from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -300,6 +308,216 @@ describe('listCodexSessionFiles', () => {
|
||||
).toBe(3)
|
||||
})
|
||||
|
||||
it('counts token events copied into forked rollout files exactly once', async () => {
|
||||
const sessionsDir = join(userDataDir, 'codex-runtime-home', 'home', 'sessions')
|
||||
mkdirSync(sessionsDir, { recursive: true })
|
||||
const originalPath = join(sessionsDir, 'aaaa-original.jsonl')
|
||||
const forkPath = join(sessionsDir, 'bbbb-fork.jsonl')
|
||||
const copiedPrefix = [
|
||||
`${JSON.stringify({
|
||||
type: 'session_meta',
|
||||
payload: { id: 'session-1', cwd: join(fakeHomeDir, 'repo') }
|
||||
})}\n`,
|
||||
usageRecord('2026-05-26T12:00:00.000Z', 10),
|
||||
usageRecord('2026-05-26T12:01:00.000Z', 5, 15)
|
||||
].join('')
|
||||
writeFileSync(originalPath, copiedPrefix, 'utf-8')
|
||||
writeFileSync(forkPath, `${copiedPrefix}${usageRecord('2026-05-26T12:02:00.000Z', 7, 22)}`)
|
||||
|
||||
const result = await scanCodexUsageFiles([], [])
|
||||
|
||||
expect(
|
||||
result.dailyAggregates.reduce((total, aggregate) => total + aggregate.totalTokens, 0)
|
||||
).toBe(22)
|
||||
expect(
|
||||
result.dailyAggregates.reduce((total, aggregate) => total + aggregate.eventCount, 0)
|
||||
).toBe(3)
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]?.totalTokens).toBe(22)
|
||||
})
|
||||
|
||||
it('dedupes fork copies even when session_meta id is rewritten', async () => {
|
||||
const sessionsDir = join(userDataDir, 'codex-runtime-home', 'home', 'sessions')
|
||||
mkdirSync(sessionsDir, { recursive: true })
|
||||
const originalPath = join(sessionsDir, 'aaaa-original.jsonl')
|
||||
const forkPath = join(sessionsDir, 'bbbb-fork.jsonl')
|
||||
const originalPrefix = [
|
||||
`${JSON.stringify({
|
||||
type: 'session_meta',
|
||||
payload: { id: 'session-original', cwd: join(fakeHomeDir, 'repo') }
|
||||
})}\n`,
|
||||
usageRecord('2026-05-26T12:00:00.000Z', 10),
|
||||
usageRecord('2026-05-26T12:01:00.000Z', 5, 15)
|
||||
].join('')
|
||||
// Same token_count records, different session_meta id (real resume/fork shape).
|
||||
const forkBody = [
|
||||
`${JSON.stringify({
|
||||
type: 'session_meta',
|
||||
payload: { id: 'session-fork', cwd: join(fakeHomeDir, 'repo') }
|
||||
})}\n`,
|
||||
usageRecord('2026-05-26T12:00:00.000Z', 10),
|
||||
usageRecord('2026-05-26T12:01:00.000Z', 5, 15),
|
||||
usageRecord('2026-05-26T12:02:00.000Z', 7, 22)
|
||||
].join('')
|
||||
writeFileSync(originalPath, originalPrefix, 'utf-8')
|
||||
writeFileSync(forkPath, forkBody)
|
||||
|
||||
const result = await scanCodexUsageFiles([], [])
|
||||
expect(
|
||||
result.dailyAggregates.reduce((total, aggregate) => total + aggregate.totalTokens, 0)
|
||||
).toBe(22)
|
||||
expect(result.processedFiles[0]?.ownedEventKeys).toHaveLength(2)
|
||||
expect(result.processedFiles[1]?.ownedEventKeys).toHaveLength(1)
|
||||
expect(result.processedFiles[1]?.hasDeferredClaims).toBe(true)
|
||||
})
|
||||
|
||||
it('does not re-count the cumulative session for copied total-only records', async () => {
|
||||
const sessionsDir = join(userDataDir, 'codex-runtime-home', 'home', 'sessions')
|
||||
mkdirSync(sessionsDir, { recursive: true })
|
||||
const originalPath = join(sessionsDir, 'aaaa-original.jsonl')
|
||||
const forkPath = join(sessionsDir, 'bbbb-fork.jsonl')
|
||||
const copiedPrefix = [
|
||||
`${JSON.stringify({
|
||||
type: 'session_meta',
|
||||
payload: { id: 'session-1', cwd: join(fakeHomeDir, 'repo') }
|
||||
})}\n`,
|
||||
totalOnlyUsageRecord('2026-05-26T12:00:00.000Z', 10),
|
||||
totalOnlyUsageRecord('2026-05-26T12:01:00.000Z', 15)
|
||||
].join('')
|
||||
writeFileSync(originalPath, copiedPrefix, 'utf-8')
|
||||
// Without cross-file ownership the fork's first copied total-only record
|
||||
// re-counts the entire cumulative session (10) as a fresh delta.
|
||||
writeFileSync(
|
||||
forkPath,
|
||||
`${copiedPrefix}${totalOnlyUsageRecord('2026-05-26T12:02:00.000Z', 22)}`
|
||||
)
|
||||
|
||||
const result = await scanCodexUsageFiles([], [])
|
||||
|
||||
expect(
|
||||
result.dailyAggregates.reduce((total, aggregate) => total + aggregate.totalTokens, 0)
|
||||
).toBe(22)
|
||||
expect(
|
||||
result.dailyAggregates.reduce((total, aggregate) => total + aggregate.eventCount, 0)
|
||||
).toBe(3)
|
||||
})
|
||||
|
||||
it('keeps fork dedupe stable when the original file is reused from cache', async () => {
|
||||
const sessionsDir = join(userDataDir, 'codex-runtime-home', 'home', 'sessions')
|
||||
mkdirSync(sessionsDir, { recursive: true })
|
||||
const originalPath = join(sessionsDir, 'aaaa-original.jsonl')
|
||||
const forkPath = join(sessionsDir, 'zzzz-fork.jsonl')
|
||||
const copiedPrefix = [
|
||||
`${JSON.stringify({
|
||||
type: 'session_meta',
|
||||
payload: { id: 'session-1', cwd: join(fakeHomeDir, 'repo') }
|
||||
})}\n`,
|
||||
usageRecord('2026-05-26T12:00:00.000Z', 10),
|
||||
usageRecord('2026-05-26T12:01:00.000Z', 5, 15)
|
||||
].join('')
|
||||
writeFileSync(originalPath, copiedPrefix, 'utf-8')
|
||||
|
||||
const first = await scanCodexUsageFiles([], [])
|
||||
expect(
|
||||
first.dailyAggregates.reduce((total, aggregate) => total + aggregate.totalTokens, 0)
|
||||
).toBe(15)
|
||||
expect(first.processedFiles[0]?.ownedEventKeys).toHaveLength(2)
|
||||
|
||||
// A fork appears later while the original stays unchanged (cache reuse).
|
||||
writeFileSync(forkPath, `${copiedPrefix}${usageRecord('2026-05-26T12:02:00.000Z', 7, 22)}`)
|
||||
|
||||
const second = await scanCodexUsageFiles([], first.processedFiles)
|
||||
expect(
|
||||
second.dailyAggregates.reduce((total, aggregate) => total + aggregate.totalTokens, 0)
|
||||
).toBe(22)
|
||||
expect(second.processedFiles.map((file) => file.ownedEventKeys.length)).toEqual([2, 1])
|
||||
|
||||
// Rescanning with the full cache stays stable.
|
||||
const third = await scanCodexUsageFiles([], second.processedFiles)
|
||||
expect(
|
||||
third.dailyAggregates.reduce((total, aggregate) => total + aggregate.totalTokens, 0)
|
||||
).toBe(22)
|
||||
})
|
||||
|
||||
it('reclaims copied token events when the owning original file is deleted', async () => {
|
||||
const sessionsDir = join(userDataDir, 'codex-runtime-home', 'home', 'sessions')
|
||||
mkdirSync(sessionsDir, { recursive: true })
|
||||
const originalPath = join(sessionsDir, 'aaaa-original.jsonl')
|
||||
const forkPath = join(sessionsDir, 'bbbb-fork.jsonl')
|
||||
const copiedPrefix = [
|
||||
`${JSON.stringify({
|
||||
type: 'session_meta',
|
||||
payload: { id: 'session-1', cwd: join(fakeHomeDir, 'repo') }
|
||||
})}\n`,
|
||||
usageRecord('2026-05-26T12:00:00.000Z', 10),
|
||||
usageRecord('2026-05-26T12:01:00.000Z', 5, 15)
|
||||
].join('')
|
||||
writeFileSync(originalPath, copiedPrefix, 'utf-8')
|
||||
writeFileSync(forkPath, `${copiedPrefix}${usageRecord('2026-05-26T12:02:00.000Z', 7, 22)}`)
|
||||
|
||||
const first = await scanCodexUsageFiles([], [])
|
||||
expect(
|
||||
first.dailyAggregates.reduce((total, aggregate) => total + aggregate.totalTokens, 0)
|
||||
).toBe(22)
|
||||
expect(first.processedFiles[0]?.ownedEventKeys).toHaveLength(2)
|
||||
expect(first.processedFiles[0]?.hasDeferredClaims).toBe(false)
|
||||
expect(first.processedFiles[1]?.ownedEventKeys).toHaveLength(1)
|
||||
expect(first.processedFiles[1]?.hasDeferredClaims).toBe(true)
|
||||
|
||||
// Deleting the owner must reparse deferred forks so they can re-claim the
|
||||
// copied prefix instead of permanently under-counting.
|
||||
unlinkSync(originalPath)
|
||||
|
||||
const second = await scanCodexUsageFiles([], first.processedFiles)
|
||||
expect(
|
||||
second.dailyAggregates.reduce((total, aggregate) => total + aggregate.totalTokens, 0)
|
||||
).toBe(22)
|
||||
expect(second.processedFiles).toHaveLength(1)
|
||||
expect(second.processedFiles[0]?.ownedEventKeys).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('keeps unrelated cached rollouts when a different owner file is deleted', async () => {
|
||||
const sessionsDir = join(userDataDir, 'codex-runtime-home', 'home', 'sessions')
|
||||
mkdirSync(sessionsDir, { recursive: true })
|
||||
const originalPath = join(sessionsDir, 'aaaa-original.jsonl')
|
||||
const forkPath = join(sessionsDir, 'bbbb-fork.jsonl')
|
||||
const unrelatedPath = join(sessionsDir, 'cccc-unrelated.jsonl')
|
||||
const copiedPrefix = [
|
||||
`${JSON.stringify({
|
||||
type: 'session_meta',
|
||||
payload: { id: 'session-1', cwd: join(fakeHomeDir, 'repo') }
|
||||
})}\n`,
|
||||
usageRecord('2026-05-26T12:00:00.000Z', 10),
|
||||
usageRecord('2026-05-26T12:01:00.000Z', 5, 15)
|
||||
].join('')
|
||||
writeFileSync(originalPath, copiedPrefix, 'utf-8')
|
||||
writeFileSync(forkPath, `${copiedPrefix}${usageRecord('2026-05-26T12:02:00.000Z', 7, 22)}`)
|
||||
writeFileSync(
|
||||
unrelatedPath,
|
||||
[
|
||||
`${JSON.stringify({
|
||||
type: 'session_meta',
|
||||
payload: { id: 'session-9', cwd: join(fakeHomeDir, 'repo') }
|
||||
})}\n`,
|
||||
usageRecord('2026-05-26T13:00:00.000Z', 3)
|
||||
].join('')
|
||||
)
|
||||
|
||||
const first = await scanCodexUsageFiles([], [])
|
||||
const unrelatedBefore = first.processedFiles.find((file) => file.path === unrelatedPath)
|
||||
expect(unrelatedBefore?.hasDeferredClaims).toBe(false)
|
||||
expect(unrelatedBefore?.ownedEventKeys).toHaveLength(1)
|
||||
|
||||
unlinkSync(originalPath)
|
||||
|
||||
const second = await scanCodexUsageFiles([], first.processedFiles)
|
||||
const unrelatedAfter = second.processedFiles.find((file) => file.path === unrelatedPath)
|
||||
expect(unrelatedAfter).toBe(unrelatedBefore)
|
||||
expect(
|
||||
second.dailyAggregates.reduce((total, aggregate) => total + aggregate.totalTokens, 0)
|
||||
).toBe(25)
|
||||
})
|
||||
|
||||
it('treats a leading total-only source suffix record as baseline', async () => {
|
||||
const runtimeSessionsDir = join(userDataDir, 'codex-runtime-home', 'home', 'sessions')
|
||||
const runtimeBridgeMarkerDir = join(
|
||||
|
||||
@@ -99,6 +99,7 @@ describe('parseCodexUsageRecord', () => {
|
||||
expect(first).toEqual({
|
||||
sessionId: 'session-1',
|
||||
timestamp: '2026-04-09T10:00:00.000Z',
|
||||
eventKey: expect.any(String),
|
||||
cwd: '/workspace/repo/packages/app',
|
||||
model: 'gpt-5.2-codex',
|
||||
hasInferredPricing: false,
|
||||
@@ -196,6 +197,7 @@ describe('attributeCodexUsageEvent', () => {
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
timestamp: '2026-04-09T10:00:00.000Z',
|
||||
eventKey: 'event-1',
|
||||
cwd: '/workspace/repo/app2/subdir',
|
||||
model: 'gpt-5.2-codex',
|
||||
hasInferredPricing: false,
|
||||
@@ -233,6 +235,7 @@ describe('attributeCodexUsageEvent', () => {
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
timestamp: '2026-04-09T10:00:00.000Z',
|
||||
eventKey: 'event-1',
|
||||
cwd: '/workspace/repo/..fixtures/session',
|
||||
model: 'gpt-5.2-codex',
|
||||
hasInferredPricing: false,
|
||||
@@ -263,6 +266,7 @@ describe('attributeCodexUsageEvent', () => {
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
timestamp: '2026-04-09T10:00:00.000Z',
|
||||
eventKey: 'event-1',
|
||||
cwd: '/workspace/repo/../other/session',
|
||||
model: 'gpt-5.2-codex',
|
||||
hasInferredPricing: false,
|
||||
@@ -292,6 +296,7 @@ describe('attributeCodexUsageEvent', () => {
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
timestamp: '2026-04-09T10:00:00.000Z',
|
||||
eventKey: 'event-1',
|
||||
cwd: 'D:\\other\\repo',
|
||||
model: 'gpt-5.2-codex',
|
||||
hasInferredPricing: false,
|
||||
|
||||
+108
-14
@@ -388,6 +388,28 @@ function resolveCodexUsageDelta(
|
||||
return null
|
||||
}
|
||||
|
||||
function buildCodexUsageEventKey(
|
||||
timestamp: string,
|
||||
totalUsage: CodexUsageRawUsage | null,
|
||||
lastUsage: CodexUsageRawUsage | null
|
||||
): string {
|
||||
// Why: fork/resume copies token_count records byte-for-byte into a new
|
||||
// rollout file, but session_meta.id is often rewritten to the new session.
|
||||
// Key only on the raw record fields (timestamp + usage tuples) so the copy
|
||||
// matches the original regardless of surrounding parse context / session id.
|
||||
const tupleOf = (usage: CodexUsageRawUsage | null): string =>
|
||||
usage
|
||||
? [
|
||||
usage.inputTokens,
|
||||
usage.cachedInputTokens,
|
||||
usage.outputTokens,
|
||||
usage.reasoningOutputTokens,
|
||||
usage.totalTokens
|
||||
].join(',')
|
||||
: ''
|
||||
return [timestamp, tupleOf(totalUsage), tupleOf(lastUsage)].join('|')
|
||||
}
|
||||
|
||||
function extractString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
@@ -956,6 +978,7 @@ export function parseCodexUsageRecord(
|
||||
return {
|
||||
sessionId: context.sessionId,
|
||||
timestamp: parsed.timestamp,
|
||||
eventKey: buildCodexUsageEventKey(parsed.timestamp, totalUsage, lastUsage),
|
||||
cwd: context.currentCwd ?? context.sessionCwd,
|
||||
model: resolvedModel,
|
||||
hasInferredPricing,
|
||||
@@ -970,7 +993,7 @@ export function parseCodexUsageRecord(
|
||||
export async function parseCodexUsageFile(
|
||||
filePath: string,
|
||||
worktrees: (CodexUsageWorktreeRef & { canonicalPath: string })[],
|
||||
options: { skipInitialBytes?: number } = {}
|
||||
options: { skipInitialBytes?: number; claimEventKey?: (eventKey: string) => boolean } = {}
|
||||
): Promise<CodexUsagePersistedFile> {
|
||||
const processedFile = await getProcessedFileInfo(filePath)
|
||||
const lines = createInterface({
|
||||
@@ -992,11 +1015,21 @@ export async function parseCodexUsageFile(
|
||||
totalOnlyBaselinePending: (options.skipInitialBytes ?? 0) > 0
|
||||
}
|
||||
|
||||
const ownedEventKeys = new Set<string>()
|
||||
let hasDeferredClaims = false
|
||||
for await (const line of lines) {
|
||||
const parsed = parseCodexUsageRecord(line, context)
|
||||
if (!parsed) {
|
||||
continue
|
||||
}
|
||||
// Why: fork/resume rollouts start with a copied prefix of the parent file.
|
||||
// Events another file already owns are dropped here, but the record still
|
||||
// advanced context.previousTotals above, so later deltas stay correct.
|
||||
if (options.claimEventKey && !options.claimEventKey(parsed.eventKey)) {
|
||||
hasDeferredClaims = true
|
||||
continue
|
||||
}
|
||||
ownedEventKeys.add(parsed.eventKey)
|
||||
const attributed = await attributeCodexUsageEvent(parsed, worktrees)
|
||||
if (attributed) {
|
||||
events.push(attributed)
|
||||
@@ -1005,7 +1038,9 @@ export async function parseCodexUsageFile(
|
||||
|
||||
return {
|
||||
...processedFile,
|
||||
...aggregateCodexUsage(events)
|
||||
...aggregateCodexUsage(events),
|
||||
ownedEventKeys: [...ownedEventKeys],
|
||||
hasDeferredClaims
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1019,31 +1054,77 @@ export async function scanCodexUsageFiles(
|
||||
}> {
|
||||
const files = await listCodexSessionFiles()
|
||||
const previousByPath = new Map(previousProcessedFiles.map((file) => [file.path, file]))
|
||||
const processedFiles: CodexUsagePersistedFile[] = []
|
||||
const worktreesWithCanonicalPaths = await buildWorktreesWithCanonicalPaths(worktrees)
|
||||
const legacySourceSkipBytesByPath = getLegacySourceSkipBytesByPath(files)
|
||||
const sessionsById = new Map<string, CodexUsageSession>()
|
||||
const dailyByKey = new Map<string, CodexUsageDailyAggregate>()
|
||||
|
||||
const currentPaths = new Set(files)
|
||||
// Why: when a rollout that owned event keys is deleted, remaining forks still
|
||||
// contain those records but their caches record them as unowned. Only files
|
||||
// that previously deferred claims can reclaim, so invalidate those — not the
|
||||
// entire rollout corpus.
|
||||
const lostOwnerPath = previousProcessedFiles.some(
|
||||
(file) =>
|
||||
!currentPaths.has(file.path) &&
|
||||
Array.isArray(file.ownedEventKeys) &&
|
||||
file.ownedEventKeys.length > 0
|
||||
)
|
||||
|
||||
const reusedByPath = new Map<string, CodexUsagePersistedFile>()
|
||||
const pathsToParse: string[] = []
|
||||
for (const [index, filePath] of files.entries()) {
|
||||
const legacySourceSkipBytes = legacySourceSkipBytesByPath.get(filePath) ?? 0
|
||||
const fileInfo = await getProcessedFileInfo(filePath)
|
||||
const previous = previousByPath.get(filePath)
|
||||
// When an owner disappears, only deferred-claim files need reparse.
|
||||
const mustReclaimDeferred = lostOwnerPath && previous?.hasDeferredClaims !== false
|
||||
const canReuse =
|
||||
!mustReclaimDeferred &&
|
||||
legacySourceSkipBytes === 0 &&
|
||||
previous &&
|
||||
previous.mtimeMs === fileInfo.mtimeMs &&
|
||||
previous.size === fileInfo.size
|
||||
previous.size === fileInfo.size &&
|
||||
Array.isArray(previous.ownedEventKeys) &&
|
||||
typeof previous.hasDeferredClaims === 'boolean'
|
||||
if (canReuse) {
|
||||
reusedByPath.set(filePath, previous)
|
||||
} else {
|
||||
pathsToParse.push(filePath)
|
||||
}
|
||||
if ((index + 1) % YIELD_EVERY_FILES === 0) {
|
||||
await yieldToEventLoop()
|
||||
}
|
||||
}
|
||||
|
||||
const processed = canReuse
|
||||
? previous
|
||||
: await parseCodexUsageFile(filePath, worktreesWithCanonicalPaths, {
|
||||
skipInitialBytes: legacySourceSkipBytes
|
||||
})
|
||||
// Why: resuming or forking a Codex session copies the parent rollout's
|
||||
// token_count records into a new file, so per-file parsing re-counts the
|
||||
// whole copied history once per descendant (#8006). Cross-file ownership
|
||||
// counts each record for exactly one file; cached files keep the claims
|
||||
// they persisted, and new files claim in sorted-path order so rescans stay
|
||||
// deterministic.
|
||||
const eventOwnerByKey = new Map<string, string>()
|
||||
for (const [filePath, previous] of reusedByPath) {
|
||||
for (const eventKey of previous.ownedEventKeys) {
|
||||
// First cached claim wins so conflicting projections stay deterministic.
|
||||
if (!eventOwnerByKey.has(eventKey)) {
|
||||
eventOwnerByKey.set(eventKey, filePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processedFiles.push(processed)
|
||||
mergeSessions(sessionsById, processed.sessions)
|
||||
mergeDailyAggregates(dailyByKey, processed.dailyAggregates)
|
||||
const parsedByPath = new Map<string, CodexUsagePersistedFile>()
|
||||
for (const [index, filePath] of pathsToParse.entries()) {
|
||||
const processed = await parseCodexUsageFile(filePath, worktreesWithCanonicalPaths, {
|
||||
skipInitialBytes: legacySourceSkipBytesByPath.get(filePath) ?? 0,
|
||||
claimEventKey: (eventKey) => {
|
||||
const owner = eventOwnerByKey.get(eventKey)
|
||||
if (owner !== undefined && owner !== filePath) {
|
||||
return false
|
||||
}
|
||||
eventOwnerByKey.set(eventKey, filePath)
|
||||
return true
|
||||
}
|
||||
})
|
||||
parsedByPath.set(filePath, processed)
|
||||
|
||||
// Why: Codex session history can grow large, and scans run on the Electron
|
||||
// main process. Yield regularly so opening Settings does not stall while
|
||||
@@ -1053,6 +1134,19 @@ export async function scanCodexUsageFiles(
|
||||
}
|
||||
}
|
||||
|
||||
const processedFiles: CodexUsagePersistedFile[] = []
|
||||
const sessionsById = new Map<string, CodexUsageSession>()
|
||||
const dailyByKey = new Map<string, CodexUsageDailyAggregate>()
|
||||
for (const filePath of files) {
|
||||
const processed = reusedByPath.get(filePath) ?? parsedByPath.get(filePath)
|
||||
if (!processed) {
|
||||
continue
|
||||
}
|
||||
processedFiles.push(processed)
|
||||
mergeSessions(sessionsById, processed.sessions)
|
||||
mergeDailyAggregates(dailyByKey, processed.dailyAggregates)
|
||||
}
|
||||
|
||||
return {
|
||||
processedFiles,
|
||||
sessions: finalizeSessions(sessionsById),
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('CodexUsageStore', () => {
|
||||
|
||||
it('persists a successful refresh with one compact disk write', async () => {
|
||||
const store = createStoreWithState({
|
||||
schemaVersion: 3,
|
||||
schemaVersion: 5,
|
||||
scanState: {
|
||||
enabled: true,
|
||||
lastScanStartedAt: null,
|
||||
@@ -138,7 +138,7 @@ describe('CodexUsageStore', () => {
|
||||
const pendingScan = createDeferred<ScanResult>()
|
||||
vi.mocked(scanCodexUsageFiles).mockReturnValueOnce(pendingScan.promise)
|
||||
const store = createStoreWithState({
|
||||
schemaVersion: 3,
|
||||
schemaVersion: 5,
|
||||
scanState: {
|
||||
enabled: true,
|
||||
lastScanStartedAt: null,
|
||||
@@ -768,7 +768,7 @@ describe('CodexUsageStore', () => {
|
||||
} as unknown as CodexUsagePersistedState)
|
||||
|
||||
expect(normalized).toEqual({
|
||||
schemaVersion: 3,
|
||||
schemaVersion: 5,
|
||||
worktreeFingerprint: null,
|
||||
processedFiles: [],
|
||||
sessions: [],
|
||||
|
||||
@@ -19,7 +19,10 @@ import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-w
|
||||
import type { CodexUsagePersistedState } from './types'
|
||||
import { createWorktreeRefs, scanCodexUsageFiles } from './scanner'
|
||||
|
||||
const SCHEMA_VERSION = 3
|
||||
// Why: v5 keys Codex ownership on raw token_count identity without session id
|
||||
// so forks that rewrite session_meta still match. Older caches used session-
|
||||
// scoped keys and can double-count after fork/resume (#8006).
|
||||
const SCHEMA_VERSION = 5
|
||||
const STALE_MS = 5 * 60_000
|
||||
const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000
|
||||
|
||||
|
||||
@@ -86,6 +86,14 @@ export type CodexUsageDailyAggregate = {
|
||||
export type CodexUsagePersistedFile = CodexUsageProcessedFile & {
|
||||
sessions: CodexUsageSession[]
|
||||
dailyAggregates: CodexUsageDailyAggregate[]
|
||||
/** Event keys this file counted. Resumed/forked rollouts copy earlier
|
||||
* token_count records into new files; ownership keeps each record counted
|
||||
* by exactly one cached file across incremental scans. */
|
||||
ownedEventKeys: string[]
|
||||
/** True when this file saw events already claimed by another file. When that
|
||||
* owner disappears, only deferred files need reparse to reclaim — not the
|
||||
* entire rollout corpus. */
|
||||
hasDeferredClaims: boolean
|
||||
}
|
||||
|
||||
export type CodexUsagePersistedState = {
|
||||
@@ -105,6 +113,10 @@ export type CodexUsagePersistedState = {
|
||||
export type CodexUsageParsedEvent = {
|
||||
sessionId: string
|
||||
timestamp: string
|
||||
/** Raw-record identity (timestamp + token tuples) used to dedupe the same
|
||||
* token_count record copied across fork/resume rollout files. Session id is
|
||||
* omitted because forks rewrite session_meta while copying the record. */
|
||||
eventKey: string
|
||||
model: string | null
|
||||
cwd: string | null
|
||||
hasInferredPricing: boolean
|
||||
|
||||
@@ -25,6 +25,7 @@ vi.mock('node:os', async () => {
|
||||
|
||||
import {
|
||||
prepareSystemConfigForFreshRuntimeMirror,
|
||||
resolveCodexConfigMirrorSourceDirectory,
|
||||
syncSystemConfigIntoManagedCodexHome
|
||||
} from './codex-config-mirror'
|
||||
|
||||
@@ -402,6 +403,20 @@ describe('syncSystemConfigIntoManagedCodexHome', () => {
|
||||
})
|
||||
|
||||
describe('prepareSystemConfigForFreshRuntimeMirror', () => {
|
||||
it('uses the Linux-side directory for WSL UNC source homes', () => {
|
||||
const sourceDir = resolveCodexConfigMirrorSourceDirectory(
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex'
|
||||
)
|
||||
|
||||
expect(sourceDir).toBe('/home/alice/.codex')
|
||||
expect(
|
||||
prepareSystemConfigForFreshRuntimeMirror(
|
||||
'model_instructions_file = "instructions.md"\n',
|
||||
sourceDir
|
||||
)
|
||||
).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'")
|
||||
})
|
||||
|
||||
it('rewrites relative paths against a Linux-side home and strips hook trust', () => {
|
||||
const prepared = prepareSystemConfigForFreshRuntimeMirror(
|
||||
[
|
||||
|
||||
@@ -3,9 +3,11 @@ import { dirname, join } from 'node:path'
|
||||
import { writeFileAtomically } from '../codex-accounts/fs-utils'
|
||||
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths'
|
||||
import { rewriteRelativePathConfigValues } from './codex-config-path-reference-rewrite'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import {
|
||||
promoteCodexRuntimeSettingsToSystem,
|
||||
snapshotCodexRuntimeSettingsBaseline
|
||||
snapshotCodexRuntimeSettingsBaseline,
|
||||
type CodexSettingsPromotionHomes
|
||||
} from './config-settings-promotion'
|
||||
import {
|
||||
createTomlLineScanState,
|
||||
@@ -14,33 +16,37 @@ import {
|
||||
updateTomlLineScanState
|
||||
} from './config-toml-line-scan'
|
||||
|
||||
function getRuntimeCodexConfigTomlPath(): string {
|
||||
return join(getOrcaManagedCodexHomePath(), 'config.toml')
|
||||
}
|
||||
|
||||
function getSystemCodexConfigTomlPath(): string {
|
||||
return join(getSystemCodexHomePath(), 'config.toml')
|
||||
}
|
||||
|
||||
export function syncSystemConfigIntoManagedCodexHome(): void {
|
||||
export function syncSystemConfigIntoManagedCodexHome(
|
||||
homes: CodexSettingsPromotionHomes = {
|
||||
runtimeHomePath: getOrcaManagedCodexHomePath(),
|
||||
systemHomePath: getSystemCodexHomePath()
|
||||
}
|
||||
): void {
|
||||
// Why: the mirror overwrites runtime settings from ~/.codex, so changes the
|
||||
// user made inside Orca-launched Codex (/model, /approvals) must be written
|
||||
// back to ~/.codex first or this very pass silently reverts them.
|
||||
promoteCodexRuntimeSettingsToSystem()
|
||||
if (!promoteCodexRuntimeSettingsToSystem(homes)) {
|
||||
// Why: mirroring after a failed write-back would erase the runtime change;
|
||||
// leave both runtime and its old baseline intact so the next launch retries.
|
||||
return
|
||||
}
|
||||
try {
|
||||
syncSystemConfigIntoManagedCodexHomeUnsafe()
|
||||
syncSystemConfigIntoManagedCodexHomeUnsafe(homes)
|
||||
} catch (error) {
|
||||
console.warn('[codex-config] Failed to mirror system Codex config:', error)
|
||||
return
|
||||
}
|
||||
// Why: the baseline advances only after a successful mirror; recording an
|
||||
// unpromoted runtime change as Orca-written would strand it forever.
|
||||
snapshotCodexRuntimeSettingsBaseline()
|
||||
snapshotCodexRuntimeSettingsBaseline(homes.runtimeHomePath)
|
||||
}
|
||||
|
||||
function syncSystemConfigIntoManagedCodexHomeUnsafe(): void {
|
||||
const systemConfigPath = getSystemCodexConfigTomlPath()
|
||||
const runtimeConfigPath = getRuntimeCodexConfigTomlPath()
|
||||
function syncSystemConfigIntoManagedCodexHomeUnsafe({
|
||||
runtimeHomePath,
|
||||
systemHomePath
|
||||
}: CodexSettingsPromotionHomes): void {
|
||||
const systemConfigPath = join(systemHomePath, 'config.toml')
|
||||
const runtimeConfigPath = join(runtimeHomePath, 'config.toml')
|
||||
const systemConfigExists = existsSync(systemConfigPath)
|
||||
const runtimeConfigExists = existsSync(runtimeConfigPath)
|
||||
if (!systemConfigExists && !runtimeConfigExists) {
|
||||
@@ -48,18 +54,16 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe(): void {
|
||||
}
|
||||
|
||||
const rawSystemConfig = systemConfigExists ? readFileSync(systemConfigPath, 'utf-8') : ''
|
||||
const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(systemHomePath)
|
||||
if (!runtimeConfigExists) {
|
||||
writeFileAtomically(
|
||||
runtimeConfigPath,
|
||||
prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, dirname(systemConfigPath))
|
||||
prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const systemConfig = prepareSystemConfigForRuntimeMirror(
|
||||
rawSystemConfig,
|
||||
dirname(systemConfigPath)
|
||||
)
|
||||
const systemConfig = prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir)
|
||||
const runtimeConfig = readFileSync(runtimeConfigPath, 'utf-8')
|
||||
const mergedConfig = mergeSystemCodexConfigIntoRuntime(runtimeConfig, systemConfig)
|
||||
if (mergedConfig !== runtimeConfig) {
|
||||
@@ -67,6 +71,10 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe(): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveCodexConfigMirrorSourceDirectory(systemHomePath: string): string {
|
||||
return parseWslUncPath(systemHomePath)?.linuxPath ?? dirname(join(systemHomePath, 'config.toml'))
|
||||
}
|
||||
|
||||
function prepareSystemConfigForRuntimeMirror(config: string, systemConfigDir: string): string {
|
||||
return rewriteRelativePathConfigValues(
|
||||
normalizeDeprecatedCodexHookFeatureFlag(config),
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
utimesSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import type * as Os from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type * as CodexFsUtils from '../codex-accounts/fs-utils'
|
||||
|
||||
const { homedirMock } = vi.hoisted(() => ({
|
||||
homedirMock: vi.fn<() => string>()
|
||||
const { homedirMock, promotionTestState } = vi.hoisted(() => ({
|
||||
homedirMock: vi.fn<() => string>(),
|
||||
promotionTestState: { failAtomicWrite: false }
|
||||
}))
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
@@ -16,6 +30,19 @@ vi.mock('node:os', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../codex-accounts/fs-utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof CodexFsUtils>()
|
||||
return {
|
||||
...actual,
|
||||
writeFileAtomically: (...args: Parameters<typeof actual.writeFileAtomically>) => {
|
||||
if (promotionTestState.failAtomicWrite) {
|
||||
throw new Error('injected atomic write failure')
|
||||
}
|
||||
return actual.writeFileAtomically(...args)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror'
|
||||
import { upsertTopLevelSettingsInContent } from './config-settings-promotion'
|
||||
|
||||
@@ -29,6 +56,7 @@ beforeEach(() => {
|
||||
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
|
||||
process.env.ORCA_USER_DATA_PATH = userDataDir
|
||||
homedirMock.mockReturnValue(tmpHome)
|
||||
promotionTestState.failAtomicWrite = false
|
||||
// Why: promotion writes into homedir()/.codex — if the mock ever fails to
|
||||
// intercept, these tests would rewrite the developer's real Codex config.
|
||||
if (homedir() !== tmpHome) {
|
||||
@@ -188,7 +216,9 @@ describe('codex settings write-back promotion', () => {
|
||||
})
|
||||
|
||||
it('creates ~/.codex/config.toml when a user without one changes a setting', () => {
|
||||
mkdirSync(join(tmpHome, '.codex'), { recursive: true })
|
||||
// Why: no mkdir of ~/.codex here — a genuinely fresh host has neither the
|
||||
// config nor its directory, and promotion must create both.
|
||||
expect(existsSync(join(tmpHome, '.codex'))).toBe(false)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
expect(existsSync(baselinePath())).toBe(true)
|
||||
|
||||
@@ -266,6 +296,110 @@ describe('codex settings write-back promotion', () => {
|
||||
|
||||
expect(readSystemConfig()).toBe('model = "o4"\n')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'preserves a restrictive mode on the existing ~/.codex/config.toml',
|
||||
() => {
|
||||
writeSystemConfig('model = "gpt-5"\n')
|
||||
chmodSync(systemConfigPath(), 0o600)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
simulateCodexSettingWrite('model', '"o4"')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe('model = "o4"\n')
|
||||
expect(statSync(systemConfigPath()).mode & 0o777).toBe(0o600)
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'creates a new ~/.codex/config.toml with owner-only permissions',
|
||||
() => {
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
simulateCodexSettingWrite('model', '"o4"')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(statSync(systemConfigPath()).mode & 0o777).toBe(0o600)
|
||||
// The created ~/.codex itself is owner-only — it will also hold
|
||||
// auth.json once the user signs in.
|
||||
expect(statSync(join(tmpHome, '.codex')).mode & 0o777).toBe(0o700)
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'writes through a symlinked config.toml without replacing the link',
|
||||
() => {
|
||||
mkdirSync(join(tmpHome, '.codex'), { recursive: true })
|
||||
const realConfigPath = join(tmpHome, 'dotfiles-config.toml')
|
||||
writeFileSync(realConfigPath, 'model = "gpt-5"\n', 'utf-8')
|
||||
symlinkSync(realConfigPath, systemConfigPath())
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
simulateCodexSettingWrite('model', '"o4"')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(lstatSync(systemConfigPath()).isSymbolicLink()).toBe(true)
|
||||
expect(readFileSync(realConfigPath, 'utf-8')).toBe('model = "o4"\n')
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'preserves a dangling config.toml symlink and creates its target',
|
||||
() => {
|
||||
mkdirSync(join(tmpHome, '.codex'), { recursive: true })
|
||||
const realConfigPath = join(tmpHome, 'dotfiles', 'config.toml')
|
||||
symlinkSync(realConfigPath, systemConfigPath())
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
simulateCodexSettingWrite('model', '"o4"')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(lstatSync(systemConfigPath()).isSymbolicLink()).toBe(true)
|
||||
expect(readFileSync(realConfigPath, 'utf-8')).toBe('model = "o4"\n')
|
||||
}
|
||||
)
|
||||
|
||||
it('inserts a missing key into a CRLF config with CRLF endings', () => {
|
||||
writeSystemConfig('model = "gpt-5"\r\n\r\n[features]\r\nhooks = true\r\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
simulateCodexSettingWrite('approval_policy', '"never"')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe(
|
||||
'model = "gpt-5"\r\napproval_policy = "never"\r\n\r\n[features]\r\nhooks = true\r\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not rewrite an unchanged settings baseline', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const past = new Date(Date.now() - 120_000)
|
||||
utimesSync(baselinePath(), past, past)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(statSync(baselinePath()).mtimeMs).toBeLessThan(Date.now() - 60_000)
|
||||
})
|
||||
|
||||
it('keeps the old baseline and retries after a transient promotion failure', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
const baselineBeforeFailure = readFileSync(baselinePath(), 'utf-8')
|
||||
simulateCodexSettingWrite('model', '"o4"')
|
||||
|
||||
promotionTestState.failAtomicWrite = true
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe('model = "gpt-5"\n')
|
||||
expect(readRuntimeConfig()).toBe('model = "o4"\n')
|
||||
expect(readFileSync(baselinePath(), 'utf-8')).toBe(baselineBeforeFailure)
|
||||
|
||||
promotionTestState.failAtomicWrite = false
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
expect(readSystemConfig()).toBe('model = "o4"\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('upsertTopLevelSettingsInContent', () => {
|
||||
@@ -293,4 +427,16 @@ describe('upsertTopLevelSettingsInContent', () => {
|
||||
)
|
||||
).toBe('# keep\nmodel = "new"\n\n[t]\nk = 1\n')
|
||||
})
|
||||
|
||||
it('inserts with CRLF endings into CRLF content', () => {
|
||||
expect(
|
||||
upsertTopLevelSettingsInContent('[features]\r\nhooks = true\r\n', new Map([['model', '"x"']]))
|
||||
).toBe('model = "x"\r\n\r\n[features]\r\nhooks = true\r\n')
|
||||
})
|
||||
|
||||
it('appends with CRLF to a CRLF preamble-only file', () => {
|
||||
expect(
|
||||
upsertTopLevelSettingsInContent('approval_policy = "never"\r\n', new Map([['model', '"x"']]))
|
||||
).toBe('approval_policy = "never"\r\nmodel = "x"\r\n')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
realpathSync,
|
||||
statSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { writeFileAtomically } from '../codex-accounts/fs-utils'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths'
|
||||
import {
|
||||
createTomlLineScanState,
|
||||
@@ -103,9 +113,10 @@ function readTopLevelSettingValues(configPath: string): Map<string, TopLevelSett
|
||||
* from "value Codex wrote for the user". Call after a successful mirror only —
|
||||
* advancing the baseline past an unpromoted change would strand it forever.
|
||||
*/
|
||||
export function snapshotCodexRuntimeSettingsBaseline(): void {
|
||||
export function snapshotCodexRuntimeSettingsBaseline(
|
||||
runtimeHomePath = getOrcaManagedCodexHomePath()
|
||||
): void {
|
||||
try {
|
||||
const runtimeHomePath = getOrcaManagedCodexHomePath()
|
||||
const runtimeTomlPath = join(runtimeHomePath, 'config.toml')
|
||||
// Why: a missing runtime config still records an empty baseline — when
|
||||
// Codex later creates the file for a user with no ~/.codex/config.toml,
|
||||
@@ -117,7 +128,14 @@ export function snapshotCodexRuntimeSettingsBaseline(): void {
|
||||
}
|
||||
}
|
||||
const file: SettingsBaselineFile = { version: 1, settings }
|
||||
writeFileSync(getSettingsBaselinePath(runtimeHomePath), `${JSON.stringify(file, null, 2)}\n`, {
|
||||
const baselinePath = getSettingsBaselinePath(runtimeHomePath)
|
||||
const serialized = `${JSON.stringify(file, null, 2)}\n`
|
||||
// Why: launch preparation can run repeatedly; skip byte-identical rewrites
|
||||
// so an unchanged pass does no disk writes.
|
||||
if (existsSync(baselinePath) && readFileSync(baselinePath, 'utf-8') === serialized) {
|
||||
return
|
||||
}
|
||||
writeFileSync(baselinePath, serialized, {
|
||||
encoding: 'utf-8',
|
||||
mode: 0o600
|
||||
})
|
||||
@@ -126,25 +144,39 @@ export function snapshotCodexRuntimeSettingsBaseline(): void {
|
||||
}
|
||||
}
|
||||
|
||||
export type CodexSettingsPromotionHomes = {
|
||||
runtimeHomePath: string
|
||||
systemHomePath: string
|
||||
}
|
||||
|
||||
function getHostPromotionHomes(): CodexSettingsPromotionHomes {
|
||||
return {
|
||||
runtimeHomePath: getOrcaManagedCodexHomePath(),
|
||||
systemHomePath: getSystemCodexHomePath()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Promotes setting changes the user made inside Orca-launched Codex (written
|
||||
* by Codex into the runtime config.toml) into ~/.codex/config.toml. Runs
|
||||
* before the config mirror so the promoted values survive the same mirror
|
||||
* pass instead of reverting.
|
||||
* pass instead of reverting. WSL callers pass explicit per-distro homes; the
|
||||
* default is the host runtime home and host ~/.codex.
|
||||
*/
|
||||
export function promoteCodexRuntimeSettingsToSystem(): void {
|
||||
export function promoteCodexRuntimeSettingsToSystem(homes?: CodexSettingsPromotionHomes): boolean {
|
||||
try {
|
||||
promoteCodexRuntimeSettingsToSystemUnsafe()
|
||||
promoteCodexRuntimeSettingsToSystemUnsafe(homes ?? getHostPromotionHomes())
|
||||
return true
|
||||
} catch (error) {
|
||||
// Why: promotion is best-effort launch prep; a malformed runtime file
|
||||
// must not block the config mirror or the Codex launch itself.
|
||||
// Why: promotion is best-effort launch prep; callers preserve the runtime
|
||||
// for retry, while a malformed file must not block Codex launch itself.
|
||||
console.warn('[codex-settings-promotion] failed to promote runtime settings', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function promoteCodexRuntimeSettingsToSystemUnsafe(): void {
|
||||
const runtimeHomePath = getOrcaManagedCodexHomePath()
|
||||
const systemHomePath = getSystemCodexHomePath()
|
||||
function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotionHomes): void {
|
||||
const { runtimeHomePath, systemHomePath } = homes
|
||||
const runtimeTomlPath = join(runtimeHomePath, 'config.toml')
|
||||
const systemTomlPath = join(systemHomePath, 'config.toml')
|
||||
if (resolve(runtimeTomlPath) === resolve(systemTomlPath)) {
|
||||
@@ -188,8 +220,71 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(): void {
|
||||
if (updates.size === 0) {
|
||||
return
|
||||
}
|
||||
const systemContent = existsSync(systemTomlPath) ? readFileSync(systemTomlPath, 'utf-8') : ''
|
||||
writeFileAtomically(systemTomlPath, upsertTopLevelSettingsInContent(systemContent, updates))
|
||||
// Why: a genuinely fresh host has no ~/.codex yet; without the directory
|
||||
// the atomic write ENOENTs and the following mirror wipes the setting.
|
||||
// Owner-only: the directory holds auth.json and the full user config.
|
||||
mkdirSync(systemHomePath, { recursive: true, mode: 0o700 })
|
||||
const writeTarget = resolvePromotionWriteTarget(systemTomlPath)
|
||||
// Why: a dangling dotfile-manager symlink can point into a directory tree
|
||||
// that has not been materialized yet; preserve the link and create its real
|
||||
// parent so the atomic temp file can be written beside the target.
|
||||
mkdirSync(dirname(writeTarget.path), { recursive: true, mode: 0o700 })
|
||||
const targetExists = existsSync(writeTarget.path)
|
||||
const systemContent = targetExists ? readFileSync(writeTarget.path, 'utf-8') : ''
|
||||
const nextContent = upsertTopLevelSettingsInContent(systemContent, updates)
|
||||
if (targetExists && parseWslUncPath(writeTarget.path)) {
|
||||
// Why: symlink metadata through the \\wsl$ 9P provider is not reliable
|
||||
// enough for realpath/lstat detection, and an atomic rename would replace
|
||||
// a WSL-side dotfile symlink with a plain file. Writing through the
|
||||
// existing file preserves the Linux-side inode (and its mode).
|
||||
writeFileSync(writeTarget.path, nextContent, 'utf-8')
|
||||
return
|
||||
}
|
||||
writeFileAtomically(writeTarget.path, nextContent, {
|
||||
mode: writeTarget.mode
|
||||
})
|
||||
}
|
||||
|
||||
// Why: promotion rewrites the user's real config.toml. Follow an existing
|
||||
// symlink (dotfile managers) instead of replacing the link with a plain file,
|
||||
// and carry the real file's mode forward — an atomic write without a mode
|
||||
// would widen a user-restricted 0600 config to the process umask default.
|
||||
// A new or unreadable target is created owner-only.
|
||||
function resolvePromotionWriteTarget(systemTomlPath: string): { path: string; mode: number } {
|
||||
try {
|
||||
const realPath = realpathSync(systemTomlPath)
|
||||
return { path: realPath, mode: statSync(realPath).mode & 0o777 }
|
||||
} catch {
|
||||
// Continue below: realpath also fails for a valid dangling dotfile link.
|
||||
}
|
||||
try {
|
||||
if (lstatSync(systemTomlPath).isSymbolicLink()) {
|
||||
const targetPath = resolveDanglingSymlinkTarget(systemTomlPath)
|
||||
return { path: targetPath, mode: 0o600 }
|
||||
}
|
||||
} catch {
|
||||
// Missing non-link targets are created owner-only at the requested path.
|
||||
}
|
||||
return { path: systemTomlPath, mode: 0o600 }
|
||||
}
|
||||
|
||||
function resolveDanglingSymlinkTarget(linkPath: string): string {
|
||||
let currentPath = linkPath
|
||||
const visited = new Set<string>()
|
||||
while (!visited.has(currentPath)) {
|
||||
visited.add(currentPath)
|
||||
try {
|
||||
if (!lstatSync(currentPath).isSymbolicLink()) {
|
||||
return currentPath
|
||||
}
|
||||
currentPath = resolve(dirname(currentPath), readlinkSync(currentPath))
|
||||
} catch {
|
||||
return currentPath
|
||||
}
|
||||
}
|
||||
// Why: replacing any link in a cycle would destroy dotfile-manager state;
|
||||
// abort promotion and leave the runtime/baseline intact for manual repair.
|
||||
throw new Error(`Codex config symlink cycle at ${linkPath}`)
|
||||
}
|
||||
|
||||
export function upsertTopLevelSettingsInContent(
|
||||
@@ -215,16 +310,17 @@ export function upsertTopLevelSettingsInContent(
|
||||
state = updateTomlLineScanState(state, line)
|
||||
}
|
||||
|
||||
// Why: CRLF configs keep a trailing \r after the split; new lines must use
|
||||
// the file's existing endings or a Windows-owned config becomes mixed-EOL.
|
||||
const usesCrlf = content.includes('\r\n')
|
||||
const insertions: string[] = []
|
||||
for (const [key, raw] of updates) {
|
||||
const existingIndex = keyLineIndexes.get(key)
|
||||
const rendered = `${key} = ${raw}`
|
||||
if (existingIndex !== undefined) {
|
||||
// Why: CRLF configs keep a trailing \r after the split; preserve it so
|
||||
// the rewritten line matches the file's existing endings.
|
||||
lines[existingIndex] = lines[existingIndex]?.endsWith('\r') ? `${rendered}\r` : rendered
|
||||
} else {
|
||||
insertions.push(rendered)
|
||||
insertions.push(usesCrlf ? `${rendered}\r` : rendered)
|
||||
}
|
||||
}
|
||||
if (insertions.length > 0) {
|
||||
@@ -233,10 +329,13 @@ export function upsertTopLevelSettingsInContent(
|
||||
insertAt -= 1
|
||||
}
|
||||
if (insertAt === preambleEnd && preambleEnd < lines.length) {
|
||||
insertions.push('')
|
||||
insertions.push(usesCrlf ? '\r' : '')
|
||||
}
|
||||
lines.splice(insertAt, 0, ...insertions)
|
||||
}
|
||||
const result = lines.join('\n')
|
||||
return result.endsWith('\n') || result.length === 0 ? result : `${result}\n`
|
||||
if (result.endsWith('\n') || result.length === 0) {
|
||||
return result
|
||||
}
|
||||
return result.endsWith('\r') ? `${result}\n` : `${result}${usesCrlf ? '\r\n' : '\n'}`
|
||||
}
|
||||
|
||||
@@ -213,6 +213,7 @@ describe('Codex WSL runtime hook install', () => {
|
||||
...process.env,
|
||||
PATH: `${binDir}:${process.env.PATH ?? ''}`,
|
||||
WSL_DISTRO_NAME: 'Ubuntu',
|
||||
ORCA_AGENT_HOOK_ENDPOINT: '',
|
||||
ORCA_AGENT_HOOK_PORT: '43210',
|
||||
ORCA_AGENT_HOOK_TOKEN: 'token',
|
||||
ORCA_PANE_KEY: 'pane-1',
|
||||
|
||||
@@ -6,6 +6,17 @@ import { ServeSimStateWatcher, type ServeSimStateDetectedEvent } from './serve-s
|
||||
|
||||
const TEST_UDID = '11111111-2222-3333-4444-555555555555'
|
||||
|
||||
let isolatedStateDirSeq = 0
|
||||
// Why: the default state dir is the real $TMPDIR/serve-sim — a leftover state
|
||||
// file from an actual simulator session would leak extra events into tests
|
||||
// that only exercise PTY ingestion. Point at a dir that never exists.
|
||||
function createIsolatedWatcher(): ServeSimStateWatcher {
|
||||
isolatedStateDirSeq += 1
|
||||
return new ServeSimStateWatcher({
|
||||
stateDir: join(tmpdir(), `orca-serve-sim-test-none-${process.pid}-${isolatedStateDirSeq}`)
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForEvent(
|
||||
events: ServeSimStateDetectedEvent[],
|
||||
predicate: (event: ServeSimStateDetectedEvent) => boolean
|
||||
@@ -68,7 +79,7 @@ describe('ServeSimStateWatcher', () => {
|
||||
})
|
||||
|
||||
it('suppresses Orca-managed sessions only while they are marked managed', async () => {
|
||||
const watcher = new ServeSimStateWatcher()
|
||||
const watcher = createIsolatedWatcher()
|
||||
const events: ServeSimStateDetectedEvent[] = []
|
||||
const payload = JSON.stringify({
|
||||
device: TEST_UDID,
|
||||
@@ -95,7 +106,7 @@ describe('ServeSimStateWatcher', () => {
|
||||
})
|
||||
|
||||
it('prunes worktree-scoped dedupe keys on forget so a re-bound worktree re-emits', () => {
|
||||
const watcher = new ServeSimStateWatcher()
|
||||
const watcher = createIsolatedWatcher()
|
||||
const events: ServeSimStateDetectedEvent[] = []
|
||||
const payload = JSON.stringify({
|
||||
device: TEST_UDID,
|
||||
@@ -122,7 +133,7 @@ describe('ServeSimStateWatcher', () => {
|
||||
})
|
||||
|
||||
it('dedupes one helper without hiding a later helper for the same simulator', () => {
|
||||
const watcher = new ServeSimStateWatcher()
|
||||
const watcher = createIsolatedWatcher()
|
||||
const events: ServeSimStateDetectedEvent[] = []
|
||||
const firstPayload = JSON.stringify({
|
||||
device: TEST_UDID,
|
||||
|
||||
+60
-11
@@ -848,6 +848,43 @@ async function fetchIssueWorkItem(
|
||||
return mapIssueWorkItem(JSON.parse(stdout) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
// REST /pulls/{n} has requested_reviewers but not latestReviews. When the JSON
|
||||
// `gh pr view` path fails, still pull review fields from gh so mobile/desktop
|
||||
// reviewer lists (CodeRabbit COMMENTED, etc.) are not silently empty.
|
||||
const WORK_ITEM_PR_REVIEW_JSON_FIELDS = 'reviewRequests,latestReviews'
|
||||
|
||||
async function fetchPullRequestReviewFields(
|
||||
number: number,
|
||||
ownerRepo: OwnerRepo | null,
|
||||
ghOptions: GhExecOptions
|
||||
): Promise<Pick<MainWorkItem, 'reviewRequests' | 'latestReviews'>> {
|
||||
try {
|
||||
const args = ownerRepo
|
||||
? [
|
||||
'pr',
|
||||
'view',
|
||||
String(number),
|
||||
'--repo',
|
||||
`${ownerRepo.owner}/${ownerRepo.repo}`,
|
||||
'--json',
|
||||
WORK_ITEM_PR_REVIEW_JSON_FIELDS
|
||||
]
|
||||
: ['pr', 'view', String(number), '--json', WORK_ITEM_PR_REVIEW_JSON_FIELDS]
|
||||
const { stdout } = await ghExecFileAsync(args, ghOptions)
|
||||
const item = JSON.parse(stdout) as Record<string, unknown>
|
||||
return {
|
||||
...(item.reviewRequests !== undefined
|
||||
? { reviewRequests: usersFromUnknown(item.reviewRequests) }
|
||||
: {}),
|
||||
...(item.latestReviews !== undefined
|
||||
? { latestReviews: latestReviewsFromUnknown(item.latestReviews) }
|
||||
: {})
|
||||
}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPullRequestWorkItem(
|
||||
repoPath: string,
|
||||
ownerRepo: OwnerRepo | null,
|
||||
@@ -872,24 +909,36 @@ async function fetchPullRequestWorkItem(
|
||||
)
|
||||
const item = JSON.parse(stdout) as Record<string, unknown>
|
||||
const mapped = mapPullRequestWorkItem(item, ownerRepo)
|
||||
// Why: merge-metadata GraphQL is best-effort. A failure here must not fall
|
||||
// through to the REST path below — that path drops latestReviews and blanks
|
||||
// the mobile/desktop reviewer list for bots that only left a review.
|
||||
const baseRefName = typeof item.baseRefName === 'string' ? item.baseRefName : undefined
|
||||
const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, baseRefName, ghOptions)
|
||||
return {
|
||||
...mapped,
|
||||
mergeQueueRequired: mergeMetadata.mergeQueueRequired,
|
||||
...(mergeMetadata.autoMergeAllowed !== null
|
||||
? { autoMergeAllowed: mergeMetadata.autoMergeAllowed }
|
||||
: {}),
|
||||
...(mergeMetadata.mergeMethodSettings
|
||||
? { mergeMethodSettings: mergeMetadata.mergeMethodSettings }
|
||||
: {})
|
||||
try {
|
||||
const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, baseRefName, ghOptions)
|
||||
return {
|
||||
...mapped,
|
||||
mergeQueueRequired: mergeMetadata.mergeQueueRequired,
|
||||
...(mergeMetadata.autoMergeAllowed !== null
|
||||
? { autoMergeAllowed: mergeMetadata.autoMergeAllowed }
|
||||
: {}),
|
||||
...(mergeMetadata.mergeMethodSettings
|
||||
? { mergeMethodSettings: mergeMetadata.mergeMethodSettings }
|
||||
: {})
|
||||
}
|
||||
} catch {
|
||||
return mapped
|
||||
}
|
||||
} catch {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`],
|
||||
ghOptions
|
||||
)
|
||||
return mapPullRequestWorkItem(JSON.parse(stdout) as Record<string, unknown>, ownerRepo)
|
||||
const mapped = mapPullRequestWorkItem(
|
||||
JSON.parse(stdout) as Record<string, unknown>,
|
||||
ownerRepo
|
||||
)
|
||||
const reviewFields = await fetchPullRequestReviewFields(number, ownerRepo, ghOptions)
|
||||
return { ...mapped, ...reviewFields }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
rebuildAppMenu
|
||||
} from './menu/register-app-menu'
|
||||
import { checkForUpdatesFromMenu, isQuittingForUpdate } from './updater'
|
||||
import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics'
|
||||
import {
|
||||
configureElectronNetworkCompatibility,
|
||||
configureDevUserDataPath,
|
||||
@@ -2210,6 +2211,11 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
if (isQuittingForUpdate()) {
|
||||
recordUpdaterLifecycle('before_quit_allowed', undefined, {
|
||||
message: 'before-quit allowed for update install'
|
||||
})
|
||||
}
|
||||
isQuitting = true
|
||||
unsubscribeSystemResumeBroadcast?.()
|
||||
unsubscribeSystemResumeBroadcast = null
|
||||
@@ -2231,6 +2237,14 @@ app.on('before-quit', () => {
|
||||
// async work and let Electron exit.
|
||||
let daemonDisconnectDone = false
|
||||
app.on('will-quit', (e) => {
|
||||
const updateQuitInProgress = isQuittingForUpdate()
|
||||
if (updateQuitInProgress) {
|
||||
recordUpdaterLifecycle(
|
||||
'will_quit_cleanup_started',
|
||||
{ daemonTeardown: 'disconnect' },
|
||||
{ message: 'will-quit cleanup for update install; daemonTeardown=disconnect' }
|
||||
)
|
||||
}
|
||||
// Why: before-quit can still be aborted by renderer beforeunload; wait until
|
||||
// the committed quit path before removing the Windows notification icon.
|
||||
destroySystemTray()
|
||||
|
||||
@@ -97,7 +97,43 @@ describe('registerLinearHandlers', () => {
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
|
||||
expect(listIssuesMock).toHaveBeenCalledWith('all', 216, 'workspace-1')
|
||||
expect(listIssuesMock).toHaveBeenCalledWith('all', 216, 'workspace-1', {
|
||||
attributeFilter: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('parses attribute filters and rejects malformed payloads before listIssues', async () => {
|
||||
listIssuesMock.mockResolvedValue({ items: [], hasMore: false })
|
||||
registerLinearHandlers()
|
||||
|
||||
await handlers['linear:listIssues'](null, {
|
||||
filter: 'all',
|
||||
limit: 20,
|
||||
workspaceId: 'workspace-1',
|
||||
attributeFilter: {
|
||||
stateIds: ['state-1'],
|
||||
priorities: [1],
|
||||
assignee: { kind: 'unassigned' },
|
||||
labelIds: []
|
||||
}
|
||||
})
|
||||
expect(listIssuesMock).toHaveBeenCalledWith('all', 20, 'workspace-1', {
|
||||
attributeFilter: {
|
||||
stateIds: ['state-1'],
|
||||
priorities: [1],
|
||||
assignee: { kind: 'unassigned' },
|
||||
labelIds: []
|
||||
}
|
||||
})
|
||||
|
||||
listIssuesMock.mockClear()
|
||||
await expect(
|
||||
handlers['linear:listIssues'](null, {
|
||||
filter: 'all',
|
||||
attributeFilter: { stateIds: [] }
|
||||
})
|
||||
).rejects.toThrow(/required/)
|
||||
expect(listIssuesMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards expanded Linear project issue limits through local IPC', async () => {
|
||||
|
||||
+16
-2
@@ -25,6 +25,7 @@ import {
|
||||
import { listTeams, getTeamStates, getTeamLabels, getTeamMembers } from '../linear/teams'
|
||||
import type { LinearListFilter } from '../linear/issues'
|
||||
import { clampLinearIssueListLimit } from '../../shared/linear-issue-read-limits'
|
||||
import { optionalParsedLinearIssueAttributeFilter } from '../../shared/linear-issue-attribute-filter'
|
||||
import type {
|
||||
LinearCustomViewModel,
|
||||
LinearIssueUpdate,
|
||||
@@ -131,13 +132,26 @@ export function registerLinearHandlers(): void {
|
||||
'linear:listIssues',
|
||||
async (
|
||||
_event,
|
||||
args?: { filter?: LinearListFilter; limit?: number; workspaceId?: LinearWorkspaceSelection }
|
||||
args?: {
|
||||
filter?: LinearListFilter
|
||||
limit?: number
|
||||
workspaceId?: LinearWorkspaceSelection
|
||||
attributeFilter?: unknown
|
||||
}
|
||||
) => {
|
||||
const filter = VALID_FILTERS.has(args?.filter as LinearListFilter)
|
||||
? (args!.filter as LinearListFilter)
|
||||
: undefined
|
||||
const limit = clampLinearIssueListLimit(args?.limit)
|
||||
return listIssues(filter, limit, normalizeWorkspaceSelection(args?.workspaceId))
|
||||
// Why: reject malformed filters at the trust boundary instead of
|
||||
// normalizing them to empty (which would silently broaden results).
|
||||
const attributeFilter =
|
||||
args && 'attributeFilter' in args && args.attributeFilter !== undefined
|
||||
? optionalParsedLinearIssueAttributeFilter(args.attributeFilter)
|
||||
: undefined
|
||||
return listIssues(filter, limit, normalizeWorkspaceSelection(args?.workspaceId), {
|
||||
attributeFilter
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
+1
-1
@@ -3928,7 +3928,7 @@ export function registerHeadlessPtyRuntime(
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill all PTY processes. Call on app quit.
|
||||
* Kill in-process local PTYs. Daemon-backed PTYs are preserved by daemon disconnect.
|
||||
*/
|
||||
export function killAllPty(): void {
|
||||
if (localProvider instanceof LocalPtyProvider) {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildLinearListIssueFilter } from './issue-list-filter'
|
||||
|
||||
describe('buildLinearListIssueFilter', () => {
|
||||
it('returns undefined for all preset without attributes or team', () => {
|
||||
expect(buildLinearListIssueFilter({ filter: 'all' })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('merges status ids, priority, assignee, and labels for the all preset', () => {
|
||||
expect(
|
||||
buildLinearListIssueFilter({
|
||||
filter: 'all',
|
||||
attributeFilter: {
|
||||
stateIds: ['state-1', 'state-2'],
|
||||
priorities: [0, 1],
|
||||
assignee: { kind: 'user', id: 'user-1' },
|
||||
labelIds: ['label-1', 'label-2']
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
state: { id: { in: ['state-1', 'state-2'] } },
|
||||
priority: { in: [0, 1] },
|
||||
assignee: { id: { eq: 'user-1' } },
|
||||
labels: { some: { id: { in: ['label-1', 'label-2'] } } }
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps preset state.type and attribute state.id together', () => {
|
||||
expect(
|
||||
buildLinearListIssueFilter({
|
||||
filter: 'assigned',
|
||||
attributeFilter: {
|
||||
stateIds: ['state-open'],
|
||||
priorities: [],
|
||||
assignee: null,
|
||||
labelIds: []
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
state: {
|
||||
type: { nin: ['completed', 'canceled'] },
|
||||
id: { in: ['state-open'] }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('shapes unassigned and optional team filters', () => {
|
||||
expect(
|
||||
buildLinearListIssueFilter({
|
||||
filter: 'all',
|
||||
teamId: 'team-1',
|
||||
attributeFilter: {
|
||||
stateIds: [],
|
||||
priorities: [],
|
||||
assignee: { kind: 'unassigned' },
|
||||
labelIds: []
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
team: { id: { eq: 'team-1' } },
|
||||
assignee: { null: true }
|
||||
})
|
||||
})
|
||||
|
||||
it('omits empty attribute filters so variables match the unfiltered path', () => {
|
||||
expect(
|
||||
buildLinearListIssueFilter({
|
||||
filter: 'completed',
|
||||
attributeFilter: {
|
||||
stateIds: [],
|
||||
priorities: [],
|
||||
assignee: null,
|
||||
labelIds: []
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
state: { type: { in: ['completed', 'canceled'] } }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
isEmptyLinearIssueAttributeFilter,
|
||||
type LinearIssueAttributeFilter
|
||||
} from '../../shared/linear-issue-attribute-filter'
|
||||
import type { LinearListFilter } from './issues'
|
||||
|
||||
// Why: attribute facets must merge into Linear's IssueFilter before the first-N
|
||||
// cursor walk so hasMore reflects the filtered set, not the unfiltered window.
|
||||
// @linear/sdk does not export IssueFilter as a public type, so we keep a
|
||||
// structural subset that matches the operators this list path actually sends.
|
||||
|
||||
export type LinearGraphQLIssueFilter = {
|
||||
state?: {
|
||||
type?: { nin?: string[]; in?: string[] }
|
||||
id?: { in?: string[] }
|
||||
}
|
||||
team?: { id?: { eq?: string } }
|
||||
priority?: { in?: number[] }
|
||||
assignee?: { null?: boolean; id?: { eq?: string } }
|
||||
labels?: { some?: { id?: { in?: string[] } } }
|
||||
}
|
||||
|
||||
const ACTIVE_STATE_FILTER: LinearGraphQLIssueFilter = {
|
||||
state: { type: { nin: ['completed', 'canceled'] } }
|
||||
}
|
||||
const COMPLETED_STATE_FILTER: LinearGraphQLIssueFilter = {
|
||||
state: { type: { in: ['completed', 'canceled'] } }
|
||||
}
|
||||
|
||||
function listFilterForState(filter: LinearListFilter): LinearGraphQLIssueFilter | undefined {
|
||||
if (filter === 'assigned' || filter === 'created' || filter === 'open') {
|
||||
return ACTIVE_STATE_FILTER
|
||||
}
|
||||
if (filter === 'completed') {
|
||||
return COMPLETED_STATE_FILTER
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function attributeFacetFilter(
|
||||
attributeFilter: LinearIssueAttributeFilter | null | undefined
|
||||
): LinearGraphQLIssueFilter | undefined {
|
||||
if (!attributeFilter || isEmptyLinearIssueAttributeFilter(attributeFilter)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const next: LinearGraphQLIssueFilter = {}
|
||||
|
||||
if (attributeFilter.stateIds.length > 0) {
|
||||
next.state = { id: { in: attributeFilter.stateIds } }
|
||||
}
|
||||
|
||||
if (attributeFilter.priorities.length > 0) {
|
||||
next.priority = { in: attributeFilter.priorities }
|
||||
}
|
||||
|
||||
if (attributeFilter.assignee?.kind === 'unassigned') {
|
||||
next.assignee = { null: true }
|
||||
} else if (attributeFilter.assignee?.kind === 'user') {
|
||||
next.assignee = { id: { eq: attributeFilter.assignee.id } }
|
||||
}
|
||||
|
||||
if (attributeFilter.labelIds.length > 0) {
|
||||
// Why: collection `some` is any-of; a direct labels.id comparator would
|
||||
// not express multi-label membership the same way.
|
||||
next.labels = { some: { id: { in: attributeFilter.labelIds } } }
|
||||
}
|
||||
|
||||
return Object.keys(next).length > 0 ? next : undefined
|
||||
}
|
||||
|
||||
function mergeIssueFilters(
|
||||
...parts: (LinearGraphQLIssueFilter | undefined)[]
|
||||
): LinearGraphQLIssueFilter | undefined {
|
||||
const defined = parts.filter((part): part is LinearGraphQLIssueFilter => part !== undefined)
|
||||
if (defined.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
if (defined.length === 1) {
|
||||
return defined[0]
|
||||
}
|
||||
|
||||
const merged: LinearGraphQLIssueFilter = {}
|
||||
for (const part of defined) {
|
||||
if (part.state) {
|
||||
// Why: preset state.type and attribute state.id must coexist; a top-level
|
||||
// spread would overwrite one nested constraint.
|
||||
merged.state = { ...merged.state, ...part.state }
|
||||
}
|
||||
if (part.team) {
|
||||
merged.team = part.team
|
||||
}
|
||||
if (part.priority) {
|
||||
merged.priority = part.priority
|
||||
}
|
||||
if (part.assignee) {
|
||||
merged.assignee = part.assignee
|
||||
}
|
||||
if (part.labels) {
|
||||
merged.labels = part.labels
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
export function buildLinearListIssueFilter(options: {
|
||||
filter: LinearListFilter
|
||||
teamId?: string
|
||||
attributeFilter?: LinearIssueAttributeFilter | null
|
||||
}): LinearGraphQLIssueFilter | undefined {
|
||||
const stateFilter = listFilterForState(options.filter)
|
||||
const teamFilter: LinearGraphQLIssueFilter | undefined = options.teamId
|
||||
? { team: { id: { eq: options.teamId } } }
|
||||
: undefined
|
||||
const attributes = attributeFacetFilter(options.attributeFilter)
|
||||
return mergeIssueFilters(stateFilter, teamFilter, attributes)
|
||||
}
|
||||
@@ -188,7 +188,9 @@ describe('Linear issue queries', () => {
|
||||
})
|
||||
const { listIssues } = await import('./issues')
|
||||
|
||||
await expect(listIssues('open', 10, 'workspace-1', 'team-1')).resolves.toMatchObject({
|
||||
await expect(
|
||||
listIssues('open', 10, 'workspace-1', { teamId: 'team-1' })
|
||||
).resolves.toMatchObject({
|
||||
items: [{ id: 'LIN-1' }],
|
||||
hasMore: false
|
||||
})
|
||||
@@ -202,6 +204,48 @@ describe('Linear issue queries', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('passes attribute facets into Linear GraphQL variables before pagination', async () => {
|
||||
rawRequest.mockResolvedValueOnce({
|
||||
data: { issues: { nodes: [rawIssue('LIN-1')], pageInfo: { hasNextPage: false } } }
|
||||
})
|
||||
const { listIssues } = await import('./issues')
|
||||
|
||||
await expect(
|
||||
listIssues('all', 10, 'workspace-1', {
|
||||
attributeFilter: {
|
||||
stateIds: ['state-1'],
|
||||
priorities: [0, 1],
|
||||
assignee: { kind: 'unassigned' },
|
||||
labelIds: ['label-1']
|
||||
}
|
||||
})
|
||||
).resolves.toMatchObject({ items: [{ id: 'LIN-1' }] })
|
||||
|
||||
expect(rawRequest.mock.calls[0][1]).toMatchObject({
|
||||
filter: {
|
||||
state: { id: { in: ['state-1'] } },
|
||||
priority: { in: [0, 1] },
|
||||
assignee: { null: true },
|
||||
labels: { some: { id: { in: ['label-1'] } } }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects non-empty attribute filters when workspace scope is all', async () => {
|
||||
const { listIssues } = await import('./issues')
|
||||
await expect(
|
||||
listIssues('all', 10, 'all', {
|
||||
attributeFilter: {
|
||||
stateIds: ['state-1'],
|
||||
priorities: [],
|
||||
assignee: null,
|
||||
labelIds: []
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/concrete workspace/i)
|
||||
expect(getClients).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps single-workspace search results in Linear relevance order', async () => {
|
||||
rawRequest.mockResolvedValueOnce({
|
||||
data: {
|
||||
|
||||
+38
-34
@@ -14,6 +14,10 @@ import {
|
||||
LINEAR_ISSUE_API_PAGE_SIZE_MAX,
|
||||
clampLinearIssueListLimit
|
||||
} from '../../shared/linear-issue-read-limits'
|
||||
import {
|
||||
isEmptyLinearIssueAttributeFilter,
|
||||
type LinearIssueAttributeFilter
|
||||
} from '../../shared/linear-issue-attribute-filter'
|
||||
import {
|
||||
acquire,
|
||||
release,
|
||||
@@ -22,8 +26,14 @@ import {
|
||||
clearToken,
|
||||
type LinearClientForWorkspace
|
||||
} from './client'
|
||||
import { buildLinearListIssueFilter } from './issue-list-filter'
|
||||
import { mapLinearIssue } from './mappers'
|
||||
|
||||
export type LinearIssueListOptions = {
|
||||
teamId?: string
|
||||
attributeFilter?: LinearIssueAttributeFilter | null
|
||||
}
|
||||
|
||||
type LinearIssueNode = {
|
||||
id: string
|
||||
identifier: string
|
||||
@@ -457,11 +467,17 @@ function getOldestIssueTime(issues: LinearIssue[]): number {
|
||||
function getListIssueConnectionLoader(
|
||||
entry: LinearClientForWorkspace,
|
||||
filter: LinearListFilter,
|
||||
teamId?: string
|
||||
options?: LinearIssueListOptions
|
||||
): LinearIssueConnectionLoader {
|
||||
const orderBy = 'updatedAt'
|
||||
const variables = { orderBy }
|
||||
const filterInput = listIssueFilter(filter, teamId)
|
||||
// Why: apply attribute + team filters in GraphQL variables before the first-N
|
||||
// cursor walk so pagination hasMore matches the filtered set.
|
||||
const filterInput = buildLinearListIssueFilter({
|
||||
filter,
|
||||
teamId: options?.teamId,
|
||||
attributeFilter: options?.attributeFilter
|
||||
})
|
||||
|
||||
if (filter === 'assigned') {
|
||||
return async (page) => {
|
||||
@@ -833,31 +849,6 @@ export async function searchIssues(
|
||||
|
||||
export type LinearListFilter = 'assigned' | 'created' | 'all' | 'completed' | 'open'
|
||||
|
||||
const ACTIVE_STATE_FILTER = { state: { type: { nin: ['completed', 'canceled'] } } }
|
||||
const COMPLETED_STATE_FILTER = { state: { type: { in: ['completed', 'canceled'] } } }
|
||||
|
||||
function listFilterForState(filter: LinearListFilter): Record<string, unknown> | undefined {
|
||||
if (filter === 'assigned' || filter === 'created' || filter === 'open') {
|
||||
return ACTIVE_STATE_FILTER
|
||||
}
|
||||
if (filter === 'completed') {
|
||||
return COMPLETED_STATE_FILTER
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function listIssueFilter(
|
||||
filter: LinearListFilter,
|
||||
teamId?: string
|
||||
): Record<string, unknown> | undefined {
|
||||
const stateFilter = listFilterForState(filter)
|
||||
const teamFilter = teamId ? { team: { id: { eq: teamId } } } : undefined
|
||||
if (stateFilter && teamFilter) {
|
||||
return { ...stateFilter, ...teamFilter }
|
||||
}
|
||||
return stateFilter ?? teamFilter
|
||||
}
|
||||
|
||||
type LinearIssuePageResult = {
|
||||
items: LinearIssue[]
|
||||
hasMore: boolean
|
||||
@@ -904,14 +895,14 @@ async function readListIssuesForWorkspace(
|
||||
filter: LinearListFilter,
|
||||
limit: number,
|
||||
workspaceId: LinearWorkspaceSelection | null | undefined,
|
||||
teamId?: string
|
||||
options?: LinearIssueListOptions
|
||||
): Promise<LinearCollectionResult<LinearIssue>> {
|
||||
await acquire()
|
||||
try {
|
||||
return await readIssueConnectionPages(
|
||||
entry,
|
||||
limit,
|
||||
getListIssueConnectionLoader(entry, filter, teamId)
|
||||
getListIssueConnectionLoader(entry, filter, options)
|
||||
)
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
@@ -1016,11 +1007,11 @@ async function readListIssuesAcrossWorkspaces(
|
||||
filter: LinearListFilter,
|
||||
limit: number,
|
||||
workspaceId: LinearWorkspaceSelection | null | undefined,
|
||||
teamId?: string
|
||||
options?: LinearIssueListOptions
|
||||
): Promise<LinearCollectionResult<LinearIssue>> {
|
||||
const states: LinearIssueWorkspacePageState[] = entries.map((entry) => ({
|
||||
entry,
|
||||
loadConnection: getListIssueConnectionLoader(entry, filter, teamId),
|
||||
loadConnection: getListIssueConnectionLoader(entry, filter, options),
|
||||
items: [],
|
||||
hasMore: false,
|
||||
canPage: false
|
||||
@@ -1063,19 +1054,32 @@ export async function listIssues(
|
||||
filter: LinearListFilter = 'assigned',
|
||||
limit = 20,
|
||||
workspaceId?: LinearWorkspaceSelection | null,
|
||||
teamId?: string
|
||||
options?: LinearIssueListOptions
|
||||
): Promise<LinearCollectionResult<LinearIssue>> {
|
||||
const effectiveLimit = clampLinearIssueListLimit(limit)
|
||||
const attributeFilter = options?.attributeFilter
|
||||
// Why: workspace-specific state/member/label ids cannot fan out safely across
|
||||
// "all" workspaces; reject before creating clients so non-UI callers cannot
|
||||
// get a misleading partial subset.
|
||||
if (
|
||||
attributeFilter &&
|
||||
!isEmptyLinearIssueAttributeFilter(attributeFilter) &&
|
||||
workspaceId === 'all'
|
||||
) {
|
||||
throw new Error(
|
||||
'Linear attribute filters require a concrete workspace; "all" workspaces is not supported.'
|
||||
)
|
||||
}
|
||||
const entries = getClients(workspaceId)
|
||||
if (entries.length === 0) {
|
||||
return { items: [] }
|
||||
}
|
||||
|
||||
if (entries.length === 1) {
|
||||
return readListIssuesForWorkspace(entries[0], filter, effectiveLimit, workspaceId, teamId)
|
||||
return readListIssuesForWorkspace(entries[0], filter, effectiveLimit, workspaceId, options)
|
||||
}
|
||||
|
||||
return readListIssuesAcrossWorkspaces(entries, filter, effectiveLimit, workspaceId, teamId)
|
||||
return readListIssuesAcrossWorkspaces(entries, filter, effectiveLimit, workspaceId, options)
|
||||
}
|
||||
|
||||
export async function createIssue(
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { mkdirSync, mkdtempSync, rmSync, unlinkSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import Database from '../sqlite/sync-database'
|
||||
import {
|
||||
attributeOpenCodeUsageEvent,
|
||||
parseOpenCodeUsageDatabase,
|
||||
parseOpenCodeUsageRow
|
||||
parseOpenCodeUsageRow,
|
||||
scanOpenCodeUsageDatabases
|
||||
} from './scanner'
|
||||
|
||||
const WORKTREE = '/workspace/repo'
|
||||
@@ -32,6 +33,50 @@ function worktrees() {
|
||||
]
|
||||
}
|
||||
|
||||
function createSessionTotalsSchema(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE session (
|
||||
id TEXT PRIMARY KEY,
|
||||
directory TEXT,
|
||||
title TEXT,
|
||||
model TEXT,
|
||||
cost REAL,
|
||||
tokens_input INTEGER,
|
||||
tokens_output INTEGER,
|
||||
tokens_reasoning INTEGER,
|
||||
tokens_cache_read INTEGER,
|
||||
time_created INTEGER,
|
||||
time_updated INTEGER
|
||||
);
|
||||
`)
|
||||
}
|
||||
|
||||
function insertSessionTotalsRow(
|
||||
db: Database.Database,
|
||||
sessionId: string,
|
||||
inputTokens: number
|
||||
): void {
|
||||
db.prepare(
|
||||
`INSERT INTO session (
|
||||
id, directory, title, model, cost,
|
||||
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read,
|
||||
time_created, time_updated
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
sessionId,
|
||||
`${WORKTREE}/packages/app`,
|
||||
'Session',
|
||||
JSON.stringify({ providerID: 'anthropic', id: 'claude-sonnet-4-5' }),
|
||||
0.01,
|
||||
inputTokens,
|
||||
100,
|
||||
0,
|
||||
0,
|
||||
1_777_777_700_000,
|
||||
1_777_777_800_000
|
||||
)
|
||||
}
|
||||
|
||||
function usageEvent(cwd: string) {
|
||||
return {
|
||||
sessionId: 'session-1',
|
||||
@@ -259,6 +304,17 @@ describe('parseOpenCodeUsageDatabase', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reports the session ids the database counted', async () => {
|
||||
const { db, path } = createTempDb()
|
||||
createSessionTotalsSchema(db)
|
||||
insertSessionTotalsRow(db, 'session-1', 1000)
|
||||
db.close()
|
||||
|
||||
const parsed = await parseOpenCodeUsageDatabase(path, worktrees())
|
||||
|
||||
expect(parsed.ownedSessionIds).toEqual(['session-1'])
|
||||
})
|
||||
|
||||
it('prefers session_message rows over legacy message rows to avoid double counting', async () => {
|
||||
const { db, path } = createTempDb()
|
||||
db.exec(`
|
||||
@@ -324,3 +380,154 @@ describe('parseOpenCodeUsageDatabase', () => {
|
||||
expect(parsed.sessions[0]?.eventCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scanOpenCodeUsageDatabases', () => {
|
||||
let dataRoot: string
|
||||
let openCodeDir: string
|
||||
let previousXdgDataHome: string | undefined
|
||||
let previousOpenCodeDb: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
dataRoot = mkdtempSync(join(tmpdir(), 'orca-opencode-usage-scan-'))
|
||||
openCodeDir = join(dataRoot, 'opencode')
|
||||
mkdirSync(openCodeDir, { recursive: true })
|
||||
previousXdgDataHome = process.env.XDG_DATA_HOME
|
||||
previousOpenCodeDb = process.env.OPENCODE_DB
|
||||
process.env.XDG_DATA_HOME = dataRoot
|
||||
delete process.env.OPENCODE_DB
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (previousXdgDataHome === undefined) {
|
||||
delete process.env.XDG_DATA_HOME
|
||||
} else {
|
||||
process.env.XDG_DATA_HOME = previousXdgDataHome
|
||||
}
|
||||
if (previousOpenCodeDb === undefined) {
|
||||
delete process.env.OPENCODE_DB
|
||||
} else {
|
||||
process.env.OPENCODE_DB = previousOpenCodeDb
|
||||
}
|
||||
rmSync(dataRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function writeSessionTotalsDb(fileName: string, rows: [string, number][]): string {
|
||||
const path = join(openCodeDir, fileName)
|
||||
const db = new Database(path)
|
||||
createSessionTotalsSchema(db)
|
||||
for (const [sessionId, inputTokens] of rows) {
|
||||
insertSessionTotalsRow(db, sessionId, inputTokens)
|
||||
}
|
||||
db.close()
|
||||
return path
|
||||
}
|
||||
|
||||
it('counts a session duplicated into a stale backup database exactly once', async () => {
|
||||
// The backup holds a stale snapshot of session-1; the canonical db has
|
||||
// grown since. The canonical totals must win and be counted once.
|
||||
writeSessionTotalsDb('opencode.db', [
|
||||
['session-1', 1000],
|
||||
['session-2', 300]
|
||||
])
|
||||
writeSessionTotalsDb('opencode-backup.db', [['session-1', 400]])
|
||||
|
||||
const result = await scanOpenCodeUsageDatabases([], [])
|
||||
|
||||
expect(result.sessions).toHaveLength(2)
|
||||
const sessionOne = result.sessions.find((session) => session.sessionId === 'session-1')
|
||||
expect(sessionOne?.totalInputTokens).toBe(1000)
|
||||
expect(sessionOne?.eventCount).toBe(1)
|
||||
expect(
|
||||
result.dailyAggregates.reduce((total, aggregate) => total + aggregate.inputTokens, 0)
|
||||
).toBe(1300)
|
||||
})
|
||||
|
||||
it('still counts backup-only sessions and stays stable across cached rescans', async () => {
|
||||
const canonicalPath = writeSessionTotalsDb('opencode.db', [['session-1', 1000]])
|
||||
// The backup duplicates session-1 (stale) and preserves session-9, which
|
||||
// no longer exists in the canonical database.
|
||||
writeSessionTotalsDb('opencode-backup.db', [
|
||||
['session-1', 400],
|
||||
['session-9', 50]
|
||||
])
|
||||
|
||||
const first = await scanOpenCodeUsageDatabases([], [])
|
||||
expect(
|
||||
first.dailyAggregates.reduce((total, aggregate) => total + aggregate.inputTokens, 0)
|
||||
).toBe(1050)
|
||||
|
||||
const second = await scanOpenCodeUsageDatabases([], first.processedDatabases)
|
||||
expect(
|
||||
second.dailyAggregates.reduce((total, aggregate) => total + aggregate.inputTokens, 0)
|
||||
).toBe(1050)
|
||||
|
||||
// The canonical db keeps growing while the backup stays cached; session-1
|
||||
// must stay owned by the canonical db.
|
||||
const db = new Database(canonicalPath)
|
||||
insertSessionTotalsRow(db, 'session-2', 200)
|
||||
db.close()
|
||||
|
||||
const third = await scanOpenCodeUsageDatabases([], second.processedDatabases)
|
||||
expect(
|
||||
third.dailyAggregates.reduce((total, aggregate) => total + aggregate.inputTokens, 0)
|
||||
).toBe(1250)
|
||||
const sessionOne = third.sessions.find((session) => session.sessionId === 'session-1')
|
||||
expect(sessionOne?.totalInputTokens).toBe(1000)
|
||||
expect(sessionOne?.eventCount).toBe(1)
|
||||
})
|
||||
|
||||
it('lets the live database reclaim sessions after a sticky backup-only claim', async () => {
|
||||
// First scan only has the backup (e.g. live db temporarily missing), so it
|
||||
// owns session-1 at the stale snapshot. When opencode.db reappears with
|
||||
// higher totals it must reclaim the session instead of staying frozen.
|
||||
writeSessionTotalsDb('opencode-backup.db', [['session-1', 400]])
|
||||
|
||||
const first = await scanOpenCodeUsageDatabases([], [])
|
||||
expect(
|
||||
first.dailyAggregates.reduce((total, aggregate) => total + aggregate.inputTokens, 0)
|
||||
).toBe(400)
|
||||
expect(first.processedDatabases[0]?.ownedSessionIds).toEqual(['session-1'])
|
||||
|
||||
writeSessionTotalsDb('opencode.db', [
|
||||
['session-1', 1000],
|
||||
['session-2', 200]
|
||||
])
|
||||
|
||||
const second = await scanOpenCodeUsageDatabases([], first.processedDatabases)
|
||||
expect(
|
||||
second.dailyAggregates.reduce((total, aggregate) => total + aggregate.inputTokens, 0)
|
||||
).toBe(1200)
|
||||
const sessionOne = second.sessions.find((session) => session.sessionId === 'session-1')
|
||||
expect(sessionOne?.totalInputTokens).toBe(1000)
|
||||
expect(sessionOne?.eventCount).toBe(1)
|
||||
})
|
||||
|
||||
it('reclaims sessions when the owning live database is deleted', async () => {
|
||||
const canonicalPath = writeSessionTotalsDb('opencode.db', [
|
||||
['session-1', 1000],
|
||||
['session-2', 200]
|
||||
])
|
||||
writeSessionTotalsDb('opencode-backup.db', [
|
||||
['session-1', 400],
|
||||
['session-9', 50]
|
||||
])
|
||||
|
||||
const first = await scanOpenCodeUsageDatabases([], [])
|
||||
expect(
|
||||
first.dailyAggregates.reduce((total, aggregate) => total + aggregate.inputTokens, 0)
|
||||
).toBe(1250)
|
||||
|
||||
unlinkSync(canonicalPath)
|
||||
|
||||
const second = await scanOpenCodeUsageDatabases([], first.processedDatabases)
|
||||
expect(
|
||||
second.dailyAggregates.reduce((total, aggregate) => total + aggregate.inputTokens, 0)
|
||||
).toBe(450)
|
||||
expect(second.sessions.map((session) => session.sessionId).sort()).toEqual([
|
||||
'session-1',
|
||||
'session-9'
|
||||
])
|
||||
const sessionOne = second.sessions.find((session) => session.sessionId === 'session-1')
|
||||
expect(sessionOne?.totalInputTokens).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { readdir, realpath, stat } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { isAbsolute, join, posix, win32 } from 'node:path'
|
||||
import { basename, isAbsolute, join, posix, win32 } from 'node:path'
|
||||
import type { Repo } from '../../shared/types'
|
||||
import { areWorktreePathsEqual } from '../ipc/worktree-logic'
|
||||
import Database from '../sqlite/sync-database'
|
||||
@@ -121,6 +121,18 @@ export async function listOpenCodeDatabases(): Promise<string[]> {
|
||||
}
|
||||
}
|
||||
|
||||
function compareOpenCodeClaimPriority(left: string, right: string): number {
|
||||
// Why: the canonical opencode.db is the live database; it must claim
|
||||
// duplicated sessions ahead of stale sibling copies. Remaining ties use
|
||||
// path order so ownership is deterministic across rescans.
|
||||
const leftRank = basename(left).toLowerCase() === 'opencode.db' ? 0 : 1
|
||||
const rightRank = basename(right).toLowerCase() === 'opencode.db' ? 0 : 1
|
||||
if (leftRank !== rightRank) {
|
||||
return leftRank - rightRank
|
||||
}
|
||||
return left < right ? -1 : left > right ? 1 : 0
|
||||
}
|
||||
|
||||
export async function getProcessedDatabaseInfo(
|
||||
dbPath: string
|
||||
): Promise<OpenCodeUsageProcessedDatabase> {
|
||||
@@ -831,18 +843,32 @@ function mergeDailyAggregates(
|
||||
|
||||
export async function parseOpenCodeUsageDatabase(
|
||||
dbPath: string,
|
||||
worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[]
|
||||
worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[],
|
||||
options: { claimSession?: (sessionId: string) => boolean } = {}
|
||||
): Promise<OpenCodeUsagePersistedDatabase> {
|
||||
const processedDatabase = await getProcessedDatabaseInfo(dbPath)
|
||||
const db = new Database(dbPath, { readonly: true, fileMustExist: true })
|
||||
try {
|
||||
db.pragma('query_only = ON')
|
||||
const events: OpenCodeUsageAttributedEvent[] = []
|
||||
const claimedBySessionId = new Map<string, boolean>()
|
||||
let hasDeferredClaims = false
|
||||
for (const row of selectUsageRows(db)) {
|
||||
const parsed = parseOpenCodeUsageRow(row)
|
||||
if (!parsed) {
|
||||
continue
|
||||
}
|
||||
// Why: a stale sibling copy of opencode.db carries the same sessions, so
|
||||
// each session must be counted from exactly one database (#8006).
|
||||
let owned = claimedBySessionId.get(parsed.sessionId)
|
||||
if (owned === undefined) {
|
||||
owned = options.claimSession ? options.claimSession(parsed.sessionId) : true
|
||||
claimedBySessionId.set(parsed.sessionId, owned)
|
||||
}
|
||||
if (!owned) {
|
||||
hasDeferredClaims = true
|
||||
continue
|
||||
}
|
||||
const attributed = await attributeOpenCodeUsageEvent(parsed, worktrees)
|
||||
if (attributed) {
|
||||
events.push(attributed)
|
||||
@@ -850,7 +876,11 @@ export async function parseOpenCodeUsageDatabase(
|
||||
}
|
||||
return {
|
||||
...processedDatabase,
|
||||
...aggregateOpenCodeUsage(events)
|
||||
...aggregateOpenCodeUsage(events),
|
||||
ownedSessionIds: [...claimedBySessionId.entries()]
|
||||
.filter(([, owned]) => owned)
|
||||
.map(([sessionId]) => sessionId),
|
||||
hasDeferredClaims
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
@@ -869,29 +899,107 @@ export async function scanOpenCodeUsageDatabases(
|
||||
const previousByPath = new Map(
|
||||
previousProcessedDatabases.map((database) => [database.path, database])
|
||||
)
|
||||
const processedDatabases: OpenCodeUsagePersistedDatabase[] = []
|
||||
const worktreesWithCanonicalPaths = await buildWorktreesWithCanonicalPaths(worktrees)
|
||||
const sessionsById = new Map<string, OpenCodeUsageSession>()
|
||||
const dailyByKey = new Map<string, OpenCodeUsageDailyAggregate>()
|
||||
|
||||
for (const [index, dbPath] of dbPaths.entries()) {
|
||||
const currentPaths = new Set(dbPaths)
|
||||
// Why: when a database that owned sessions is deleted, remaining siblings
|
||||
// still contain those sessions but their caches record them as unowned.
|
||||
// Only databases that previously deferred claims can reclaim.
|
||||
const lostOwnerPath = previousProcessedDatabases.some(
|
||||
(database) =>
|
||||
!currentPaths.has(database.path) &&
|
||||
Array.isArray(database.ownedSessionIds) &&
|
||||
database.ownedSessionIds.length > 0
|
||||
)
|
||||
|
||||
const reusedByPath = new Map<string, OpenCodeUsagePersistedDatabase>()
|
||||
const pathsToParse: string[] = []
|
||||
for (const dbPath of dbPaths) {
|
||||
const databaseInfo = await getProcessedDatabaseInfo(dbPath)
|
||||
const previous = previousByPath.get(dbPath)
|
||||
// When an owner disappears, only deferred-claim databases need reparse.
|
||||
const mustReclaimDeferred = lostOwnerPath && previous?.hasDeferredClaims !== false
|
||||
const canReuse =
|
||||
previous && previous.mtimeMs === databaseInfo.mtimeMs && previous.size === databaseInfo.size
|
||||
const processed = canReuse
|
||||
? previous
|
||||
: await parseOpenCodeUsageDatabase(dbPath, worktreesWithCanonicalPaths)
|
||||
!mustReclaimDeferred &&
|
||||
previous &&
|
||||
previous.mtimeMs === databaseInfo.mtimeMs &&
|
||||
previous.size === databaseInfo.size &&
|
||||
Array.isArray(previous.ownedSessionIds) &&
|
||||
typeof previous.hasDeferredClaims === 'boolean'
|
||||
if (canReuse) {
|
||||
reusedByPath.set(dbPath, previous)
|
||||
} else {
|
||||
pathsToParse.push(dbPath)
|
||||
}
|
||||
}
|
||||
|
||||
processedDatabases.push(processed)
|
||||
mergeSessions(sessionsById, processed.sessions)
|
||||
mergeDailyAggregates(dailyByKey, processed.dailyAggregates)
|
||||
// Why: a sticky backup claim from a scan where opencode.db was missing would
|
||||
// otherwise freeze a still-growing session at the backup snapshot when the
|
||||
// live db reappears. Reparse lower-priority siblings whenever a higher-
|
||||
// priority path is being re-evaluated so the live db can reclaim sessions
|
||||
// without double-counting cached backup aggregates.
|
||||
const demotedReusePaths: string[] = []
|
||||
for (const dbPath of reusedByPath.keys()) {
|
||||
const higherPriorityParsing = pathsToParse.some(
|
||||
(candidate) => compareOpenCodeClaimPriority(candidate, dbPath) < 0
|
||||
)
|
||||
if (higherPriorityParsing) {
|
||||
demotedReusePaths.push(dbPath)
|
||||
}
|
||||
}
|
||||
for (const dbPath of demotedReusePaths) {
|
||||
reusedByPath.delete(dbPath)
|
||||
pathsToParse.push(dbPath)
|
||||
}
|
||||
|
||||
// Why: `opencode-*.db` siblings are typically stale copies of `opencode.db`
|
||||
// (backups), so mergeSessions would double every duplicated session (#8006).
|
||||
// Each session is counted from exactly one database. The canonical live db
|
||||
// claims first so a stale backup cannot freeze a still-growing session at
|
||||
// its snapshot totals; cached databases keep the claims they persisted.
|
||||
const sessionOwnerById = new Map<string, string>()
|
||||
for (const dbPath of [...reusedByPath.keys()].sort(compareOpenCodeClaimPriority)) {
|
||||
const previous = reusedByPath.get(dbPath)
|
||||
for (const sessionId of previous?.ownedSessionIds ?? []) {
|
||||
if (!sessionOwnerById.has(sessionId)) {
|
||||
sessionOwnerById.set(sessionId, dbPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parsedByPath = new Map<string, OpenCodeUsagePersistedDatabase>()
|
||||
const orderedPathsToParse = [...pathsToParse].sort(compareOpenCodeClaimPriority)
|
||||
for (const [index, dbPath] of orderedPathsToParse.entries()) {
|
||||
const processed = await parseOpenCodeUsageDatabase(dbPath, worktreesWithCanonicalPaths, {
|
||||
claimSession: (sessionId) => {
|
||||
const owner = sessionOwnerById.get(sessionId)
|
||||
if (owner !== undefined && owner !== dbPath) {
|
||||
return false
|
||||
}
|
||||
sessionOwnerById.set(sessionId, dbPath)
|
||||
return true
|
||||
}
|
||||
})
|
||||
parsedByPath.set(dbPath, processed)
|
||||
|
||||
if ((index + 1) % YIELD_EVERY_DATABASES === 0) {
|
||||
await yieldToEventLoop()
|
||||
}
|
||||
}
|
||||
|
||||
const processedDatabases: OpenCodeUsagePersistedDatabase[] = []
|
||||
const sessionsById = new Map<string, OpenCodeUsageSession>()
|
||||
const dailyByKey = new Map<string, OpenCodeUsageDailyAggregate>()
|
||||
for (const dbPath of dbPaths) {
|
||||
const processed = reusedByPath.get(dbPath) ?? parsedByPath.get(dbPath)
|
||||
if (!processed) {
|
||||
continue
|
||||
}
|
||||
processedDatabases.push(processed)
|
||||
mergeSessions(sessionsById, processed.sessions)
|
||||
mergeDailyAggregates(dailyByKey, processed.dailyAggregates)
|
||||
}
|
||||
|
||||
return {
|
||||
processedDatabases,
|
||||
sessions: finalizeSessions(sessionsById),
|
||||
|
||||
@@ -19,7 +19,7 @@ import { OpenCodeUsageStore, normalizePersistedState } from './store'
|
||||
|
||||
function getDefaultState(): OpenCodeUsagePersistedState {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
worktreeFingerprint: null,
|
||||
processedDatabases: [],
|
||||
sessions: [],
|
||||
@@ -283,7 +283,9 @@ describe('OpenCodeUsageStore', () => {
|
||||
mtimeMs: 1,
|
||||
size: 2,
|
||||
sessions: [makeSession()],
|
||||
dailyAggregates: [makeDaily()]
|
||||
dailyAggregates: [makeDaily()],
|
||||
ownedSessionIds: ['session-1'],
|
||||
hasDeferredClaims: false
|
||||
}
|
||||
],
|
||||
sessions: [makeSession()],
|
||||
@@ -300,7 +302,9 @@ describe('OpenCodeUsageStore', () => {
|
||||
mtimeMs: 1,
|
||||
size: 2,
|
||||
sessions: [],
|
||||
dailyAggregates: []
|
||||
dailyAggregates: [],
|
||||
ownedSessionIds: [],
|
||||
hasDeferredClaims: false
|
||||
}
|
||||
]
|
||||
}).processedDatabases
|
||||
|
||||
@@ -18,7 +18,9 @@ import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-w
|
||||
import type { OpenCodeUsageDailyAggregate, OpenCodeUsagePersistedState } from './types'
|
||||
import { createWorktreeRefs, scanOpenCodeUsageDatabases } from './scanner'
|
||||
|
||||
const SCHEMA_VERSION = 1
|
||||
// Why: v2 adds per-database session ownership (stale sibling-copy dedupe).
|
||||
// Older caches were built without it and can carry doubled sessions (#8006).
|
||||
const SCHEMA_VERSION = 2
|
||||
const STALE_MS = 5 * 60_000
|
||||
|
||||
let _openCodeUsageFile: string | null = null
|
||||
|
||||
@@ -86,6 +86,13 @@ export type OpenCodeUsageDailyAggregate = {
|
||||
export type OpenCodeUsagePersistedDatabase = OpenCodeUsageProcessedDatabase & {
|
||||
sessions: OpenCodeUsageSession[]
|
||||
dailyAggregates: OpenCodeUsageDailyAggregate[]
|
||||
/** Session ids this database counted. Sibling copies (opencode-backup.db)
|
||||
* duplicate sessions; ownership keeps each session counted by exactly one
|
||||
* cached database across incremental scans. */
|
||||
ownedSessionIds: string[]
|
||||
/** True when this database saw sessions already claimed by another database.
|
||||
* When that owner disappears, only deferred databases need reparse. */
|
||||
hasDeferredClaims: boolean
|
||||
}
|
||||
|
||||
export type OpenCodeUsagePersistedState = {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type {
|
||||
OrcaCloudCapabilities,
|
||||
OrcaCloudOrgSummary
|
||||
} from '../../shared/orca-profiles'
|
||||
import type { OrcaCloudCapabilities, OrcaCloudOrgSummary } from '../../shared/orca-profiles'
|
||||
import type { OrcaCloudSessionExchangeResponse } from './profile-cloud-session-exchange'
|
||||
|
||||
const DEV_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
@@ -138,9 +138,7 @@ describe('Orca cloud profile auth-failure retry', () => {
|
||||
safeStorageMock.encryptString.mockReset()
|
||||
safeStorageMock.isEncryptionAvailable.mockReset()
|
||||
safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf-8'))
|
||||
safeStorageMock.encryptString.mockImplementation((value: string) =>
|
||||
Buffer.from(value, 'utf-8')
|
||||
)
|
||||
safeStorageMock.encryptString.mockImplementation((value: string) => Buffer.from(value, 'utf-8'))
|
||||
safeStorageMock.isEncryptionAvailable.mockReturnValue(true)
|
||||
vi.unstubAllEnvs()
|
||||
vi.stubEnv('ORCA_CLOUD_API_URL', '')
|
||||
|
||||
@@ -123,9 +123,7 @@ describe('Orca cloud profile service session refresh', () => {
|
||||
safeStorageMock.encryptString.mockReset()
|
||||
safeStorageMock.isEncryptionAvailable.mockReset()
|
||||
safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf-8'))
|
||||
safeStorageMock.encryptString.mockImplementation((value: string) =>
|
||||
Buffer.from(value, 'utf-8')
|
||||
)
|
||||
safeStorageMock.encryptString.mockImplementation((value: string) => Buffer.from(value, 'utf-8'))
|
||||
safeStorageMock.isEncryptionAvailable.mockReturnValue(true)
|
||||
vi.unstubAllEnvs()
|
||||
vi.stubEnv('ORCA_CLOUD_API_URL', '')
|
||||
|
||||
@@ -114,9 +114,7 @@ describe('Orca cloud profile service', () => {
|
||||
safeStorageMock.encryptString.mockReset()
|
||||
safeStorageMock.isEncryptionAvailable.mockReset()
|
||||
safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf-8'))
|
||||
safeStorageMock.encryptString.mockImplementation((value: string) =>
|
||||
Buffer.from(value, 'utf-8')
|
||||
)
|
||||
safeStorageMock.encryptString.mockImplementation((value: string) => Buffer.from(value, 'utf-8'))
|
||||
safeStorageMock.isEncryptionAvailable.mockReturnValue(true)
|
||||
revokeOrcaCloudSessionMock.mockResolvedValue(undefined)
|
||||
vi.unstubAllEnvs()
|
||||
|
||||
@@ -3094,6 +3094,12 @@ export class Store {
|
||||
),
|
||||
settings: {
|
||||
...defaults.settings,
|
||||
// Why (#7977): a persisted experimentalNewWorktreeCardStyle:true is
|
||||
// kept even though the default is now false. The v1.4.130 open-
|
||||
// onboarding auto-default wrote the same plain boolean as a real
|
||||
// opt-in, so a rollback migration would also revert genuine opt-ins;
|
||||
// product intent was only to change the default, and the setting
|
||||
// stays user-toggleable.
|
||||
...stripLegacyTerminalScrollbackBytes(parsed.settings),
|
||||
// Why: v1.3.42 renamed the cosmetic sidekick setting to pet. Carry
|
||||
// the old persisted flag forward once so enabled users don't lose it.
|
||||
|
||||
@@ -1044,7 +1044,7 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
return ptyProcesses.get(id)
|
||||
}
|
||||
|
||||
/** Kill all PTYs. Call on app quit. */
|
||||
/** Kill all in-process local PTYs. Call on app quit. */
|
||||
killAll(): void {
|
||||
for (const [id, proc] of ptyProcesses) {
|
||||
safeKillAndClean(id, proc)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'
|
||||
import { mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'
|
||||
import type * as pty from 'node-pty'
|
||||
import type * as LocalPtyShellReadyModule from './local-pty-shell-ready'
|
||||
import {
|
||||
@@ -724,6 +724,31 @@ describePosix('local PTY shell-ready launch config', () => {
|
||||
// Fallback chain: discovered → normalized spawn-env path → HOME
|
||||
expect(zshenv).toContain('${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}')
|
||||
})
|
||||
|
||||
it('restores wrapper ZDOTDIR from the runtime sourced path, not the baked literal', async () => {
|
||||
// Why: issue #8003 — WSL sources Windows-generated wrappers via /mnt/c,
|
||||
// so the generation-time path baked into .zshenv does not exist there.
|
||||
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
|
||||
|
||||
getShellReadyLaunchConfig('/bin/zsh')
|
||||
|
||||
const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8')
|
||||
|
||||
expect(zshenv).toContain('_orca_wrapper_zdotdir_self="${ZDOTDIR:-}"')
|
||||
// The runtime path is only trusted when it still holds a wrapper .zshenv;
|
||||
// otherwise the generation-time literal remains as the fallback.
|
||||
expect(zshenv).toContain(
|
||||
'if [[ -n "${_orca_wrapper_zdotdir_self:-}" && -f "${_orca_wrapper_zdotdir_self:-}/.zshenv" ]]; then\n' +
|
||||
' export ZDOTDIR="${_orca_wrapper_zdotdir_self:-}"\n' +
|
||||
'else\n' +
|
||||
` export ZDOTDIR='${join(userDataPath, 'shell-ready', 'zsh')}'\n` +
|
||||
'fi'
|
||||
)
|
||||
// Capture must happen before the wrapper unsets ZDOTDIR to source user files.
|
||||
expect(zshenv.indexOf('_orca_wrapper_zdotdir_self="${ZDOTDIR:-}"')).toBeLessThan(
|
||||
zshenv.indexOf('unset ZDOTDIR')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Why: end-to-end validation that wrapper ZDOTDIR discovery preserves top-level
|
||||
@@ -812,6 +837,61 @@ path=(/custom/bin $path)
|
||||
expect(output).toContain('PATH_HAS_CUSTOM=/custom/bin')
|
||||
})
|
||||
|
||||
it('loads user .zshrc when wrappers are sourced from a different runtime path (WSL simulation)', async () => {
|
||||
// Why: issue #8003 — on Windows the wrappers are generated under the
|
||||
// native userData path but WSL sources them via /mnt/c, where the baked
|
||||
// generation-time path does not exist. Renaming the userData dir after
|
||||
// generation reproduces that split: runtime ZDOTDIR resolves, the baked
|
||||
// literal does not.
|
||||
writeFileSync(join(testHome, '.zshrc'), 'export USER_ZSHRC_LOADED=yes\n')
|
||||
|
||||
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
|
||||
getShellReadyLaunchConfig('/bin/zsh')
|
||||
|
||||
const movedUserData = `${userDataPath}-wsl-view`
|
||||
renameSync(userDataPath, movedUserData)
|
||||
try {
|
||||
const cleanEnv: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
HOME: testHome,
|
||||
PATH: '/usr/bin:/bin'
|
||||
}
|
||||
delete cleanEnv.ZDOTDIR
|
||||
delete cleanEnv.ORCA_ORIG_ZDOTDIR
|
||||
delete cleanEnv.ORCA_ATTRIBUTION_SHIM_DIR
|
||||
delete cleanEnv.USER_ZSHRC_LOADED
|
||||
cleanEnv.ZDOTDIR = join(movedUserData, 'shell-ready', 'zsh')
|
||||
|
||||
// Production WSL launches a login shell (`exec zsh -l`); also cover the
|
||||
// non-login flow used by local panes so both restore paths stay pinned.
|
||||
// Login must still load user .zshrc (via wrapper .zshrc after .zprofile)
|
||||
// and leave final ZDOTDIR at the user home after .zlogin restore.
|
||||
for (const args of [['-i'], ['-l', '-i']] as const) {
|
||||
const result = spawnSync(
|
||||
'zsh',
|
||||
[
|
||||
...args,
|
||||
'-c',
|
||||
'echo "USER_ZSHRC_LOADED=${USER_ZSHRC_LOADED:-no}" && echo "FINAL_ZDOTDIR=${ZDOTDIR:-unset}" && echo "IS_LOGIN=$([[ -o login ]] && echo yes || echo no)"'
|
||||
],
|
||||
{
|
||||
env: cleanEnv as NodeJS.ProcessEnv,
|
||||
encoding: 'utf8'
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.status, `zsh ${args.join(' ')} failed: ${result.stderr}`).toBe(0)
|
||||
expect(result.stdout).toContain('USER_ZSHRC_LOADED=yes')
|
||||
expect(result.stdout).toContain(`FINAL_ZDOTDIR=${testHome}`)
|
||||
// Why: `as const` makes .includes('-l') reject the union of tuple
|
||||
// element types; check the login flag by position instead.
|
||||
expect(result.stdout).toContain(args[0] === '-l' ? 'IS_LOGIN=yes' : 'IS_LOGIN=no')
|
||||
}
|
||||
} finally {
|
||||
rmSync(movedUserData, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves top-level .zshenv path and function side effects', async () => {
|
||||
// Why: .zshenv is the normal place for always-on zsh env/path setup.
|
||||
// Dropping those side effects regresses non-Orca zsh startup semantics.
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createAuthFilesystemOperation } from './auth-filesystem-operation'
|
||||
|
||||
describe('createAuthFilesystemOperation', () => {
|
||||
it('serializes WSL aliases by distro and drops an abandoned queued read', async () => {
|
||||
let resolveFirst!: (value: string) => void
|
||||
const firstRaw = vi.fn(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
)
|
||||
const secondRaw = vi.fn(async () => 'second')
|
||||
const first = createAuthFilesystemOperation(
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex\\auth.json',
|
||||
firstRaw
|
||||
)
|
||||
const second = createAuthFilesystemOperation(
|
||||
'\\\\WSL$\\ubuntu\\home\\alice\\managed\\auth.json',
|
||||
secondRaw
|
||||
)
|
||||
const firstController = new AbortController()
|
||||
const secondController = new AbortController()
|
||||
|
||||
const firstWait = first.wait(firstController.signal)
|
||||
const secondWait = second.wait(secondController.signal)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(firstRaw).toHaveBeenCalledOnce()
|
||||
expect(secondRaw).not.toHaveBeenCalled()
|
||||
|
||||
const queuedAbort = new Error('queued caller expired')
|
||||
secondController.abort(queuedAbort)
|
||||
await expect(secondWait).rejects.toBe(queuedAbort)
|
||||
|
||||
resolveFirst('first')
|
||||
await expect(firstWait).resolves.toBe('first')
|
||||
await expect(second.result).rejects.toBe(queuedAbort)
|
||||
expect(secondRaw).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows different WSL distros to probe concurrently', async () => {
|
||||
const ubuntuRaw = vi.fn(async () => 'ubuntu')
|
||||
const debianRaw = vi.fn(async () => 'debian')
|
||||
const ubuntu = createAuthFilesystemOperation(
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex\\auth.json',
|
||||
ubuntuRaw
|
||||
)
|
||||
const debian = createAuthFilesystemOperation(
|
||||
'\\\\wsl.localhost\\Debian\\home\\alice\\.codex\\auth.json',
|
||||
debianRaw
|
||||
)
|
||||
const controller = new AbortController()
|
||||
|
||||
await expect(
|
||||
Promise.all([ubuntu.wait(controller.signal), debian.wait(controller.signal)])
|
||||
).resolves.toEqual(['ubuntu', 'debian'])
|
||||
expect(ubuntuRaw).toHaveBeenCalledOnce()
|
||||
expect(debianRaw).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not start an operation for an already-aborted waiter', async () => {
|
||||
const raw = vi.fn(async () => 'unexpected')
|
||||
const operation = createAuthFilesystemOperation(
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex\\auth.json',
|
||||
raw
|
||||
)
|
||||
const controller = new AbortController()
|
||||
const abortError = new Error('already expired')
|
||||
controller.abort(abortError)
|
||||
|
||||
await expect(operation.wait(controller.signal)).rejects.toBe(abortError)
|
||||
await expect(operation.result).rejects.toBe(abortError)
|
||||
expect(raw).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('caps cross-distro operations and never starts an expired queued probe', async () => {
|
||||
let resolveUbuntu!: (value: string) => void
|
||||
let resolveDebian!: (value: string) => void
|
||||
const ubuntuRaw = vi.fn(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
resolveUbuntu = resolve
|
||||
})
|
||||
)
|
||||
const debianRaw = vi.fn(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
resolveDebian = resolve
|
||||
})
|
||||
)
|
||||
const fedoraRaw = vi.fn(async () => 'fedora')
|
||||
const ubuntu = createAuthFilesystemOperation('\\\\wsl$\\Ubuntu\\a\\auth.json', ubuntuRaw)
|
||||
const debian = createAuthFilesystemOperation('\\\\wsl$\\Debian\\b\\auth.json', debianRaw)
|
||||
const fedora = createAuthFilesystemOperation('\\\\wsl$\\Fedora\\c\\auth.json', fedoraRaw)
|
||||
const ubuntuController = new AbortController()
|
||||
const debianController = new AbortController()
|
||||
const fedoraController = new AbortController()
|
||||
|
||||
const ubuntuWait = ubuntu.wait(ubuntuController.signal)
|
||||
const debianWait = debian.wait(debianController.signal)
|
||||
const fedoraWait = fedora.wait(fedoraController.signal)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(ubuntuRaw).toHaveBeenCalledOnce()
|
||||
expect(debianRaw).toHaveBeenCalledOnce()
|
||||
expect(fedoraRaw).not.toHaveBeenCalled()
|
||||
|
||||
const queuedAbort = new Error('global queue expired')
|
||||
fedoraController.abort(queuedAbort)
|
||||
await expect(fedoraWait).rejects.toBe(queuedAbort)
|
||||
await expect(fedora.result).rejects.toBe(queuedAbort)
|
||||
|
||||
resolveUbuntu('ubuntu')
|
||||
resolveDebian('debian')
|
||||
await expect(Promise.all([ubuntuWait, debianWait])).resolves.toEqual(['ubuntu', 'debian'])
|
||||
expect(fedoraRaw).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
|
||||
const MAX_CONCURRENT_WSL_AUTH_OPERATIONS = 2
|
||||
const activeWslOperationDistros = new Set<string>()
|
||||
const queuedWslOperations: QueuedWslOperation<unknown>[] = []
|
||||
let activeWslOperationCount = 0
|
||||
|
||||
type QueuedWslOperation<T> = {
|
||||
distroKey: string
|
||||
neededSignal: AbortSignal
|
||||
operation: () => Promise<T>
|
||||
resolve: (value: T) => void
|
||||
reject: (error: unknown) => void
|
||||
onAbort: () => void
|
||||
state: 'queued' | 'running' | 'settled'
|
||||
}
|
||||
|
||||
function getAbortReason(signal: AbortSignal): unknown {
|
||||
return signal.reason ?? new Error('Auth filesystem operation aborted')
|
||||
}
|
||||
|
||||
function finishWslOperation<T>(task: QueuedWslOperation<T>, settle: () => void): void {
|
||||
if (task.state !== 'running') {
|
||||
return
|
||||
}
|
||||
task.state = 'settled'
|
||||
activeWslOperationCount -= 1
|
||||
activeWslOperationDistros.delete(task.distroKey)
|
||||
settle()
|
||||
pumpWslOperations()
|
||||
}
|
||||
|
||||
function pumpWslOperations(): void {
|
||||
while (activeWslOperationCount < MAX_CONCURRENT_WSL_AUTH_OPERATIONS) {
|
||||
const nextIndex = queuedWslOperations.findIndex(
|
||||
(task) => !activeWslOperationDistros.has(task.distroKey)
|
||||
)
|
||||
if (nextIndex === -1) {
|
||||
return
|
||||
}
|
||||
const task = queuedWslOperations.splice(nextIndex, 1)[0]
|
||||
if (!task || task.state !== 'queued') {
|
||||
continue
|
||||
}
|
||||
task.neededSignal.removeEventListener('abort', task.onAbort)
|
||||
if (task.neededSignal.aborted) {
|
||||
task.state = 'settled'
|
||||
task.reject(getAbortReason(task.neededSignal))
|
||||
continue
|
||||
}
|
||||
task.state = 'running'
|
||||
activeWslOperationCount += 1
|
||||
activeWslOperationDistros.add(task.distroKey)
|
||||
void Promise.resolve()
|
||||
.then(() => {
|
||||
if (task.neededSignal.aborted) {
|
||||
throw getAbortReason(task.neededSignal)
|
||||
}
|
||||
return task.operation()
|
||||
})
|
||||
.then(
|
||||
(value) => finishWslOperation(task, () => task.resolve(value)),
|
||||
(error: unknown) => finishWslOperation(task, () => task.reject(error))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleWslAuthFilesystemOperation<T>(
|
||||
distroKey: string,
|
||||
neededSignal: AbortSignal,
|
||||
operation: () => Promise<T>
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const task: QueuedWslOperation<T> = {
|
||||
distroKey,
|
||||
neededSignal,
|
||||
operation,
|
||||
resolve,
|
||||
reject,
|
||||
state: 'queued',
|
||||
onAbort: () => {
|
||||
if (task.state !== 'queued') {
|
||||
return
|
||||
}
|
||||
task.state = 'settled'
|
||||
const index = queuedWslOperations.indexOf(task as QueuedWslOperation<unknown>)
|
||||
if (index !== -1) {
|
||||
queuedWslOperations.splice(index, 1)
|
||||
}
|
||||
reject(getAbortReason(neededSignal))
|
||||
pumpWslOperations()
|
||||
}
|
||||
}
|
||||
neededSignal.addEventListener('abort', task.onAbort, { once: true })
|
||||
queuedWslOperations.push(task as QueuedWslOperation<unknown>)
|
||||
queueMicrotask(pumpWslOperations)
|
||||
})
|
||||
}
|
||||
|
||||
function scheduleAuthFilesystemOperation<T>(
|
||||
authPath: string,
|
||||
neededSignal: AbortSignal,
|
||||
operation: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const wslInfo = parseWslUncPath(authPath)
|
||||
if (!wslInfo) {
|
||||
return Promise.resolve().then(() => {
|
||||
if (neededSignal.aborted) {
|
||||
throw getAbortReason(neededSignal)
|
||||
}
|
||||
return operation()
|
||||
})
|
||||
}
|
||||
// Why: a few disconnected distros must not occupy libuv's entire default
|
||||
// filesystem pool. Distro serialization also folds wsl$/wsl.localhost and
|
||||
// case aliases without forcing healthy local auth reads through the queue.
|
||||
return scheduleWslAuthFilesystemOperation(
|
||||
wslInfo.distro.trim().toLowerCase(),
|
||||
neededSignal,
|
||||
operation
|
||||
)
|
||||
}
|
||||
|
||||
export type SharedAuthFilesystemOperation<T> = {
|
||||
result: Promise<T>
|
||||
wait: (signal: AbortSignal) => Promise<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* Shares one raw operation with all callers for an auth path. WSL paths also
|
||||
* serialize by normalized distro because one stuck UNC request per account can
|
||||
* otherwise exhaust libuv's filesystem threadpool.
|
||||
*/
|
||||
export function createAuthFilesystemOperation<T>(
|
||||
authPath: string,
|
||||
operation: () => Promise<T>
|
||||
): SharedAuthFilesystemOperation<T> {
|
||||
const neededController = new AbortController()
|
||||
const waiters = new Set<symbol>()
|
||||
let settled = false
|
||||
const result = scheduleAuthFilesystemOperation(authPath, neededController.signal, operation)
|
||||
const markSettled = (): void => {
|
||||
settled = true
|
||||
}
|
||||
void result.then(markSettled, markSettled)
|
||||
|
||||
return {
|
||||
result,
|
||||
wait(signal) {
|
||||
if (signal.aborted) {
|
||||
if (!settled && waiters.size === 0) {
|
||||
neededController.abort(getAbortReason(signal))
|
||||
}
|
||||
return Promise.reject(getAbortReason(signal))
|
||||
}
|
||||
|
||||
const waiter = Symbol('auth-filesystem-waiter')
|
||||
waiters.add(waiter)
|
||||
let onAbort: (() => void) | null = null
|
||||
const aborted = new Promise<never>((_resolve, reject) => {
|
||||
onAbort = () => reject(getAbortReason(signal))
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
})
|
||||
return Promise.race([result, aborted]).finally(() => {
|
||||
if (onAbort) {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
waiters.delete(waiter)
|
||||
if (!settled && waiters.size === 0) {
|
||||
neededController.abort(getAbortReason(signal))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { existsSyncMock, homedirMock } = vi.hoisted(() => ({
|
||||
existsSyncMock: vi.fn(),
|
||||
const { accessMock, homedirMock } = vi.hoisted(() => ({
|
||||
accessMock: vi.fn(),
|
||||
homedirMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
existsSync: existsSyncMock
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
access: accessMock
|
||||
}))
|
||||
|
||||
vi.mock('node:os', () => ({
|
||||
@@ -33,33 +33,101 @@ describe('codexAuthExists', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('checks an explicit managed-account home first', () => {
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
it('checks an explicit managed-account home first', async () => {
|
||||
accessMock.mockResolvedValue(undefined)
|
||||
|
||||
expect(codexAuthExists('/managed/home')).toBe(true)
|
||||
expect(existsSyncMock).toHaveBeenCalledWith(join('/managed/home', 'auth.json'))
|
||||
await expect(codexAuthExists('/managed/home')).resolves.toBe(true)
|
||||
expect(accessMock).toHaveBeenCalledWith(join('/managed/home', 'auth.json'))
|
||||
})
|
||||
|
||||
it('falls back to CODEX_HOME when no home is provided', () => {
|
||||
it('falls back to CODEX_HOME when no home is provided', async () => {
|
||||
process.env.CODEX_HOME = '/custom/codex'
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
accessMock.mockResolvedValue(undefined)
|
||||
|
||||
expect(codexAuthExists()).toBe(true)
|
||||
expect(existsSyncMock).toHaveBeenCalledWith(join('/custom/codex', 'auth.json'))
|
||||
await expect(codexAuthExists()).resolves.toBe(true)
|
||||
expect(accessMock).toHaveBeenCalledWith(join('/custom/codex', 'auth.json'))
|
||||
})
|
||||
|
||||
it('falls back to ~/.codex when neither home nor CODEX_HOME is set', () => {
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
it('falls back to ~/.codex when neither home nor CODEX_HOME is set', async () => {
|
||||
accessMock.mockRejectedValue(new Error('ENOENT'))
|
||||
|
||||
expect(codexAuthExists()).toBe(false)
|
||||
expect(existsSyncMock).toHaveBeenCalledWith(join('/home/alice', '.codex', 'auth.json'))
|
||||
await expect(codexAuthExists()).resolves.toBe(false)
|
||||
expect(accessMock).toHaveBeenCalledWith(join('/home/alice', '.codex', 'auth.json'))
|
||||
})
|
||||
|
||||
it('returns false instead of throwing when the fs check fails', () => {
|
||||
existsSyncMock.mockImplementation(() => {
|
||||
throw new Error('EACCES')
|
||||
it('returns false instead of throwing when the fs check fails', async () => {
|
||||
accessMock.mockRejectedValue(new Error('EACCES'))
|
||||
|
||||
await expect(codexAuthExists('/managed/home')).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('stops waiting for a stalled filesystem check when the caller aborts', async () => {
|
||||
let resolveAccess!: () => void
|
||||
accessMock.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
resolveAccess = resolve
|
||||
})
|
||||
)
|
||||
const controller = new AbortController()
|
||||
|
||||
const result = codexAuthExists('/managed/home', { signal: controller.signal })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
controller.abort()
|
||||
|
||||
await expect(result).resolves.toBe(false)
|
||||
resolveAccess()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
it('bounds a stalled filesystem check even without a caller signal', async () => {
|
||||
let resolveAccess!: () => void
|
||||
accessMock.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
resolveAccess = resolve
|
||||
})
|
||||
)
|
||||
const timeoutController = new AbortController()
|
||||
const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(timeoutController.signal)
|
||||
|
||||
const result = codexAuthExists('/managed/home')
|
||||
expect(timeout).toHaveBeenCalledWith(5_000)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
timeoutController.abort()
|
||||
|
||||
await expect(result).resolves.toBe(false)
|
||||
resolveAccess()
|
||||
await Promise.resolve()
|
||||
timeout.mockRestore()
|
||||
})
|
||||
|
||||
it('shares one stalled UNC probe across concurrent callers', async () => {
|
||||
let resolveAccess!: () => void
|
||||
accessMock.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
resolveAccess = resolve
|
||||
})
|
||||
)
|
||||
const firstController = new AbortController()
|
||||
const secondController = new AbortController()
|
||||
|
||||
const first = codexAuthExists('\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex', {
|
||||
signal: firstController.signal
|
||||
})
|
||||
const second = codexAuthExists('\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex', {
|
||||
signal: secondController.signal
|
||||
})
|
||||
|
||||
expect(codexAuthExists('/managed/home')).toBe(false)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(accessMock).toHaveBeenCalledTimes(1)
|
||||
firstController.abort()
|
||||
secondController.abort()
|
||||
await expect(first).resolves.toBe(false)
|
||||
await expect(second).resolves.toBe(false)
|
||||
|
||||
resolveAccess()
|
||||
await Promise.resolve()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,42 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { access } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
createAuthFilesystemOperation,
|
||||
type SharedAuthFilesystemOperation
|
||||
} from './auth-filesystem-operation'
|
||||
|
||||
const AUTH_PRESENCE_TIMEOUT_MS = 5_000
|
||||
const authPresenceProbeByPath = new Map<string, SharedAuthFilesystemOperation<boolean>>()
|
||||
|
||||
type CodexAuthPresenceOptions = {
|
||||
signal?: AbortSignal
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
function getAuthPresenceProbe(authPath: string): SharedAuthFilesystemOperation<boolean> {
|
||||
const existing = authPresenceProbeByPath.get(authPath)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
// Why: aborting a Node fs promise does not necessarily cancel an already
|
||||
// issued UNC operation. Share the raw probe until it really settles so a
|
||||
// disconnected WSL home cannot accumulate native requests across polls.
|
||||
const probe = createAuthFilesystemOperation(authPath, () =>
|
||||
access(authPath).then(
|
||||
() => true,
|
||||
() => false
|
||||
)
|
||||
)
|
||||
authPresenceProbeByPath.set(authPath, probe)
|
||||
const clearProbe = (): void => {
|
||||
if (authPresenceProbeByPath.get(authPath) === probe) {
|
||||
authPresenceProbeByPath.delete(authPath)
|
||||
}
|
||||
}
|
||||
void probe.result.then(clearProbe, clearProbe)
|
||||
return probe
|
||||
}
|
||||
|
||||
// Why: the background quota poller spawns the real `codex` binary to read rate
|
||||
// limits. For users who installed Codex but never signed in, that spawn can
|
||||
@@ -8,12 +44,21 @@ import { join } from 'node:path'
|
||||
// the background. A signed-in Codex always writes an auth.json under its
|
||||
// CODEX_HOME, so gating the fetch on that file keeps the poller silent until
|
||||
// the user actually uses Codex.
|
||||
export function codexAuthExists(codexHomePath?: string | null): boolean {
|
||||
export async function codexAuthExists(
|
||||
codexHomePath?: string | null,
|
||||
options: CodexAuthPresenceOptions = {}
|
||||
): Promise<boolean> {
|
||||
// Mirror Codex's own home resolution: an explicit managed-account home wins,
|
||||
// then CODEX_HOME, then the default ~/.codex.
|
||||
const home = codexHomePath ?? process.env.CODEX_HOME ?? join(homedir(), '.codex')
|
||||
const authPath = join(home, 'auth.json')
|
||||
const timeoutSignal = AbortSignal.timeout(options.timeoutMs ?? AUTH_PRESENCE_TIMEOUT_MS)
|
||||
const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal
|
||||
try {
|
||||
return existsSync(join(home, 'auth.json'))
|
||||
// Why: managed WSL homes are UNC paths. A synchronous stat can park
|
||||
// Electron main while Windows wakes or reconnects the distro; the race
|
||||
// also keeps a disconnected distro from serializing all later refreshes.
|
||||
return await getAuthPresenceProbe(authPath).wait(signal)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { childSpawnMock, readFileMock, ptySpawnMock } = vi.hoisted(() => ({
|
||||
childSpawnMock: vi.fn(),
|
||||
readFileMock: vi.fn(),
|
||||
ptySpawnMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:child_process', () => ({ spawn: childSpawnMock }))
|
||||
vi.mock('node:fs/promises', () => ({ readFile: readFileMock }))
|
||||
vi.mock('node-pty', () => ({ spawn: ptySpawnMock }))
|
||||
vi.mock('./codex-auth-presence', () => ({ codexAuthExists: vi.fn(async () => true) }))
|
||||
|
||||
import { consumeCodexRateLimitResetCredit, fetchCodexRateLimits } from './codex-fetcher'
|
||||
|
||||
describe('Codex backend rate-limit requests', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('uses the official backend usage contract without spawning Codex', async () => {
|
||||
readFileMock.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
tokens: { access_token: 'access-token', account_id: 'account-id' }
|
||||
})
|
||||
)
|
||||
vi.mocked(fetch)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
plan_type: 'plus',
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
used_percent: 12,
|
||||
limit_window_seconds: 3_600,
|
||||
reset_at: 1_800_000_000
|
||||
},
|
||||
secondary_window: {
|
||||
used_percent: 34,
|
||||
limit_window_seconds: 86_400,
|
||||
reset_at: 1_800_100_000
|
||||
}
|
||||
},
|
||||
rate_limit_reset_credits: { available_count: 1 }
|
||||
})
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
available_count: 1,
|
||||
credits: [
|
||||
{
|
||||
status: 'available',
|
||||
expires_at: '2027-01-15T12:00:00Z',
|
||||
granted_at: '2027-01-08T12:00:00Z'
|
||||
}
|
||||
]
|
||||
})
|
||||
} as Response)
|
||||
|
||||
await expect(
|
||||
fetchCodexRateLimits({
|
||||
codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\account\\home'
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
session: { usedPercent: 12, windowMinutes: 60, resetsAt: 1_800_000_000_000 },
|
||||
weekly: { usedPercent: 34, windowMinutes: 1440, resetsAt: 1_800_100_000_000 },
|
||||
rateLimitResetCredits: {
|
||||
availableCount: 1,
|
||||
nextExpiresAt: Date.parse('2027-01-15T12:00:00Z')
|
||||
},
|
||||
status: 'ok'
|
||||
})
|
||||
|
||||
expect(childSpawnMock).not.toHaveBeenCalled()
|
||||
expect(ptySpawnMock).not.toHaveBeenCalled()
|
||||
expect(fetch).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://chatgpt.com/backend-api/wham/usage',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer access-token' }),
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('aborts callers while sharing one stalled backend auth read', async () => {
|
||||
let resolveRead!: (content: string) => void
|
||||
readFileMock.mockImplementation(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
resolveRead = resolve
|
||||
})
|
||||
)
|
||||
const firstController = new AbortController()
|
||||
const secondController = new AbortController()
|
||||
const codexHomePath = '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex'
|
||||
|
||||
const first = fetchCodexRateLimits({ codexHomePath, signal: firstController.signal })
|
||||
const second = fetchCodexRateLimits({ codexHomePath, signal: secondController.signal })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(readFileMock).toHaveBeenCalledTimes(1)
|
||||
expect(readFileMock).toHaveBeenCalledWith(expect.stringContaining('auth.json'), 'utf8')
|
||||
|
||||
firstController.abort()
|
||||
secondController.abort()
|
||||
|
||||
await expect(first).resolves.toMatchObject({
|
||||
provider: 'codex',
|
||||
status: 'error',
|
||||
error: 'Rate-limit fetch aborted'
|
||||
})
|
||||
await expect(second).resolves.toMatchObject({
|
||||
provider: 'codex',
|
||||
status: 'error',
|
||||
error: 'Rate-limit fetch aborted'
|
||||
})
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
expect(childSpawnMock).not.toHaveBeenCalled()
|
||||
expect(ptySpawnMock).not.toHaveBeenCalled()
|
||||
resolveRead('{}')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
|
||||
it('applies the backend deadline while auth reading is still pending', async () => {
|
||||
let resolveRead!: (content: string) => void
|
||||
readFileMock.mockImplementation(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
resolveRead = resolve
|
||||
})
|
||||
)
|
||||
const timeoutController = new AbortController()
|
||||
const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValueOnce(timeoutController.signal)
|
||||
const deadlineError = new Error('backend deadline')
|
||||
|
||||
const result = consumeCodexRateLimitResetCredit({
|
||||
codexHomePath: '/managed/deadline-home',
|
||||
idempotencyKey: 'redeem-timeout'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
// Why: redeem is user-triggered, so it gets the longer redeem deadline.
|
||||
expect(timeout).toHaveBeenCalledWith(30_000)
|
||||
expect(readFileMock).toHaveBeenCalledWith(join('/managed/deadline-home', 'auth.json'), 'utf8')
|
||||
|
||||
timeoutController.abort(deadlineError)
|
||||
|
||||
await expect(result).rejects.toBe(deadlineError)
|
||||
resolveRead('{}')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
|
||||
it('consumes a reset credit with the official payload and bounded request signal', async () => {
|
||||
readFileMock.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
tokens: { access_token: 'access-token', account_id: 'account-id' }
|
||||
})
|
||||
)
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ code: 'already_redeemed' })
|
||||
} as Response)
|
||||
|
||||
await expect(
|
||||
consumeCodexRateLimitResetCredit({
|
||||
codexHomePath: '/managed/codex-home',
|
||||
idempotencyKey: 'redeem-123'
|
||||
})
|
||||
).resolves.toBe('alreadyRedeemed')
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ redeem_request_id: 'redeem-123' }),
|
||||
signal: expect.any(AbortSignal),
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer access-token',
|
||||
'ChatGPT-Account-Id': 'account-id',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -77,7 +77,7 @@ describe('fetchCodexRateLimits', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
resolveCodexCommandMock.mockReturnValue('codex')
|
||||
vi.mocked(codexAuthExists).mockReturnValue(true)
|
||||
vi.mocked(codexAuthExists).mockResolvedValue(true)
|
||||
readFileMock.mockRejectedValue(new Error('no auth fixture'))
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
})
|
||||
@@ -87,7 +87,7 @@ describe('fetchCodexRateLimits', () => {
|
||||
})
|
||||
|
||||
it('does not spawn Codex when the user is not signed in', async () => {
|
||||
vi.mocked(codexAuthExists).mockReturnValue(false)
|
||||
vi.mocked(codexAuthExists).mockResolvedValue(false)
|
||||
|
||||
await expect(fetchCodexRateLimits()).resolves.toMatchObject({
|
||||
provider: 'codex',
|
||||
@@ -101,6 +101,28 @@ describe('fetchCodexRateLimits', () => {
|
||||
expect(ptySpawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves the aborted result when cancellation lands during the auth check', async () => {
|
||||
let resolveAuth!: (exists: boolean) => void
|
||||
vi.mocked(codexAuthExists).mockReturnValueOnce(
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveAuth = resolve
|
||||
})
|
||||
)
|
||||
const controller = new AbortController()
|
||||
|
||||
const result = fetchCodexRateLimits({ signal: controller.signal })
|
||||
controller.abort()
|
||||
resolveAuth(false)
|
||||
|
||||
await expect(result).resolves.toMatchObject({
|
||||
provider: 'codex',
|
||||
status: 'error',
|
||||
error: 'Rate-limit fetch aborted'
|
||||
})
|
||||
expect(childSpawnMock).not.toHaveBeenCalled()
|
||||
expect(ptySpawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disposes node-pty listeners before killing the PTY fallback on timeout', async () => {
|
||||
const onDataDisposable = makeDisposable()
|
||||
const onExitDisposable = makeDisposable()
|
||||
@@ -133,6 +155,7 @@ describe('fetchCodexRateLimits', () => {
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
|
||||
const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
const spawnCwd = childSpawnMock.mock.calls[0]?.[2]?.cwd as string
|
||||
expect(spawnCwd).toContain('rate-limit-pty-cwd')
|
||||
@@ -168,6 +191,7 @@ describe('fetchCodexRateLimits', () => {
|
||||
const controller = new AbortController()
|
||||
|
||||
const resultPromise = fetchCodexRateLimits({ signal: controller.signal })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
controller.abort()
|
||||
|
||||
@@ -221,6 +245,7 @@ describe('fetchCodexRateLimits', () => {
|
||||
})
|
||||
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
rpcChild.emit('close')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
@@ -247,6 +272,7 @@ describe('fetchCodexRateLimits', () => {
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
|
||||
const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
rpcChild.emit('close')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
@@ -421,6 +447,7 @@ describe('fetchCodexRateLimits', () => {
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits',
|
||||
expect.objectContaining({
|
||||
signal: expect.any(AbortSignal),
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer access-token',
|
||||
'ChatGPT-Account-Id': 'account-id',
|
||||
@@ -553,6 +580,8 @@ describe('fetchCodexRateLimits', () => {
|
||||
)
|
||||
expect(shellCommand.match(/<&3 >&4 3<&- 4>&-/g)).toHaveLength(3)
|
||||
expect(shellCommand.match(/exec codex [^\n]+<&3 >&4 3<&- 4>&-/g)).toHaveLength(3)
|
||||
expect(shellCommand).not.toContain('_orca_codex')
|
||||
expect(shellCommand).not.toContain('wsl-codex-path')
|
||||
expect(spawnOptions).toEqual(
|
||||
expect.objectContaining({
|
||||
cwd: expect.stringContaining('rate-limit-pty-cwd'),
|
||||
@@ -649,6 +678,7 @@ describe('fetchCodexRateLimits', () => {
|
||||
const resultPromise = fetchCodexRateLimits({
|
||||
codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\account\\home'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
rpcChild.emit('close')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
@@ -668,6 +698,8 @@ describe('fetchCodexRateLimits', () => {
|
||||
"export CODEX_HOME='\\''/home/alice/.local/share/orca/account/home'\\''"
|
||||
)
|
||||
expect(shellCommand).toContain('exec codex ')
|
||||
expect(shellCommand).not.toContain('_orca_codex')
|
||||
expect(shellCommand).not.toContain('wsl-codex-path')
|
||||
expect(spawnOptions).toEqual(
|
||||
expect.objectContaining({
|
||||
cwd: expect.stringContaining('rate-limit-pty-cwd'),
|
||||
|
||||
@@ -25,10 +25,18 @@ import {
|
||||
getHiddenRateLimitWslCwdSetupCommands,
|
||||
resolveHiddenRateLimitPtyCwd
|
||||
} from './hidden-rate-limit-pty-cwd'
|
||||
import {
|
||||
createAuthFilesystemOperation,
|
||||
type SharedAuthFilesystemOperation
|
||||
} from './auth-filesystem-operation'
|
||||
|
||||
const RPC_TIMEOUT_MS = 10_000
|
||||
const WSL_RPC_TIMEOUT_MS = 25_000
|
||||
const PTY_TIMEOUT_MS = 15_000
|
||||
const BACKEND_TIMEOUT_MS = 10_000
|
||||
// Why: redeeming a reset credit is an explicit user action, not a background
|
||||
// poll — give it more room before failing so a slow backend can still finish.
|
||||
const REDEEM_BACKEND_TIMEOUT_MS = 30_000
|
||||
const MAX_DIAGNOSTIC_OUTPUT_LENGTH = 100_000
|
||||
|
||||
export type FetchCodexRateLimitsOptions = {
|
||||
@@ -102,6 +110,21 @@ type BackendRateLimitResetCreditsResponse = {
|
||||
}[]
|
||||
}
|
||||
|
||||
type BackendRateLimitWindow = {
|
||||
used_percent?: number
|
||||
limit_window_seconds?: number
|
||||
reset_at?: number
|
||||
}
|
||||
|
||||
type BackendUsageResponse = {
|
||||
plan_type?: string
|
||||
rate_limit?: {
|
||||
primary_window?: BackendRateLimitWindow | null
|
||||
secondary_window?: BackendRateLimitWindow | null
|
||||
} | null
|
||||
rate_limit_reset_credits?: BackendRateLimitResetCreditsResponse | null
|
||||
}
|
||||
|
||||
type BackendConsumeRateLimitResetCreditResponse = {
|
||||
code?: string
|
||||
}
|
||||
@@ -110,6 +133,15 @@ type CodexBackendAuthHeaders = {
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
type BackendAuthReadResult =
|
||||
| { content: string; error?: never }
|
||||
| { content?: never; error: unknown }
|
||||
|
||||
const backendAuthReadByPath = new Map<
|
||||
string,
|
||||
SharedAuthFilesystemOperation<BackendAuthReadResult>
|
||||
>()
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, "'\\''")}'`
|
||||
}
|
||||
@@ -126,14 +158,19 @@ function buildWslCodexCommand(
|
||||
if (process.platform !== 'win32' || !wslInfo) {
|
||||
return null
|
||||
}
|
||||
const script = [
|
||||
const setupCommands = [
|
||||
...getHiddenRateLimitWslCwdSetupCommands(),
|
||||
`export CODEX_HOME=${shellQuote(wslInfo.linuxPath)}`,
|
||||
`exec codex ${args.map(shellQuote).join(' ')}${
|
||||
options?.isolateRpcStdio ? ' <&3 >&4 3<&- 4>&-' : ''
|
||||
}`
|
||||
`export CODEX_HOME=${shellQuote(wslInfo.linuxPath)}`
|
||||
].join(' && ')
|
||||
const loginShellCommand = buildWslLoginShellCommand(script)
|
||||
const execSuffix = `${args.map(shellQuote).join(' ')}${
|
||||
options?.isolateRpcStdio ? ' <&3 >&4 3<&- 4>&-' : ''
|
||||
}`
|
||||
// Why: npm/nvm Codex launchers use `#!/usr/bin/env node`. Resolving an
|
||||
// absolute launcher in a login shell and later execing it from plain `sh`
|
||||
// loses the PATH that supplies Node and also pins obsolete installations.
|
||||
const loginShellCommand = buildWslLoginShellCommand(
|
||||
[setupCommands, `exec codex ${execSuffix}`].join(' && ')
|
||||
)
|
||||
// Why: keep the outer sh non-login and hide RPC pipes before the configured
|
||||
// shell startup can read input or print banners.
|
||||
const command = options?.isolateRpcStdio
|
||||
@@ -255,14 +292,57 @@ function mapBackendRateLimitResetCredits(
|
||||
}
|
||||
}
|
||||
|
||||
function createBackendRequestSignal(
|
||||
callerSignal?: AbortSignal,
|
||||
timeoutMs = BACKEND_TIMEOUT_MS
|
||||
): AbortSignal {
|
||||
const timeoutSignal = AbortSignal.timeout(timeoutMs)
|
||||
return callerSignal ? AbortSignal.any([callerSignal, timeoutSignal]) : timeoutSignal
|
||||
}
|
||||
|
||||
function getBackendAuthRead(
|
||||
authPath: string
|
||||
): SharedAuthFilesystemOperation<BackendAuthReadResult> {
|
||||
const existing = backendAuthReadByPath.get(authPath)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
// Why: the caller deadline must settle promptly, but Node cannot guarantee
|
||||
// cancellation of an already-issued UNC read. Keep one raw read per auth
|
||||
// path until the OS finishes so repeated quota refreshes cannot stack them.
|
||||
const read = createAuthFilesystemOperation(authPath, () =>
|
||||
readFile(authPath, 'utf8').then(
|
||||
(content) => ({ content }),
|
||||
(error: unknown) => ({ error })
|
||||
)
|
||||
)
|
||||
backendAuthReadByPath.set(authPath, read)
|
||||
const clearRead = (): void => {
|
||||
if (backendAuthReadByPath.get(authPath) === read) {
|
||||
backendAuthReadByPath.delete(authPath)
|
||||
}
|
||||
}
|
||||
void read.result.then(clearRead, clearRead)
|
||||
return read
|
||||
}
|
||||
|
||||
async function readBackendAuth(authPath: string, signal: AbortSignal): Promise<string> {
|
||||
const result = await getBackendAuthRead(authPath).wait(signal)
|
||||
if ('error' in result) {
|
||||
throw result.error
|
||||
}
|
||||
return result.content
|
||||
}
|
||||
|
||||
async function getCodexBackendAuthHeaders(
|
||||
options?: FetchCodexRateLimitsOptions
|
||||
options: FetchCodexRateLimitsOptions | undefined,
|
||||
signal: AbortSignal
|
||||
): Promise<CodexBackendAuthHeaders | null> {
|
||||
if (options?.signal?.aborted) {
|
||||
if (signal.aborted) {
|
||||
return null
|
||||
}
|
||||
const authPath = join(getCodexHomePath(options?.codexHomePath), 'auth.json')
|
||||
const auth = JSON.parse(await readFile(authPath, 'utf8')) as CodexAuthFile
|
||||
const auth = JSON.parse(await readBackendAuth(authPath, signal)) as CodexAuthFile
|
||||
const accessToken = auth.tokens?.access_token
|
||||
if (!accessToken) {
|
||||
return null
|
||||
@@ -286,18 +366,19 @@ async function fetchBackendRateLimitResetCredits(
|
||||
if (options?.signal?.aborted) {
|
||||
return null
|
||||
}
|
||||
const auth = await getCodexBackendAuthHeaders(options)
|
||||
const signal = createBackendRequestSignal(options?.signal)
|
||||
const auth = await getCodexBackendAuthHeaders(options, signal)
|
||||
if (!auth) {
|
||||
return null
|
||||
}
|
||||
if (options?.signal?.aborted) {
|
||||
if (signal.aborted) {
|
||||
return null
|
||||
}
|
||||
// Why: published Codex 0.140 can read windows through app-server but strips
|
||||
// reset-credit metadata that the backend already returns.
|
||||
const response = await fetch('https://chatgpt.com/backend-api/wham/rate-limit-reset-credits', {
|
||||
...auth,
|
||||
signal: options?.signal
|
||||
signal
|
||||
})
|
||||
if (!response.ok) {
|
||||
return null
|
||||
@@ -349,7 +430,8 @@ export async function consumeCodexRateLimitResetCredit(options: {
|
||||
if (!options.idempotencyKey.trim()) {
|
||||
throw new Error('Codex reset idempotency key is required')
|
||||
}
|
||||
const auth = await getCodexBackendAuthHeaders(options)
|
||||
const signal = createBackendRequestSignal(undefined, REDEEM_BACKEND_TIMEOUT_MS)
|
||||
const auth = await getCodexBackendAuthHeaders(options, signal)
|
||||
if (!auth) {
|
||||
throw new Error('Codex not signed in')
|
||||
}
|
||||
@@ -361,7 +443,8 @@ export async function consumeCodexRateLimitResetCredit(options: {
|
||||
...auth.headers,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ redeem_request_id: options.idempotencyKey })
|
||||
body: JSON.stringify({ redeem_request_id: options.idempotencyKey }),
|
||||
signal
|
||||
}
|
||||
)
|
||||
if (!response.ok) {
|
||||
@@ -375,13 +458,13 @@ function mapRpcWindow(
|
||||
raw: RpcRateWindow | undefined,
|
||||
expectedWindowMinutes: number
|
||||
): RateLimitWindow | null {
|
||||
if (!raw || typeof raw.usedPercent !== 'number') {
|
||||
if (!raw || typeof raw.usedPercent !== 'number' || !Number.isFinite(raw.usedPercent)) {
|
||||
return null
|
||||
}
|
||||
let resetDescription: string | null = null
|
||||
let resetsAt: number | null = null
|
||||
|
||||
if (raw.resetsAt) {
|
||||
if (typeof raw.resetsAt === 'number' && Number.isFinite(raw.resetsAt) && raw.resetsAt > 0) {
|
||||
// Why: Codex returns resetsAt as Unix seconds, not milliseconds.
|
||||
const date = new Date(raw.resetsAt * 1000)
|
||||
if (!Number.isNaN(date.getTime())) {
|
||||
@@ -408,6 +491,71 @@ function mapRpcWindow(
|
||||
}
|
||||
}
|
||||
|
||||
function mapBackendUsageWindow(
|
||||
raw: BackendRateLimitWindow | null | undefined,
|
||||
fallbackWindowMinutes: number
|
||||
): RateLimitWindow | null {
|
||||
const limitWindowSeconds = raw?.limit_window_seconds
|
||||
// Match Codex backend-client's `window_minutes_from_seconds`: the backend
|
||||
// field is the actual bucket duration and rounds partial minutes upward.
|
||||
const windowMinutes =
|
||||
typeof limitWindowSeconds === 'number' &&
|
||||
Number.isFinite(limitWindowSeconds) &&
|
||||
limitWindowSeconds > 0
|
||||
? Math.ceil(limitWindowSeconds / 60)
|
||||
: fallbackWindowMinutes
|
||||
return mapRpcWindow(
|
||||
raw
|
||||
? {
|
||||
usedPercent: raw.used_percent,
|
||||
resetsAt: raw.reset_at
|
||||
}
|
||||
: undefined,
|
||||
windowMinutes
|
||||
)
|
||||
}
|
||||
|
||||
async function fetchViaBackend(
|
||||
options?: FetchCodexRateLimitsOptions
|
||||
): Promise<ProviderRateLimits | null> {
|
||||
const signal = createBackendRequestSignal(options?.signal)
|
||||
const auth = await getCodexBackendAuthHeaders(options, signal)
|
||||
if (!auth || signal.aborted) {
|
||||
return null
|
||||
}
|
||||
// Why: Codex itself reads this endpoint in backend-client's
|
||||
// `get_rate_limit_status`; using the same contract avoids launching a hidden
|
||||
// app-server (and a WSL login shell) for every routine quota refresh.
|
||||
const response = await fetch('https://chatgpt.com/backend-api/wham/usage', {
|
||||
...auth,
|
||||
signal
|
||||
})
|
||||
if (!response.ok) {
|
||||
return null
|
||||
}
|
||||
const payload = (await response.json()) as BackendUsageResponse
|
||||
// `plan_type` is required by Codex's RateLimitStatusPayload. Reject a
|
||||
// superficially successful but unrelated/malformed JSON response so the
|
||||
// established app-server fallback still gets a chance.
|
||||
if (typeof payload.plan_type !== 'string') {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
provider: 'codex',
|
||||
session: mapBackendUsageWindow(payload.rate_limit?.primary_window, 300),
|
||||
weekly: mapBackendUsageWindow(payload.rate_limit?.secondary_window, 10080),
|
||||
...(payload.rate_limit_reset_credits !== undefined
|
||||
? {
|
||||
rateLimitResetCredits:
|
||||
mapBackendRateLimitResetCredits(payload.rate_limit_reset_credits) ?? null
|
||||
}
|
||||
: {}),
|
||||
updatedAt: Date.now(),
|
||||
error: null,
|
||||
status: 'ok'
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RPC fetch — spawn `codex -s read-only -a untrusted app-server`
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -878,7 +1026,13 @@ export async function fetchCodexRateLimits(
|
||||
// Why: never spawn the `codex` binary unless the user has signed in. Without
|
||||
// auth the RPC/PTY paths can only error, and spawning them shows up as an
|
||||
// unexpected background Codex process for users who don't use Codex.
|
||||
if (!codexAuthExists(options?.codexHomePath)) {
|
||||
const hasCodexAuth = await codexAuthExists(options?.codexHomePath, {
|
||||
signal: options?.signal
|
||||
})
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
}
|
||||
if (!hasCodexAuth) {
|
||||
return {
|
||||
provider: 'codex',
|
||||
session: null,
|
||||
@@ -889,7 +1043,29 @@ export async function fetchCodexRateLimits(
|
||||
}
|
||||
}
|
||||
|
||||
// Path A: try RPC first
|
||||
// Path A (WSL): use Codex's own backend usage contract. Host accounts retain
|
||||
// app-server's token-refresh/custom-CA behavior; WSL avoids starting a login
|
||||
// shell just to reconstruct the CLI environment for a routine poll.
|
||||
if (options?.codexHomePath && parseWslUncPath(options.codexHomePath)) {
|
||||
try {
|
||||
const backendResult = await fetchViaBackend(options)
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
}
|
||||
if (backendResult) {
|
||||
const withResetCredits = await withBackendRateLimitResetCredits(backendResult, options)
|
||||
return options?.signal?.aborted ? abortedCodexRateLimitResult() : withResetCredits
|
||||
}
|
||||
} catch {
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
}
|
||||
// Token refresh, network routing, and custom-CA behavior can differ from
|
||||
// the host fetch stack. Preserve the CLI paths as compatibility fallbacks.
|
||||
}
|
||||
}
|
||||
|
||||
// Path B: try RPC
|
||||
try {
|
||||
const rpcResult = await fetchViaRpc(options)
|
||||
if (options?.signal?.aborted) {
|
||||
@@ -924,7 +1100,7 @@ export async function fetchCodexRateLimits(
|
||||
// RPC failed — fall through to PTY
|
||||
}
|
||||
|
||||
// Path B: PTY fallback
|
||||
// Path C: PTY fallback
|
||||
try {
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
|
||||
@@ -14907,7 +14907,24 @@ describe('OrcaRuntimeService', () => {
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...store,
|
||||
getAllWorktreeMeta: () => metaById,
|
||||
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId]
|
||||
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
|
||||
getWorkspaceSession: () => ({
|
||||
...getDefaultWorkspaceSession(),
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
'tab-1:leaf-1': {
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'session-1' },
|
||||
prompt: 'test',
|
||||
state: 'done',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1,
|
||||
origin: 'worktree-sleep'
|
||||
}
|
||||
}
|
||||
})
|
||||
} as never)
|
||||
runtime.setNotifier({
|
||||
worktreesChanged: vi.fn(),
|
||||
@@ -14924,13 +14941,13 @@ describe('OrcaRuntimeService', () => {
|
||||
terminalFitOverrideChanged: vi.fn(),
|
||||
terminalDriverChanged: vi.fn()
|
||||
})
|
||||
// A renderer must be attached to receive the wake (headless serve is a
|
||||
// deliberate non-goal — no renderer, no wake).
|
||||
// A renderer must be attached to receive the wake — headless serve reports
|
||||
// the explicit 'unsupported-headless' outcome instead (see below).
|
||||
electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false } as never)
|
||||
runtime.attachWindow(TEST_WINDOW_ID)
|
||||
runtime.markGraphReady(TEST_WINDOW_ID)
|
||||
|
||||
await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, {
|
||||
const result = await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, {
|
||||
notifyClients: false,
|
||||
clientKind: 'mobile'
|
||||
})
|
||||
@@ -14939,6 +14956,86 @@ describe('OrcaRuntimeService', () => {
|
||||
// routes exclusively through the renderer's own navigation-free wake.
|
||||
expect(resumeSleepingAgents).toHaveBeenCalledWith(TEST_WORKTREE_ID)
|
||||
expect(activateWorktree).not.toHaveBeenCalled()
|
||||
expect(result.sleepingAgentWake).toBe('requested')
|
||||
})
|
||||
|
||||
it('reports the wake as unsupported when a phone activates a worktree on headless serve', async () => {
|
||||
// Why: without an attached renderer nothing holds the sleeping records, so
|
||||
// no agent actually wakes. The result must say so explicitly — activating
|
||||
// silently would let the phone present slept agents as resumed (#7906).
|
||||
const metaById: Record<string, WorktreeMeta> = {
|
||||
[TEST_WORKTREE_ID]: makeWorktreeMeta({ isUnread: false })
|
||||
}
|
||||
const resumeSleepingAgents = vi.fn()
|
||||
const getWorkspaceSession = vi.fn(() => ({
|
||||
...getDefaultWorkspaceSession(),
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
'tab-1:leaf-1': {
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'session-1' },
|
||||
prompt: 'test',
|
||||
state: 'done',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1,
|
||||
origin: 'worktree-sleep'
|
||||
}
|
||||
}
|
||||
}))
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...store,
|
||||
getAllWorktreeMeta: () => metaById,
|
||||
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
|
||||
getWorkspaceSession
|
||||
} as never)
|
||||
runtime.setNotifier({
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree: vi.fn(),
|
||||
createTerminal: vi.fn(),
|
||||
revealTerminalSession: vi.fn(),
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
resumeSleepingAgents,
|
||||
terminalFitOverrideChanged: vi.fn(),
|
||||
terminalDriverChanged: vi.fn()
|
||||
})
|
||||
// Headless/no-live-renderer: the graph is ready but no BrowserWindow backs
|
||||
// the authoritative id, so getAvailableAuthoritativeWindow() is null.
|
||||
electronMocks.BrowserWindow.fromId.mockReturnValue(null as never)
|
||||
runtime.attachWindow(TEST_WINDOW_ID)
|
||||
runtime.markGraphReady(TEST_WINDOW_ID)
|
||||
|
||||
const result = await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, {
|
||||
notifyClients: false,
|
||||
clientKind: 'mobile'
|
||||
})
|
||||
|
||||
expect(result.activated).toBe(true)
|
||||
expect(result.sleepingAgentWake).toBe('unsupported-headless')
|
||||
// Why: sleeping records are host-partitioned; the check must read the
|
||||
// repo's execution host partition, not always the local one.
|
||||
expect(getWorkspaceSession).toHaveBeenCalledWith('local')
|
||||
expect(resumeSleepingAgents).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not report headless wake degradation without sleeping records', async () => {
|
||||
const runtime = new OrcaRuntimeService(store as never)
|
||||
electronMocks.BrowserWindow.fromId.mockReturnValue(null as never)
|
||||
runtime.attachWindow(TEST_WINDOW_ID)
|
||||
runtime.markGraphReady(TEST_WINDOW_ID)
|
||||
|
||||
const result = await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, {
|
||||
notifyClients: false,
|
||||
clientKind: 'mobile'
|
||||
})
|
||||
|
||||
expect(result.sleepingAgentWake).toBe('not-applicable')
|
||||
})
|
||||
|
||||
it('does not wake slept agents for non-mobile session-only activation', async () => {
|
||||
@@ -14972,12 +15069,13 @@ describe('OrcaRuntimeService', () => {
|
||||
|
||||
// INV-3: web/desktop runtime clients keep their existing wake-on-activation
|
||||
// paths untouched — the renderer notifier wake is mobile-scoped.
|
||||
await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, {
|
||||
const result = await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, {
|
||||
notifyClients: false,
|
||||
clientKind: 'runtime'
|
||||
})
|
||||
|
||||
expect(resumeSleepingAgents).not.toHaveBeenCalled()
|
||||
expect(result.sleepingAgentWake).toBe('not-applicable')
|
||||
})
|
||||
|
||||
it('does not rewrite unread metadata when a mobile activation finds the worktree already read', async () => {
|
||||
|
||||
@@ -128,7 +128,11 @@ import type {
|
||||
DirEntry
|
||||
} from '../../shared/types'
|
||||
import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree-removal'
|
||||
import { parseExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
parseExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../shared/execution-host'
|
||||
import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume'
|
||||
import type { RuntimeClientEvent } from '../../shared/runtime-client-events'
|
||||
import { toRuntimeActivateWorktreeEvent } from '../../shared/runtime-client-events'
|
||||
@@ -477,7 +481,8 @@ import {
|
||||
updateIssueForAgent as updateLinearIssueForAgent,
|
||||
updateIssue as updateLinearIssue,
|
||||
LinearWriteFailure,
|
||||
type LinearListFilter
|
||||
type LinearListFilter,
|
||||
type LinearIssueListOptions
|
||||
} from '../linear/issues'
|
||||
import {
|
||||
LinearAgentAccessError,
|
||||
@@ -12627,6 +12632,10 @@ export class OrcaRuntimeService {
|
||||
repoId: string
|
||||
worktreeId: string
|
||||
activated: boolean
|
||||
/** Mobile-scoped slept-agent wake outcome. `unsupported-headless` means no
|
||||
* renderer holds the sleeping records (headless `orca serve`), so nothing
|
||||
* woke — clients must not present the worktree's agents as resumed. */
|
||||
sleepingAgentWake: 'requested' | 'unsupported-headless' | 'not-applicable'
|
||||
}> {
|
||||
this.assertGraphReady()
|
||||
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
|
||||
@@ -12642,6 +12651,8 @@ export class OrcaRuntimeService {
|
||||
this.notifyWorktreesChanged(repo.id)
|
||||
}
|
||||
|
||||
let sleepingAgentWake: 'requested' | 'unsupported-headless' | 'not-applicable' =
|
||||
'not-applicable'
|
||||
if (opts.notifyClients !== false) {
|
||||
// Why: inactive worktree terminal panes are renderer-owned and may not have
|
||||
// live PTYs until the desktop activates the worktree and mounts them.
|
||||
@@ -12657,12 +12668,29 @@ export class OrcaRuntimeService {
|
||||
// Why: a phone open must also wake the worktree's slept agents (experimental
|
||||
// agent sleep). Only the host renderer holds the sleeping records + wake
|
||||
// authority, so fire-and-forget ask it — mobile-scoped so web/desktop are
|
||||
// unaffected, and only when a renderer is attached to receive it.
|
||||
if (opts.clientKind === 'mobile' && this.getAvailableAuthoritativeWindow()) {
|
||||
this.notifier?.resumeSleepingAgents?.(worktree.id)
|
||||
// unaffected. Headless serve has no renderer to wake anything, so report
|
||||
// that explicitly instead of letting mobile assume the agents resumed.
|
||||
if (opts.clientKind === 'mobile') {
|
||||
if (this.getAvailableAuthoritativeWindow()) {
|
||||
this.notifier?.resumeSleepingAgents?.(worktree.id)
|
||||
sleepingAgentWake = 'requested'
|
||||
} else if (
|
||||
// Why: sleeping records are partitioned by execution host; reading
|
||||
// only the local partition would miss slept agents on SSH-host
|
||||
// worktrees and skip the headless warning for them.
|
||||
Object.values(
|
||||
this.store?.getWorkspaceSession?.(getRepoExecutionHostId(repo))
|
||||
.sleepingAgentSessionsByPaneKey ?? {}
|
||||
).some((record) => record.worktreeId === worktree.id)
|
||||
) {
|
||||
// Why: headless is only degraded when this worktree actually has a
|
||||
// persisted resume record. Ordinary mobile activation must not show
|
||||
// an unsupported warning merely because no desktop window is open.
|
||||
sleepingAgentWake = 'unsupported-headless'
|
||||
}
|
||||
}
|
||||
}
|
||||
return { repoId: repo.id, worktreeId: worktree.id, activated: true }
|
||||
return { repoId: repo.id, worktreeId: worktree.id, activated: true, sleepingAgentWake }
|
||||
}
|
||||
|
||||
private async buildStartupForDraft(
|
||||
@@ -20814,7 +20842,9 @@ export class OrcaRuntimeService {
|
||||
: null
|
||||
const workspaceId = team?.workspaceId ?? params.workspaceId
|
||||
try {
|
||||
const result = await listLinearIssues(filter, limit, workspaceId, team?.id)
|
||||
const result = await listLinearIssues(filter, limit, workspaceId, {
|
||||
teamId: team?.id
|
||||
})
|
||||
return {
|
||||
issues: result.items.map((issue) => ({
|
||||
id: issue.id,
|
||||
@@ -20942,9 +20972,9 @@ export class OrcaRuntimeService {
|
||||
filter?: LinearListFilter,
|
||||
limit = 20,
|
||||
workspaceId?: LinearWorkspaceSelection,
|
||||
teamId?: string
|
||||
options?: LinearIssueListOptions
|
||||
): ReturnType<typeof listLinearIssues> {
|
||||
return listLinearIssues(filter, clampLinearIssueListLimit(limit), workspaceId, teamId)
|
||||
return listLinearIssues(filter, clampLinearIssueListLimit(limit), workspaceId, options)
|
||||
}
|
||||
|
||||
linearCreateIssue(
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH,
|
||||
LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS,
|
||||
LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_PRIORITIES,
|
||||
LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS
|
||||
} from '../../../../shared/linear-issue-attribute-filter'
|
||||
|
||||
// Why: keep ListIssues param validation co-located with shared limits without
|
||||
// pushing linear.ts past the max-lines ratchet.
|
||||
const LinearAttributeFilterId = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH)
|
||||
|
||||
export const LinearIssueAttributeFilterSchema = z
|
||||
.object({
|
||||
stateIds: z.array(LinearAttributeFilterId).max(LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS),
|
||||
priorities: z
|
||||
.array(z.number().int().min(0).max(4))
|
||||
.max(LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_PRIORITIES),
|
||||
assignee: z.union([
|
||||
z.object({ kind: z.literal('unassigned') }).strict(),
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('user'),
|
||||
id: LinearAttributeFilterId
|
||||
})
|
||||
.strict(),
|
||||
z.null()
|
||||
]),
|
||||
labelIds: z.array(LinearAttributeFilterId).max(LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS)
|
||||
})
|
||||
.strict()
|
||||
@@ -102,7 +102,9 @@ describe('linear RPC methods', () => {
|
||||
)
|
||||
|
||||
expect(runtime.linearSearchIssues).toHaveBeenCalledWith('bug', 30, 'all')
|
||||
expect(runtime.linearListIssues).toHaveBeenCalledWith('assigned', 20, 'workspace-1')
|
||||
expect(runtime.linearListIssues).toHaveBeenCalledWith('assigned', 20, 'workspace-1', {
|
||||
attributeFilter: undefined
|
||||
})
|
||||
expect(runtime.linearGetIssue).toHaveBeenCalledWith('issue-3', 'workspace-1')
|
||||
expect(runtime.linearCreateIssue).toHaveBeenCalledWith(
|
||||
'team-1',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from 'zod'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
|
||||
import { LINEAR_PROJECT_CREATE_METHOD } from './linear-project-create'
|
||||
import { LinearIssueAttributeFilterSchema } from './linear-issue-attribute-filter-schema'
|
||||
|
||||
const VALID_FILTERS = ['assigned', 'created', 'all', 'completed'] as const
|
||||
const VALID_CUSTOM_VIEW_MODELS = ['issue', 'project'] as const
|
||||
@@ -37,8 +38,10 @@ const ListIssues = z
|
||||
.object({
|
||||
filter: z.enum(VALID_FILTERS).optional(),
|
||||
limit: OptionalFiniteNumber,
|
||||
workspaceId: OptionalString
|
||||
workspaceId: OptionalString,
|
||||
attributeFilter: LinearIssueAttributeFilterSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
.optional()
|
||||
|
||||
const CreateIssue = z.object({
|
||||
@@ -164,7 +167,9 @@ export const LINEAR_METHODS: RpcMethod[] = [
|
||||
name: 'linear.listIssues',
|
||||
params: ListIssues,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.linearListIssues(params?.filter, params?.limit, params?.workspaceId)
|
||||
runtime.linearListIssues(params?.filter, params?.limit, params?.workspaceId, {
|
||||
attributeFilter: params?.attributeFilter
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.createIssue',
|
||||
|
||||
@@ -10,6 +10,13 @@ export function getZshEnvTemplate(zshDir: string, headerPrefix = ''): string {
|
||||
? `Orca ${headerPrefix} zsh shell-ready wrapper`
|
||||
: 'Orca zsh shell-ready wrapper'
|
||||
return `# ${header}
|
||||
# Why: capture the runtime wrapper dir before it is unset below. On WSL this
|
||||
# file is generated with a Windows path but sourced via /mnt/c, so the baked
|
||||
# literal is unusable there and ZDOTDIR must be restored from this value.
|
||||
_orca_wrapper_zdotdir_self="\${ZDOTDIR:-}"
|
||||
while [[ "\${_orca_wrapper_zdotdir_self:-}" == */ ]]; do
|
||||
_orca_wrapper_zdotdir_self="\${_orca_wrapper_zdotdir_self%/}"
|
||||
done
|
||||
_orca_spawn_orig_zdotdir="\${ORCA_ORIG_ZDOTDIR:-}"
|
||||
_orca_user_zdotdir="\${_orca_spawn_orig_zdotdir:-$HOME}"
|
||||
_orca_zshenv_source_dir="\${ORCA_ZSHENV_SOURCE_DIR:-$HOME}"
|
||||
@@ -67,8 +74,14 @@ case "\${ORCA_ORIG_ZDOTDIR}" in
|
||||
""|*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;
|
||||
esac
|
||||
|
||||
export ZDOTDIR=${quotePosixSingle(zshDir)}
|
||||
unset _orca_spawn_orig_zdotdir _orca_user_zdotdir _orca_zshenv_source_dir _orca_zshenv_path _orca_discovered_zdotdir
|
||||
# Why: use :- after user .zshenv — a pathological unset under set -u must not
|
||||
# abort the wrapper; empty falls through to the baked-literal branch.
|
||||
if [[ -n "\${_orca_wrapper_zdotdir_self:-}" && -f "\${_orca_wrapper_zdotdir_self:-}/.zshenv" ]]; then
|
||||
export ZDOTDIR="\${_orca_wrapper_zdotdir_self:-}"
|
||||
else
|
||||
export ZDOTDIR=${quotePosixSingle(zshDir)}
|
||||
fi
|
||||
unset _orca_spawn_orig_zdotdir _orca_user_zdotdir _orca_zshenv_source_dir _orca_zshenv_path _orca_discovered_zdotdir _orca_wrapper_zdotdir_self
|
||||
`
|
||||
}
|
||||
|
||||
|
||||
@@ -239,6 +239,26 @@ describe('SttService', () => {
|
||||
expect(worker!.messages.filter((message) => message.type === 'feed')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('drops in-flight audio while stop is waiting for the worker', async () => {
|
||||
const service = new SttService({
|
||||
getModelState: vi.fn().mockResolvedValue({ id: 'model-a', status: 'ready' }),
|
||||
getModelDir: vi.fn().mockReturnValue('/tmp/model-a')
|
||||
} as never)
|
||||
|
||||
await service.startDictation('model-a', vi.fn(), undefined, 'desktop:1')
|
||||
const worker = getLastWorker()
|
||||
expect(worker).toBeDefined()
|
||||
worker!.emitStoppedOnStop = false
|
||||
|
||||
const stopPromise = service.stopDictation('desktop:1')
|
||||
await Promise.resolve()
|
||||
service.feedAudio(new Float32Array([1]), 16000, 'desktop:1')
|
||||
expect(worker!.messages.filter((message) => message.type === 'feed')).toHaveLength(0)
|
||||
|
||||
worker!.emit('message', { type: 'stopped' })
|
||||
await stopPromise
|
||||
})
|
||||
|
||||
it('rejects deletion prep while the target model is starting', async () => {
|
||||
let resolveModelState: (state: { id: string; status: string }) => void = () => {}
|
||||
const modelStatePromise = new Promise<{ id: string; status: string }>((resolve) => {
|
||||
|
||||
@@ -35,6 +35,10 @@ export class SttService {
|
||||
private canceledOwners = new Set<string>()
|
||||
private eventSink: SttEventSink | null = null
|
||||
private idleTeardownTimer: NodeJS.Timeout | null = null
|
||||
// Why: stop resolves only after the worker flushes; in-flight feedAudio IPC
|
||||
// must not enqueue samples after that flush or they stick on the warm worker
|
||||
// and contaminate the next dictation session.
|
||||
private stopping = false
|
||||
// Why: warm workers intentionally keep lifecycle listeners while reusable;
|
||||
// stale workers must not retain this service after error, exit, or teardown.
|
||||
private cleanupWorkerLifecycleListeners: (() => void) | null = null
|
||||
@@ -259,6 +263,9 @@ export class SttService {
|
||||
}
|
||||
|
||||
feedAudio(samples: Float32Array, sampleRate: number, owner = 'desktop'): void {
|
||||
if (this.stopping) {
|
||||
return
|
||||
}
|
||||
const currentOwner = this.activeOwner ?? this.startingOwner
|
||||
if (!currentOwner) {
|
||||
return
|
||||
@@ -288,92 +295,97 @@ export class SttService {
|
||||
throw new Error('dictation_owner_mismatch')
|
||||
}
|
||||
|
||||
if (this.cloudSession) {
|
||||
const session = this.cloudSession
|
||||
this.cloudSession = null
|
||||
try {
|
||||
const text = await session.finish()
|
||||
if (text) {
|
||||
this.eventSink?.({ type: 'final', text })
|
||||
this.stopping = true
|
||||
try {
|
||||
if (this.cloudSession) {
|
||||
const session = this.cloudSession
|
||||
this.cloudSession = null
|
||||
try {
|
||||
const text = await session.finish()
|
||||
if (text) {
|
||||
this.eventSink?.({ type: 'final', text })
|
||||
}
|
||||
} catch (error) {
|
||||
this.eventSink?.({
|
||||
type: 'error',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
} finally {
|
||||
this.eventSink?.({ type: 'stopped' })
|
||||
this.activeModelId = null
|
||||
this.activeHotwordsFilePath = undefined
|
||||
this.activeOwner = null
|
||||
this.eventSink = null
|
||||
}
|
||||
} catch (error) {
|
||||
this.eventSink?.({
|
||||
type: 'error',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
} finally {
|
||||
this.eventSink?.({ type: 'stopped' })
|
||||
this.activeModelId = null
|
||||
this.activeHotwordsFilePath = undefined
|
||||
this.activeOwner = null
|
||||
this.eventSink = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const worker = this.worker
|
||||
if (!worker) {
|
||||
return
|
||||
}
|
||||
worker.postMessage({ type: 'stop' })
|
||||
|
||||
let forcedTeardown = false
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const cleanup = (): void => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
worker.off('message', onStopped)
|
||||
return
|
||||
}
|
||||
|
||||
const finish = (): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cleanup()
|
||||
resolve()
|
||||
const worker = this.worker
|
||||
if (!worker) {
|
||||
return
|
||||
}
|
||||
worker.postMessage({ type: 'stop' })
|
||||
|
||||
const onStopped = (msg: { type: string; text?: string; error?: string }) => {
|
||||
if (msg.type === 'stopped') {
|
||||
finish()
|
||||
let forcedTeardown = false
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const cleanup = (): void => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
worker.off('message', onStopped)
|
||||
}
|
||||
|
||||
const finish = (): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
|
||||
const onStopped = (msg: { type: string; text?: string; error?: string }) => {
|
||||
if (msg.type === 'stopped') {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
forcedTeardown = true
|
||||
cleanup()
|
||||
// Why: a worker that cannot finish dictation is no longer reusable; do
|
||||
// not keep it in the warm-worker slot or retain its message listeners.
|
||||
this.cleanupActiveWorkerLifecycleListeners()
|
||||
worker.removeAllListeners()
|
||||
void worker.terminate().finally(resolve)
|
||||
}, STOP_DICTATION_TIMEOUT_MS)
|
||||
|
||||
worker.on('message', onStopped)
|
||||
})
|
||||
|
||||
if (this.worker === worker) {
|
||||
if (forcedTeardown) {
|
||||
this.worker = null
|
||||
this.activeModelId = null
|
||||
this.activeHotwordsFilePath = undefined
|
||||
this.activeOwner = null
|
||||
this.eventSink = null
|
||||
} else {
|
||||
this.activeOwner = null
|
||||
this.eventSink = null
|
||||
this.scheduleIdleTeardown()
|
||||
}
|
||||
}
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
forcedTeardown = true
|
||||
cleanup()
|
||||
// Why: a worker that cannot finish dictation is no longer reusable; do
|
||||
// not keep it in the warm-worker slot or retain its message listeners.
|
||||
this.cleanupActiveWorkerLifecycleListeners()
|
||||
worker.removeAllListeners()
|
||||
void worker.terminate().finally(resolve)
|
||||
}, STOP_DICTATION_TIMEOUT_MS)
|
||||
|
||||
worker.on('message', onStopped)
|
||||
})
|
||||
|
||||
if (this.worker === worker) {
|
||||
if (forcedTeardown) {
|
||||
this.worker = null
|
||||
this.activeModelId = null
|
||||
this.activeHotwordsFilePath = undefined
|
||||
this.activeOwner = null
|
||||
this.eventSink = null
|
||||
} else {
|
||||
this.activeOwner = null
|
||||
this.eventSink = null
|
||||
this.scheduleIdleTeardown()
|
||||
}
|
||||
} finally {
|
||||
this.stopping = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { readdirSync } from 'node:fs'
|
||||
|
||||
// Why: different models name their ONNX files differently (e.g.
|
||||
// encoder.int8.onnx vs tiny-encoder.onnx vs encoder-epoch-99-avg-1.onnx).
|
||||
// We resolve the actual path from the manifest's files list by searching
|
||||
// for the role name anywhere in the filename.
|
||||
export function resolveFile(
|
||||
files: string[],
|
||||
role: string,
|
||||
modelDir: string,
|
||||
ext = '.onnx'
|
||||
): string {
|
||||
const match = files.find((f) => f.includes(role) && f.endsWith(ext))
|
||||
if (!match) {
|
||||
throw new Error(`No *${role}*${ext} found in model files: ${files.join(', ')}`)
|
||||
}
|
||||
return `${modelDir}/${match}`
|
||||
}
|
||||
|
||||
export function resolveTokens(files: string[], modelDir: string): string {
|
||||
const match = files.find((f) => f.endsWith('tokens.txt'))
|
||||
if (!match) {
|
||||
throw new Error(`No *tokens.txt found in model files: ${files.join(', ')}`)
|
||||
}
|
||||
return `${modelDir}/${match}`
|
||||
}
|
||||
|
||||
// Why: BPE models need a vocab file for hotwords token matching. The file
|
||||
// ships in the model archive but isn't listed in the manifest. We discover
|
||||
// it at runtime to avoid breaking existing downloads.
|
||||
function discoverBpeVocab(modelDir: string): string | undefined {
|
||||
try {
|
||||
const entries = readdirSync(modelDir)
|
||||
const vocabFile = entries.find((f) => f.endsWith('.vocab'))
|
||||
return vocabFile ? `${modelDir}/${vocabFile}` : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export type HotwordsConfig = {
|
||||
decodingMethod: string
|
||||
hotwordsFile?: string
|
||||
hotwordsScore?: number
|
||||
modelingUnit?: string
|
||||
bpeVocab?: string
|
||||
}
|
||||
|
||||
export function buildHotwordsConfig(opts: {
|
||||
modelDir: string
|
||||
modelType: string
|
||||
hotwordsFilePath?: string
|
||||
modelingUnit?: string
|
||||
}): HotwordsConfig {
|
||||
if (opts.modelType !== 'transducer' || !opts.hotwordsFilePath) {
|
||||
return { decodingMethod: 'greedy_search' }
|
||||
}
|
||||
|
||||
const unit = opts.modelingUnit
|
||||
if (unit?.includes('bpe')) {
|
||||
const bpeVocab = discoverBpeVocab(opts.modelDir)
|
||||
if (!bpeVocab) {
|
||||
return { decodingMethod: 'greedy_search' }
|
||||
}
|
||||
return {
|
||||
decodingMethod: 'modified_beam_search',
|
||||
hotwordsFile: opts.hotwordsFilePath,
|
||||
hotwordsScore: 1.5,
|
||||
modelingUnit: unit,
|
||||
bpeVocab
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
decodingMethod: 'modified_beam_search',
|
||||
hotwordsFile: opts.hotwordsFilePath,
|
||||
hotwordsScore: 1.5,
|
||||
modelingUnit: unit
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
/* oxlint-disable typescript-eslint/no-explicit-any -- sherpa-onnx native addon has no type definitions */
|
||||
import { parentPort, workerData } from 'node:worker_threads'
|
||||
import { readdirSync } from 'node:fs'
|
||||
import { resampleToRate } from './stt-audio-resample'
|
||||
import { OfflineAudioChunker } from './stt-offline-audio-chunker'
|
||||
import { buildHotwordsConfig, resolveFile, resolveTokens } from './stt-worker-model-config'
|
||||
|
||||
type WorkerMessage =
|
||||
| {
|
||||
@@ -39,73 +39,6 @@ function loadSherpa(): any {
|
||||
return require(modulePath)
|
||||
}
|
||||
|
||||
// Why: different models name their ONNX files differently (e.g.
|
||||
// encoder.int8.onnx vs tiny-encoder.onnx vs encoder-epoch-99-avg-1.onnx).
|
||||
// We resolve the actual path from the manifest's files list by searching
|
||||
// for the role name anywhere in the filename.
|
||||
function resolveFile(files: string[], role: string, modelDir: string, ext = '.onnx'): string {
|
||||
const match = files.find((f) => f.includes(role) && f.endsWith(ext))
|
||||
if (!match) {
|
||||
throw new Error(`No *${role}*${ext} found in model files: ${files.join(', ')}`)
|
||||
}
|
||||
return `${modelDir}/${match}`
|
||||
}
|
||||
|
||||
function resolveTokens(files: string[], modelDir: string): string {
|
||||
const match = files.find((f) => f.endsWith('tokens.txt'))
|
||||
if (!match) {
|
||||
throw new Error(`No *tokens.txt found in model files: ${files.join(', ')}`)
|
||||
}
|
||||
return `${modelDir}/${match}`
|
||||
}
|
||||
|
||||
// Why: BPE models need a vocab file for hotwords token matching. The file
|
||||
// ships in the model archive but isn't listed in the manifest. We discover
|
||||
// it at runtime to avoid breaking existing downloads.
|
||||
function discoverBpeVocab(modelDir: string): string | undefined {
|
||||
try {
|
||||
const entries = readdirSync(modelDir)
|
||||
const vocabFile = entries.find((f) => f.endsWith('.vocab'))
|
||||
return vocabFile ? `${modelDir}/${vocabFile}` : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function buildHotwordsConfig(msg: Extract<WorkerMessage, { type: 'init' }>): {
|
||||
decodingMethod: string
|
||||
hotwordsFile?: string
|
||||
hotwordsScore?: number
|
||||
modelingUnit?: string
|
||||
bpeVocab?: string
|
||||
} {
|
||||
if (msg.modelType !== 'transducer' || !msg.hotwordsFilePath) {
|
||||
return { decodingMethod: 'greedy_search' }
|
||||
}
|
||||
|
||||
const unit = msg.modelingUnit
|
||||
if (unit?.includes('bpe')) {
|
||||
const bpeVocab = discoverBpeVocab(msg.modelDir)
|
||||
if (!bpeVocab) {
|
||||
return { decodingMethod: 'greedy_search' }
|
||||
}
|
||||
return {
|
||||
decodingMethod: 'modified_beam_search',
|
||||
hotwordsFile: msg.hotwordsFilePath,
|
||||
hotwordsScore: 1.5,
|
||||
modelingUnit: unit,
|
||||
bpeVocab
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
decodingMethod: 'modified_beam_search',
|
||||
hotwordsFile: msg.hotwordsFilePath,
|
||||
hotwordsScore: 1.5,
|
||||
modelingUnit: unit
|
||||
}
|
||||
}
|
||||
|
||||
function handleInit(msg: Extract<WorkerMessage, { type: 'init' }>): void {
|
||||
try {
|
||||
sherpa = loadSherpa()
|
||||
@@ -204,15 +137,33 @@ function handleInit(msg: Extract<WorkerMessage, { type: 'init' }>): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: an offline stream is single-use — decode one bounded chunk, then
|
||||
// recreate the stream so the recognizer is ready for the next chunk.
|
||||
// Why: an offline stream is single-use — always mint a fresh stream after an
|
||||
// attempt so a failed decode cannot leave a spent stream on a warm worker.
|
||||
function decodeOfflineChunk(samples: Float32Array): string {
|
||||
sherpa.acceptWaveformOffline(stream, { sampleRate: offlineSampleRate, samples })
|
||||
sherpa.decodeOfflineStream(recognizer, stream)
|
||||
const resultJson = sherpa.getOfflineStreamResultAsJson(stream)
|
||||
stream = sherpa.createOfflineStream(recognizer)
|
||||
const result = JSON.parse(resultJson)
|
||||
return result?.text?.trim() ?? ''
|
||||
try {
|
||||
sherpa.acceptWaveformOffline(stream, { sampleRate: offlineSampleRate, samples })
|
||||
sherpa.decodeOfflineStream(recognizer, stream)
|
||||
const resultJson = sherpa.getOfflineStreamResultAsJson(stream)
|
||||
const result = JSON.parse(resultJson)
|
||||
return result?.text?.trim() ?? ''
|
||||
} finally {
|
||||
if (sherpa && recognizer) {
|
||||
stream = sherpa.createOfflineStream(recognizer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: warm-worker reuse must not see residual audio or a spent offline stream.
|
||||
// Recovery failures are swallowed so they cannot skip the stopped lifecycle signal.
|
||||
function resetOfflineSessionState(): void {
|
||||
try {
|
||||
offlineChunker = new OfflineAudioChunker(offlineSampleRate)
|
||||
if (sherpa && recognizer) {
|
||||
stream = sherpa.createOfflineStream(recognizer)
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: prefer posting stopped over stranding stopDictation for 60s.
|
||||
}
|
||||
}
|
||||
|
||||
function handleFeed(msg: Extract<WorkerMessage, { type: 'feed' }>): void {
|
||||
@@ -253,12 +204,23 @@ function handleFeed(msg: Extract<WorkerMessage, { type: 'feed' }>): void {
|
||||
// the whole app (#7925). Decode bounded chunks as they fill instead;
|
||||
// each consumer already appends multiple 'final' segments per session.
|
||||
const readyChunks = offlineChunker?.push(new Float32Array(samples)) ?? []
|
||||
// Why: keep decoding later ready windows after one failure — push() has
|
||||
// already removed them from the chunker, and decodeOfflineChunk refreshes
|
||||
// the stream in finally so a spent stream cannot poison the next attempt.
|
||||
let firstError: unknown = null
|
||||
for (const chunk of readyChunks) {
|
||||
const text = decodeOfflineChunk(chunk)
|
||||
if (text) {
|
||||
parentPort?.postMessage({ type: 'final', text })
|
||||
try {
|
||||
const text = decodeOfflineChunk(chunk)
|
||||
if (text) {
|
||||
parentPort?.postMessage({ type: 'final', text })
|
||||
}
|
||||
} catch (err) {
|
||||
firstError ??= err
|
||||
}
|
||||
}
|
||||
if (firstError) {
|
||||
throw firstError
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
parentPort?.postMessage({ type: 'error', error: String(err) })
|
||||
@@ -294,12 +256,17 @@ function handleStop(): void {
|
||||
parentPort?.postMessage({ type: 'final', text })
|
||||
}
|
||||
}
|
||||
resetOfflineSessionState()
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isStreaming) {
|
||||
resetOfflineSessionState()
|
||||
}
|
||||
parentPort?.postMessage({ type: 'error', error: String(err) })
|
||||
} finally {
|
||||
// Why: stopDictation waits on this signal; recovery must never prevent it.
|
||||
parentPort?.postMessage({ type: 'stopped' })
|
||||
}
|
||||
|
||||
parentPort?.postMessage({ type: 'stopped' })
|
||||
}
|
||||
|
||||
function handleTeardown(): void {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user