mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
@@ -897,6 +897,9 @@ jobs:
|
||||
contents: read
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
with:
|
||||
# The synthetic pull-request merge ref can disappear while this reusable
|
||||
# workflow is queued. The head SHA is immutable and works for every PR.
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
test_files: ${{ needs.e2e-paths.outputs.test_files }}
|
||||
ssh_source_changed: ${{ needs.e2e-paths.outputs.ssh_source_changed }}
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ describe('PR E2E gate contract', () => {
|
||||
expect(prWorkflow.jobs['e2e-paths'].outputs.test_files).toBe(
|
||||
'${{ steps.filter.outputs.test_files }}'
|
||||
)
|
||||
expect(prWorkflow.jobs.e2e.with.ref).toBe('${{ github.event.pull_request.head.sha }}')
|
||||
expect(prWorkflow.jobs.e2e.with.test_files).toBe('${{ needs.e2e-paths.outputs.test_files }}')
|
||||
})
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ const result = spawnSync(
|
||||
'tests/e2e/ssh-ai-vault-session-history.spec.ts',
|
||||
'tests/e2e/ssh-cold-activation-restore.spec.ts',
|
||||
'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts',
|
||||
'tests/e2e/ssh-docker-quick-open-large-listing.spec.ts',
|
||||
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
|
||||
'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts',
|
||||
'tests/e2e/ssh-external-image-preview.spec.ts',
|
||||
|
||||
@@ -357,6 +357,21 @@ the command:
|
||||
This disables a security boundary. Prefer a dedicated unprivileged service
|
||||
user, especially when the listener is reachable beyond localhost.
|
||||
|
||||
The Linux CLI is named `orca-ide`, not `orca`, so it never shadows the GNOME
|
||||
Orca screen reader at `/usr/bin/orca`. The `.deb` and `.rpm` packages put
|
||||
`orca-ide` on `PATH` themselves at install time; with the AppImage it arrives
|
||||
as `~/.local/bin/orca-ide` when the CLI is registered.
|
||||
|
||||
A packaged `orca serve` start also writes a bare `orca` into `~/.local/bin`
|
||||
that execs the same launcher, which is why the skills commands below can be
|
||||
typed as `orca`. It writes it while starting, so it is never the command that
|
||||
starts the server — the first launch is `orca-ide serve`, or the AppImage
|
||||
invoked directly as above. The write is best-effort: it is gated on a packaged
|
||||
build, it is skipped when no bundled launcher resolves, and it is skipped when
|
||||
a file Orca does not own already holds that name (ownership is a marker on the
|
||||
second line of the file). A host that really does run the screen reader keeps
|
||||
its own `orca`.
|
||||
|
||||
## Pairing troubleshooting
|
||||
|
||||
- A pairing offer is a capability containing a device credential and E2EE
|
||||
|
||||
@@ -72,12 +72,20 @@ behavioural engine can be expected to score it low.
|
||||
|
||||
### Every process gets a handle, on a timer
|
||||
|
||||
`src/main/windows/windows-process-table.ts` takes a Toolhelp32 snapshot under one
|
||||
of two flag sets. Identity (`None | CreationTime`) answers pid/ppid/name from the
|
||||
snapshot alone and opens nothing; the detailed set adds `CommandLine`, which
|
||||
costs one `OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)` per process. `Memory`
|
||||
is retired — it took a second handle carrying `PROCESS_VM_READ` and never read
|
||||
through it.
|
||||
`src/main/windows/windows-process-table.ts` takes a Toolhelp32 snapshot under
|
||||
**one** flag set, `CommandLine | CreationTime`, shared by every caller. pid, ppid
|
||||
and name come out of the snapshot itself and open nothing. `CommandLine` is what
|
||||
opens a handle: the addon calls `GetProcessCommandLine` per process, which opens
|
||||
`PROCESS_QUERY_INFORMATION | PROCESS_VM_READ` and walks the PEB with three
|
||||
`ReadProcessMemory` calls (`src/process_commandline.cc:32,41-47` in the vendored
|
||||
`@vscode/windows-process-tree` 0.8.0 source that `config/patches/` patches).
|
||||
|
||||
`Memory` is retired as of this change, and that is a real reduction: it made
|
||||
`GetProcessMemoryUsage` open a **second** `PROCESS_QUERY_INFORMATION |
|
||||
PROCESS_VM_READ` handle per process for a `GetProcessMemoryInfo` call whose
|
||||
result no caller read (`src/process.cc:47-63`). Dropping it halves the handles
|
||||
opened per snapshot. It does not remove the remote memory read, because the
|
||||
command line still performs one.
|
||||
|
||||
It exists because seven independent readers used to fork `powershell.exe` for a
|
||||
`Get-CimInstance Win32_Process` scan. That cost, measured: a PowerShell
|
||||
@@ -90,23 +98,41 @@ panes multiplied it (#15036). The native snapshot answers the same question in
|
||||
See
|
||||
[`windows-process-enumeration.md`](./windows-process-enumeration.md).
|
||||
|
||||
Asking for fewer fields is cheaper, and since the split the module does: one
|
||||
cache per flag set, so teardown identity and the session owner probe open no
|
||||
handle at all (6.3 ms p50) while only the callers that read a command line pay
|
||||
for one (12.3 ms p50, at 492 processes). Each cache still single-flights within
|
||||
itself, and one gate serializes the native reads because the vendored wrapper
|
||||
coalesces the flags of two overlapping calls.
|
||||
Asking for fewer fields is cheaper, and the module now asks for the smallest set
|
||||
that still answers every caller. There is **no** per-flag-set cache split: one
|
||||
TTL-cached snapshot serves everyone, deliberately, because a split would restore
|
||||
the per-pane fan-out the cache exists to remove — a 32-wide teardown has to
|
||||
collapse into one scan. So the cheap identity-only read is not something any
|
||||
caller can select; every read pays for `CommandLine`. An earlier revision of this
|
||||
file described a two-cache design with 6.3 ms / 12.3 ms p50 figures at 492
|
||||
processes. That design is not in the tree and those numbers describe no code
|
||||
path here; the figures that do apply are the module's own, in
|
||||
[`windows-process-enumeration.md`](./windows-process-enumeration.md).
|
||||
|
||||
**How an EDR reads it:** a cross-process handle plus a remote memory read against
|
||||
every process on the box, repeating on a cadence, is the read half of the
|
||||
telemetry that credential dumping and process injection produce. MDE surfaced it
|
||||
as "suspicious memory activity". The memory read is gone: the command line now
|
||||
comes from the kernel, through `NtQueryInformationProcess`'s
|
||||
`ProcessCommandLineInformation` class, which needs only
|
||||
`PROCESS_QUERY_LIMITED_INFORMATION`. `ReadProcessMemory` is absent from the
|
||||
compiled addon, asserted against the binary's import table because the published
|
||||
prebuild loads fine and emits byte-identical strings. What is left to declare to
|
||||
administrators is the per-process handle itself.
|
||||
as "suspicious memory activity".
|
||||
|
||||
**That signal is still present.** An earlier revision of this file claimed the
|
||||
command line "now comes from the kernel" through `NtQueryInformationProcess`'s
|
||||
`ProcessCommandLineInformation` class, needing only
|
||||
`PROCESS_QUERY_LIMITED_INFORMATION`, and that `ReadProcessMemory` was absent from
|
||||
the compiled addon. None of that is true of the code we ship.
|
||||
`process_commandline.cc` calls `NtQueryInformationProcess` with
|
||||
`ProcessBasicInformation` only — to locate the PEB — and then issues three
|
||||
`ReadProcessMemory` calls against a `PROCESS_VM_READ` handle to read the PEB, the
|
||||
`RTL_USER_PROCESS_PARAMETERS`, and the command-line buffer. Nothing asserts an
|
||||
import table, and no such assertion would pass.
|
||||
|
||||
What this change did remove is the `Memory` flag's second handle and its
|
||||
`GetProcessMemoryInfo` call, so the per-process handle count per snapshot halves.
|
||||
What remains to declare to administrators is unchanged in kind: one
|
||||
`PROCESS_QUERY_INFORMATION | PROCESS_VM_READ` handle and a PEB read against every
|
||||
process on the box, at the shared snapshot's cadence. Moving to
|
||||
`ProcessCommandLineInformation` (Windows 8.1+, `PROCESS_QUERY_LIMITED_INFORMATION`
|
||||
only) would genuinely retire the remote read, but it is an addon patch nobody has
|
||||
written; treat it as unclaimed work, not as shipped.
|
||||
|
||||
### Encoded, policy-bypassing PowerShell
|
||||
|
||||
|
||||
@@ -40,6 +40,15 @@ Measured on Windows 11 with 1050 processes (p50 / p95):
|
||||
| + memory + command line | 30.6 ms | 33.7 ms |
|
||||
| `Get-CimInstance` via PowerShell | 706 ms | 723 ms |
|
||||
|
||||
Those are the module's published figures. The flag set this module actually
|
||||
requests is `CommandLine | CreationTime` — **not** `Memory`, which cost a second
|
||||
`OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ)` plus
|
||||
`GetProcessMemoryInfo` per process (`src/process.cc:47-63`) for a value nothing
|
||||
read. Dropping it halves the handles a snapshot opens. The remaining set sits
|
||||
between the two rows above and has not been measured separately; on a real
|
||||
Windows host, `Get-Counter '\Process(Orca)\Handle Count'` sampled across a
|
||||
snapshot cadence is the check.
|
||||
|
||||
Those CIM numbers are from a 1050-process host. The scan scales with process
|
||||
count: on a 1486-process Windows SSH host it measured **1.36 s** and produced
|
||||
**4.8 MiB** of JSON, against the fallback's 3 s and 8 MiB limits. Both limits
|
||||
@@ -220,11 +229,13 @@ ownership, and CPU accounting in the memory collector — still reads it through
|
||||
its own query. Those callers are not migrated.
|
||||
|
||||
Committed private bytes have no equivalent either, and the one memory value the
|
||||
snapshot does carry is unusable for the sizes Orca now sees: `process.cc` stores
|
||||
snapshot _can_ carry is unusable for the sizes Orca now sees: `process.cc` stores
|
||||
`pmc.WorkingSetSize` into a `DWORD`, so anything above 4 GB wraps. That is the
|
||||
second reason `windows-process-resource-collector.ts` still runs its own
|
||||
`Get-CimInstance` sweep — it needs `PageFileUsage` (commit) and the CPU-time
|
||||
counters in the same pass. Migrating it to the native table would cost both.
|
||||
counters in the same pass. Migrating it to the native table would cost both, and
|
||||
it is why this module no longer sets the `Memory` flag at all: the field had no
|
||||
reader, and asking for it opened a handle per process on every snapshot.
|
||||
|
||||
Start time is a proxy for identity, not identity. The durable answer for the
|
||||
process trees Orca itself spawns is an inherited handle: a job object names the
|
||||
|
||||
@@ -35,11 +35,11 @@ silent, and indistinguishable from the real thing.
|
||||
|
||||
Three instances so far:
|
||||
|
||||
| Where | What the user saw | Status |
|
||||
| --- | --- | --- |
|
||||
| Preflight CLI probes | Caching the result would have pinned "git not installed" until relaunch | Bounded entry ([#17350](https://github.com/stablyai/orca/pull/17350)) |
|
||||
| `glab auth status` fallback into WSL | Idle VM woken repeatedly for users who never touch GitLab | Open ([#8941](https://github.com/stablyai/orca/issues/8941)) |
|
||||
| `listRunningWslDistrosAsync` | Fails closed to `[]` with no last-known-good, polled every 2s — a persistently broken `wsl.exe` makes every WSL session vanish app-wide | Open (PR #17072 review) |
|
||||
| Where | What the user saw | Status |
|
||||
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
|
||||
| Preflight CLI probes | Caching the result would have pinned "git not installed" until relaunch | Bounded entry ([#17350](https://github.com/stablyai/orca/pull/17350)) |
|
||||
| `glab auth status` fallback into WSL | Idle VM woken repeatedly for users who never touch GitLab | Open ([#8941](https://github.com/stablyai/orca/issues/8941)) |
|
||||
| `listRunningWslDistrosAsync` | Fails closed to `[]` with no last-known-good, polled every 2s — a persistently broken `wsl.exe` makes every WSL session vanish app-wide | Open (PR #17072 review) |
|
||||
|
||||
## What to do instead
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Callout } from '@/components/docs/prose'
|
||||
|
||||
The Orca CLI is the `orca` command-line interface for scripting a running Orca editor from any shell. Use it to create and inspect worktrees, drive agent terminals, open files and diffs, automate the built-in browser, run scheduled automations, share HTML/Markdown artifacts, and control Orca-native tools from scripts or AI agents.
|
||||
|
||||
It ships with the desktop app; register it under [Settings → General → Orca CLI](/docs/settings).
|
||||
It ships with the desktop app; register it under [Settings → General → Orca CLI](/docs/settings). On Linux the command is `orca-ide`, because GNOME Orca's screen reader already owns `/usr/bin/orca` — see [Install → Linux](/docs/install#linux).
|
||||
|
||||
Agents can install the matching Orca CLI skill with:
|
||||
|
||||
|
||||
@@ -9,13 +9,29 @@ The `orca` CLI talks to a running Orca runtime. Use it when a shell script or ag
|
||||
|
||||
## Verify the runtime
|
||||
|
||||
Register the CLI under [Settings → General → Orca CLI](/docs/settings), then check that it can reach Orca:
|
||||
Register the CLI under [Settings → General → Orca CLI](/docs/settings), then check that it can reach Orca.
|
||||
|
||||
<Callout title="On Linux the command is orca-ide">
|
||||
GNOME Orca — the screen reader that ships with most GNOME desktops — already owns `/usr/bin/orca`,
|
||||
so Orca's Linux CLI installs as `orca-ide`. Do not check for it with `command -v orca`: that
|
||||
succeeds on a GNOME desktop and resolves to the screen reader, not to Orca. This page writes
|
||||
`orca` throughout — read it as `orca-ide` on Linux. See [Install → Linux](/docs/install#linux).
|
||||
</Callout>
|
||||
|
||||
On macOS and Windows:
|
||||
|
||||
```bash
|
||||
command -v orca
|
||||
orca status --json
|
||||
```
|
||||
|
||||
On Linux:
|
||||
|
||||
```bash
|
||||
command -v orca-ide
|
||||
orca-ide status --json
|
||||
```
|
||||
|
||||
If Orca is not already running:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -31,8 +31,11 @@ import { Callout } from '@/components/docs/prose'
|
||||
</li>
|
||||
<li>
|
||||
**Linux:**
|
||||
[AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) ·
|
||||
[.deb](https://github.com/stablyai/orca/releases)
|
||||
AppImage
|
||||
[x64](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) ·
|
||||
[arm64](https://github.com/stablyai/orca/releases/latest/download/orca-linux-arm64.AppImage) ·
|
||||
[.deb](https://github.com/stablyai/orca/releases) ·
|
||||
[.rpm](https://github.com/stablyai/orca/releases) — see [Linux](#linux) for which to pick
|
||||
</li>
|
||||
<li>Older versions: [GitHub Releases](https://github.com/stablyai/orca/releases).</li>
|
||||
</ul>
|
||||
@@ -59,6 +62,8 @@ On first launch Orca will:
|
||||
|
||||
Orca auto-updates by default, tracking the **stable** channel. Stable releases are vetted; **RC (release candidate)** builds ship new features first, often daily.
|
||||
|
||||
On Linux, whether Orca can apply an update itself depends on which package you installed. See [Linux](#linux) before you pick one.
|
||||
|
||||
There is no permanent in-app opt-in for the RC channel. Modifier clicks on **Check for Updates** ([Settings → General → Updates](/docs/settings), or the app / Help menu):
|
||||
|
||||
| Modifier | Effect |
|
||||
@@ -87,4 +92,49 @@ The default shell can be set to PowerShell or CMD under [Settings → Terminal](
|
||||
|
||||
### Linux
|
||||
|
||||
AppImage and `.deb` builds are available. See the Releases page for details.
|
||||
Each published release ships three Linux packages — an **AppImage**, a **`.deb`**, and an **`.rpm`** — for both x64 and arm64. They contain the same app. What differs is how updates reach you, so pick on that.
|
||||
|
||||
| Package | Pick it when | Updates |
|
||||
| ------------ | --------------------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| **AppImage** | You want Orca to update itself, like on macOS and Windows | Orca downloads and applies the update in place |
|
||||
| **`.deb`** | You manage software with `apt` on Debian or Ubuntu | Orca tells you a version is out and hands you the install command |
|
||||
| **`.rpm`** | You manage software with `dnf`, `yum`, or `zypper` | Same as `.deb` |
|
||||
|
||||
The AppImage has a stable download link per architecture — [`orca-linux.AppImage`](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) for x64 and [`orca-linux-arm64.AppImage`](https://github.com/stablyai/orca/releases/latest/download/orca-linux-arm64.AppImage) for arm64 — and needs `chmod +x` before its first run, because GitHub release assets carry no permission bits. The `.deb` and `.rpm` filenames carry the version and architecture, and the two formats spell architecture differently (`orca-ide_<version>_amd64.deb` or `_arm64.deb`; `orca-ide-<version>.x86_64.rpm` or `.aarch64.rpm`), so take those from the [Releases page](https://github.com/stablyai/orca/releases) rather than a fixed URL.
|
||||
|
||||
#### How updating works
|
||||
|
||||
**The AppImage self-updates.** Choose it if you want automatic updates. Orca checks for a new release, you click **Update**, and it replaces the AppImage in place — the same flow as macOS and Windows.
|
||||
|
||||
**The `.deb` and `.rpm` do not self-update.** Orca still notices the new version and downloads the package, then gives you a **Copy Install Command** button. Copy it rather than retyping it: Orca resolves every program to an absolute path in a trusted system directory and single-quotes the package path, so what you paste looks like this:
|
||||
|
||||
```
|
||||
/usr/bin/sudo /usr/bin/apt install -- '/home/you/.cache/orca-updater/pending/orca-ide_1.4.194_amd64.deb'
|
||||
```
|
||||
|
||||
Which package manager appears depends on what your system actually has: `apt`, else `dpkg -i`, for a `.deb`; `zypper`, `dnf`, `yum`, then `rpm -Uvh` for an `.rpm`. The download directory follows `XDG_CACHE_HOME` when that is set and falls back to `~/.cache` when it is not.
|
||||
|
||||
**Quit Orca before you run the command**, then reopen it once the install finishes. You are replacing the files of a running application, and the package manager cannot swap them safely underneath a live process. Orca deliberately never escalates privileges to do this for you: installing a system package needs root, `orca serve` runs as an unprivileged user, and a headless machine has no authentication agent to prompt. VS Code and Signal make the same call on `.deb`.
|
||||
|
||||
**A distro-managed build is left alone.** If you are running a repackaged Orca — an AUR build, a Nix derivation — Orca sees that no package manager it can drive owns this install and stops offering a download it could never apply. It still reports that a new version exists, so you can update the way you normally would.
|
||||
|
||||
<Callout title="Planned: a signed apt/yum repository">
|
||||
[#18086](https://github.com/stablyai/orca/issues/18086) tracks publishing a signed repository so
|
||||
your OS package manager owns Orca updates the way it owns everything else. It does not exist yet —
|
||||
today, `.deb` and `.rpm` updates are the manual step described above.
|
||||
</Callout>
|
||||
|
||||
#### The CLI command is `orca-ide`
|
||||
|
||||
On Linux the [Orca CLI](/docs/cli/reference) installs as **`orca-ide`**, not `orca`. GNOME Orca — the screen reader that ships by default on Ubuntu and other GNOME desktops — already owns `/usr/bin/orca`, and Orca will not shadow it. The `.deb` and `.rpm` packages are named `orca-ide` for the same reason.
|
||||
|
||||
- The `.deb` and `.rpm` put `orca-ide` on your `PATH` at install time, as `/usr/bin/orca-ide`.
|
||||
- With the AppImage, register the CLI from [Settings → General → Orca CLI](/docs/settings). That installs `~/.local/bin/orca-ide`.
|
||||
- Inside Orca's own terminals, bare `orca` works. Orca puts a shim on the `PATH` of the terminals it manages, so agents and scripts running there use the same command as on macOS and Windows.
|
||||
- On a headless host, a packaged `orca serve` writes a bare `orca` into `~/.local/bin` as it starts, unless a file it does not own already holds that name. It writes that *during* startup, so it is never what starts the server — the first launch is always [`orca-ide serve`](/docs/remote-servers).
|
||||
|
||||
Do not verify with `command -v orca`: on a GNOME desktop that succeeds and resolves to the screen reader. Use `orca-ide` in your own shell and `orca` inside Orca. If you want the short name everywhere and you do not use the screen reader, link it yourself:
|
||||
|
||||
```
|
||||
ln -s "$(command -v orca-ide)" ~/.local/bin/orca
|
||||
```
|
||||
|
||||
@@ -126,6 +126,14 @@ Use `orca serve` when the host should run without the desktop window—for examp
|
||||
|
||||
Install Orca and its bundled CLI on the server, then run:
|
||||
|
||||
<Callout title="On Linux, start it with orca-ide serve">
|
||||
The Linux CLI is named `orca-ide`, because GNOME Orca's screen reader already owns
|
||||
`/usr/bin/orca`. A packaged `orca serve` does write a bare `orca` into `~/.local/bin`, but only
|
||||
while it is starting, so that shim can never be the command that starts the server. Read
|
||||
`orca serve` as `orca-ide serve` throughout this page when the host is Linux. See
|
||||
[Install → Linux](/docs/install#linux).
|
||||
</Callout>
|
||||
|
||||
```bash
|
||||
orca serve --pairing-address <server-tailscale-ip-or-hostname>
|
||||
```
|
||||
|
||||
@@ -52,10 +52,10 @@ Keep Orca running on a machine you control—an old laptop, Mac mini, home serve
|
||||
|
||||
**Easiest setup:** install Orca and Tailscale on both computers. On the server, open **Settings → Remote Orca Servers → Advertise this app as a server → New Link**, choose its Tailscale address, and generate an access link. On the client, choose **Add Server** and paste that link.
|
||||
|
||||
For a headless Linux server or service-managed VM, use `orca serve` as the alternative:
|
||||
For a headless Linux server or service-managed VM, use `orca serve` as the alternative. On Linux the CLI is named `orca-ide`, so the first launch is:
|
||||
|
||||
```bash
|
||||
orca serve --pairing-address <reachable-tailscale-ip-or-hostname>
|
||||
orca-ide serve --pairing-address <reachable-tailscale-ip-or-hostname>
|
||||
```
|
||||
|
||||
Full detail: [Remote Orca Servers](/docs/remote-servers).
|
||||
|
||||
@@ -205,6 +205,144 @@ describe('AgentHookServer listener replay', () => {
|
||||
expect(listener).toHaveBeenNthCalledWith(4, [])
|
||||
})
|
||||
|
||||
it('evicts only the matching persisted status identity', () => {
|
||||
const server = new AgentHookServer()
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
providerSession: { key: 'session_id', id: 'resume-me' },
|
||||
payload: { state: 'done', prompt: 'old run', agentType: 'claude' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const old = server.getStatusSnapshot()[0]
|
||||
expect(old).toBeDefined()
|
||||
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'working', prompt: 'new run', agentType: 'claude' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
server.dropPersistedStatusEntry({
|
||||
paneKey: old!.paneKey,
|
||||
receivedAt: old!.receivedAt,
|
||||
stateStartedAt: old!.stateStartedAt
|
||||
})
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working', prompt: 'new run' })
|
||||
|
||||
// A matching eviction follows ordinary dismissal semantics, including
|
||||
// preserving a resumable provider session for the still-live TUI.
|
||||
const resumed = new AgentHookServer()
|
||||
resumed.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
providerSession: { key: 'session_id', id: 'resume-me' },
|
||||
payload: { state: 'done', prompt: 'old run', agentType: 'claude' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const resumedIdentity = resumed.getStatusSnapshot()[0]!
|
||||
expect(
|
||||
resumed.dropPersistedStatusEntry({
|
||||
paneKey: resumedIdentity.paneKey,
|
||||
receivedAt: resumedIdentity.receivedAt,
|
||||
stateStartedAt: resumedIdentity.stateStartedAt
|
||||
})
|
||||
).toBe(true)
|
||||
expect(resumed.getStatusSnapshot()[0]).toMatchObject({
|
||||
providerSessionOnly: true,
|
||||
providerSession: { id: 'resume-me' }
|
||||
})
|
||||
})
|
||||
|
||||
it('evicts when the renderer identity was stamped after receipt but pins the same turn', () => {
|
||||
// Runtime-sync and recovery entries stamp updatedAt with Date.now()/capturedAt, which is
|
||||
// at or after main's receivedAt; the eviction must still land for those rows.
|
||||
const server = new AgentHookServer()
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'done', prompt: 'run', agentType: 'claude' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const entry = server.getStatusSnapshot()[0]!
|
||||
expect(
|
||||
server.dropPersistedStatusEntry({
|
||||
paneKey: entry.paneKey,
|
||||
receivedAt: entry.receivedAt + 5_000,
|
||||
stateStartedAt: entry.stateStartedAt
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
// A different turn never matches, whatever the receivedAt relationship.
|
||||
const other = new AgentHookServer()
|
||||
other.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'done', prompt: 'run', agentType: 'claude' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const otherEntry = other.getStatusSnapshot()[0]!
|
||||
expect(
|
||||
other.dropPersistedStatusEntry({
|
||||
paneKey: otherEntry.paneKey,
|
||||
receivedAt: otherEntry.receivedAt + 5_000,
|
||||
stateStartedAt: otherEntry.stateStartedAt + 1
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('evicts a batch of persisted identities with one status-change notification', () => {
|
||||
const server = new AgentHookServer()
|
||||
const otherPane = makePaneKey('tab-2', '22222222-2222-4222-8222-222222222222')
|
||||
for (const paneKey of [PANE, otherPane]) {
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey,
|
||||
tabId: paneKey.split(':')[0]!,
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'done', prompt: 'run', agentType: 'claude' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
}
|
||||
const listener = vi.fn()
|
||||
server.subscribeStatusChanges(listener)
|
||||
const dropped: string[] = []
|
||||
server.subscribeStatusDrop((paneKey) => dropped.push(paneKey))
|
||||
const identities = server.getStatusSnapshot().map((entry) => ({
|
||||
paneKey: entry.paneKey,
|
||||
receivedAt: entry.receivedAt,
|
||||
stateStartedAt: entry.stateStartedAt
|
||||
}))
|
||||
|
||||
const evicted = server.dropPersistedStatusEntries([
|
||||
...identities,
|
||||
// A stale identity never matches and never blocks the rest of the batch.
|
||||
{ ...identities[0]!, stateStartedAt: identities[0]!.stateStartedAt + 1 }
|
||||
])
|
||||
|
||||
expect(evicted.sort()).toEqual([PANE, otherPane].sort())
|
||||
expect(dropped.sort()).toEqual([PANE, otherPane].sort())
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
expect(server.getStatusSnapshot()).toEqual([])
|
||||
})
|
||||
|
||||
it('notifies pane-status-clear listener when pane teardown evicts a cached status', () => {
|
||||
const server = new AgentHookServer()
|
||||
const listener = vi.fn()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { paneHasStateClaims } from '../../../shared/agent-hook-listener/listener-state'
|
||||
import type { AgentStatusCacheIdentity } from '../../../shared/agent-status-types'
|
||||
import type { EnrichedAgentHookEventPayload } from './server-types'
|
||||
import { AgentHookServerAuthorityFences } from './server-authority-fences'
|
||||
|
||||
@@ -35,6 +36,51 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen
|
||||
this.emitStatusDropped(deleted.paneKey)
|
||||
}
|
||||
|
||||
/** Evict a UI-cleared status only if no newer status has replaced it. */
|
||||
dropPersistedStatusEntry(identity: AgentStatusCacheIdentity): boolean {
|
||||
return this.dropPersistedStatusEntries([identity]).length > 0
|
||||
}
|
||||
|
||||
/** Batch form: one persist and one listener notification for the whole set. Returns the
|
||||
* pane keys that were actually evicted. */
|
||||
dropPersistedStatusEntries(identities: readonly AgentStatusCacheIdentity[]): string[] {
|
||||
const evicted: string[] = []
|
||||
for (const identity of identities) {
|
||||
const resolvedPaneKey = this.resolvePaneKeyAlias(identity.paneKey)
|
||||
const existing = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as
|
||||
| EnrichedAgentHookEventPayload
|
||||
| undefined
|
||||
// Why: stateStartedAt pins the turn; the renderer's updatedAt is stamped at or after this
|
||||
// receivedAt (runtime-sync and recovery paths use Date.now()/capturedAt), so a strictly
|
||||
// newer cached event is the only replacement worth protecting.
|
||||
if (
|
||||
!existing ||
|
||||
existing.stateStartedAt !== identity.stateStartedAt ||
|
||||
existing.receivedAt > identity.receivedAt
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const deleted = this.deleteStatusEntry(resolvedPaneKey, { preserveAuthority: true })
|
||||
if (!deleted) {
|
||||
continue
|
||||
}
|
||||
const retained = this.toRetainedProviderSessionRow(deleted)
|
||||
if (retained) {
|
||||
this.state.lastStatusByPaneKey.set(deleted.paneKey, retained)
|
||||
}
|
||||
evicted.push(deleted.paneKey)
|
||||
}
|
||||
if (evicted.length === 0) {
|
||||
return evicted
|
||||
}
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
for (const paneKey of evicted) {
|
||||
this.emitStatusDropped(paneKey)
|
||||
}
|
||||
return evicted
|
||||
}
|
||||
|
||||
/** Retire panes whose owning process is certifiably dead.
|
||||
*
|
||||
* The ordinary teardown already does this: every attributable PTY exit reaches
|
||||
|
||||
@@ -187,10 +187,15 @@ export async function removeStaleDurableWriteTempFiles(
|
||||
}
|
||||
|
||||
/** Synchronous counterpart for quit and crash paths that cannot await. */
|
||||
export function writeFileDurableSync(tmpPath: string, finalPath: string, payload: string): void {
|
||||
export function writeFileDurableSync(
|
||||
tmpPath: string,
|
||||
finalPath: string,
|
||||
payload: string | Uint8Array
|
||||
): void {
|
||||
let renamed = false
|
||||
try {
|
||||
writeFileSync(tmpPath, payload, 'utf-8')
|
||||
// A Uint8Array payload is written verbatim; a string still defaults to UTF-8.
|
||||
writeFileSync(tmpPath, payload)
|
||||
const fd = openSync(tmpPath, 'r+')
|
||||
try {
|
||||
fsyncSync(fd)
|
||||
|
||||
@@ -157,7 +157,11 @@ export async function probeAnyExactRefBatched(
|
||||
} catch {
|
||||
return { found: false, unknown: true }
|
||||
}
|
||||
const lines = stdout.split('\n').filter((line) => line.trim().length > 0)
|
||||
// Trim per line so a CRLF-translating host's `\r` does not become part of the type.
|
||||
const lines = stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
// One line per input, in order; a short read means the batch never answered for the rest.
|
||||
if (lines.length !== safeRefs.length) {
|
||||
return { found: false, unknown: true }
|
||||
|
||||
@@ -193,6 +193,17 @@ describe('git remote operations', () => {
|
||||
if (args[0] === 'remote' && args[1] === 'get-url' && args[2] === 'pr-pynickle-orca') {
|
||||
return { stdout: 'https://github.com/pynickle/orca.git\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'remote' && args[1] === '-v') {
|
||||
return {
|
||||
stdout: [
|
||||
'origin\thttps://github.com/stablyai/orca.git (fetch)',
|
||||
'origin\thttps://github.com/stablyai/orca.git (push)',
|
||||
'pr-pynickle-orca\thttps://github.com/pynickle/orca.git (fetch)',
|
||||
'pr-pynickle-orca\thttps://github.com/pynickle/orca.git (push)'
|
||||
].join('\n'),
|
||||
stderr: ''
|
||||
}
|
||||
}
|
||||
if (args[0] === 'remote') {
|
||||
return { stdout: 'origin\npr-pynickle-orca\n', stderr: '' }
|
||||
}
|
||||
@@ -207,6 +218,54 @@ describe('git remote operations', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// Regression: normalizing a URL-valued push remote used to run `git remote` and then a
|
||||
// serial `git remote get-url` per remote -- 59 subprocesses on a 58-remote repo.
|
||||
it('normalizes a URL-valued push remote from one remote table read at 58 remotes', async () => {
|
||||
const remotes = [
|
||||
{ name: 'origin', url: 'https://github.com/stablyai/orca.git' },
|
||||
...Array.from({ length: 56 }, (_, index) => ({
|
||||
name: `pr-user${index}-orca`,
|
||||
url: `https://github.com/user${index}/orca.git`
|
||||
})),
|
||||
{ name: 'pr-pynickle-orca', url: 'https://github.com/pynickle/orca.git' }
|
||||
]
|
||||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: 'imp/chinese-translation\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.remote')) {
|
||||
return { stdout: 'https://github.com/pynickle/orca.git\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.merge')) {
|
||||
return { stdout: 'refs/heads/imp/chinese-translation\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'config') {
|
||||
throw new Error(`config key is not set: ${args.join(' ')}`)
|
||||
}
|
||||
if (args[0] === 'remote' && args[1] === '-v') {
|
||||
return {
|
||||
stdout: remotes
|
||||
.flatMap(({ name, url }) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`])
|
||||
.join('\n'),
|
||||
stderr: ''
|
||||
}
|
||||
}
|
||||
if (args[0] === 'remote') {
|
||||
throw new Error(`unexpected remote scan: ${args.join(' ')}`)
|
||||
}
|
||||
return { stdout: '', stderr: '' }
|
||||
})
|
||||
|
||||
await gitPush('/repo', false)
|
||||
|
||||
const remoteReads = gitExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'remote')
|
||||
expect(remoteReads.map(([args]) => args)).toEqual([['remote', '-v']])
|
||||
expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith(
|
||||
['push', '--set-upstream', 'pr-pynickle-orca', 'HEAD:imp/chinese-translation'],
|
||||
{ cwd: '/repo' }
|
||||
)
|
||||
})
|
||||
|
||||
it('uses an explicit push target even when it differs from the local branch name', async () => {
|
||||
gitExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '' })
|
||||
|
||||
+15
-23
@@ -4,6 +4,7 @@ import {
|
||||
} from '../../shared/git-remote-error'
|
||||
import { resolveEffectiveGitUpstream } from '../../shared/git-effective-upstream'
|
||||
import { gitRefTargetsBranchOnRemote } from '../../shared/git-remote-branch-name'
|
||||
import { findGitRemoteNameByFetchUrl } from '../../shared/git-remote-url-index'
|
||||
import type { GitPushTarget } from '../../shared/worktree/types'
|
||||
import type { GitRuntimeOptions } from './git-runtime-options'
|
||||
import { gitOptionsForWorktree } from './git-runtime-options'
|
||||
@@ -84,6 +85,8 @@ type ConfiguredPushRemote = {
|
||||
branchRemote: string | null
|
||||
}
|
||||
|
||||
// One `git remote -v` instead of `git remote` plus a serial `git remote get-url`
|
||||
// per remote; both print the same insteadOf-expanded fetch URL.
|
||||
async function findRemoteNameForUrl(
|
||||
worktreePath: string,
|
||||
remoteUrl: string,
|
||||
@@ -91,30 +94,13 @@ async function findRemoteNameForUrl(
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(
|
||||
['remote'],
|
||||
['remote', '-v'],
|
||||
gitOptionsForWorktree(worktreePath, options)
|
||||
)
|
||||
const remotes = stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
for (const remoteName of remotes) {
|
||||
try {
|
||||
const { stdout: urlStdout } = await gitExecFileAsync(
|
||||
['remote', 'get-url', remoteName],
|
||||
gitOptionsForWorktree(worktreePath, options)
|
||||
)
|
||||
if (urlStdout.trim() === remoteUrl) {
|
||||
return remoteName
|
||||
}
|
||||
} catch {
|
||||
// Ignore a remote that disappeared or has no fetch URL.
|
||||
}
|
||||
}
|
||||
return findGitRemoteNameByFetchUrl(stdout, (candidateUrl) => candidateUrl === remoteUrl)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function normalizePushRemote(
|
||||
@@ -141,11 +127,17 @@ async function getConfiguredPushRemote(
|
||||
if (!remote) {
|
||||
return null
|
||||
}
|
||||
const normalizedRemote = await normalizePushRemote(worktreePath, remote, options)
|
||||
// The two usually name the same URL; resolving it twice reads the remote table twice.
|
||||
if (!branchRemote) {
|
||||
return { remote: normalizedRemote, branchRemote: null }
|
||||
}
|
||||
return {
|
||||
remote: await normalizePushRemote(worktreePath, remote, options),
|
||||
branchRemote: branchRemote
|
||||
? await normalizePushRemote(worktreePath, branchRemote, options)
|
||||
: null
|
||||
remote: normalizedRemote,
|
||||
branchRemote:
|
||||
branchRemote === remote
|
||||
? normalizedRemote
|
||||
: await normalizePushRemote(worktreePath, branchRemote, options)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Why: the batched `cat-file --batch-check` conflict probe decides from stdout, so a
|
||||
// WSL login-shell fallback that prints the distro banner onto that stream desynchronizes
|
||||
// the one-line-per-ref contract. Every batch then came back undecided and fell through to
|
||||
// one `show-ref` subprocess per remote -- the cost the batch exists to remove. These tests
|
||||
// pin the fence request and the resulting subprocess count at 58 remotes.
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() }))
|
||||
|
||||
vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock }))
|
||||
|
||||
import { getBranchConflictKind } from './repo-branch-conflict'
|
||||
|
||||
const REMOTES = Array.from({ length: 58 }, (_, index) => `r${index}`)
|
||||
const BRANCH = 'user/feature'
|
||||
const WSL_BANNER =
|
||||
'Welcome to Ubuntu 24.04.1 LTS (GNU/Linux 5.15.167.4-microsoft-standard-WSL2 x86_64)\n' +
|
||||
'To run a command as administrator (user "root"), use "sudo <command>".\n'
|
||||
|
||||
type GitExecOptions = { stdin?: string; captureWslLoginShellOutput?: boolean }
|
||||
|
||||
/**
|
||||
* Stand-in for a WSL-routed runner: the login shell prepends its banner to stdout unless
|
||||
* the caller asked for the fenced form, which slices the payload back out.
|
||||
*/
|
||||
function installLoginShellRunner(): { argv: string[][] } {
|
||||
const argv: string[][] = []
|
||||
gitExecFileAsyncMock.mockImplementation(async (args: string[], options: GitExecOptions = {}) => {
|
||||
argv.push(args)
|
||||
if (args[0] === 'rev-parse') {
|
||||
throw new Error('local branch is absent')
|
||||
}
|
||||
if (args[0] === 'remote') {
|
||||
return { stdout: `${WSL_BANNER}${REMOTES.join('\n')}\n`, stderr: '' }
|
||||
}
|
||||
if (args[0] === 'show-ref') {
|
||||
throw Object.assign(new Error('missing ref'), { code: 1, stderr: '' })
|
||||
}
|
||||
if (args[0] === 'cat-file') {
|
||||
const payload = `${(options.stdin ?? '')
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((ref) => `${ref} missing`)
|
||||
.join('\n')}\n`
|
||||
return {
|
||||
stdout: options.captureWslLoginShellOutput ? payload : `${WSL_BANNER}${payload}`,
|
||||
stderr: ''
|
||||
}
|
||||
}
|
||||
throw new Error(`unexpected git command: ${args.join(' ')}`)
|
||||
})
|
||||
return { argv }
|
||||
}
|
||||
|
||||
function countSubcommand(argv: readonly string[][], subcommand: string): number {
|
||||
return argv.filter((args) => args[0] === subcommand).length
|
||||
}
|
||||
|
||||
describe('getBranchConflictKind batched remote probe', () => {
|
||||
beforeEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
})
|
||||
|
||||
it('asks the WSL login shell to fence the batch payload it parses', async () => {
|
||||
installLoginShellRunner()
|
||||
|
||||
await getBranchConflictKind('/repo', BRANCH)
|
||||
|
||||
const batchCall = gitExecFileAsyncMock.mock.calls.find(([args]) => args[0] === 'cat-file')
|
||||
expect(batchCall?.[1]).toMatchObject({ captureWslLoginShellOutput: true })
|
||||
})
|
||||
|
||||
it('answers from one batched subprocess instead of one show-ref per remote', async () => {
|
||||
const { argv } = installLoginShellRunner()
|
||||
|
||||
await expect(getBranchConflictKind('/repo', BRANCH)).resolves.toBeNull()
|
||||
|
||||
expect(countSubcommand(argv, 'cat-file')).toBe(1)
|
||||
expect(countSubcommand(argv, 'show-ref')).toBe(0)
|
||||
})
|
||||
|
||||
it('still falls back to per-ref probes when the batch itself fails', async () => {
|
||||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'rev-parse') {
|
||||
throw new Error('local branch is absent')
|
||||
}
|
||||
if (args[0] === 'remote') {
|
||||
return { stdout: `${REMOTES.join('\n')}\n`, stderr: '' }
|
||||
}
|
||||
if (args[0] === 'cat-file') {
|
||||
throw new Error('cat-file is unavailable on this host')
|
||||
}
|
||||
if (args[0] === 'show-ref') {
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
throw new Error(`unexpected git command: ${args.join(' ')}`)
|
||||
})
|
||||
|
||||
await expect(getBranchConflictKind('/repo', BRANCH)).resolves.toBe('remote')
|
||||
})
|
||||
})
|
||||
@@ -147,10 +147,12 @@ export function getBranchConflictKind(
|
||||
const execOptions = gitExecOptions(path, options)
|
||||
const runLocalGit = (
|
||||
argv: string[],
|
||||
commandOptions?: ExactRefProbeExecOptions & { stdin?: string }
|
||||
commandOptions?: ExactRefProbeExecOptions & { stdin?: string },
|
||||
captureWslLoginShellOutput = false
|
||||
): Promise<{ stdout: string }> =>
|
||||
gitExecFileAsync(argv, {
|
||||
...execOptions,
|
||||
...(captureWslLoginShellOutput ? { captureWslLoginShellOutput: true } : {}),
|
||||
...(commandOptions?.maxBuffer === undefined ? {} : { maxBuffer: commandOptions.maxBuffer }),
|
||||
...(commandOptions?.timeoutMs === undefined ? {} : { timeout: commandOptions.timeoutMs }),
|
||||
...(commandOptions?.stdin === undefined ? {} : { stdin: commandOptions.stdin })
|
||||
@@ -160,7 +162,13 @@ export function getBranchConflictKind(
|
||||
branchName,
|
||||
allowedBaseRef,
|
||||
{},
|
||||
(argv, commandOptions) => runLocalGit(argv, commandOptions)
|
||||
// Why fenced: the batch decides from stdout, and a WSL login-shell fallback writes
|
||||
// the distro's rc/motd banner to that same stream. The extra lines break the
|
||||
// one-line-per-ref contract, so every batch came back undecided and fell through to
|
||||
// one `show-ref` subprocess per remote -- the exact cost the batch exists to remove.
|
||||
// `show-ref --verify --quiet` prints nothing and is read by exit code, so it needs
|
||||
// no fence; the capture wrapper preserves the payload's exit status either way.
|
||||
(argv, commandOptions) => runLocalGit(argv, commandOptions, true)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -363,6 +363,16 @@ describe('getUpstreamStatus', () => {
|
||||
if (args[0] === 'remote' && args[1] === 'get-url' && args[2] === 'pr-pynickle-orca') {
|
||||
return Promise.resolve({ stdout: 'https://github.com/pynickle/orca.git\n' })
|
||||
}
|
||||
if (args[0] === 'remote' && args[1] === '-v') {
|
||||
return Promise.resolve({
|
||||
stdout: [
|
||||
'origin\thttps://github.com/stablyai/orca.git (fetch)',
|
||||
'origin\thttps://github.com/stablyai/orca.git (push)',
|
||||
'pr-pynickle-orca\thttps://github.com/pynickle/orca.git (fetch)',
|
||||
'pr-pynickle-orca\thttps://github.com/pynickle/orca.git (push)'
|
||||
].join('\n')
|
||||
})
|
||||
}
|
||||
if (args[0] === 'remote') {
|
||||
return Promise.resolve({ stdout: 'origin\npr-pynickle-orca\n' })
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
// evicts the entry.
|
||||
|
||||
const dropStatusEntry = vi.fn()
|
||||
const dropPersistedStatusEntry = vi.fn()
|
||||
const dropPersistedStatusEntries = vi.fn(() => [] as string[])
|
||||
const dropStatusEntriesByTabPrefix = vi.fn()
|
||||
const retirePaneAuthority = vi.fn()
|
||||
const transferPaneAuthority = vi.fn()
|
||||
@@ -44,6 +46,8 @@ vi.mock('../agent-hooks/server', async () => {
|
||||
...actual,
|
||||
agentHookServer: {
|
||||
dropStatusEntry,
|
||||
dropPersistedStatusEntry,
|
||||
dropPersistedStatusEntries,
|
||||
dropStatusEntriesByTabPrefix,
|
||||
retirePaneAuthority,
|
||||
transferPaneAuthority,
|
||||
@@ -105,6 +109,9 @@ vi.mock('../kimi/hook-service', () => ({
|
||||
|
||||
beforeEach(() => {
|
||||
dropStatusEntry.mockReset()
|
||||
dropPersistedStatusEntry.mockReset()
|
||||
dropPersistedStatusEntries.mockReset()
|
||||
dropPersistedStatusEntries.mockReturnValue([])
|
||||
dropStatusEntriesByTabPrefix.mockReset()
|
||||
retirePaneAuthority.mockReset()
|
||||
transferPaneAuthority.mockReset()
|
||||
@@ -279,6 +286,71 @@ describe('agentStatus:drop IPC', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentStatus:dropPersisted IPC', () => {
|
||||
it('forwards a validated cache identity without clearing pane state', async () => {
|
||||
const { registerAgentHookHandlers } = await import('./agent-hooks')
|
||||
registerAgentHookHandlers()
|
||||
|
||||
const handler = onHandlers.get('agentStatus:dropPersisted')
|
||||
expect(handler).toBeDefined()
|
||||
const identity = {
|
||||
paneKey: PANE_KEY,
|
||||
receivedAt: 2_000,
|
||||
stateStartedAt: 1_000
|
||||
}
|
||||
handler!({}, identity)
|
||||
expect(dropPersistedStatusEntry).toHaveBeenCalledWith(identity)
|
||||
expect(dropStatusEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards a batch, keeping only valid identities, and clears migration state per evicted pane', async () => {
|
||||
const { registerAgentHookHandlers } = await import('./agent-hooks')
|
||||
registerAgentHookHandlers()
|
||||
|
||||
const handler = onHandlers.get('agentStatus:dropPersistedBatch')
|
||||
expect(handler).toBeDefined()
|
||||
const good = { paneKey: PANE_KEY, receivedAt: 2_000, stateStartedAt: 1_000 }
|
||||
const alsoGood = { paneKey: CHILD_PANE_KEY, receivedAt: 3_000, stateStartedAt: 2_500 }
|
||||
dropPersistedStatusEntries.mockReturnValue([PANE_KEY])
|
||||
handler!({}, [good, { paneKey: 'not-a-pane-key', receivedAt: 1, stateStartedAt: 1 }, alsoGood])
|
||||
expect(dropPersistedStatusEntries).toHaveBeenCalledWith([good, alsoGood])
|
||||
expect(clearMigrationUnsupportedPtysForPaneKey).toHaveBeenCalledWith(PANE_KEY)
|
||||
expect(clearMigrationUnsupportedPtysForPaneKey).not.toHaveBeenCalledWith(CHILD_PANE_KEY)
|
||||
expect(dropPersistedStatusEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores a batch that is not an array or is empty after validation', async () => {
|
||||
const { registerAgentHookHandlers } = await import('./agent-hooks')
|
||||
registerAgentHookHandlers()
|
||||
|
||||
const handler = onHandlers.get('agentStatus:dropPersistedBatch')!
|
||||
for (const value of [null, {}, 'x', [], [{ paneKey: PANE_KEY }]]) {
|
||||
expect(() => handler({}, value)).not.toThrow()
|
||||
}
|
||||
expect(dropPersistedStatusEntries).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects malformed cache identities', async () => {
|
||||
const { registerAgentHookHandlers } = await import('./agent-hooks')
|
||||
registerAgentHookHandlers()
|
||||
|
||||
const handler = onHandlers.get('agentStatus:dropPersisted')!
|
||||
for (const value of [
|
||||
null,
|
||||
undefined,
|
||||
{},
|
||||
{ paneKey: PANE_KEY },
|
||||
{ paneKey: PANE_KEY, receivedAt: Number.NaN, stateStartedAt: 1 },
|
||||
{ paneKey: PANE_KEY, receivedAt: 2, stateStartedAt: Number.POSITIVE_INFINITY },
|
||||
{ paneKey: 'not-a-pane-key', receivedAt: 2, stateStartedAt: 1 },
|
||||
{ paneKey: PANE_KEY, receivedAt: '2', stateStartedAt: 1 }
|
||||
]) {
|
||||
expect(() => handler({}, value)).not.toThrow()
|
||||
}
|
||||
expect(dropPersistedStatusEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentStatus:dropByTabPrefix IPC', () => {
|
||||
it('forwards valid tab ids to tab-prefix cache eviction', async () => {
|
||||
const { registerAgentHookHandlers } = await import('./agent-hooks')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { agentHookServer, isValidPaneKey } from '../agent-hooks/server'
|
||||
import type { AgentStatusCacheIdentity } from '../../shared/agent-status-types'
|
||||
import {
|
||||
clearMigrationUnsupportedPtysByTabPrefix,
|
||||
clearMigrationUnsupportedPtysForPaneKey
|
||||
@@ -7,7 +8,7 @@ import {
|
||||
import { isValidAgentStatusDropTabId } from './agent-status-ipc-boundary'
|
||||
|
||||
/**
|
||||
* The three renderer-initiated ways a status row goes away. All fire-and-forget
|
||||
* The renderer-initiated ways a status row goes away. All fire-and-forget
|
||||
* (`ipcRenderer.send` → `ipcMain.on`), so none round-trips a response; removing the
|
||||
* listeners first keeps re-registration safe.
|
||||
*
|
||||
@@ -15,8 +16,13 @@ import { isValidAgentStatusDropTabId } from './agent-status-ipc-boundary'
|
||||
* still be alive; a confirmed process exit must take them too, or a surviving Claude latch resolves
|
||||
* the pane's next event straight back to `working`.
|
||||
*/
|
||||
// Why a cap: the renderer sends one batch per Clear-completed click, bounded by visible rows.
|
||||
const MAX_DROP_PERSISTED_BATCH = 5_000
|
||||
|
||||
export function registerAgentStatusRowTeardownIpcHandlers(): void {
|
||||
ipcMain.removeAllListeners('agentStatus:drop')
|
||||
ipcMain.removeAllListeners('agentStatus:dropPersisted')
|
||||
ipcMain.removeAllListeners('agentStatus:dropPersistedBatch')
|
||||
ipcMain.removeAllListeners('agentStatus:reconcileEndedProcess')
|
||||
ipcMain.removeAllListeners('agentStatus:dropByTabPrefix')
|
||||
|
||||
@@ -36,6 +42,36 @@ export function registerAgentStatusRowTeardownIpcHandlers(): void {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.on('agentStatus:dropPersisted', (_event, request: unknown) => {
|
||||
if (!isValidAgentStatusCacheIdentity(request)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (agentHookServer.dropPersistedStatusEntry(request)) {
|
||||
clearMigrationUnsupportedPtysForPaneKey(request.paneKey)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[agent-hooks] dropPersistedStatusEntry failed:', err)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.on('agentStatus:dropPersistedBatch', (_event, request: unknown) => {
|
||||
if (!Array.isArray(request) || request.length > MAX_DROP_PERSISTED_BATCH) {
|
||||
return
|
||||
}
|
||||
const identities = request.filter(isValidAgentStatusCacheIdentity)
|
||||
if (identities.length === 0) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
for (const paneKey of agentHookServer.dropPersistedStatusEntries(identities)) {
|
||||
clearMigrationUnsupportedPtysForPaneKey(paneKey)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[agent-hooks] dropPersistedStatusEntries failed:', err)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.on('agentStatus:reconcileEndedProcess', (_event, paneKey: unknown) => {
|
||||
if (typeof paneKey !== 'string' || !isValidPaneKey(paneKey)) {
|
||||
return
|
||||
@@ -67,3 +103,18 @@ export function registerAgentStatusRowTeardownIpcHandlers(): void {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function isValidAgentStatusCacheIdentity(value: unknown): value is AgentStatusCacheIdentity {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
return false
|
||||
}
|
||||
const request = value as Record<string, unknown>
|
||||
return (
|
||||
typeof request.paneKey === 'string' &&
|
||||
isValidPaneKey(request.paneKey) &&
|
||||
typeof request.receivedAt === 'number' &&
|
||||
Number.isFinite(request.receivedAt) &&
|
||||
typeof request.stateStartedAt === 'number' &&
|
||||
Number.isFinite(request.stateStartedAt)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { listWorktrees } from '../git/worktree'
|
||||
import type { SshGitProvider } from '../providers/ssh-git-provider'
|
||||
import type { GitPushTarget } from '../../shared/worktree/types'
|
||||
import { WORKTREE_ID_SEPARATOR, worktreeIdComparisonKey } from '../../shared/worktree/id'
|
||||
import { iterateProcessOutputLines } from '../../shared/process-output-field-scanner'
|
||||
import { parseGitRemoteFetchUrls } from '../../shared/git-remote-url-index'
|
||||
import {
|
||||
findWorktreeMetaReferencingRemote,
|
||||
hasBranchConfigUsingRemote,
|
||||
@@ -44,26 +44,9 @@ async function listPrRemoteCandidates(
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
const candidates = new Map<string, string>()
|
||||
for (const line of iterateProcessOutputLines(stdout)) {
|
||||
const parsed = parseRemoteVerboseLine(line)
|
||||
if (parsed?.direction === 'fetch' && isOrcaGeneratedPrRemoteName(parsed.name)) {
|
||||
candidates.set(parsed.name, parsed.url)
|
||||
}
|
||||
}
|
||||
return [...candidates.entries()].map(([name, url]) => ({ name, url }))
|
||||
}
|
||||
|
||||
function parseRemoteVerboseLine(
|
||||
line: string
|
||||
): { name: string; url: string; direction: 'fetch' | 'push' } | null {
|
||||
const tabIndex = line.indexOf('\t')
|
||||
if (tabIndex === -1) {
|
||||
return null
|
||||
}
|
||||
const name = line.slice(0, tabIndex)
|
||||
const match = /^(.*) \((fetch|push)\)$/.exec(line.slice(tabIndex + 1).trim())
|
||||
return match ? { name, url: match[1], direction: match[2] as 'fetch' | 'push' } : null
|
||||
return [...parseGitRemoteFetchUrls(stdout)]
|
||||
.filter(([name]) => isOrcaGeneratedPrRemoteName(name))
|
||||
.map(([name, url]) => ({ name, url }))
|
||||
}
|
||||
|
||||
async function shouldReclaimPrRemote(
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
// Why: `findRemoteForUrl` used to run `git remote` and then one serial
|
||||
// `git remote get-url` per remote. These tests pin both halves of the fix: the
|
||||
// subprocess count at 58 remotes, and result-for-result parity with the old scan
|
||||
// across the remote shapes a real repo produces.
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseGitHubOwnerRepo } from '../github/gh-utils'
|
||||
import { findRemoteForUrl } from './worktree-push-target-setup'
|
||||
import type { GitRemoteExec } from './worktree-push-target-cleanup'
|
||||
|
||||
const SSH_FORK = 'git@github.com:contributor/orca.git'
|
||||
const HTTPS_FORK = 'https://github.com/contributor/orca.git'
|
||||
const GITLAB_FORK = 'https://gitlab.com/contributor/orca.git'
|
||||
const UPSTREAM = 'https://github.com/stablyai/orca.git'
|
||||
|
||||
type RemoteRow = { name: string; fetchUrl: string; pushUrl?: string }
|
||||
|
||||
type CountingExec = GitRemoteExec & { spawns: string[][] }
|
||||
|
||||
function makeExec(remotes: readonly RemoteRow[]): CountingExec {
|
||||
const spawns: string[][] = []
|
||||
const exec: GitRemoteExec = async (args: string[]) => {
|
||||
spawns.push(args)
|
||||
if (args[0] === 'remote' && args.length === 1) {
|
||||
return { stdout: `${remotes.map((remote) => remote.name).join('\n')}\n` }
|
||||
}
|
||||
if (args[0] === 'remote' && args[1] === '-v') {
|
||||
return {
|
||||
stdout: remotes
|
||||
.flatMap((remote) => [
|
||||
`${remote.name}\t${remote.fetchUrl} (fetch)`,
|
||||
`${remote.name}\t${remote.pushUrl ?? remote.fetchUrl} (push)`
|
||||
])
|
||||
.join('\n')
|
||||
}
|
||||
}
|
||||
if (args[0] === 'remote' && args[1] === 'get-url') {
|
||||
const match = remotes.find((remote) => remote.name === args[2])
|
||||
if (!match) {
|
||||
throw new Error(`No such remote ${args[2]}`)
|
||||
}
|
||||
return { stdout: `${match.fetchUrl}\n` }
|
||||
}
|
||||
throw new Error(`unexpected git command: ${args.join(' ')}`)
|
||||
}
|
||||
return Object.assign(exec, { spawns })
|
||||
}
|
||||
|
||||
/** The pre-fix scan, kept as the oracle the batched form must reproduce exactly. */
|
||||
async function findRemoteForUrlPerRemote(
|
||||
execGit: GitRemoteExec,
|
||||
repoPath: string,
|
||||
remoteUrl: string
|
||||
): Promise<string | null> {
|
||||
const target = parseGitHubOwnerRepo(remoteUrl)
|
||||
try {
|
||||
const { stdout } = await execGit(['remote'], repoPath)
|
||||
for (const remote of stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)) {
|
||||
try {
|
||||
const { stdout: urlStdout } = await execGit(['remote', 'get-url', remote], repoPath)
|
||||
const candidateUrl = urlStdout.trim()
|
||||
const candidate = parseGitHubOwnerRepo(candidateUrl)
|
||||
if (
|
||||
target &&
|
||||
candidate &&
|
||||
target.owner.toLowerCase() === candidate.owner.toLowerCase() &&
|
||||
target.repo.toLowerCase() === candidate.repo.toLowerCase()
|
||||
) {
|
||||
return remote
|
||||
}
|
||||
if (candidateUrl === remoteUrl) {
|
||||
return remote
|
||||
}
|
||||
} catch {
|
||||
// Ignore a remote that disappeared or has no fetch URL.
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const fiftyEightRemotes: RemoteRow[] = [
|
||||
{ name: 'origin', fetchUrl: UPSTREAM },
|
||||
...Array.from({ length: 56 }, (_, index) => ({
|
||||
name: `pr-user${index}-orca`,
|
||||
fetchUrl: `https://github.com/user${index}/orca.git`
|
||||
})),
|
||||
{ name: 'pr-contributor-orca', fetchUrl: SSH_FORK }
|
||||
]
|
||||
|
||||
const matrix: { name: string; remotes: RemoteRow[]; lookupUrl: string }[] = [
|
||||
{ name: 'no remotes', remotes: [], lookupUrl: SSH_FORK },
|
||||
{
|
||||
name: 'one matching remote',
|
||||
remotes: [{ name: 'origin', fetchUrl: SSH_FORK }],
|
||||
lookupUrl: SSH_FORK
|
||||
},
|
||||
{
|
||||
name: 'one non-matching remote',
|
||||
remotes: [{ name: 'origin', fetchUrl: UPSTREAM }],
|
||||
lookupUrl: SSH_FORK
|
||||
},
|
||||
{ name: '58 remotes, match last', remotes: fiftyEightRemotes, lookupUrl: SSH_FORK },
|
||||
{
|
||||
name: '58 remotes, no match',
|
||||
remotes: fiftyEightRemotes,
|
||||
lookupUrl: 'https://github.com/nobody/other.git'
|
||||
},
|
||||
{
|
||||
name: 'duplicate URLs on two remotes',
|
||||
remotes: [
|
||||
{ name: 'origin', fetchUrl: UPSTREAM },
|
||||
{ name: 'fork-a', fetchUrl: SSH_FORK },
|
||||
{ name: 'fork-b', fetchUrl: SSH_FORK }
|
||||
],
|
||||
lookupUrl: SSH_FORK
|
||||
},
|
||||
{
|
||||
name: 'fetch and push URLs differ',
|
||||
remotes: [{ name: 'split', fetchUrl: SSH_FORK, pushUrl: HTTPS_FORK }],
|
||||
lookupUrl: SSH_FORK
|
||||
},
|
||||
{
|
||||
name: 'SSH-form lookup against an HTTPS-form remote',
|
||||
remotes: [
|
||||
{ name: 'origin', fetchUrl: UPSTREAM },
|
||||
{ name: 'fork', fetchUrl: HTTPS_FORK }
|
||||
],
|
||||
lookupUrl: SSH_FORK
|
||||
},
|
||||
{
|
||||
name: 'HTTPS-form lookup against an SSH-form remote',
|
||||
remotes: [
|
||||
{ name: 'origin', fetchUrl: UPSTREAM },
|
||||
{ name: 'fork', fetchUrl: SSH_FORK }
|
||||
],
|
||||
lookupUrl: HTTPS_FORK
|
||||
},
|
||||
{
|
||||
name: 'non-GitHub provider matches only on the exact URL',
|
||||
remotes: [{ name: 'gitlab-fork', fetchUrl: GITLAB_FORK }],
|
||||
lookupUrl: GITLAB_FORK
|
||||
},
|
||||
{
|
||||
name: 'non-GitHub provider with a different host does not match',
|
||||
remotes: [{ name: 'gitlab-fork', fetchUrl: GITLAB_FORK }],
|
||||
lookupUrl: 'https://bitbucket.org/contributor/orca.git'
|
||||
}
|
||||
]
|
||||
|
||||
describe('findRemoteForUrl', () => {
|
||||
it.each(matrix)('matches the per-remote scan for $name', async ({ remotes, lookupUrl }) => {
|
||||
const expected = await findRemoteForUrlPerRemote(makeExec(remotes), '/repo', lookupUrl)
|
||||
await expect(findRemoteForUrl(makeExec(remotes), '/repo', lookupUrl)).resolves.toBe(expected)
|
||||
})
|
||||
|
||||
it('answers from one subprocess at 58 remotes instead of one per remote', async () => {
|
||||
const legacyExec = makeExec(fiftyEightRemotes)
|
||||
await findRemoteForUrlPerRemote(legacyExec, '/repo', 'https://github.com/nobody/other.git')
|
||||
expect(legacyExec.spawns).toHaveLength(fiftyEightRemotes.length + 1)
|
||||
|
||||
const exec = makeExec(fiftyEightRemotes)
|
||||
await findRemoteForUrl(exec, '/repo', 'https://github.com/nobody/other.git')
|
||||
expect(exec.spawns).toEqual([['remote', '-v']])
|
||||
})
|
||||
|
||||
it('returns null when the remote table cannot be read', async () => {
|
||||
const failing: GitRemoteExec = async () => {
|
||||
throw new Error('not a git repository')
|
||||
}
|
||||
await expect(findRemoteForUrl(failing, '/repo', SSH_FORK)).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,13 @@ const REPO = '/repo-root'
|
||||
const FORK_SSH = 'git@github.com:contributor/orca.git'
|
||||
const FORK_HTTPS = 'https://github.com/contributor/orca.git'
|
||||
|
||||
/** Real `git remote -v` shape: a fetch row and a push row per remote, tab-separated. */
|
||||
export function renderRemoteVerbose(remotes: Record<string, string>): string {
|
||||
return Object.entries(remotes)
|
||||
.flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`])
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
// A stateful fake git: `remotes` maps name -> url. `remote add` mutates it so
|
||||
// later lookups see the new remote, matching real git behavior. Defaults
|
||||
// `symbolic-ref --short HEAD` to a real branch name, since a worktree's HEAD
|
||||
@@ -31,6 +38,9 @@ function makeRepoExec(
|
||||
if (args[0] === 'remote' && args.length === 1) {
|
||||
return { stdout: Object.keys(remotes).join('\n'), stderr: '' }
|
||||
}
|
||||
if (args[0] === 'remote' && args[1] === '-v' && args.length === 2) {
|
||||
return { stdout: renderRemoteVerbose(remotes), stderr: '' }
|
||||
}
|
||||
if (args[0] === 'remote' && args[1] === 'get-url') {
|
||||
const url = remotes[args[2]!]
|
||||
if (!url) {
|
||||
|
||||
@@ -5,53 +5,33 @@
|
||||
// repo. The store-aware ownership decision stays with the caller via a predicate.
|
||||
|
||||
import type { GitPushTarget } from '../../shared/worktree/types'
|
||||
import { parseGitHubOwnerRepo } from '../github/gh-utils'
|
||||
import type { GitRemoteExec } from './worktree-push-target-cleanup'
|
||||
import { findGitRemoteNameByFetchUrl } from '../../shared/git-remote-url-index'
|
||||
import { sameGitHubRemoteUrl, type GitRemoteExec } from './worktree-push-target-cleanup'
|
||||
import {
|
||||
buildNarrowForkFetchRefspec,
|
||||
ensureRemoteTracksBranchNarrowly
|
||||
} from '../git/fork-remote-refspec'
|
||||
|
||||
// One `git remote -v` replaces `git remote` plus a serial `git remote get-url` per
|
||||
// remote -- 59 subprocesses at 58 remotes, on every push-target resolution (#17914).
|
||||
export async function findRemoteForUrl(
|
||||
execGit: GitRemoteExec,
|
||||
repoPath: string,
|
||||
remoteUrl: string
|
||||
): Promise<string | null> {
|
||||
const target = parseGitHubOwnerRepo(remoteUrl)
|
||||
try {
|
||||
const { stdout } = await execGit(['remote'], repoPath)
|
||||
for (const remote of stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)) {
|
||||
try {
|
||||
const { stdout: urlStdout } = await execGit(['remote', 'get-url', remote], repoPath)
|
||||
const candidateUrl = urlStdout.trim()
|
||||
const candidate = parseGitHubOwnerRepo(candidateUrl)
|
||||
if (
|
||||
target &&
|
||||
candidate &&
|
||||
target.owner.toLowerCase() === candidate.owner.toLowerCase() &&
|
||||
target.repo.toLowerCase() === candidate.repo.toLowerCase()
|
||||
) {
|
||||
return remote
|
||||
}
|
||||
if (candidateUrl === remoteUrl) {
|
||||
return remote
|
||||
}
|
||||
} catch {
|
||||
// Ignore a remote that disappeared or has no fetch URL.
|
||||
}
|
||||
}
|
||||
const { stdout } = await execGit(['remote', '-v'], repoPath)
|
||||
return findGitRemoteNameByFetchUrl(stdout, (candidateUrl) =>
|
||||
sameGitHubRemoteUrl(candidateUrl, remoteUrl)
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// O(1) probe used before materializing on demand (push/pull/fetch/fast-forward):
|
||||
// a single `remote get-url <name>` avoids the O(remotes) `findRemoteForUrl` scan
|
||||
// once a fork remote already exists under its expected name (#17828).
|
||||
// a single `remote get-url <name>` skips the whole-remote-table read once a fork
|
||||
// remote already exists under its expected name (#17828).
|
||||
export async function remoteAlreadyMatchesUrl(
|
||||
execGit: GitRemoteExec,
|
||||
repoPath: string,
|
||||
@@ -60,18 +40,7 @@ export async function remoteAlreadyMatchesUrl(
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execGit(['remote', 'get-url', remoteName], repoPath)
|
||||
const candidateUrl = stdout.trim()
|
||||
if (candidateUrl === remoteUrl) {
|
||||
return true
|
||||
}
|
||||
const target = parseGitHubOwnerRepo(remoteUrl)
|
||||
const candidate = parseGitHubOwnerRepo(candidateUrl)
|
||||
return Boolean(
|
||||
target &&
|
||||
candidate &&
|
||||
target.owner.toLowerCase() === candidate.owner.toLowerCase() &&
|
||||
target.repo.toLowerCase() === candidate.repo.toLowerCase()
|
||||
)
|
||||
return sameGitHubRemoteUrl(stdout.trim(), remoteUrl)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -553,6 +553,17 @@ describe('materializeWorktreePushTargetRemoteSsh', () => {
|
||||
}
|
||||
throw new Error('No such remote')
|
||||
}
|
||||
if (args[0] === 'remote' && args[1] === '-v') {
|
||||
return {
|
||||
stdout: [
|
||||
'origin\thttps://github.com/stablyai/orca.git (fetch)',
|
||||
'origin\thttps://github.com/stablyai/orca.git (push)',
|
||||
`${SIBLING_REMOTE}\t${FORK_URL} (fetch)`,
|
||||
`${SIBLING_REMOTE}\t${FORK_URL} (push)`
|
||||
].join('\n'),
|
||||
stderr: ''
|
||||
}
|
||||
}
|
||||
if (args[0] === 'remote' && args.length === 1) {
|
||||
return { stdout: `origin\n${SIBLING_REMOTE}\n`, stderr: '' }
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ export function getPersistedUI(
|
||||
markdownTocPanelWidth: clampMarkdownTocPanelWidth(state.ui?.markdownTocPanelWidth),
|
||||
combinedDiffFileTreeWidth: clampCombinedDiffFileTreeWidth(state.ui?.combinedDiffFileTreeWidth),
|
||||
visibleWorkspaceHostIds: normalizeVisibleExecutionHostIds(state.ui?.visibleWorkspaceHostIds),
|
||||
agentsVisibleHostIds: normalizeVisibleExecutionHostIds(state.ui?.agentsVisibleHostIds),
|
||||
workspaceHostOrder: normalizeExecutionHostOrder(state.ui?.workspaceHostOrder),
|
||||
manualRepoOrder: normalizeManualRepoOrder(state.ui?.manualRepoOrder),
|
||||
browserDefaultZoomLevel: normalizeBrowserPageZoomLevel(state.ui?.browserDefaultZoomLevel),
|
||||
|
||||
@@ -152,6 +152,10 @@ export function updatePersistedUI(
|
||||
sanitizedUpdates.visibleWorkspaceHostIds !== undefined
|
||||
? normalizeVisibleExecutionHostIds(sanitizedUpdates.visibleWorkspaceHostIds)
|
||||
: normalizeVisibleExecutionHostIds(operations.state.ui?.visibleWorkspaceHostIds),
|
||||
agentsVisibleHostIds:
|
||||
sanitizedUpdates.agentsVisibleHostIds !== undefined
|
||||
? normalizeVisibleExecutionHostIds(sanitizedUpdates.agentsVisibleHostIds)
|
||||
: normalizeVisibleExecutionHostIds(operations.state.ui?.agentsVisibleHostIds),
|
||||
workspaceHostOrder:
|
||||
sanitizedUpdates.workspaceHostOrder !== undefined
|
||||
? normalizeExecutionHostOrder(sanitizedUpdates.workspaceHostOrder)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getDefaultPersistedState } from '../../../shared/constants'
|
||||
import { normalizeLoadedGlobalSettings } from './normalize-loaded-global-settings'
|
||||
import { prepareLoadedTerminalSettings } from './prepare-loaded-terminal-settings'
|
||||
import { prepareLoadedProfileSettings } from './prepare-loaded-profile-settings'
|
||||
import type { GlobalSettings } from '../../../shared/global-settings-types'
|
||||
import type { PersistedState } from '../../../shared/persisted-state-types'
|
||||
|
||||
// Simulates a profile created before the dedicated Experimental switch was persisted.
|
||||
function normalizeLegacyProfile(overrides: Partial<GlobalSettings>): PersistedState['settings'] {
|
||||
const defaults = getDefaultPersistedState(homedir())
|
||||
const settings: Partial<GlobalSettings> = { ...defaults.settings }
|
||||
delete settings.showAgentsSidebar
|
||||
delete settings.experimentalActivity
|
||||
delete settings.experimentalAgentDashboardPopout
|
||||
Object.assign(settings, overrides)
|
||||
const parsed: PersistedState = { ...defaults, settings: settings as GlobalSettings }
|
||||
const noop = (): void => {}
|
||||
const terminal = prepareLoadedTerminalSettings(parsed, noop)
|
||||
const profile = prepareLoadedProfileSettings(parsed, defaults, noop)
|
||||
return normalizeLoadedGlobalSettings(parsed, terminal, profile)
|
||||
}
|
||||
|
||||
describe('showAgentsSidebar experimental-setting migration', () => {
|
||||
it('keeps the sidebar for Agents-view opt-ins regardless of the dashboard experiment', () => {
|
||||
const normalized = normalizeLegacyProfile({
|
||||
experimentalActivity: true,
|
||||
experimentalAgentDashboardPopout: false
|
||||
})
|
||||
expect(normalized.showAgentsSidebar).toBe(true)
|
||||
expect(normalized.agentsSidebarMigratedFromExperimental).toBe(true)
|
||||
})
|
||||
|
||||
it('carries the legacy Agents-view opt-in into the sidebar', () => {
|
||||
expect(normalizeLegacyProfile({ experimentalActivity: true }).showAgentsSidebar).toBe(true)
|
||||
})
|
||||
|
||||
it('does not show Agents migration copy for a dashboard-only opt-in', () => {
|
||||
expect(
|
||||
normalizeLegacyProfile({ experimentalAgentDashboardPopout: true })
|
||||
.agentsSidebarMigratedFromExperimental
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('defaults profiles with no legacy signal to the sidebar', () => {
|
||||
const normalized = normalizeLegacyProfile({})
|
||||
expect(normalized.showAgentsSidebar).toBe(true)
|
||||
expect(normalized.agentsSidebarMigratedFromExperimental).toBe(false)
|
||||
})
|
||||
|
||||
it('does not treat a dashboard opt-out as an Agents-tab opt-out', () => {
|
||||
expect(
|
||||
normalizeLegacyProfile({ experimentalAgentDashboardPopout: false }).showAgentsSidebar
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores a pre-stamp forced-default experimentalActivity true (not an opt-in)', () => {
|
||||
const normalized = normalizeLegacyProfile({
|
||||
experimentalActivity: true,
|
||||
experimentalActivityDefaultedOffForAllUsers: undefined
|
||||
})
|
||||
expect(normalized.experimentalActivity).toBe(false)
|
||||
expect(normalized.showAgentsSidebar).toBe(true)
|
||||
expect(normalized.agentsSidebarMigratedFromExperimental).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves a stored showAgentsSidebar choice over legacy flags', () => {
|
||||
expect(
|
||||
normalizeLegacyProfile({ showAgentsSidebar: false, experimentalActivity: true })
|
||||
.showAgentsSidebar
|
||||
).toBe(false)
|
||||
expect(
|
||||
normalizeLegacyProfile({
|
||||
showAgentsSidebar: true,
|
||||
experimentalAgentDashboardPopout: false
|
||||
}).showAgentsSidebar
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getDefaultVoiceSettings } from '../../../shared/constants'
|
||||
import { resolveAgentsSidebarVisible } from '../../../shared/agents-sidebar-visibility'
|
||||
import { normalizePRBotAuthorOverrides } from '../../../shared/pr-bot-author-overrides'
|
||||
import { normalizeTerminalQuickCommands } from '../../../shared/terminal-quick-commands'
|
||||
import { normalizeOpenInApplications } from '../../../shared/open-in-applications'
|
||||
@@ -85,6 +86,16 @@ export function normalizeLoadedGlobalSettings(
|
||||
...migratedTerminalTuiScrollSensitivity.settings,
|
||||
experimentalActivity: migratedExperimentalActivity,
|
||||
experimentalActivityDefaultedOffForAllUsers: true,
|
||||
// Keep the experimental Agents tab's rollout default for older profiles while
|
||||
// preserving any choice made through its dedicated Experimental setting.
|
||||
showAgentsSidebar: resolveAgentsSidebarVisible({
|
||||
showAgentsSidebar: parsed.settings?.showAgentsSidebar
|
||||
}),
|
||||
// Preserve the legacy opt-in before the experimental setting is normalized away. This
|
||||
// drives the migration-specific introduction copy without changing runtime behavior.
|
||||
agentsSidebarMigratedFromExperimental:
|
||||
parsed.settings?.agentsSidebarMigratedFromExperimental === true ||
|
||||
migratedExperimentalActivity,
|
||||
// Why: compact worktree cards graduated from Experimental; preserve the old opt-in for rollout-era profiles.
|
||||
compactWorktreeCards: loadedCompactWorktreeCards,
|
||||
experimentalCompactWorktreeCards: undefined,
|
||||
|
||||
@@ -35,6 +35,8 @@ export function normalizeLoadedProfileState(
|
||||
const { defaults, migratedExternalVisibility, osc52ClipboardNoticePending } = terminal
|
||||
const { normalizedOnboarding, normalizedProjectGroups, loadedCompactWorktreeCards } = profile
|
||||
const projectCatalog = normalizeLoadedProjectCatalog(parsed, markNeedsSave)
|
||||
// Ordered: the host partitions drop the global fields this slice already owns.
|
||||
const workspaceSession = normalizeLoadedLocalSession(parsed, defaults, markNeedsSave)
|
||||
|
||||
return {
|
||||
...defaults,
|
||||
@@ -69,9 +71,14 @@ export function normalizeLoadedProfileState(
|
||||
markNeedsSave
|
||||
),
|
||||
// Why: volatile schema; zod-validate workspaceSession at read so a bad payload falls to defaults, not a renderer crash.
|
||||
workspaceSession: normalizeLoadedLocalSession(parsed, defaults, markNeedsSave),
|
||||
workspaceSession,
|
||||
// Why: per-host session partitions, validated independently; 'local' stays in workspaceSession for downgrade compat.
|
||||
workspaceSessionsByHostId: normalizeLoadedHostSessions(parsed, defaults, markNeedsSave),
|
||||
workspaceSessionsByHostId: normalizeLoadedHostSessions(
|
||||
parsed,
|
||||
defaults,
|
||||
workspaceSession,
|
||||
markNeedsSave
|
||||
),
|
||||
sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget),
|
||||
deletedSshConfigAliases: Array.isArray(parsed.deletedSshConfigAliases)
|
||||
? parsed.deletedSshConfigAliases.filter((alias): alias is string => typeof alias === 'string')
|
||||
|
||||
@@ -41,11 +41,13 @@ export function normalizeLoadedLocalSession(
|
||||
export function normalizeLoadedHostSessions(
|
||||
parsed: PersistedState,
|
||||
defaults: PersistedState,
|
||||
localSession: WorkspaceSessionState,
|
||||
markNeedsSave: () => void
|
||||
): PersistedState['workspaceSessionsByHostId'] {
|
||||
const { partitions, repaired } = parseWorkspaceSessionsByHostId(
|
||||
parsed.workspaceSessionsByHostId,
|
||||
defaults.workspaceSession
|
||||
defaults.workspaceSession,
|
||||
localSession
|
||||
)
|
||||
if (repaired) {
|
||||
// Why: salvage repairs only the in-memory partitions; without a save the corrupt entries stay on disk and get re-dropped every launch.
|
||||
|
||||
@@ -161,7 +161,8 @@ export async function writeToDiskAsync(owner: PrimaryStateWriteOperations): Prom
|
||||
// Why: fsync before rename, then fsync the directory; see writeFileDurable.
|
||||
const handle = await open(tmpFile, 'w')
|
||||
try {
|
||||
await handle.writeFile(payload, 'utf-8')
|
||||
// Already UTF-8 bytes: passing the string here would re-encode the whole state on the main thread.
|
||||
await handle.writeFile(payload)
|
||||
await handle.sync()
|
||||
} finally {
|
||||
await handle.close()
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* The bar for this change is "the bytes on disk did not move". Every case below runs the exact
|
||||
* loop `applySecretSentinelSubstitutions` replaced — reproduced in `previousImplementation` — and
|
||||
* compares payload bytes and guard hash, because a drifting hash silently disables the no-op write
|
||||
* guard and a drifting payload is corrupted persisted state.
|
||||
*/
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applySecretSentinelSubstitutions,
|
||||
type SecretSentinelSubstitution
|
||||
} from './secret-sentinel-substitution'
|
||||
|
||||
/** Verbatim from state-serialization-secret-handling.ts before this change. */
|
||||
function previousImplementation(
|
||||
serialized: string,
|
||||
secretSubs: readonly SecretSentinelSubstitution[],
|
||||
degradedPrefix: string
|
||||
): { payload: Buffer; stateHash: string } {
|
||||
let payload = serialized
|
||||
let hashInput = serialized
|
||||
for (const { sentinel, blob, hashValue } of secretSubs) {
|
||||
const escapedSentinel = JSON.stringify(sentinel).slice(1, -1)
|
||||
payload = payload.replace(escapedSentinel, () => JSON.stringify(blob).slice(1, -1))
|
||||
hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(hashValue).slice(1, -1))
|
||||
}
|
||||
const stateHash = createHash('sha1').update(degradedPrefix).update(hashInput).digest('hex')
|
||||
// `handle.writeFile(payload, 'utf-8')` is what turned the string into bytes.
|
||||
return { payload: Buffer.from(payload, 'utf8'), stateHash }
|
||||
}
|
||||
|
||||
function expectIdenticalToPrevious(
|
||||
serialized: string,
|
||||
subs: readonly SecretSentinelSubstitution[],
|
||||
degradedPrefix = ''
|
||||
): void {
|
||||
const before = previousImplementation(serialized, subs, degradedPrefix)
|
||||
const after = applySecretSentinelSubstitutions(serialized, subs, degradedPrefix)
|
||||
expect(after.payload.equals(before.payload)).toBe(true)
|
||||
expect(after.stateHash).toBe(before.stateHash)
|
||||
}
|
||||
|
||||
function sentinel(): string {
|
||||
return `orca-secret-slot-${randomUUID()}`
|
||||
}
|
||||
|
||||
describe('applySecretSentinelSubstitutions', () => {
|
||||
it('produces bytes and a hash identical to the previous implementation', () => {
|
||||
const subs: SecretSentinelSubstitution[] = [
|
||||
{ sentinel: sentinel(), blob: 'djEwY2lwaGVy', hashValue: 'cookie-value' },
|
||||
{
|
||||
sentinel: sentinel(),
|
||||
// Regex-special *and* JSON-escapable, which is the pair that breaks a naive rewrite:
|
||||
// `$&` would splice the match back in under string-form replace, and the backslash and
|
||||
// quote have to survive `JSON.stringify(...).slice(1, -1)` unchanged.
|
||||
blob: 'A+/=$&$1$`\\x "quoted" |.*?[](){}^',
|
||||
hashValue: 'http://proxy.example:8080/?a=b&c=$&'
|
||||
},
|
||||
{ sentinel: sentinel(), blob: '', hashValue: 'https://kagi.com/session?t=abc' }
|
||||
]
|
||||
const state = {
|
||||
settings: { opencodeSessionCookie: subs[0].sentinel, httpProxyUrl: subs[1].sentinel },
|
||||
ui: { browserKagiSessionLink: subs[2].sentinel },
|
||||
// Adjacent content that must not shift: a near-miss prefix, and JSON escapes either side.
|
||||
noise: ['orca-secret-slot-', 'a\\b"c\n\t', subs[0].sentinel.slice(0, -1)]
|
||||
}
|
||||
expectIdenticalToPrevious(JSON.stringify(state), subs)
|
||||
})
|
||||
|
||||
it('stays identical when the state holds multi-byte and escaped characters', () => {
|
||||
const subs: SecretSentinelSubstitution[] = [
|
||||
{ sentinel: sentinel(), blob: 'blob-é', hashValue: 'plain-é' },
|
||||
{ sentinel: sentinel(), blob: '😀', hashValue: '中文' }
|
||||
]
|
||||
const state = {
|
||||
// Segment boundaries land next to these, so a wrong split would corrupt the encode.
|
||||
before: 'é中文😀',
|
||||
a: subs[0].sentinel,
|
||||
between: '😀
',
|
||||
b: subs[1].sentinel,
|
||||
after: '😀'
|
||||
}
|
||||
expectIdenticalToPrevious(JSON.stringify(state), subs)
|
||||
})
|
||||
|
||||
it('stays identical with no substitutions and with the degraded-storage prefix', () => {
|
||||
const state = JSON.stringify({ settings: { httpProxyUrl: '' }, big: 'x'.repeat(4096) })
|
||||
expectIdenticalToPrevious(state, [])
|
||||
expectIdenticalToPrevious(state, [], 'safeStorage-degraded\0')
|
||||
|
||||
const subs = [{ sentinel: sentinel(), blob: 'b', hashValue: 'h' }]
|
||||
expectIdenticalToPrevious(
|
||||
JSON.stringify({ s: subs[0].sentinel }),
|
||||
subs,
|
||||
'safeStorage-degraded\0'
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes regex metacharacters in the sentinel itself', () => {
|
||||
// Not reachable from a UUID sentinel, but the alternation must not be able to become a pattern.
|
||||
const subs = [{ sentinel: 'a.b*c(d)|e[f]', blob: 'BLOB', hashValue: 'HASH' }]
|
||||
const serialized = JSON.stringify({ real: subs[0].sentinel, decoy: 'axbxxcXdX_eXfX' })
|
||||
expectIdenticalToPrevious(serialized, subs)
|
||||
expect(
|
||||
applySecretSentinelSubstitutions(serialized, subs, '').payload.toString('utf8')
|
||||
).toContain('axbxxcXdX_eXfX')
|
||||
})
|
||||
|
||||
it('substitutes every occurrence when a sentinel repeats', () => {
|
||||
// Cannot happen today (a sentinel is a UUID minted after the state is assembled, so it appears
|
||||
// exactly once), but the old first-match-only `String.replace` would have written a raw
|
||||
// sentinel to disk in place of a secret if it ever did. The alternation is global instead.
|
||||
const subs = [{ sentinel: sentinel(), blob: 'CIPHER', hashValue: 'PLAIN' }]
|
||||
const serialized = JSON.stringify({ a: subs[0].sentinel, b: subs[0].sentinel })
|
||||
const { payload } = applySecretSentinelSubstitutions(serialized, subs, '')
|
||||
expect(payload.toString('utf8')).toBe(JSON.stringify({ a: 'CIPHER', b: 'CIPHER' }))
|
||||
expect(payload.toString('utf8')).not.toContain(subs[0].sentinel)
|
||||
})
|
||||
|
||||
it('copies and UTF-8 encodes the full state once, not once per sentinel per side', () => {
|
||||
const subs: SecretSentinelSubstitution[] = Array.from({ length: 3 }, () => ({
|
||||
sentinel: sentinel(),
|
||||
blob: 'CIPHERTEXT',
|
||||
hashValue: 'plaintext'
|
||||
}))
|
||||
const serialized = JSON.stringify({
|
||||
pad: 'x'.repeat(200_000),
|
||||
a: subs[0].sentinel,
|
||||
b: subs[1].sentinel,
|
||||
c: subs[2].sentinel
|
||||
})
|
||||
const FULL_STATE = 100_000
|
||||
|
||||
// Both costs are observable at their sources: a `String.replace` whose receiver is the whole
|
||||
// state allocates another copy of it, and every string handed to `Buffer.from` or `hash.update`
|
||||
// is one full UTF-8 encode pass on the main thread.
|
||||
const counted = (run: () => unknown): { fullStateReplaces: number; encodedChars: number } => {
|
||||
const realReplace = String.prototype.replace
|
||||
const realBufferFrom = Buffer.from
|
||||
const hashProto = Object.getPrototypeOf(createHash('sha1')) as {
|
||||
update: (...args: unknown[]) => unknown
|
||||
}
|
||||
const realUpdate = hashProto.update
|
||||
const counts = { fullStateReplaces: 0, encodedChars: 0 }
|
||||
String.prototype.replace = function (this: string, ...args: unknown[]) {
|
||||
if (this.length >= FULL_STATE) {
|
||||
counts.fullStateReplaces++
|
||||
}
|
||||
return realReplace.apply(this, args as never)
|
||||
} as typeof String.prototype.replace
|
||||
Buffer.from = function (...args: unknown[]) {
|
||||
if (typeof args[0] === 'string') {
|
||||
counts.encodedChars += args[0].length
|
||||
}
|
||||
return (realBufferFrom as (...a: unknown[]) => Buffer).apply(Buffer, args)
|
||||
} as typeof Buffer.from
|
||||
hashProto.update = function (this: unknown, ...args: unknown[]) {
|
||||
if (typeof args[0] === 'string') {
|
||||
counts.encodedChars += args[0].length
|
||||
}
|
||||
return realUpdate.apply(this, args)
|
||||
}
|
||||
try {
|
||||
run()
|
||||
} finally {
|
||||
String.prototype.replace = realReplace
|
||||
Buffer.from = realBufferFrom
|
||||
hashProto.update = realUpdate
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
const before = counted(() => previousImplementation(serialized, subs, ''))
|
||||
const after = counted(() => applySecretSentinelSubstitutions(serialized, subs, ''))
|
||||
|
||||
// Two `String.replace` calls over the whole state per sentinel — payload and hash input.
|
||||
expect(before.fullStateReplaces).toBe(subs.length * 2)
|
||||
expect(after.fullStateReplaces).toBe(0)
|
||||
// The old path encoded the state twice: once for sha1, once for the file write.
|
||||
expect(before.encodedChars).toBeGreaterThan(serialized.length * 1.9)
|
||||
expect(after.encodedChars).toBeLessThan(serialized.length * 1.1)
|
||||
expect(after.encodedChars).toBeGreaterThan(serialized.length * 0.9)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { escapeRegex } from '../../../shared/string-utils'
|
||||
|
||||
export type SecretSentinelSubstitution = {
|
||||
/** The `orca-secret-slot-<uuid>` placeholder standing in the serialized state. */
|
||||
sentinel: string
|
||||
/** What the on-disk payload gets: the ciphertext. */
|
||||
blob: string
|
||||
/** What the guard hash gets: a value stable across non-deterministic encryption. */
|
||||
hashValue: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace every secret sentinel in `serialized` in ONE pass, producing the on-disk bytes and the
|
||||
* guard hash from the same encoded segments.
|
||||
*
|
||||
* Why not the obvious `payload.replace(...)` / `hashInput.replace(...)` loop it replaces: each
|
||||
* `String.replace` returns a rope that the *next* `replace` has to flatten before it can search, so
|
||||
* N sentinels cost 2N-1 flattened copies of the whole multi-MB state, plus one more per side when
|
||||
* `hash.update` and the file write finally consume them. Measured on a 4.65 MB store with three
|
||||
* sentinels: 7 full-state string allocations, 62 MB of V8 heap, 27 MB of it in large_object_space.
|
||||
*
|
||||
* Here the state is walked once, each literal run is UTF-8 encoded exactly once, and those same
|
||||
* buffers feed both the payload and the hash — 1 full-state string, 1 encode.
|
||||
*
|
||||
* Byte-for-byte identical output to the loop: both sides read the sentinel in its JSON-escaped
|
||||
* form, the replacements are the JSON-escaped `blob`/`hashValue`, and the hash sees the same byte
|
||||
* sequence it saw when it was handed one concatenated string.
|
||||
*/
|
||||
export function applySecretSentinelSubstitutions(
|
||||
serialized: string,
|
||||
substitutions: readonly SecretSentinelSubstitution[],
|
||||
degradedPrefix: string
|
||||
): { payload: Buffer; stateHash: string } {
|
||||
const hash = createHash('sha1').update(degradedPrefix)
|
||||
if (substitutions.length === 0) {
|
||||
const payload = Buffer.from(serialized, 'utf8')
|
||||
return { payload, stateHash: hash.update(payload).digest('hex') }
|
||||
}
|
||||
|
||||
const replacementBySentinel = new Map<string, { blob: Buffer; hashValue: Buffer }>()
|
||||
const alternatives: string[] = []
|
||||
for (const { sentinel, blob, hashValue } of substitutions) {
|
||||
// Preserved from the loop this replaces: both the search key and the replacements are the
|
||||
// JSON-escaped forms, because that is what `serialized` actually contains.
|
||||
const escapedSentinel = JSON.stringify(sentinel).slice(1, -1)
|
||||
if (replacementBySentinel.has(escapedSentinel)) {
|
||||
continue
|
||||
}
|
||||
alternatives.push(escapeRegex(escapedSentinel))
|
||||
replacementBySentinel.set(escapedSentinel, {
|
||||
blob: Buffer.from(JSON.stringify(blob).slice(1, -1), 'utf8'),
|
||||
hashValue: Buffer.from(JSON.stringify(hashValue).slice(1, -1), 'utf8')
|
||||
})
|
||||
}
|
||||
|
||||
// Global, though a sentinel is a UUID minted after the state was assembled and so occurs exactly
|
||||
// once: a single pass that substitutes every occurrence cannot leave one behind on disk.
|
||||
const pattern = new RegExp(alternatives.join('|'), 'g')
|
||||
const chunks: Buffer[] = []
|
||||
let cursor = 0
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = pattern.exec(serialized)) !== null) {
|
||||
// Non-null: the alternation is built from exactly the map's keys.
|
||||
const replacement = replacementBySentinel.get(match[0])!
|
||||
// A sliced substring, so this does not copy the state; the encode below is its only pass.
|
||||
const literal = Buffer.from(serialized.slice(cursor, match.index), 'utf8')
|
||||
chunks.push(literal, replacement.blob)
|
||||
hash.update(literal)
|
||||
hash.update(replacement.hashValue)
|
||||
cursor = match.index + match[0].length
|
||||
}
|
||||
const tail = Buffer.from(serialized.slice(cursor), 'utf8')
|
||||
chunks.push(tail)
|
||||
hash.update(tail)
|
||||
|
||||
return { payload: Buffer.concat(chunks), stateHash: hash.digest('hex') }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { PersistedState } from '../../../shared/persisted-state-types'
|
||||
import { collectFolderWorkspaceDiffComments } from '../../folder-workspace-diff-comments'
|
||||
import {
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
} from '../../protected-secret-persistence'
|
||||
import { stripRetiredGlobalSettings } from '../applying-settings/terminal-settings-migrations'
|
||||
|
||||
import {
|
||||
applySecretSentinelSubstitutions,
|
||||
type SecretSentinelSubstitution
|
||||
} from './secret-sentinel-substitution'
|
||||
import type { StoreRuntimeState } from './store-runtime-state'
|
||||
|
||||
type StateSerializationSecretHandlingOperationsRuntime = Pick<
|
||||
@@ -24,7 +28,7 @@ export class StateSerializationSecretHandlingOperations {
|
||||
}
|
||||
|
||||
buildStateToSave(): {
|
||||
payload: string
|
||||
payload: Buffer
|
||||
stateHash: string
|
||||
protectedSecretUpdates: ProtectedSecretRetentionUpdate[]
|
||||
} {
|
||||
@@ -37,7 +41,7 @@ export class StateSerializationSecretHandlingOperations {
|
||||
// on deterministic-IV platforms (macOS/legacy-Linux OSCrypt). A per-slot
|
||||
// random UUID can't occur anywhere else in the serialized state (the user
|
||||
// sets their data before it is minted), so it appears exactly once.
|
||||
const secretSubs: { sentinel: string; blob: string; hashValue: string }[] = []
|
||||
const secretSubs: SecretSentinelSubstitution[] = []
|
||||
const protectedSecretUpdates: ProtectedSecretRetentionUpdate[] = []
|
||||
let protectedStorageDegraded = false
|
||||
const encryptToSentinel = (slot: string, plaintext: string): string => {
|
||||
@@ -105,21 +109,14 @@ export class StateSerializationSecretHandlingOperations {
|
||||
// Why compact: ~20% fewer bytes and less serialize time; all readers JSON.parse so formatting is irrelevant.
|
||||
// One full-state stringify; secret slots currently hold sentinels.
|
||||
const serialized = JSON.stringify(stateToSave)
|
||||
// Substitute each unique sentinel exactly once: ciphertext for the on-disk
|
||||
// payload, a stable normalized value for the guard hash. Function-form
|
||||
// replacement keeps `$` inert; both sides read the sentinel as JSON-escaped
|
||||
// in `serialized`, so each replace is byte-for-byte position-exact.
|
||||
let payload = serialized
|
||||
let hashInput = serialized
|
||||
for (const { sentinel, blob, hashValue } of secretSubs) {
|
||||
const escapedSentinel = JSON.stringify(sentinel).slice(1, -1)
|
||||
payload = payload.replace(escapedSentinel, () => JSON.stringify(blob).slice(1, -1))
|
||||
hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(hashValue).slice(1, -1))
|
||||
}
|
||||
const stateHash = createHash('sha1')
|
||||
.update(protectedStorageDegraded ? 'safeStorage-degraded\0' : '')
|
||||
.update(hashInput)
|
||||
.digest('hex')
|
||||
// Substitute each unique sentinel: ciphertext for the on-disk payload, a stable normalized
|
||||
// value for the guard hash. One pass builds both, so the multi-MB state is never copied per
|
||||
// sentinel and never encoded twice.
|
||||
const { payload, stateHash } = applySecretSentinelSubstitutions(
|
||||
serialized,
|
||||
secretSubs,
|
||||
protectedStorageDegraded ? 'safeStorage-degraded\0' : ''
|
||||
)
|
||||
return { payload, stateHash, protectedSecretUpdates }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* The write path now hands the file a Buffer it built in one pass instead of a string it rebuilt
|
||||
* per secret. Drives the real `Store` end to end — encrypted settings, a local session and a remote
|
||||
* host partition — and reloads from the file it actually wrote, because the failure this guards
|
||||
* against (a mis-sliced segment, a re-encoded payload, a dropped sentinel) is invisible until
|
||||
* something reads the bytes back.
|
||||
*/
|
||||
import { mkdtempSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => tmpdir(),
|
||||
getName: () => 'orca-test',
|
||||
getVersion: () => '0.0.0-test',
|
||||
isPackaged: false,
|
||||
on: () => {},
|
||||
whenReady: () => Promise.resolve()
|
||||
},
|
||||
safeStorage: {
|
||||
// Encryption ON, so the secret slots really do mint sentinels and the substitution pass runs.
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (value: string) => Buffer.from(`enc:${value}`),
|
||||
decryptString: (value: Buffer) => value.toString().slice(4)
|
||||
},
|
||||
ipcMain: { on: () => {}, handle: () => {} },
|
||||
BrowserWindow: { getAllWindows: () => [] }
|
||||
}))
|
||||
|
||||
const { Store } = await import('./store')
|
||||
|
||||
const HOST_ID = 'ssh:user@host'
|
||||
|
||||
const stores: InstanceType<typeof Store>[] = []
|
||||
afterEach(() => {
|
||||
for (const store of stores.splice(0)) {
|
||||
store.flush()
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function openStore(dataFile: string): InstanceType<typeof Store> {
|
||||
const store = new Store({ dataFile })
|
||||
stores.push(store)
|
||||
return store
|
||||
}
|
||||
|
||||
function session(activeTabId: string): WorkspaceSessionState {
|
||||
return {
|
||||
activeRepoId: 'repo-1',
|
||||
// Left null: the load path's deregistered-repo sweep nulls an active worktree whose repo is
|
||||
// not registered, which would mask what this test is actually about.
|
||||
activeWorktreeId: null,
|
||||
activeTabId,
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
// Non-ASCII on purpose: a byte-offset mistake in the encode shows up here first.
|
||||
browserUrlHistory: [
|
||||
{
|
||||
url: 'https://example.test/é😀',
|
||||
normalizedUrl: 'https://example.test/é😀',
|
||||
title: '中文 title',
|
||||
lastVisitedAt: 17,
|
||||
visitCount: 3
|
||||
}
|
||||
]
|
||||
} as WorkspaceSessionState
|
||||
}
|
||||
|
||||
describe('persisted state survives a save/load round trip', () => {
|
||||
it('reloads settings, secrets and both session partitions unchanged', () => {
|
||||
const dataFile = join(
|
||||
realpathSync(mkdtempSync(join(tmpdir(), 'orca-store-round-trip-'))),
|
||||
'orca-data.json'
|
||||
)
|
||||
const written = openStore(dataFile)
|
||||
written.updateSettings({
|
||||
// Three secret slots, i.e. three sentinels in one save — the case the old loop paid 7 copies for.
|
||||
opencodeSessionCookie: 'cookie-é-value',
|
||||
httpProxyUrl: 'http://proxy.example:8080/?a=b&c=$&'
|
||||
})
|
||||
written.updateUI({ browserKagiSessionLink: 'https://kagi.com/session?t=abc' })
|
||||
written.setWorkspaceSession(session('local-tab'))
|
||||
written.setWorkspaceSession(session('remote-tab'), HOST_ID)
|
||||
written.flush()
|
||||
|
||||
const before = {
|
||||
settings: written.getSettings(),
|
||||
ui: written.getUI(),
|
||||
local: written.getWorkspaceSession(),
|
||||
remote: written.getWorkspaceSession(HOST_ID)
|
||||
}
|
||||
|
||||
// The file is valid UTF-8 JSON and holds ciphertext, not the plaintext secrets.
|
||||
const bytes = readFileSync(dataFile)
|
||||
const onDisk = JSON.parse(bytes.toString('utf8'))
|
||||
expect(onDisk.settings.opencodeSessionCookie).not.toBe('cookie-é-value')
|
||||
expect(Buffer.from(onDisk.settings.opencodeSessionCookie, 'base64').toString('utf8')).toContain(
|
||||
'cookie-é-value'
|
||||
)
|
||||
expect(bytes.toString('utf8')).not.toContain('orca-secret-slot-')
|
||||
|
||||
const reloaded = openStore(dataFile)
|
||||
expect(reloaded.getSettings().opencodeSessionCookie).toBe(before.settings.opencodeSessionCookie)
|
||||
expect(reloaded.getSettings().httpProxyUrl).toBe(before.settings.httpProxyUrl)
|
||||
expect(reloaded.getUI().browserKagiSessionLink).toBe(before.ui.browserKagiSessionLink)
|
||||
// `toMatchObject`: the load path spreads session defaults over what was written, so the
|
||||
// reloaded slice is a superset. Exact deep equality is asserted on the second trip below.
|
||||
expect(reloaded.getWorkspaceSession()).toMatchObject(before.local)
|
||||
// The remote partition keeps everything it owns; only globals local already holds are dropped,
|
||||
// and `browserUrlHistory` comes back at its default from the same spread as before.
|
||||
expect(reloaded.getWorkspaceSession(HOST_ID).activeTabId).toBe('remote-tab')
|
||||
expect(reloaded.getWorkspaceSession(HOST_ID).browserUrlHistory).toEqual([])
|
||||
|
||||
// Deep equality of the whole reloaded state, taken across a second round trip so the assertion
|
||||
// is not comparing against the first load's one-time settings migrations.
|
||||
reloaded.flush()
|
||||
const bytesAfterReload = readFileSync(dataFile)
|
||||
const again = openStore(dataFile)
|
||||
expect(again.getSettings()).toEqual(reloaded.getSettings())
|
||||
expect(again.getUI()).toEqual(reloaded.getUI())
|
||||
expect(again.getWorkspaceSession()).toEqual(reloaded.getWorkspaceSession())
|
||||
expect(again.getWorkspaceSession(HOST_ID)).toEqual(reloaded.getWorkspaceSession(HOST_ID))
|
||||
// ...and the bytes are stable, so a quiet app is not rewriting a 4 MB file with new content.
|
||||
again.flush()
|
||||
expect(readFileSync(dataFile).equals(bytesAfterReload)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Global session fields live in the 'local' slice. Copies of them inside a non-local host partition
|
||||
* are legacy residue: the split never writes them there and the merge never reads them from there
|
||||
* unless local has nothing. These tests pin the drop to exactly that condition, keep the renderer's
|
||||
* merge landing on the same value either way, and re-check the two safety gates that decide which
|
||||
* global fields may be dropped at all.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getDefaultWorkspaceSession } from '../../../shared/constants'
|
||||
import type { BrowserHistoryEntry } from '../../../shared/browser-workspace-types'
|
||||
import type { WorkspaceDocHistoryEntry } from '../../../shared/workspace-doc-history'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import { WORKSPACE_SESSION_FIELD_OWNERSHIP } from '../../../shared/workspace-session-host-field-ownership'
|
||||
import { WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND } from '../restoring-sessions/session-worktree-ownership'
|
||||
import {
|
||||
HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS,
|
||||
parseWorkspaceSessionsByHostId
|
||||
} from './workspace-session-partitions'
|
||||
|
||||
const HOST = 'ssh:target-1'
|
||||
|
||||
function history(url: string): BrowserHistoryEntry[] {
|
||||
return [{ url, normalizedUrl: url, title: url, lastVisitedAt: 1, visitCount: 1 }]
|
||||
}
|
||||
|
||||
function docEntry(filePath: string): WorkspaceDocHistoryEntry {
|
||||
return {
|
||||
docLocation: { kind: 'workspace-doc', worktreeId: 'repo-1::/tmp/a', filePath },
|
||||
title: filePath,
|
||||
lastVisitedAt: 2,
|
||||
visitCount: 1
|
||||
}
|
||||
}
|
||||
|
||||
function localSession(overrides: Partial<WorkspaceSessionState>): WorkspaceSessionState {
|
||||
return { ...getDefaultWorkspaceSession(), ...overrides }
|
||||
}
|
||||
|
||||
function parse(
|
||||
raw: Record<string, unknown>,
|
||||
local?: WorkspaceSessionState
|
||||
): Partial<Record<string, WorkspaceSessionState>> {
|
||||
return parseWorkspaceSessionsByHostId(raw, getDefaultWorkspaceSession(), local).partitions
|
||||
}
|
||||
|
||||
describe('HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS', () => {
|
||||
it('only lists fields that are global AND that no worktree-ownership pass follows', () => {
|
||||
for (const field of HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS) {
|
||||
// Gate 1: the renderer's split/merge treat it as local-owned, so a non-local copy is dead.
|
||||
expect(WORKSPACE_SESSION_FIELD_OWNERSHIP[field]).toBe('global')
|
||||
// Gate 2: `collectPersistedSessionWorktreeOwners` and the deregistered-repo residue sweep
|
||||
// walk EVERY partition through this table. Anything but 'none' means dropping the field
|
||||
// could un-own a worktree and get its metadata pruned.
|
||||
expect(WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND[field]).toBe('none')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseWorkspaceSessionsByHostId global-field residue', () => {
|
||||
it('drops a non-local global field the local slice already owns', () => {
|
||||
const local = localSession({ browserUrlHistory: history('https://local.test') })
|
||||
const partitions = parse(
|
||||
{
|
||||
[HOST]: {
|
||||
...getDefaultWorkspaceSession(),
|
||||
browserUrlHistory: history('https://stale.test')
|
||||
}
|
||||
},
|
||||
local
|
||||
)
|
||||
// Back to the default from the spread, not the 65 KB stale replica. The merge reads this field
|
||||
// from local whenever local has it, so the renderer still sees `https://local.test`
|
||||
// (`workspace-session-host-split.test.ts` pins that half of the contract).
|
||||
expect(partitions[HOST]?.browserUrlHistory).toEqual([])
|
||||
})
|
||||
|
||||
it('retains a non-local global field the local slice does NOT have', () => {
|
||||
// `workspaceDocHistory` is optional and absent from the defaults, so local can genuinely lack
|
||||
// it and the merge's fallback to another slice is live.
|
||||
const local = localSession({})
|
||||
expect(local.workspaceDocHistory).toBeUndefined()
|
||||
const docs = [docEntry('/repo/remote.md')]
|
||||
const partitions = parse(
|
||||
{ [HOST]: { ...getDefaultWorkspaceSession(), workspaceDocHistory: docs } },
|
||||
local
|
||||
)
|
||||
// Retained, so the merge's "fall back to any slice that has it" path still finds a value.
|
||||
expect(partitions[HOST]?.workspaceDocHistory).toEqual(docs)
|
||||
})
|
||||
|
||||
it('drops that same field once the local slice does have it', () => {
|
||||
const localDocs = [docEntry('/repo/local.md')]
|
||||
const local = localSession({ workspaceDocHistory: localDocs })
|
||||
const partitions = parse(
|
||||
{
|
||||
[HOST]: {
|
||||
...getDefaultWorkspaceSession(),
|
||||
workspaceDocHistory: [docEntry('/repo/stale.md')]
|
||||
}
|
||||
},
|
||||
local
|
||||
)
|
||||
expect(partitions[HOST]).not.toHaveProperty('workspaceDocHistory')
|
||||
expect(local.workspaceDocHistory).toEqual(localDocs)
|
||||
})
|
||||
|
||||
it('leaves worktree-referencing globals and host-owned fields alone', () => {
|
||||
const local = localSession({
|
||||
browserUrlHistory: history('https://local.test'),
|
||||
activeWorktreeId: 'repo-1::/tmp/local',
|
||||
activeTabId: 'local-tab'
|
||||
})
|
||||
const tabs = { 'repo-1::/tmp/a': [] }
|
||||
const partitions = parse(
|
||||
{
|
||||
[HOST]: {
|
||||
...getDefaultWorkspaceSession(),
|
||||
// A `'direct'` worktree reference the residue sweep reads out of every partition.
|
||||
activeWorktreeId: 'repo-1::/tmp/a',
|
||||
// Read on a partition by the mobile terminal projection.
|
||||
activeTabId: 'remote-tab',
|
||||
tabsByWorktree: tabs,
|
||||
terminalTopologyRevisionByRepoId: { 'repo-1': 4 }
|
||||
}
|
||||
},
|
||||
local
|
||||
)
|
||||
expect(partitions[HOST]?.activeWorktreeId).toBe('repo-1::/tmp/a')
|
||||
expect(partitions[HOST]?.activeTabId).toBe('remote-tab')
|
||||
expect(partitions[HOST]?.tabsByWorktree).toEqual(tabs)
|
||||
expect(partitions[HOST]?.terminalTopologyRevisionByRepoId).toEqual({ 'repo-1': 4 })
|
||||
})
|
||||
|
||||
it('is a no-op when no local slice is supplied', () => {
|
||||
const stale = history('https://stale.test')
|
||||
const partitions = parse({
|
||||
[HOST]: { ...getDefaultWorkspaceSession(), browserUrlHistory: stale }
|
||||
})
|
||||
expect(partitions[HOST]?.browserUrlHistory).toEqual(stale)
|
||||
})
|
||||
})
|
||||
@@ -17,11 +17,49 @@ export function workspaceSessionSalvageLogDetails(result: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global fields belong to the 'local' slice: the split writes them only there and the merge reads
|
||||
* them only from there. A copy inside a non-local partition is legacy residue no read can reach —
|
||||
* stale `browserUrlHistory` replicas alone were 589 KB, 12.7% of a 4.65 MB store, rewritten on
|
||||
* every save and reparsed on every launch.
|
||||
*
|
||||
* Deliberately NOT every field in `GLOBAL_WORKSPACE_SESSION_FIELDS`. Two separate gates disqualify
|
||||
* the rest, and both are load-bearing:
|
||||
* - `activeWorktreeId` and `activeWorkspaceKey` are `'direct'` in
|
||||
* `WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND`, and both `collectPersistedSessionWorktreeOwners`
|
||||
* and the deregistered-repo residue sweep read them out of EVERY partition. Dropping one
|
||||
* un-owns a worktree, and an un-owned worktree gets its metadata pruned.
|
||||
* - `activeTabId`, `activeConnectionIdsAtShutdown` and `activeRepoId` have live main-side readers
|
||||
* on a partition: `isPersistedTerminalLeafActive` falls back to `activeTabId` for the mobile
|
||||
* projection, and the runtime attach-window handoff unions `activeConnectionIdsAtShutdown`.
|
||||
*
|
||||
* `workspace-session-partitions.test.ts` re-checks both gates for every field listed here.
|
||||
*/
|
||||
export const HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS = [
|
||||
'browserUrlHistory',
|
||||
'workspaceDocHistory'
|
||||
] as const satisfies readonly (keyof WorkspaceSessionState)[]
|
||||
|
||||
/** Dropped only where local already holds the field — exactly when the merge's fallback to another
|
||||
* slice cannot fire. Runs before the defaults spread, so a field the type requires comes back at
|
||||
* its default rather than going missing. */
|
||||
function dropRedundantGlobalFields(
|
||||
slice: Partial<WorkspaceSessionState>,
|
||||
local: WorkspaceSessionState | undefined
|
||||
): void {
|
||||
for (const field of HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS) {
|
||||
if (local?.[field] !== undefined) {
|
||||
delete slice[field]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize non-'local' host partitions; 'local' (the legacy workspaceSession blob) is dropped so the two surfaces never diverge.
|
||||
* Each partition is zod-validated independently, so one corrupt host drops to defaults without taking out the others. Idempotent. */
|
||||
export function parseWorkspaceSessionsByHostId(
|
||||
raw: unknown,
|
||||
defaults: WorkspaceSessionState
|
||||
defaults: WorkspaceSessionState,
|
||||
localSession?: WorkspaceSessionState
|
||||
): { partitions: Partial<Record<ExecutionHostId, WorkspaceSessionState>>; repaired: boolean } {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
return { partitions: {}, repaired: raw !== undefined }
|
||||
@@ -50,6 +88,7 @@ export function parseWorkspaceSessionsByHostId(
|
||||
)
|
||||
repaired = true
|
||||
}
|
||||
dropRedundantGlobalFields(result.value, localSession)
|
||||
partitions[hostId] = { ...defaults, ...result.value }
|
||||
}
|
||||
return { partitions, repaired }
|
||||
|
||||
@@ -15,6 +15,8 @@ import { registerPersistedPaneKeyAlias } from '../restoring-sessions/pane-alias-
|
||||
import {
|
||||
normalizeWorkspaceSessionPaneIdentities,
|
||||
remapAcknowledgedAgentPaneKeys,
|
||||
remapActivityClearedAtPaneKeys,
|
||||
remapManuallyUnreadTurnPaneKeys,
|
||||
remapSshRemotePtyLeaseLeafIds,
|
||||
type WorkspaceSessionPaneIdentityRemap
|
||||
} from '../restoring-sessions/workspace-pane-normalization'
|
||||
@@ -50,10 +52,30 @@ export function setLocalWorkspaceSession(
|
||||
context.runtime.state.ui?.acknowledgedAgentsByPaneKey,
|
||||
normalized.leafIdByInputLeafIdByTabId
|
||||
)
|
||||
if (remappedAcknowledgements.changed) {
|
||||
const remappedActivityCutoffs = remapActivityClearedAtPaneKeys(
|
||||
context.runtime.state.ui?.activityClearedAtByPaneKey,
|
||||
normalized.leafIdByInputLeafIdByTabId
|
||||
)
|
||||
const remappedManualUnread = remapManuallyUnreadTurnPaneKeys(
|
||||
context.runtime.state.ui?.manuallyUnreadTurnsByPaneKey,
|
||||
normalized.leafIdByInputLeafIdByTabId
|
||||
)
|
||||
if (
|
||||
remappedAcknowledgements.changed ||
|
||||
remappedActivityCutoffs.changed ||
|
||||
remappedManualUnread.changed
|
||||
) {
|
||||
context.runtime.state.ui = {
|
||||
...context.runtime.state.ui,
|
||||
acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements
|
||||
...(remappedAcknowledgements.changed
|
||||
? { acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements }
|
||||
: {}),
|
||||
...(remappedActivityCutoffs.changed
|
||||
? { activityClearedAtByPaneKey: remappedActivityCutoffs.cutoffs }
|
||||
: {}),
|
||||
...(remappedManualUnread.changed
|
||||
? { manuallyUnreadTurnsByPaneKey: remappedManualUnread.turns }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
for (const entry of normalized.legacyPaneKeyAliasEntries) {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { makePaneKey } from '../../../shared/stable-pane-id'
|
||||
import {
|
||||
remapActivityClearedAtPaneKeys,
|
||||
remapManuallyUnreadTurnPaneKeys
|
||||
} from './pane-key-remapping'
|
||||
|
||||
const STABLE_LEAF_ID = '00000000-0000-4000-8000-000000000001'
|
||||
|
||||
describe('remapManuallyUnreadTurnPaneKeys', () => {
|
||||
it('promotes legacy pane keys to the restored stable leaf like clear-completed cutoffs', () => {
|
||||
const remap = new Map([['tab-1', new Map([['pane:1', STABLE_LEAF_ID]])]])
|
||||
const turns = { 'tab-1:pane:1': 42, 'tab-2:pane:9': 7 }
|
||||
|
||||
const result = remapManuallyUnreadTurnPaneKeys(turns, remap)
|
||||
|
||||
expect(result.changed).toBe(true)
|
||||
expect(result.turns).toEqual({ [makePaneKey('tab-1', STABLE_LEAF_ID)]: 42, 'tab-2:pane:9': 7 })
|
||||
// Same remap contract as the cutoffs so the two never drift after a session restore.
|
||||
expect(remapActivityClearedAtPaneKeys(turns, remap).cutoffs).toEqual(result.turns)
|
||||
})
|
||||
|
||||
it('reports no change for empty or already-stable records', () => {
|
||||
const remap = new Map([['tab-1', new Map([['pane:1', STABLE_LEAF_ID]])]])
|
||||
expect(remapManuallyUnreadTurnPaneKeys(undefined, remap).changed).toBe(false)
|
||||
expect(remapManuallyUnreadTurnPaneKeys({}, remap).changed).toBe(false)
|
||||
const stable = { [makePaneKey('tab-1', STABLE_LEAF_ID)]: 1 }
|
||||
expect(remapManuallyUnreadTurnPaneKeys(stable, remap)).toEqual({
|
||||
turns: stable,
|
||||
changed: false
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { PersistedState } from '../../../shared/persisted-state-types'
|
||||
import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id'
|
||||
|
||||
type PaneLeafRemap = Map<string, Map<string, string>>
|
||||
|
||||
function remapPaneKeys<T extends number>(
|
||||
values: Record<string, T> | undefined,
|
||||
leafIdByInputLeafIdByTabId: PaneLeafRemap
|
||||
): { values: Record<string, T> | undefined; changed: boolean } {
|
||||
if (!values || Object.keys(values).length === 0) {
|
||||
return { values, changed: false }
|
||||
}
|
||||
|
||||
let changed = false
|
||||
const next: Record<string, T> = {}
|
||||
const setValue = (paneKey: string, value: T): void => {
|
||||
const existing = next[paneKey]
|
||||
next[paneKey] = existing === undefined ? value : (Math.max(existing, value) as T)
|
||||
}
|
||||
for (const [paneKey, value] of Object.entries(values)) {
|
||||
const parsed = parsePaneKey(paneKey)
|
||||
if (parsed) {
|
||||
setValue(paneKey, value)
|
||||
continue
|
||||
}
|
||||
|
||||
const delimiter = paneKey.indexOf(':')
|
||||
if (delimiter <= 0 || delimiter === paneKey.length - 1) {
|
||||
setValue(paneKey, value)
|
||||
continue
|
||||
}
|
||||
|
||||
const tabId = paneKey.slice(0, delimiter)
|
||||
const legacyLeafId = paneKey.slice(delimiter + 1)
|
||||
const remappedLeafId = leafIdByInputLeafIdByTabId.get(tabId)?.get(legacyLeafId)
|
||||
if (!remappedLeafId || !isTerminalLeafId(remappedLeafId)) {
|
||||
setValue(paneKey, value)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
// Carry values over when a legacy leaf is promoted to a UUID.
|
||||
setValue(makePaneKey(tabId, remappedLeafId), value)
|
||||
changed = true
|
||||
} catch {
|
||||
setValue(paneKey, value)
|
||||
}
|
||||
}
|
||||
|
||||
return { values: next, changed }
|
||||
}
|
||||
|
||||
export function remapAcknowledgedAgentPaneKeys(
|
||||
acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey'],
|
||||
leafIdByInputLeafIdByTabId: PaneLeafRemap
|
||||
): { acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey']; changed: boolean } {
|
||||
const result = remapPaneKeys(acknowledgements, leafIdByInputLeafIdByTabId)
|
||||
return { acknowledgements: result.values, changed: result.changed }
|
||||
}
|
||||
|
||||
export function remapManuallyUnreadTurnPaneKeys(
|
||||
turns: PersistedState['ui']['manuallyUnreadTurnsByPaneKey'],
|
||||
leafIdByInputLeafIdByTabId: PaneLeafRemap
|
||||
): { turns: PersistedState['ui']['manuallyUnreadTurnsByPaneKey']; changed: boolean } {
|
||||
const result = remapPaneKeys(turns, leafIdByInputLeafIdByTabId)
|
||||
return { turns: result.values, changed: result.changed }
|
||||
}
|
||||
|
||||
export function remapActivityClearedAtPaneKeys(
|
||||
cutoffs: PersistedState['ui']['activityClearedAtByPaneKey'],
|
||||
leafIdByInputLeafIdByTabId: PaneLeafRemap
|
||||
): { cutoffs: PersistedState['ui']['activityClearedAtByPaneKey']; changed: boolean } {
|
||||
const result = remapPaneKeys(cutoffs, leafIdByInputLeafIdByTabId)
|
||||
return { cutoffs: result.values, changed: result.changed }
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type ExecutionHostId
|
||||
} from '../../../shared/execution-host'
|
||||
import type { SshRemotePtyLease } from '../../../shared/ssh-types'
|
||||
import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { isTerminalLeafId, parsePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { findCrossHostPaneTabIds, withoutPaneTabIds } from './cross-host-pane-tab-ids'
|
||||
import {
|
||||
createLazyTerminalTabLookup,
|
||||
@@ -22,6 +22,17 @@ import {
|
||||
migrationUnsupportedEntriesEqual,
|
||||
normalizeLegacyPaneKeyAliasEntries
|
||||
} from './pane-alias-normalization'
|
||||
import {
|
||||
remapAcknowledgedAgentPaneKeys,
|
||||
remapActivityClearedAtPaneKeys,
|
||||
remapManuallyUnreadTurnPaneKeys
|
||||
} from './pane-key-remapping'
|
||||
|
||||
export {
|
||||
remapAcknowledgedAgentPaneKeys,
|
||||
remapActivityClearedAtPaneKeys,
|
||||
remapManuallyUnreadTurnPaneKeys
|
||||
} from './pane-key-remapping'
|
||||
|
||||
export function normalizeWorkspaceSessionPaneIdentities(
|
||||
session: WorkspaceSessionState,
|
||||
@@ -220,6 +231,14 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): {
|
||||
state.ui?.acknowledgedAgentsByPaneKey,
|
||||
withoutPaneTabIds(acknowledgementLeafIdByInputLeafIdByTabId, crossHostTabIds)
|
||||
)
|
||||
const remappedActivityCutoffs = remapActivityClearedAtPaneKeys(
|
||||
state.ui?.activityClearedAtByPaneKey,
|
||||
withoutPaneTabIds(acknowledgementLeafIdByInputLeafIdByTabId, crossHostTabIds)
|
||||
)
|
||||
const remappedManualUnread = remapManuallyUnreadTurnPaneKeys(
|
||||
state.ui?.manuallyUnreadTurnsByPaneKey,
|
||||
withoutPaneTabIds(acknowledgementLeafIdByInputLeafIdByTabId, crossHostTabIds)
|
||||
)
|
||||
const migrationUnsupportedChanged = !migrationUnsupportedEntriesEqual(
|
||||
state.migrationUnsupportedPtyEntries ?? [],
|
||||
mergedMigrationUnsupportedEntries
|
||||
@@ -234,7 +253,9 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): {
|
||||
!remappedLeases.changed &&
|
||||
!migrationUnsupportedChanged &&
|
||||
!legacyAliasesChanged &&
|
||||
!remappedAcknowledgements.changed
|
||||
!remappedAcknowledgements.changed &&
|
||||
!remappedActivityCutoffs.changed &&
|
||||
!remappedManualUnread.changed
|
||||
) {
|
||||
return {
|
||||
state,
|
||||
@@ -251,11 +272,21 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): {
|
||||
sshRemotePtyLeases: remappedLeases.leases,
|
||||
migrationUnsupportedPtyEntries: mergedMigrationUnsupportedEntries,
|
||||
legacyPaneKeyAliasEntries: mergedLegacyPaneKeyAliasEntries,
|
||||
...(remappedAcknowledgements.changed
|
||||
...(remappedAcknowledgements.changed ||
|
||||
remappedActivityCutoffs.changed ||
|
||||
remappedManualUnread.changed
|
||||
? {
|
||||
ui: {
|
||||
...state.ui,
|
||||
acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements
|
||||
...(remappedAcknowledgements.changed
|
||||
? { acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements }
|
||||
: {}),
|
||||
...(remappedActivityCutoffs.changed
|
||||
? { activityClearedAtByPaneKey: remappedActivityCutoffs.cutoffs }
|
||||
: {}),
|
||||
...(remappedManualUnread.changed
|
||||
? { manuallyUnreadTurnsByPaneKey: remappedManualUnread.turns }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
: {})
|
||||
@@ -265,50 +296,3 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): {
|
||||
legacyPaneKeyAliasEntries: mergedLegacyPaneKeyAliasEntries
|
||||
}
|
||||
}
|
||||
|
||||
export function remapAcknowledgedAgentPaneKeys(
|
||||
acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey'],
|
||||
leafIdByInputLeafIdByTabId: Map<string, Map<string, string>>
|
||||
): { acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey']; changed: boolean } {
|
||||
if (!acknowledgements || Object.keys(acknowledgements).length === 0) {
|
||||
return { acknowledgements, changed: false }
|
||||
}
|
||||
|
||||
let changed = false
|
||||
const next: NonNullable<PersistedState['ui']['acknowledgedAgentsByPaneKey']> = {}
|
||||
const setAcknowledgement = (paneKey: string, acknowledgedAt: number): void => {
|
||||
const existing = next[paneKey]
|
||||
next[paneKey] = existing === undefined ? acknowledgedAt : Math.max(existing, acknowledgedAt)
|
||||
}
|
||||
for (const [paneKey, acknowledgedAt] of Object.entries(acknowledgements)) {
|
||||
const parsed = parsePaneKey(paneKey)
|
||||
if (parsed) {
|
||||
setAcknowledgement(paneKey, acknowledgedAt)
|
||||
continue
|
||||
}
|
||||
|
||||
const delimiter = paneKey.indexOf(':')
|
||||
if (delimiter <= 0 || delimiter === paneKey.length - 1) {
|
||||
setAcknowledgement(paneKey, acknowledgedAt)
|
||||
continue
|
||||
}
|
||||
|
||||
const tabId = paneKey.slice(0, delimiter)
|
||||
const legacyLeafId = paneKey.slice(delimiter + 1)
|
||||
const remappedLeafId = leafIdByInputLeafIdByTabId.get(tabId)?.get(legacyLeafId)
|
||||
if (!remappedLeafId || !isTerminalLeafId(remappedLeafId)) {
|
||||
setAcknowledgement(paneKey, acknowledgedAt)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
// Why: when a legacy leaf is promoted to a UUID, carry the read marker over so seen rows don't come back unread.
|
||||
setAcknowledgement(makePaneKey(tabId, remappedLeafId), acknowledgedAt)
|
||||
changed = true
|
||||
} catch {
|
||||
setAcknowledgement(paneKey, acknowledgedAt)
|
||||
}
|
||||
}
|
||||
|
||||
return { acknowledgements: next, changed }
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -1,16 +1,15 @@
|
||||
import {
|
||||
isAgentForegroundWrapperProcess,
|
||||
isExpectedAgentProcess,
|
||||
recognizeAgentProcessFromCommandLine
|
||||
isExpectedAgentProcess
|
||||
} from '../../shared/agent-process-recognition'
|
||||
import { getFirstCommandToken } from '../../shared/command-token-scanner'
|
||||
import { resolveOuterWrapperForegroundProcess } from '../../shared/foreground-wrapper-agent'
|
||||
import { selectForegroundProcessCandidate } from '../../shared/foreground-process-selection'
|
||||
import type { ForegroundProcessEvidence } from '../../shared/foreground-process-evidence'
|
||||
import {
|
||||
buildProcessTableIndex,
|
||||
getStrictProcessTableSnapshot,
|
||||
lookupProcessTableIndex,
|
||||
scoreForegroundCandidateRow,
|
||||
type ProcessTableIndex,
|
||||
type ProcessTableIndexStats,
|
||||
type ProcessTableRow
|
||||
@@ -126,23 +125,15 @@ export function resolveAgentForegroundProcessesFromIndex(
|
||||
if (wrapperFallback && candidates.length !== 1) {
|
||||
return { available: true, processName: null }
|
||||
}
|
||||
let bestCandidate: (ProcessTableRow & { depth: number }) | null = null
|
||||
let bestName: ReturnType<typeof recognizeAgentProcessFromCommandLine> = null
|
||||
for (const candidate of candidates) {
|
||||
const recognized = recognizeAgentProcessFromCommandLine(candidate.command)
|
||||
if (
|
||||
recognized &&
|
||||
(bestCandidate === null ||
|
||||
scoreForegroundCandidateRow(candidate) > scoreForegroundCandidateRow(bestCandidate))
|
||||
) {
|
||||
bestCandidate = candidate
|
||||
bestName = recognized
|
||||
}
|
||||
}
|
||||
if (bestCandidate && bestName) {
|
||||
const selected = selectForegroundProcessCandidate(candidates, allCandidates)
|
||||
if (selected) {
|
||||
return {
|
||||
available: true,
|
||||
processName: resolveOuterWrapperForegroundProcess(bestName, bestCandidate, allCandidates)
|
||||
processName: resolveOuterWrapperForegroundProcess(
|
||||
selected.recognized,
|
||||
selected.candidate,
|
||||
allCandidates
|
||||
)
|
||||
}
|
||||
}
|
||||
return { available: true, processName: null }
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { gunzipSync } from 'node:zlib'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ProcessTableRow } from '../../shared/process-table-snapshot'
|
||||
import { resolveAgentForegroundProcessFromPs } from './agent-foreground-process'
|
||||
|
||||
type CapturedRun = {
|
||||
agent: string
|
||||
shellPid: number
|
||||
rows: ProcessTableRow[]
|
||||
}
|
||||
|
||||
describe('real foreground process captures', () => {
|
||||
it('resolves all six agents, including omp over its deeper vendor helpers', () => {
|
||||
const captured = JSON.parse(
|
||||
gunzipSync(readFileSync(join(__dirname, '__fixtures__', 'real-agent-rows.json.gz'))).toString(
|
||||
'utf8'
|
||||
)
|
||||
) as CapturedRun[]
|
||||
|
||||
expect(captured).toHaveLength(6)
|
||||
expect(
|
||||
captured.map(({ agent, shellPid, rows }) => ({
|
||||
agent,
|
||||
processName: resolveAgentForegroundProcessFromPs(rows, shellPid)
|
||||
}))
|
||||
).toEqual([
|
||||
{ agent: 'claude', processName: 'claude' },
|
||||
{ agent: 'codex', processName: 'codex' },
|
||||
{ agent: 'opencode', processName: 'opencode' },
|
||||
{ agent: 'gemini', processName: 'gemini' },
|
||||
{ agent: 'grok', processName: 'grok' },
|
||||
{ agent: 'omp', processName: 'omp' }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,9 @@
|
||||
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
|
||||
import { resolveOuterWrapperForegroundProcess } from '../../shared/foreground-wrapper-agent'
|
||||
import {
|
||||
collectDescendantsFromIndex,
|
||||
getFreshProcessTableSnapshot,
|
||||
getProcessTableIndex,
|
||||
getProcessTableSnapshot,
|
||||
type ProcessTableRow
|
||||
} from '../../shared/process-table-snapshot'
|
||||
@@ -11,6 +13,7 @@ import {
|
||||
type AgentForegroundResolutionOptions
|
||||
} from './windows-agent-foreground-process'
|
||||
import { isShellProcess } from '../../shared/shell-process-detection'
|
||||
import { selectForegroundProcessCandidate } from '../../shared/foreground-process-selection'
|
||||
|
||||
export type { AgentForegroundResolutionOptions } from './windows-agent-foreground-process'
|
||||
export {
|
||||
@@ -42,29 +45,6 @@ type ShellForegroundConfirmationOptions = {
|
||||
| Promise<ReadonlySet<number> | null>
|
||||
}
|
||||
|
||||
function collectDescendants<Row extends { pid: number; ppid: number }>(
|
||||
rows: Row[],
|
||||
rootPid: number
|
||||
): (Row & { depth: number })[] {
|
||||
const childrenByParent = new Map<number, Row[]>()
|
||||
for (const row of rows) {
|
||||
const children = childrenByParent.get(row.ppid) ?? []
|
||||
children.push(row)
|
||||
childrenByParent.set(row.ppid, children)
|
||||
}
|
||||
|
||||
const descendants: (Row & { depth: number })[] = []
|
||||
const stack = (childrenByParent.get(rootPid) ?? []).map((row) => ({ row, depth: 1 }))
|
||||
while (stack.length > 0) {
|
||||
const { row, depth } = stack.pop()!
|
||||
descendants.push({ ...row, depth })
|
||||
for (const child of childrenByParent.get(row.pid) ?? []) {
|
||||
stack.push({ row: child, depth: depth + 1 })
|
||||
}
|
||||
}
|
||||
return descendants
|
||||
}
|
||||
|
||||
function commandExecutable(command: string): string {
|
||||
const trimmed = command.trim().replace(/^[-]/, '')
|
||||
if (trimmed.startsWith('"') || trimmed.startsWith("'")) {
|
||||
@@ -96,12 +76,12 @@ export async function confirmShellForegroundProcess(
|
||||
}
|
||||
}
|
||||
try {
|
||||
const rows = await getFreshProcessTableSnapshot()
|
||||
if (!rows.some((row) => row.pid === shellPid)) {
|
||||
const index = getProcessTableIndex(await getFreshProcessTableSnapshot())
|
||||
const root = index.byPid.get(shellPid)
|
||||
if (!root) {
|
||||
return false
|
||||
}
|
||||
const root = rows.find((row) => row.pid === shellPid)!
|
||||
const tree = [{ ...root, depth: 0 }, ...collectDescendants(rows, shellPid)]
|
||||
const tree = [{ ...root, depth: 0 }, ...collectDescendantsFromIndex(index, shellPid)]
|
||||
const spawnedShellBasename = executableBasename(spawnedShellProcess)
|
||||
const foregroundShell = tree
|
||||
.filter(
|
||||
@@ -120,13 +100,6 @@ export async function confirmShellForegroundProcess(
|
||||
}
|
||||
}
|
||||
|
||||
function candidateScore(row: ProcessTableRow & { depth: number }): number {
|
||||
// Why: foreground descendants carry `+` in `ps stat` on Unix PTYs. Prefer
|
||||
// them, then prefer leaf/deeper wrappers so `node /path/bin/codex` beats the
|
||||
// parent shell but still lets the native child confirm the same identity.
|
||||
return (row.stat.includes('+') ? 10_000 : 0) + row.depth
|
||||
}
|
||||
|
||||
export async function resolveAgentForegroundProcess(
|
||||
shellPid: number | null | undefined,
|
||||
fallbackProcess: string | null,
|
||||
@@ -178,7 +151,7 @@ export async function resolveAgentForegroundProcessWithAvailability(
|
||||
const rows = options.fresh
|
||||
? await getFreshProcessTableSnapshot()
|
||||
: await getProcessTableSnapshot()
|
||||
if (options.fresh && !rows.some((row) => row.pid === shellPid)) {
|
||||
if (options.fresh && !getProcessTableIndex(rows).byPid.has(shellPid)) {
|
||||
return { available: false, processName: fallbackProcess }
|
||||
}
|
||||
return {
|
||||
@@ -191,30 +164,32 @@ export async function resolveAgentForegroundProcessWithAvailability(
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAgentForegroundProcessFromPs(
|
||||
rows: ProcessTableRow[],
|
||||
export function resolveAgentForegroundProcessFromPs(
|
||||
rows: readonly ProcessTableRow[],
|
||||
shellPid: number
|
||||
): string | null {
|
||||
const shellRow = rows.find((row) => row.pid === shellPid)
|
||||
const candidates = collectDescendants(rows, shellPid).sort(
|
||||
(a, b) => candidateScore(b) - candidateScore(a)
|
||||
)
|
||||
// Memoized per snapshot identity, so the caller's own index build is reused.
|
||||
const index = getProcessTableIndex(rows)
|
||||
const shellRow = index.byPid.get(shellPid)
|
||||
const candidates = collectDescendantsFromIndex(index, shellPid)
|
||||
// Why: `+` in `ps stat` marks the process holding the terminal foreground.
|
||||
// The root shell can hold it after Ctrl-Z, so use the whole PTY tree as the
|
||||
// foreground gate; otherwise a stopped agent child still masquerades as live.
|
||||
const foregroundIsKnown =
|
||||
shellRow?.stat.includes('+') === true ||
|
||||
candidates.some((candidate) => candidate.stat.includes('+'))
|
||||
for (const candidate of candidates) {
|
||||
if (foregroundIsKnown && !candidate.stat.includes('+')) {
|
||||
continue
|
||||
}
|
||||
const recognized = recognizeAgentProcessFromCommandLine(candidate.command)
|
||||
if (recognized) {
|
||||
// Why: return the outer wrapper (omp) rather than the deeper wrapped child
|
||||
// (pi) of a shell→omp→pi tree — see resolveOuterWrapperForegroundProcess.
|
||||
return resolveOuterWrapperForegroundProcess(recognized, candidate, candidates)
|
||||
}
|
||||
const foregroundCandidates = foregroundIsKnown
|
||||
? candidates.filter((candidate) => candidate.stat.includes('+'))
|
||||
: candidates
|
||||
// Keep the complete process tree for ancestry checks. A recognized agent can
|
||||
// sit above a non-foreground helper before another recognized process; the
|
||||
// helper is filtered from selection but must remain traversable.
|
||||
const ancestryCandidates = shellRow ? [{ ...shellRow, depth: 0 }, ...candidates] : candidates
|
||||
const selected = selectForegroundProcessCandidate(foregroundCandidates, ancestryCandidates)
|
||||
if (selected) {
|
||||
// Why: return the outer wrapper (omp) rather than the deeper wrapped child
|
||||
// (pi) of a shell→omp→pi tree — see resolveOuterWrapperForegroundProcess.
|
||||
return resolveOuterWrapperForegroundProcess(selected.recognized, selected.candidate, candidates)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -486,14 +486,16 @@ describe('SshFilesystemProvider', () => {
|
||||
expect(result).toEqual(searchResult)
|
||||
})
|
||||
|
||||
it('listFiles sends fs.listFiles request', async () => {
|
||||
// Why #12547: a monorepo listing does not fit one control-lane frame, so the request opts into
|
||||
// response streaming. An old relay ignores `__streamResponse` and answers plainly, which is the
|
||||
// plain-array case each of these asserts.
|
||||
it('listFiles sends a streamable fs.listFiles request', async () => {
|
||||
mux.request.mockResolvedValue(['src/index.ts', 'package.json'])
|
||||
const result = await provider.listFiles('/home/user/project')
|
||||
expect(mux.request).toHaveBeenCalledWith(
|
||||
'fs.listFiles',
|
||||
{ rootPath: '/home/user/project' },
|
||||
{ signal: undefined }
|
||||
)
|
||||
expect(mux.request).toHaveBeenCalledWith('fs.listFiles', {
|
||||
rootPath: '/home/user/project',
|
||||
__streamResponse: true
|
||||
})
|
||||
expect(result).toEqual(['src/index.ts', 'package.json'])
|
||||
})
|
||||
|
||||
@@ -503,26 +505,22 @@ describe('SshFilesystemProvider', () => {
|
||||
maxResults: 20_000,
|
||||
searchQuery: 'target'
|
||||
})
|
||||
expect(mux.request).toHaveBeenCalledWith(
|
||||
'fs.listFiles',
|
||||
{
|
||||
rootPath: '/home/user/project',
|
||||
excludePaths: ['/home/user/project/worktrees/b'],
|
||||
maxResults: 20_000,
|
||||
searchQuery: 'target'
|
||||
},
|
||||
{ signal: undefined }
|
||||
)
|
||||
expect(mux.request).toHaveBeenCalledWith('fs.listFiles', {
|
||||
rootPath: '/home/user/project',
|
||||
excludePaths: ['/home/user/project/worktrees/b'],
|
||||
maxResults: 20_000,
|
||||
searchQuery: 'target',
|
||||
__streamResponse: true
|
||||
})
|
||||
})
|
||||
|
||||
it('listFiles omits excludePaths when empty', async () => {
|
||||
mux.request.mockResolvedValue([])
|
||||
await provider.listFiles('/home/user/project', { excludePaths: [] })
|
||||
expect(mux.request).toHaveBeenCalledWith(
|
||||
'fs.listFiles',
|
||||
{ rootPath: '/home/user/project' },
|
||||
{ signal: undefined }
|
||||
)
|
||||
expect(mux.request).toHaveBeenCalledWith('fs.listFiles', {
|
||||
rootPath: '/home/user/project',
|
||||
__streamResponse: true
|
||||
})
|
||||
})
|
||||
|
||||
it('listFiles forwards the cancellation signal to the mux request (#7721)', async () => {
|
||||
@@ -531,8 +529,8 @@ describe('SshFilesystemProvider', () => {
|
||||
await provider.listFiles('/home/user/project', { signal: controller.signal })
|
||||
expect(mux.request).toHaveBeenCalledWith(
|
||||
'fs.listFiles',
|
||||
{ rootPath: '/home/user/project' },
|
||||
{ signal: controller.signal }
|
||||
{ rootPath: '/home/user/project', __streamResponse: true },
|
||||
{ signal: controller.signal, timeoutMs: undefined }
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
|
||||
import { isMethodNotFoundError, readFileViaStream } from '../ssh/ssh-filesystem-stream-reader'
|
||||
import { uploadBuffer } from '../ssh/sftp-upload'
|
||||
import { requestGitStreamable } from '../ssh/ssh-git-response-stream-reader'
|
||||
import { lstatViaSftp } from './ssh-filesystem-provider-sftp'
|
||||
import {
|
||||
downloadFileViaSftp,
|
||||
@@ -314,7 +315,11 @@ export class SshFilesystemProvider implements IFilesystemProvider {
|
||||
// Why #7721: the signal lets a workspace switch send rpc.cancel so the
|
||||
// relay aborts the full-tree scan instead of stacking abandoned scans
|
||||
// that starve interactive fs.readDir/fs.stat on the shared SSH channel.
|
||||
return (await this.mux.request('fs.listFiles', params, {
|
||||
// Why streamable: a monorepo listing serializes past the relay's 1 MiB control lane, and the
|
||||
// lane it demotes to is refused under unrelated producer load. Opting in moves it to the bulk
|
||||
// lane in chunks; an old relay ignores the flag and answers plainly, which the reader detects
|
||||
// by the sentinel marker being absent.
|
||||
return (await requestGitStreamable(this.mux, 'fs.listFiles', params, {
|
||||
signal: options?.signal
|
||||
})) as string[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// Regression guard on the per-inspection cost of Windows agent foreground
|
||||
// inspection — the Windows analogue of the POSIX index memo (#6288).
|
||||
//
|
||||
// The shared TTL cache already collapses N panes into one Toolhelp32 snapshot
|
||||
// (windows-agent-foreground-process-scan-volume.test.ts). What it never
|
||||
// collapsed is the work each pane does ON that snapshot: a full
|
||||
// `native.map(toProcessRow)` projection, a `childrenByPpid` Map rebuilt from
|
||||
// scratch, and two linear scans. This file counts that work at a realistic
|
||||
// table size and pane count, and pins the flag set the snapshot asks for.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table'
|
||||
import {
|
||||
queryWindowsPaneProcessInventory,
|
||||
resetWindowsProcessRowsSnapshotForTests
|
||||
} from './windows-foreground-process-rows'
|
||||
|
||||
// 1050 processes is the host measured in windows-process-enumeration.md; 11
|
||||
// panes is the fan-out the shared snapshot exists to serve.
|
||||
const TABLE_SIZE = 1050
|
||||
const PANE_COUNT = 11
|
||||
|
||||
const SELF_ROW = { pid: process.pid, ppid: 0, name: 'vitest.exe', commandLine: 'vitest' }
|
||||
|
||||
const shellPid = (pane: number): number => 10_000 + pane * 10
|
||||
const agentPid = (pane: number): number => shellPid(pane) + 1
|
||||
/** A row every pane can look up, so distinct results == distinct projections. */
|
||||
const PROBE_PID = 900_000 + TABLE_SIZE - 1
|
||||
|
||||
/** One shell + one agent child per pane, padded out to a real table size. */
|
||||
function buildNativeTable(): { pid: number; ppid: number; name: string; commandLine: string }[] {
|
||||
const rows = [SELF_ROW]
|
||||
for (let pane = 0; pane < PANE_COUNT; pane += 1) {
|
||||
rows.push({ pid: shellPid(pane), ppid: 4, name: 'cmd.exe', commandLine: 'cmd.exe' })
|
||||
rows.push({
|
||||
pid: agentPid(pane),
|
||||
ppid: shellPid(pane),
|
||||
name: 'node.exe',
|
||||
commandLine: 'node C:/Users/dev/AppData/codex/bin/codex.js'
|
||||
})
|
||||
}
|
||||
for (let filler = rows.length; filler < TABLE_SIZE; filler += 1) {
|
||||
rows.push({ pid: 900_000 + filler, ppid: 4, name: 'svchost.exe', commandLine: 'svchost.exe' })
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
const NATIVE_TABLE = buildNativeTable()
|
||||
|
||||
/**
|
||||
* Count `Map.prototype.set` calls — the primitive both the old per-call
|
||||
* `childrenByPpid` rebuild and the shared index build are made of. Patched for
|
||||
* one awaited region and restored in `finally`, so nothing else observes it.
|
||||
*/
|
||||
async function countMapInsertions(run: () => Promise<void>): Promise<number> {
|
||||
const original = Map.prototype.set
|
||||
let insertions = 0
|
||||
Map.prototype.set = function patched(this: Map<unknown, unknown>, key: unknown, value: unknown) {
|
||||
insertions += 1
|
||||
return original.call(this, key, value)
|
||||
} as typeof Map.prototype.set
|
||||
try {
|
||||
await run()
|
||||
} finally {
|
||||
Map.prototype.set = original
|
||||
}
|
||||
return insertions
|
||||
}
|
||||
|
||||
describe('windows foreground inspection cost per pane', () => {
|
||||
const getAllProcesses = vi.fn()
|
||||
let platform: PropertyDescriptor | undefined
|
||||
let flagsSeen: number[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
flagsSeen = []
|
||||
getAllProcesses.mockReset()
|
||||
getAllProcesses.mockImplementation((cb: (rows: unknown) => void, flags: number) => {
|
||||
flagsSeen.push(flags)
|
||||
cb(NATIVE_TABLE)
|
||||
})
|
||||
platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
__setWindowsProcessTreeLoaderForTests(() => ({
|
||||
ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 },
|
||||
getAllProcesses
|
||||
}))
|
||||
resetWindowsProcessRowsSnapshotForTests()
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(0)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
__setWindowsProcessTreeLoaderForTests()
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
})
|
||||
|
||||
async function sweepPanes(): Promise<(number | undefined)[]> {
|
||||
const resolved: (number | undefined)[] = []
|
||||
for (let pane = 0; pane < PANE_COUNT; pane += 1) {
|
||||
const inventory = await queryWindowsPaneProcessInventory(shellPid(pane), {
|
||||
anchorPid: agentPid(pane)
|
||||
})
|
||||
expect(inventory?.candidates).toHaveLength(1)
|
||||
resolved.push(inventory?.candidates[0]?.pid)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
it('never sets the Memory flag on the snapshot', async () => {
|
||||
await queryWindowsPaneProcessInventory(shellPid(0))
|
||||
expect(flagsSeen).toHaveLength(1)
|
||||
// Memory is bit 0, and it costs the addon a second OpenProcess per process
|
||||
// carrying PROCESS_VM_READ (process.cc `GetProcessMemoryUsage`).
|
||||
expect(flagsSeen[0]! & 1).toBe(0)
|
||||
// CommandLine (2) | CreationTime (4).
|
||||
expect(flagsSeen[0]).toBe(6)
|
||||
})
|
||||
|
||||
it('projects the shared snapshot once for the whole pane fan-out', async () => {
|
||||
const probeRows: unknown[] = []
|
||||
for (let pane = 0; pane < PANE_COUNT; pane += 1) {
|
||||
const inventory = await queryWindowsPaneProcessInventory(shellPid(pane), {
|
||||
anchorPid: PROBE_PID
|
||||
})
|
||||
probeRows.push(inventory?.anchorRow)
|
||||
}
|
||||
expect(probeRows.filter(Boolean)).toHaveLength(PANE_COUNT)
|
||||
// One projection produced every pane's row object. Pre-fix each pane ran
|
||||
// its own `native.map(toProcessRow)` over all 1050 rows, so this set held
|
||||
// PANE_COUNT distinct objects and the sweep allocated PANE_COUNT * 1050.
|
||||
expect(new Set(probeRows).size).toBe(1)
|
||||
})
|
||||
|
||||
it('indexes the shared snapshot once for the whole pane fan-out', async () => {
|
||||
// Prime the TTL cache and the index so the snapshot read is not in the count.
|
||||
await queryWindowsPaneProcessInventory(shellPid(0), { anchorPid: agentPid(0) })
|
||||
|
||||
const insertions = await countMapInsertions(async () => {
|
||||
await sweepPanes()
|
||||
})
|
||||
|
||||
// Pre-fix every pane rebuilt a whole-table `childrenByPpid`, so this was
|
||||
// >= PANE_COUNT * (rows with a distinct ppid). One shared index makes the
|
||||
// whole sweep cost no table-sized Map build at all.
|
||||
expect(insertions).toBeLessThan(TABLE_SIZE)
|
||||
})
|
||||
|
||||
it('resolves the same foreground child for every pane as an unshared scan would', async () => {
|
||||
const resolved = await sweepPanes()
|
||||
expect(resolved).toEqual(Array.from({ length: PANE_COUNT }, (_, pane) => agentPid(pane)))
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
collectDescendantsFromIndex,
|
||||
getProcessTableIndex
|
||||
} from '../../shared/process-table-snapshot'
|
||||
import {
|
||||
readWindowsProcessTable,
|
||||
readWindowsProcessTableFresh,
|
||||
@@ -25,15 +29,40 @@ function toProcessRow(row: NativeWindowsProcessRow): WindowsProcessRow {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One projection per snapshot identity, mirroring `getProcessTableIndex`.
|
||||
*
|
||||
* The TTL cache already gives every pane the same native rows array; without
|
||||
* this each of them still rebuilt ~1050 row objects, which also handed
|
||||
* `getProcessTableIndex` a new array each time and defeated its memo by
|
||||
* construction. Keyed weakly, so a projection dies with its snapshot. Rows are
|
||||
* shared, never mutated: descendants are copied with their depth, and
|
||||
* `anchorRow` is read-only to every caller.
|
||||
*/
|
||||
const projectedRows = new WeakMap<readonly NativeWindowsProcessRow[], WindowsProcessRow[]>()
|
||||
|
||||
function projectProcessRows(native: readonly NativeWindowsProcessRow[]): WindowsProcessRow[] {
|
||||
const cached = projectedRows.get(native)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
const rows = native.map(toProcessRow)
|
||||
projectedRows.set(native, rows)
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows from a scan that starts after this call.
|
||||
*
|
||||
* PID-identity checks in teardown must not reuse a cached row — it can predate
|
||||
* the very recycle it is meant to detect. Rejects when the table is unreadable,
|
||||
* so "unavailable" stays distinguishable from "nothing is running".
|
||||
*
|
||||
* `readonly` because the projection is shared with every other reader of the
|
||||
* same snapshot.
|
||||
*/
|
||||
export async function queryWindowsProcessRowsFresh(): Promise<WindowsProcessRow[]> {
|
||||
return (await readWindowsProcessTableFresh()).map(toProcessRow)
|
||||
export async function queryWindowsProcessRowsFresh(): Promise<readonly WindowsProcessRow[]> {
|
||||
return projectProcessRows(await readWindowsProcessTableFresh())
|
||||
}
|
||||
|
||||
export async function queryWindowsProcessDescendants(
|
||||
@@ -63,21 +92,22 @@ export async function queryWindowsPaneProcessInventory(
|
||||
options.fresh === true
|
||||
? await readWindowsProcessTableFresh()
|
||||
: await readWindowsProcessTable()
|
||||
rows = native.map(toProcessRow)
|
||||
rows = projectProcessRows(native)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
// One index per snapshot, shared by every pane inspecting inside the TTL
|
||||
// window: `byPid` answers both lookups that used to be linear scans, and
|
||||
// `childrenByPpid` replaces a per-call Map rebuild over the whole table.
|
||||
const index = getProcessTableIndex(rows)
|
||||
// Why: a snapshot that omitted the PTY root may be stale or permission-
|
||||
// filtered; only an observed root can authoritatively have no descendants.
|
||||
if (!rows.some((row) => row.pid === rootPid)) {
|
||||
if (!index.byPid.has(rootPid)) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
candidates: collectDescendants(rows, rootPid).sort((a, b) => b.depth - a.depth),
|
||||
anchorRow:
|
||||
options.anchorPid !== undefined
|
||||
? (rows.find((row) => row.pid === options.anchorPid) ?? null)
|
||||
: null
|
||||
candidates: collectDescendantsFromIndex(index, rootPid).sort((a, b) => b.depth - a.depth),
|
||||
anchorRow: options.anchorPid !== undefined ? (index.byPid.get(options.anchorPid) ?? null) : null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,26 +115,3 @@ export async function queryWindowsPaneProcessInventory(
|
||||
export function resetWindowsProcessRowsSnapshotForTests(): void {
|
||||
resetWindowsProcessTableForTests()
|
||||
}
|
||||
|
||||
function collectDescendants<Row extends { pid: number; ppid: number }>(
|
||||
rows: Row[],
|
||||
rootPid: number
|
||||
): (Row & { depth: number })[] {
|
||||
const childrenByParent = new Map<number, Row[]>()
|
||||
for (const row of rows) {
|
||||
const children = childrenByParent.get(row.ppid) ?? []
|
||||
children.push(row)
|
||||
childrenByParent.set(row.ppid, children)
|
||||
}
|
||||
|
||||
const descendants: (Row & { depth: number })[] = []
|
||||
const stack = (childrenByParent.get(rootPid) ?? []).map((row) => ({ row, depth: 1 }))
|
||||
while (stack.length > 0) {
|
||||
const { row, depth } = stack.pop()!
|
||||
descendants.push({ ...row, depth })
|
||||
for (const child of childrenByParent.get(row.pid) ?? []) {
|
||||
stack.push({ row: child, depth: depth + 1 })
|
||||
}
|
||||
}
|
||||
return descendants
|
||||
}
|
||||
|
||||
@@ -144,51 +144,6 @@ describe('fetchClaudeRateLimits', () => {
|
||||
expect(fetchViaPty).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps a scoped Fable window whose display name carries a point release', async () => {
|
||||
const configDir = '/Users/test/.claude'
|
||||
const authPreparation: ClaudeRuntimeAuthPreparation = {
|
||||
configDir,
|
||||
envPatch: { CLAUDE_CONFIG_DIR: configDir },
|
||||
stripAuthEnv: false,
|
||||
provenance: 'managed:account-1'
|
||||
}
|
||||
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce(
|
||||
JSON.stringify({ claudeAiOauth: { accessToken: 'oauth-token' } })
|
||||
)
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
five_hour: { utilization: 36 },
|
||||
seven_day: { utilization: 73 },
|
||||
// Discriminating: a passing scope match must beat this fallback.
|
||||
fable_weekly: { utilization: 12 },
|
||||
limits: [
|
||||
{
|
||||
kind: 'weekly_scoped',
|
||||
percent: 64,
|
||||
resets_at: '2026-07-17T20:00:00.099908+00:00',
|
||||
is_active: true,
|
||||
scope: { model: { display_name: 'Fable 5.1' } }
|
||||
}
|
||||
]
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
)
|
||||
|
||||
await expect(
|
||||
fetchClaudeRateLimits({ authPreparation, allowUsagePanelSupplement: true })
|
||||
).resolves.toMatchObject({
|
||||
provider: 'claude',
|
||||
status: 'ok',
|
||||
fableWeekly: {
|
||||
usedPercent: 64,
|
||||
resetsAt: Date.parse('2026-07-17T20:00:00.099908+00:00')
|
||||
}
|
||||
})
|
||||
expect(fetchViaPty).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces inactive scoped Fable usage over the legacy OAuth fallback', async () => {
|
||||
const configDir = '/Users/test/.claude'
|
||||
const authPreparation: ClaudeRuntimeAuthPreparation = {
|
||||
|
||||
@@ -32,17 +32,13 @@ async function ensureProxyFromEnvironment(): Promise<void> {
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// Why: the scope name carries the shipped version once a point release exists
|
||||
// ("Fable 5.1"), so exact equality would drop the window.
|
||||
const FABLE_SCOPE_RE = /^fable\b/
|
||||
|
||||
function mapFableWeeklyWindow(data: OAuthUsageResponse): RateLimitWindow | null {
|
||||
const scoped = Array.isArray(data.limits)
|
||||
? data.limits.find(
|
||||
(limit) =>
|
||||
limit?.kind === 'weekly_scoped' &&
|
||||
Number.isFinite(limit.percent) &&
|
||||
FABLE_SCOPE_RE.test(limit.scope?.model?.display_name?.trim().toLowerCase() ?? '')
|
||||
limit.scope?.model?.display_name?.trim().toLowerCase() === 'fable'
|
||||
)
|
||||
: undefined
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DECORATIVE_TITLE_FACT_HEARTBEAT_MS,
|
||||
shouldEmitTitleFactForFrame
|
||||
} from './decorative-title-fact-emission'
|
||||
|
||||
const base = {
|
||||
decorativeOnly: true,
|
||||
staleWorkingTitleClear: false,
|
||||
lastEmittedAtMs: 1_000,
|
||||
nowMs: 1_000
|
||||
}
|
||||
|
||||
describe('shouldEmitTitleFactForFrame', () => {
|
||||
it('always emits a frame that is not a decorative repeat', () => {
|
||||
expect(shouldEmitTitleFactForFrame({ ...base, decorativeOnly: false })).toBe(true)
|
||||
})
|
||||
|
||||
it('emits the first frame of a pane', () => {
|
||||
expect(shouldEmitTitleFactForFrame({ ...base, lastEmittedAtMs: null })).toBe(true)
|
||||
})
|
||||
|
||||
it('suppresses a decorative repeat inside the heartbeat window', () => {
|
||||
expect(
|
||||
shouldEmitTitleFactForFrame({ ...base, nowMs: 1_000 + DECORATIVE_TITLE_FACT_HEARTBEAT_MS - 1 })
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('lets a decorative repeat through once the heartbeat window elapses', () => {
|
||||
expect(
|
||||
shouldEmitTitleFactForFrame({ ...base, nowMs: 1_000 + DECORATIVE_TITLE_FACT_HEARTBEAT_MS })
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('never throttles a timer-synthesized stale-working clear', () => {
|
||||
// Why: it carries a staleWorkingTitleClear flag no earlier repeat can stand in for.
|
||||
expect(shouldEmitTitleFactForFrame({ ...base, staleWorkingTitleClear: true })).toBe(true)
|
||||
})
|
||||
|
||||
it('emits after a backwards clock step instead of parking until it catches up', () => {
|
||||
expect(shouldEmitTitleFactForFrame({ ...base, nowMs: 900 })).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps at least three frames inside the renderer hook-done quiet window', () => {
|
||||
// Why: observeTitle's arriving working title is what cancels a Pi/OMP milestone `done`
|
||||
// scheduled with HOOK_DONE_QUIET_MS = 1500. Losing that would mint a false completion.
|
||||
expect(DECORATIVE_TITLE_FACT_HEARTBEAT_MS * 3).toBeLessThanOrEqual(1_500)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Why: an agent spinner re-emits a semantically identical OSC title ~12.5x/sec (Orca's own
|
||||
* synthetic frame timer, Pi/OMP, Claude Code, Grok), and main ships every frame to the renderer
|
||||
* as its own `pty:sideEffect` message. Both renderer store writes already discard those frames
|
||||
* via `isDecorativeAgentTitleFrameChange`, so the message is pure cross-process cost.
|
||||
*
|
||||
* Why a heartbeat and not a hard drop: `agentCompletionCoordinator.observeTitle` treats an
|
||||
* arriving *working* title as "still working" and cancels a scheduled hook-`done` completion
|
||||
* inside `HOOK_DONE_QUIET_MS` (1500ms). That is exactly how a Pi/OMP milestone `done` emitted
|
||||
* mid-turn is stopped from minting a completion notification, and the frames that carry it are
|
||||
* decorative repeats. 500ms keeps 3 frames inside that window.
|
||||
*/
|
||||
export const DECORATIVE_TITLE_FACT_HEARTBEAT_MS = 500
|
||||
|
||||
export type DecorativeTitleFactEmissionInput = {
|
||||
/** The frame's decorative gate key matches the previous frame's. */
|
||||
decorativeOnly: boolean
|
||||
/** Timer-synthesized stale-working clear — carries a flag no repeat can stand in for. */
|
||||
staleWorkingTitleClear: boolean
|
||||
lastEmittedAtMs: number | null
|
||||
nowMs: number
|
||||
}
|
||||
|
||||
export function shouldEmitTitleFactForFrame({
|
||||
decorativeOnly,
|
||||
staleWorkingTitleClear,
|
||||
lastEmittedAtMs,
|
||||
nowMs
|
||||
}: DecorativeTitleFactEmissionInput): boolean {
|
||||
if (!decorativeOnly || staleWorkingTitleClear) {
|
||||
return true
|
||||
}
|
||||
if (lastEmittedAtMs === null) {
|
||||
return true
|
||||
}
|
||||
// A backwards clock step must not park the heartbeat until it catches up.
|
||||
return nowMs < lastEmittedAtMs || nowMs - lastEmittedAtMs >= DECORATIVE_TITLE_FACT_HEARTBEAT_MS
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithEmitDaemonPtyTransientFact } from './orca-runtime-emit-daemon-pty-transient-fact'
|
||||
import { getDecorativeAgentTitleSignature } from '../../shared/agent-decorative-title-signature'
|
||||
import { shouldEmitTitleFactForFrame } from './decorative-title-fact-emission'
|
||||
import type { RuntimePtyTitleTrackerEntry } from './runtime-terminal-state-records'
|
||||
import { createTerminalTitleTracker } from '../../shared/terminal-output-side-effects'
|
||||
import { detectAgentStatusFromTitle } from '../../shared/agent-detection'
|
||||
@@ -64,20 +65,36 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime
|
||||
const tracker = createTerminalTitleTracker(
|
||||
{
|
||||
onTitle: (normalizedTitle, rawTitle, meta) => {
|
||||
this.recordTerminalSideEffectFact(ptyId, {
|
||||
kind: 'title',
|
||||
normalizedTitle,
|
||||
rawTitle,
|
||||
...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {})
|
||||
})
|
||||
const changed = this.applyTrackedPtyTitle(ptyId, rawTitle, normalizedTitle, meta)
|
||||
const identityOnlyTitle = this.isLiveCursorNativeTitle(rawTitle, meta)
|
||||
const live = this.ptyTitleTrackersByPtyId.get(ptyId)
|
||||
const gateKey = this.makeDecorativeTitleGateKey(rawTitle, normalizedTitle)
|
||||
const decorativeOnly = live?.lastMobileTitleGateKey === gateKey
|
||||
if (live) {
|
||||
live.lastMobileTitleGateKey = gateKey
|
||||
}
|
||||
// Why: the same gate the mobile fan-out below already uses, applied one hop earlier —
|
||||
// a spinner frame the renderer store discards should not cost a pty:sideEffect message
|
||||
// at all. See decorative-title-fact-emission.ts for why repeats still heartbeat.
|
||||
const nowMs = Date.now()
|
||||
if (
|
||||
shouldEmitTitleFactForFrame({
|
||||
decorativeOnly,
|
||||
staleWorkingTitleClear: meta?.staleWorkingTitleClear === true,
|
||||
lastEmittedAtMs: live?.lastTitleFactAtMs ?? null,
|
||||
nowMs
|
||||
})
|
||||
) {
|
||||
if (live) {
|
||||
live.lastTitleFactAtMs = nowMs
|
||||
}
|
||||
this.recordTerminalSideEffectFact(ptyId, {
|
||||
kind: 'title',
|
||||
normalizedTitle,
|
||||
rawTitle,
|
||||
...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {})
|
||||
})
|
||||
}
|
||||
const changed = this.applyTrackedPtyTitle(ptyId, rawTitle, normalizedTitle, meta)
|
||||
const identityOnlyTitle = this.isLiveCursorNativeTitle(rawTitle, meta)
|
||||
const tracksReplicatedStatus =
|
||||
live?.applyingChunk === true && this.mobileSessionTabListeners.size > 0
|
||||
const titleStatus = tracksReplicatedStatus ? detectAgentStatusFromTitle(rawTitle) : null
|
||||
@@ -151,6 +168,7 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime
|
||||
tracker,
|
||||
applyingChunk: false,
|
||||
lastMobileTitleGateKey: null,
|
||||
lastTitleFactAtMs: null,
|
||||
chunkTouchedSessionTabs: false,
|
||||
pendingFacts: [],
|
||||
// Why: command-code facts exist only for the pty:sideEffect channel —
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalSideEffectBatch } from '../../../shared/terminal-side-effect-facts'
|
||||
import { syncSinglePty } from '../orca-runtime-test-fixtures.spec'
|
||||
import { createSideEffectRuntime } from '../orca-runtime-test-scenario-builders.spec'
|
||||
import { DECORATIVE_TITLE_FACT_HEARTBEAT_MS } from '../decorative-title-fact-emission'
|
||||
|
||||
// Orca's own synthetic agent spinner: one frame per pane every 80ms while an agent works.
|
||||
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
||||
const SPINNER_INTERVAL_MS = 80
|
||||
const EPOCH = 1_700_000_000_000
|
||||
|
||||
type TitleFact = { kind: 'title'; normalizedTitle: string; rawTitle: string }
|
||||
|
||||
function titleFacts(batches: TerminalSideEffectBatch[]): TitleFact[] {
|
||||
return batches.flatMap((batch) =>
|
||||
batch.facts.filter((fact): fact is TitleFact => fact.kind === 'title')
|
||||
)
|
||||
}
|
||||
|
||||
describe('decorative title fact throttle', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(new Date(EPOCH))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('collapses spinner ticks with an unchanged underlying title to the heartbeat rate', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
const ticks = 125 // 10s of Orca's 80ms synthetic spinner timer
|
||||
for (let tick = 0; tick < ticks; tick += 1) {
|
||||
vi.setSystemTime(new Date(EPOCH + tick * SPINNER_INTERVAL_MS))
|
||||
runtime.ingestSyntheticTitleFrame(
|
||||
'pty-1',
|
||||
`\x1b]0;${SPINNER_FRAMES[tick % SPINNER_FRAMES.length]} Claude Code\x07`
|
||||
)
|
||||
}
|
||||
|
||||
const facts = titleFacts(batches)
|
||||
// Every frame carried the same underlying title, so the renderer learns nothing new past
|
||||
// the heartbeat: 125 pty:sideEffect messages collapse to one per heartbeat window.
|
||||
const elapsedMs = ticks * SPINNER_INTERVAL_MS
|
||||
expect(facts.length).toBeLessThanOrEqual(
|
||||
Math.ceil(elapsedMs / DECORATIVE_TITLE_FACT_HEARTBEAT_MS)
|
||||
)
|
||||
expect(facts.length).toBeLessThan(ticks / 5)
|
||||
// The heartbeat must not thin out below what the renderer's 1500ms hook-done quiet window
|
||||
// needs to cancel a milestone `done` — three working frames per window.
|
||||
expect(facts.length).toBeGreaterThanOrEqual(Math.floor(elapsedMs / 1_500) * 3)
|
||||
for (const fact of facts) {
|
||||
expect(fact.normalizedTitle.endsWith('Claude Code')).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('propagates a real title change on the tick it arrives, mid-heartbeat', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Claude Code\x07')
|
||||
// Two more decorative ticks — still well inside the heartbeat window, so they are dropped.
|
||||
vi.setSystemTime(new Date(EPOCH + SPINNER_INTERVAL_MS))
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠙ Claude Code\x07')
|
||||
vi.setSystemTime(new Date(EPOCH + 2 * SPINNER_INTERVAL_MS))
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠹ Claude Code\x07')
|
||||
expect(titleFacts(batches)).toHaveLength(1)
|
||||
|
||||
const beforeChange = batches.length
|
||||
vi.setSystemTime(new Date(EPOCH + 3 * SPINNER_INTERVAL_MS))
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;✳ Claude Code\x07')
|
||||
|
||||
expect(batches.length).toBeGreaterThan(beforeChange)
|
||||
expect(titleFacts(batches.slice(beforeChange))).toEqual([
|
||||
{ kind: 'title', normalizedTitle: '✳ Claude Code', rawTitle: '✳ Claude Code' }
|
||||
])
|
||||
})
|
||||
|
||||
it('propagates a changed working label immediately even while the spinner rotates', () => {
|
||||
// Why: only the spinner glyph is decoration. Grok/Pi-style label churn is real content.
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Claude Code\x07')
|
||||
vi.setSystemTime(new Date(EPOCH + SPINNER_INTERVAL_MS))
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠙ Reviewing diff — Claude Code\x07')
|
||||
|
||||
expect(titleFacts(batches).map((fact) => fact.rawTitle)).toEqual([
|
||||
'⠋ Claude Code',
|
||||
'⠙ Reviewing diff — Claude Code'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps main-side tracked title state current for every suppressed frame', () => {
|
||||
// Why: mobile/remote snapshots read the tracked record, not the fact stream — suppressing
|
||||
// the fact must not freeze what a phone or a paired client is shown.
|
||||
const { runtime } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Claude Code\x07')
|
||||
vi.setSystemTime(new Date(EPOCH + SPINNER_INTERVAL_MS))
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠙ Claude Code\x07')
|
||||
|
||||
expect(runtime.getTerminalSideEffectSnapshot('pty-1')?.facts).toEqual([
|
||||
{ kind: 'title', normalizedTitle: '⠙ Claude Code', rawTitle: '⠙ Claude Code' }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
syncSinglePty
|
||||
} from '../orca-runtime-test-fixtures.spec'
|
||||
import { createSideEffectRuntime } from '../orca-runtime-test-scenario-builders.spec'
|
||||
import { DECORATIVE_TITLE_FACT_HEARTBEAT_MS } from '../decorative-title-fact-emission'
|
||||
|
||||
describe('terminal side-effect fact channel', () => {
|
||||
it('defers desktop-only output scanners until a headless runtime is promoted', () => {
|
||||
@@ -70,53 +71,66 @@ describe('terminal side-effect fact channel', () => {
|
||||
expect(events).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('bounds decorative title delivery per paired client without reducing local frames', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
const firstClientEvents: RuntimeClientEvent[] = []
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
|
||||
runtime.onClientEvent((event) => firstClientEvents.push(event))
|
||||
it('bounds decorative title delivery per paired client below the local heartbeat', () => {
|
||||
// Why the clock steps: main throttles decorative repeats on the local fact stream, so each
|
||||
// round must clear that heartbeat for the per-client gate to be what collapses them here.
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
try {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
const firstClientEvents: RuntimeClientEvent[] = []
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
|
||||
runtime.onClientEvent((event) => firstClientEvents.push(event))
|
||||
|
||||
const ptyIds = Array.from({ length: 64 }, (_, index) => `pty-remote-${index}`)
|
||||
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
||||
for (const ptyId of ptyIds) {
|
||||
runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frames[0]} Cursor Agent\x07`)
|
||||
}
|
||||
firstClientEvents.length = 0
|
||||
|
||||
for (const frame of frames.slice(1)) {
|
||||
for (const ptyId of ptyIds) {
|
||||
runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frame} Cursor Agent\x07`)
|
||||
const ptyIds = Array.from({ length: 64 }, (_, index) => `pty-remote-${index}`)
|
||||
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
||||
const stepPastHeartbeat = (): void => {
|
||||
vi.setSystemTime(new Date(Date.now() + DECORATIVE_TITLE_FACT_HEARTBEAT_MS))
|
||||
}
|
||||
for (const ptyId of ptyIds) {
|
||||
runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frames[0]} Cursor Agent\x07`)
|
||||
}
|
||||
firstClientEvents.length = 0
|
||||
|
||||
for (const frame of frames.slice(1)) {
|
||||
stepPastHeartbeat()
|
||||
for (const ptyId of ptyIds) {
|
||||
runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frame} Cursor Agent\x07`)
|
||||
}
|
||||
}
|
||||
|
||||
expect(firstClientEvents).toEqual([])
|
||||
expect(batches).toHaveLength(ptyIds.length * frames.length)
|
||||
|
||||
const bellChunk = `\x1b]0;${frames.at(-1)} Cursor Agent\x07\x07`
|
||||
runtime.onPtyData(ptyIds[0], bellChunk, 1)
|
||||
expect(firstClientEvents).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'terminalSideEffects',
|
||||
batch: expect.objectContaining({ facts: [{ kind: 'bell' }] })
|
||||
})
|
||||
])
|
||||
firstClientEvents.length = 0
|
||||
|
||||
const secondClientEvents: RuntimeClientEvent[] = []
|
||||
runtime.onClientEvent((event) => secondClientEvents.push(event))
|
||||
stepPastHeartbeat()
|
||||
for (const ptyId of ptyIds) {
|
||||
runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frames[0]} Cursor Agent\x07`)
|
||||
}
|
||||
|
||||
expect(firstClientEvents).toEqual([])
|
||||
expect(secondClientEvents).toHaveLength(ptyIds.length)
|
||||
|
||||
// A real title change is never throttled — no clock step needed.
|
||||
for (const ptyId of ptyIds) {
|
||||
runtime.ingestSyntheticTitleFrame(ptyId, '\x1b]0;Cursor ready\x07')
|
||||
}
|
||||
expect(firstClientEvents).toHaveLength(ptyIds.length)
|
||||
expect(secondClientEvents).toHaveLength(ptyIds.length * 2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
|
||||
expect(firstClientEvents).toEqual([])
|
||||
expect(batches).toHaveLength(ptyIds.length * frames.length)
|
||||
|
||||
const bellChunk = `\x1b]0;${frames.at(-1)} Cursor Agent\x07\x07`
|
||||
runtime.onPtyData(ptyIds[0], bellChunk, 1)
|
||||
expect(firstClientEvents).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'terminalSideEffects',
|
||||
batch: expect.objectContaining({ facts: [{ kind: 'bell' }] })
|
||||
})
|
||||
])
|
||||
firstClientEvents.length = 0
|
||||
|
||||
const secondClientEvents: RuntimeClientEvent[] = []
|
||||
runtime.onClientEvent((event) => secondClientEvents.push(event))
|
||||
for (const ptyId of ptyIds) {
|
||||
runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frames[0]} Cursor Agent\x07`)
|
||||
}
|
||||
|
||||
expect(firstClientEvents).toEqual([])
|
||||
expect(secondClientEvents).toHaveLength(ptyIds.length)
|
||||
|
||||
for (const ptyId of ptyIds) {
|
||||
runtime.ingestSyntheticTitleFrame(ptyId, '\x1b]0;Cursor ready\x07')
|
||||
}
|
||||
expect(firstClientEvents).toHaveLength(ptyIds.length)
|
||||
expect(secondClientEvents).toHaveLength(ptyIds.length * 2)
|
||||
})
|
||||
|
||||
it('omits terminalSideEffects from non-consuming listeners while other events still flow', () => {
|
||||
|
||||
@@ -30,6 +30,7 @@ await import('./orca-runtime-tests/pty-title-status.spec')
|
||||
await import('./orca-runtime-tests/terminal-side-effect-facts.spec')
|
||||
await import('./orca-runtime-tests/terminal-side-effect-facts-part-02.spec')
|
||||
await import('./orca-runtime-tests/terminal-side-effect-facts-part-03.spec')
|
||||
await import('./orca-runtime-tests/decorative-title-fact-throttle.spec')
|
||||
await import('./orca-runtime-tests/headless-snapshots.spec')
|
||||
await import('./orca-runtime-tests/headless-snapshots-part-02.spec')
|
||||
await import('./orca-runtime-tests/agent-status-and-waits.spec')
|
||||
|
||||
@@ -44,7 +44,13 @@ describe('client UI RPC pairing-local field seams', () => {
|
||||
manualRepoOrder: [
|
||||
{ hostId: 'runtime:web-11111111-2222-3333-4444-555555555555', repoId: 'repo-a' }
|
||||
],
|
||||
workspaceHostOrder: ['runtime:web-11111111-2222-3333-4444-555555555555', 'local']
|
||||
workspaceHostOrder: ['runtime:web-11111111-2222-3333-4444-555555555555', 'local'],
|
||||
agentsVisibleHostIds: ['runtime:web-11111111-2222-3333-4444-555555555555'],
|
||||
agentsFilterRepoIds: ['repo-a'],
|
||||
agentsShowChildAgents: true,
|
||||
agentsCompactMode: false,
|
||||
activityClearedAtByPaneKey: { 'tab-1:leaf-1': 123 },
|
||||
manuallyUnreadTurnsByPaneKey: { 'tab-1:leaf-1': 321 }
|
||||
}
|
||||
|
||||
it.each(PAIRING_LOCAL_UI_FIELDS.map((field) => [field] as const))(
|
||||
|
||||
@@ -122,6 +122,10 @@ const UiUpdateFields = z
|
||||
showInactiveWorkspaces: z.boolean().optional(),
|
||||
workspaceHostScope: z.string().optional(),
|
||||
visibleWorkspaceHostIds: z.array(z.string()).nullable().optional(),
|
||||
agentsVisibleHostIds: z.array(z.string()).nullable().optional(),
|
||||
agentsFilterRepoIds: StringArray.optional(),
|
||||
agentsShowChildAgents: z.boolean().optional(),
|
||||
agentsCompactMode: z.boolean().optional(),
|
||||
workspaceHostOrder: z.array(z.string()).optional(),
|
||||
automationHostFilter: z
|
||||
.union([
|
||||
@@ -171,6 +175,8 @@ const UiUpdateFields = z
|
||||
updateReassuranceSeen: z.boolean().optional(),
|
||||
osc52ClipboardDefaultOnNoticePending: z.boolean().optional(),
|
||||
acknowledgedAgentsByPaneKey: z.record(z.string(), z.number().finite()).optional(),
|
||||
activityClearedAtByPaneKey: z.record(z.string(), z.number().finite()).optional(),
|
||||
manuallyUnreadTurnsByPaneKey: z.record(z.string(), z.number().finite()).optional(),
|
||||
browserDefaultUrl: NullableString.optional(),
|
||||
browserDefaultSearchEngine: z
|
||||
.enum(['google', 'duckduckgo', 'bing', 'kagi'])
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* #12547: `files.listAll` did not declare `maxResults`, so "the client names its cap and a full page
|
||||
* means there is more" was wired only on the Electron IPC hop. Web and mobile were saved incidentally,
|
||||
* by `remoteFileContentBudget` defaulting the cap inside `listRuntimeFiles`.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import type { RpcRequest } from '../core'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { FILE_METHODS } from './files'
|
||||
|
||||
function makeRequest(method: string, params?: unknown): RpcRequest {
|
||||
return { id: 'req-1', authToken: 'tok', method, params }
|
||||
}
|
||||
|
||||
describe('files.listAll page size', () => {
|
||||
// Why #12547: `maxResults` was wired only on the Electron IPC hop, so "a full page means there is
|
||||
// more" was true for a desktop client and incidental for web/mobile. Declaring it here is a new
|
||||
// optional field (wire rule 1): an older host strips it and keeps its own default.
|
||||
it('forwards a client-named page size for a selected worktree', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
listRuntimeFiles: vi.fn().mockResolvedValue(['src/index.ts'])
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('files.listAll', { worktree: 'id:wt-1', maxResults: 20_001 })
|
||||
)
|
||||
|
||||
expect(runtime.listRuntimeFiles).toHaveBeenCalledWith('id:wt-1', {
|
||||
excludePaths: undefined,
|
||||
maxResults: 20_001
|
||||
})
|
||||
expect(response).toMatchObject({ ok: true, result: ['src/index.ts'] })
|
||||
})
|
||||
|
||||
// Why refuse rather than fall back: no released client sends this field, so a malformed value is a
|
||||
// bug in the caller, not skew — the same call `files.search` already makes for its own maxResults.
|
||||
it('refuses a malformed page size instead of silently picking one', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
listRuntimeFiles: vi.fn().mockResolvedValue(['src/index.ts'])
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('files.listAll', { worktree: 'id:wt-1', maxResults: -3 })
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: false })
|
||||
})
|
||||
})
|
||||
@@ -93,8 +93,13 @@ const FileSearch = WorktreeSelector.extend({
|
||||
maxResults: z.number().int().positive().optional()
|
||||
})
|
||||
|
||||
// Why: `maxResults` is a new optional field (wire rule 1) — an older host strips it and keeps its
|
||||
// own default. It existed only on the Electron IPC hop, so "the client names its cap and a full page
|
||||
// means there is more" was true for desktop and merely incidental for web and mobile, which were
|
||||
// saved by `remoteFileContentBudget` defaulting the cap inside `listRuntimeFiles`.
|
||||
const FileListAll = WorktreeSelector.extend({
|
||||
excludePaths: z.array(z.string()).optional()
|
||||
excludePaths: z.array(z.string()).optional(),
|
||||
maxResults: z.number().int().positive().optional()
|
||||
})
|
||||
|
||||
const FileUnwatch = z.object({
|
||||
@@ -236,6 +241,7 @@ export const FILE_METHODS: RpcAnyMethod[] = [
|
||||
const maxContentBytes = remoteFileContentBudget(clientKind, requestId)
|
||||
return runtime.listRuntimeFiles(params.worktree, {
|
||||
excludePaths: params.excludePaths,
|
||||
...(params.maxResults === undefined ? {} : { maxResults: params.maxResults }),
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
...(maxContentBytes === undefined ? {} : { maxContentBytes })
|
||||
})
|
||||
|
||||
@@ -89,6 +89,8 @@ export type RuntimePtyTitleTrackerEntry = {
|
||||
tracker: TerminalTitleTracker
|
||||
applyingChunk: boolean
|
||||
lastMobileTitleGateKey: string | null
|
||||
/** When the last title fact was emitted — throttles decorative-only repeats. */
|
||||
lastTitleFactAtMs: number | null
|
||||
chunkTouchedSessionTabs: boolean
|
||||
pendingFacts: TerminalSideEffectFact[]
|
||||
commandCodeDetector: { observe: (data: string) => boolean } | null
|
||||
|
||||
@@ -183,6 +183,48 @@ describe('startup ordering', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the git-environment barrier off the PTY startup services', () => {
|
||||
const barrierSource = readFileSync(
|
||||
join(process.cwd(), 'src/main/startup/main-process-ipc-bootstrap.ts'),
|
||||
'utf8'
|
||||
)
|
||||
const launchSource = readFileSync(
|
||||
join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'),
|
||||
'utf8'
|
||||
)
|
||||
const gitBarrierStart = barrierSource.indexOf(
|
||||
"ipcMain.handle('app:awaitGitEnvironmentStartupBarrier'"
|
||||
)
|
||||
const gitBarrierEnd = barrierSource.indexOf(
|
||||
"'app:prepareTerminalStartupRestoration'",
|
||||
gitBarrierStart
|
||||
)
|
||||
expect(gitBarrierStart).toBeGreaterThanOrEqual(0)
|
||||
expect(gitBarrierEnd).toBeGreaterThan(gitBarrierStart)
|
||||
const gitBarrier = barrierSource.slice(gitBarrierStart, gitBarrierEnd)
|
||||
// The git environment fence is shell PATH + WSL registration; a daemon PTY provider or a
|
||||
// hook-server bind here puts terminal startup back in front of worktree hydration.
|
||||
expect(gitBarrier).toContain('state.shellPathReady')
|
||||
expect(gitBarrier).toContain('state.managedWslCliStartupBarrierReady')
|
||||
expect(gitBarrier).not.toContain('firstWindowStartupServicesReady')
|
||||
// The published promise must be the same one the terminal startup services wait on.
|
||||
expect(launchSource).toContain('state.shellPathReady = shellPathReady')
|
||||
expect(launchSource.indexOf('state.shellPathReady = shellPathReady')).toBeLessThan(
|
||||
launchSource.indexOf('await launchDesktopMode(')
|
||||
)
|
||||
// Terminal restoration itself must still fence on the first-window services.
|
||||
const restorationStart = barrierSource.indexOf(
|
||||
"ipcMain.handle('app:prepareTerminalStartupRestoration'"
|
||||
)
|
||||
const restorationEnd = barrierSource.indexOf(
|
||||
"'app:recoverLegacyWorkerTerminalsForRendererStartup'",
|
||||
restorationStart
|
||||
)
|
||||
expect(barrierSource.slice(restorationStart, restorationEnd)).toContain(
|
||||
'state.firstWindowStartupServicesReady'
|
||||
)
|
||||
})
|
||||
|
||||
it('reconciles retained Codex homes after authoritative daemon inventory', () => {
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), 'src/main/startup/main-process-pty-startup.ts'),
|
||||
|
||||
@@ -11,6 +11,13 @@ export function registerMainProcessIpcHandlers(): void {
|
||||
state.managedWslCliStartupBarrierReady
|
||||
])
|
||||
})
|
||||
// Why separate from the first-window barrier: host Git needs the shell-PATH
|
||||
// generation and the managed WSL CLI registration, not a daemon PTY provider
|
||||
// or a hook-server bind. Bundling them made worktree hydration wait on a
|
||||
// terminal service it never calls.
|
||||
ipcMain.handle('app:awaitGitEnvironmentStartupBarrier', async () => {
|
||||
await Promise.all([state.shellPathReady, state.managedWslCliStartupBarrierReady])
|
||||
})
|
||||
ipcMain.handle('app:prepareTerminalStartupRestoration', async () => {
|
||||
await Promise.all([
|
||||
state.firstWindowStartupServicesReady,
|
||||
|
||||
@@ -24,6 +24,7 @@ import { shutdownObservability } from '../observability'
|
||||
import { isQuittingForUpdate } from '../updater'
|
||||
import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics'
|
||||
import { stopTccPromptNotice } from '../macos-tcc-prompt-notice'
|
||||
import { cancelHistoryGc } from '../terminal-history-gc'
|
||||
import { shouldQuitWhenAllWindowsClosed } from './window-all-closed-quit-policy'
|
||||
import { mainProcessState as state } from './main-process-state'
|
||||
import { isDevParentShutdownRequested } from './configure-process'
|
||||
@@ -82,6 +83,9 @@ function installBeforeQuitHandler(): void {
|
||||
state.repoMaintenanceShutdown = awaitPackedRefsLockRelease()
|
||||
// Why: defer PTY cleanup to will-quit so the renderer captures scrollback before PTY-exit events unmount TerminalPane (dropping its capture callbacks).
|
||||
state.rateLimits?.stop()
|
||||
// Why safe on a vetoed quit: background history GC is idempotent and re-scheduled next launch,
|
||||
// so abandoning the walk here only costs one deferred sweep, never a half-applied prune.
|
||||
cancelHistoryGc()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -289,6 +289,9 @@ export async function initializeMainProcessRuntimeLaunch(
|
||||
state.serveOptions = serveOptions
|
||||
const runtimeRpc = installRuntimeRpc(runtime, serveOptions)
|
||||
const shellPathReady = shellPathHydration.whenReady()
|
||||
// Why published: the renderer's git-environment barrier must fence on the same
|
||||
// generation the terminal startup services wait for, not a later re-read.
|
||||
state.shellPathReady = shellPathReady
|
||||
let desktopWindow: BrowserWindow | null = null
|
||||
if (process.platform === 'win32' && app.isPackaged && !serveOptions) {
|
||||
const desktopStartup = startWindowsDesktopBeforeShellPathReady({
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup'
|
||||
|
||||
const { removeHostTreeMock } = vi.hoisted(() => ({
|
||||
removeHostTreeMock: vi.fn<(dir: string) => Promise<void>>()
|
||||
}))
|
||||
|
||||
// Why intercept rather than no-op: the tombstone path each prune produces is the decision this
|
||||
// suite reads, but the drain re-queues any tombstone still on disk after a "successful" removal,
|
||||
// so the stub has to really delete or the queue never terminates.
|
||||
vi.mock('./host-tree-removal', () => ({
|
||||
removeHostTree: removeHostTreeMock
|
||||
}))
|
||||
|
||||
import { readHistoryMeta } from './terminal-history'
|
||||
import {
|
||||
cancelPendingHistoryTreeRemovalRetries,
|
||||
flushPendingWorktreeHistoryDeletions
|
||||
} from './terminal-history-deletion'
|
||||
import { cancelHistoryGc, runHistoryGc, scheduleHistoryGc } from './terminal-history-gc'
|
||||
|
||||
const GC_MIN_AGE_MS = 5 * 60 * 1000
|
||||
const PENDING_DELETE_DIR_NAME = '.pending-delete'
|
||||
const LIVE_WORKTREE_ID = 'repo-1::/path/live-wt'
|
||||
const DEAD_WORKTREE_ID = 'repo-1::/path/dead-wt'
|
||||
|
||||
let userDataDir: string
|
||||
let historyRoot: string
|
||||
let originalXdgDataHome: string | undefined
|
||||
|
||||
/**
|
||||
* The enumeration this exercises used to be a synchronous walk. Its replacement is an async
|
||||
* fixed-worker pass, so the whole safety net is that both reach the same prune decision over a
|
||||
* realistic tree: over-pruning here destroys scrollback the user still expects to have.
|
||||
*
|
||||
* A verbatim port of the pre-change decision logic, reporting names instead of deleting.
|
||||
*/
|
||||
function referenceSyncPruneDecisions(root: string, liveWorktreeIds: Set<string>): string[] {
|
||||
const decisions: string[] = []
|
||||
if (!existsSync(root)) {
|
||||
return decisions
|
||||
}
|
||||
const now = Date.now()
|
||||
for (const entry of readdirSync(root)) {
|
||||
if (entry === PENDING_DELETE_DIR_NAME) {
|
||||
continue
|
||||
}
|
||||
const entryPath = join(root, entry)
|
||||
try {
|
||||
const stats = statSync(entryPath)
|
||||
if (!stats.isDirectory()) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
for (const file of readdirSync(entryPath)) {
|
||||
statSync(join(entryPath, file))
|
||||
}
|
||||
} catch {
|
||||
// Skip size estimation on error.
|
||||
}
|
||||
if (!existsSync(join(entryPath, 'meta.json'))) {
|
||||
continue
|
||||
}
|
||||
const meta = readHistoryMeta(entryPath)
|
||||
if (!meta?.worktreeId) {
|
||||
continue
|
||||
}
|
||||
if (!liveWorktreeIds.has(meta.worktreeId)) {
|
||||
if (meta.createdAt && now - new Date(meta.createdAt).getTime() < GC_MIN_AGE_MS) {
|
||||
continue
|
||||
}
|
||||
decisions.push(entry)
|
||||
}
|
||||
} catch {
|
||||
// Skip individual entries that fail.
|
||||
}
|
||||
}
|
||||
return decisions
|
||||
}
|
||||
|
||||
function seedDir(name: string, files: Record<string, string>): string {
|
||||
const dir = join(historyRoot, name)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
for (const [file, contents] of Object.entries(files)) {
|
||||
writeFileSync(join(dir, file), contents)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
function meta(worktreeId: string | undefined, ageMs: number | null): string {
|
||||
return JSON.stringify({
|
||||
...(worktreeId === undefined ? {} : { worktreeId }),
|
||||
...(ageMs === null ? {} : { createdAt: new Date(Date.now() - ageMs).toISOString() })
|
||||
})
|
||||
}
|
||||
|
||||
const OLD = GC_MIN_AGE_MS * 2
|
||||
|
||||
/** Every decision shape the walk has to get right, including the ones that must never prune. */
|
||||
function seedDecisionMatrix(): void {
|
||||
seedDir('live-old', { 'meta.json': meta(LIVE_WORKTREE_ID, OLD), zsh_history: 'a' })
|
||||
seedDir('live-young', { 'meta.json': meta(LIVE_WORKTREE_ID, 0) })
|
||||
seedDir('orphan-old', { 'meta.json': meta(DEAD_WORKTREE_ID, OLD), zsh_history: 'b' })
|
||||
seedDir('orphan-no-createdat', { 'meta.json': meta(DEAD_WORKTREE_ID, null) })
|
||||
seedDir('orphan-unparseable-createdat', {
|
||||
'meta.json': JSON.stringify({ worktreeId: DEAD_WORKTREE_ID, createdAt: 'not-a-date' })
|
||||
})
|
||||
seedDir('orphan-young', { 'meta.json': meta(DEAD_WORKTREE_ID, 1_000) })
|
||||
seedDir('no-meta', { zsh_history: 'c' })
|
||||
seedDir('malformed-meta', { 'meta.json': '{ this is not json' })
|
||||
seedDir('truncated-meta', { 'meta.json': `{"worktreeId":"${DEAD_WORKTREE_ID}` })
|
||||
seedDir('empty-meta', { 'meta.json': '{}' })
|
||||
seedDir('array-meta', { 'meta.json': `["${DEAD_WORKTREE_ID}"]` })
|
||||
seedDir('null-meta', { 'meta.json': 'null' })
|
||||
seedDir('no-worktree-id', { 'meta.json': meta(undefined, OLD) })
|
||||
seedDir('oversize-meta', {
|
||||
'meta.json': JSON.stringify({
|
||||
worktreeId: DEAD_WORKTREE_ID,
|
||||
createdAt: new Date(Date.now() - OLD).toISOString(),
|
||||
pad: 'x'.repeat(64 * 1024)
|
||||
})
|
||||
})
|
||||
// meta.json as a directory: stat succeeds, the read does not.
|
||||
mkdirSync(join(historyRoot, 'meta-is-a-dir', 'meta.json'), { recursive: true })
|
||||
seedDir('empty-dir', {})
|
||||
// A plain file at the root is not a history directory.
|
||||
writeFileSync(join(historyRoot, 'stray-file'), 'x')
|
||||
mkdirSync(join(historyRoot, PENDING_DELETE_DIR_NAME), { recursive: true })
|
||||
}
|
||||
|
||||
/** Enough entries to run several worker batches and cross the cooperative-yield boundary. */
|
||||
function seedBulk(count: number, orphanEvery: number): void {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const orphan = i % orphanEvery === 0
|
||||
seedDir(`bulk-${i}`, {
|
||||
'meta.json': meta(orphan ? `${DEAD_WORKTREE_ID}-${i}` : LIVE_WORKTREE_ID, OLD),
|
||||
zsh_history: `entry-${i}`,
|
||||
bash_history: `entry-${i}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function survivingDirs(): Set<string> {
|
||||
return new Set(
|
||||
readdirSync(historyRoot).filter(
|
||||
(entry) =>
|
||||
entry !== PENDING_DELETE_DIR_NAME && statSync(join(historyRoot, entry)).isDirectory()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
userDataDir = mkdtempSync(join(tmpdir(), 'orca-history-gc-'))
|
||||
historyRoot = join(userDataDir, 'terminal-history')
|
||||
mkdirSync(historyRoot, { recursive: true })
|
||||
installFakeAppEnvironment({ getPath: () => userDataDir })
|
||||
// Why: the fish sweep resolves a real user data dir otherwise, and would delete the
|
||||
// developer's own orca fish history files while this suite runs.
|
||||
originalXdgDataHome = process.env.XDG_DATA_HOME
|
||||
process.env.XDG_DATA_HOME = userDataDir
|
||||
removeHostTreeMock.mockReset()
|
||||
removeHostTreeMock.mockImplementation(async (dir) => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
|
||||
/** Tombstone paths the pass condemned, with the `.<timestamp>.<rand>` rename suffix stripped. */
|
||||
function tombstonedNames(): Set<string> {
|
||||
return new Set(
|
||||
removeHostTreeMock.mock.calls.map(([dir]) => basename(dir).split('.').slice(0, -2).join('.'))
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
cancelHistoryGc()
|
||||
vi.useRealTimers()
|
||||
await flushPendingWorktreeHistoryDeletions()
|
||||
cancelPendingHistoryTreeRemovalRetries()
|
||||
if (originalXdgDataHome === undefined) {
|
||||
delete process.env.XDG_DATA_HOME
|
||||
} else {
|
||||
process.env.XDG_DATA_HOME = originalXdgDataHome
|
||||
}
|
||||
rmSync(userDataDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('history GC prune decisions', () => {
|
||||
it('prunes exactly the set the synchronous walk chose', async () => {
|
||||
seedDecisionMatrix()
|
||||
seedBulk(200, 7)
|
||||
const live = new Set([LIVE_WORKTREE_ID])
|
||||
|
||||
const before = survivingDirs()
|
||||
const expected = new Set(referenceSyncPruneDecisions(historyRoot, live))
|
||||
|
||||
await runHistoryGc(live)
|
||||
|
||||
const after = survivingDirs()
|
||||
const actual = new Set([...before].filter((entry) => !after.has(entry)))
|
||||
|
||||
expect(expected.size).toBeGreaterThan(0)
|
||||
expect([...actual].sort()).toEqual([...expected].sort())
|
||||
})
|
||||
|
||||
it('keeps every directory whose ownership cannot be established', async () => {
|
||||
seedDecisionMatrix()
|
||||
|
||||
await runHistoryGc(new Set([LIVE_WORKTREE_ID]))
|
||||
|
||||
const after = survivingDirs()
|
||||
for (const kept of [
|
||||
'live-old',
|
||||
'live-young',
|
||||
'orphan-young',
|
||||
'no-meta',
|
||||
'malformed-meta',
|
||||
'truncated-meta',
|
||||
'empty-meta',
|
||||
'array-meta',
|
||||
'null-meta',
|
||||
'no-worktree-id',
|
||||
'oversize-meta',
|
||||
'meta-is-a-dir',
|
||||
'empty-dir'
|
||||
]) {
|
||||
expect(after.has(kept)).toBe(true)
|
||||
}
|
||||
expect(after.has('orphan-old')).toBe(false)
|
||||
expect(after.has('orphan-no-createdat')).toBe(false)
|
||||
expect(after.has('orphan-unparseable-createdat')).toBe(false)
|
||||
expect(existsSync(join(historyRoot, 'stray-file'))).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses to prune anything when the live set is empty', async () => {
|
||||
seedDecisionMatrix()
|
||||
|
||||
await runHistoryGc(new Set())
|
||||
|
||||
expect(survivingDirs().has('orphan-old')).toBe(true)
|
||||
expect(readdirSync(join(historyRoot, PENDING_DELETE_DIR_NAME))).toEqual([])
|
||||
})
|
||||
|
||||
it('does not throw when the history root does not exist', async () => {
|
||||
rmSync(historyRoot, { recursive: true, force: true })
|
||||
await expect(runHistoryGc(new Set([LIVE_WORKTREE_ID]))).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('tombstones orphans instead of removing them on the calling thread', async () => {
|
||||
seedDecisionMatrix()
|
||||
|
||||
await runHistoryGc(new Set([LIVE_WORKTREE_ID]))
|
||||
|
||||
// The recursive rm only ever sees a path already renamed into the tombstone queue.
|
||||
for (const [dir] of removeHostTreeMock.mock.calls) {
|
||||
expect(dir).toContain(PENDING_DELETE_DIR_NAME)
|
||||
}
|
||||
expect(tombstonedNames().has('orphan-old')).toBe(true)
|
||||
})
|
||||
|
||||
it('drains pre-existing tombstones without scanning them as worktrees', async () => {
|
||||
seedDecisionMatrix()
|
||||
const leftover = join(historyRoot, PENDING_DELETE_DIR_NAME, 'abc123.1700000000000.deadbeef')
|
||||
mkdirSync(leftover, { recursive: true })
|
||||
|
||||
await runHistoryGc(new Set([LIVE_WORKTREE_ID]))
|
||||
|
||||
expect(removeHostTreeMock).toHaveBeenCalledWith(expect.stringContaining('abc123.1700000000000'))
|
||||
})
|
||||
|
||||
it('continues the pass after one orphan tombstone fails', async () => {
|
||||
seedDir('orphan-a', { 'meta.json': meta(`${DEAD_WORKTREE_ID}-a`, OLD) })
|
||||
seedDir('orphan-b', { 'meta.json': meta(`${DEAD_WORKTREE_ID}-b`, OLD) })
|
||||
// A file where the tombstone root must be makes the first rename fail; mkdir cannot replace it.
|
||||
writeFileSync(join(historyRoot, PENDING_DELETE_DIR_NAME), 'not a directory')
|
||||
|
||||
await expect(runHistoryGc(new Set([LIVE_WORKTREE_ID]))).resolves.toBeUndefined()
|
||||
|
||||
// Nothing could be tombstoned, and both entries survive for a later pass to reclaim.
|
||||
expect(survivingDirs()).toEqual(new Set(['orphan-a', 'orphan-b']))
|
||||
})
|
||||
})
|
||||
|
||||
describe('history GC concurrency behaviour', () => {
|
||||
it('joins a second call to the in-flight pass instead of walking twice', async () => {
|
||||
seedDecisionMatrix()
|
||||
const live = new Set([LIVE_WORKTREE_ID])
|
||||
|
||||
const first = runHistoryGc(live)
|
||||
const second = runHistoryGc(live)
|
||||
expect(second).toBe(first)
|
||||
|
||||
await first
|
||||
// Each rename produces its own tombstone, so a second overlapping walk would condemn twice.
|
||||
const orphanRemovals = removeHostTreeMock.mock.calls.filter(([dir]) =>
|
||||
basename(dir).startsWith('orphan-old.')
|
||||
)
|
||||
expect(orphanRemovals).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('starts a fresh pass once the previous one has settled', async () => {
|
||||
seedDecisionMatrix()
|
||||
const live = new Set([LIVE_WORKTREE_ID])
|
||||
await runHistoryGc(live)
|
||||
const second = runHistoryGc(live)
|
||||
await expect(second).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('stops an in-flight walk on cancel without pruning', async () => {
|
||||
seedDecisionMatrix()
|
||||
seedBulk(300, 3)
|
||||
const before = survivingDirs()
|
||||
|
||||
const pass = runHistoryGc(new Set([LIVE_WORKTREE_ID]))
|
||||
// Cancelling before the root listing resolves means no entry is ever visited.
|
||||
cancelHistoryGc()
|
||||
await pass
|
||||
|
||||
expect(survivingDirs()).toEqual(before)
|
||||
})
|
||||
|
||||
it('does not run a scheduled pass that was cancelled while resolving live worktrees', async () => {
|
||||
seedDecisionMatrix()
|
||||
vi.useFakeTimers()
|
||||
let resolveLiveIds: (ids: Set<string>) => void = () => {}
|
||||
scheduleHistoryGc(
|
||||
() =>
|
||||
new Promise<Set<string>>((resolve) => {
|
||||
resolveLiveIds = resolve
|
||||
})
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
cancelHistoryGc()
|
||||
resolveLiveIds(new Set([LIVE_WORKTREE_ID]))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(survivingDirs().has('orphan-old')).toBe(true)
|
||||
})
|
||||
|
||||
it('coalesces duplicate scheduled startup GC calls', async () => {
|
||||
vi.useFakeTimers()
|
||||
const getLiveWorktreeIds = vi.fn().mockResolvedValue(new Set<string>())
|
||||
|
||||
scheduleHistoryGc(getLiveWorktreeIds)
|
||||
scheduleHistoryGc(getLiveWorktreeIds)
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
expect(getLiveWorktreeIds).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('history GC races an async walk introduces', () => {
|
||||
it('survives a directory removed while the walk is in flight', async () => {
|
||||
seedDecisionMatrix()
|
||||
seedBulk(300, 5)
|
||||
const live = new Set([LIVE_WORKTREE_ID])
|
||||
const vanishing = ['bulk-11', 'bulk-77', 'bulk-201']
|
||||
|
||||
const pass = runHistoryGc(live)
|
||||
for (const name of vanishing) {
|
||||
rmSync(join(historyRoot, name), { recursive: true, force: true })
|
||||
}
|
||||
await expect(pass).resolves.toBeUndefined()
|
||||
|
||||
// Every live directory the racer did not touch is still there.
|
||||
expect(survivingDirs().has('live-old')).toBe(true)
|
||||
expect(survivingDirs().has('bulk-1')).toBe(true)
|
||||
for (const name of vanishing) {
|
||||
expect(existsSync(join(historyRoot, name))).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('never prunes a directory whose meta.json is half-written when the walk reads it', async () => {
|
||||
seedDir('being-written', {})
|
||||
seedBulk(200, 5)
|
||||
const live = new Set([LIVE_WORKTREE_ID])
|
||||
|
||||
const pass = runHistoryGc(live)
|
||||
writeFileSync(
|
||||
join(historyRoot, 'being-written', 'meta.json'),
|
||||
`{"worktreeId":"${DEAD_WORKTREE_ID}","created`
|
||||
)
|
||||
await pass
|
||||
|
||||
expect(survivingDirs().has('being-written')).toBe(true)
|
||||
})
|
||||
|
||||
it('prunes a directory whose meta.json arrived after the directory did', async () => {
|
||||
seedDir('late-meta', {})
|
||||
writeFileSync(
|
||||
join(historyRoot, 'late-meta', 'meta.json'),
|
||||
meta(`${DEAD_WORKTREE_ID}-late`, OLD)
|
||||
)
|
||||
|
||||
await runHistoryGc(new Set([LIVE_WORKTREE_ID]))
|
||||
|
||||
expect(survivingDirs().has('late-meta')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('history GC main-thread occupancy', () => {
|
||||
it('yields to timers throughout the walk instead of blocking on it', async () => {
|
||||
seedBulk(1_200, 40)
|
||||
const live = new Set([LIVE_WORKTREE_ID])
|
||||
|
||||
const ticks = { sync: 0, async: 0 }
|
||||
let maxAsyncGapMs = 0
|
||||
|
||||
// The pre-change walk is the control: a synchronous pass over the same tree cannot tick at all.
|
||||
const syncTimer = setInterval(() => {
|
||||
ticks.sync += 1
|
||||
}, 4)
|
||||
referenceSyncPruneDecisions(historyRoot, live)
|
||||
clearInterval(syncTimer)
|
||||
|
||||
let last = performance.now()
|
||||
const asyncTimer = setInterval(() => {
|
||||
const now = performance.now()
|
||||
maxAsyncGapMs = Math.max(maxAsyncGapMs, now - last - 4)
|
||||
last = now
|
||||
ticks.async += 1
|
||||
}, 4)
|
||||
await runHistoryGc(live)
|
||||
clearInterval(asyncTimer)
|
||||
|
||||
// A synchronous pass cannot tick at all, however long it takes.
|
||||
expect(ticks.sync).toBe(0)
|
||||
expect(ticks.async).toBeGreaterThan(5)
|
||||
// Generous because shared CI runners stall an idle timer by tens of ms on their own; the
|
||||
// failure this guards against is a whole-walk block, which is seconds.
|
||||
expect(maxAsyncGapMs).toBeLessThan(2_000)
|
||||
})
|
||||
})
|
||||
+160
-80
@@ -1,5 +1,5 @@
|
||||
import { join } from 'node:path'
|
||||
import { existsSync, readdirSync, statSync } from 'node:fs'
|
||||
import { readdir, stat } from 'node:fs/promises'
|
||||
import {
|
||||
getHistoryRoot,
|
||||
listWslHistoryRoots,
|
||||
@@ -9,9 +9,11 @@ import {
|
||||
schedulePendingHistoryTreeRemovals,
|
||||
scheduleWorktreeHistoryTreeDeletion
|
||||
} from './terminal-history-deletion'
|
||||
import { readHistoryMeta } from './terminal-history'
|
||||
import { readHistoryMetaAsync } from './terminal-history'
|
||||
import { resolveFishHistoryDir, sweepOrphanedFishHistoryFiles } from './fish-history-session'
|
||||
import { hashWorktreeId } from './terminal-history-id'
|
||||
import { forEachWithConcurrency } from '../shared/map-with-concurrency'
|
||||
import { yieldToEventLoop } from '../shared/event-loop-yield'
|
||||
|
||||
// Why 5 minutes: GC runs ~10s after startup, and the live-worktree snapshot is
|
||||
// taken just before. A worktree created between the snapshot and GC execution
|
||||
@@ -20,100 +22,136 @@ import { hashWorktreeId } from './terminal-history-id'
|
||||
// to cover any realistic snapshot-to-scan delay.
|
||||
const GC_MIN_AGE_MS = 5 * 60 * 1000
|
||||
|
||||
let scheduledHistoryGcTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let historyGcRunning = false
|
||||
// Why a fixed worker pool over a frontier and not per-entry promise fan-out: a real
|
||||
// history root holds thousands of directories, and starting every one at once queues
|
||||
// tens of thousands of libuv requests before the first completes. 16 is deep enough to
|
||||
// keep the default 4-thread pool saturated without monopolising the disk during startup.
|
||||
const HISTORY_GC_SCAN_CONCURRENCY = 16
|
||||
// Why yield at all when every step already awaits I/O: a fully cached root resolves each
|
||||
// await in a microtask, which never returns to the macrotask queue. This bounds that run.
|
||||
const HISTORY_GC_YIELD_EVERY = 32
|
||||
|
||||
/** Scan a single history root directory, pruning orphaned entries.
|
||||
* Returns { totalDirs, orphaned, pruned, totalSizeKB }. */
|
||||
function gcScanRoot(
|
||||
root: string,
|
||||
liveWorktreeIds: Set<string>
|
||||
): {
|
||||
let scheduledHistoryGcTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let historyGcStarting = false
|
||||
let historyGcCancelled = false
|
||||
let activeHistoryGc: Promise<void> | null = null
|
||||
let activeHistoryGcAbort: AbortController | null = null
|
||||
|
||||
type GcRootScan = {
|
||||
totalDirs: number
|
||||
orphaned: number
|
||||
pruned: number
|
||||
totalSizeKB: number
|
||||
/** Every fish data dir a meta.json in this root names, for the orphan sweep. */
|
||||
fishHistoryDirs: Set<string>
|
||||
} {
|
||||
const result = {
|
||||
}
|
||||
|
||||
/** Inspect one history directory, tombstoning it when its worktree is gone. */
|
||||
async function gcScanEntry(
|
||||
root: string,
|
||||
entry: string,
|
||||
liveWorktreeIds: Set<string>,
|
||||
now: number,
|
||||
result: GcRootScan
|
||||
): Promise<void> {
|
||||
const entryPath = join(root, entry)
|
||||
try {
|
||||
const stats = await stat(entryPath)
|
||||
if (!stats.isDirectory()) {
|
||||
return
|
||||
}
|
||||
result.totalDirs++
|
||||
|
||||
// Estimate directory size from meta.json + history files.
|
||||
// Why a local accumulator: `result.totalSizeKB += <expression containing await>`
|
||||
// reads the field before suspending and writes back a stale sum once workers interleave.
|
||||
let dirSizeKB = 0
|
||||
try {
|
||||
for (const file of await readdir(entryPath)) {
|
||||
dirSizeKB += Math.ceil((await stat(join(entryPath, file))).size / 1024)
|
||||
}
|
||||
} catch {
|
||||
// Skip size estimation on error, keeping whatever was measured first.
|
||||
}
|
||||
result.totalSizeKB += dirSizeKB
|
||||
|
||||
// A missing, truncated, oversized or malformed meta.json reads back as null, and a
|
||||
// null meta is never pruned — an entry whose ownership we cannot establish is kept.
|
||||
const meta = await readHistoryMetaAsync(entryPath)
|
||||
if (meta?.fishHistoryDir) {
|
||||
result.fishHistoryDirs.add(meta.fishHistoryDir)
|
||||
}
|
||||
if (!meta?.worktreeId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!liveWorktreeIds.has(meta.worktreeId)) {
|
||||
// Why: avoid a TOCTOU race where a worktree is created after the
|
||||
// live-ID snapshot but before GC runs. Directories younger than
|
||||
// GC_MIN_AGE_MS are presumed still live and skipped.
|
||||
if (meta.createdAt) {
|
||||
const ageMs = now - new Date(meta.createdAt).getTime()
|
||||
if (ageMs < GC_MIN_AGE_MS) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
result.orphaned++
|
||||
// Why: a large orphaned tree recursive-rm'd here would stall the main process ~10s after
|
||||
// launch — the same freeze the explicit-delete path already tombstones its way out of.
|
||||
if (scheduleWorktreeHistoryTreeDeletion(entryPath, root)) {
|
||||
result.pruned++
|
||||
console.log(`[pty:history:gc] Pruned orphaned history: ${meta.worktreeId}`)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip individual entries that fail.
|
||||
}
|
||||
}
|
||||
|
||||
/** Scan a single history root directory, pruning orphaned entries. */
|
||||
async function gcScanRoot(
|
||||
root: string,
|
||||
liveWorktreeIds: Set<string>,
|
||||
signal: AbortSignal
|
||||
): Promise<GcRootScan> {
|
||||
const result: GcRootScan = {
|
||||
totalDirs: 0,
|
||||
orphaned: 0,
|
||||
pruned: 0,
|
||||
totalSizeKB: 0,
|
||||
fishHistoryDirs: new Set<string>()
|
||||
}
|
||||
if (!existsSync(root)) {
|
||||
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = await readdir(root)
|
||||
} catch {
|
||||
// Absent or unreadable root: nothing to collect.
|
||||
return result
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
// Why: pending-delete is a tombstone queue drained asynchronously, not a live worktree hash.
|
||||
const frontier = entries.filter((entry) => entry !== PENDING_DELETE_DIR_NAME)
|
||||
|
||||
for (const entry of readdirSync(root)) {
|
||||
// Why: pending-delete is a tombstone queue drained asynchronously, not a live worktree hash.
|
||||
if (entry === PENDING_DELETE_DIR_NAME) {
|
||||
continue
|
||||
await forEachWithConcurrency(
|
||||
frontier,
|
||||
HISTORY_GC_SCAN_CONCURRENCY,
|
||||
async (entry, index): Promise<void> => {
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
await gcScanEntry(root, entry, liveWorktreeIds, now, result)
|
||||
if (index % HISTORY_GC_YIELD_EVERY === HISTORY_GC_YIELD_EVERY - 1) {
|
||||
await yieldToEventLoop()
|
||||
}
|
||||
}
|
||||
const entryPath = join(root, entry)
|
||||
try {
|
||||
const stat = statSync(entryPath)
|
||||
if (!stat.isDirectory()) {
|
||||
continue
|
||||
}
|
||||
result.totalDirs++
|
||||
|
||||
// Estimate directory size from meta.json + history files.
|
||||
try {
|
||||
for (const file of readdirSync(entryPath)) {
|
||||
result.totalSizeKB += Math.ceil(statSync(join(entryPath, file)).size / 1024)
|
||||
}
|
||||
} catch {
|
||||
// Skip size estimation on error.
|
||||
}
|
||||
|
||||
const metaPath = join(entryPath, 'meta.json')
|
||||
if (!existsSync(metaPath)) {
|
||||
// No meta.json — can't determine ownership, skip.
|
||||
continue
|
||||
}
|
||||
|
||||
const meta = readHistoryMeta(entryPath)
|
||||
if (meta?.fishHistoryDir) {
|
||||
result.fishHistoryDirs.add(meta.fishHistoryDir)
|
||||
}
|
||||
if (!meta?.worktreeId) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!liveWorktreeIds.has(meta.worktreeId)) {
|
||||
// Why: avoid a TOCTOU race where a worktree is created after the
|
||||
// live-ID snapshot but before GC runs. Directories younger than
|
||||
// GC_MIN_AGE_MS are presumed still live and skipped.
|
||||
if (meta.createdAt) {
|
||||
const ageMs = now - new Date(meta.createdAt).getTime()
|
||||
if (ageMs < GC_MIN_AGE_MS) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
result.orphaned++
|
||||
// Why: a large orphaned tree recursive-rm'd here would stall the main process ~10s after
|
||||
// launch — the same freeze the explicit-delete path already tombstones its way out of.
|
||||
if (scheduleWorktreeHistoryTreeDeletion(entryPath, root)) {
|
||||
result.pruned++
|
||||
console.log(`[pty:history:gc] Pruned orphaned history: ${meta.worktreeId}`)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip individual entries that fail.
|
||||
}
|
||||
}
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
/** Run background GC to prune history directories for worktrees that are no
|
||||
* longer in Orca's known live-worktree set. */
|
||||
export function runHistoryGc(liveWorktreeIds: Set<string>): void {
|
||||
async function executeHistoryGc(liveWorktreeIds: Set<string>, signal: AbortSignal): Promise<void> {
|
||||
try {
|
||||
// Why: finish tombstones left by quit mid-rm before scanning live worktree hashes.
|
||||
// Safe ahead of the guard below: these entries were already condemned by a
|
||||
@@ -130,14 +168,17 @@ export function runHistoryGc(liveWorktreeIds: Set<string>): void {
|
||||
console.log('[pty:history:gc] Skipped: live worktree set is empty')
|
||||
return
|
||||
}
|
||||
const main = gcScanRoot(getHistoryRoot(), liveWorktreeIds)
|
||||
const main = await gcScanRoot(getHistoryRoot(), liveWorktreeIds, signal)
|
||||
|
||||
// Also scan WSL history directories (each distro has its own subdirectory).
|
||||
const wslTotals = { totalDirs: 0, orphaned: 0, pruned: 0, totalSizeKB: 0 }
|
||||
const liveFishHistoryDirs = new Set(main.fishHistoryDirs)
|
||||
for (const distroRoot of listWslHistoryRoots()) {
|
||||
if (signal.aborted) {
|
||||
break
|
||||
}
|
||||
schedulePendingHistoryTreeRemovals(distroRoot)
|
||||
const r = gcScanRoot(distroRoot, liveWorktreeIds)
|
||||
const r = await gcScanRoot(distroRoot, liveWorktreeIds, signal)
|
||||
wslTotals.totalDirs += r.totalDirs
|
||||
wslTotals.orphaned += r.orphaned
|
||||
wslTotals.pruned += r.pruned
|
||||
@@ -147,6 +188,11 @@ export function runHistoryGc(liveWorktreeIds: Set<string>): void {
|
||||
}
|
||||
}
|
||||
|
||||
if (signal.aborted) {
|
||||
console.log('[pty:history:gc] Cancelled mid-scan')
|
||||
return
|
||||
}
|
||||
|
||||
// Why a sweep on top of per-worktree deletion: a fish history file lives in
|
||||
// the user's fish data dir, so it outlives the directory that names it. A
|
||||
// crash between tombstone and removal, or a hand-deleted history dir, leaves
|
||||
@@ -178,28 +224,62 @@ export function runHistoryGc(liveWorktreeIds: Set<string>): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Run background GC to prune history directories for worktrees that are no
|
||||
* longer in Orca's known live-worktree set. Resolves when the pass finishes. */
|
||||
export function runHistoryGc(liveWorktreeIds: Set<string>): Promise<void> {
|
||||
// Why join instead of starting a second pass: two walks would race each other's
|
||||
// tombstone renames, and the loser's `scheduleWorktreeHistoryTreeDeletion` would
|
||||
// report a failure for a directory the winner already condemned.
|
||||
if (activeHistoryGc) {
|
||||
return activeHistoryGc
|
||||
}
|
||||
const controller = new AbortController()
|
||||
activeHistoryGcAbort = controller
|
||||
activeHistoryGc = executeHistoryGc(liveWorktreeIds, controller.signal).finally(() => {
|
||||
activeHistoryGc = null
|
||||
activeHistoryGcAbort = null
|
||||
})
|
||||
return activeHistoryGc
|
||||
}
|
||||
|
||||
/** Drop a pending GC and stop an in-flight walk at its next entry. */
|
||||
export function cancelHistoryGc(): void {
|
||||
if (scheduledHistoryGcTimer !== null) {
|
||||
clearTimeout(scheduledHistoryGcTimer)
|
||||
scheduledHistoryGcTimer = null
|
||||
}
|
||||
// Why a flag as well: the timer has already fired while the live-worktree lookup is
|
||||
// in flight, and there is no controller to abort until the scan itself starts.
|
||||
historyGcCancelled = true
|
||||
activeHistoryGcAbort?.abort()
|
||||
}
|
||||
|
||||
/** Schedule GC after a delay so it runs after workspace hydration completes.
|
||||
* `getLiveWorktreeIds` should use already-known IDs, not probe repo paths. */
|
||||
export function scheduleHistoryGc(getLiveWorktreeIds: () => Promise<Set<string>>): void {
|
||||
// Why: main-window services can reattach during reload/reactivation; one
|
||||
// pending/running disk GC is enough and avoids duplicate startup I/O.
|
||||
if (scheduledHistoryGcTimer !== null || historyGcRunning) {
|
||||
if (scheduledHistoryGcTimer !== null || historyGcStarting || activeHistoryGc !== null) {
|
||||
return
|
||||
}
|
||||
historyGcCancelled = false
|
||||
// Why 10s: avoids competing with startup-critical I/O while still running
|
||||
// early enough to clean up before the user notices disk usage (§7.6).
|
||||
scheduledHistoryGcTimer = setTimeout(async () => {
|
||||
scheduledHistoryGcTimer = null
|
||||
historyGcRunning = true
|
||||
historyGcStarting = true
|
||||
try {
|
||||
const liveIds = await getLiveWorktreeIds()
|
||||
runHistoryGc(liveIds)
|
||||
if (historyGcCancelled) {
|
||||
return
|
||||
}
|
||||
await runHistoryGc(liveIds)
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[pty:history:gc] Failed to enumerate live worktrees for GC: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
} finally {
|
||||
historyGcRunning = false
|
||||
historyGcStarting = false
|
||||
}
|
||||
}, 10_000)
|
||||
}
|
||||
|
||||
@@ -96,8 +96,6 @@ import {
|
||||
flushPendingWorktreeHistoryDeletions
|
||||
} from './terminal-history-deletion'
|
||||
|
||||
import { runHistoryGc, scheduleHistoryGc } from './terminal-history-gc'
|
||||
|
||||
const OTHER_WORKTREE_HASH = hashWorktreeId('repo-1::/path/other-wt')
|
||||
|
||||
describe('terminal-history', () => {
|
||||
@@ -688,179 +686,6 @@ describe('terminal-history', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('runHistoryGc', () => {
|
||||
it('coalesces duplicate scheduled startup GC calls', async () => {
|
||||
vi.useFakeTimers()
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
const getLiveWorktreeIds = vi.fn().mockResolvedValue(new Set<string>())
|
||||
|
||||
scheduleHistoryGc(getLiveWorktreeIds)
|
||||
scheduleHistoryGc(getLiveWorktreeIds)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
expect(getLiveWorktreeIds).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('prunes orphaned directories', () => {
|
||||
existsSyncMock.mockImplementation((p: string) => {
|
||||
// WSL root doesn't exist, so GC skips it
|
||||
if (p.includes('terminal-history-wsl')) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
readdirSyncMock.mockImplementation((dir: string) => {
|
||||
if (dir.endsWith('terminal-history')) {
|
||||
return ['dir1', 'dir2']
|
||||
}
|
||||
return ['meta.json']
|
||||
})
|
||||
statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 })
|
||||
readFileSyncMock.mockImplementation((p: string) => {
|
||||
// Use a createdAt old enough to pass the GC age threshold
|
||||
const oldDate = new Date(Date.now() - 10 * 60 * 1000).toISOString()
|
||||
if (p.includes('dir1')) {
|
||||
return JSON.stringify({ worktreeId: 'live-wt', createdAt: oldDate })
|
||||
}
|
||||
return JSON.stringify({ worktreeId: 'dead-wt', createdAt: oldDate })
|
||||
})
|
||||
|
||||
const liveIds = new Set(['live-wt'])
|
||||
runHistoryGc(liveIds)
|
||||
|
||||
// Should only prune dir2 (dead-wt), not dir1 (live-wt), and never recursive-rm on the main thread.
|
||||
expect(rmSyncMock).not.toHaveBeenCalled()
|
||||
expect(renameSyncMock).toHaveBeenCalledTimes(1)
|
||||
expect(renameSyncMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('dir2'),
|
||||
expect.stringContaining(`.pending-delete${sep}dir2.`)
|
||||
)
|
||||
expect(rmAsyncMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`.pending-delete${sep}dir2.`),
|
||||
expect.objectContaining({ recursive: true, force: true })
|
||||
)
|
||||
})
|
||||
|
||||
// Why: an empty live set is what a store that fell back to default state
|
||||
// looks like, and it is indistinguishable from a user with no worktrees —
|
||||
// who has no history to collect either. Treating it as "everything is
|
||||
// orphaned" turns a recoverable bad load into deleted shell history.
|
||||
it('refuses to prune anything when the live set is empty', () => {
|
||||
existsSyncMock.mockImplementation((p: string) => !p.includes('terminal-history-wsl'))
|
||||
readdirSyncMock.mockImplementation((dir: string) => {
|
||||
if (dir.endsWith('.pending-delete')) {
|
||||
return []
|
||||
}
|
||||
if (dir.endsWith('terminal-history')) {
|
||||
return ['dir1', 'dir2']
|
||||
}
|
||||
return ['meta.json']
|
||||
})
|
||||
statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 })
|
||||
readFileSyncMock.mockReturnValue(
|
||||
JSON.stringify({
|
||||
worktreeId: 'some-wt',
|
||||
createdAt: new Date(Date.now() - 10 * 60 * 1000).toISOString()
|
||||
})
|
||||
)
|
||||
|
||||
runHistoryGc(new Set())
|
||||
|
||||
expect(renameSyncMock).not.toHaveBeenCalled()
|
||||
expect(rmSyncMock).not.toHaveBeenCalled()
|
||||
expect(rmAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('continues GC after one orphan tombstone fails', async () => {
|
||||
existsSyncMock.mockImplementation((path: string) => !path.includes('terminal-history-wsl'))
|
||||
readdirSyncMock.mockImplementation((dir: string) => {
|
||||
if (dir.endsWith('.pending-delete')) {
|
||||
return []
|
||||
}
|
||||
if (dir.endsWith('terminal-history')) {
|
||||
return ['broken', 'healthy']
|
||||
}
|
||||
return ['meta.json']
|
||||
})
|
||||
statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 })
|
||||
readFileSyncMock.mockReturnValue(
|
||||
JSON.stringify({
|
||||
worktreeId: 'orphan',
|
||||
createdAt: new Date(Date.now() - 10 * 60 * 1000).toISOString()
|
||||
})
|
||||
)
|
||||
renameSyncMock.mockImplementationOnce(() => {
|
||||
throw new Error('busy')
|
||||
})
|
||||
|
||||
expect(() => runHistoryGc(new Set(['live-wt']))).not.toThrow()
|
||||
expect(renameSyncMock).toHaveBeenCalledTimes(2)
|
||||
expect(rmAsyncMock).toHaveBeenCalledTimes(1)
|
||||
await flushPendingWorktreeHistoryDeletions()
|
||||
})
|
||||
|
||||
it('skips recently-created directories to avoid TOCTOU race', () => {
|
||||
existsSyncMock.mockImplementation((p: string) => {
|
||||
if (p.includes('terminal-history-wsl')) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
readdirSyncMock.mockImplementation((dir: string) => {
|
||||
if (dir.endsWith('terminal-history')) {
|
||||
return ['fresh-dir']
|
||||
}
|
||||
return ['meta.json']
|
||||
})
|
||||
statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 })
|
||||
// createdAt is just now — younger than the 5-minute GC threshold
|
||||
readFileSyncMock.mockReturnValue(
|
||||
JSON.stringify({ worktreeId: 'unknown-wt', createdAt: new Date().toISOString() })
|
||||
)
|
||||
|
||||
runHistoryGc(new Set(['live-wt']))
|
||||
|
||||
// Should NOT prune because the directory is too young
|
||||
expect(rmSyncMock).not.toHaveBeenCalled()
|
||||
expect(renameSyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not throw when history root does not exist', () => {
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
expect(() => runHistoryGc(new Set(['live-wt']))).not.toThrow()
|
||||
expect(readdirSyncMock).not.toHaveBeenCalledWith('/fake/userData/terminal-history')
|
||||
})
|
||||
|
||||
it('drains delete tombstones asynchronously instead of scanning them as worktrees', async () => {
|
||||
let tombstonePresent = true
|
||||
existsSyncMock.mockImplementation((p: string) => !String(p).includes('terminal-history-wsl'))
|
||||
readdirSyncMock.mockImplementation((dir: string) => {
|
||||
if (String(dir).endsWith('.pending-delete')) {
|
||||
return tombstonePresent ? ['abc123.1700000000000.deadbeef'] : []
|
||||
}
|
||||
if (String(dir).endsWith('terminal-history')) {
|
||||
return ['.pending-delete']
|
||||
}
|
||||
return ['meta.json']
|
||||
})
|
||||
statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 })
|
||||
rmAsyncMock.mockImplementation(async () => {
|
||||
tombstonePresent = false
|
||||
})
|
||||
|
||||
runHistoryGc(new Set(['live-wt']))
|
||||
|
||||
// The tombstone queue is drained off-thread; GC must never rmSync it or count it as a worktree.
|
||||
expect(rmSyncMock).not.toHaveBeenCalled()
|
||||
expect(rmAsyncMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('abc123.1700000000000.deadbeef'),
|
||||
expect.objectContaining({ recursive: true, force: true })
|
||||
)
|
||||
await flushPendingWorktreeHistoryDeletions()
|
||||
})
|
||||
})
|
||||
|
||||
describe('WSL path conversion', () => {
|
||||
it('converts HISTFILE to Linux path for WSL cwd', () => {
|
||||
const originalPlatform = process.platform
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { join, basename } from 'node:path'
|
||||
import { mkdirSync, existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { readFile, stat } from 'node:fs/promises'
|
||||
import {
|
||||
dropInheritedOrcaFishHistory,
|
||||
fishHistorySessionName,
|
||||
@@ -131,7 +132,29 @@ export function readHistoryMeta(dir: string): HistoryDirMeta | null {
|
||||
if (statSync(metaPath).size > MAX_HISTORY_META_BYTES) {
|
||||
return null
|
||||
}
|
||||
const raw: unknown = JSON.parse(readFileSync(metaPath, 'utf-8'))
|
||||
return parseHistoryMeta(dir, readFileSync(metaPath, 'utf-8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** `readHistoryMeta` off the main thread, for scans that walk thousands of directories. */
|
||||
export async function readHistoryMetaAsync(dir: string): Promise<HistoryDirMeta | null> {
|
||||
try {
|
||||
const metaPath = join(dir, 'meta.json')
|
||||
// Why stat before read: the cap must reject an oversized meta.json without loading it.
|
||||
if ((await stat(metaPath)).size > MAX_HISTORY_META_BYTES) {
|
||||
return null
|
||||
}
|
||||
return parseHistoryMeta(dir, await readFile(metaPath, 'utf-8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function parseHistoryMeta(dir: string, contents: string): HistoryDirMeta | null {
|
||||
try {
|
||||
const raw: unknown = JSON.parse(contents)
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -75,8 +75,6 @@ export function parseWindowsCimProcessRows(stdout: string): WindowsProcessRow[]
|
||||
return []
|
||||
}
|
||||
const name = fieldAsString(row.Name)
|
||||
// memoryBytes stays undefined: Win32_Process reports WorkingSetSize, but no
|
||||
// caller reads it off this table and asking widens an already costly scan.
|
||||
return [{ pid, ppid, name, command: fieldAsString(row.CommandLine) || name }]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -52,21 +52,24 @@ describe('windows process table', () => {
|
||||
it('maps native rows, defaulting an unreadable command line to empty', async () => {
|
||||
const rows = await readWindowsProcessTableFresh()
|
||||
expect(rows).toEqual([
|
||||
{ pid: process.pid, ppid: 0, name: 'vitest.exe', command: '', memoryBytes: undefined },
|
||||
{ pid: process.pid, ppid: 0, name: 'vitest.exe', command: '' },
|
||||
{
|
||||
pid: 100,
|
||||
ppid: 4,
|
||||
name: 'orca.exe',
|
||||
command: '"C:/a b/orca.exe" --x',
|
||||
memoryBytes: 4096,
|
||||
creationTimeMs: 1_700_000_000_000
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('requests memory and command line together', async () => {
|
||||
it('requests the command line and creation time, never memory', async () => {
|
||||
await readWindowsProcessTableFresh()
|
||||
expect(getAllProcesses.mock.calls[0]?.[1]).toBe(7)
|
||||
// CommandLine (2) | CreationTime (4). The Memory bit (1) stays clear: the
|
||||
// addon opens a second PROCESS_VM_READ handle per process to serve it and
|
||||
// nothing reads a working set off this table.
|
||||
expect(getAllProcesses.mock.calls[0]?.[1]).toBe(6)
|
||||
expect((getAllProcesses.mock.calls[0]?.[1] as number) & 1).toBe(0)
|
||||
})
|
||||
|
||||
it('only advertises PID-safe ownership when the native creation-time field exists', () => {
|
||||
@@ -400,20 +403,19 @@ describe('resolving the native reader', () => {
|
||||
})
|
||||
const rows = await readWindowsProcessTableFresh()
|
||||
expect(rows).toEqual([
|
||||
{ pid: process.pid, ppid: 0, name: 'vitest.exe', command: '', memoryBytes: undefined },
|
||||
{ pid: process.pid, ppid: 0, name: 'vitest.exe', command: '' },
|
||||
{
|
||||
pid: 100,
|
||||
ppid: 4,
|
||||
name: 'orca.exe',
|
||||
command: '"C:/a b/orca.exe" --x',
|
||||
memoryBytes: 4096,
|
||||
creationTimeMs: 1_700_000_000_000
|
||||
}
|
||||
])
|
||||
expect(isWindowsProcessTableAvailable()).toBe(true)
|
||||
})
|
||||
|
||||
it('asks the addon for memory and command line, as the package path does', async () => {
|
||||
it('asks the addon for the command line but not memory, as the package path does', async () => {
|
||||
const addon = addonReturning(NATIVE)
|
||||
__setWindowsProcessTreeRequireForTests((specifier: string) => {
|
||||
if (specifier === ADDON_SPECIFIER) {
|
||||
@@ -422,9 +424,10 @@ describe('resolving the native reader', () => {
|
||||
throw new Error('MODULE_NOT_FOUND')
|
||||
})
|
||||
await readWindowsProcessTableFresh()
|
||||
// Memory | CommandLine. A bare snapshot would silently drop the command
|
||||
// line every agent-recognition caller matches on first.
|
||||
expect(addon.getProcessList).toHaveBeenCalledWith(expect.any(Function), 3)
|
||||
// CommandLine only: a bare snapshot would silently drop the command line
|
||||
// every agent-recognition caller matches on first, and the relay addon
|
||||
// exposes no CreationTime bit to add.
|
||||
expect(addon.getProcessList).toHaveBeenCalledWith(expect.any(Function), 2)
|
||||
})
|
||||
|
||||
it('reaches the CIM scan when neither the package nor the addon is present', async () => {
|
||||
|
||||
@@ -23,6 +23,10 @@ import { readWindowsProcessRowsWithCim } from './windows-process-table-cim-scan'
|
||||
* pid+ppid+name 15.9 / 17.5 ms
|
||||
* +memory +commandLine 30.6 / 33.7 ms
|
||||
* PowerShell CIM 706 / 723 ms
|
||||
*
|
||||
* Those are the module's published figures for both extra fields together; the
|
||||
* only flag set this module asks for is `CommandLine` (+ `CreationTime`, free),
|
||||
* which sits between the two rows and has not been separately measured.
|
||||
*/
|
||||
|
||||
export type WindowsProcessRow = {
|
||||
@@ -31,8 +35,6 @@ export type WindowsProcessRow = {
|
||||
name: string
|
||||
/** Full command line. Empty when the process denied a query handle. */
|
||||
command: string
|
||||
/** Working set in bytes, or undefined when not requested/queryable. */
|
||||
memoryBytes?: number
|
||||
/** Process creation time in Unix milliseconds, when the native snapshot provides it. */
|
||||
creationTimeMs?: number
|
||||
}
|
||||
@@ -41,7 +43,6 @@ type NativeProcessInfo = {
|
||||
pid: number
|
||||
ppid: number
|
||||
name: string
|
||||
memory?: number
|
||||
commandLine?: string
|
||||
creationTimeMs?: number
|
||||
}
|
||||
@@ -49,7 +50,6 @@ type NativeProcessInfo = {
|
||||
type WindowsProcessTreeModule = {
|
||||
ProcessDataFlag: {
|
||||
None: number
|
||||
Memory: number
|
||||
CommandLine: number
|
||||
CreationTime?: number
|
||||
}
|
||||
@@ -82,7 +82,10 @@ type WindowsProcessTreeAddon = {
|
||||
) => void
|
||||
}
|
||||
|
||||
/** Mirrors the package's enum; the addon takes the raw bit field. */
|
||||
/**
|
||||
* Mirrors the package's enum; the addon takes the raw bit field. `Memory` (1)
|
||||
* is listed for completeness and is deliberately never set — see `flags` below.
|
||||
*/
|
||||
const PROCESS_DATA_FLAG = { None: 0, Memory: 1, CommandLine: 2 } as const
|
||||
|
||||
/** Staged beside the relay bundle by build-relay; see RELAY_ARTIFACTS. */
|
||||
@@ -190,16 +193,16 @@ function readNativeRows(): Promise<WindowsProcessRow[]> {
|
||||
}
|
||||
const readId = ++readSequence
|
||||
const readerEpoch = nativeReaderEpoch
|
||||
// Why always both flags: each adds an OpenProcess per process (Memory a
|
||||
// GetProcessMemoryInfo, CommandLine a PEB read), so asking for less would be
|
||||
// cheaper -- 15.9ms p50 versus 30.6ms at 1050 processes. But every read shares
|
||||
// one snapshot so a 32-wide teardown collapses into a single scan, and that
|
||||
// snapshot has to satisfy every caller. Splitting the cache per field set
|
||||
// would restore exactly the fan-out it exists to prevent.
|
||||
const flags =
|
||||
native.ProcessDataFlag.Memory |
|
||||
native.ProcessDataFlag.CommandLine |
|
||||
(native.ProcessDataFlag.CreationTime ?? 0)
|
||||
// Why CommandLine but not Memory: each flag costs one OpenProcess per process
|
||||
// inside the addon (process.cc), and every caller of this table matches on
|
||||
// `command`, while nothing reads a working set off it -- the Resource Manager
|
||||
// runs its own CIM sweep because it needs commit and CPU time in one pass, and
|
||||
// `process.cc` truncates the working set into a DWORD anyway. Dropping Memory
|
||||
// halves the per-snapshot handle count; the remaining flags stay in ONE flag
|
||||
// set because every read shares one snapshot, so a 32-wide teardown collapses
|
||||
// into a single scan. Splitting the cache per field set would restore exactly
|
||||
// the fan-out it exists to prevent.
|
||||
const flags = native.ProcessDataFlag.CommandLine | (native.ProcessDataFlag.CreationTime ?? 0)
|
||||
return new Promise((resolve, reject) => {
|
||||
// Hoisted so a synchronous throw from getAllProcesses can clear it. An
|
||||
// orphaned timer would otherwise fire later and wedge a reader that had
|
||||
@@ -241,7 +244,6 @@ function readNativeRows(): Promise<WindowsProcessRow[]> {
|
||||
ppid: row.ppid,
|
||||
name: row.name,
|
||||
command: row.commandLine ?? '',
|
||||
memoryBytes: row.memory,
|
||||
...(typeof row.creationTimeMs === 'number'
|
||||
? { creationTimeMs: row.creationTimeMs }
|
||||
: {})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AgentStatusCacheIdentity,
|
||||
AgentStatusClearIpcPayload,
|
||||
AgentStatusIpcPayload,
|
||||
MigrationUnsupportedPtyEntry
|
||||
@@ -30,6 +31,10 @@ export type AgentStatusApi = {
|
||||
getMigrationUnsupportedSnapshot: () => Promise<MigrationUnsupportedPtyEntry[]>
|
||||
/** Drop a paneKey from the main-process hook cache and on-disk last-status file. Fire-and-forget. */
|
||||
drop: (paneKey: string) => void
|
||||
/** Evict a previously-cleared status only when its identity still matches the main-process cache. */
|
||||
dropPersisted: (identity: AgentStatusCacheIdentity) => void
|
||||
/** Same as dropPersisted for many identities in one IPC message and one listener notification. */
|
||||
dropPersistedBatch?: (identities: readonly AgentStatusCacheIdentity[]) => void
|
||||
/** Retire a pane whose agent process is proven gone — clears the row AND the per-pane caches a
|
||||
* dismissal deliberately keeps. Not `drop`: that one is a user dismissal of a live pane's row. */
|
||||
reconcileEndedProcess: (paneKey: string) => void
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ipcRenderer } from 'electron'
|
||||
import type {
|
||||
AgentStatusCacheIdentity,
|
||||
AgentStatusClearIpcPayload,
|
||||
AgentStatusIpcPayload,
|
||||
MigrationUnsupportedPtyEntry
|
||||
@@ -66,6 +67,12 @@ export const agentStatusApi = {
|
||||
drop: (paneKey: string): void => {
|
||||
ipcRenderer.send('agentStatus:drop', paneKey)
|
||||
},
|
||||
dropPersisted: (identity: AgentStatusCacheIdentity): void => {
|
||||
ipcRenderer.send('agentStatus:dropPersisted', identity)
|
||||
},
|
||||
dropPersistedBatch: (identities: readonly AgentStatusCacheIdentity[]): void => {
|
||||
ipcRenderer.send('agentStatus:dropPersistedBatch', identities)
|
||||
},
|
||||
reconcileEndedProcess: (paneKey: string): void => {
|
||||
ipcRenderer.send('agentStatus:reconcileEndedProcess', paneKey)
|
||||
},
|
||||
|
||||
@@ -38,6 +38,9 @@ export type AppApi = {
|
||||
/** Resolves when the daemon PTY provider and hook receiver have either
|
||||
* started or failed open for the first BrowserWindow. */
|
||||
awaitFirstWindowStartupServices: () => Promise<void>
|
||||
/** Resolves when host Git can run: shell-PATH generation is published and the
|
||||
* managed WSL CLI registration has reconciled. Does not wait on PTY services. */
|
||||
awaitGitEnvironmentStartupBarrier: () => Promise<void>
|
||||
/** Inventories retained PTYs and restores durable structured ownership before renderer adoption. */
|
||||
prepareTerminalStartupRestoration: () => Promise<void>
|
||||
/** Reconciles legacy worker authority around persisted terminal reconnect. */
|
||||
|
||||
@@ -43,6 +43,8 @@ export const appApi = {
|
||||
awaitBeforeUnloadCheckpoint: () => awaitBeforeUnloadCheckpoint(),
|
||||
awaitFirstWindowStartupServices: (): Promise<void> =>
|
||||
ipcRenderer.invoke('app:awaitFirstWindowStartupServices'),
|
||||
awaitGitEnvironmentStartupBarrier: (): Promise<void> =>
|
||||
ipcRenderer.invoke('app:awaitGitEnvironmentStartupBarrier'),
|
||||
prepareTerminalStartupRestoration: (): Promise<void> =>
|
||||
ipcRenderer.invoke('app:prepareTerminalStartupRestoration'),
|
||||
recoverLegacyWorkerTerminalsForRendererStartup: (): Promise<void> =>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { platformApi } from './platform-bridge'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ getLinuxDisplayServer: vi.fn(() => null) }))
|
||||
|
||||
vi.mock('../preload-runtime-support', () => ({
|
||||
getLinuxDisplayServer: mocks.getLinuxDisplayServer
|
||||
}))
|
||||
|
||||
// Electron declares getSystemVersion as required on NodeJS.Process; Node does not have it.
|
||||
const mutableProcess = process as unknown as { getSystemVersion?: () => string }
|
||||
|
||||
async function loadPlatformApi(): Promise<typeof platformApi> {
|
||||
vi.resetModules()
|
||||
return (await import('./platform-bridge')).platformApi
|
||||
}
|
||||
|
||||
describe('platformApi.get', () => {
|
||||
beforeEach(() => {
|
||||
mocks.getLinuxDisplayServer.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete mutableProcess.getSystemVersion
|
||||
})
|
||||
|
||||
it('resolves the immutable payload once and returns the identical object', async () => {
|
||||
const platformApi = await loadPlatformApi()
|
||||
const getSystemVersion = vi.fn(() => '25.3.0')
|
||||
mutableProcess.getSystemVersion = getSystemVersion
|
||||
|
||||
const first = platformApi.get()
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
expect(platformApi.get()).toBe(first)
|
||||
}
|
||||
|
||||
expect(getSystemVersion).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.getLinuxDisplayServer).toHaveBeenCalledTimes(1)
|
||||
expect(first.platform).toBe(process.platform)
|
||||
expect(first.arch).toBe(process.arch)
|
||||
expect(first.osRelease).toBe('25.3.0')
|
||||
})
|
||||
|
||||
it('freezes the payload so no consumer can corrupt the shared instance', async () => {
|
||||
const platformApi = await loadPlatformApi()
|
||||
|
||||
expect(Object.isFrozen(platformApi.get())).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves nothing before the first get, keeping preload startup free', async () => {
|
||||
await loadPlatformApi()
|
||||
|
||||
expect(mocks.getLinuxDisplayServer).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,14 @@
|
||||
import { getLinuxDisplayServer } from '../preload-runtime-support'
|
||||
import type { PreloadApi } from '../api-types'
|
||||
|
||||
export const platformApi = {
|
||||
get: () => ({
|
||||
type PlatformInfo = ReturnType<PreloadApi['platform']['get']>
|
||||
|
||||
// Why: the renderer reads this on its render cadence, and every field below is fixed
|
||||
// for the process lifetime, so resolve once and hand back the same frozen payload.
|
||||
let platformInfo: PlatformInfo | undefined
|
||||
|
||||
function resolvePlatformInfo(): PlatformInfo {
|
||||
return Object.freeze({
|
||||
platform: process.platform,
|
||||
// Why: sandboxed preload cannot require node:os; Electron exposes the OS
|
||||
// version on process.getSystemVersion when available.
|
||||
@@ -14,4 +20,9 @@ export const platformApi = {
|
||||
shell: process.env.SHELL?.trim() || process.env.ComSpec?.trim() || '',
|
||||
displayServer: getLinuxDisplayServer()
|
||||
})
|
||||
}
|
||||
|
||||
export const platformApi = {
|
||||
// Why: resolved lazily so preload startup keeps paying nothing for it.
|
||||
get: () => (platformInfo ??= resolvePlatformInfo())
|
||||
} satisfies PreloadApi['platform']
|
||||
|
||||
+22
-3
@@ -25,6 +25,7 @@ import {
|
||||
writeRelayFile
|
||||
} from './fs-path-mutation-requests'
|
||||
import { buildExcludePathPrefixes } from '../shared/quick-open-filter'
|
||||
import { maybeStreamRpcResponse, type GitResponseStreamRegistry } from './git-response-stream'
|
||||
import { readRelayFileContent, readRelayFileStreamMetadata } from './fs-handler-file-read'
|
||||
import { readRelayFileRange } from './fs-handler-file-range'
|
||||
import { FileRangeReadRequestError } from '../shared/file-range-read'
|
||||
@@ -47,12 +48,19 @@ export class FsHandler {
|
||||
private watchRegistry: RelayFilesystemWatchRegistry
|
||||
private streamRegistry = new RelayStreamRegistry()
|
||||
private listFilesScans = new ListFilesScanCoordinator()
|
||||
private readonly responseStreams: GitResponseStreamRegistry | undefined
|
||||
|
||||
constructor(
|
||||
dispatcher: RelayDispatcher,
|
||||
_context: RelayContext,
|
||||
watcherPool?: RelayWatcherProcessPool
|
||||
watcherPool?: RelayWatcherProcessPool,
|
||||
// Why passed in rather than owned: GitHandler registers the `git.responseAck` route every pump
|
||||
// is credited through, and a client keys reassembly on `streamId` alone — see the header of
|
||||
// git-response-stream.ts. Without one this handler answers plainly, which is the pre-streaming
|
||||
// behavior rather than a stream nothing can credit.
|
||||
responseStreams?: GitResponseStreamRegistry
|
||||
) {
|
||||
this.responseStreams = responseStreams
|
||||
this.dispatcher = dispatcher
|
||||
this.watchRegistry = new RelayFilesystemWatchRegistry(dispatcher, watcherPool)
|
||||
this.registerHandlers()
|
||||
@@ -204,7 +212,10 @@ export class FsHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private listFiles(params: Record<string, unknown>, context?: RequestContext): Promise<string[]> {
|
||||
private async listFiles(
|
||||
params: Record<string, unknown>,
|
||||
context?: RequestContext
|
||||
): Promise<unknown> {
|
||||
const rootPath = expandTilde(params.rootPath as string)
|
||||
const maxResults =
|
||||
typeof params.maxResults === 'number' &&
|
||||
@@ -224,13 +235,21 @@ export class FsHandler {
|
||||
// Why #7721: full-tree scans are the relay's most expensive request; the
|
||||
// coordinator caps them at one per client, coalescing duplicates and
|
||||
// aborting a stale scan when the workspace changes or the host cancels.
|
||||
return this.listFilesScans.run({
|
||||
const files = await this.listFilesScans.run({
|
||||
clientId: context?.clientId ?? 0,
|
||||
key: JSON.stringify([rootPath, excludePathPrefixes, maxResults, searchQuery]),
|
||||
signal: context?.signal,
|
||||
start: (signal) =>
|
||||
runListFilesScan(rootPath, excludePathPrefixes, signal, maxResults, searchQuery)
|
||||
})
|
||||
// Why: a full listing of a real monorepo serializes past the 1 MiB control lane — Orca's own
|
||||
// checkout is 22.6k paths averaging 58 characters, so a 20,001-row page is ~1.2MB — and the
|
||||
// legacy-response lane it demotes to is refused under unrelated producer load. Streaming makes
|
||||
// size stop being a correctness question instead of picking a row or byte ceiling to refuse at.
|
||||
// A client that did not opt in still gets the plain array, exactly as before.
|
||||
return this.responseStreams
|
||||
? maybeStreamRpcResponse(files, params, context, this.responseStreams, this.dispatcher)
|
||||
: files
|
||||
}
|
||||
|
||||
private async workspaceSpaceScan(params: Record<string, unknown>, context: RequestContext) {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* #12547: a full `fs.listFiles` reply for a real monorepo does not fit the relay's control lane.
|
||||
*
|
||||
* Orca's own checkout is ~22.6k tracked paths averaging 58 characters, so a 20,001-row page
|
||||
* serializes to ~1.2MB — past `DISPATCHER_CONTROL_QUEUE_MAX_BYTES`, which demotes it to the
|
||||
* `legacy-response` lane where an unrelated producer backlog can refuse it. Refusing at a fixed row
|
||||
* or byte ceiling only moves where that shows up; streaming removes it, so these run the real
|
||||
* dispatcher, the real FsHandler and the real client multiplexer over an in-memory pipe and assert
|
||||
* an over-budget listing arrives intact — in both wire directions.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { runListFilesScanMock } = vi.hoisted(() => ({ runListFilesScanMock: vi.fn() }))
|
||||
|
||||
vi.mock('./fs-list-files-fallback-chain', () => ({ runListFilesScan: runListFilesScanMock }))
|
||||
vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() }))
|
||||
|
||||
import {
|
||||
SshChannelMultiplexer,
|
||||
type MultiplexerTransport
|
||||
} from '../main/ssh/ssh-channel-multiplexer'
|
||||
import { requestGitStreamable } from '../main/ssh/ssh-git-response-stream-reader'
|
||||
import { RelayContext } from './context'
|
||||
import { RelayDispatcher } from './dispatcher'
|
||||
import { DISPATCHER_CONTROL_QUEUE_MAX_BYTES } from './dispatcher-writer-admission'
|
||||
import { FsHandler } from './fs-handler'
|
||||
import { GitHandler } from './git-handler'
|
||||
import { GitResponseStreamRegistry } from './git-response-stream'
|
||||
import { QUICK_OPEN_LISTING_MAX_RESULTS } from '../shared/quick-open-listing-limits'
|
||||
|
||||
/** Shaped like this repository: `packages/<name>/src/...`, ~58 characters. */
|
||||
function monorepoPaths(count: number): string[] {
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, index) =>
|
||||
`packages/pkg-${String(index % 64).padStart(2, '0')}/src/renderer/components/entry-${String(index).padStart(6, '0')}.tsx`
|
||||
)
|
||||
}
|
||||
|
||||
describe('Integration: an over-budget fs.listFiles reply (#12547)', () => {
|
||||
let mux: SshChannelMultiplexer
|
||||
let dispatcher: RelayDispatcher
|
||||
let fsHandler: FsHandler
|
||||
let gitHandler: GitHandler
|
||||
let writtenFrames: number[]
|
||||
|
||||
beforeEach(() => {
|
||||
runListFilesScanMock.mockReset()
|
||||
writtenFrames = []
|
||||
|
||||
let relayFeed: (data: Buffer) => void
|
||||
const clientDataCallbacks: ((data: Buffer) => void)[] = []
|
||||
const clientTransport: MultiplexerTransport = {
|
||||
write: (data: Buffer) => {
|
||||
setImmediate(() => relayFeed?.(data))
|
||||
},
|
||||
onData: (cb) => {
|
||||
clientDataCallbacks.push(cb)
|
||||
},
|
||||
onClose: () => {}
|
||||
}
|
||||
dispatcher = new RelayDispatcher((data: Buffer) => {
|
||||
writtenFrames.push(data.length)
|
||||
setImmediate(() => {
|
||||
for (const cb of clientDataCallbacks) {
|
||||
cb(data)
|
||||
}
|
||||
})
|
||||
return true
|
||||
})
|
||||
relayFeed = (data: Buffer) => dispatcher.feed(data)
|
||||
// Why: the same single registry production wires, so `git.responseAck` — registered by
|
||||
// GitHandler — credits the pump an fs.listFiles stream parks on.
|
||||
const responseStreams = new GitResponseStreamRegistry()
|
||||
const context = new RelayContext()
|
||||
fsHandler = new FsHandler(dispatcher, context, undefined, responseStreams)
|
||||
gitHandler = new GitHandler(dispatcher, context, undefined, responseStreams)
|
||||
mux = new SshChannelMultiplexer(clientTransport)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mux.dispose()
|
||||
dispatcher.dispose()
|
||||
fsHandler.dispose()
|
||||
gitHandler.dispose()
|
||||
})
|
||||
|
||||
it('delivers a page too large for the control lane, in chunks no frame has to carry', async () => {
|
||||
const files = monorepoPaths(QUICK_OPEN_LISTING_MAX_RESULTS)
|
||||
// Precondition, measured from the payload rather than asserted between two constants: this is
|
||||
// the listing that does not fit, which is what makes the rest of the test mean anything.
|
||||
expect(Buffer.byteLength(JSON.stringify(files), 'utf8')).toBeGreaterThan(
|
||||
DISPATCHER_CONTROL_QUEUE_MAX_BYTES
|
||||
)
|
||||
runListFilesScanMock.mockResolvedValue(files)
|
||||
|
||||
const received = await requestGitStreamable(mux, 'fs.listFiles', {
|
||||
rootPath: '/remote/root',
|
||||
maxResults: QUICK_OPEN_LISTING_MAX_RESULTS
|
||||
})
|
||||
|
||||
expect(received).toEqual(files)
|
||||
expect(Math.max(...writtenFrames)).toBeLessThan(DISPATCHER_CONTROL_QUEUE_MAX_BYTES)
|
||||
})
|
||||
|
||||
it('still answers a client that never opts into streaming, with the whole array', async () => {
|
||||
const files = monorepoPaths(QUICK_OPEN_LISTING_MAX_RESULTS)
|
||||
runListFilesScanMock.mockResolvedValue(files)
|
||||
|
||||
// Why: an old client sends neither `__streamResponse` nor `maxResults`. It gets one plain frame
|
||||
// on the legacy-response lane, as it did before this call ever learned to stream.
|
||||
const received = await mux.request('fs.listFiles', { rootPath: '/remote/root' })
|
||||
|
||||
expect(received).toEqual(files)
|
||||
expect(Math.max(...writtenFrames)).toBeGreaterThan(DISPATCHER_CONTROL_QUEUE_MAX_BYTES)
|
||||
})
|
||||
|
||||
it('leaves a reply that fits on the plain response path', async () => {
|
||||
const files = monorepoPaths(100)
|
||||
runListFilesScanMock.mockResolvedValue(files)
|
||||
|
||||
const received = await requestGitStreamable(mux, 'fs.listFiles', {
|
||||
rootPath: '/remote/root',
|
||||
maxResults: 100
|
||||
})
|
||||
|
||||
expect(received).toEqual(files)
|
||||
expect(Math.max(...writtenFrames)).toBeLessThan(DISPATCHER_CONTROL_QUEUE_MAX_BYTES)
|
||||
})
|
||||
})
|
||||
@@ -46,6 +46,17 @@ function gitForConfig(config: {
|
||||
}
|
||||
return { stdout: `${config.base ?? ''}\n`, stderr: '' }
|
||||
}
|
||||
if (args[0] === 'remote' && args[1] === '-v') {
|
||||
return {
|
||||
stdout: (config.remotes ?? [])
|
||||
.flatMap((name) => {
|
||||
const url = config.remoteUrls?.[name] ?? ''
|
||||
return [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]
|
||||
})
|
||||
.join('\n'),
|
||||
stderr: ''
|
||||
}
|
||||
}
|
||||
if (args[0] === 'remote' && args.length === 1) {
|
||||
return { stdout: `${config.remotes?.join('\n') ?? ''}\n`, stderr: '' }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { assertGitPushTargetShape } from '../shared/git-push-target-validation'
|
||||
import { gitRefTargetsBranchOnRemote } from '../shared/git-remote-branch-name'
|
||||
import { findGitRemoteNameByFetchUrl } from '../shared/git-remote-url-index'
|
||||
import type { GitPushTarget } from '../shared/worktree/types'
|
||||
|
||||
type RelayGit = (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string }>
|
||||
@@ -67,31 +68,19 @@ type ConfiguredPushRemote = {
|
||||
branchRemote: string | null
|
||||
}
|
||||
|
||||
// Host-side twin of `src/main/git/remote.ts`: one `git remote -v` instead of
|
||||
// `git remote` plus a serial `git remote get-url` per remote.
|
||||
async function findRemoteNameForUrl(
|
||||
git: RelayGit,
|
||||
worktreePath: string,
|
||||
remoteUrl: string
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await git(['remote'], worktreePath)
|
||||
const remotes = stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
for (const remoteName of remotes) {
|
||||
try {
|
||||
const { stdout: urlStdout } = await git(['remote', 'get-url', remoteName], worktreePath)
|
||||
if (urlStdout.trim() === remoteUrl) {
|
||||
return remoteName
|
||||
}
|
||||
} catch {
|
||||
// Ignore a remote that disappeared or has no fetch URL.
|
||||
}
|
||||
}
|
||||
const { stdout } = await git(['remote', '-v'], worktreePath)
|
||||
return findGitRemoteNameByFetchUrl(stdout, (candidateUrl) => candidateUrl === remoteUrl)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function normalizePushRemote(
|
||||
@@ -120,9 +109,17 @@ async function getConfiguredPushRemote(
|
||||
if (!remote) {
|
||||
return null
|
||||
}
|
||||
const normalizedRemote = await normalizePushRemote(git, worktreePath, remote)
|
||||
// The two usually name the same URL; resolving it twice reads the remote table twice.
|
||||
if (!branchRemote) {
|
||||
return { remote: normalizedRemote, branchRemote: null }
|
||||
}
|
||||
return {
|
||||
remote: await normalizePushRemote(git, worktreePath, remote),
|
||||
branchRemote: branchRemote ? await normalizePushRemote(git, worktreePath, branchRemote) : null
|
||||
remote: normalizedRemote,
|
||||
branchRemote:
|
||||
branchRemote === remote
|
||||
? normalizedRemote
|
||||
: await normalizePushRemote(git, worktreePath, branchRemote)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
createSubmodulePathsCache,
|
||||
type SubmodulePathsCache
|
||||
} from './git-handler-submodule-ops'
|
||||
import { GitResponseStreamRegistry } from './git-response-stream'
|
||||
import { GIT_RESPONSE_STREAM_THRESHOLD } from './protocol'
|
||||
import { GitResponseStreamRegistry, maybeStreamRpcResponse } from './git-response-stream'
|
||||
import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache'
|
||||
import { invalidateGitBranchLineTotalInFlight } from '../shared/git-branch-line-total'
|
||||
import { buildRelayGitEnv, buildRelayUnattendedGitEnv } from './relay-command-env'
|
||||
@@ -68,9 +67,6 @@ export class GitHandler {
|
||||
private dispatcher: RelayDispatcher
|
||||
private readonly gitDiffReadDedupe = new InFlightPromiseDedupe<unknown>()
|
||||
private readonly gitCapabilities = new GitCapabilityCache()
|
||||
// Why: use the bulk lane so large responses do not block interactive PTY echo.
|
||||
private readonly responseStreams = new GitResponseStreamRegistry()
|
||||
|
||||
// Why: cache .gitmodules per instance to avoid SSH reads and test leakage.
|
||||
private submodulePathsCache: SubmodulePathsCache = createSubmodulePathsCache()
|
||||
|
||||
@@ -78,7 +74,12 @@ export class GitHandler {
|
||||
constructor(
|
||||
dispatcher: RelayDispatcher,
|
||||
_context: RelayContext,
|
||||
private readonly watcherRegistry?: GitHandlerWatcherRegistry
|
||||
private readonly watcherRegistry?: GitHandlerWatcherRegistry,
|
||||
// Why: use the bulk lane so large responses do not block interactive PTY echo. This handler
|
||||
// registers the `git.responseAck` route below, so in production it takes the relay's single
|
||||
// registry and FsHandler is handed the same one — see the header of git-response-stream.ts for
|
||||
// why a second registry both collides on stream ids and stalls on credit.
|
||||
private readonly responseStreams: GitResponseStreamRegistry = new GitResponseStreamRegistry()
|
||||
) {
|
||||
this.dispatcher = dispatcher
|
||||
const handlers = createGitHandlerOperationSet({
|
||||
@@ -132,14 +133,7 @@ export class GitHandler {
|
||||
params: Record<string, unknown>,
|
||||
context: RequestContext | undefined
|
||||
): unknown {
|
||||
if (params.__streamResponse !== true || !context) {
|
||||
return result
|
||||
}
|
||||
const payload = Buffer.from(JSON.stringify(result ?? null), 'utf-8')
|
||||
if (payload.length <= GIT_RESPONSE_STREAM_THRESHOLD) {
|
||||
return result
|
||||
}
|
||||
return this.responseStreams.startStream(payload, this.dispatcher, context)
|
||||
return maybeStreamRpcResponse(result, params, context, this.responseStreams, this.dispatcher)
|
||||
}
|
||||
|
||||
private clearGitMutationReadCaches(): void {
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
// Streams large git RPC responses (diff family + exec) onto the bulk lane in
|
||||
// chunks instead of one JSON-RPC frame, so a big diff cannot head-of-line-block
|
||||
// interactive pty.data echo on the shared SSH channel. Mirrors the fs
|
||||
// read-stream credit-window pattern (see fs-handler-file-read.ts) but the
|
||||
// payload is an in-memory serialized string rather than a file handle.
|
||||
// Streams large RPC responses onto the bulk lane in chunks instead of one
|
||||
// JSON-RPC frame, so a big reply cannot head-of-line-block interactive pty.data
|
||||
// echo on the shared SSH channel. Mirrors the fs read-stream credit-window
|
||||
// pattern (see fs-handler-file-read.ts) but the payload is an in-memory
|
||||
// serialized string rather than a file handle.
|
||||
//
|
||||
// ONE REGISTRY PER RELAY. The `git.*` method names below are the shipped wire
|
||||
// spelling and are permanent, the way an opcode number is, so a second handler
|
||||
// that needs streaming (`fs.listFiles` is the first) shares this instance rather
|
||||
// than minting its own. A second registry is not an option: a client keys
|
||||
// reassembly on `streamId` alone, so two would hand out the same id and
|
||||
// cross-feed each other's chunks, and only the handler that registers
|
||||
// `git.responseAck` can credit the ack window a pump parks on — the other's
|
||||
// streams would stall at STREAM_ACK_WINDOW_CHUNKS forever. See
|
||||
// `relay-runtime-services.ts` for the wiring.
|
||||
import type { RelayDispatcher, RequestContext } from './dispatcher'
|
||||
import {
|
||||
GIT_RESPONSE_CHUNK_SIZE,
|
||||
GIT_RESPONSE_STREAM_THRESHOLD,
|
||||
STREAM_ACK_WINDOW_CHUNKS,
|
||||
STREAM_ACK_STALL_RECHECK_MS,
|
||||
type GitResponseStreamMarker
|
||||
@@ -220,3 +231,29 @@ export class GitResponseStreamRegistry {
|
||||
this.streams.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opt-in response streaming, shared by every handler that can answer with a
|
||||
* payload too large for one control-lane frame.
|
||||
*
|
||||
* `__streamResponse` is its own negotiation in both directions: an old client
|
||||
* never sends it and gets the plain result, and an old relay ignores it and
|
||||
* answers plainly, which the client detects by the sentinel marker being absent.
|
||||
* So there is no new method and no capability to advertise.
|
||||
*/
|
||||
export function maybeStreamRpcResponse(
|
||||
result: unknown,
|
||||
params: Record<string, unknown>,
|
||||
context: RequestContext | undefined,
|
||||
registry: GitResponseStreamRegistry,
|
||||
dispatcher: RelayDispatcher
|
||||
): unknown {
|
||||
if (params.__streamResponse !== true || !context) {
|
||||
return result
|
||||
}
|
||||
const payload = Buffer.from(JSON.stringify(result ?? null), 'utf-8')
|
||||
if (payload.length <= GIT_RESPONSE_STREAM_THRESHOLD) {
|
||||
return result
|
||||
}
|
||||
return registry.startStream(payload, dispatcher, context)
|
||||
}
|
||||
|
||||
@@ -6,17 +6,16 @@ import { promisify } from 'node:util'
|
||||
import {
|
||||
isAgentForegroundWrapperProcess,
|
||||
isExpectedAgentProcess,
|
||||
recognizeAgentProcess,
|
||||
recognizeAgentProcessFromCommandLine
|
||||
recognizeAgentProcess
|
||||
} from '../shared/agent-process-recognition'
|
||||
import { getFirstCommandToken } from '../shared/command-token-scanner'
|
||||
import {
|
||||
getProcessTableIndex,
|
||||
getProcessTableSnapshot,
|
||||
scoreForegroundCandidateRow,
|
||||
type ProcessTableIndex,
|
||||
type ProcessTableRow
|
||||
} from '../shared/process-table-snapshot'
|
||||
import { selectForegroundProcessCandidate } from '../shared/foreground-process-selection'
|
||||
import {
|
||||
resolveOuterWrapperForegroundProcess,
|
||||
shouldInspectOuterWrapperForegroundProcess
|
||||
@@ -240,9 +239,7 @@ function getForegroundProcessNameFromProcessTable(
|
||||
// snapshot no longer each rebuild the parent/child map over every row.
|
||||
const index = getProcessTableIndex(rows)
|
||||
const root = index.byPid.get(pid)
|
||||
const candidates = collectDescendants(index, pid).sort(
|
||||
(a, b) => scoreForegroundCandidateRow(b) - scoreForegroundCandidateRow(a)
|
||||
)
|
||||
const candidates = collectDescendants(index, pid)
|
||||
// Why: SSH relays do not have the daemon's async wrapper cache. Inspect the
|
||||
// remote process tree so node/python agent entrypoints become real agents.
|
||||
const foregroundIsKnown =
|
||||
@@ -264,13 +261,12 @@ function getForegroundProcessNameFromProcessTable(
|
||||
) {
|
||||
return null
|
||||
}
|
||||
for (const candidate of inspectionCandidates) {
|
||||
const recognized = recognizeAgentProcessFromCommandLine(candidate.command)
|
||||
if (recognized) {
|
||||
// Why: return the outer wrapper (omp) rather than the deeper wrapped child
|
||||
// (pi) of a shell→omp→pi tree — see resolveOuterWrapperForegroundProcess.
|
||||
return resolveOuterWrapperForegroundProcess(recognized, candidate, candidates)
|
||||
}
|
||||
const ancestryCandidates = root ? [{ ...root, depth: 0 }, ...candidates] : candidates
|
||||
const selected = selectForegroundProcessCandidate(inspectionCandidates, ancestryCandidates)
|
||||
if (selected) {
|
||||
// Why: return the outer wrapper (omp) rather than a deeper recognized helper
|
||||
// in the same process lineage.
|
||||
return resolveOuterWrapperForegroundProcess(selected.recognized, selected.candidate, candidates)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { RelayContext, expandTilde } from './context'
|
||||
import { PtyHandler } from './pty-handler'
|
||||
import { FsHandler } from './fs-handler'
|
||||
import { GitHandler } from './git-handler'
|
||||
import { GitResponseStreamRegistry } from './git-response-stream'
|
||||
import { PreflightHandler } from './preflight-handler'
|
||||
import { ExternalAutomationsHandler } from './external-automations-handler'
|
||||
import { PortScanHandler } from './port-scan-handler'
|
||||
@@ -51,13 +52,17 @@ export class RelayRuntimeServices {
|
||||
)
|
||||
this.ptyHandler.setSourcePublication(this.ptySourcePublication)
|
||||
|
||||
this.fsHandler = new FsHandler(dispatcher, context)
|
||||
// Why one instance for both handlers: a client reassembles a streamed reply by `streamId` alone,
|
||||
// so two registries would hand out the same id, and only GitHandler routes the `git.responseAck`
|
||||
// credit every pump waits on. A second registry is not an option — see git-response-stream.ts.
|
||||
const responseStreams = new GitResponseStreamRegistry()
|
||||
this.fsHandler = new FsHandler(dispatcher, context, undefined, responseStreams)
|
||||
const watchRegistry = this.fsHandler.getWatchRegistry()
|
||||
this.ptyHandler.setWorktreeRemovalCoordinator(watchRegistry)
|
||||
watchRegistry.setWorktreePtyTeardown((rootPath) =>
|
||||
this.ptyHandler.shutdownForWorktreePath(rootPath)
|
||||
)
|
||||
this.gitHandler = new GitHandler(dispatcher, context, watchRegistry)
|
||||
this.gitHandler = new GitHandler(dispatcher, context, watchRegistry, responseStreams)
|
||||
const preflightHandler = new PreflightHandler(dispatcher)
|
||||
this.skillInstallHandler = new SkillInstallHandler(dispatcher)
|
||||
const externalAutomationsHandler = new ExternalAutomationsHandler(dispatcher)
|
||||
|
||||
@@ -2,13 +2,14 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { reconcileHydratedWorkspaceTabModels } from './reconcile-hydrated-workspace-tab-models'
|
||||
|
||||
describe('reconcileHydratedWorkspaceTabModels', () => {
|
||||
it('reconciles every workspace the session hydrated, in session order', () => {
|
||||
it('reconciles every workspace the session hydrated, in session order, in one call', () => {
|
||||
const reconcile = vi.fn()
|
||||
const reconciled = reconcileHydratedWorkspaceTabModels(
|
||||
{ tabsByWorktree: { 'wt-a': [], 'wt-b': [], 'wt-c': [] } },
|
||||
reconcile
|
||||
)
|
||||
expect(reconcile.mock.calls.map((call) => call[0])).toEqual(['wt-a', 'wt-b', 'wt-c'])
|
||||
expect(reconcile).toHaveBeenCalledTimes(1)
|
||||
expect(reconcile.mock.calls[0]?.[0]).toEqual(['wt-a', 'wt-b', 'wt-c'])
|
||||
expect(reconciled).toEqual(['wt-a', 'wt-b', 'wt-c'])
|
||||
})
|
||||
|
||||
|
||||
@@ -3,12 +3,13 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-st
|
||||
/** Reconcile every workspace loaded during boot so stale unified-tab subsets converge. */
|
||||
export function reconcileHydratedWorkspaceTabModels(
|
||||
session: Pick<WorkspaceSessionState, 'tabsByWorktree'>,
|
||||
reconcileWorktreeTabModel: (worktreeId: string) => unknown
|
||||
// Why batched: one store write for the whole session instead of one per
|
||||
// workspace, each fanning out to every non-React store subscriber.
|
||||
reconcileWorktreeTabModels: (worktreeIds: readonly string[]) => void
|
||||
): string[] {
|
||||
const reconciled: string[] = []
|
||||
for (const worktreeId of Object.keys(session.tabsByWorktree)) {
|
||||
reconcileWorktreeTabModel(worktreeId)
|
||||
reconciled.push(worktreeId)
|
||||
const reconciled = Object.keys(session.tabsByWorktree)
|
||||
if (reconciled.length > 0) {
|
||||
reconcileWorktreeTabModels(reconciled)
|
||||
}
|
||||
return reconciled
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ function makeActions(): StartupActions {
|
||||
reconnectPersistedTerminals: vi.fn(),
|
||||
setTerminalStartupRestorationReady: vi.fn(),
|
||||
setDeferredSshReconnectTargets: vi.fn(),
|
||||
removeDeferredSshReconnectTarget: vi.fn(),
|
||||
setSshConnectionState: vi.fn(),
|
||||
hydratePersistedUI: vi.fn(),
|
||||
setHydrationSucceeded: vi.fn(),
|
||||
|
||||
@@ -22,6 +22,7 @@ export type StartupActions = Pick<
|
||||
| 'reconnectPersistedTerminals'
|
||||
| 'setTerminalStartupRestorationReady'
|
||||
| 'setDeferredSshReconnectTargets'
|
||||
| 'removeDeferredSshReconnectTarget'
|
||||
| 'setSshConnectionState'
|
||||
| 'hydratePersistedUI'
|
||||
| 'setHydrationSucceeded'
|
||||
@@ -59,6 +60,8 @@ export function selectStartupActions(state: StartupActions): StartupActions {
|
||||
cachedStartupActions.setTerminalStartupRestorationReady ===
|
||||
state.setTerminalStartupRestorationReady &&
|
||||
cachedStartupActions.setDeferredSshReconnectTargets === state.setDeferredSshReconnectTargets &&
|
||||
cachedStartupActions.removeDeferredSshReconnectTarget ===
|
||||
state.removeDeferredSshReconnectTarget &&
|
||||
cachedStartupActions.setSshConnectionState === state.setSshConnectionState &&
|
||||
cachedStartupActions.hydratePersistedUI === state.hydratePersistedUI &&
|
||||
cachedStartupActions.setHydrationSucceeded === state.setHydrationSucceeded &&
|
||||
@@ -91,6 +94,7 @@ export function selectStartupActions(state: StartupActions): StartupActions {
|
||||
reconnectPersistedTerminals: state.reconnectPersistedTerminals,
|
||||
setTerminalStartupRestorationReady: state.setTerminalStartupRestorationReady,
|
||||
setDeferredSshReconnectTargets: state.setDeferredSshReconnectTargets,
|
||||
removeDeferredSshReconnectTarget: state.removeDeferredSshReconnectTarget,
|
||||
setSshConnectionState: state.setSshConnectionState,
|
||||
hydratePersistedUI: state.hydratePersistedUI,
|
||||
setHydrationSucceeded: state.setHydrationSucceeded,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '../startup/startup-diagnostics'
|
||||
import { recoverFromDegradedStartup } from '../startup/startup-degraded-recovery'
|
||||
import { restoreSshConnectionsForStartup } from '../startup/startup-ssh-connection-restore'
|
||||
import { collectActiveWorkspaceSshTargetIds } from '../startup/active-workspace-ssh-targets'
|
||||
import { publishTerminalViewAttributesAtAppStart } from '../components/terminal-pane/terminal-appearance'
|
||||
import { getSystemPrefersDark } from '../lib/terminal-theme'
|
||||
import {
|
||||
@@ -155,9 +156,12 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
|
||||
// Why: disconnected SSH repos hydrate from local metadata; only runtime-owned repos use placeholders.
|
||||
parseExecutionHostId(getRepoExecutionHostId(repo))?.kind !== 'runtime'
|
||||
)
|
||||
// Why: worktree refresh can spawn host Git; wait for main's shell-PATH generation fence first.
|
||||
await timeRendererStartupStep('first-window-services-await', () =>
|
||||
window.api.app.awaitFirstWindowStartupServices()
|
||||
// Why this barrier and not the first-window one: worktree refresh can spawn host Git,
|
||||
// which needs the shell-PATH generation and the managed WSL CLI registration. It never
|
||||
// needs the daemon PTY provider or the hook-server bind, and `prepare-terminal-startup-restoration`
|
||||
// below still fences those before any terminal is restored.
|
||||
await timeRendererStartupStep('git-environment-barrier-await', () =>
|
||||
window.api.app.awaitGitEnvironmentStartupBarrier()
|
||||
)
|
||||
await timeRendererStartupStep('fetch-hydration-worktrees', () =>
|
||||
mapWithConcurrency(hydrationRepos, WORKTREE_REFRESH_CONCURRENCY, (repo) =>
|
||||
@@ -198,7 +202,7 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
|
||||
actions.hydrateBrowserSession(sessionRead.session, sessionHydrationOptions)
|
||||
reconcileHydratedWorkspaceTabModels(
|
||||
sessionRead.session,
|
||||
useAppStore.getState().reconcileWorktreeTabModel
|
||||
useAppStore.getState().reconcileWorktreeTabModels
|
||||
)
|
||||
})
|
||||
await timeRendererStartupStep('prepare-terminal-startup-restoration', () =>
|
||||
@@ -213,9 +217,14 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
|
||||
actions.pruneLastVisitedTimestamps()
|
||||
actions.seedActiveWorktreeLastVisitedIfMissing()
|
||||
})
|
||||
await timeRendererStartupStep('fetch-browser-session-profiles', () =>
|
||||
// Why started here but not awaited: on a remote runtime this is an RPC with a 15s
|
||||
// timeout, and nothing between here and terminal restoration reads the profile list —
|
||||
// awaiting it put that timeout on the terminal-restoration gate. Starting it at the
|
||||
// original point keeps the profiles landing no later than they did before; the action
|
||||
// swallows its own failures, so the `.catch` only marks the timing wrapper handled.
|
||||
void timeRendererStartupStep('fetch-browser-session-profiles', () =>
|
||||
actions.fetchBrowserSessionProfiles()
|
||||
)
|
||||
).catch(() => {})
|
||||
const onboardingState = await onboardingPromise
|
||||
if (!cancelled) {
|
||||
onOnboardingLoadedRef.current(onboardingState)
|
||||
@@ -228,9 +237,17 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
|
||||
)
|
||||
if (connectionIds.length > 0) {
|
||||
try {
|
||||
// Why scoped: an unreachable host used to hold every restored terminal — local ones
|
||||
// included — for the full reconnect timeout. Only the targets whose panes mount as
|
||||
// soon as the gate opens are worth waiting for; the rest reattach on tab focus.
|
||||
const blockingConnectionIds = collectActiveWorkspaceSshTargetIds(
|
||||
useAppStore.getState()
|
||||
)
|
||||
await restoreSshConnectionsForStartup({
|
||||
connectionIds,
|
||||
blockingConnectionIds,
|
||||
setDeferredSshReconnectTargets: actions.setDeferredSshReconnectTargets,
|
||||
removeDeferredSshReconnectTarget: actions.removeDeferredSshReconnectTarget,
|
||||
publishSshConnectionState: actions.setSshConnectionState
|
||||
})
|
||||
} catch (err) {
|
||||
@@ -240,7 +257,8 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
|
||||
logRendererStartupDiagnostic('ssh-reconnect-skipped', { connectionIds: 0 })
|
||||
}
|
||||
|
||||
// first-window-services-await already fenced worktree hydration; terminal recovery reuses that ready state.
|
||||
// Why no explicit barrier here: prepare-terminal-startup-restoration above already awaited
|
||||
// the first-window services, and main re-awaits them inside this handler anyway.
|
||||
await timeRendererStartupStep('recover-legacy-worker-terminals-pre-reconnect', () =>
|
||||
window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()
|
||||
)
|
||||
@@ -269,6 +287,16 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
|
||||
// Why (issue #1158): unlock the session writer only after hydration and all dependent steps succeeded, so a mid-startup throw can't serialize partially-mutated state to disk.
|
||||
actions.setHydrationSucceeded(true)
|
||||
actions.setTerminalStartupRestorationReady(true)
|
||||
// Why the explicit opt-in: unconditional seeding hijacks every empty dev
|
||||
// profile's active workspace, making onboarding/empty-state flows untestable.
|
||||
if (
|
||||
import.meta.env.DEV &&
|
||||
String(import.meta.env.VITE_ACTIVITY_DEV_FIXTURE).toLowerCase() === 'true'
|
||||
) {
|
||||
const { seedDevActivityFixture } =
|
||||
await import('../components/activity/dev-activity-fixture')
|
||||
seedDevActivityFixture()
|
||||
}
|
||||
logRendererStartupDiagnostic('startup-hydration-done', {
|
||||
durationMs: Math.round(performance.now() - startupStartedAt)
|
||||
})
|
||||
|
||||
@@ -167,7 +167,11 @@ export function usePersistedUIWriter(): void {
|
||||
// paths in agent-status.ts (close/dismiss) flow to disk through map identity changes.
|
||||
// Without persisting, agent rows that survive restart come back bold even when the
|
||||
// user had already visited them.
|
||||
acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey
|
||||
acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey,
|
||||
// Why: "Clear completed" must survive restart, or cleared done/interrupted rows return.
|
||||
activityClearedAtByPaneKey: s.activityClearedAtByPaneKey,
|
||||
// Why: an explicit "mark unread" must survive restart, or the row comes back read.
|
||||
manuallyUnreadTurnsByPaneKey: s.manuallyUnreadTurnsByPaneKey
|
||||
}))
|
||||
)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -68,8 +68,11 @@ describe('renderer startup runtime routing', () => {
|
||||
const hydrationWorktreesIndex = source.indexOf(
|
||||
"timeRendererStartupStep('fetch-hydration-worktrees'"
|
||||
)
|
||||
const servicesIndex = source.indexOf(
|
||||
"timeRendererStartupStep('first-window-services-await'",
|
||||
// Why this barrier: worktree hydration can spawn host Git, so it must sit behind the
|
||||
// shell-PATH + managed-WSL fence. On packaged Windows the window opens before
|
||||
// shellPathReady resolves, so this really is the fence, not a formality.
|
||||
const gitEnvironmentBarrierIndex = source.indexOf(
|
||||
"timeRendererStartupStep('git-environment-barrier-await'",
|
||||
sessionIndex
|
||||
)
|
||||
const fullWorktreesIndex = source.indexOf('await actions.fetchAllWorktrees()')
|
||||
@@ -89,8 +92,11 @@ describe('renderer startup runtime routing', () => {
|
||||
expect(localReposIndex).toBeLessThan(localGroupsIndex)
|
||||
expect(localGroupsIndex).toBeLessThan(localFoldersIndex)
|
||||
expect(localReposIndex).toBeLessThan(sessionIndex)
|
||||
expect(sessionIndex).toBeLessThan(servicesIndex)
|
||||
expect(servicesIndex).toBeLessThan(hydrationWorktreesIndex)
|
||||
expect(sessionIndex).toBeLessThan(gitEnvironmentBarrierIndex)
|
||||
expect(gitEnvironmentBarrierIndex).toBeLessThan(hydrationWorktreesIndex)
|
||||
expect(source.slice(gitEnvironmentBarrierIndex, hydrationWorktreesIndex)).toContain(
|
||||
'window.api.app.awaitGitEnvironmentStartupBarrier()'
|
||||
)
|
||||
const hydrationWorktreeBlock = source.slice(
|
||||
hydrationWorktreesIndex,
|
||||
source.indexOf('await keybindingsPromise')
|
||||
@@ -180,7 +186,13 @@ describe('renderer startup runtime routing', () => {
|
||||
|
||||
it('waits for first-window startup services before terminal reconnect', () => {
|
||||
const source = readSource(STARTUP_HYDRATION_PATH)
|
||||
const servicesIndex = source.indexOf("timeRendererStartupStep('first-window-services-await'")
|
||||
// Why this step: `app:prepareTerminalStartupRestoration` awaits
|
||||
// firstWindowStartupServicesReady + managedWslCliStartupBarrierReady in main before it
|
||||
// does anything else, so it is the renderer-side position of that fence.
|
||||
// `desktop-startup-ordering.test.ts` pins the main-side await itself.
|
||||
const servicesIndex = source.indexOf(
|
||||
"timeRendererStartupStep('prepare-terminal-startup-restoration'"
|
||||
)
|
||||
const preReconnectRecoveryIndex = source.indexOf(
|
||||
"timeRendererStartupStep('recover-legacy-worker-terminals-pre-reconnect'"
|
||||
)
|
||||
@@ -193,6 +205,9 @@ describe('renderer startup runtime routing', () => {
|
||||
)
|
||||
|
||||
expect(servicesIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(source.slice(servicesIndex)).toContain(
|
||||
'window.api.app.prepareTerminalStartupRestoration()'
|
||||
)
|
||||
expect(preReconnectRecoveryIndex).toBeGreaterThan(servicesIndex)
|
||||
expect(capabilityRefreshIndex).toBeGreaterThan(preReconnectRecoveryIndex)
|
||||
expect(reconnectIndex).toBeGreaterThan(capabilityRefreshIndex)
|
||||
|
||||
@@ -1956,7 +1956,9 @@ html.native-shell .app-layout {
|
||||
transform 120ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||
width 120ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||
opacity 80ms ease-out;
|
||||
will-change: transform, width, opacity;
|
||||
/* Why no `width`: it is not compositable, so hinting it only pins a layer that
|
||||
has to be re-rastered every frame of the transition anyway. */
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
[data-workspace-board-card-drop-indicator='true']::before,
|
||||
|
||||
@@ -399,7 +399,7 @@ describe('buildActivityEvents', () => {
|
||||
expect(threads[0].events[0].entry.prompt).toBe('Retained prior run')
|
||||
})
|
||||
|
||||
it('groups visible threads by current status order', () => {
|
||||
it('groups visible threads with attention states before working and done', () => {
|
||||
const repo = makeRepo()
|
||||
const worktree = makeWorktree()
|
||||
const workingTab = makeTab()
|
||||
@@ -442,11 +442,49 @@ describe('buildActivityEvents', () => {
|
||||
})
|
||||
)
|
||||
|
||||
expect(groups.map((group) => group.id)).toEqual(['working', 'blocked', 'done'])
|
||||
expect(groups.map((group) => group.id)).toEqual(['blocked', 'working', 'done'])
|
||||
expect(groups.map((group) => group.threads.map((thread) => thread.paneKey))).toEqual([
|
||||
[PANE_KEY],
|
||||
[PANE_KEY_2],
|
||||
[PANE_KEY],
|
||||
[PANE_KEY_3]
|
||||
])
|
||||
})
|
||||
|
||||
it('merges runtime orchestration context into activity events and entries', () => {
|
||||
const repo = makeRepo()
|
||||
const worktree = makeWorktree()
|
||||
const tab1 = makeTabWithIds('tab-1', worktree.id)
|
||||
const tab2 = makeTabWithIds('tab-2', worktree.id)
|
||||
const result = buildActivityEvents({
|
||||
agentStatusByPaneKey: {
|
||||
[PANE_KEY]: makeWorkingEntryWithoutHistory(),
|
||||
[PANE_KEY_2]: {
|
||||
...makeWorkingEntryWithoutHistory(),
|
||||
paneKey: PANE_KEY_2,
|
||||
terminalHandle: 'terminal-child'
|
||||
}
|
||||
},
|
||||
runtimeAgentOrchestrationByPaneKey: {
|
||||
[PANE_KEY_2]: {
|
||||
parentPaneKey: PANE_KEY,
|
||||
parentTerminalHandle: 'terminal-parent',
|
||||
taskId: 'task-counsel',
|
||||
dispatchId: 'ctx-counsel'
|
||||
}
|
||||
},
|
||||
retainedAgentsByPaneKey: {},
|
||||
tabsByWorktree: {
|
||||
[worktree.id]: [tab1, tab2]
|
||||
},
|
||||
worktreeMap: new Map([[worktree.id, worktree]]),
|
||||
repoMap: new Map([[repo.id, repo]]),
|
||||
acknowledgedAgentsByPaneKey: {},
|
||||
now: 5_000
|
||||
})
|
||||
|
||||
expect(result.liveAgentByPaneKey[PANE_KEY_2].entry.orchestration?.parentPaneKey).toBe(PANE_KEY)
|
||||
expect(result.liveAgentByPaneKey[PANE_KEY_2].entry.orchestration?.parentTerminalHandle).toBe(
|
||||
'terminal-parent'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user