mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 08:03:33 +00:00
Merge branch 'main' of https://github.com/stablyai/orca into static-analysis-page-stuck-in-loading
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
name: Release Policy
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
- edited
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-policy
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
enforce:
|
||||
if: github.repository == 'stablyai/orca'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Enforce release policy
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const release = context.payload.release;
|
||||
const tag = release.tag_name;
|
||||
const author = release.author?.login;
|
||||
const number = "(?:0|[1-9][0-9]*)";
|
||||
const version = `${number}\\.${number}\\.${number}`;
|
||||
const stableTag = new RegExp(`^v${version}$`);
|
||||
const prereleaseTag = new RegExp(
|
||||
`^(?:v${version}-rc\\.${number}(?:\\.[0-9A-Za-z]+)?|mobile(?:-android)?-v${version})$`,
|
||||
);
|
||||
const expectedPrerelease = prereleaseTag.test(tag);
|
||||
const allowed = author === "github-actions[bot]" &&
|
||||
(stableTag.test(tag) || expectedPrerelease);
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
async function restoreLatestStable() {
|
||||
const releases = await github.paginate(github.rest.repos.listReleases, {
|
||||
owner,
|
||||
repo,
|
||||
per_page: 100,
|
||||
});
|
||||
const stable = releases
|
||||
.filter((candidate) =>
|
||||
!candidate.draft &&
|
||||
!candidate.prerelease &&
|
||||
candidate.author?.login === "github-actions[bot]" &&
|
||||
stableTag.test(candidate.tag_name),
|
||||
)
|
||||
.sort((left, right) => {
|
||||
const a = left.tag_name.slice(1).split(".").map(Number);
|
||||
const b = right.tag_name.slice(1).split(".").map(Number);
|
||||
return a.reduce((result, part, index) => result || part - b[index], 0);
|
||||
})
|
||||
.at(-1);
|
||||
if (stable) {
|
||||
await github.rest.repos.updateRelease({
|
||||
owner,
|
||||
repo,
|
||||
release_id: stable.id,
|
||||
make_latest: "true",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (allowed) {
|
||||
if (release.prerelease !== expectedPrerelease) {
|
||||
await github.rest.repos.updateRelease({
|
||||
owner,
|
||||
repo,
|
||||
release_id: release.id,
|
||||
prerelease: expectedPrerelease,
|
||||
make_latest: expectedPrerelease ? "false" : "legacy",
|
||||
});
|
||||
}
|
||||
await restoreLatestStable();
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.repos.updateRelease({
|
||||
owner,
|
||||
repo,
|
||||
release_id: release.id,
|
||||
draft: true,
|
||||
prerelease: true,
|
||||
make_latest: "false",
|
||||
});
|
||||
await restoreLatestStable();
|
||||
await github.rest.repos.deleteRelease({ owner, repo, release_id: release.id });
|
||||
try {
|
||||
await github.rest.git.deleteRef({ owner, repo, ref: `tags/${tag}` });
|
||||
} catch (error) {
|
||||
if (error.status !== 404) throw error;
|
||||
}
|
||||
core.warning(`Deleted unauthorized release ${tag} created by ${author || "unknown"}.`);
|
||||
@@ -94,6 +94,7 @@ docs/**
|
||||
!docs/readme/
|
||||
!docs/readme/**
|
||||
!docs/STYLEGUIDE.md
|
||||
!docs/ai-vault-process-isolation-plan.md
|
||||
!docs/mobile-terminal-shortcut-bar.md
|
||||
!docs/reference/
|
||||
!docs/reference/git-compatibility.md
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.42](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
|
||||
Pair with your desktop app to monitor and steer your agents from your phone.
|
||||
|
||||
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [Download APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
|
||||
- **Android:** [Download APK 0.0.42](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -182,6 +182,7 @@ module.exports = {
|
||||
'out/main/grok/**',
|
||||
'out/main/hermes/**',
|
||||
'out/main/daemon-entry.js',
|
||||
'out/main/session-scanner-service-entry.js',
|
||||
'out/main/plugin-host-entry.js',
|
||||
'out/main/computer-sidecar.js',
|
||||
'out/main/parcel-watcher-process-entry.js',
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/sidebar/WorktreeCard.tsx",
|
||||
"filePath": "src/renderer/src/components/sidebar/worktree-card-meta-row.tsx",
|
||||
"kind": "jsx-attribute:label",
|
||||
"text": "sidebar",
|
||||
"dynamic": false,
|
||||
|
||||
@@ -235,7 +235,6 @@ inline src/renderer/src/components/settings/Settings.tsx
|
||||
inline src/renderer/src/components/settings/SettingsFormControls.tsx
|
||||
inline src/renderer/src/components/sidebar/RemoteFileBrowser.tsx
|
||||
inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx
|
||||
inline src/renderer/src/components/sidebar/WorktreeCard.tsx
|
||||
inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
|
||||
inline src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts
|
||||
inline src/renderer/src/components/sidebar/WorktreeList.tsx
|
||||
@@ -270,7 +269,6 @@ inline src/renderer/src/hooks/useComposerState.ts
|
||||
inline src/renderer/src/hooks/useEditorExternalWatch.ts
|
||||
inline src/renderer/src/hooks/useIpcEvents.test.ts
|
||||
inline src/renderer/src/hooks/useIpcEvents.ts
|
||||
inline src/renderer/src/hooks/useIssueMetadata.ts
|
||||
inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts
|
||||
inline src/renderer/src/lib/active-agent-note-send.test.ts
|
||||
inline src/renderer/src/lib/file-type-icons.ts
|
||||
@@ -342,7 +340,6 @@ mobile-config app/h/*/source-control/*.tsx
|
||||
mobile-config app/h/*/tasks.tsx
|
||||
mobile-config app/index.tsx
|
||||
mobile-config app/pair-scan.tsx
|
||||
mobile-config app/terminal-settings.tsx
|
||||
mobile-config app/troubleshoot.tsx
|
||||
mobile-config scripts/mock-server.ts
|
||||
mobile-config scripts/repro-terminal-colors.ts
|
||||
|
||||
+168
-45
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-08-09",
|
||||
"updatedAt": "2026-08-10",
|
||||
"policy": {
|
||||
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
|
||||
"blockingPromotion": {
|
||||
@@ -1969,7 +1969,7 @@
|
||||
"invariant": "Close permanently removes the owned provider session, agent descendants, and resume authority even when no TerminalPane is mounted; a terminating id remains reserved through natural exit, duplicate callers await the same completion, and immediate teardown upgrades any graceful request without signalling a recycled PID or a descendant tree after root ownership is lost; process-table work is locale-stable, bounded, fresh for each post-start request, same-turn coalesced, and begins within the requesting caller's deadline, including bulk worktree cleanup; detach and park preserve ownership; aliases prevent a detached agent's immutable physical pane key from being retired with its former tab.",
|
||||
"oracle": "Capture the exact PTY before parking, prove it remains listed while the view is absent, close through the product state boundary, and poll the provider inventory until that exact ID disappears; an agent-marked PTY's detached-pgid child is alive before close and absent afterward; unit tests keep a naturally exited id reserved without re-killing its PID or signalling its captured tree, upgrade pending and post-snapshot graceful kills to immediate, force ps into the C locale, coalesce each bounded bulk-shutdown batch, share duplicate teardown completion, coalesce 20 same-turn process-table requests, start one shared successor without waiting for the prior scan, terminate cyclic-looking traversal, retain the source scan's timestamp, bound both read phases, avoid ambiguous SIGKILL, and assert canonical owner dedupe, exact pane tombstones, chained detach transfer, and restart alias restoration.",
|
||||
"commands": [
|
||||
"pnpm dlx node@24 ./node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-pane-authority.test.ts src/main/ipc/agent-hooks.test.ts src/main/ipc/agent-pane-authority-ownership.test.ts src/main/ipc/pty-management.test.ts src/main/persistence.test.ts src/renderer/src/store/slices/agent-pane-authority.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/renderer/src/store/slices/terminal-tab-retirement.test.ts src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts",
|
||||
"pnpm dlx node@24 ./node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-pane-authority.test.ts src/main/ipc/agent-hooks.test.ts src/main/ipc/agent-pane-authority-ownership.test.ts src/main/ipc/pty-management.test.ts src/main/persistence.test.ts src/renderer/src/store/slices/agent-pane-authority.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/renderer/src/store/slices/terminal-tab-retirement.test.ts src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/pty-descendant-termination.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/providers/local-pty-provider.test.ts src/main/runtime/worktree-teardown.test.ts",
|
||||
"pnpm run test:e2e -- tests/e2e/terminal-parked-close-retirement.spec.ts --workers=1",
|
||||
"pnpm run test:e2e -- tests/e2e/agent-descendant-process-kill.spec.ts --workers=1"
|
||||
@@ -1989,11 +1989,18 @@
|
||||
"src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts",
|
||||
"src/renderer/src/store/slices/terminal-tab-retirement.test.ts",
|
||||
"src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts",
|
||||
"tests/e2e/completed-worker-retirement-resume.unit.test.ts",
|
||||
"src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts",
|
||||
"tests/e2e/terminal-parked-close-retirement.spec.ts",
|
||||
"tests/e2e/agent-descendant-process-kill.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "tests/e2e/completed-worker-retirement-resume.unit.test.ts",
|
||||
"assertions": [
|
||||
"explicit close revokes the exact live recovery record both before and after PTY exit removes the tab, while PTY exit alone preserves interrupted-session recovery"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/terminal-parked-close-retirement.spec.ts",
|
||||
"assertions": [
|
||||
@@ -2711,11 +2718,13 @@
|
||||
"oracle": "Renderer-state tests assert provider-session ownership across preserved and queued panes. Main/provider contracts assert atomic attach-only adoption, stable host/worktree/tab/leaf identity, no fresh spawn on adoption, and safe paired-runtime cancellation. The Electron oracle creates inactive runtime-owned Codex and Setup PTYs plus an unrelated canary, seeds an exact resumable provider session, removes only the target renderer projections, and activates the workspace. It requires byte-stable handle, PTY, incarnation, tab, leaf, process PID, renderer graph, persisted binding, runtime id, graph epoch, and daemon PID across first mount and reload; PID-specific DOM keyboard I/O must remain live with one launch, zero resume argv, zero signals, zero interruption text, and no canary mutation.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts --reporter=verbose",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts src/renderer/src/lib/pane-manager/pane-fit.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts",
|
||||
"pnpm exec electron-vite build --mode e2e && SKIP_BUILD=1 pnpm exec playwright test tests/e2e/live-background-terminal-mount-authority.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/lib/resume-sleeping-agent-session.test.ts",
|
||||
"tests/e2e/completed-worker-retirement-resume.unit.test.ts",
|
||||
"src/main/providers/local-pty-provider.test.ts",
|
||||
"src/main/daemon/terminal-host.test.ts",
|
||||
"src/main/daemon/daemon-pty-adapter.test.ts",
|
||||
@@ -2727,6 +2736,13 @@
|
||||
"tests/e2e/live-background-terminal-mount-authority.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "tests/e2e/completed-worker-retirement-resume.unit.test.ts",
|
||||
"assertions": [
|
||||
"completed and explicitly retired workers retain no provider-resume authority on first workspace activation",
|
||||
"the original tab, leaf, PTY, provider session, release state, replacement count, queued resume argv, emitted restored banner, and unrelated canary remain exact across restart"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/resume-sleeping-agent-session.test.ts",
|
||||
"assertions": [
|
||||
@@ -3656,9 +3672,7 @@
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts",
|
||||
"assertions": [
|
||||
"risky-output atlas recovery is bounded, coalesced, and survives a throwing manager"
|
||||
]
|
||||
"assertions": ["continues recovery when a manager throws after scheduling"]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts",
|
||||
@@ -4315,14 +4329,16 @@
|
||||
"providers": ["paired-runtime", "ssh"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["paired-runtime"],
|
||||
"coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, prove sustained output while all six client xterms are warm-mounted but hidden causes zero renderer scheduler work, ordinary-park five xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore the bounded authoritative tail exactly once on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. A separate headed desktop-client journey relaunches the host main process while retaining the daemon and a daemon-backgrounded PTY, then requires first-observation foreground sync and continued input/output on the same PTY. Unit coverage keeps layout versions out of snapshot output high-waters when a restored snapshot has no output sequence and retries an existing subscriber only after authoritative provider inventory proves the retained PTY exists. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A headed output-drop oracle proves a successful snapshot probe cannot certify a stale live stream when the authoritative PTY sequence advanced beyond the client high-water. Unit coverage requires an unsequenced client to establish a first-probe baseline, remain attached at the same sequence, and recover only after a later probe advances. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, 64 simultaneous synthetic-title sources with zero decorative client events after convergence, per-client late-subscribe recovery, semantic bell/title transitions, local animation-frame preservation, legacy no-output-pause delivery gating with exact snapshot restore, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, live SSH, and production-scale paired hosts remain gaps.",
|
||||
"coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, prove sustained output while all six client xterms are warm-mounted but hidden causes zero renderer scheduler work, then drive 180 timed real-PTY spinner frames through a second production `session.tabs.subscribeAll` transport and the real Zustand store. Each worktree must publish and apply only its exact initial-working and final-idle titles. Both topologies ordinary-park five xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore the bounded authoritative tail exactly once on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. Production-parity React contracts reproduce and remove the independent cold-park watcher/title feedback loop, including memo-cache discard, StrictMode cleanup replay, detached split-PTY replacement, and the zero-park fast path. A deterministic cross-boundary contract drives 24 real PTY trackers in distinct worktrees through 2,880 decorative spinner and bare-native redraw chunks and counts host publications, serialized bytes, renderer apply calls, and store mutations while preserving exact per-PTY raw chunks and per-worktree semantic transitions. Fake-clock continuation beyond the 30-minute stale boundary requires one deduplicated refresh per worktree every 15 minutes, globally spaced by at least 50 ms, with every mirrored working status still fresh. Exact-identical Pi plus rotating normalized Pi/Grok frames renew coarse working/permission state; rich prompt/tool/question fields retain their original 30-minute lease, and `stateStartedAt`/acknowledgement identity remain stable across title/hook source swaps. A separate headed desktop-client journey relaunches the host main process while retaining the daemon and a daemon-backgrounded PTY, then requires first-observation foreground sync and continued input/output on the same PTY. Unit coverage keeps layout versions out of snapshot output high-waters when a restored snapshot has no output sequence and retries an existing subscriber only after authoritative provider inventory proves the retained PTY exists. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A headed output-drop oracle proves a successful snapshot probe cannot certify a stale live stream when the authoritative PTY sequence advanced beyond the client high-water. Unit coverage requires an unsequenced client to establish a first-probe baseline, remain attached at the same sequence, and recover only after a later probe advances. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, 64 simultaneous synthetic-title sources with zero decorative client events after convergence, per-client late-subscribe recovery, semantic bell/title transitions, local animation-frame preservation, legacy no-output-pause delivery gating with exact snapshot restore, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, live SSH, and production-scale paired hosts remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/8652",
|
||||
"https://github.com/stablyai/orca/pull/10625"
|
||||
],
|
||||
"invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. After a host main-process relaunch, unknown local delivery-sync state must not be treated as proof that a surviving daemon PTY is already foregrounded. If subscription precedes provider readiness, authoritative inventory must reconsider that existing subscriber without spawning, resizing, or changing reconnect state. A snapshot without an output high-water must keep it unknown rather than borrowing a layout version that can suppress newer live output. Hosts without the capability must gate hidden raw output before xterm scheduling and repaint from the authoritative snapshot on reveal while retaining the existing limit/TTL force-parking fallback. After a paired client's first semantic title state, decorative spinner frequency must add zero encrypted client-event frames; local title animation and semantic title, status, bell, completion, and query facts remain intact. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.",
|
||||
"oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Drive 64 host PTYs through ten 80 ms synthetic title frames, require zero paired client events after the first frame per PTY while all 640 local frames remain observable, attach a late client and require one current frame per PTY, then require semantic bell and idle transitions on both clients. Reconstruct a legacy subscribed stream with no outputPause capability, hide a chatty pane, require no hidden xterm writes, reveal it, and require one authoritative snapshot plus continued live output. Drop and acknowledge output only for one original paired-client stream, prove the fixture process consumed input while the host model advanced and the client stayed stale, then require the command snapshot probe to replace that stream, repaint exact fixture output, preserve authoritative PTY identity and target-tab cardinality, and resume live I/O. For a client without a delivered sequence, require the first numeric probe to establish a baseline without replacement, a same-sequence probe to remain attached, and a later advanced probe to replace only that stream. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.",
|
||||
"invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Parked watcher synchronization compares parking semantics rather than fresh object identity, owns setup and cleanup across StrictMode replay, reconciles real PTY/layout changes, and performs no terminal-state scan when nothing is parked. After a host main-process relaunch, unknown local delivery-sync state must not be treated as proof that a surviving daemon PTY is already foregrounded. If subscription precedes provider readiness, authoritative inventory must reconsider that existing subscriber without spawning, resizing, or changing reconnect state. A snapshot without an output high-water must keep it unknown rather than borrowing a layout version that can suppress newer live output. Hosts without the capability must gate hidden raw output before xterm scheduling and repaint from the authoritative snapshot on reveal while retaining the existing limit/TTL force-parking fallback. After the first semantic title state, decorative spinner frequency must add zero paired client-event frames and zero full session-tabs work between status-freshness leases regardless of hidden worktree count. Continuous working or permission evidence may refresh each affected worktree once per 15 minutes through one globally spaced FIFO so current and legacy viewers do not decay before 30 minutes; sibling PTYs share that worktree refresh, while local title animation, raw output, and semantic title, status, bell, completion, and query facts remain intact. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.",
|
||||
"oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, hide all six warm managers, emit an initial working title followed by 30 timed real-PTY spinner frames and a final idle title per worktree, and observe a second real session-tabs subscription plus the production renderer store. Require exactly two transport envelopes and two title mutations per worktree, no decorative title crossing either boundary, and positive decoded-envelope bytes. Enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Drive 24 real PTY title trackers in distinct worktrees through 40 decorative spinner frames each, then 40 spinner frames alternating with Cursor's bare native identity redraw, using fake clocks. Require all 2,880 raw chunks exactly and zero host session-tabs publications, serialized bytes, renderer apply calls, and renderer store mutations. Continue decorative evidence beyond 30 minutes, require exactly one refresh per worktree per 15-minute lease through a FIFO spaced by at least 50 ms, and require all 24 viewer statuses to remain working and fresh; then require one exact semantic idle transition and one visible-output chunk per PTY. Require ordinary and interior-Braille title changes to publish. Drive 64 host PTYs through ten 80 ms synthetic title frames, require zero paired client events after the first frame per PTY while all 640 local frames remain observable, attach a late client and require one current frame per PTY, then require semantic bell and idle transitions on both clients. Reconstruct a legacy subscribed stream with no outputPause capability, hide a chatty pane, require no hidden xterm writes, reveal it, and require one authoritative snapshot plus continued live output. Drop and acknowledge output only for one original paired-client stream, prove the fixture process consumed input while the host model advanced and the client stayed stale, then require the command snapshot probe to replace that stream, repaint exact fixture output, preserve authoritative PTY identity and target-tab cardinality, and resume live I/O. For a client without a delivered sequence, require the first numeric probe to establish a baseline without replacement, a same-sequence probe to remain attached, and a later advanced probe to replace only that stream. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts --maxWorkers=1",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts --maxWorkers=1",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api.test.ts src/main/ipc/runtime-environments.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/shared/remote-runtime-shared-control-connection.test.ts",
|
||||
@@ -4335,11 +4351,20 @@
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-stall-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-parked-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts",
|
||||
"src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts",
|
||||
"tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts",
|
||||
"tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx",
|
||||
"src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx",
|
||||
"src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx",
|
||||
"src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts",
|
||||
"src/main/runtime/orca-runtime.test.ts",
|
||||
"src/renderer/src/web/web-preload-api.test.ts",
|
||||
"src/main/ipc/runtime-environments.test.ts",
|
||||
@@ -4371,6 +4396,59 @@
|
||||
"tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts",
|
||||
"assertions": [
|
||||
"one globally spaced FIFO refreshes each continuously active worktree before status expiry",
|
||||
"sibling PTYs deduplicate to one worktree refresh while semantic transitions bypass the queue",
|
||||
"sole-source PTY teardown removes queued work while sibling PTY sources remain deduplicated",
|
||||
"worktree, pending-queue, and timer state is removed on teardown"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts",
|
||||
"assertions": [
|
||||
"a live pending question remains visible under the current live shell title but cannot cross a later working interval, identity-only owner title, or provider generation",
|
||||
"inherited tool names, stale rows, resume-only rows, and unconfirmed restored rows cannot reopen a shell-reclaimed pane"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx",
|
||||
"assertions": [
|
||||
"parked terminal and unified-label title republication settles without a maximum-depth loop",
|
||||
"equivalent semantic inputs remain stable when React discards memoized identities"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx",
|
||||
"assertions": [
|
||||
"StrictMode cleanup replay restores watcher ownership",
|
||||
"detached split-PTY and layout replacement reconciles",
|
||||
"unparked unrelated writes perform zero PTY-map reads"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts",
|
||||
"assertions": [
|
||||
"2,880 decorative spinner and bare-native real-PTY OSC chunks publish zero session-tabs snapshots or serialized bytes after semantic convergence",
|
||||
"the viewer performs zero snapshot applies and zero store mutations during decorative activity",
|
||||
"after 30 minutes, one globally spaced refresh per worktree keeps every mirrored working status fresh",
|
||||
"exact Pi and rotating normalized Pi/Grok frames renew explicit and fallback status without changing stateStartedAt or acknowledgement semantics",
|
||||
"matching title evidence renews retained OSC hook status without resetting stateStartedAt, then drops unverifiable prompt metadata after the hook row expires",
|
||||
"renderer-authored rich status keeps its original lease, then renews only coarse title-confirmed state and provider identity",
|
||||
"ordinary-shell title transitions replace working stateStartedAt with the completion transition time",
|
||||
"decorative heartbeats remain blocked until unknown foreground ownership resolves, then resume at the bounded interval",
|
||||
"tracked terminal state reset clears delayed foreground ownership markers",
|
||||
"raw chunks, semantic idle transitions, and visible output remain exact"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts",
|
||||
"assertions": [
|
||||
"pending renderer, retained OSC, and live-hook questions survive the shell title that currently obscures them",
|
||||
"a new working interval, provider generation, or identity owner strips the prior question"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts",
|
||||
"assertions": ["retries an existing subscriber when provider inventory becomes ready"]
|
||||
@@ -4425,6 +4503,7 @@
|
||||
"assertions": [
|
||||
"ordinary-parks paired terminals and restores authoritative host scrollback",
|
||||
"warm-mounted hidden paired streams schedule zero renderer output work under sustained host output",
|
||||
"six hidden real PTYs publish and apply only exact working and idle title transitions across paired transport",
|
||||
"client recovery completes while every target terminal remains unmounted in the host renderer"
|
||||
]
|
||||
},
|
||||
@@ -4433,6 +4512,7 @@
|
||||
"assertions": [
|
||||
"ordinary-parks paired terminals against an isolated headless Orca host",
|
||||
"cold-activates only visible paired terminals against an isolated headless host",
|
||||
"the headless host bounds real-PTY decorative title transport and production store application",
|
||||
"the headless host shares the zero-hidden-renderer-work and exact-restoration contract"
|
||||
]
|
||||
},
|
||||
@@ -4527,6 +4607,42 @@
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-10",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts --maxWorkers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 1.56,
|
||||
"summary": "All 16 production-parity parking/watcher contracts passed on the combined candidate, covering the maximum-depth loop, semantic stability, StrictMode ownership, split-PTY reconciliation, and zero-park fast path."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-10",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_WEB_CLIENT=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 27.6,
|
||||
"summary": "The isolated headed desktop host and separate encrypted paired client drove 180 timed decorative real-PTY titles across six hidden worktrees and observed exactly 12 semantic transport envelopes and 12 production store title mutations, then reduced six managers to one, stayed within heap/cell/timer budgets, restored the bounded host tail exactly, and continued input/output while the host renderer retained zero target managers."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-10",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 33.9,
|
||||
"summary": "The isolated headless `orca serve` host passed the same six-worktree real-title transport/store bound, parking, and exact-restoration oracle; a second scenario proved cold activation mounted only the visible paired terminal."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-10",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts --maxWorkers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 5.62,
|
||||
"summary": "Forty-two deterministic contracts passed. The real-PTY cross-boundary contract delivered all 2,880 decorative spinner and bare-native redraw chunks with zero hot-path host publications, serialized bytes, renderer applies, or store mutations; continued past 30 minutes with one globally spaced freshness refresh per worktree every 15 minutes; kept all 24 mirrored statuses working; and preserved stable stateStartedAt. Exact-identical Pi and rotating normalized Pi/Grok frames renewed renderer-authored and retained-hook status without changing acknowledgement semantics. Rich status could not cross task completion, permission, provider-generation, or identity-owner boundaries, while neutral titles preserved same-task prompt and acknowledgement identity until authoritative shell evidence. Fresh renderer, retained OSC, and live-hook questions remained visible under the current shell title, but a later working interval, identity-only owner title, or provider generation stripped them; inherited tools and stale, resume-only, restored, or prior-interval questions stayed suppressed. After each rich source's original lease expired, title evidence kept only coarse working state and provider identity fresh while dropping unverifiable prompts. Unknown foreground ownership held publications at zero until resolution, then resumed the bounded heartbeat; tracked terminal state reset removed its delayed marker. The focused heartbeat contracts proved worktree deduplication, FIFO fairness, semantic bypass, PTY-source teardown, and timer cleanup. Causal-disable controls made ownership, teardown, retained source-switch, renderer rich-TTL, completion-transition, boundary-ordering, and live-question oracles fail; restoring the production corrections returned green."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-07",
|
||||
"runner": "local",
|
||||
@@ -4691,11 +4807,11 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The six-PTY ordinary-parking oracle was red in two independently observed stages: the parked paired remount first used detached attach and restored only rows 5,963–5,999, then the requested snapshot still lost to the current-screen replay until park reveals explicitly entered the capability-gated reattach coordinator. The candidate is green on headed and headless hosts, while disabling terminal.paired-parking.v1 makes the byte-identical oracle fail at its capability precondition and leaves the separately tested legacy lossy fallback green. The 64-PTY decorative-title control delivered 576 client events after convergence on unmodified main; the candidate delivers zero while preserving all 640 local frames, one current frame to a late client, and bell/idle transitions to both clients. The legacy no-output-pause control failed to request a reveal snapshot and kept hidden bytes on the xterm path; the candidate gates those bytes locally and restores one authoritative snapshot before live output. With stalled-stream recovery disabled, the host cursor advanced but the client remained frozen; enabling it repainted the marker with the same PTY and tab. The sequence-gap unit and headed paired oracle are red on current main and with only the new probe decision reverted: the authoritative host advances while the client stays stale after a successful probe. The candidate replaces the stream, repaints the marker, preserves PTY identity, and resumes I/O. Reverting the active-stream limit to 64 makes the exact 128-stream contract fail at 64; restoring 128 admits every intended stream, rejects 129, and reattaches it after one slot releases. The host-relaunch oracle fails when unknown delivery state is coerced to foregrounded or a layout version becomes an output high-water; the candidate foregrounds the preserved daemon PTY once and resumes same-PTY input/output. When authoritative inventory overlaps a refused in-flight attach, removing only the post-settlement retry leaves the existing subscriber without daemon output; the candidate retains one waiter across repeated inventory publications, retries exactly once, and restores live output."
|
||||
"evidence": "The six-PTY ordinary-parking oracle was red in two independently observed stages: the parked paired remount first used detached attach and restored only rows 5,963–5,999, then the requested snapshot still lost to the current-screen replay until park reveals explicitly entered the capability-gated reattach coordinator. The candidate is green on headed and headless hosts, while disabling terminal.paired-parking.v1 makes the byte-identical oracle fail at its capability precondition and leaves the separately tested legacy lossy fallback green. The parked-title production-parity control throws Maximum update depth exceeded on the affected parking synchronization and settles with semantic dependencies; forced memo-cache discard, StrictMode cleanup replay, and split-PTY replacement are independently red before their corrections and green on the combined candidate. The real-PTY session-tabs oracle produces 96 full snapshots, 64,032 serialized bytes, 96 renderer applies, and 96 store mutations on unmodified main and on a one-line causal revert before reaching the alternating native-title phase; the candidate produces zero in all four dimensions while preserving all 2,880 redraw chunks and 24 exact semantic transitions. The 64-PTY decorative-title control delivered 576 client events after convergence on unmodified main; the candidate delivers zero while preserving all 640 local frames, one current frame to a late client, and bell/idle transitions to both clients. The legacy no-output-pause control failed to request a reveal snapshot and kept hidden bytes on the xterm path; the candidate gates those bytes locally and restores one authoritative snapshot before live output. With stalled-stream recovery disabled, the host cursor advanced but the client remained frozen; enabling it repainted the marker with the same PTY and tab. The sequence-gap unit and headed paired oracle are red on current main and with only the new probe decision reverted: the authoritative host advances while the client stays stale after a successful probe. The candidate replaces the stream, repaints the marker, preserves PTY identity, and resumes I/O. Reverting the active-stream limit to 64 makes the exact 128-stream contract fail at 64; restoring 128 admits every intended stream, rejects 129, and reattaches it after one slot releases. The host-relaunch oracle fails when unknown delivery state is coerced to foregrounded or a layout version becomes an output high-water; the candidate foregrounds the preserved daemon PTY once and resumes same-PTY input/output. When authoritative inventory overlaps a refused in-flight attach, removing only the post-settlement retry leaves the existing subscriber without daemon output; the candidate retains one waiter across repeated inventory publications, retries exactly once, and restores live output."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Before ordinary parking, six warm-mounted hidden paired xterms receive zero raw frames and schedule zero renderer output drains while host models continue ingesting sustained output. Ordinary parking then retains the host PTY and bounded 5,000-row provider model while destroying five client xterms. Decorative title ticks are collapsed per paired subscriber before JSON serialization and encryption; semantic facts remain on the singleton client-event stream. Headed and headless oracles require exact reduction from six managers to one, at least 55% staged xterm-cell release, no more than 16 MiB heap growth, and under 500 ms timer drift. Legacy hosts retain the prior 12-worktree/45-minute lossy force-parking policy but now gate hidden raw output before xterm scheduling and restore from a bounded snapshot on reveal. Stream recovery adds no polling and is scoped to one stream. Provider-ready reconciliation runs only for PTYs returned by a successful controller inventory, requires an existing remote subscriber, and retains at most one post-settlement waiter per PTY while attach deduplication remains active."
|
||||
"evidence": "Before ordinary parking, six warm-mounted hidden paired xterms receive zero raw frames and schedule zero renderer output drains while host models continue ingesting sustained output. The headed and headless production transports each admit only the initial and final semantic title snapshots per worktree across 180 timed decorative frames, and the real store performs only those 12 exact title mutations. Ordinary parking then retains the host PTY and bounded 5,000-row provider model while destroying five client xterms. Parked watcher synchronization is keyed by bounded parking semantics rather than fresh object identities; unrelated store writes perform zero PTY-map reads when no tab is parked, while parked reconciliation remains bounded by the worktree's terminal tabs and assignments. Decorative title ticks are collapsed both on the paired client-event channel and at the authoritative real-PTY session-tabs classifier before snapshot construction, serialization, transport, recovery, or store application; semantic facts remain exact. Headed and headless oracles require exact reduction from six managers to one, at least 55% staged xterm-cell release, no more than 16 MiB heap growth, and under 500 ms timer drift. The forced hidden-worktree retention policy keeps at most four otherwise un-parkable worktrees mounted and force-parks all after 15 minutes; the separate ordinary hot-retain policy keeps at most four recently visible worktrees warm for five minutes. Legacy hosts still gate hidden raw output before xterm scheduling and restore from a bounded snapshot on reveal. Stream recovery adds no polling and is scoped to one stream. Provider-ready reconciliation runs only for PTYs returned by a successful controller inventory, requires an existing remote subscriber, and retains at most one post-settlement waiter per PTY while attach deduplication remains active."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect 100 consecutive CI passes or 14 days of soak history for the headed and headless parking commands.",
|
||||
@@ -4712,7 +4828,7 @@
|
||||
"The legacy protocol shape is deterministic unit coverage; a packaged pre-v1.4.163 host has not yet run the full headed memory oracle.",
|
||||
"The live windows-issues incident recorded two fast snapshot-probe successes while fresh output remained stale, but that release did not log the returned snapshot sequence; the deterministic sequence-gap oracle matches the observed false-health boundary without proving the field transport's lower-level drop mechanism."
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote to protection none if decorative title frequency changes paired client-event volume after convergence, a semantic fact is dropped, paired first paint is blank, passive work reconnects after manual disconnect, a capable hidden paired manager survives ordinary parking, a legacy hidden pane schedules raw xterm output or bypasses its fallback, reveal loses requested bounded history or live PTY identity, retained buffer cells do not fall, or either headed/headless scenario flakes."
|
||||
"demotionRule": "Keep experimental or demote to protection none if decorative title frequency changes paired client-event or session-tabs publication volume after convergence, a semantic fact is dropped, paired first paint is blank, passive work reconnects after manual disconnect, a capable hidden paired manager survives ordinary parking, a legacy hidden pane schedules raw xterm output or bypasses its fallback, reveal loses requested bounded history or live PTY identity, retained buffer cells do not fall, or either headed/headless scenario flakes."
|
||||
},
|
||||
{
|
||||
"id": "terminal-performance.no-hot-list-sessions",
|
||||
@@ -7767,16 +7883,24 @@
|
||||
"invariant": "A settled Dispatch may close only its one coordinator-created terminal lease. Explicit reuse, real user input, retain, identity or host change, ambiguity, and another resource for the same exact host/pane/process must fence closure. Output preservation and the requested-to-releasing transition are atomic, archives remain readable without the provider file, retries resume idempotently, and orchestration reset removes archive and authority state.",
|
||||
"oracle": "Record release intent for a settled owner, attempt exact reuse before close, and require worker-start to fail with terminal_release_in_progress while the terminal stays open; then release the original owner exactly once. Race retain and real user input against a controlled archive promise and require no committed archive or close. Change host or process identity and inject duplicate resource evidence to require retention. Freeze a structured transcript, delete its source file, and require archived worker-read to return the same bounded redacted messages. Restart a pending mutation, reset orchestration state, and create 50 resources while asserting replay convergence, zero orphan rows, two-query worker listing, and no unrelated close.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot"
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts --reporter=verbose"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/runtime/rpc/methods/orchestration-worker-release.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts",
|
||||
"src/main/runtime/rpc/orchestration-mutation-ledger.test.ts",
|
||||
"src/main/runtime/orchestration/worker-transcript-read.test.ts",
|
||||
"src/renderer/src/lib/worker-terminal-takeover-report.test.ts"
|
||||
"src/renderer/src/lib/worker-terminal-takeover-report.test.ts",
|
||||
"tests/e2e/completed-worker-retirement-resume.unit.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "tests/e2e/completed-worker-retirement-resume.unit.test.ts",
|
||||
"assertions": [
|
||||
"an already-exited succeeded Dispatch reaches released while its late exact close revokes the missing tab's stale resume record"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/methods/orchestration-worker-release.test.ts",
|
||||
"assertions": [
|
||||
@@ -8881,7 +9005,7 @@
|
||||
},
|
||||
{
|
||||
"id": "terminal-render.atlas-recovery-fanout",
|
||||
"title": "Terminal atlas recovery stays bounded to visible renderers",
|
||||
"title": "Ordinary terminal output avoids shared atlas recovery fanout",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-rendering",
|
||||
@@ -8890,31 +9014,33 @@
|
||||
"WebGL rendering",
|
||||
"document visibility",
|
||||
"hidden terminal output",
|
||||
"alternate-screen redraws",
|
||||
"paired terminal traffic"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "daemon", "ssh", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local"],
|
||||
"coverageNotes": "A deterministic renderer contract models one visible manager plus 64 mounted hidden managers, hidden synchronized/TUI output, and five minutes of sustained streaming recovery requests. A macOS Electron run verifies that a document visibility cycle preserves real WebGL atlases and terminal pixels. Production v1.4.163 evidence linked the same 49-manager fanout to paired traffic, but the candidate has not been rerun against an isolated live paired server.",
|
||||
"coverageNotes": "Deterministic renderer contracts require foreground renderer-risk, synchronized, hidden, and alternate-screen PTY output to stay on pane-local repaint paths without a global atlas reset. Separate contracts preserve explicit paste, reveal, visibility-resume, system-resume, and confirmed-desync recovery. A macOS Electron run verifies that a document visibility cycle preserves real WebGL atlases and terminal pixels; the candidate has not been rerun against an isolated live paired server.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/7054",
|
||||
"https://github.com/stablyai/orca/pull/7604",
|
||||
"https://github.com/stablyai/orca/issues/12094"
|
||||
],
|
||||
"invariant": "Ordinary document visibility transitions and hidden terminal output must not clear the shared WebGL glyph atlas. Heavy reset-and-refresh recovery may touch only managers with visible terminal surfaces; hidden managers recover when revealed. Sustained streaming recovery requests must keep resets rate-bounded without starving repair. Genuine OS resume remains a heavy recovery trigger.",
|
||||
"oracle": "Dispatch a visible document visibilitychange and require atlas-preserving wake recovery; register one visible manager and 64 hidden managers and require exactly one reset and one refresh; parse hidden synchronized and high-confidence TUI output and require zero global atlas-recovery schedules. Across five minutes of 300ms streaming recovery requests, require more than one but at most 75 resets, no internal or trailing repair gap above 6.5 seconds, atlas-preserving presentation while a reset is suppressed, and one delayed wipe for a final suppressed settle. Separately, drive an Electron visibility cycle with two real WebGL panes, require zero atlas clears, and retain at least 85% of each pane's baseline ink pixels.",
|
||||
"invariant": "Ordinary PTY output must never clear the shared WebGL glyph atlas, including synchronized frames, hidden output, renderer-risk glyphs, and alternate-screen entry, redraw, or exit. Affected visible panes still receive pane-local refresh after parsing. Explicit paste, reveal, visibility-resume, system-resume, context-loss, and confirmed-desync recovery remain available and may reset visible renderers.",
|
||||
"oracle": "Parse foreground renderer-risk, synchronized, hidden, and alternate-screen PTY output and require pane-local refresh where needed with zero calls to shared reset-and-refresh recovery. Require the atlas-recovery module to expose only explicit lifecycle entry points, then verify paste and tab reveal still perform their recovery bursts. Dispatch a visible document visibilitychange and require atlas-preserving wake recovery; register one visible manager and 64 hidden managers and require heavy recovery to reset and refresh only the visible manager. Separately, drive an Electron visibility cycle with two real WebGL panes, require zero atlas clears, and retain at least 85% of each pane's baseline ink pixels.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery-rate.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/components/terminal-pane/terminal-render-desync-sentinel.test.ts src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts",
|
||||
"pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery-rate.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts",
|
||||
"src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-render-desync-sentinel.test.ts",
|
||||
"src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-connection.test.ts",
|
||||
"tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts"
|
||||
],
|
||||
@@ -8930,22 +9056,28 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery-rate.test.ts",
|
||||
"assertions": [
|
||||
"sustained streaming requests keep atlas resets rate-bounded with no repair starvation",
|
||||
"suppressed resets present live buffers, cancel repair during resumed output, receive a trailing repair after settle, and clean up pending timers"
|
||||
]
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts",
|
||||
"assertions": ["limits atlas recovery to explicit renderer lifecycle events"]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts",
|
||||
"assertions": ["bounds atlas recovery to visible managers"]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-render-desync-sentinel.test.ts",
|
||||
"assertions": ["persists and recovers after the same cells stay missing twice"]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts",
|
||||
"assertions": ["recovers a context-lost pane on the next rendering resume"]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts",
|
||||
"assertions": [
|
||||
"defers hidden synchronized-output atlas recovery until reveal",
|
||||
"defers hidden high-confidence TUI redraw recovery until reveal",
|
||||
"advances hidden rewrite state without scheduling atlas recovery"
|
||||
"keeps hidden synchronized output off the global atlas recovery path",
|
||||
"refreshes renderer-risk output without clearing the shared glyph atlas",
|
||||
"refreshes alternate-screen redraws without clearing the shared glyph atlas",
|
||||
"avoids atlas clears when one chunk enters and exits alternate screen"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -8957,22 +9089,13 @@
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-04",
|
||||
"date": "2026-08-10",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery-rate.test.ts",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/components/terminal-pane/terminal-render-desync-sentinel.test.ts src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 0.5,
|
||||
"summary": "Seven deterministic tests passed for sustained reset cadence, bounded repair gaps, suppressed presentation with trailing repair, clock rollback, one-shot bypasses, and pending-timer cleanup."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-01",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 26,
|
||||
"summary": "All 577 focused renderer contracts passed, including atlas-preserving visibility, visible-only recovery fanout, and hidden-output rewrite-state coverage."
|
||||
"durationSeconds": 47,
|
||||
"summary": "All 625 focused renderer contracts passed on the final head for atlas-preserving visibility, visible-only heavy recovery fanout, confirmed-desync and context-loss recovery, explicit lifecycle recovery, pane-local foreground redraws, and zero ordinary-output atlas resets."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-01",
|
||||
@@ -9003,15 +9126,15 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "With the fix disabled, the byte-identical unit oracle observed clearGlyphAtlases=true, reset/refreshed all 65 managers, and scheduled hidden synchronized/TUI recovery one to three times. Restoring the fix made every assertion pass."
|
||||
"evidence": "Before the change, foreground renderer-risk and alternate-screen PTY writes requested terminal-output atlas recovery after parsing; hidden synchronized/TUI output also drove the same path. The updated oracle spies on that request before its removed 200ms debounce and on the eventual shared reset, while explicit lifecycle recovery remains required."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "A recovery with one visible and 64 hidden managers performs one reset and one refresh instead of 65 of each. Hidden synchronized/TUI output schedules zero global recovery work. Sustained 300ms recovery requests keep full atlas resets at or below 75 over five minutes while preserving a 6.5-second maximum repair gap. A final suppressed settle schedules one cancelable repair at the next budget boundary; resumed streaming cancels it. The rate budget adds no polling, recurring timer, provider call, or parking change."
|
||||
"evidence": "A 120-frame foreground redraw at 300ms cadence produced 13 terminal-output atlas reset cycles before the change and zero afterward over 37.470 seconds. Ordinary PTY output now adds no atlas debounce, retry timer, buffer-switch listener, global manager traversal, provider call, or recurring work; pane-local refresh remains for renderer-risk output. Explicit heavy recovery still touches visible managers only."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Accumulate stable macOS Electron runs with real BrowserWindow visibility transitions.",
|
||||
"Run an isolated headed paired-server terminal flood and verify bounded renderer CPU and atlas diagnostics.",
|
||||
"Run an isolated headed paired-server terminal flood and verify renderer CPU and atlas diagnostics remain flat under ordinary output.",
|
||||
"Add Linux and Windows WebGL visibility evidence before claiming cross-platform visual coverage."
|
||||
],
|
||||
"knownGaps": [
|
||||
@@ -9019,7 +9142,7 @@
|
||||
"No isolated live paired-server candidate run is recorded; remote-runtime coverage is a provider-agnostic renderer contract plus production incident evidence.",
|
||||
"The gate counts recovery fanout and pixel retention but does not impose an end-to-end renderer frame-latency threshold."
|
||||
],
|
||||
"demotionRule": "Demote if hidden managers re-enter reset/refresh recovery, ordinary visibility clears the atlas, real WebGL pixels regress, or the focused Electron test cannot remain deterministic."
|
||||
"demotionRule": "Demote if ordinary PTY output re-enters shared reset/refresh recovery, pane-local redraw coverage regresses, ordinary visibility clears the atlas, real WebGL pixels regress, or the focused Electron test cannot remain deterministic."
|
||||
},
|
||||
{
|
||||
"id": "terminal-render.atlas-identity-invalidation",
|
||||
|
||||
@@ -18,6 +18,7 @@ const __dirname = import.meta.dirname
|
||||
const ROOT = join(__dirname, '..', '..')
|
||||
const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts')
|
||||
const WATCHER_ENTRY = join(ROOT, 'src', 'main', 'ipc', 'parcel-watcher-process-entry.ts')
|
||||
const AI_VAULT_SERVICE_ENTRY = join(ROOT, 'src', 'relay', 'ai-vault-service-entry.ts')
|
||||
const MANAGED_HOOK_RUNTIME_ENTRY = join(
|
||||
ROOT,
|
||||
'src',
|
||||
@@ -88,6 +89,21 @@ for (const platform of PLATFORMS) {
|
||||
}
|
||||
})
|
||||
|
||||
await build({
|
||||
entryPoints: [AI_VAULT_SERVICE_ENTRY],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'cjs',
|
||||
outfile: join(outDir, 'relay-ai-vault-service.js'),
|
||||
external: ['electron'],
|
||||
sourcemap: false,
|
||||
minify: true,
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"'
|
||||
}
|
||||
})
|
||||
|
||||
await build({
|
||||
entryPoints: [MANAGED_HOOK_RUNTIME_ENTRY],
|
||||
bundle: true,
|
||||
@@ -110,10 +126,12 @@ for (const platform of PLATFORMS) {
|
||||
// so a companion-only change always deploys beside the matching relay host.
|
||||
const relayContent = readFileSync(join(outDir, 'relay.js'))
|
||||
const watcherContent = readFileSync(join(outDir, 'relay-watcher.js'))
|
||||
const aiVaultServiceContent = readFileSync(join(outDir, 'relay-ai-vault-service.js'))
|
||||
const managedHookRuntimeContent = readFileSync(join(outDir, 'managed-hook-runtime.js'))
|
||||
const hash = createHash('sha256')
|
||||
.update(relayContent)
|
||||
.update(watcherContent)
|
||||
.update(aiVaultServiceContent)
|
||||
.update(managedHookRuntimeContent)
|
||||
// Why: changing the remote node-pty patch must select a fresh immutable Windows relay directory.
|
||||
if (platform.startsWith('win32-')) {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env node
|
||||
// Run: node config/scripts/legacy-worker-recovery-persistence-benchmark.mjs
|
||||
import { closeSync, fsyncSync, openSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtemp, open, readFile, rename } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../..', import.meta.url))
|
||||
const runtimePath = join(repoRoot, 'src/main/runtime/orca-runtime.ts')
|
||||
const args = new Map(
|
||||
process.argv.slice(2).map((value, index, values) => [value, values[index + 1]])
|
||||
)
|
||||
const fixtureMiB = Number(args.get('--fixture-mib') ?? 24)
|
||||
const trials = Number(args.get('--trials') ?? 3)
|
||||
const jsonOutput = process.argv.includes('--json')
|
||||
|
||||
if (!Number.isInteger(fixtureMiB) || fixtureMiB < 1 || !Number.isInteger(trials) || trials < 1) {
|
||||
throw new Error('fixture-mib and trials must be positive integers')
|
||||
}
|
||||
|
||||
const runtimeSource = await readFile(runtimePath, 'utf8')
|
||||
const recoveryStart = runtimeSource.indexOf(
|
||||
'private async persistLegacyWorkerTerminalRecoveryBatch'
|
||||
)
|
||||
const recoveryEnd = runtimeSource.indexOf(
|
||||
'private reconcileMissingLegacyWorkerTerminal',
|
||||
recoveryStart
|
||||
)
|
||||
const recoverySource = runtimeSource.slice(recoveryStart, recoveryEnd)
|
||||
if (
|
||||
recoveryStart < 0 ||
|
||||
recoveryEnd < 0 ||
|
||||
!recoverySource.includes('await this.flushWorkspaceSessionOrThrowAsync()') ||
|
||||
recoverySource.includes('flushOrThrow()')
|
||||
) {
|
||||
throw new Error('recovery persistence implementation changed; update this benchmark')
|
||||
}
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-legacy-recovery-benchmark-'))
|
||||
const filler = 'x'.repeat(fixtureMiB * 1024 * 1024)
|
||||
|
||||
function payload(state) {
|
||||
return JSON.stringify({ state, filler })
|
||||
}
|
||||
|
||||
function writeDurableSync(path, body) {
|
||||
const tempPath = `${path}.sync.tmp`
|
||||
writeFileSync(tempPath, body)
|
||||
const fd = openSync(tempPath, 'r')
|
||||
try {
|
||||
fsyncSync(fd)
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
renameSync(tempPath, path)
|
||||
}
|
||||
|
||||
async function writeDurableAsync(path, body) {
|
||||
const tempPath = `${path}.async.tmp`
|
||||
const handle = await open(tempPath, 'w')
|
||||
try {
|
||||
await handle.writeFile(body)
|
||||
await handle.sync()
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
await rename(tempPath, path)
|
||||
}
|
||||
|
||||
async function measure(run) {
|
||||
let maxEventLoopDelayMs = 0
|
||||
let expected = performance.now() + 1
|
||||
const timer = setInterval(() => {
|
||||
const now = performance.now()
|
||||
maxEventLoopDelayMs = Math.max(maxEventLoopDelayMs, now - expected)
|
||||
expected = now + 1
|
||||
}, 1)
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
const startedAt = performance.now()
|
||||
await run()
|
||||
const durationMs = performance.now() - startedAt
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
clearInterval(timer)
|
||||
return { durationMs, maxEventLoopDelayMs }
|
||||
}
|
||||
|
||||
function median(values) {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
return sorted[Math.floor(sorted.length / 2)]
|
||||
}
|
||||
|
||||
async function runLegacy(path) {
|
||||
const state = { fenced: false, surfacePresent: true, recoveryRecordPresent: true }
|
||||
state.fenced = true
|
||||
writeDurableSync(path, payload(state))
|
||||
state.surfacePresent = false
|
||||
writeDurableSync(path, payload(state))
|
||||
state.recoveryRecordPresent = false
|
||||
writeDurableSync(path, payload(state))
|
||||
}
|
||||
|
||||
async function runBatched(path) {
|
||||
const state = { fenced: false, surfacePresent: true, recoveryRecordPresent: true }
|
||||
state.fenced = true
|
||||
state.surfacePresent = false
|
||||
state.recoveryRecordPresent = false
|
||||
await writeDurableAsync(path, payload(state))
|
||||
}
|
||||
|
||||
try {
|
||||
const legacy = []
|
||||
const batched = []
|
||||
for (let index = 0; index < trials; index += 1) {
|
||||
legacy.push(await measure(() => runLegacy(join(root, `legacy-${index}.json`))))
|
||||
batched.push(await measure(() => runBatched(join(root, `batched-${index}.json`))))
|
||||
}
|
||||
const result = {
|
||||
benchmark: 'legacy-worker-recovery-persistence',
|
||||
fixtureMiB,
|
||||
trials,
|
||||
legacy: {
|
||||
durableWrites: 3,
|
||||
medianDurationMs: median(legacy.map((sample) => sample.durationMs)),
|
||||
medianMaxEventLoopDelayMs: median(legacy.map((sample) => sample.maxEventLoopDelayMs))
|
||||
},
|
||||
batchedAsync: {
|
||||
durableWrites: 1,
|
||||
medianDurationMs: median(batched.map((sample) => sample.durationMs)),
|
||||
medianMaxEventLoopDelayMs: median(batched.map((sample) => sample.maxEventLoopDelayMs))
|
||||
}
|
||||
}
|
||||
if (jsonOutput) {
|
||||
console.log(JSON.stringify(result))
|
||||
} else {
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
}
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
@@ -1058,36 +1058,6 @@
|
||||
"auto.components.feature.wall.EditorAnimatedVisual.e16479c1c5": {
|
||||
"ko": "[data-slash-menu] [data-slash-row].slash-active { background: rgba(24,24,27,0.07); box-shadow: inset 0 0 0 1px rgba(24,24,27,0.06); } [data-md-active-line][data-role=\"active\"] { color: rgb(113 113 122); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; } [data-md-active-line][data-role=\"h1\"] { color: inherit; font-family: inherit; font-size: 18px; font-weight: 700; letter-spacing: -0.01em; line-height: 1.2; margin-top: 6px; } [data-md-caret] { display: inline-block; width: 1.5px; height: 1em; background: currentColor; vertical-align: -2px; margin-left: 1px; animation: md-caret-blink 1.05s steps(1) infinite; } @keyframes md-caret-blink { 0%, 50% { opacity: 1 } 51%, 100% { opacity: 0 } } @keyframes md-block-in { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: none; } } @keyframes md-cursor-ripple { 0% { transform: scale(0.4); opacity: 0.9; } 100% { transform: scale(1.4); opacity: 0; } } [data-clicking=\"1\"] [data-cursor-ripple] { animation: md-cursor-ripple 460ms ease-out forwards; }"
|
||||
},
|
||||
"auto.components.feature.wall.FeatureTourPreview.04d54d50ec": {
|
||||
"ko": "orca · zsh"
|
||||
},
|
||||
"auto.components.feature.wall.FeatureTourPreview.304ad0dfc1": {
|
||||
"ko": "생각 중..."
|
||||
},
|
||||
"auto.components.feature.wall.FeatureTourPreview.3822d8d14b": {
|
||||
"ko": "fix/worktree-picker-truncates"
|
||||
},
|
||||
"auto.components.feature.wall.FeatureTourPreview.47f16ecf34": {
|
||||
"ko": "여러 작업을 동시에 진행하세요. 각 워크스페이스는 브랜치, terminal, agent 활동을 함께 보관합니다."
|
||||
},
|
||||
"auto.components.feature.wall.FeatureTourPreview.5d6ee181b6": {
|
||||
"ko": "워크스페이스를 열어 terminal로 돌아가고, 테스트와 로그, agents 용으로 패널을 나누세요."
|
||||
},
|
||||
"auto.components.feature.wall.FeatureTourPreview.6218a9014d": {
|
||||
"ko": "pnpm playwright test"
|
||||
},
|
||||
"auto.components.feature.wall.FeatureTourPreview.6ed43cb0e0": {
|
||||
"ko": "dashboard.spec.ts"
|
||||
},
|
||||
"auto.components.feature.wall.FeatureTourPreview.70aa182266": {
|
||||
"ko": "목표를 넘겨두고 자리를 비우세요. 코디네이터 agent가 작업을 나누어 병렬 PR을 만듭니다."
|
||||
},
|
||||
"auto.components.feature.wall.FeatureTourPreview.f10c14dd9d": {
|
||||
"ko": "탭을 오갈 필요 없이 GitHub 또는 Linear 백로그에서 선택하고 한 번의 클릭으로 워크스페이스를 시작하세요."
|
||||
},
|
||||
"auto.components.feature.wall.FeatureTourWorkspaceCard.9220794f6a": {
|
||||
"ko": "작업 중"
|
||||
},
|
||||
"auto.components.feature.wall.FeatureWallSetupChecklist.713cc529a5": {
|
||||
"ko": "마일스톤"
|
||||
},
|
||||
@@ -1502,12 +1472,6 @@
|
||||
"auto.components.new.workspace.ProjectCombobox.search": {
|
||||
"ko": "프로젝트 검색..."
|
||||
},
|
||||
"auto.components.new.workspace.ProjectHostSetupCombobox.empty": {
|
||||
"ko": "이 프로젝트에 준비된 호스트가 없습니다."
|
||||
},
|
||||
"auto.components.new.workspace.ProjectHostSetupCombobox.placeholder": {
|
||||
"ko": "호스트 선택"
|
||||
},
|
||||
"auto.components.notification.sound.options.0acd3d384e": {
|
||||
"ko": "클랙"
|
||||
},
|
||||
@@ -1520,9 +1484,6 @@
|
||||
"auto.components.notification.sound.options.86af8d938c": {
|
||||
"ko": "봉 소리"
|
||||
},
|
||||
"auto.components.onboarding.AgentFeatureSetupStep.97dcdc010f": {
|
||||
"ko": "기능 활성화"
|
||||
},
|
||||
"auto.components.onboarding.AgentStep.1eee1c7bd8": {
|
||||
"ko": "PATH에서 agents를 찾지 못했습니다. 나중에 설치할 항목을 선택하거나 빈 terminal로 계속 진행하세요."
|
||||
},
|
||||
@@ -1538,12 +1499,6 @@
|
||||
"auto.components.onboarding.AgentStep.yoloPermissionsTooltip": {
|
||||
"ko": "중단을 줄이기 위해 agents 권한 확인을 건너뜁니다."
|
||||
},
|
||||
"auto.components.onboarding.FeatureSetupChecklist.01426f3a23": {
|
||||
"ko": "Agents가 사이트를 탐색하고, 페이지를 검사하고, 브라우저 작업을 처리할 수 있습니다."
|
||||
},
|
||||
"auto.components.onboarding.FeatureSetupChecklist.c5292c409d": {
|
||||
"ko": "Agents는 요청 시 앱 창을 검사하고 로컬 앱을 작동할 수 있습니다."
|
||||
},
|
||||
"auto.components.onboarding.IntegrationsStep.0b4a7d23ab": {
|
||||
"ko": "로그인 중"
|
||||
},
|
||||
@@ -1556,18 +1511,6 @@
|
||||
"auto.components.onboarding.OnboardingFlow.windowsTerminalSubtitle": {
|
||||
"ko": "새 창에서 사용할 기본 셸과 terminal에서 오른쪽 클릭이 동작하는 방식을 선택하세요."
|
||||
},
|
||||
"auto.components.onboarding.RepoStep.466108ab89": {
|
||||
"ko": "선택한 호스트에 존재하는 경로를 입력하세요."
|
||||
},
|
||||
"auto.components.onboarding.RepoStep.6558d50c69": {
|
||||
"ko": "한 번에 여러 repos를 가져오고 싶으신가요? 상위 폴더를 선택하세요."
|
||||
},
|
||||
"auto.components.onboarding.RepoStep.c33b190ca3": {
|
||||
"ko": "호스트 경로만"
|
||||
},
|
||||
"auto.components.onboarding.RepoStep.cf23006ba7": {
|
||||
"ko": "선택한 호스트"
|
||||
},
|
||||
"auto.components.onboarding.ThemeStep.7ee9234e54": {
|
||||
"ko": "Ghostty 설정을 찾았습니다."
|
||||
},
|
||||
@@ -4976,9 +4919,6 @@
|
||||
"auto.components.ui.color.picker.faa855a582": {
|
||||
"ko": "Hex"
|
||||
},
|
||||
"auto.components.ui.team.multi.combobox.18ec58881e": {
|
||||
"ko": "팀 검색..."
|
||||
},
|
||||
"auto.components.workspace.cleanup.WorkspaceCleanupDialog.1b18868569": {
|
||||
"ko": "리뷰 필요"
|
||||
},
|
||||
|
||||
@@ -197,8 +197,13 @@ describe('Electron runtime package contract', () => {
|
||||
expect(relayBuild).toContain("'parcel-watcher-process-entry.ts'")
|
||||
expect(relayBuild).toContain("outfile: join(outDir, 'relay-watcher.js')")
|
||||
expect(relayBuild).toContain("readFileSync(join(outDir, 'relay-watcher.js'))")
|
||||
expect(relayBuild).toContain("outfile: join(outDir, 'relay-ai-vault-service.js')")
|
||||
expect(relayBuild).toContain("readFileSync(join(outDir, 'relay-ai-vault-service.js'))")
|
||||
expect(builderConfig).toContain("from: 'out/relay'")
|
||||
expect(remoteCommands).toContain("joinRemotePath(host, remoteRelayDir, 'relay-watcher.js')")
|
||||
expect(remoteCommands).toContain(
|
||||
"joinRemotePath(host, remoteRelayDir, 'relay-ai-vault-service.js')"
|
||||
)
|
||||
|
||||
const assertRelayGate = (steps, publishStepName) => {
|
||||
const names = steps.map((step) => step.name)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
const npxCommand = process.platform === 'win32' ? 'npx.cmd' : 'npx'
|
||||
const knobByFlag = {
|
||||
'--iterations': 'ORCA_AI_VAULT_BENCH_ITERATIONS',
|
||||
'--sessions': 'ORCA_AI_VAULT_BENCH_SESSIONS',
|
||||
'--payload-kib': 'ORCA_AI_VAULT_BENCH_PAYLOAD_KIB',
|
||||
'--keys': 'ORCA_AI_VAULT_BENCH_KEYS',
|
||||
'--cadence-ms': 'ORCA_AI_VAULT_BENCH_CADENCE_MS',
|
||||
'--label': 'ORCA_AI_VAULT_BENCH_LABEL'
|
||||
}
|
||||
|
||||
const env = { ...process.env, ORCA_AI_VAULT_TYPING_BENCH: '1' }
|
||||
const passthroughArgs = []
|
||||
const argv = process.argv.slice(2)
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
if (argv[index] === '--') {
|
||||
continue
|
||||
}
|
||||
const knob = knobByFlag[argv[index]]
|
||||
if (knob) {
|
||||
env[knob] = argv[++index]
|
||||
} else {
|
||||
passthroughArgs.push(argv[index])
|
||||
}
|
||||
}
|
||||
|
||||
const child = spawn(
|
||||
npxCommand,
|
||||
[
|
||||
'playwright',
|
||||
'test',
|
||||
'tests/e2e/terminal-ai-vault-typing-latency.spec.ts',
|
||||
'--config',
|
||||
'tests/playwright.config.ts',
|
||||
'--project',
|
||||
'electron-headless',
|
||||
'--workers=1',
|
||||
...passthroughArgs
|
||||
],
|
||||
{ stdio: 'inherit', env }
|
||||
)
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal)
|
||||
return
|
||||
}
|
||||
process.exit(code ?? 1)
|
||||
})
|
||||
@@ -27,6 +27,12 @@ const checks = [
|
||||
],
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
name: 'Linux snapshot renderer tests',
|
||||
command: 'python3',
|
||||
args: ['native/computer-use-linux/runtime_render_test.py'],
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
name: 'native provider argument guardrails',
|
||||
run: verifyNativeArgumentGuardrails,
|
||||
@@ -69,6 +75,19 @@ const checks = [
|
||||
run: verifyWindowsProviderHandshake,
|
||||
enabled: process.platform === 'win32'
|
||||
},
|
||||
{
|
||||
name: 'Windows snapshot renderer tests',
|
||||
command: 'powershell.exe',
|
||||
args: [
|
||||
'-NoLogo',
|
||||
'-NoProfile',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-File',
|
||||
'native/computer-use-windows/runtime-render.test.ps1'
|
||||
],
|
||||
enabled: process.platform === 'win32'
|
||||
},
|
||||
{
|
||||
name: 'macOS helper app bundle and signature',
|
||||
run: verifyMacOSHelperApp,
|
||||
|
||||
@@ -153,6 +153,7 @@ function prepareRelayTree(runDir, nodePtyDir) {
|
||||
for (const filename of [
|
||||
'relay.js',
|
||||
'relay-watcher.js',
|
||||
'relay-ai-vault-service.js',
|
||||
'managed-hook-runtime.js',
|
||||
NODE_PTY_PATCH_FILENAME,
|
||||
'.version'
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env node
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { planWorktreeSortOrderUpdates } from '../../src/shared/worktree-sort-order-update.ts'
|
||||
import { reuseEqualCatalogRows } from '../../src/renderer/src/store/slices/worktree-catalog-reconciliation.ts'
|
||||
|
||||
const WORKTREE_COUNT = 655
|
||||
const TAB_COUNT = 1_895
|
||||
const REFRESH_COUNT = 10
|
||||
const ROUNDS = 15
|
||||
const BATCHES_PER_ROUND = 25
|
||||
|
||||
function makeFixture() {
|
||||
const worktrees = Array.from({ length: WORKTREE_COUNT }, (_, index) => ({
|
||||
id: `repo-${index % 15}::/workspace/${index}`,
|
||||
sortOrder: WORKTREE_COUNT - index,
|
||||
lastActivityAt: index % 19,
|
||||
isArchived: false
|
||||
}))
|
||||
const tabs = Array.from({ length: TAB_COUNT }, (_, index) => ({
|
||||
worktreeId: worktrees[index % worktrees.length].id,
|
||||
live: index % 3 === 0
|
||||
}))
|
||||
return { worktrees, tabs }
|
||||
}
|
||||
|
||||
function runSidebarProjection(worktrees, tabs) {
|
||||
const liveTabsByWorktree = new Map()
|
||||
for (const tab of tabs) {
|
||||
if (tab.live) {
|
||||
liveTabsByWorktree.set(tab.worktreeId, (liveTabsByWorktree.get(tab.worktreeId) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
const sorted = [...worktrees].sort(
|
||||
(left, right) =>
|
||||
(liveTabsByWorktree.get(right.id) ?? 0) - (liveTabsByWorktree.get(left.id) ?? 0) ||
|
||||
right.lastActivityAt - left.lastActivityAt ||
|
||||
right.sortOrder - left.sortOrder
|
||||
)
|
||||
return sorted.map((worktree) => worktree.id)
|
||||
}
|
||||
|
||||
function runBaseline() {
|
||||
const fixture = makeFixture()
|
||||
const metaById = new Map(fixture.worktrees.map((worktree) => [worktree.id, worktree]))
|
||||
const requestedIds = fixture.worktrees.map((worktree) => worktree.id)
|
||||
let catalog = fixture.worktrees
|
||||
let publications = 0
|
||||
let persistenceWrites = 0
|
||||
for (let refresh = 0; refresh < REFRESH_COUNT; refresh += 1) {
|
||||
const now = 1_000_000 + refresh
|
||||
requestedIds.forEach((worktreeId, index) => {
|
||||
metaById.set(worktreeId, {
|
||||
...metaById.get(worktreeId),
|
||||
sortOrder: now - index * 1000
|
||||
})
|
||||
persistenceWrites += 1
|
||||
})
|
||||
const incoming = structuredClone(requestedIds.map((worktreeId) => metaById.get(worktreeId)))
|
||||
const reconciled = reuseEqualCatalogRows(catalog, incoming)
|
||||
if (reconciled !== catalog) {
|
||||
catalog = reconciled
|
||||
publications += 1
|
||||
runSidebarProjection(catalog, fixture.tabs)
|
||||
}
|
||||
}
|
||||
return { publications, persistenceWrites }
|
||||
}
|
||||
|
||||
function runCandidate() {
|
||||
const fixture = makeFixture()
|
||||
const metaById = new Map(fixture.worktrees.map((worktree) => [worktree.id, worktree]))
|
||||
const requestedIds = fixture.worktrees.map((worktree) => worktree.id)
|
||||
let catalog = fixture.worktrees
|
||||
let publications = 0
|
||||
let persistenceWrites = 0
|
||||
for (let refresh = 0; refresh < REFRESH_COUNT; refresh += 1) {
|
||||
const updates = planWorktreeSortOrderUpdates(
|
||||
requestedIds,
|
||||
(worktreeId) => metaById.get(worktreeId),
|
||||
1_000_000 + refresh
|
||||
)
|
||||
for (const update of updates) {
|
||||
metaById.set(update.worktreeId, {
|
||||
...metaById.get(update.worktreeId),
|
||||
sortOrder: update.sortOrder
|
||||
})
|
||||
}
|
||||
persistenceWrites += updates.length
|
||||
const incoming = structuredClone(requestedIds.map((worktreeId) => metaById.get(worktreeId)))
|
||||
const reconciled = reuseEqualCatalogRows(catalog, incoming)
|
||||
if (reconciled !== catalog) {
|
||||
catalog = reconciled
|
||||
publications += 1
|
||||
runSidebarProjection(catalog, fixture.tabs)
|
||||
}
|
||||
}
|
||||
return { publications, persistenceWrites }
|
||||
}
|
||||
|
||||
function measureBatch(run) {
|
||||
let counters
|
||||
const startedAt = performance.now()
|
||||
for (let batch = 0; batch < BATCHES_PER_ROUND; batch += 1) {
|
||||
counters = run()
|
||||
}
|
||||
return { elapsedMs: (performance.now() - startedAt) / BATCHES_PER_ROUND, counters }
|
||||
}
|
||||
|
||||
function measureComparison() {
|
||||
runBaseline()
|
||||
runCandidate()
|
||||
const samples = Array.from({ length: ROUNDS }, (_, round) => {
|
||||
let baseline
|
||||
let candidate
|
||||
if (round % 2 === 0) {
|
||||
baseline = measureBatch(runBaseline)
|
||||
candidate = measureBatch(runCandidate)
|
||||
} else {
|
||||
candidate = measureBatch(runCandidate)
|
||||
baseline = measureBatch(runBaseline)
|
||||
}
|
||||
return { baseline, candidate, ratio: candidate.elapsedMs / baseline.elapsedMs }
|
||||
}).sort((left, right) => left.ratio - right.ratio)
|
||||
return samples[Math.floor(samples.length / 2)]
|
||||
}
|
||||
|
||||
const { baseline, candidate } = measureComparison()
|
||||
if (baseline.counters.publications !== REFRESH_COUNT || candidate.counters.publications !== 0) {
|
||||
throw new Error('Benchmark fixture no longer reproduces the refresh feedback loop')
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Worktree refresh churn: ${WORKTREE_COUNT} worktrees, ${TAB_COUNT} tabs, ${REFRESH_COUNT} refreshes`
|
||||
)
|
||||
console.log(
|
||||
`Before: ${baseline.elapsedMs.toFixed(3)} ms, ${baseline.counters.publications} catalog publications, ${baseline.counters.persistenceWrites} rank writes`
|
||||
)
|
||||
console.log(
|
||||
`After: ${candidate.elapsedMs.toFixed(3)} ms, ${candidate.counters.publications} catalog publications, ${candidate.counters.persistenceWrites} rank writes`
|
||||
)
|
||||
console.log(
|
||||
`Reduction: ${((1 - candidate.elapsedMs / baseline.elapsedMs) * 100).toFixed(1)}% CPU, 100% feedback publications`
|
||||
)
|
||||
@@ -12,6 +12,7 @@
|
||||
"../src/main/agent-hooks/local-agent-cli-presence.ts",
|
||||
"../src/main/agent-hooks/managed-agent-hook-controls.ts",
|
||||
"../src/main/agent-hooks/managed-agent-hook-registry.ts",
|
||||
"../src/main/agent-hooks/managed-hook-script-refresh.ts",
|
||||
"../src/main/amp/hook-service.ts",
|
||||
"../src/main/antigravity/hook-service.ts",
|
||||
"../src/main/claude/hook-settings.ts",
|
||||
|
||||
@@ -16,7 +16,8 @@ export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
// Why: Node 26's undefined Web Storage globals prevent Vitest from installing happy-dom's.
|
||||
execArgv: ['--no-experimental-webstorage'],
|
||||
// Why --expose-gc: retention tests need a deterministic collection point to measure what a queue really holds.
|
||||
execArgv: ['--no-experimental-webstorage', '--expose-gc'],
|
||||
// Why: happy-dom drops MutationObserver callbacks on GC; keep them alive like a browser does.
|
||||
setupFiles: [resolve('config/scripts/happy-dom-mutation-observer-retention.ts')],
|
||||
include: [
|
||||
|
||||
+2
-2
@@ -110,7 +110,7 @@ Don't add a fourth level. If something needs more emphasis than "floating," you'
|
||||
|
||||
Use the shadcn primitives in `src/renderer/src/components/ui/` before writing anything custom. The shadcn-style wrappers in this folder follow a consistent pattern:
|
||||
|
||||
- Most carry a `data-slot="<name>"` attribute on their root for CSS targeting — do not strip it. (The non-shadcn helpers in this folder — `sonner`, `repo-multi-combobox`, `team-multi-combobox` — don't follow this pattern and shouldn't be modeled when adding new primitives that should.)
|
||||
- Most carry a `data-slot="<name>"` attribute on their root for CSS targeting — do not strip it. (The non-shadcn helpers in this folder — `sonner` and `repo-multi-combobox` — don't follow this pattern and shouldn't be modeled when adding new primitives that should.)
|
||||
- Use `cn()` for class merging. Pass user `className` last so callers can override.
|
||||
- Use `class-variance-authority` (CVA) for variants when there are multiple.
|
||||
|
||||
@@ -148,7 +148,7 @@ When a control has multiple plausible primitives, use this fork:
|
||||
| Drawer / panel sliding in from an edge | `Sheet` | `Dialog` centered |
|
||||
| Single choice from a known list | `Select` | Custom listbox |
|
||||
| Single choice with search / fuzzy filtering | `Command` inside `Popover` | `Select` (no search) |
|
||||
| Multi-select with search | `repo-multi-combobox` / `team-multi-combobox` (mirror their pattern) | Roll a new one |
|
||||
| Multi-select with search | `repo-multi-combobox` (mirror its pattern) | Roll a new one |
|
||||
| Transient confirmation ("Saved", "Copied") | `sonner` toast | `Dialog`, inline banner |
|
||||
| Persistent inline status ("3 errors") | inline text + `Badge` | toast (toasts disappear) |
|
||||
|
||||
|
||||
@@ -0,0 +1,596 @@
|
||||
# AI Vault process isolation architecture and implementation plan
|
||||
|
||||
Status: implemented behind the documented desktop/runtime kill switch, with local build, parity, Electron, and A/B validation complete.
|
||||
|
||||
Last updated: 2026-08-09.
|
||||
|
||||
## Decision summary
|
||||
|
||||
AI Vault will remain part of the integrated Orca renderer, but its host-side work will move behind a persistent service-process boundary.
|
||||
|
||||
The target has three rules:
|
||||
|
||||
1. The desktop and Orca runtime route local Vault scans and title resolution to one lazy, supervised Vault service process per host process.
|
||||
2. The SSH relay routes Vault work to a relay-side Vault service process. The relay event loop that handles PTYs must not scan or parse Vault data.
|
||||
3. The renderer publishes completed Vault results at low priority after terminal input is quiet. xterm and the Vault panel remain in the same renderer.
|
||||
|
||||
This is deliberately not a separate `WebContentsView`, window, or embedded app. The measurements below show bounded CPU contention under an exaggerated scan, but no renderer long tasks and no evidence that a second Chromium renderer is justified.
|
||||
|
||||
The public Electron IPC, runtime RPC, and SSH relay method names and result meanings remain unchanged in the initial migration. The process boundary is host-internal.
|
||||
|
||||
## Goals
|
||||
|
||||
- A Vault scan, parser fault, cache fault, or memory spike cannot crash the terminal daemon, relay PTY loop, Electron main process, or runtime host.
|
||||
- Terminal input remains responsive while a local, runtime, or SSH Vault refresh is active.
|
||||
- Vault work has bounded concurrency, queue depth, memory, result size, cancellation latency, and wall time.
|
||||
- The service can be restarted without restarting the app, runtime, relay, or terminals.
|
||||
- Existing local, folder-workspace, WSL, runtime, and SSH behavior remains compatible.
|
||||
- Mixed-version remote peers continue to work.
|
||||
- The architecture is conventional and debuggable: integrated UI, thin routers, dedicated background services.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Treating the service process as a security sandbox. It runs trusted Orca code with the same user identity.
|
||||
- Rewriting the scanner or changing session discovery semantics during the process migration.
|
||||
- Moving xterm into another renderer.
|
||||
- Adding a process per window, worktree, repository, or SSH request.
|
||||
- Removing the old-relay compatibility fallback in the same change.
|
||||
- Making pagination a public wire dependency before mixed-version fallback behavior exists.
|
||||
|
||||
## Current architecture and coupling
|
||||
|
||||
| Path | Current execution | Remaining coupling |
|
||||
| --------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Desktop local scan and title resolution | Persistent `worker_threads.Worker` from `session-scanner-worker-spawn.ts` | Separate V8 isolate, but the same OS process, priority class, failure domain, and overall memory accounting as Electron main |
|
||||
| Orca runtime scan and title resolution | The same worker client used by runtime RPC methods | Same-process CPU, memory, and lifecycle coupling with the runtime host |
|
||||
| SSH relay scan and title resolution | `AiVaultHandler` calls the remote scanner and title reader inside `relay.ts` | Vault discovery, reads, parsing, cache work, and PTY routing share one event loop and process |
|
||||
| Old SSH relay fallback | Desktop main crawls the host through the SSH filesystem provider | Compatibility path can consume desktop and SSH multiplexer work; complete remote isolation is impossible without a new relay |
|
||||
| Renderer result publication | `setScanResult(result)` and `setSessions(result.sessions)` immediately | Deserialization, projection, filtering, grouping, and React rendering share the renderer thread with xterm |
|
||||
|
||||
The current local worker already provides meaningful isolation. It allows one active request, bounds the queue at 16, supports cancellation, and uses 130-second scan and 15-second title timeouts. The migration should preserve those properties instead of replacing them with an unbounded child-process API.
|
||||
|
||||
The terminal backend is already a detached daemon process. A Vault service beside it fits the existing process model; it does not require a terminal redesign.
|
||||
|
||||
## Feature-parity contract
|
||||
|
||||
The migration changes execution ownership only. Unless a separate product change is approved, the service-on and worker-fallback paths must be observably equivalent after normalizing `scannedAt` and process metrics.
|
||||
|
||||
| Capability | Required behavior after migration | Owner |
|
||||
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| Agent discovery | Preserve every `AI_VAULT_AGENTS` source: Claude, Codex, Hermes, Pi, OMP, Prime Agent, Cursor, Gemini, Antigravity, Rovo Dev, Copilot, OpenCode, Grok, OpenClaw, Devin, Droid, and Kimi | Service scanner, using existing source adapters |
|
||||
| Session result | Preserve every `AiVaultSession` field, including host/platform identity, paths, Codex home, timestamps, previews, token/message counts, queued/recoverable state, subagent count, resume command, and subagent metadata | Service result builder; router validates/restamps only where it does today |
|
||||
| Scope and depth | Preserve workspace/project/all scoping, guaranteed older in-scope sessions, 250/500/1000/unlimited depth, sorting, deduplication, and issue rows | Service per-host scan; router multi-host merge; renderer view projection |
|
||||
| Host routing | Preserve local, all-host, individual SSH, and individual runtime selection with the same per-host time budgets and partial-failure issue behavior | Router |
|
||||
| Dynamic roots | Preserve environment overrides, managed/per-account Codex homes, runtime Codex homes, WSL default and Orca-owned homes, and every platform-specific agent root | Router resolves dynamic homes per request; service discovers within them |
|
||||
| Refresh semantics | Preserve TTL reuse, force refresh, request-token cancellation, force preemption, coalescing, window-focus refresh, and new-agent-session refresh throttling | Router coordinator plus service cancellation |
|
||||
| Title synchronization | Preserve local, SSH, and runtime Claude/Codex title resolution and the existing input-quiet tab-title gate | Service title operation plus existing host routing |
|
||||
| Subagent expansion | Preserve local-only Claude and OMP child listing, path containment, status, issue rows, and retry behavior. Runtime/SSH/web remain empty until a separately negotiated feature exists | Service interactive operation; router retains current host gate |
|
||||
| First-prompt copy | Preserve local-only on-demand full prompt parsing, OpenCode row lookup, truncation safety, injected-turn suppression, and preview fallback. Runtime/SSH/web remain preview-only | Service interactive operation; router retains current host gate |
|
||||
| Resume | Preserve provider-specific resume commands, Codex-home preparation, account targeting, execution-host targeting, platform quoting, OMP path resume, drag/drop, and new-tab launch | Existing router/renderer paths; they consume an unchanged session result |
|
||||
| Logs and live state | Preserve View Log/Open Log, reveal/copy path, working-directory action, read-only editor behavior, live tail, original-pane jump, and worktree jump | Existing filesystem/editor/native-chat paths; outside the service migration |
|
||||
| Delete | Preserve local-only gating, liveness checks, OS trash/WSL deletion, companion artifacts, idempotency, and rejection reasons | Trusted router executor; invalidate router and service caches on success |
|
||||
| View behavior | Preserve search, agent toggles, sort/group, hide-empty/recoverable-empty logic, persisted view options, collapsed groups, and host/scope controls | Renderer; unchanged apart from publication scheduling |
|
||||
| Compatibility | Preserve current web/runtime limitations, old-relay fallback, mixed-version result meaning, and unavailable-host issue rows | Router and existing public transports |
|
||||
|
||||
Add a backend-equivalence suite that runs the same golden corpus through the current worker and the service process, normalizes only `scannedAt`, and deep-compares the complete results. The corpus must cover every agent, OpenCode SQLite and legacy formats, append-only incremental parsing, recoverable empty sessions, Claude/OMP subagents, managed Codex homes, duplicate Codex roots, WSL-shaped roots, scope inclusion, corrupt/partial files, and issue overflow.
|
||||
|
||||
No field may be dropped to reduce IPC size during the migration. Any later payload reduction requires an independently versioned compatibility plan because resume, drag/drop, log actions, and project ownership consume fields that may not be visibly rendered in the list.
|
||||
|
||||
## Target topology
|
||||
|
||||
```text
|
||||
Desktop / runtime host
|
||||
|
||||
Integrated renderer
|
||||
├─ xterm UI ─────────── terminal IPC/router ───────── terminal daemon
|
||||
└─ AI Vault panel ───── Vault IPC/RPC router ──────── Vault service process
|
||||
│
|
||||
└─ host merge + compatibility routing
|
||||
|
||||
SSH execution host
|
||||
|
||||
Desktop ── existing SSH Vault RPC ── relay router ───── Vault service sidecar
|
||||
└────────────────── PTY handler/event loop
|
||||
```
|
||||
|
||||
The two relay branches share only the relay's bounded request/response routing. Filesystem discovery, transcript reads, JSON parsing, SQLite access, parse caches, and title caches execute in the sidecar.
|
||||
|
||||
## Process ownership
|
||||
|
||||
- Desktop: one Vault service per Electron main process, shared by every window and workspace.
|
||||
- Orca runtime: one Vault service per runtime host process, shared by all connected clients.
|
||||
- SSH relay: one Vault service per live relay daemon, shared across relay reconnects and requests.
|
||||
- WSL: preserve current source ownership initially. The Windows local service invokes the existing WSL-aware adapters; do not add a process per distro in this migration.
|
||||
- Old relay: retain the bounded desktop fallback only when the relay method is unavailable. A new relay whose sidecar fails must return an issue instead of scanning inline on the relay loop.
|
||||
|
||||
## Service responsibilities
|
||||
|
||||
The service owns:
|
||||
|
||||
- Source discovery and filesystem metadata reads.
|
||||
- Transcript streaming and parsing for all supported agents.
|
||||
- OpenCode SQLite reads and its nested worker lifecycle.
|
||||
- In-memory and persisted session parse caches.
|
||||
- The bounded title index and transcript title resolution.
|
||||
- On-demand Claude/OMP subagent listing and full first-user-prompt extraction.
|
||||
- Scan parse concurrency and cooperative cancellation.
|
||||
- Result construction, deduplication, sorting, and internal paging.
|
||||
- Per-request metrics that contain counts and timings, not session content.
|
||||
|
||||
The router owns:
|
||||
|
||||
- Electron IPC, runtime RPC, or relay method registration.
|
||||
- Authentication and connection ownership already enforced by that transport.
|
||||
- Public argument validation and execution-host selection.
|
||||
- Resolution of dynamic host inputs such as managed Codex homes and WSL distro homes; resolved roots are passed with each scan so a long-lived service cannot retain stale account configuration.
|
||||
- Multi-host result merging.
|
||||
- Request coalescing and mapping caller cancellation to the service request.
|
||||
- Validation of service responses before publishing them across a public boundary.
|
||||
- Compatibility fallback selection.
|
||||
- Local deletion, liveness checks, OS trash integration, and cache-invalidation commands sent to the service after a successful mutation.
|
||||
|
||||
The renderer owns only view state, filtering/grouping of the returned page, user actions, and low-priority result publication.
|
||||
|
||||
## Internal process protocol
|
||||
|
||||
Add a versioned, private protocol in `src/main/ai-vault/session-scanner-service-protocol.ts`. It is not a remote wire protocol.
|
||||
|
||||
Parent to service:
|
||||
|
||||
```ts
|
||||
type ServiceRequest =
|
||||
| { type: 'init'; protocol: 1; host: HostDescriptor; cache: CacheOptions }
|
||||
| { type: 'request'; id: number; operation: 'scan'; options: ScanOptions }
|
||||
| { type: 'request'; id: number; operation: 'titles'; requests: TitleRequest[] }
|
||||
| { type: 'request'; id: number; operation: 'subagents'; request: SubagentRequest }
|
||||
| { type: 'request'; id: number; operation: 'firstPrompt'; request: FirstPromptRequest }
|
||||
| { type: 'invalidate'; paths: string[]; generation: number }
|
||||
| { type: 'cancel'; id: number }
|
||||
| { type: 'shutdown' }
|
||||
```
|
||||
|
||||
Service to parent:
|
||||
|
||||
```ts
|
||||
type ServiceResponse =
|
||||
| { type: 'ready'; protocol: 1; pid: number; capabilities: ServiceCapabilities }
|
||||
| { type: 'result'; id: number; operation: 'scan'; value: ScanPage; metrics: ScanMetrics }
|
||||
| { type: 'result'; id: number; operation: 'titles'; value: TitlesResult }
|
||||
| { type: 'result'; id: number; operation: 'subagents'; value: SubagentListResult }
|
||||
| { type: 'result'; id: number; operation: 'firstPrompt'; value: FirstPromptResult }
|
||||
| { type: 'invalidated'; generation: number }
|
||||
| { type: 'error'; id: number; code: ServiceErrorCode; message: string; retryable: boolean }
|
||||
| { type: 'fatal'; code: ServiceErrorCode; message: string }
|
||||
```
|
||||
|
||||
Protocol rules:
|
||||
|
||||
- Validate every incoming and outgoing message. Ignore an unknown response ID and terminate on a protocol-version mismatch.
|
||||
- Use monotonically increasing safe-integer request IDs scoped to the child lifetime.
|
||||
- Preserve the existing shared limits for request counts, scope-path count and length, title requests, and session depth.
|
||||
- Keep one cache-mutating lane for scans and title resolution, plus one independent interactive-read lane for subagent and first-prompt reads. Title resolution stays serialized with scans because both mutate the shared parse/title caches; the independent operations do not. Queue at most 16 calls across both lanes.
|
||||
- Coalesce equivalent scans. Replace an older queued background scan with the newest one.
|
||||
- Queue priority is first-prompt/subagent reads, queued title reads, forced foreground refresh, initial foreground load, then background refresh. Priority never preempts an active request; explicit cancellation does.
|
||||
- Keep the existing 130-second scan and 15-second title deadlines. A timeout terminates and replaces the service because a timed-out parser cannot be assumed healthy.
|
||||
- Cancellation rejects the caller immediately, sends `cancel`, and gives the service 2 seconds to acknowledge or finish. The service is terminated if it remains stuck.
|
||||
- Continue returning the last good renderer result when a refresh fails.
|
||||
- Invalidation is a control message, not queued scan work. The parent invalidates its result/host caches immediately and waits for the service acknowledgement before allowing a non-forced post-delete list result to reuse service cache state.
|
||||
- Instrument serialized response size. Warn at 8 MiB. Do not reject legacy unlimited results until internal paging and public fallback are implemented.
|
||||
|
||||
The first implementation may reuse the current plain-object Node IPC serialization. The payload does not require handles, sockets, or transferable buffers.
|
||||
|
||||
## Lifecycle and supervision
|
||||
|
||||
### Startup
|
||||
|
||||
- Start lazily on the first scan or title request.
|
||||
- Resolve an explicit entry path; packaged Electron must use `app.asar.unpacked` because `ELECTRON_RUN_AS_NODE=1` bypasses Electron's asar loader.
|
||||
- Fork with `stdio: ['ignore', 'ignore', 'pipe', 'ipc']`, `ELECTRON_RUN_AS_NODE=1`, and `windowsHide: true` on Windows.
|
||||
- Require a `ready` message within 5 seconds. A request does not enter its operation timeout until the service is ready.
|
||||
- Pipe a bounded stderr tail into the existing diagnostic log. Never allow child output backpressure to stall the child.
|
||||
|
||||
### Steady state
|
||||
|
||||
- Reuse the process and caches across calls.
|
||||
- Keep current parse concurrency at eight for the first A/B. Process isolation is not permission to increase concurrency.
|
||||
- Set best-effort below-normal priority with `os.setPriority(child.pid, PRIORITY_BELOW_NORMAL)`. Failure to lower priority is observable but not fatal.
|
||||
- Start with `--max-old-space-size=384`. Treat an out-of-memory exit as a service failure; keep terminals and the last Vault snapshot alive.
|
||||
- Exit after 10 minutes with no active or queued request. Persisted parse-cache state makes a later cold process cheaper.
|
||||
|
||||
### Faults and restart
|
||||
|
||||
- Reject the active request on exit, disconnect, malformed protocol, or timeout.
|
||||
- Retry queued work in a new process only when it is safe and has not been cancelled.
|
||||
- Back off at 250 ms, 1 second, then 5 seconds.
|
||||
- Open a 60-second circuit breaker after three unexpected exits in 60 seconds. Manual refresh may make one explicit restart attempt.
|
||||
- On idle or orderly app/runtime/relay shutdown, flush any debounced parse-cache persistence, send the final acknowledgement, and exit. The parent waits at most 2 seconds, then terminates. Never hold host shutdown indefinitely.
|
||||
|
||||
## Resource and quality-of-service policy
|
||||
|
||||
The process boundary supplies crash and heap isolation. Priority and bounds supply responsiveness.
|
||||
|
||||
- CPU: below-normal process priority, scan concurrency eight, one scan at a time.
|
||||
- Memory: 384 MiB V8 old-space starting cap, 4,096 title-cache entries, current parse-cache eviction, and response-size telemetry.
|
||||
- Queue: 16 calls, coalescing, background replacement, explicit priority.
|
||||
- I/O: stream transcripts; do not read an entire store into memory. Preserve current per-file streaming behavior.
|
||||
- UI: defer result publication until terminal input has been quiet for 100 ms, with a 1-second maximum deferral so the panel cannot starve.
|
||||
- React: publish inside `startTransition`; keep the previous snapshot visible while the transition is pending.
|
||||
- Unlimited history: retain behavior during migration. Add internal pages before enforcing a hard serialized-result cap.
|
||||
|
||||
Process priority is best effort and cross-platform. Correctness must not depend on a particular scheduler implementation.
|
||||
|
||||
## Renderer publication
|
||||
|
||||
`ai-vault-session-refresh.ts` should stop applying a completed result immediately.
|
||||
|
||||
Add `ai-vault-session-publication-gate.ts` with this behavior:
|
||||
|
||||
1. Cache the validated result immediately so another caller can reuse it.
|
||||
2. If no terminal has received input in the last 100 ms, publish now inside `startTransition`.
|
||||
3. Otherwise retain only the newest pending result and wait for quiet.
|
||||
4. Publish after 1 second even if input continues, using a transition and one bounded page.
|
||||
5. Cancel a pending publication when the scope, host, or component request token changes.
|
||||
|
||||
Reuse the existing terminal-input quiet signal used by AI Vault tab-title synchronization rather than adding global key listeners per panel.
|
||||
|
||||
A separate Vault renderer remains a contingency only if post-service measurements show repeated renderer tasks over 50 ms that cannot be removed with publication scheduling, paging, and list virtualization.
|
||||
|
||||
## Desktop and runtime implementation
|
||||
|
||||
Create these concrete modules:
|
||||
|
||||
- `src/main/ai-vault/session-scanner-service-entry.ts`: process entry, init/ready handshake, request dispatch, cancellation, cache initialization, and shutdown.
|
||||
- `src/main/ai-vault/session-scanner-service-client.ts`: queue, deadlines, cancellation, validation, and restart policy.
|
||||
- `src/main/ai-vault/session-scanner-service-spawn.ts`: lazy shared client and public scan/title functions.
|
||||
- `src/main/ai-vault/session-scanner-service-entry-path.ts`: dev, E2E, and packaged path resolution.
|
||||
- `src/main/ai-vault/session-scanner-service-protocol.ts`: message types, schemas, and error codes.
|
||||
- `src/main/ai-vault/session-scanner-service-priority.ts`: best-effort process priority policy.
|
||||
|
||||
Reuse the scanner and cache modules without moving their business logic. Replace imports in:
|
||||
|
||||
- `src/main/ai-vault/cached-session-list.ts`
|
||||
- `src/main/ai-vault/session-title-resolver.ts`
|
||||
- `src/main/ipc/ai-vault-subagent-list.ts`
|
||||
- `src/main/ai-vault/session-first-user-prompt-read.ts`
|
||||
- `src/main/ipc/ai-vault-delete.ts` for acknowledged service-cache invalidation after a successful delete
|
||||
- `src/main/ipc/ai-vault.ts`
|
||||
- `src/main/runtime/rpc/methods/ai-vault.ts`
|
||||
|
||||
Promote the parse-cache flush currently exposed only for tests into an internal production shutdown function. Idle and orderly service exits must await it within the shutdown grace period.
|
||||
|
||||
Keep the current worker implementation behind the rollout switch until the new process passes packaging and fault-injection coverage. Remove it in a later cleanup change, not in the initial migration.
|
||||
|
||||
## SSH relay sidecar implementation
|
||||
|
||||
Create:
|
||||
|
||||
- `src/relay/ai-vault-service-entry.ts`: bundled Node sidecar entry using the remote scanner and title reader.
|
||||
- `src/relay/ai-vault-service-client.ts`: relay-owned supervisor and internal protocol adapter.
|
||||
- `src/relay/ai-vault-service-priority.ts`: the same best-effort QoS policy without Electron imports.
|
||||
|
||||
Change `src/relay/ai-vault-handler.ts` so it validates public params and forwards them to the sidecar. It must no longer import or call `scanRemoteAiVaultSessions` or `readAiVaultSessionTitlesFromFiles` in the relay process.
|
||||
|
||||
`relay.ts` creates the client once, injects it into `AiVaultHandler`, and shuts it down with relay lifecycle. The PTY handler does not depend on sidecar readiness.
|
||||
|
||||
If the sidecar cannot start or crashes:
|
||||
|
||||
- Return a normal `AiVaultListResult` containing a host issue and no new sessions.
|
||||
- Do not scan inline in the relay.
|
||||
- Keep all PTYs, reconnect state, and other relay methods available.
|
||||
- Allow the next manual refresh to cross the restart circuit breaker once.
|
||||
|
||||
Build `relay-ai-vault-service.js` for every current relay platform in `config/scripts/build-relay.mjs`. Include its bytes in the immutable relay content hash so a sidecar-only change deploys a new relay directory. The bundle must target Node 18 and rely only on Node built-ins plus already supported optional externals.
|
||||
|
||||
## Remote compatibility
|
||||
|
||||
The first implementation adds no public method, stream opcode, required field, or changed result meaning.
|
||||
|
||||
- Current desktop with new relay: calls the existing Vault relay methods; the new relay handles them through its sidecar.
|
||||
- New desktop with current relay: calls the same methods and receives the same result shape.
|
||||
- New desktop with an older relay lacking the method: uses the existing budgeted SSH filesystem fallback.
|
||||
- Old desktop with new relay: unknown internal sidecar details never cross the wire.
|
||||
|
||||
Do not make a new client depend on internal paging fields from an older host. If public paging is added later, use optional request/result fields and fall back to the existing complete-result call when the host does not return paging capability.
|
||||
|
||||
The old-relay fallback is the explicit isolation exception. Keep its total budget and cancellation. Do not broaden when it is selected. Once relay adoption data shows the fallback is rare, it can be reduced or removed in a separate compatibility decision.
|
||||
|
||||
## Folder workspaces, WSL, and paths
|
||||
|
||||
- Scope paths remain opaque filesystem paths. Do not require a `.git` directory or worktree metadata.
|
||||
- Continue using `path.join` and the existing host-platform path adapters.
|
||||
- Keep Windows batch and shell selection unrelated to terminal-shell preference.
|
||||
- Preserve WSL host identity and cache scoping; capability and cache state must not leak between distros or native Windows.
|
||||
- An SSH service reads only the remote host's paths. A desktop service must not interpret remote paths as local paths.
|
||||
- Title requests must continue through the existing readable-path resolution and allowed-root validation before opening a transcript.
|
||||
- Subagent and first-prompt requests remain local-only and must validate renderer-provided paths against the same agent-source roots used for discovery, including managed and WSL roots. Tightening path validation must ship with parity fixtures so legitimate current roots are not rejected.
|
||||
|
||||
## Security boundary
|
||||
|
||||
The service is a performance and reliability boundary, not a privilege boundary.
|
||||
|
||||
- It has the same OS user and must access the same agent stores.
|
||||
- Do not expose its IPC channel to renderer code or the network.
|
||||
- The parent passes validated options; the service validates again before filesystem access.
|
||||
- Do not log prompts, titles, transcript paths, environment values, or raw service messages.
|
||||
- Preserve symlink and allowed-root checks for title reads.
|
||||
- Inherit only the environment needed for current agent-home discovery and process operation. Document any removed variables with cross-platform tests before tightening further.
|
||||
|
||||
## Packaging
|
||||
|
||||
Desktop packaging changes:
|
||||
|
||||
- Add `session-scanner-service-entry` to the main inputs in `electron.vite.config.ts`.
|
||||
- Add `out/main/session-scanner-service-entry.js` to `asarUnpack` in `config/electron-builder.config.cjs`.
|
||||
- Reuse the existing unpacked entry-path pattern used by other forked processes.
|
||||
- Add a packaged smoke test that resolves the real unpacked path, starts the service, scans one fixture, and shuts down.
|
||||
|
||||
Relay packaging changes:
|
||||
|
||||
- Bundle the sidecar beside `relay.js` and `relay-watcher.js` for every supported OS/architecture.
|
||||
- Include it in `.version` content hashing and deploy completeness assertions.
|
||||
- Verify reconnect and versioned-install cleanup keep the matching relay and sidecar together.
|
||||
- No new native module is required, so the Linux glibc floor should remain unchanged. Continue running the packaging verifier.
|
||||
|
||||
## Observability
|
||||
|
||||
Add spans and counters for:
|
||||
|
||||
- Service start, ready latency, PID, restart reason, circuit-breaker state, and idle exit.
|
||||
- Queue depth, queue wait, priority class, coalesced calls, and dropped background calls.
|
||||
- Discovery duration, parse duration, candidates, bytes read, cache reuse, incremental parses, full parses, and result count.
|
||||
- Parent/child serialization duration and serialized bytes.
|
||||
- Child RSS and heap-used snapshots at request completion.
|
||||
- Renderer result receipt, quiet-wait duration, transition publication duration, and displayed session count.
|
||||
- Relay sidecar failures separately from PTY and relay-loop health.
|
||||
|
||||
Metrics must use counts, byte sizes, and durations only. Paths and transcript-derived content stay out of logs.
|
||||
|
||||
## Verification matrix
|
||||
|
||||
The design is not ready for default-on until each layer below passes with both the worker fallback and process backend where applicable.
|
||||
|
||||
| Layer | Required coverage |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Pure parser | Existing fixtures for every agent and format; incremental append, corrupt/partial input, title precedence, usage, previews, queued prompts, recoverable empties, and subagent counts |
|
||||
| Backend equivalence | Worker versus process deep equality for complete list, title, subagent, and first-prompt results; only `scannedAt` and internal metrics may differ |
|
||||
| Cache | Cold/warm/incremental scans, persistence restart, atomic save failure, corrupt cache, app-version mismatch, delete generation guard, service invalidation acknowledgement, and idle-exit flush |
|
||||
| Queue/lifecycle | Both logical lanes, priority, coalescing, 16-call bound, cancellation before/while queued/active, deadlines, malformed IPC, crash, OOM, backoff, circuit breaker, and shutdown |
|
||||
| Desktop Electron | Visible list, scope/host/depth controls, refresh/cancel, first-prompt copy, Claude/OMP expansion, delete, View/Open Log, live tail, resume, drag/drop, and service-crash terminal typing through Electron/CDP |
|
||||
| Runtime/web | List/title/resume parity, host restamping, managed Codex homes, unchanged no-op cancellation limitation, and unchanged unavailable subagent/first-prompt/delete behavior |
|
||||
| SSH relay | All-agent remote list, title resolution, scope truncation, sidecar crash/timeout/OOM, PTY typing/output/reconnect continuity, old-relay fallback, and both mixed-version directions |
|
||||
| Windows/WSL | Native and per-distro roots, UNC paths, WSL deletion, priority failure fallback, hidden child windows, packaged entry path, and host-isolated cache/source state |
|
||||
| Linux/package | Ubuntu 20.04/glibc floor, Node 18 relay bundle, packaged entry smoke, sidecar deploy/hash completeness, and no undeclared native dependency |
|
||||
| Folder workspace | Workspace/project scope, ownership mapping, resume target, path actions, and deletion without assuming Git metadata |
|
||||
|
||||
The Electron feature-parity run must assert visible behavior rather than only inspecting the store. Service fault tests additionally assert PTY markers so a Vault failure cannot pass while silently disrupting terminals.
|
||||
|
||||
## Failure behavior
|
||||
|
||||
| Failure | User-visible Vault behavior | Terminal behavior |
|
||||
| ---------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------- |
|
||||
| Service fails to start | Keep last result and show a retryable host issue | Unchanged |
|
||||
| Service crashes during scan | Reject refresh, restart under backoff, keep last result | Unchanged |
|
||||
| Service exceeds memory cap | Treat as crash and reduce confidence telemetry; do not remove the cap silently | Unchanged |
|
||||
| Scan or cancellation hangs | Kill and replace service after the deadline/grace | Unchanged |
|
||||
| Queue is full | Coalesce or drop background work; foreground call gets a clear retryable error | Unchanged |
|
||||
| Relay sidecar unavailable | Remote Vault host shows an issue | Relay PTYs and reconnect remain available |
|
||||
| Renderer publication is superseded | Drop the stale result and publish the newest scope only | Unchanged |
|
||||
| Old relay has no Vault method | Use the existing bounded desktop fallback | Existing compatibility limitation remains |
|
||||
|
||||
## Benchmark methodology
|
||||
|
||||
The new `bench:ai-vault-typing` command launches a fresh Electron app through Playwright's Electron/CDP path and uses an isolated home.
|
||||
|
||||
Each iteration:
|
||||
|
||||
1. Keeps the Vault panel mounted in both arms.
|
||||
2. Seeds 300 new, newer-mtime Codex JSONL transcripts in the isolated home.
|
||||
3. Alternates arm order to reduce warm-up bias.
|
||||
4. Runs paced raw-mode terminal echo typing as the control.
|
||||
5. Runs the same typing while programmatically starting a forced visible Vault refresh as treatment.
|
||||
6. Verifies the newest seeded title is visible, proving the full scan and UI path completed.
|
||||
|
||||
The renderer records keydown-to-xterm-parse, keydown-to-xterm-render, long tasks, timer drift, animation-frame gaps, refresh duration, and missing echoes without polling or serializing the terminal buffer for each key.
|
||||
|
||||
Raw reports are written to the ignored `tests/tools/benchmarks/results/` directory.
|
||||
|
||||
## Current measurements
|
||||
|
||||
Machine: Apple M5 Pro, 64 GiB RAM, macOS 26.5.1. These are local-host measurements on the current worker-thread architecture after the recently merged performance work.
|
||||
|
||||
Pre-implementation parity baseline:
|
||||
|
||||
- 90 Vault-focused unit/integration test files passed: 778 tests covering parsers, caches, routing, deletion, resume, renderer projection, subagents, first prompts, and runtime contracts.
|
||||
- Electron/CDP parity spot-check passed for long View Log tail stability, single-file session deletion, and Claude companion-directory deletion: 3 tests.
|
||||
- The representative, stress, and final benchmark smoke runs all completed with visible Vault results and zero missing terminal echoes.
|
||||
|
||||
### Representative pass
|
||||
|
||||
Configuration: 3 iterations, 300 new sessions per iteration, 128 KiB assistant payload per session, 100 keys per arm at 30 ms cadence, 118.4 MB total seeded data.
|
||||
|
||||
| Metric | Control | Refresh active |
|
||||
| ------------------------------ | -------------------: | ------------------: |
|
||||
| Parse p50 / p95 / max | 1.3 / 2.5 / 29.8 ms | 1.3 / 2.0 / 5.4 ms |
|
||||
| Render p50 / p95 / max | 5.9 / 10.0 / 35.4 ms | 5.8 / 9.5 / 10.6 ms |
|
||||
| Worst timer drift | 14.2 ms | 24.5 ms |
|
||||
| Worst frame gap | 9.4 ms | 24.9 ms |
|
||||
| Renderer long tasks over 50 ms | 0 | 0 |
|
||||
| Missing echoes | 0 / 300 | 0 / 300 |
|
||||
| Refresh duration p50 / max | — | 103.5 / 204.2 ms |
|
||||
|
||||
There is no treatment-side typing regression in this pass. The current worker is doing useful work and should remain the rollback path during migration.
|
||||
|
||||
### Stress pass
|
||||
|
||||
Configuration: 2 iterations, 300 new sessions per iteration, 512 KiB assistant payload per session, 120 keys per arm at 30 ms cadence, 314.9 MB total seeded data.
|
||||
|
||||
| Metric | Control | Refresh active |
|
||||
| ------------------------------ | ------------------: | -------------------: |
|
||||
| Parse p50 / p95 / max | 1.3 / 1.9 / 19.7 ms | 1.4 / 4.7 / 13.8 ms |
|
||||
| Render p50 / p95 / max | 5.6 / 9.4 / 23.8 ms | 6.0 / 11.2 / 16.3 ms |
|
||||
| Worst timer drift | 9.3 ms | 43.6 ms |
|
||||
| Worst frame gap | 9.4 ms | 50.0 ms |
|
||||
| Renderer long tasks over 50 ms | 0 | 0 |
|
||||
| Missing echoes | 0 / 240 | 0 / 240 |
|
||||
| Refresh duration p50 / max | — | 276.3 / 453.5 ms |
|
||||
|
||||
The exaggerated workload causes measurable but bounded whole-system contention. It does not show renderer result application becoming a long task. This is evidence for a lower-priority service process and against a second Chromium renderer.
|
||||
|
||||
### Measurement limits
|
||||
|
||||
- One high-end macOS machine does not establish Windows, Linux, low-core, battery, or memory-pressure behavior.
|
||||
- The benchmark covers the local service path, not the currently unisolated SSH relay path.
|
||||
- Raw shell echo is a stable latency probe, not a full-screen TUI workload.
|
||||
- A child process still competes for machine-wide CPU; the expected benefit comes from priority, heap/failure isolation, and supervision, not from eliminating CPU cost.
|
||||
|
||||
## Post-implementation A/B
|
||||
|
||||
Run the same build with the rollout switch off and on, alternating launch order:
|
||||
|
||||
```bash
|
||||
ORCA_AI_VAULT_SERVICE_PROCESS=0 pnpm bench:ai-vault-typing -- --label worker-control
|
||||
ORCA_AI_VAULT_SERVICE_PROCESS=1 pnpm bench:ai-vault-typing -- --label process-treatment
|
||||
```
|
||||
|
||||
Run at least five representative and five stress passes on:
|
||||
|
||||
- macOS Apple Silicon.
|
||||
- Windows x64 with a folder workspace and a WSL-backed source present.
|
||||
- Ubuntu 20.04-compatible x64 packaging/runtime.
|
||||
- A 4-core or smaller machine or constrained CI runner.
|
||||
|
||||
Add a Docker SSH variant that runs the same terminal probe while the remote sidecar scans. A fault arm kills the sidecar during typing and verifies the PTY marker stream and reconnect remain healthy.
|
||||
|
||||
## Implemented result
|
||||
|
||||
The implementation now matches the target topology:
|
||||
|
||||
- Desktop and runtime list scans, title resolution, subagent reads, and first-prompt reads use a lazy supervised child process by default outside unit tests. `ORCA_AI_VAULT_SERVICE_PROCESS=0` retains the worker fallback.
|
||||
- Successful local deletion remains in the trusted main process and acknowledges child parse-cache invalidation. In-flight parses cannot restore an invalidated entry.
|
||||
- The SSH relay handler no longer imports the remote scanner, transcript title reader, or filesystem provider. It forwards existing public methods to `relay-ai-vault-service.js`; sidecar failure returns a normal host issue.
|
||||
- All six relay platform bundles include the sidecar, hash its bytes into `.version`, and require it in remote-install completeness probes.
|
||||
- Renderer results cache immediately, retain only the newest pending publication, wait for 100 ms of input quiet, publish in a React transition, and cannot defer beyond one second.
|
||||
- Desktop and relay supervisors bound the queue at 16, cap old-space at 384 MiB, use below-normal priority where supported, enforce ready/operation/cancellation/shutdown deadlines, and restart with bounded backoff plus a three-fault circuit breaker.
|
||||
|
||||
Local validation on 2026-08-09:
|
||||
|
||||
- Electron E2E build emitted `out/main/session-scanner-service-entry.js`; every relay target built `relay-ai-vault-service.js` successfully.
|
||||
- Real built-child smoke checks passed for the desktop service title lane and relay sidecar list lane.
|
||||
- Vault, runtime, renderer, relay, and remote-install focused tests passed 900/901 in one loaded run. The single unrelated 150 ms map micro-benchmark measured 184 ms during that parallel run and passed at 30 ms alone.
|
||||
- Electron/CDP feature checks passed for long View Log stability, single-file deletion, and Claude companion-directory deletion with the process backend enabled.
|
||||
|
||||
Representative A/B configuration: 3 iterations, 300 sessions per iteration, 128 KiB payloads, and 100 keys at 30 ms cadence.
|
||||
|
||||
| Metric | Worker control | Worker refresh | Process control | Process refresh |
|
||||
| --------------------- | -------------: | -------------: | --------------: | --------------: |
|
||||
| Parse p95 | 2.0 ms | 1.9 ms | 6.2 ms | 6.4 ms |
|
||||
| Render p95 | 9.6 ms | 9.6 ms | 11.0 ms | 12.3 ms |
|
||||
| Worst timer drift | 9.4 ms | 17.4 ms | 10.4 ms | 12.0 ms |
|
||||
| Worst frame gap | 9.4 ms | 9.4 ms | 16.8 ms | 16.2 ms |
|
||||
| Long tasks over 50 ms | 0 | 0 | 0 | 0 |
|
||||
| Missing echoes | 0 / 300 | 0 / 300 | 0 / 300 | 0 / 300 |
|
||||
| Refresh p50 / max | — | 74.9 / 76.9 ms | — | 83.2 / 165.1 ms |
|
||||
|
||||
The process arm stayed well inside every acceptance budget. It did not outperform the already-optimized worker on raw parse latency; its value is the intended heap, crash, priority, relay-loop, and lifecycle isolation while preserving terminal responsiveness.
|
||||
|
||||
Windows/WSL, Ubuntu 20.04, constrained-core, packaged-app launch, and Docker SSH fault measurements remain release/CI matrix work because they cannot be truthfully produced on this macOS host. The implementation includes their path, platform, packaging, Node 18, and mixed-version compatibility requirements; default rollback remains one environment variable.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
Correctness:
|
||||
|
||||
- Zero missing terminal echoes.
|
||||
- The latest seeded Vault session is visibly published in every treatment arm.
|
||||
- Local, runtime, SSH, WSL, and folder-workspace session fixtures remain equivalent to the current scanner.
|
||||
- Cancellation, force-preemption, host merging, title resolution, and unlimited history preserve current semantics.
|
||||
|
||||
Performance:
|
||||
|
||||
- Treatment parse p95 is no more than 10 ms above its paired control.
|
||||
- Treatment render p95 is no more than 15 ms above its paired control.
|
||||
- Median active-key render latency remains at or below 75 ms; worst remains below 300 ms.
|
||||
- No renderer task attributable to Vault result publication exceeds 50 ms in the representative run.
|
||||
- No relay PTY input/output stall exceeds the terminal reliability budget while its sidecar scans or crashes.
|
||||
|
||||
Isolation:
|
||||
|
||||
- Killing or exhausting the desktop service does not crash Electron main, runtime RPC, renderer, or terminal daemon.
|
||||
- Killing or exhausting the relay sidecar does not interrupt PTYs, relay reconnect, or non-Vault methods.
|
||||
- The service respects queue, timeout, idle, and heap bounds.
|
||||
- Packaged app and relay artifacts contain and launch the correct entry for every supported platform.
|
||||
|
||||
## Phased implementation plan
|
||||
|
||||
### Phase 0 — measurement harness (completed in this change)
|
||||
|
||||
- Add `terminal-ai-vault-typing-latency.spec.ts` and its focused corpus/renderer probes.
|
||||
- Add `run-ai-vault-typing-bench.mjs` and `pnpm bench:ai-vault-typing`.
|
||||
- Capture representative and stress baselines.
|
||||
|
||||
Exit: reproducible JSON report, visible Vault completion assertion, zero missing echoes.
|
||||
|
||||
### Phase 1 — desktop and runtime service behind a switch
|
||||
|
||||
- Add the private service protocol, entry-path resolver, process entry, client, priority policy, and supervisor.
|
||||
- Reuse current scanner/cache implementations.
|
||||
- Route desktop IPC and runtime RPC through the service when `ORCA_AI_VAULT_SERVICE_PROCESS` is enabled.
|
||||
- Keep the worker path as the disabled-switch fallback.
|
||||
- Add unit tests for ready timeout, queue priority, coalescing, cancellation, operation timeout, crash, restart backoff, circuit breaker, idle exit, malformed messages, and packaged path resolution.
|
||||
- Add E2E fault injection that kills the service during active terminal typing.
|
||||
|
||||
Exit: desktop/runtime correctness parity, packaging smoke test, representative/stress A/B within budgets.
|
||||
|
||||
### Phase 2 — make the desktop/runtime process the default
|
||||
|
||||
- Enable the process by default in development, E2E, and canary builds.
|
||||
- Collect process RSS, restart, timeout, and latency telemetry without content.
|
||||
- Validate Windows, Linux, WSL, and constrained-runner results.
|
||||
- Keep `ORCA_AI_VAULT_SERVICE_PROCESS=0` as a release kill switch for one stable cycle.
|
||||
|
||||
Exit: no unexplained crash or timeout increase and all acceptance gates green.
|
||||
|
||||
### Phase 3 — SSH relay sidecar
|
||||
|
||||
- Add the relay sidecar entry and supervisor.
|
||||
- Remove scanner/parser/cache imports from the relay handler.
|
||||
- Add the sidecar to every relay bundle, content hash, deploy manifest, and completeness test.
|
||||
- Add Docker SSH scan, typing, sidecar-crash, reconnect, old-relay fallback, and mixed-version tests.
|
||||
- Make inline relay scanning impossible; sidecar failure returns a host issue.
|
||||
|
||||
Exit: remote typing and fault gates green; current public relay methods unchanged.
|
||||
|
||||
### Phase 4 — renderer quiet publication
|
||||
|
||||
- Add the shared quiet-publication gate and React transition.
|
||||
- Retain only the newest pending result per scope/host.
|
||||
- Add supersession, starvation timeout, unmount, and rapid-host-switch tests.
|
||||
- Re-run the Electron benchmark with large result counts and active filtering/grouping.
|
||||
|
||||
Exit: no Vault-attributable renderer long task over 50 ms in the representative gate.
|
||||
|
||||
### Phase 5 — hardening and cleanup
|
||||
|
||||
- Remove the worker implementation after one stable release if the kill switch was unused.
|
||||
- Decide whether public optional paging is necessary from serialized-size and renderer-publication telemetry.
|
||||
- Promote `bench:ai-vault-typing` from an experimental benchmark to the terminal reliability gate.
|
||||
- Document operational recovery and service diagnostics.
|
||||
|
||||
Exit: kill switch and dead worker code removed only after evidence supports it.
|
||||
|
||||
## Rollback
|
||||
|
||||
- Desktop/runtime: set `ORCA_AI_VAULT_SERVICE_PROCESS=0` to restore the current worker path without changing public APIs or stored data.
|
||||
- Renderer: disable quiet publication and apply the validated complete result directly.
|
||||
- Relay: deploy the prior immutable relay version. A new relay must never fall back to inline scanning when its sidecar fails.
|
||||
- Parse-cache format remains unchanged during migration, so rollback does not require cache conversion.
|
||||
|
||||
## Confidence and remaining questions
|
||||
|
||||
Confidence in the overall topology is about 90%. Confidence that a separate Vault renderer is unnecessary is higher because both baseline passes recorded zero renderer long tasks. Confidence that the local child process alone will materially improve normal typing latency is lower: the current worker already performs well, and the process migration is primarily buying priority, heap, crash, and lifecycle isolation.
|
||||
|
||||
The remaining implementation questions are deliberately narrow:
|
||||
|
||||
1. Is 384 MiB the right old-space cap on Windows/Linux and for unlimited histories? Resolve with packaged stress runs before default-on.
|
||||
2. Does the 10-minute idle exit improve retained memory without creating noticeable cold-refresh churn? Resolve with startup/RSS telemetry.
|
||||
3. Is public paging necessary? Add it only if response-size or renderer-publication evidence crosses the stated budgets.
|
||||
4. How large is the current SSH relay impact on low-core hosts? Resolve with the Phase 3 Docker and constrained-runner typing gate; it does not change the decision to remove scans from the relay loop.
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 17m">
|
||||
<title>downloads: 17m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 18m">
|
||||
<title>downloads: 18m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">17m</text>
|
||||
<text x="90" y="14">17m</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">18m</text>
|
||||
<text x="90" y="14">18m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 935 B After Width: | Height: | Size: 935 B |
@@ -220,6 +220,9 @@ export const electronViteConfig: UserConfig = {
|
||||
'session-scanner-worker-entry': resolve(
|
||||
'src/main/ai-vault/session-scanner-worker-entry.ts'
|
||||
),
|
||||
'session-scanner-service-entry': resolve(
|
||||
'src/main/ai-vault/session-scanner-service-entry.ts'
|
||||
),
|
||||
// Why: libuv spawns processes inline on the calling loop, so the port
|
||||
// scan's probe commands run on a worker thread instead of the UI one.
|
||||
'port-scan-command-worker-entry': resolve(
|
||||
|
||||
@@ -93,12 +93,6 @@
|
||||
"max-lines": ["error", { "max": 531, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["app/terminal-settings.tsx"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 514, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["scripts/mock-server.ts"],
|
||||
"rules": {
|
||||
|
||||
@@ -115,13 +115,15 @@ import {
|
||||
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
|
||||
import { triggerMediumImpact } from '../../../src/platform/haptics'
|
||||
import {
|
||||
type GitHubProjectSortDirection,
|
||||
type GitHubProjectTable as SharedGitHubProjectTable,
|
||||
groupRows,
|
||||
isIterationCurrent,
|
||||
sortRows,
|
||||
type ProjectGroup
|
||||
} from '../../../src/tasks/mobile-github-project-group-sort'
|
||||
} from '../../../../src/shared/github-project-group-sort'
|
||||
import type {
|
||||
GitHubProjectSortDirection,
|
||||
GitHubProjectTable as SharedGitHubProjectTable
|
||||
} from '../../../../src/shared/github-project-types'
|
||||
import {
|
||||
CROSS_REPO_DISPLAY_LIMIT,
|
||||
isGitHubWorkItemsSshRemoteRequiredError,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { View, Text, StyleSheet, Pressable, Switch } from 'react-native'
|
||||
import { View, Text, Pressable, Switch } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler'
|
||||
import Animated, {
|
||||
@@ -9,7 +9,7 @@ import Animated, {
|
||||
} from 'react-native-reanimated'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { ChevronLeft, ChevronRight, Smartphone, Type } from 'lucide-react-native'
|
||||
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
|
||||
import { colors, spacing } from '../src/theme/mobile-theme'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
import type { HostProfile } from '../src/transport/types'
|
||||
import { useFocusedSettingsHostClients } from '../src/transport/settings-host-client-connections'
|
||||
@@ -17,6 +17,7 @@ import type { RpcClient } from '../src/transport/rpc-client'
|
||||
import { PickerModal, type PickerOption } from '../src/components/PickerModal'
|
||||
import { TerminalShortcutSettings } from '../src/components/TerminalShortcutSettings'
|
||||
import { setTerminalAutoRestoreFitMsForHost } from '../src/terminal/terminal-auto-restore-fit-state'
|
||||
import { terminalSettingsScreenStyles as styles } from '../src/terminal/terminal-settings-screen-styles'
|
||||
import {
|
||||
loadTerminalAutocompleteEnabled,
|
||||
loadTerminalTextScale,
|
||||
@@ -380,92 +381,3 @@ export default function TerminalSettingsScreen() {
|
||||
</GestureHandlerRootView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: 0
|
||||
},
|
||||
topRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.sm,
|
||||
marginBottom: spacing.lg
|
||||
},
|
||||
backButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.sm
|
||||
},
|
||||
heading: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: spacing.xl
|
||||
},
|
||||
groupHeading: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: spacing.xs,
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
groupDescription: {
|
||||
fontSize: typography.bodySize - 1,
|
||||
color: colors.textSecondary,
|
||||
lineHeight: 20,
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
section: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: radii.card,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
sectionTopGap: {
|
||||
marginTop: spacing.sm
|
||||
},
|
||||
inputGroupGap: {
|
||||
marginTop: spacing.xl
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary,
|
||||
padding: spacing.md
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm + 2,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
rowContent: {
|
||||
flex: 1
|
||||
},
|
||||
rowLabel: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
rowSublabel: {
|
||||
fontSize: typography.bodySize - 2,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 2
|
||||
},
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
|
||||
import { Check, ChevronDown } from 'lucide-react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { searchBaseRefs } from '../source-control/mobile-base-ref-search'
|
||||
|
||||
type Props = {
|
||||
client: RpcClient | null
|
||||
worktreeId: string
|
||||
value: string
|
||||
onChange: (ref: string) => void
|
||||
editable?: boolean
|
||||
}
|
||||
|
||||
// Base-branch field for the create-PR composer: a free-text input that also searches
|
||||
// repo refs (debounced) and offers matches to tap — the RN analogue of desktop's
|
||||
// base-ref combobox. Free text stays valid so an SSH-only / unmatched ref can still
|
||||
// be entered.
|
||||
export function MobilePrBasePicker({
|
||||
client,
|
||||
worktreeId,
|
||||
value,
|
||||
onChange,
|
||||
editable = true
|
||||
}: Props) {
|
||||
const [results, setResults] = useState<string[]>([])
|
||||
const [focused, setFocused] = useState(false)
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
// Guards: drop results after unmount, and ignore an earlier search whose response
|
||||
// arrives after a later one (out-of-order network) so stale matches can't clobber.
|
||||
const mounted = useRef(true)
|
||||
const seq = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
mounted.current = false
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const queryRefs = useCallback(
|
||||
(query: string) => {
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current)
|
||||
}
|
||||
if (!client || query.trim().length === 0) {
|
||||
// Advance the generation so an earlier in-flight search can't land and
|
||||
// repopulate results after the input was cleared.
|
||||
seq.current += 1
|
||||
setResults([])
|
||||
return
|
||||
}
|
||||
timer.current = setTimeout(() => {
|
||||
const requestSeq = ++seq.current
|
||||
void searchBaseRefs(client, worktreeId, query.trim())
|
||||
.then((refs) => {
|
||||
if (!mounted.current || requestSeq !== seq.current) {
|
||||
return
|
||||
}
|
||||
setResults(refs.filter((r) => r !== query).slice(0, 6))
|
||||
})
|
||||
// Why: a rejected ref search must not escape as an unhandled rejection;
|
||||
// drop to an empty result set (free text stays valid to submit).
|
||||
.catch(() => {
|
||||
if (mounted.current && requestSeq === seq.current) {
|
||||
setResults([])
|
||||
}
|
||||
})
|
||||
}, 200)
|
||||
},
|
||||
[client, worktreeId]
|
||||
)
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={[styles.inputShell, !editable && styles.inputShellDisabled]}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={value}
|
||||
onChangeText={(text) => {
|
||||
onChange(text)
|
||||
queryRefs(text)
|
||||
}}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
placeholder="main"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={editable}
|
||||
/>
|
||||
<ChevronDown size={14} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
</View>
|
||||
{focused && results.length > 0 ? (
|
||||
<View style={styles.results}>
|
||||
{results.map((ref) => (
|
||||
<Pressable
|
||||
key={ref}
|
||||
style={({ pressed }) => [styles.resultRow, pressed && styles.resultRowPressed]}
|
||||
onPress={() => {
|
||||
onChange(ref)
|
||||
setResults([])
|
||||
}}
|
||||
>
|
||||
<Text style={styles.resultText} numberOfLines={1}>
|
||||
{ref}
|
||||
</Text>
|
||||
{ref === value ? (
|
||||
<Check size={14} color={colors.textPrimary} strokeWidth={2.2} />
|
||||
) : null}
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
inputShell: {
|
||||
minHeight: 40,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs
|
||||
},
|
||||
inputShellDisabled: {
|
||||
opacity: 0.6
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
padding: 0,
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.bodySize,
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
results: {
|
||||
marginTop: spacing.xs,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.input,
|
||||
backgroundColor: colors.bgPanel,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
resultRow: {
|
||||
minHeight: 40,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
resultRowPressed: { backgroundColor: colors.bgRaised },
|
||||
resultText: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.bodySize,
|
||||
fontFamily: typography.monoFamily
|
||||
}
|
||||
})
|
||||
@@ -149,8 +149,7 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
// Why: bgRaised lifts the field off bgBase/bgPanel so search is an obvious
|
||||
// control, matching TextInputModal / MobilePrBasePicker input shells.
|
||||
// Keep search visually distinct from surrounding panels.
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { ActivityIndicator, Pressable, Switch, Text, TextInput, View } from 'react-native'
|
||||
import {
|
||||
ArrowRight,
|
||||
GitMerge,
|
||||
GitPullRequestArrow,
|
||||
Sparkles,
|
||||
TriangleAlert,
|
||||
X
|
||||
} from 'lucide-react-native'
|
||||
import { colors } from '../../theme/mobile-theme'
|
||||
import type { RpcClient } from '../../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../../transport/types'
|
||||
import { triggerError, triggerSuccess } from '../../platform/haptics'
|
||||
import {
|
||||
createMobilePr,
|
||||
getMobilePrCreateSuccessWarning,
|
||||
shouldPushBeforeMobilePrCreate,
|
||||
type MobilePrPrefill
|
||||
} from '../../source-control/mobile-pr-create'
|
||||
import { hostedReviewCopy } from '../../source-control/hosted-review-copy'
|
||||
import {
|
||||
getPrComposeDisabledReason,
|
||||
isBaseHeadDistinct
|
||||
} from '../../source-control/pr-compose-validation'
|
||||
import { MobilePrBasePicker } from '../MobilePrBasePicker'
|
||||
import { mobilePrComposeFormStyles as styles } from './mobile-pr-compose-form-styles'
|
||||
|
||||
export type PrComposePrefill = MobilePrPrefill
|
||||
|
||||
type Props = {
|
||||
client: RpcClient | null
|
||||
worktreeId: string
|
||||
prefill: PrComposePrefill
|
||||
// Head branch — enables the base≠head guard and the "from <branch>" hint.
|
||||
head?: string | null
|
||||
onCancel: () => void
|
||||
onCreated: (url: string, warning?: string) => void
|
||||
}
|
||||
|
||||
// A plain View keeps the compose form usable inside the PR sidebar ScrollView.
|
||||
export function MobilePrComposeForm({
|
||||
client,
|
||||
worktreeId,
|
||||
prefill,
|
||||
head,
|
||||
onCancel,
|
||||
onCreated
|
||||
}: Props) {
|
||||
const copy = hostedReviewCopy(prefill.provider)
|
||||
const ReviewIcon = prefill.provider === 'gitlab' ? GitMerge : GitPullRequestArrow
|
||||
const [title, setTitle] = useState(prefill.title)
|
||||
const [body, setBody] = useState(prefill.body)
|
||||
const [base, setBase] = useState(prefill.base)
|
||||
const [draft, setDraft] = useState(false)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const pushBeforeCreate = shouldPushBeforeMobilePrCreate(prefill)
|
||||
|
||||
const generate = useCallback(async () => {
|
||||
if (!client || generating) {
|
||||
return
|
||||
}
|
||||
setGenerating(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await client.sendRequest('git.generatePullRequestFields', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
base,
|
||||
title,
|
||||
body,
|
||||
draft
|
||||
})
|
||||
if (!response.ok) {
|
||||
setError(response.error?.message || 'Failed to generate PR fields')
|
||||
return
|
||||
}
|
||||
const result = (response as RpcSuccess).result as {
|
||||
success?: boolean
|
||||
fields?: { base: string; title: string; body: string; draft: boolean }
|
||||
error?: string
|
||||
}
|
||||
if (result.success && result.fields) {
|
||||
setBase(result.fields.base || base)
|
||||
setTitle(result.fields.title || title)
|
||||
setBody(result.fields.body || body)
|
||||
setDraft(result.fields.draft)
|
||||
} else if (result.error) {
|
||||
setError(result.error)
|
||||
}
|
||||
} catch (err) {
|
||||
triggerError()
|
||||
setError(err instanceof Error ? err.message : 'Failed to generate PR fields')
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}, [base, body, client, draft, generating, title, worktreeId])
|
||||
|
||||
const headRef = head ?? ''
|
||||
const baseConflict = base.trim().length > 0 && !isBaseHeadDistinct(base, headRef)
|
||||
const submitDisabledReason = getPrComposeDisabledReason({
|
||||
title,
|
||||
base,
|
||||
head: headRef,
|
||||
generating,
|
||||
reviewLabel: copy.reviewLabel
|
||||
})
|
||||
const canSubmit = submitDisabledReason === null
|
||||
const fieldsLocked = submitting || generating
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
if (!client || submitting || !canSubmit) {
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const outcome = await createMobilePr(client, worktreeId, {
|
||||
provider: prefill.provider,
|
||||
base,
|
||||
// Send the same head the submit guard validated against, so the PR opens
|
||||
// from the validated branch instead of a host-inferred one.
|
||||
...(head ? { head } : {}),
|
||||
title,
|
||||
body,
|
||||
draft,
|
||||
pushBeforeCreate
|
||||
})
|
||||
if (outcome.ok) {
|
||||
triggerSuccess()
|
||||
const warning = getMobilePrCreateSuccessWarning(outcome, prefill.provider)
|
||||
if (warning) {
|
||||
setError(warning)
|
||||
}
|
||||
onCreated(outcome.url, warning)
|
||||
} else {
|
||||
triggerError()
|
||||
setError(outcome.error)
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}, [
|
||||
base,
|
||||
body,
|
||||
canSubmit,
|
||||
client,
|
||||
copy.titleLabel,
|
||||
draft,
|
||||
head,
|
||||
onCreated,
|
||||
prefill.provider,
|
||||
pushBeforeCreate,
|
||||
submitting,
|
||||
title,
|
||||
worktreeId
|
||||
])
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<View style={styles.headingRow}>
|
||||
<View style={styles.headingTitle}>
|
||||
<ReviewIcon size={14} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
<Text style={styles.heading}>New {copy.reviewLabel}</Text>
|
||||
</View>
|
||||
<View style={styles.headingActions}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.genButton, pressed && styles.genButtonPressed]}
|
||||
disabled={generating || submitting}
|
||||
onPress={() => void generate()}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Generate ${copy.reviewLabel} details with AI`}
|
||||
>
|
||||
{generating ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
) : (
|
||||
<Sparkles size={13} color={colors.textSecondary} strokeWidth={2.1} />
|
||||
)}
|
||||
<Text style={styles.genButtonText}>{generating ? 'Generating…' : 'Generate'}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.iconButton}
|
||||
onPress={onCancel}
|
||||
disabled={submitting}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Cancel"
|
||||
hitSlop={8}
|
||||
>
|
||||
<X size={16} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{head ? (
|
||||
<View style={styles.branchFlow}>
|
||||
<Text style={styles.branchToken} numberOfLines={1}>
|
||||
{head}
|
||||
</Text>
|
||||
<ArrowRight size={12} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
<Text
|
||||
style={[styles.branchToken, baseConflict && styles.branchTokenError]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{base || 'base'}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.fieldStack}>
|
||||
<TextInput
|
||||
style={styles.titleInput}
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
placeholder="Title"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
editable={!fieldsLocked}
|
||||
accessibilityLabel={`${copy.titleLabel} title`}
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.bodyInput}
|
||||
value={body}
|
||||
onChangeText={setBody}
|
||||
placeholder="Description (optional)"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
multiline
|
||||
editable={!fieldsLocked}
|
||||
accessibilityLabel={`${copy.titleLabel} description`}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{generating ? (
|
||||
<View style={styles.notice}>
|
||||
<Sparkles size={13} color={colors.textSecondary} strokeWidth={2.1} />
|
||||
<Text style={styles.noticeText}>Generating title and description…</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.baseRow}>
|
||||
<Text style={styles.baseLabel}>Base</Text>
|
||||
<View style={styles.baseControl}>
|
||||
<MobilePrBasePicker
|
||||
client={client}
|
||||
worktreeId={worktreeId}
|
||||
value={base}
|
||||
onChange={setBase}
|
||||
editable={!fieldsLocked}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.draftRow}>
|
||||
<Text style={styles.draftText}>Create as draft</Text>
|
||||
<Switch value={draft} onValueChange={setDraft} disabled={fieldsLocked} />
|
||||
</View>
|
||||
{error || submitDisabledReason ? (
|
||||
<View style={styles.notice}>
|
||||
<TriangleAlert size={13} color={colors.statusRed} strokeWidth={2.1} />
|
||||
<Text style={[styles.noticeText, styles.errorText]}>{error ?? submitDisabledReason}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.submit,
|
||||
(submitting || !canSubmit) && styles.submitDisabled,
|
||||
pressed && styles.submitPressed
|
||||
]}
|
||||
disabled={submitting || !canSubmit}
|
||||
onPress={() => void submit()}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
{submitting ? (
|
||||
<ActivityIndicator size="small" color={colors.bgBase} />
|
||||
) : (
|
||||
<ReviewIcon size={14} color={colors.bgBase} strokeWidth={2.2} />
|
||||
)}
|
||||
<Text style={styles.submitText}>
|
||||
{pushBeforeCreate
|
||||
? draft
|
||||
? `Push & create draft ${copy.shortLabel}`
|
||||
: `Push & create ${copy.shortLabel}`
|
||||
: draft
|
||||
? `Create draft ${copy.shortLabel}`
|
||||
: `Create ${copy.shortLabel}`}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { canEditComment, isResolvableComment } from '../../session/pr-comment-ac
|
||||
import { ConfirmModal } from '../ConfirmModal'
|
||||
import { CommentMarkdown } from './CommentMarkdown'
|
||||
import { PRCommentComposer } from './PRCommentComposer'
|
||||
import { formatPrCommentRelativeTime } from './pr-comment-time'
|
||||
import { formatPrCommentRelativeTime } from '../../../../src/shared/pr-comment-time'
|
||||
import { prCommentsStyles as styles } from './pr-comments-styles'
|
||||
|
||||
export type PRCommentRepoSlug = { owner: string; repo: string; host?: string }
|
||||
|
||||
@@ -12,12 +12,14 @@ import { CommentMarkdown } from './CommentMarkdown'
|
||||
import { PRCommentCard, type PRCommentCardActions } from './PRCommentCard'
|
||||
import { PRCommentComposer } from './PRCommentComposer'
|
||||
import {
|
||||
PR_COMMENT_AUDIENCE_FILTERS,
|
||||
filterPRCommentsByAudience,
|
||||
getPRCommentAudienceCounts,
|
||||
getPRCommentAudienceEmptyLabel,
|
||||
type PRCommentAudienceFilter
|
||||
} from './pr-comment-audience'
|
||||
} from '../../../../src/shared/pr-comment-audience'
|
||||
import {
|
||||
PR_COMMENT_AUDIENCE_FILTERS,
|
||||
getPRCommentAudienceEmptyLabel
|
||||
} from './pr-comment-audience-labels'
|
||||
import {
|
||||
getPRCommentGroupCount,
|
||||
getPRCommentGroupId,
|
||||
@@ -25,7 +27,7 @@ import {
|
||||
groupPRComments,
|
||||
isResolvedPRCommentGroup,
|
||||
type PRCommentGroup
|
||||
} from './pr-comment-groups'
|
||||
} from '../../../../src/shared/pr-comment-groups'
|
||||
import { prCommentsStyles as styles } from './pr-comments-styles'
|
||||
import { mobilePrSidebarStyles as shared } from './mobile-pr-sidebar-styles'
|
||||
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
|
||||
|
||||
export const mobilePrComposeFormStyles = StyleSheet.create({
|
||||
root: {
|
||||
gap: spacing.sm
|
||||
},
|
||||
headingRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
headingTitle: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs
|
||||
},
|
||||
heading: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '700'
|
||||
},
|
||||
headingActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs
|
||||
},
|
||||
genButton: {
|
||||
minHeight: 32,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderRadius: radii.button,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
genButtonPressed: {
|
||||
opacity: 0.7
|
||||
},
|
||||
genButtonText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '700'
|
||||
},
|
||||
iconButton: {
|
||||
minWidth: 32,
|
||||
minHeight: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radii.button
|
||||
},
|
||||
branchFlow: {
|
||||
minHeight: 28,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs
|
||||
},
|
||||
branchToken: {
|
||||
maxWidth: 116,
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.metaSize,
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
branchTokenError: {
|
||||
color: colors.statusRed
|
||||
},
|
||||
fieldStack: {
|
||||
gap: spacing.sm
|
||||
},
|
||||
titleInput: {
|
||||
minHeight: 40,
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
bodyInput: {
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.bodySize,
|
||||
// Why: a moderate fixed height avoids over-expanding inside the sidebar scroll.
|
||||
minHeight: 120,
|
||||
textAlignVertical: 'top'
|
||||
},
|
||||
baseRow: {
|
||||
minHeight: 40,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
baseLabel: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize,
|
||||
width: 36
|
||||
},
|
||||
baseControl: {
|
||||
flex: 1,
|
||||
minWidth: 0
|
||||
},
|
||||
draftRow: {
|
||||
minHeight: 36,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.sm,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgPanel,
|
||||
paddingHorizontal: spacing.sm
|
||||
},
|
||||
draftText: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '700'
|
||||
},
|
||||
notice: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: spacing.xs
|
||||
},
|
||||
noticeText: {
|
||||
flex: 1,
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize,
|
||||
lineHeight: 18
|
||||
},
|
||||
errorText: {
|
||||
color: colors.statusRed
|
||||
},
|
||||
submit: {
|
||||
marginTop: spacing.xs,
|
||||
minHeight: 44,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.textPrimary,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xs
|
||||
},
|
||||
submitDisabled: {
|
||||
opacity: 0.45
|
||||
},
|
||||
submitPressed: {
|
||||
opacity: 0.8
|
||||
},
|
||||
submitText: {
|
||||
color: colors.bgBase,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '700'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { PRCommentAudienceFilter } from '../../../../src/shared/pr-comment-audience'
|
||||
|
||||
export const PR_COMMENT_AUDIENCE_FILTERS = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'human', label: 'Humans' },
|
||||
{ value: 'bot', label: 'Bots' }
|
||||
] satisfies { value: PRCommentAudienceFilter; label: string }[]
|
||||
|
||||
export function getPRCommentAudienceEmptyLabel(filter: PRCommentAudienceFilter): string {
|
||||
switch (filter) {
|
||||
case 'bot':
|
||||
return 'No bot comments.'
|
||||
case 'human':
|
||||
return 'No human comments.'
|
||||
case 'all':
|
||||
return 'No comments yet.'
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import type { PRComment } from '../../../../src/shared/types'
|
||||
|
||||
// Thread grouping for the PR comment timeline, ported from the desktop helper
|
||||
// (src/renderer/src/lib/pr-comment-groups.ts) minus the DOM class constants.
|
||||
// Inline review comments sharing a threadId collapse into a root + replies; all
|
||||
// other comments are standalone. Upstream order is preserved.
|
||||
export type PRCommentGroup =
|
||||
| { kind: 'standalone'; comment: PRComment }
|
||||
| { kind: 'thread'; threadId: string; root: PRComment; replies: PRComment[] }
|
||||
|
||||
export function groupPRComments(comments: PRComment[]): PRCommentGroup[] {
|
||||
const threadMap = new Map<string, { root: PRComment; replies: PRComment[] }>()
|
||||
const groupsByFirstComment = new Map<PRComment, PRCommentGroup>()
|
||||
|
||||
for (const comment of comments) {
|
||||
if (!comment.threadId) {
|
||||
groupsByFirstComment.set(comment, { kind: 'standalone', comment })
|
||||
continue
|
||||
}
|
||||
const existing = threadMap.get(comment.threadId)
|
||||
if (existing) {
|
||||
existing.replies.push(comment)
|
||||
continue
|
||||
}
|
||||
threadMap.set(comment.threadId, { root: comment, replies: [] })
|
||||
}
|
||||
|
||||
const emitted = new Set<string>()
|
||||
const groups: PRCommentGroup[] = []
|
||||
for (const comment of comments) {
|
||||
if (!comment.threadId) {
|
||||
const group = groupsByFirstComment.get(comment)
|
||||
if (group) {
|
||||
groups.push(group)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (emitted.has(comment.threadId)) {
|
||||
continue
|
||||
}
|
||||
emitted.add(comment.threadId)
|
||||
const thread = threadMap.get(comment.threadId)
|
||||
if (thread) {
|
||||
groups.push({ kind: 'thread', threadId: comment.threadId, ...thread })
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
export function getPRCommentGroupComments(group: PRCommentGroup): PRComment[] {
|
||||
return group.kind === 'thread' ? [group.root, ...group.replies] : [group.comment]
|
||||
}
|
||||
|
||||
export function getPRCommentGroupRoot(group: PRCommentGroup): PRComment {
|
||||
return group.kind === 'thread' ? group.root : group.comment
|
||||
}
|
||||
|
||||
export function getPRCommentGroupCount(group: PRCommentGroup): number {
|
||||
return getPRCommentGroupComments(group).length
|
||||
}
|
||||
|
||||
export function isResolvedPRCommentGroup(group: PRCommentGroup): boolean {
|
||||
return getPRCommentGroupRoot(group).isResolved === true
|
||||
}
|
||||
|
||||
export function getPRCommentGroupId(group: PRCommentGroup): string {
|
||||
return group.kind === 'thread' ? `thread:${group.threadId}` : `comment:${group.comment.id}`
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PRComment } from '../../../../src/shared/types'
|
||||
import {
|
||||
createBotAuthorOverrideSet,
|
||||
filterPRCommentsByAudience,
|
||||
getPRCommentAudienceCounts,
|
||||
isBotPRComment
|
||||
} from './pr-comment-audience'
|
||||
import { groupPRComments, isResolvedPRCommentGroup } from './pr-comment-groups'
|
||||
import { formatPrCommentRelativeTime } from './pr-comment-time'
|
||||
|
||||
function comment(overrides: Partial<PRComment> & { id: number }): PRComment {
|
||||
return {
|
||||
author: 'octocat',
|
||||
authorAvatarUrl: '',
|
||||
body: '',
|
||||
createdAt: '',
|
||||
url: '',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('pr comment audience', () => {
|
||||
it('classifies app bots, [bot] suffix, and known automation logins', () => {
|
||||
expect(isBotPRComment(comment({ id: 1, author: 'alice' }))).toBe(false)
|
||||
expect(isBotPRComment(comment({ id: 2, author: 'alice', isBot: true }))).toBe(true)
|
||||
expect(isBotPRComment(comment({ id: 3, author: 'renovate[bot]' }))).toBe(true)
|
||||
expect(isBotPRComment(comment({ id: 4, author: 'coderabbitai' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('counts and filters by audience', () => {
|
||||
const comments = [
|
||||
comment({ id: 1, author: 'alice' }),
|
||||
comment({ id: 2, author: 'dependabot[bot]' }),
|
||||
comment({ id: 3, author: 'bob' })
|
||||
]
|
||||
expect(getPRCommentAudienceCounts(comments)).toEqual({ all: 3, human: 2, bot: 1 })
|
||||
expect(filterPRCommentsByAudience(comments, 'bot').map((c) => c.id)).toEqual([2])
|
||||
expect(filterPRCommentsByAudience(comments, 'human').map((c) => c.id)).toEqual([1, 3])
|
||||
expect(filterPRCommentsByAudience(comments, 'all')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('classifies desktop-marked bot authors via overrides (desktop parity)', () => {
|
||||
const overrides = createBotAuthorOverrideSet([' GretelFlux '])
|
||||
const comments = [comment({ id: 1, author: 'alice' }), comment({ id: 2, author: 'gretelflux' })]
|
||||
expect(isBotPRComment(comments[1])).toBe(false)
|
||||
expect(isBotPRComment(comments[1], overrides)).toBe(true)
|
||||
expect(getPRCommentAudienceCounts(comments, overrides)).toEqual({ all: 2, human: 1, bot: 1 })
|
||||
expect(filterPRCommentsByAudience(comments, 'bot', overrides).map((c) => c.id)).toEqual([2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('pr comment groups', () => {
|
||||
it('threads comments sharing a threadId as root + replies, preserving order', () => {
|
||||
const comments = [
|
||||
comment({ id: 1, author: 'a' }),
|
||||
comment({ id: 2, author: 'b', threadId: 't1', isResolved: true }),
|
||||
comment({ id: 3, author: 'c', threadId: 't1' })
|
||||
]
|
||||
const groups = groupPRComments(comments)
|
||||
expect(groups).toHaveLength(2)
|
||||
expect(groups[0]).toEqual({ kind: 'standalone', comment: comments[0] })
|
||||
expect(groups[1].kind).toBe('thread')
|
||||
if (groups[1].kind === 'thread') {
|
||||
expect(groups[1].root.id).toBe(2)
|
||||
expect(groups[1].replies.map((r) => r.id)).toEqual([3])
|
||||
}
|
||||
expect(isResolvedPRCommentGroup(groups[1])).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pr comment relative time', () => {
|
||||
const now = Date.parse('2026-06-16T12:00:00Z')
|
||||
it('formats buckets and rejects bad input', () => {
|
||||
expect(formatPrCommentRelativeTime('2026-06-16T11:59:30Z', now)).toBe('just now')
|
||||
expect(formatPrCommentRelativeTime('2026-06-16T11:30:00Z', now)).toBe('30m ago')
|
||||
expect(formatPrCommentRelativeTime('2026-06-16T09:00:00Z', now)).toBe('3h ago')
|
||||
expect(formatPrCommentRelativeTime('2026-06-10T12:00:00Z', now)).toBe('6d ago')
|
||||
expect(formatPrCommentRelativeTime('not-a-date', now)).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -1,30 +0,0 @@
|
||||
// Relative timestamp for PR comments (ISO string in, "Xm/Xh/Xd/Xmo/Xy" out),
|
||||
// mirroring the desktop formatRelativeTime so the timeline reads the same. Pure +
|
||||
// unit-testable; nowMs is passed in (Date.now() is unavailable in some contexts).
|
||||
export function formatPrCommentRelativeTime(iso: string, nowMs: number): string {
|
||||
const ts = Date.parse(iso)
|
||||
if (Number.isNaN(ts)) {
|
||||
return ''
|
||||
}
|
||||
const delta = nowMs - ts
|
||||
if (delta < 60_000) {
|
||||
return 'just now'
|
||||
}
|
||||
const minutes = Math.floor(delta / 60_000)
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m ago`
|
||||
}
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) {
|
||||
return `${hours}h ago`
|
||||
}
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 30) {
|
||||
return `${days}d ago`
|
||||
}
|
||||
const months = Math.floor(days / 30)
|
||||
if (months < 12) {
|
||||
return `${months}mo ago`
|
||||
}
|
||||
return `${Math.floor(months / 12)}y ago`
|
||||
}
|
||||
@@ -276,7 +276,6 @@ function AgentControls({
|
||||
|
||||
function MobileNativeChatMessageImpl({
|
||||
message,
|
||||
queued,
|
||||
toolsExpanded = false,
|
||||
fontScale = 1,
|
||||
messageIndex,
|
||||
@@ -284,7 +283,6 @@ function MobileNativeChatMessageImpl({
|
||||
onOpenFile
|
||||
}: {
|
||||
message: NativeChatMessage
|
||||
queued?: boolean
|
||||
toolsExpanded?: boolean
|
||||
/** Multiplies all chat text sizes for pinch-to-zoom (1 = no change). */
|
||||
fontScale?: number
|
||||
@@ -328,27 +326,24 @@ function MobileNativeChatMessageImpl({
|
||||
|
||||
// Copy + scroll-to-top, shown inline with the first tool call (or after the
|
||||
// prose when there are no tools).
|
||||
const controls =
|
||||
isAgent && !queued ? (
|
||||
<AgentControls
|
||||
onCopy={handleCopy}
|
||||
onScrollToTop={
|
||||
onScrollToMessage && messageIndex !== undefined
|
||||
? () => onScrollToMessage(messageIndex)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
const controls = isAgent ? (
|
||||
<AgentControls
|
||||
onCopy={handleCopy}
|
||||
onScrollToTop={
|
||||
onScrollToMessage && messageIndex !== undefined
|
||||
? () => onScrollToMessage(messageIndex)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<View style={[styles.row, isUser && styles.rowUser]}>
|
||||
{isUser && queued ? <Text style={styles.queuedTag}>Queued</Text> : null}
|
||||
<View
|
||||
style={[
|
||||
styles.content,
|
||||
isUser && styles.userBubble,
|
||||
isReasoning && styles.reasoning,
|
||||
queued && styles.queued,
|
||||
copied && styles.copied
|
||||
]}
|
||||
>
|
||||
|
||||
@@ -71,6 +71,7 @@ type Overrides = {
|
||||
onClearSendError?: () => void
|
||||
inputLockReason?: 'disconnected' | 'waiting' | null
|
||||
onSend?: (text: string) => Promise<boolean>
|
||||
pending?: Parameters<typeof MobileNativeChatView>[0]['pending']
|
||||
}
|
||||
|
||||
function assistantTurn(id: string, text: string): NativeChatMessage {
|
||||
@@ -117,6 +118,13 @@ describe('MobileNativeChatView', () => {
|
||||
return (list.props.data as { id: string }[]).map((row) => row.id)
|
||||
}
|
||||
|
||||
function renderedRow(id: string): ReturnType<typeof createElement> {
|
||||
const list = renderer!.root.find((node) => node.type === 'FlatList')
|
||||
const data = list.props.data as NativeChatMessage[]
|
||||
const index = data.findIndex((row) => row.id === id)
|
||||
return list.props.renderItem({ item: data[index], index })
|
||||
}
|
||||
|
||||
function banners(): ReactTestInstance[] {
|
||||
return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert')
|
||||
}
|
||||
@@ -187,6 +195,15 @@ describe('MobileNativeChatView', () => {
|
||||
expect(listIds()).toEqual(['a1', 'streaming'])
|
||||
})
|
||||
|
||||
it('renders an accepted optimistic image send without a queued state', async () => {
|
||||
await render({
|
||||
pending: [{ id: 'pending-1', text: 'look', images: ['file:///phone-photo.jpg'] }]
|
||||
})
|
||||
|
||||
expect(listIds()).toEqual(['pending-1'])
|
||||
expect(renderedRow('pending-1').props).not.toHaveProperty('queued')
|
||||
})
|
||||
|
||||
it('keeps a visible lock through a subscribed-end lease blip', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
||||
@@ -58,8 +58,7 @@ type Props = {
|
||||
loadingEarlier?: boolean
|
||||
onLoadEarlier?: () => void
|
||||
onSend: (text: string) => Promise<boolean>
|
||||
/** Optimistic queued sends (owned by the route so they survive view switches). */
|
||||
/** Optimistic user echoes, including any ridden-along image preview URIs. */
|
||||
/** Accepted user echoes awaiting transcript replacement, including image previews. */
|
||||
pending: MobileNativeChatPendingItem[]
|
||||
/** Local photo URIs retained when the authoritative transcript replaces an
|
||||
* optimistic image bubble. */
|
||||
@@ -176,9 +175,8 @@ export function MobileNativeChatView({
|
||||
[]
|
||||
)
|
||||
|
||||
const pendingIds = useMemo(() => new Set(pending.map((p) => p.id)), [pending])
|
||||
// `data` is the list source: folded transcript + synthetic streaming bubble +
|
||||
// route-owned optimistic queued messages. Memoize on the same deps so the
|
||||
// route-owned accepted echoes. Memoize on the same deps so the
|
||||
// downstream autoscroll effects/`renderItem` keep referential stability.
|
||||
const { data } = useMemo(
|
||||
() =>
|
||||
@@ -248,7 +246,6 @@ export function MobileNativeChatView({
|
||||
({ item, index }: { item: NativeChatMessage; index: number }) => (
|
||||
<MobileNativeChatMessage
|
||||
message={item}
|
||||
queued={pendingIds.has(item.id)}
|
||||
toolsExpanded={toolsExpanded}
|
||||
fontScale={fontScale}
|
||||
messageIndex={index}
|
||||
@@ -256,7 +253,7 @@ export function MobileNativeChatView({
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
),
|
||||
[pendingIds, toolsExpanded, fontScale, onScrollToMessage, onOpenFile]
|
||||
[toolsExpanded, fontScale, onScrollToMessage, onOpenFile]
|
||||
)
|
||||
|
||||
const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error)
|
||||
|
||||
@@ -49,15 +49,6 @@ export const styles = StyleSheet.create({
|
||||
reasoning: {
|
||||
opacity: 0.7
|
||||
},
|
||||
queued: {
|
||||
opacity: 0.55
|
||||
},
|
||||
queuedTag: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
marginBottom: 2
|
||||
},
|
||||
toolRun: {
|
||||
marginTop: spacing.xs
|
||||
},
|
||||
|
||||
@@ -51,7 +51,7 @@ export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): Nat
|
||||
|
||||
/** Assemble the list data the chat renders: the folded transcript, then a
|
||||
* synthetic bubble for the streaming text the gate let through, then the
|
||||
* route-owned optimistic "queued" messages at the tail. */
|
||||
* route-owned accepted optimistic messages at the tail. */
|
||||
export function buildMobileNativeChatTransientData({
|
||||
folded,
|
||||
streaming,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// (src/renderer/src/components/native-chat/NativeChatComposer.tsx): slash/skill
|
||||
// sends are TUI control actions, not chat turns — they never echo as a user
|
||||
// bubble, because the transcript will never contain a matching user turn and
|
||||
// the optimistic echo would sit at "Queued" forever.
|
||||
// the optimistic echo would never reconcile.
|
||||
|
||||
import {
|
||||
getNativeChatAgentProfile,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
|
||||
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
@@ -7,10 +7,13 @@ import {
|
||||
openMobileNativeChatSendBudget,
|
||||
clearMobileNativeChatInput,
|
||||
sendMobileNativeChatMessage,
|
||||
sendMobileNativeChatMessageWithOutcome
|
||||
sendMobileNativeChatMessageWithOutcome,
|
||||
typeMobileNativeChatCommandWithOutcome
|
||||
} from './mobile-native-chat-send'
|
||||
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
function clientWithResponse(response: unknown): RpcClient {
|
||||
return {
|
||||
sendRequest: vi.fn().mockResolvedValue(response)
|
||||
@@ -290,6 +293,37 @@ describe('sendMobileNativeChatMessage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('typeMobileNativeChatCommandWithOutcome', () => {
|
||||
it('writes the Codex picker command as keys instead of one pasted text write', async () => {
|
||||
vi.useFakeTimers()
|
||||
const client = clientWithResponse({
|
||||
id: 'request',
|
||||
ok: true,
|
||||
result: { send: { accepted: true } },
|
||||
_meta: { runtimeId: 'runtime' }
|
||||
})
|
||||
const result = typeMobileNativeChatCommandWithOutcome({
|
||||
client,
|
||||
terminal: 'term',
|
||||
command: '/model'
|
||||
})
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await expect(result).resolves.toBe('accepted')
|
||||
expect(
|
||||
vi.mocked(client.sendRequest).mock.calls.map((call) => {
|
||||
const params = call[1] as { text: string; enter: boolean }
|
||||
return { text: params.text, enter: params.enter }
|
||||
})
|
||||
).toEqual(
|
||||
['\x15', '/', 'm', 'o', 'd', 'e', 'l', '\r'].map((text) => ({
|
||||
text,
|
||||
enter: false
|
||||
}))
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearMobileNativeChatInput', () => {
|
||||
const accepted = {
|
||||
id: 'request',
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { RpcClient } from '../transport/rpc-client'
|
||||
import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
|
||||
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response'
|
||||
import { typeAgentTuiCommand } from '../../../src/shared/agent-tui-command-typing'
|
||||
|
||||
type MobileTerminalClient = {
|
||||
id: string
|
||||
@@ -93,6 +94,27 @@ export async function sendMobileNativeChatMessage(
|
||||
return (await sendMobileNativeChatMessageWithOutcome(args)) === 'accepted'
|
||||
}
|
||||
|
||||
export async function typeMobileNativeChatCommandWithOutcome(args: {
|
||||
client: RpcClient
|
||||
terminal: string
|
||||
command: string
|
||||
mobileClient?: MobileTerminalClient
|
||||
deadline?: number
|
||||
}): Promise<MobileNativeChatSendOutcome> {
|
||||
return typeAgentTuiCommand({
|
||||
command: args.command,
|
||||
write: (key) =>
|
||||
sendMobileNativeChatMessageWithOutcome({
|
||||
client: args.client,
|
||||
terminal: args.terminal,
|
||||
text: key,
|
||||
enter: false,
|
||||
...(args.mobileClient ? { mobileClient: args.mobileClient } : {}),
|
||||
...(args.deadline === undefined ? {} : { deadline: args.deadline })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the agent's input line as its OWN write, before any body.
|
||||
*
|
||||
|
||||
@@ -7,8 +7,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const sendWithOutcome = vi.fn()
|
||||
const clearInputWrite = vi.fn()
|
||||
const typeCommandWithOutcome = vi.fn()
|
||||
vi.mock('./mobile-native-chat-send', () => ({
|
||||
sendMobileNativeChatMessageWithOutcome: (...args: unknown[]) => sendWithOutcome(...args),
|
||||
typeMobileNativeChatCommandWithOutcome: (...args: unknown[]) => typeCommandWithOutcome(...args),
|
||||
clearMobileNativeChatInput: (...args: unknown[]) => clearInputWrite(...args),
|
||||
openMobileNativeChatSendBudget: () => Date.now() + 15_000,
|
||||
MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS: 15_000,
|
||||
@@ -84,6 +86,8 @@ describe('useMobileNativeChatMessageSend', () => {
|
||||
sendWithOutcome.mockResolvedValue('accepted')
|
||||
clearInputWrite.mockReset()
|
||||
clearInputWrite.mockResolvedValue(true)
|
||||
typeCommandWithOutcome.mockReset()
|
||||
typeCommandWithOutcome.mockResolvedValue('accepted')
|
||||
acceptSend.mockReset()
|
||||
holdUnconfirmedSend.mockReset()
|
||||
onCommandSend.mockReset()
|
||||
@@ -260,6 +264,19 @@ describe('useMobileNativeChatMessageSend', () => {
|
||||
expect(sentArgs().resolvedLaunchDraft).toBeUndefined()
|
||||
})
|
||||
|
||||
it('routes typed picker commands around the pasted composer-text send', async () => {
|
||||
mount(() => null, 'codex')
|
||||
let outcome: string | undefined
|
||||
await act(async () => {
|
||||
outcome = await api!.dispatchCommand('/model', { delivery: 'type' })
|
||||
})
|
||||
expect(outcome).toBe('accepted')
|
||||
expect(typeCommandWithOutcome).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: '/model', terminal: 'term' })
|
||||
)
|
||||
expect(sendWithOutcome).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('binds classification to the agent that started the send', async () => {
|
||||
let resolveSend!: (outcome: MobileNativeChatSendOutcome) => void
|
||||
sendWithOutcome.mockReturnValue(
|
||||
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
clearMobileNativeChatInput,
|
||||
openMobileNativeChatSendBudget,
|
||||
sendMobileNativeChatMessageWithOutcome,
|
||||
typeMobileNativeChatCommandWithOutcome,
|
||||
type MobileNativeChatSendOutcome
|
||||
} from './mobile-native-chat-send'
|
||||
import type { CatalogCommandDelivery } from '../../../src/shared/agent-session-option-catalog'
|
||||
import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input'
|
||||
import { classifyMobileNativeChatSend } from './mobile-native-chat-send-classification'
|
||||
import {
|
||||
@@ -32,7 +34,10 @@ export type MobileNativeChatMessageSend = {
|
||||
answerQuestion: (text: string) => Promise<boolean>
|
||||
/** Session-option command dispatch (e.g. `/model sonnet`) — never touches the
|
||||
* composer draft; callers need the outcome to track dispatched state. */
|
||||
dispatchCommand: (text: string) => Promise<MobileNativeChatSendOutcome>
|
||||
dispatchCommand: (
|
||||
text: string,
|
||||
options?: { delivery?: CatalogCommandDelivery }
|
||||
) => Promise<MobileNativeChatSendOutcome>
|
||||
}
|
||||
|
||||
/** The native-chat send seam: one write path shared by composer sends, image
|
||||
@@ -181,7 +186,7 @@ export function useMobileNativeChatMessageSend(args: {
|
||||
})
|
||||
// Why (desktop parity): a slash/skill send dispatches into the agent's own
|
||||
// TUI, not the conversation — the transcript never echoes it as a user
|
||||
// turn, so an optimistic bubble would sit at "Queued" forever and the
|
||||
// turn, so an optimistic bubble would never reconcile and the
|
||||
// unconfirmed hold could never observe a landing.
|
||||
const classification = classifyMobileNativeChatSend(agent, text)
|
||||
if (outcome === 'unknown') {
|
||||
@@ -269,12 +274,41 @@ export function useMobileNativeChatMessageSend(args: {
|
||||
// spaces a send's body and its Enter ~500ms apart — so without this lock an
|
||||
// apply lands between them and is submitted as part of the user's prompt.
|
||||
const dispatchCommand = useCallback(
|
||||
async (text: string): Promise<MobileNativeChatSendOutcome> => {
|
||||
async (
|
||||
text: string,
|
||||
options?: { delivery?: CatalogCommandDelivery }
|
||||
): Promise<MobileNativeChatSendOutcome> => {
|
||||
const terminal = handleRef.current
|
||||
if (terminal && !acquireMobileNativeChatTerminalWrite(terminal)) {
|
||||
return 'rejected'
|
||||
}
|
||||
try {
|
||||
if (options?.delivery === 'type') {
|
||||
if (!client || !terminal || !enabled) {
|
||||
return 'rejected'
|
||||
}
|
||||
const deadline = openMobileNativeChatSendBudget()
|
||||
const mobileClient = deviceTokenRef.current
|
||||
? { id: deviceTokenRef.current, type: 'mobile' as const }
|
||||
: undefined
|
||||
if (
|
||||
!(await healMobileNativeChatStaleInput({
|
||||
client,
|
||||
terminal,
|
||||
deviceToken: deviceTokenRef.current,
|
||||
deadline
|
||||
}))
|
||||
) {
|
||||
return 'rejected'
|
||||
}
|
||||
return typeMobileNativeChatCommandWithOutcome({
|
||||
client,
|
||||
terminal,
|
||||
command: text,
|
||||
...(mobileClient ? { mobileClient } : {}),
|
||||
deadline
|
||||
})
|
||||
}
|
||||
return await sendMessage(text, undefined, false, false)
|
||||
} finally {
|
||||
if (terminal) {
|
||||
@@ -282,7 +316,7 @@ export function useMobileNativeChatMessageSend(args: {
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleRef, sendMessage]
|
||||
[client, deviceTokenRef, enabled, handleRef, sendMessage]
|
||||
)
|
||||
|
||||
return { send, sendWithOutcome, answerQuestion, dispatchCommand }
|
||||
|
||||
@@ -14,7 +14,7 @@ describe('useMobileNativeChatSessionOptions', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let api: MobileNativeChatSessionOptionsController | null = null
|
||||
let hookArgs: HookArgs
|
||||
const dispatchCommand = vi.fn<(command: string) => Promise<MobileNativeChatSendOutcome>>()
|
||||
const dispatchCommand = vi.fn<HookArgs['dispatchCommand']>()
|
||||
const onAgentPicker = vi.fn()
|
||||
|
||||
function Probe(): null {
|
||||
@@ -97,14 +97,23 @@ describe('useMobileNativeChatSessionOptions', () => {
|
||||
expect(api!.snapshot[0]).toMatchObject({ valueSource: 'unknown' })
|
||||
})
|
||||
|
||||
it('applies Codex model changes through the native command', async () => {
|
||||
it('types the Codex picker command and switches to the terminal', async () => {
|
||||
mount({ agent: 'codex' })
|
||||
expect(api!.snapshot[0]?.action).toBeUndefined()
|
||||
expect(api!.snapshot[0]?.action).toEqual({ type: 'agent-picker' })
|
||||
await act(async () => {
|
||||
await api!.setOption('model', 'gpt-5.5')
|
||||
await api!.invokeAction('model')
|
||||
})
|
||||
expect(dispatchCommand).toHaveBeenCalledWith('/model gpt-5.5')
|
||||
expect(onAgentPicker).not.toHaveBeenCalled()
|
||||
expect(dispatchCommand).toHaveBeenCalledWith('/model', { delivery: 'type' })
|
||||
expect(onAgentPicker).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('types the Codex effort picker command', async () => {
|
||||
mount({ agent: 'codex', reportedModel: 'gpt-5.5' })
|
||||
await act(async () => {
|
||||
await api!.invokeAction('effort')
|
||||
})
|
||||
expect(dispatchCommand).toHaveBeenCalledWith('/model', { delivery: 'type' })
|
||||
expect(onAgentPicker).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('seeds the current model from a hook-reported provider model', () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
|
||||
import {
|
||||
getAgentSessionOptionCatalog,
|
||||
type AgentSessionOptionCatalog,
|
||||
type CatalogCommandDelivery,
|
||||
type CatalogModel
|
||||
} from '../../../src/shared/agent-session-option-catalog'
|
||||
import type {
|
||||
@@ -95,7 +96,10 @@ export function useMobileNativeChatSessionOptions(args: {
|
||||
scopeKey: string | null
|
||||
/** Provider model from live agent status, when the hook reported one. */
|
||||
reportedModel: string | null
|
||||
dispatchCommand: (command: string) => Promise<MobileNativeChatSendOutcome>
|
||||
dispatchCommand: (
|
||||
command: string,
|
||||
options?: { delivery?: CatalogCommandDelivery }
|
||||
) => Promise<MobileNativeChatSendOutcome>
|
||||
/** A model change that must happen in the agent's own TUI picker was
|
||||
* dispatched — bring the terminal view forward. */
|
||||
onAgentPicker?: () => void
|
||||
@@ -297,7 +301,9 @@ export function useMobileNativeChatSessionOptions(args: {
|
||||
?.options.find((option) => option.id === id)?.apply
|
||||
const midSession = apply?.midSession
|
||||
if (midSession?.kind === 'agent-picker') {
|
||||
const outcome = await dispatchCommand(midSession.command)
|
||||
const outcome = midSession.delivery
|
||||
? await dispatchCommand(midSession.command, { delivery: midSession.delivery })
|
||||
: await dispatchCommand(midSession.command)
|
||||
if (outcome === 'rejected') {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mapBaseRefResults } from './mobile-base-ref-search'
|
||||
|
||||
describe('mapBaseRefResults', () => {
|
||||
it('extracts a clean string list from a well-formed payload', () => {
|
||||
expect(mapBaseRefResults({ refs: ['main', 'dev'] })).toEqual(['main', 'dev'])
|
||||
})
|
||||
|
||||
it('returns [] for malformed payloads and drops bad entries', () => {
|
||||
expect(mapBaseRefResults(null)).toEqual([])
|
||||
expect(mapBaseRefResults({})).toEqual([])
|
||||
expect(mapBaseRefResults({ refs: 'x' })).toEqual([])
|
||||
expect(mapBaseRefResults({ refs: ['ok', 2, '', null, 'two'] })).toEqual(['ok', 'two'])
|
||||
})
|
||||
})
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import { mobileRepoSelectorFromWorktreeId } from './mobile-pr-create'
|
||||
|
||||
export function mapBaseRefResults(raw: unknown): string[] {
|
||||
if (raw === null || typeof raw !== 'object') {
|
||||
return []
|
||||
}
|
||||
const refs = (raw as { refs?: unknown }).refs
|
||||
if (!Array.isArray(refs)) {
|
||||
return []
|
||||
}
|
||||
return refs.filter((r): r is string => typeof r === 'string' && r.length > 0)
|
||||
}
|
||||
|
||||
export async function searchBaseRefs(
|
||||
client: Pick<RpcClient, 'sendRequest'>,
|
||||
worktreeId: string,
|
||||
query: string,
|
||||
limit = 20
|
||||
): Promise<string[]> {
|
||||
const response = await client.sendRequest('repo.searchRefs', {
|
||||
repo: mobileRepoSelectorFromWorktreeId(worktreeId),
|
||||
query,
|
||||
limit
|
||||
})
|
||||
if (!response.ok) {
|
||||
return []
|
||||
}
|
||||
return mapBaseRefResults((response as RpcSuccess).result)
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
import type { MobileGitUpstreamStatus } from './mobile-git-status'
|
||||
|
||||
export type MobileSourceControlPrimaryActionKind =
|
||||
| 'commit'
|
||||
| 'stage'
|
||||
| 'push'
|
||||
| 'pull'
|
||||
| 'sync'
|
||||
| 'publish'
|
||||
|
||||
export type MobileSourceControlRemoteOpKind =
|
||||
| 'push'
|
||||
| 'force_push'
|
||||
| 'pull'
|
||||
| 'sync'
|
||||
| 'fetch'
|
||||
| 'fast_forward'
|
||||
| 'publish'
|
||||
| 'rebase'
|
||||
|
||||
export type MobileSourceControlPrimaryActionTitleIntent =
|
||||
| 'commit_in_progress'
|
||||
| 'force_push_in_progress'
|
||||
| 'action_in_progress'
|
||||
| 'remote_operation_in_progress'
|
||||
| 'remote_operation_blocks_commit'
|
||||
| 'resolve_conflicts_before_commit'
|
||||
| 'commit_staged_changes'
|
||||
| 'enter_commit_message'
|
||||
| 'stage_all_changes'
|
||||
| 'stage_file_to_commit'
|
||||
| 'checkout_branch_before_publish'
|
||||
| 'publish_branch'
|
||||
| 'force_push_with_lease'
|
||||
| 'sync_counts'
|
||||
| 'pull_count'
|
||||
| 'push_count'
|
||||
| 'nothing_to_commit_up_to_date'
|
||||
|
||||
export type MobileSourceControlPrimaryActionDecision = {
|
||||
kind: MobileSourceControlPrimaryActionKind
|
||||
disabled: boolean
|
||||
labelIntent: MobileSourceControlPrimaryActionKind | 'force_push'
|
||||
titleIntent: MobileSourceControlPrimaryActionTitleIntent
|
||||
count?: number
|
||||
ahead?: number
|
||||
behind?: number
|
||||
upstreamName?: string
|
||||
requiresForceWithLease?: boolean
|
||||
}
|
||||
|
||||
export type MobileSourceControlPrimaryActionDecisionInputs = {
|
||||
stagedCount: number
|
||||
hasUnstagedChanges: boolean
|
||||
hasStageableChanges: boolean
|
||||
hasPartiallyStagedChanges: boolean
|
||||
hasMessage: boolean
|
||||
hasUnresolvedConflicts: boolean
|
||||
isCommitting: boolean
|
||||
isRemoteOperationActive: boolean
|
||||
upstreamStatus: MobileGitUpstreamStatus | undefined
|
||||
inFlightRemoteOpKind?: MobileSourceControlRemoteOpKind | null
|
||||
branchCommitsAhead?: number
|
||||
hasCurrentBranch?: boolean
|
||||
}
|
||||
|
||||
// Why: Metro cannot load runtime modules from the desktop/root `src/shared`
|
||||
// tree. Keep this mobile mirror narrow and parity-tested against the shared
|
||||
// commit-area decision core so the semantic ladder cannot drift silently.
|
||||
export function resolveMobileSourceControlCommitAreaPrimaryActionDecision(
|
||||
inputs: MobileSourceControlPrimaryActionDecisionInputs
|
||||
): MobileSourceControlPrimaryActionDecision {
|
||||
const {
|
||||
stagedCount,
|
||||
hasUnstagedChanges,
|
||||
hasStageableChanges,
|
||||
hasMessage,
|
||||
hasUnresolvedConflicts,
|
||||
isCommitting,
|
||||
isRemoteOperationActive,
|
||||
upstreamStatus,
|
||||
branchCommitsAhead,
|
||||
hasCurrentBranch = true
|
||||
} = inputs
|
||||
|
||||
if (isCommitting) {
|
||||
return {
|
||||
kind: 'commit',
|
||||
labelIntent: 'commit',
|
||||
titleIntent: 'commit_in_progress',
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
|
||||
if (isRemoteOperationActive) {
|
||||
return resolveMobilePrimaryActionDuringRemoteOp(inputs)
|
||||
}
|
||||
|
||||
if (hasUnresolvedConflicts) {
|
||||
return {
|
||||
kind: 'commit',
|
||||
labelIntent: 'commit',
|
||||
titleIntent: 'resolve_conflicts_before_commit',
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
|
||||
const hasStaged = stagedCount > 0
|
||||
|
||||
if (hasStaged && hasMessage) {
|
||||
return {
|
||||
kind: 'commit',
|
||||
labelIntent: 'commit',
|
||||
titleIntent: 'commit_staged_changes',
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
|
||||
if (hasStaged && !hasMessage) {
|
||||
return {
|
||||
kind: 'commit',
|
||||
labelIntent: 'commit',
|
||||
titleIntent: 'enter_commit_message',
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasStaged && hasStageableChanges) {
|
||||
return {
|
||||
kind: 'stage',
|
||||
labelIntent: 'stage',
|
||||
titleIntent: 'stage_all_changes',
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
|
||||
if (!upstreamStatus) {
|
||||
return {
|
||||
kind: 'commit',
|
||||
labelIntent: 'commit',
|
||||
titleIntent: 'stage_file_to_commit',
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
|
||||
if (!upstreamStatus.hasUpstream) {
|
||||
if (!hasCurrentBranch) {
|
||||
return {
|
||||
kind: 'commit',
|
||||
labelIntent: 'commit',
|
||||
titleIntent: 'checkout_branch_before_publish',
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: 'publish',
|
||||
labelIntent: 'publish',
|
||||
titleIntent: 'publish_branch',
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
|
||||
if (upstreamStatus.ahead > 0 && upstreamStatus.behind > 0) {
|
||||
if (shouldForcePushWithLeaseForMobileUpstream(upstreamStatus)) {
|
||||
return {
|
||||
kind: 'push',
|
||||
labelIntent: 'force_push',
|
||||
titleIntent: 'force_push_with_lease',
|
||||
disabled: false,
|
||||
count: branchCommitsAhead,
|
||||
upstreamName: upstreamStatus.upstreamName,
|
||||
requiresForceWithLease: true
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: 'sync',
|
||||
labelIntent: 'sync',
|
||||
titleIntent: 'sync_counts',
|
||||
disabled: false,
|
||||
ahead: upstreamStatus.ahead,
|
||||
behind: upstreamStatus.behind
|
||||
}
|
||||
}
|
||||
|
||||
if (upstreamStatus.behind > 0) {
|
||||
return {
|
||||
kind: 'pull',
|
||||
labelIntent: 'pull',
|
||||
titleIntent: 'pull_count',
|
||||
disabled: false,
|
||||
count: upstreamStatus.behind
|
||||
}
|
||||
}
|
||||
|
||||
if (upstreamStatus.ahead > 0) {
|
||||
return {
|
||||
kind: 'push',
|
||||
labelIntent: 'push',
|
||||
titleIntent: 'push_count',
|
||||
disabled: false,
|
||||
count: upstreamStatus.ahead
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'commit',
|
||||
labelIntent: 'commit',
|
||||
titleIntent: hasUnstagedChanges ? 'stage_file_to_commit' : 'nothing_to_commit_up_to_date',
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMobilePrimaryActionDuringRemoteOp(
|
||||
inputs: MobileSourceControlPrimaryActionDecisionInputs
|
||||
): MobileSourceControlPrimaryActionDecision {
|
||||
const { inFlightRemoteOpKind, hasUnresolvedConflicts } = inputs
|
||||
const candidate = resolveMobileSourceControlCommitAreaPrimaryActionDecision({
|
||||
...inputs,
|
||||
isRemoteOperationActive: false
|
||||
})
|
||||
const inFlightIsPrimaryKind =
|
||||
inFlightRemoteOpKind === 'push' ||
|
||||
inFlightRemoteOpKind === 'pull' ||
|
||||
inFlightRemoteOpKind === 'sync' ||
|
||||
inFlightRemoteOpKind === 'publish'
|
||||
|
||||
if (inFlightRemoteOpKind === 'force_push') {
|
||||
return {
|
||||
kind: 'push',
|
||||
labelIntent: 'force_push',
|
||||
titleIntent: 'force_push_in_progress',
|
||||
disabled: true,
|
||||
requiresForceWithLease: true
|
||||
}
|
||||
}
|
||||
|
||||
if (inFlightIsPrimaryKind && candidate.kind !== inFlightRemoteOpKind) {
|
||||
return {
|
||||
kind: inFlightRemoteOpKind,
|
||||
labelIntent: inFlightRemoteOpKind,
|
||||
titleIntent: 'action_in_progress',
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
|
||||
const titleIntent = hasUnresolvedConflicts
|
||||
? 'resolve_conflicts_before_commit'
|
||||
: candidate.kind === 'commit'
|
||||
? 'remote_operation_blocks_commit'
|
||||
: 'remote_operation_in_progress'
|
||||
|
||||
return {
|
||||
...candidate,
|
||||
titleIntent,
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
|
||||
function shouldForcePushWithLeaseForMobileUpstream(
|
||||
status: MobileGitUpstreamStatus | undefined
|
||||
): boolean {
|
||||
return (
|
||||
status?.hasUpstream === true &&
|
||||
status.ahead > 0 &&
|
||||
status.behind > 0 &&
|
||||
status.behindCommitsArePatchEquivalent === true
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolveSourceControlCommitAreaPrimaryActionDecision } from '../../../src/shared/source-control-primary-action-decision'
|
||||
import {
|
||||
resolveMobileSourceControlCommitAreaPrimaryActionDecision,
|
||||
type MobileSourceControlPrimaryActionDecisionInputs
|
||||
} from './mobile-source-control-primary-action-decision'
|
||||
import {
|
||||
buildMobileSourceControlPrimaryAction,
|
||||
type MobileSourceControlPrimaryActionArgs,
|
||||
@@ -159,113 +154,3 @@ describe('buildMobileSourceControlPrimaryAction', () => {
|
||||
expect(action.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
function decisionInputs(
|
||||
overrides: Partial<MobileSourceControlPrimaryActionDecisionInputs> = {}
|
||||
): MobileSourceControlPrimaryActionDecisionInputs {
|
||||
return {
|
||||
stagedCount: 0,
|
||||
hasUnstagedChanges: false,
|
||||
hasStageableChanges: false,
|
||||
hasPartiallyStagedChanges: false,
|
||||
hasMessage: false,
|
||||
hasUnresolvedConflicts: false,
|
||||
isCommitting: false,
|
||||
isRemoteOperationActive: false,
|
||||
upstreamStatus: undefined,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('mobile source-control primary action decision parity', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'dirty tree stages first',
|
||||
input: decisionInputs({
|
||||
hasUnstagedChanges: true,
|
||||
hasStageableChanges: true,
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 2 }
|
||||
})
|
||||
},
|
||||
{
|
||||
name: 'staged message commits',
|
||||
input: decisionInputs({ stagedCount: 1, hasMessage: true })
|
||||
},
|
||||
{
|
||||
name: 'staged without message blocks commit',
|
||||
input: decisionInputs({ stagedCount: 1, hasMessage: false })
|
||||
},
|
||||
{
|
||||
name: 'unresolved conflicts block commit',
|
||||
input: decisionInputs({ stagedCount: 1, hasMessage: true, hasUnresolvedConflicts: true })
|
||||
},
|
||||
{
|
||||
name: 'unpublished branch publishes',
|
||||
input: decisionInputs({
|
||||
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
|
||||
hasCurrentBranch: true
|
||||
})
|
||||
},
|
||||
{
|
||||
name: 'detached head blocks publish',
|
||||
input: decisionInputs({
|
||||
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
|
||||
hasCurrentBranch: false
|
||||
})
|
||||
},
|
||||
{
|
||||
name: 'tracked ahead pushes',
|
||||
input: decisionInputs({ upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 } })
|
||||
},
|
||||
{
|
||||
name: 'tracked behind pulls',
|
||||
input: decisionInputs({ upstreamStatus: { hasUpstream: true, ahead: 0, behind: 3 } })
|
||||
},
|
||||
{
|
||||
name: 'tracked diverged syncs',
|
||||
input: decisionInputs({ upstreamStatus: { hasUpstream: true, ahead: 2, behind: 3 } })
|
||||
},
|
||||
{
|
||||
name: 'patch-equivalent diverged force-pushes with lease',
|
||||
input: decisionInputs({
|
||||
branchCommitsAhead: 4,
|
||||
upstreamStatus: {
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/feature',
|
||||
ahead: 10,
|
||||
behind: 2,
|
||||
behindCommitsArePatchEquivalent: true
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
name: 'in-flight pull mirrors pull',
|
||||
input: decisionInputs({
|
||||
isRemoteOperationActive: true,
|
||||
inFlightRemoteOpKind: 'pull',
|
||||
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 }
|
||||
})
|
||||
},
|
||||
{
|
||||
name: 'in-flight force push mirrors force push',
|
||||
input: decisionInputs({
|
||||
isRemoteOperationActive: true,
|
||||
inFlightRemoteOpKind: 'force_push',
|
||||
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 }
|
||||
})
|
||||
},
|
||||
{
|
||||
name: 'in-flight push blocks committable candidate',
|
||||
input: decisionInputs({
|
||||
isRemoteOperationActive: true,
|
||||
inFlightRemoteOpKind: 'push',
|
||||
stagedCount: 1,
|
||||
hasMessage: true
|
||||
})
|
||||
}
|
||||
])('matches the shared commit-area decision for $name', ({ input }) => {
|
||||
expect(resolveMobileSourceControlCommitAreaPrimaryActionDecision(input)).toEqual(
|
||||
resolveSourceControlCommitAreaPrimaryActionDecision(input)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import {
|
||||
resolveMobileSourceControlCommitAreaPrimaryActionDecision,
|
||||
type MobileSourceControlPrimaryActionDecision,
|
||||
type MobileSourceControlPrimaryActionKind,
|
||||
type MobileSourceControlRemoteOpKind
|
||||
} from './mobile-source-control-primary-action-decision'
|
||||
resolveSourceControlCommitAreaPrimaryActionDecision,
|
||||
type SourceControlCommitAreaPrimaryActionDecision,
|
||||
type SourceControlRemoteOpKind
|
||||
} from '../../../src/shared/source-control-primary-action-decision'
|
||||
import type { MobileGitBranchCompareResult } from './mobile-branch-compare'
|
||||
import type { MobileGitStatusResult } from './mobile-git-status'
|
||||
|
||||
type GitStep = { method: string; params?: Record<string, unknown> }
|
||||
|
||||
type MobileSourceControlPrimaryActionKind = SourceControlCommitAreaPrimaryActionDecision['kind']
|
||||
type MobileSourceControlPrimaryActionDecision = SourceControlCommitAreaPrimaryActionDecision
|
||||
type MobileSourceControlRemoteOpKind = SourceControlRemoteOpKind
|
||||
|
||||
export type MobileSourceControlPrimaryAction = {
|
||||
kind: MobileSourceControlPrimaryActionKind
|
||||
label: string
|
||||
@@ -44,7 +47,7 @@ export type MobileSourceControlPrimaryActionArgs = {
|
||||
export function buildMobileSourceControlPrimaryAction(
|
||||
args: MobileSourceControlPrimaryActionArgs
|
||||
): MobileSourceControlPrimaryAction {
|
||||
const decision = resolveMobileSourceControlCommitAreaPrimaryActionDecision({
|
||||
const decision = resolveSourceControlCommitAreaPrimaryActionDecision({
|
||||
stagedCount: args.stagedCount,
|
||||
hasUnstagedChanges: args.unstagedCount > 0,
|
||||
hasStageableChanges: args.stageablePaths.length > 0,
|
||||
@@ -167,8 +170,16 @@ function getMobilePrimaryActionHint(decision: MobileSourceControlPrimaryActionDe
|
||||
return 'Stage at least one file to commit.'
|
||||
case 'checkout_branch_before_publish':
|
||||
return 'Check out a branch before publishing commits.'
|
||||
case 'checking_review_status':
|
||||
return 'Checking review status.'
|
||||
case 'review_already_merged':
|
||||
return 'Nothing to commit. The review is already merged.'
|
||||
case 'publish_branch':
|
||||
return 'Publish this branch to origin.'
|
||||
case 'push_linked_review':
|
||||
return 'Push updates to the linked review branch.'
|
||||
case 'linked_review_target_unavailable':
|
||||
return 'The linked review branch is unavailable.'
|
||||
case 'force_push_with_lease':
|
||||
return 'Force push with lease to update the remote branch.'
|
||||
case 'sync_counts':
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
canSubmitPrCompose,
|
||||
getPrComposeDisabledReason,
|
||||
isBaseHeadDistinct
|
||||
} from './pr-compose-validation'
|
||||
|
||||
describe('isBaseHeadDistinct', () => {
|
||||
it('is true when base and head differ', () => {
|
||||
expect(isBaseHeadDistinct('main', 'feature')).toBe(true)
|
||||
})
|
||||
|
||||
it('is false when they match after normalization (case-insensitive, prefix-stripped)', () => {
|
||||
expect(isBaseHeadDistinct('main', 'main')).toBe(false)
|
||||
expect(isBaseHeadDistinct('refs/heads/main', 'main')).toBe(false)
|
||||
expect(isBaseHeadDistinct('origin/Main', 'main')).toBe(false)
|
||||
expect(isBaseHeadDistinct('upstream/main', 'refs/heads/main')).toBe(false)
|
||||
})
|
||||
|
||||
it('is false for an empty base', () => {
|
||||
expect(isBaseHeadDistinct('', 'feature')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canSubmitPrCompose', () => {
|
||||
it('requires a non-empty title and a distinct base', () => {
|
||||
expect(canSubmitPrCompose('Title', 'main', 'feature')).toBe(true)
|
||||
expect(canSubmitPrCompose(' ', 'main', 'feature')).toBe(false)
|
||||
expect(canSubmitPrCompose('Title', 'main', 'main')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPrComposeDisabledReason', () => {
|
||||
it('returns null when the form can submit', () => {
|
||||
expect(
|
||||
getPrComposeDisabledReason({
|
||||
title: 'Title',
|
||||
base: 'main',
|
||||
head: 'feature',
|
||||
generating: false,
|
||||
reviewLabel: 'pull request'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('names the active blocker', () => {
|
||||
expect(
|
||||
getPrComposeDisabledReason({
|
||||
title: 'Title',
|
||||
base: 'main',
|
||||
head: 'feature',
|
||||
generating: true,
|
||||
reviewLabel: 'pull request'
|
||||
})
|
||||
).toBe('Wait for generation to finish.')
|
||||
expect(
|
||||
getPrComposeDisabledReason({
|
||||
title: '',
|
||||
base: 'main',
|
||||
head: 'feature',
|
||||
generating: false,
|
||||
reviewLabel: 'merge request'
|
||||
})
|
||||
).toBe('Enter a merge request title.')
|
||||
expect(
|
||||
getPrComposeDisabledReason({
|
||||
title: 'Title',
|
||||
base: '',
|
||||
head: 'feature',
|
||||
generating: false,
|
||||
reviewLabel: 'pull request'
|
||||
})
|
||||
).toBe('Choose a base branch.')
|
||||
expect(
|
||||
getPrComposeDisabledReason({
|
||||
title: 'Title',
|
||||
base: 'main',
|
||||
head: 'main',
|
||||
generating: false,
|
||||
reviewLabel: 'pull request'
|
||||
})
|
||||
).toBe('Base branch must differ from the head branch.')
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
// Ref normalizers ported from src/shared/hosted-review-refs.ts (value-imported, not
|
||||
// referenced across the package boundary — Metro's resolver is rooted at mobile/ and
|
||||
// can't bundle a runtime import from repo-root/src, unlike erased `import type`s).
|
||||
// Keep in sync with the shared versions so mobile and desktop compare refs identically.
|
||||
function normalizeHeadRef(ref: string): string {
|
||||
return ref
|
||||
.trim()
|
||||
.replace(/^refs\/heads\//, '')
|
||||
.replace(/^refs\/remotes\/[^/]+\//, '')
|
||||
}
|
||||
|
||||
function normalizeBaseRef(ref: string): string {
|
||||
return normalizeHeadRef(ref).replace(/^(origin|upstream)\//, '')
|
||||
}
|
||||
|
||||
// Submit-gating for the create-PR composer, matching desktop CreatePullRequestDialog:
|
||||
// the base ref must be non-empty and must differ from the head branch after ref
|
||||
// normalization (strip refs/heads, remote prefixes, origin/upstream), case-insensitive.
|
||||
export function isBaseHeadDistinct(base: string, head: string): boolean {
|
||||
const b = normalizeBaseRef(base).toLowerCase()
|
||||
const h = normalizeHeadRef(head).toLowerCase()
|
||||
return b.length > 0 && b !== h
|
||||
}
|
||||
|
||||
export function canSubmitPrCompose(title: string, base: string, head: string): boolean {
|
||||
return title.trim().length > 0 && isBaseHeadDistinct(base, head)
|
||||
}
|
||||
|
||||
export function getPrComposeDisabledReason({
|
||||
title,
|
||||
base,
|
||||
head,
|
||||
generating,
|
||||
reviewLabel
|
||||
}: {
|
||||
title: string
|
||||
base: string
|
||||
head: string
|
||||
generating: boolean
|
||||
reviewLabel: string
|
||||
}): string | null {
|
||||
if (generating) {
|
||||
return 'Wait for generation to finish.'
|
||||
}
|
||||
if (title.trim().length === 0) {
|
||||
return `Enter a ${reviewLabel} title.`
|
||||
}
|
||||
if (base.trim().length === 0) {
|
||||
return 'Choose a base branch.'
|
||||
}
|
||||
if (!isBaseHeadDistinct(base, head)) {
|
||||
return 'Base branch must differ from the head branch.'
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
export type GitHubProjectSortDirection = 'ASC' | 'DESC'
|
||||
|
||||
export type GitHubProjectFieldValue =
|
||||
| { kind: 'text'; text: string }
|
||||
| { kind: 'number'; number: number }
|
||||
| { kind: 'date'; date: string }
|
||||
| { kind: 'single-select'; optionId: string; name: string }
|
||||
| { kind: 'iteration'; iterationId: string; title: string }
|
||||
| { kind: 'labels'; labels: Array<{ name: string }> }
|
||||
| { kind: 'users'; users: Array<{ login: string }> }
|
||||
|
||||
export type GitHubProjectField = {
|
||||
id: string
|
||||
name: string
|
||||
kind: string
|
||||
options?: Array<{ id: string }>
|
||||
iterations?: Array<{
|
||||
id: string
|
||||
title?: string
|
||||
startDate: string
|
||||
duration: number
|
||||
completed: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
export type GitHubProjectRow = {
|
||||
position?: number | null
|
||||
fieldValuesByFieldId: Record<string, GitHubProjectFieldValue>
|
||||
}
|
||||
|
||||
export type GitHubProjectSort = {
|
||||
direction: GitHubProjectSortDirection
|
||||
field: GitHubProjectField
|
||||
}
|
||||
|
||||
export type GitHubProjectTable = {
|
||||
selectedView: {
|
||||
groupByFields: GitHubProjectField[]
|
||||
sortByFields: GitHubProjectSort[]
|
||||
}
|
||||
rows: GitHubProjectRow[]
|
||||
}
|
||||
|
||||
export type ProjectGroup = {
|
||||
key: string
|
||||
label: string
|
||||
iteration: {
|
||||
startDate: string
|
||||
duration: number
|
||||
completed: boolean
|
||||
} | null
|
||||
rows: GitHubProjectRow[]
|
||||
}
|
||||
|
||||
const EMPTY_GROUP_KEY = '__empty__'
|
||||
const UNKNOWN_INDEX_SENTINEL = Number.MAX_SAFE_INTEGER
|
||||
|
||||
function labelForEmpty(field: GitHubProjectField): string {
|
||||
return `No ${field.name}`
|
||||
}
|
||||
|
||||
function deriveStringValue(value: GitHubProjectFieldValue): string {
|
||||
switch (value.kind) {
|
||||
case 'text':
|
||||
return value.text
|
||||
case 'number':
|
||||
return String(value.number)
|
||||
case 'date':
|
||||
return value.date
|
||||
case 'single-select':
|
||||
return value.name
|
||||
case 'iteration':
|
||||
return value.title
|
||||
case 'labels':
|
||||
return value.labels.map((label) => label.name).join(', ')
|
||||
case 'users':
|
||||
return value.users.map((user) => user.login).join(', ')
|
||||
}
|
||||
}
|
||||
|
||||
function getFieldValueForGrouping(
|
||||
row: GitHubProjectRow,
|
||||
field: GitHubProjectField
|
||||
): { key: string; label: string; orderHint: number; iteration: ProjectGroup['iteration'] } {
|
||||
const value = row.fieldValuesByFieldId[field.id]
|
||||
if (!value) {
|
||||
return {
|
||||
key: EMPTY_GROUP_KEY,
|
||||
label: labelForEmpty(field),
|
||||
orderHint: UNKNOWN_INDEX_SENTINEL,
|
||||
iteration: null
|
||||
}
|
||||
}
|
||||
if (field.kind === 'iteration' && value.kind === 'iteration') {
|
||||
const iterations = field.iterations ?? []
|
||||
const idx = iterations.findIndex((it) => it.id === value.iterationId)
|
||||
const meta = iterations.find((it) => it.id === value.iterationId)
|
||||
return {
|
||||
key: value.iterationId,
|
||||
label: value.title || meta?.title || 'Iteration',
|
||||
orderHint: idx === -1 ? UNKNOWN_INDEX_SENTINEL - 1 : idx,
|
||||
iteration: meta
|
||||
? { startDate: meta.startDate, duration: meta.duration, completed: meta.completed }
|
||||
: null
|
||||
}
|
||||
}
|
||||
if (field.kind === 'single-select' && value.kind === 'single-select') {
|
||||
const idx = (field.options ?? []).findIndex((option) => option.id === value.optionId)
|
||||
return {
|
||||
key: value.optionId,
|
||||
label: value.name,
|
||||
orderHint: idx === -1 ? UNKNOWN_INDEX_SENTINEL - 1 : idx,
|
||||
iteration: null
|
||||
}
|
||||
}
|
||||
const label = deriveStringValue(value)
|
||||
return { key: `raw:${label}`, label, orderHint: 0, iteration: null }
|
||||
}
|
||||
|
||||
export function groupRows(
|
||||
table: GitHubProjectTable,
|
||||
rowsInOrder: GitHubProjectRow[]
|
||||
): ProjectGroup[] {
|
||||
const groupField = table.selectedView.groupByFields[0]
|
||||
if (!groupField) {
|
||||
return [{ key: 'all', label: '', iteration: null, rows: rowsInOrder }]
|
||||
}
|
||||
const buckets = new Map<
|
||||
string,
|
||||
{
|
||||
label: string
|
||||
orderHint: number
|
||||
iteration: ProjectGroup['iteration']
|
||||
rows: GitHubProjectRow[]
|
||||
}
|
||||
>()
|
||||
for (const row of rowsInOrder) {
|
||||
const { key, label, orderHint, iteration } = getFieldValueForGrouping(row, groupField)
|
||||
let bucket = buckets.get(key)
|
||||
if (!bucket) {
|
||||
bucket = { label, orderHint, iteration, rows: [] }
|
||||
buckets.set(key, bucket)
|
||||
}
|
||||
bucket.rows.push(row)
|
||||
}
|
||||
const entries = Array.from(buckets.entries())
|
||||
entries.sort((a, b) => {
|
||||
if (a[0] === EMPTY_GROUP_KEY) {
|
||||
return 1
|
||||
}
|
||||
if (b[0] === EMPTY_GROUP_KEY) {
|
||||
return -1
|
||||
}
|
||||
if (groupField.kind === 'iteration' || groupField.kind === 'single-select') {
|
||||
return a[1].orderHint - b[1].orderHint
|
||||
}
|
||||
return a[1].label.localeCompare(b[1].label)
|
||||
})
|
||||
return entries.map(([key, value]) => ({
|
||||
key,
|
||||
label: value.label,
|
||||
iteration: value.iteration,
|
||||
rows: value.rows
|
||||
}))
|
||||
}
|
||||
|
||||
function compareSort(a: GitHubProjectRow, b: GitHubProjectRow, sort: GitHubProjectSort): number {
|
||||
const field = sort.field
|
||||
const aValue = a.fieldValuesByFieldId[field.id]
|
||||
const bValue = b.fieldValuesByFieldId[field.id]
|
||||
if (!aValue && !bValue) {
|
||||
return 0
|
||||
}
|
||||
if (!aValue) {
|
||||
return 1
|
||||
}
|
||||
if (!bValue) {
|
||||
return -1
|
||||
}
|
||||
|
||||
let cmp = 0
|
||||
if (
|
||||
field.kind === 'single-select' &&
|
||||
aValue.kind === 'single-select' &&
|
||||
bValue.kind === 'single-select'
|
||||
) {
|
||||
const options = field.options ?? []
|
||||
const aIdx = options.findIndex((option) => option.id === aValue.optionId)
|
||||
const bIdx = options.findIndex((option) => option.id === bValue.optionId)
|
||||
cmp =
|
||||
(aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx)
|
||||
} else if (
|
||||
field.kind === 'iteration' &&
|
||||
aValue.kind === 'iteration' &&
|
||||
bValue.kind === 'iteration'
|
||||
) {
|
||||
const iterations = field.iterations ?? []
|
||||
const aIdx = iterations.findIndex((iteration) => iteration.id === aValue.iterationId)
|
||||
const bIdx = iterations.findIndex((iteration) => iteration.id === bValue.iterationId)
|
||||
cmp =
|
||||
(aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx)
|
||||
} else if (aValue.kind === 'number' && bValue.kind === 'number') {
|
||||
cmp = aValue.number - bValue.number
|
||||
} else if (aValue.kind === 'date' && bValue.kind === 'date') {
|
||||
cmp = aValue.date.localeCompare(bValue.date)
|
||||
} else if (aValue.kind === 'text' && bValue.kind === 'text') {
|
||||
cmp = aValue.text.localeCompare(bValue.text)
|
||||
} else if (aValue.kind === 'users' && bValue.kind === 'users') {
|
||||
const aLogin = aValue.users[0]?.login ?? ''
|
||||
const bLogin = bValue.users[0]?.login ?? ''
|
||||
if (!aLogin && !bLogin) {
|
||||
cmp = 0
|
||||
} else if (!aLogin) {
|
||||
cmp = 1
|
||||
} else if (!bLogin) {
|
||||
cmp = -1
|
||||
} else {
|
||||
cmp = aLogin.localeCompare(bLogin)
|
||||
}
|
||||
} else if (aValue.kind === 'labels' && bValue.kind === 'labels') {
|
||||
const aName = aValue.labels[0]?.name ?? ''
|
||||
const bName = bValue.labels[0]?.name ?? ''
|
||||
if (!aName && !bName) {
|
||||
cmp = 0
|
||||
} else if (!aName) {
|
||||
cmp = 1
|
||||
} else if (!bName) {
|
||||
cmp = -1
|
||||
} else {
|
||||
cmp = aName.localeCompare(bName)
|
||||
}
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
return sort.direction === 'DESC' ? -cmp : cmp
|
||||
}
|
||||
|
||||
export function sortRows(table: GitHubProjectTable, rows: GitHubProjectRow[]): GitHubProjectRow[] {
|
||||
const sorts = table.selectedView.sortByFields
|
||||
const out = [...rows]
|
||||
out.sort((a, b) => {
|
||||
for (const sort of sorts) {
|
||||
const cmp = compareSort(a, b, sort)
|
||||
if (cmp !== 0) {
|
||||
return cmp
|
||||
}
|
||||
}
|
||||
return (a.position ?? UNKNOWN_INDEX_SENTINEL) - (b.position ?? UNKNOWN_INDEX_SENTINEL)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
export function isIterationCurrent(iteration: { startDate: string; duration: number }): boolean {
|
||||
const start = new Date(`${iteration.startDate}T00:00:00Z`).getTime()
|
||||
if (Number.isNaN(start)) {
|
||||
return false
|
||||
}
|
||||
const end = start + iteration.duration * 86_400_000
|
||||
const now = Date.now()
|
||||
return now >= start && now < end
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
|
||||
export const terminalSettingsScreenStyles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: 0
|
||||
},
|
||||
topRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.sm,
|
||||
marginBottom: spacing.lg
|
||||
},
|
||||
backButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.sm
|
||||
},
|
||||
heading: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: spacing.xl
|
||||
},
|
||||
groupHeading: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: spacing.xs,
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
groupDescription: {
|
||||
fontSize: typography.bodySize - 1,
|
||||
color: colors.textSecondary,
|
||||
lineHeight: 20,
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
section: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: radii.card,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
sectionTopGap: {
|
||||
marginTop: spacing.sm
|
||||
},
|
||||
inputGroupGap: {
|
||||
marginTop: spacing.xl
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary,
|
||||
padding: spacing.md
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm + 2,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
rowContent: {
|
||||
flex: 1
|
||||
},
|
||||
rowLabel: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
rowSublabel: {
|
||||
fontSize: typography.bodySize - 2,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 2
|
||||
},
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
}
|
||||
})
|
||||
@@ -1,41 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { startPairingConnectionAttempt } from './pairing-connection-attempt'
|
||||
|
||||
describe('pairing connection attempt cleanup', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('closes the temporary client when the overall pairing timeout fires', () => {
|
||||
vi.useFakeTimers()
|
||||
const closeClient = vi.fn()
|
||||
|
||||
const attempt = startPairingConnectionAttempt({ timeoutMs: 25_000, closeClient })
|
||||
|
||||
expect(attempt.timedOut).toBe(false)
|
||||
vi.advanceTimersByTime(24_999)
|
||||
expect(closeClient).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(attempt.timedOut).toBe(true)
|
||||
expect(closeClient).toHaveBeenCalledTimes(1)
|
||||
|
||||
attempt.dispose()
|
||||
expect(closeClient).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('clears the timeout and closes the temporary client when disposed early', () => {
|
||||
vi.useFakeTimers()
|
||||
const closeClient = vi.fn()
|
||||
|
||||
const attempt = startPairingConnectionAttempt({ timeoutMs: 25_000, closeClient })
|
||||
attempt.dispose()
|
||||
|
||||
expect(attempt.timedOut).toBe(false)
|
||||
expect(closeClient).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.advanceTimersByTime(25_000)
|
||||
expect(attempt.timedOut).toBe(false)
|
||||
expect(closeClient).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,50 +0,0 @@
|
||||
export type PairingConnectionAttempt = {
|
||||
readonly timedOut: boolean
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
export function startPairingConnectionAttempt({
|
||||
timeoutMs,
|
||||
closeClient
|
||||
}: {
|
||||
timeoutMs: number
|
||||
closeClient: () => void
|
||||
}): PairingConnectionAttempt {
|
||||
let disposed = false
|
||||
let clientClosed = false
|
||||
let timedOut = false
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function closeClientOnce() {
|
||||
if (clientClosed) {
|
||||
return
|
||||
}
|
||||
clientClosed = true
|
||||
closeClient()
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
disposed = true
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
closeClientOnce()
|
||||
}
|
||||
|
||||
timer = setTimeout(() => {
|
||||
timer = null
|
||||
timedOut = true
|
||||
dispose()
|
||||
}, timeoutMs)
|
||||
|
||||
return {
|
||||
get timedOut() {
|
||||
return timedOut
|
||||
},
|
||||
dispose
|
||||
}
|
||||
}
|
||||
@@ -408,8 +408,11 @@ def render_accessibility_tree(root, window_rect, root_path, compact_browser_tabs
|
||||
item = record(node, len(records), path, window_rect)
|
||||
child_items = list(children(node))
|
||||
role_key = (item["controlType"] or "").lower()
|
||||
summary_values = text_snippets(node, limit=8, max_depth=4)
|
||||
generic_summary = " ".join(summary_values) if role_key in {"panel", "filler", "unknown", "section"} and not item["name"] and not item["value"] and len(summary_values) >= 2 and is_plain_text_subtree(node) else None
|
||||
generic_summary = None
|
||||
if role_key in {"panel", "filler", "unknown", "section"} and not item["name"] and not item["value"]:
|
||||
summary_values = text_snippets(node, limit=8, max_depth=4)
|
||||
if len(summary_values) >= 2 and is_plain_text_subtree(node):
|
||||
generic_summary = " ".join(summary_values)
|
||||
if should_elide(item, len(child_items), generic_summary):
|
||||
for child_index, child in child_items:
|
||||
walk(child, depth, path + [child_index])
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class FakeStateType:
|
||||
PROTECTED = 1
|
||||
SELECTED = 2
|
||||
|
||||
|
||||
class FakeText:
|
||||
@staticmethod
|
||||
def get_character_count(node):
|
||||
return len(node.value)
|
||||
|
||||
@staticmethod
|
||||
def get_text(node, start, end):
|
||||
return node.value[start:end]
|
||||
|
||||
|
||||
def load_runtime():
|
||||
gi = types.ModuleType("gi")
|
||||
repository = types.ModuleType("gi.repository")
|
||||
gi.require_version = lambda *_: None
|
||||
repository.Atspi = types.SimpleNamespace(StateType=FakeStateType, Text=FakeText)
|
||||
repository.Gdk = types.SimpleNamespace()
|
||||
repository.GdkPixbuf = types.SimpleNamespace()
|
||||
gi.repository = repository
|
||||
sys.modules["gi"] = gi
|
||||
sys.modules["gi.repository"] = repository
|
||||
|
||||
path = Path(__file__).with_name("runtime.py")
|
||||
spec = importlib.util.spec_from_file_location("orca_linux_runtime_test", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
runtime = load_runtime()
|
||||
|
||||
|
||||
class FakeAccessible:
|
||||
def __init__(self, role, name="", value="", children=(), counter=None):
|
||||
self.role = role
|
||||
self.name = name
|
||||
self.value = value
|
||||
self.children = list(children)
|
||||
self.counter = counter or {"child_reads": 0}
|
||||
for child in self.children:
|
||||
child.use_counter(self.counter)
|
||||
|
||||
def use_counter(self, counter):
|
||||
self.counter = counter
|
||||
for child in self.children:
|
||||
child.use_counter(counter)
|
||||
|
||||
def get_child_count(self):
|
||||
self.counter["child_reads"] += 1
|
||||
return len(self.children)
|
||||
|
||||
def get_child_at_index(self, index):
|
||||
self.counter["child_reads"] += 1
|
||||
return self.children[index]
|
||||
|
||||
def get_role_name(self):
|
||||
return self.role
|
||||
|
||||
def get_name(self):
|
||||
return self.name
|
||||
|
||||
def get_accessible_id(self):
|
||||
return ""
|
||||
|
||||
def get_toolkit_name(self):
|
||||
return "fake"
|
||||
|
||||
def get_component_iface(self):
|
||||
return None
|
||||
|
||||
def get_state_set(self):
|
||||
return None
|
||||
|
||||
def get_n_actions(self):
|
||||
return 0
|
||||
|
||||
def is_text(self):
|
||||
return bool(self.value)
|
||||
|
||||
def get_text_iface(self):
|
||||
return self if self.value else None
|
||||
|
||||
def get_value_iface(self):
|
||||
return None
|
||||
|
||||
|
||||
class FailingChildrenAccessible(FakeAccessible):
|
||||
def get_child_count(self):
|
||||
raise RuntimeError("defunct node")
|
||||
|
||||
|
||||
def render(root):
|
||||
return runtime.render_accessibility_tree(root, None, [0])
|
||||
|
||||
|
||||
class RuntimeRenderTest(unittest.TestCase):
|
||||
def test_named_non_generic_node_skips_unused_summary_walk(self):
|
||||
root = FakeAccessible("button", "Save", children=[FakeAccessible("text", "unused")])
|
||||
|
||||
records, lines, truncation = render(root)
|
||||
|
||||
self.assertEqual([record["name"] for record in records], ["Save"])
|
||||
self.assertEqual(lines, ["0 button Save"])
|
||||
self.assertEqual(root.counter["child_reads"], 2)
|
||||
self.assertFalse(truncation["truncated"])
|
||||
|
||||
def test_named_generic_node_skips_unused_summary_walk(self):
|
||||
root = FakeAccessible("section", "Details", children=[FakeAccessible("text", "body")])
|
||||
|
||||
records, lines, _ = render(root)
|
||||
|
||||
self.assertEqual([record["name"] for record in records], ["Details", "body"])
|
||||
self.assertEqual(lines, ["0 section Details", "\t1 text body"])
|
||||
self.assertEqual(root.counter["child_reads"], 3)
|
||||
|
||||
def test_unnamed_generic_node_keeps_plain_text_summary(self):
|
||||
root = FakeAccessible(
|
||||
"section",
|
||||
children=[FakeAccessible("text", "Alpha"), FakeAccessible("text", "Beta")],
|
||||
)
|
||||
|
||||
records, lines, _ = render(root)
|
||||
|
||||
self.assertEqual(len(records), 1)
|
||||
self.assertEqual(lines, ["0 section, Text: Alpha Beta"])
|
||||
self.assertEqual(root.counter["child_reads"], 13)
|
||||
|
||||
def test_row_keeps_its_specific_summary_walk(self):
|
||||
root = FakeAccessible(
|
||||
"row",
|
||||
"Invoice",
|
||||
children=[FakeAccessible("text", "Alpha"), FakeAccessible("text", "Beta")],
|
||||
)
|
||||
|
||||
records, lines, _ = render(root)
|
||||
|
||||
self.assertEqual([record["name"] for record in records], ["Invoice", "Alpha", "Beta"])
|
||||
self.assertEqual(lines, ["0 row Invoice, Text: Alpha Beta", "\t1 text Alpha", "\t2 text Beta"])
|
||||
self.assertEqual(root.counter["child_reads"], 10)
|
||||
|
||||
def test_empty_generic_wrapper_keeps_elision_path_and_depth(self):
|
||||
root = FakeAccessible("section", children=[FakeAccessible("button", "Continue")])
|
||||
|
||||
records, lines, _ = render(root)
|
||||
|
||||
self.assertEqual([record["runtimeId"] for record in records], [[0, 0]])
|
||||
self.assertEqual(lines, ["0 button Continue"])
|
||||
|
||||
def test_child_failure_keeps_fail_soft_row_output(self):
|
||||
root = FailingChildrenAccessible("row", "Invoice")
|
||||
|
||||
records, lines, truncation = render(root)
|
||||
|
||||
self.assertEqual([record["name"] for record in records], ["Invoice"])
|
||||
self.assertEqual(lines, ["0 row Invoice"])
|
||||
self.assertFalse(truncation["truncated"])
|
||||
|
||||
def test_node_limit_keeps_exact_prefix_and_truncation(self):
|
||||
root = FakeAccessible(
|
||||
"document",
|
||||
"Results",
|
||||
children=[FakeAccessible("text", f"Item {index}") for index in range(runtime.MAX_NODES)],
|
||||
)
|
||||
|
||||
records, _, truncation = render(root)
|
||||
|
||||
self.assertEqual(len(records), runtime.MAX_NODES)
|
||||
self.assertEqual(records[-1]["name"], f"Item {runtime.MAX_NODES - 2}")
|
||||
self.assertTrue(truncation["truncated"])
|
||||
self.assertFalse(truncation["maxDepthReached"])
|
||||
|
||||
def test_depth_limit_keeps_exact_prefix_and_flag(self):
|
||||
root = FakeAccessible("document", "Depth 65")
|
||||
for depth in range(runtime.MAX_DEPTH, -1, -1):
|
||||
root = FakeAccessible("document", f"Depth {depth}", children=[root])
|
||||
|
||||
records, _, truncation = render(root)
|
||||
|
||||
self.assertEqual(len(records), runtime.MAX_DEPTH + 1)
|
||||
self.assertEqual(records[-1]["name"], f"Depth {runtime.MAX_DEPTH}")
|
||||
self.assertTrue(truncation["truncated"])
|
||||
self.assertTrue(truncation["maxDepthReached"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,224 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Assert-TestEqual($Actual, $Expected, [string]$Label) {
|
||||
if ($Actual -ne $Expected) {
|
||||
throw "$Label expected '$Expected', received '$Actual'"
|
||||
}
|
||||
}
|
||||
|
||||
Add-Type -TypeDefinition @"
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
|
||||
public sealed class OrcaRenderTestCounter {
|
||||
public int FindAll;
|
||||
}
|
||||
|
||||
public sealed class OrcaRenderTestBounds {
|
||||
public bool IsEmpty = true;
|
||||
public double X;
|
||||
public double Y;
|
||||
public double Width;
|
||||
public double Height;
|
||||
}
|
||||
|
||||
public sealed class OrcaRenderTestControlType {
|
||||
public string ProgrammaticName = "";
|
||||
}
|
||||
|
||||
public sealed class OrcaRenderTestCurrent {
|
||||
public string AutomationId = "";
|
||||
public OrcaRenderTestBounds BoundingRectangle = new OrcaRenderTestBounds();
|
||||
public string ClassName = "fake";
|
||||
public OrcaRenderTestControlType ControlType = new OrcaRenderTestControlType();
|
||||
public bool IsPassword;
|
||||
public string LocalizedControlType = "";
|
||||
public string Name = "";
|
||||
public long NativeWindowHandle;
|
||||
}
|
||||
|
||||
public sealed class OrcaRenderTestPatternCurrent {
|
||||
public bool IsSelected;
|
||||
public string Value = "";
|
||||
}
|
||||
|
||||
public sealed class OrcaRenderTestPattern {
|
||||
public OrcaRenderTestPatternCurrent Current = new OrcaRenderTestPatternCurrent();
|
||||
}
|
||||
|
||||
public sealed class OrcaRenderTestCollection : IEnumerable {
|
||||
private readonly OrcaRenderTestElement[] values;
|
||||
|
||||
public OrcaRenderTestCollection(OrcaRenderTestElement[] values) {
|
||||
this.values = values;
|
||||
}
|
||||
|
||||
public int Count {
|
||||
get { return values.Length; }
|
||||
}
|
||||
|
||||
public OrcaRenderTestElement Item(int index) {
|
||||
return values[index];
|
||||
}
|
||||
|
||||
public IEnumerator GetEnumerator() {
|
||||
return values.GetEnumerator();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class OrcaRenderTestElement {
|
||||
public readonly List<OrcaRenderTestElement> Children =
|
||||
new List<OrcaRenderTestElement>();
|
||||
public OrcaRenderTestCounter Counter = new OrcaRenderTestCounter();
|
||||
public readonly OrcaRenderTestCurrent Current = new OrcaRenderTestCurrent();
|
||||
public bool FailFindAll;
|
||||
public int RuntimeIdValue;
|
||||
public string ValueText = "";
|
||||
|
||||
public OrcaRenderTestCollection FindAll(object scope, object condition) {
|
||||
Counter.FindAll++;
|
||||
if (FailFindAll) {
|
||||
throw new System.InvalidOperationException("defunct node");
|
||||
}
|
||||
return new OrcaRenderTestCollection(Children.ToArray());
|
||||
}
|
||||
|
||||
public OrcaRenderTestPattern GetCurrentPattern(object pattern) {
|
||||
OrcaRenderTestPattern result = new OrcaRenderTestPattern();
|
||||
result.Current.Value = ValueText;
|
||||
return result;
|
||||
}
|
||||
|
||||
public int[] GetRuntimeId() {
|
||||
return new int[] { RuntimeIdValue };
|
||||
}
|
||||
|
||||
public object[] GetSupportedPatterns() {
|
||||
return new object[0];
|
||||
}
|
||||
}
|
||||
"@
|
||||
|
||||
function New-TestCounter {
|
||||
New-Object -TypeName OrcaRenderTestCounter
|
||||
}
|
||||
|
||||
function New-TestElement {
|
||||
param(
|
||||
[string]$Role,
|
||||
[string]$Name = "",
|
||||
[string]$Value = "",
|
||||
[object[]]$Children = @(),
|
||||
$Counter = $(New-TestCounter),
|
||||
[switch]$FailFindAll,
|
||||
[int]$RuntimeId = 1
|
||||
)
|
||||
$element = New-Object -TypeName OrcaRenderTestElement
|
||||
$element.Counter = $Counter
|
||||
$element.FailFindAll = [bool]$FailFindAll
|
||||
$element.RuntimeIdValue = $RuntimeId
|
||||
$element.ValueText = $Value
|
||||
$element.Current.ControlType.ProgrammaticName = $Role
|
||||
$element.Current.LocalizedControlType = $Role
|
||||
$element.Current.Name = $Name
|
||||
foreach ($child in @($Children)) {
|
||||
[void]$element.Children.Add($child)
|
||||
}
|
||||
$element
|
||||
}
|
||||
|
||||
$operationPath = Join-Path ([IO.Path]::GetTempPath()) ("orca-runtime-render-test-" + [guid]::NewGuid() + ".json")
|
||||
try {
|
||||
Set-Content -LiteralPath $operationPath -Encoding UTF8 -Value '{"tool":"handshake"}'
|
||||
$runtimeOutput = . (Join-Path $PSScriptRoot "runtime.ps1") -OperationPath $operationPath
|
||||
$handshake = $runtimeOutput | ConvertFrom-Json
|
||||
Assert-TestEqual $handshake.ok $true "runtime handshake"
|
||||
|
||||
$counter = New-TestCounter
|
||||
$leaf = New-TestElement -Role "text" -Name "unused" -Counter $counter -RuntimeId 2
|
||||
$root = New-TestElement -Role "button" -Name "Save" -Children @($leaf) -Counter $counter
|
||||
$tree = Render-OrcaTree $root $null
|
||||
Assert-TestEqual $tree.elements.Count 1 "named control record count"
|
||||
Assert-TestEqual ([string]$tree.lines[0]) "0 button Save" "named control line"
|
||||
Assert-TestEqual $counter.findAll 1 "named control child enumeration"
|
||||
|
||||
$counter = New-TestCounter
|
||||
$leaf = New-TestElement -Role "text" -Name "body" -Counter $counter -RuntimeId 2
|
||||
$root = New-TestElement -Role "group" -Name "Details" -Children @($leaf) -Counter $counter
|
||||
$tree = Render-OrcaTree $root $null
|
||||
Assert-TestEqual (($tree.elements | ForEach-Object { $_.name }) -join "|") "Details|body" "named generic records"
|
||||
Assert-TestEqual (@($tree.lines) -join "|") "0 group Details|`t1 text body" "named generic lines"
|
||||
Assert-TestEqual $counter.findAll 2 "named generic child enumeration"
|
||||
|
||||
$counter = New-TestCounter
|
||||
$alpha = New-TestElement -Role "text" -Name "Alpha" -Counter $counter -RuntimeId 2
|
||||
$beta = New-TestElement -Role "text" -Name "Beta" -Counter $counter -RuntimeId 3
|
||||
$root = New-TestElement -Role "group" -Children @($alpha, $beta) -Counter $counter
|
||||
$tree = Render-OrcaTree $root $null
|
||||
Assert-TestEqual $tree.elements.Count 1 "anonymous generic record count"
|
||||
Assert-TestEqual ([string]$tree.lines[0]) "0 group, Text: Alpha Beta" "anonymous generic summary"
|
||||
Assert-TestEqual $counter.findAll 7 "anonymous generic child enumeration"
|
||||
|
||||
$counter = New-TestCounter
|
||||
$alpha = New-TestElement -Role "text" -Name "Alpha" -Counter $counter -RuntimeId 2
|
||||
$beta = New-TestElement -Role "text" -Name "Beta" -Counter $counter -RuntimeId 3
|
||||
$root = New-TestElement -Role "row" -Name "Invoice" -Children @($alpha, $beta) -Counter $counter
|
||||
$tree = Render-OrcaTree $root $null
|
||||
Assert-TestEqual (($tree.elements | ForEach-Object { $_.name }) -join "|") "Invoice|Alpha|Beta" "row records"
|
||||
Assert-TestEqual ([string]$tree.lines[0]) "0 row Invoice, Text: Alpha Beta" "row summary"
|
||||
Assert-TestEqual $counter.findAll 6 "row child enumeration"
|
||||
|
||||
$counter = New-TestCounter
|
||||
$button = New-TestElement -Role "button" -Name "Continue" -Counter $counter -RuntimeId 2
|
||||
$root = New-TestElement -Role "group" -Children @($button) -Counter $counter
|
||||
$tree = Render-OrcaTree $root $null
|
||||
Assert-TestEqual $tree.elements.Count 1 "elided wrapper record count"
|
||||
Assert-TestEqual ([string]$tree.lines[0]) "0 button Continue" "elided wrapper line"
|
||||
Assert-TestEqual $counter.findAll 4 "elided wrapper child enumeration"
|
||||
|
||||
$counter = New-TestCounter
|
||||
$root = New-TestElement -Role "row" -Name "Invoice" -Counter $counter -FailFindAll
|
||||
$tree = Render-OrcaTree $root $null
|
||||
Assert-TestEqual $tree.elements.Count 1 "failed child read record count"
|
||||
Assert-TestEqual ([string]$tree.lines[0]) "0 row Invoice" "failed child read line"
|
||||
Assert-TestEqual $counter.findAll 2 "failed child read retries"
|
||||
|
||||
$originalMaxNodes = $MaxNodes
|
||||
try {
|
||||
$MaxNodes = 3
|
||||
$counter = New-TestCounter
|
||||
$children = @()
|
||||
for ($index = 0; $index -lt 3; $index++) {
|
||||
$children += New-TestElement -Role "text" -Name "Item $index" -Counter $counter -RuntimeId ($index + 2)
|
||||
}
|
||||
$root = New-TestElement -Role "document" -Name "Results" -Children $children -Counter $counter
|
||||
$tree = Render-OrcaTree $root $null
|
||||
Assert-TestEqual $tree.elements.Count 3 "node limit record count"
|
||||
Assert-TestEqual ([string]$tree.elements[-1].name) "Item 1" "node limit prefix"
|
||||
Assert-TestEqual $tree.truncation.truncated $true "node limit truncation"
|
||||
Assert-TestEqual $tree.truncation.maxDepthReached $false "node limit depth flag"
|
||||
} finally {
|
||||
$MaxNodes = $originalMaxNodes
|
||||
}
|
||||
|
||||
$originalMaxDepth = $MaxDepth
|
||||
try {
|
||||
$MaxDepth = 2
|
||||
$counter = New-TestCounter
|
||||
$root = New-TestElement -Role "document" -Name "Depth 3" -Counter $counter -RuntimeId 4
|
||||
for ($depth = 2; $depth -ge 0; $depth--) {
|
||||
$root = New-TestElement -Role "document" -Name "Depth $depth" -Children @($root) -Counter $counter -RuntimeId ($depth + 1)
|
||||
}
|
||||
$tree = Render-OrcaTree $root $null
|
||||
Assert-TestEqual $tree.elements.Count 3 "depth limit record count"
|
||||
Assert-TestEqual ([string]$tree.elements[-1].name) "Depth 2" "depth limit prefix"
|
||||
Assert-TestEqual $tree.truncation.truncated $true "depth limit truncation"
|
||||
Assert-TestEqual $tree.truncation.maxDepthReached $true "depth limit flag"
|
||||
} finally {
|
||||
$MaxDepth = $originalMaxDepth
|
||||
}
|
||||
|
||||
Write-Output "windows-snapshot-render-tests-ok"
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $operationPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
@@ -561,10 +561,12 @@ function Render-OrcaTree($RootElement, $WindowFrame, [bool]$CompactBrowserTabs =
|
||||
$title = if ([string]::IsNullOrWhiteSpace($record.name)) { $record.automationId } else { $record.name }
|
||||
$role = if ([string]::IsNullOrWhiteSpace($record.localizedControlType)) { $record.controlType } else { $record.localizedControlType }
|
||||
$roleKey = $role.ToLowerInvariant()
|
||||
$snippets = @(Get-OrcaTextSnippets $Node 8 4)
|
||||
$genericSummary = $null
|
||||
if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value) -and $snippets.Count -ge 2 -and (Test-OrcaPlainTextSubtree $Node)) {
|
||||
$genericSummary = ($snippets -join " ")
|
||||
if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value)) {
|
||||
$snippets = @(Get-OrcaTextSnippets $Node 8 4)
|
||||
if ($snippets.Count -ge 2 -and (Test-OrcaPlainTextSubtree $Node)) {
|
||||
$genericSummary = ($snippets -join " ")
|
||||
}
|
||||
}
|
||||
if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value) -and $meaningfulActions.Count -eq 0 -and $null -eq $genericSummary -and $children.Count -le 1) {
|
||||
for ($i = 0; $i -lt $children.Count; $i++) {
|
||||
|
||||
@@ -115,7 +115,9 @@
|
||||
"bench:main-thread-jank": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/main-thread-jank-bench.mjs",
|
||||
"bench:worktree-deletion": "node tests/tools/benchmarks/worktree-deletion-dev-bench.mjs",
|
||||
"bench:zustand-selector-fanout": "node config/scripts/zustand-selector-fanout-benchmark.mjs",
|
||||
"bench:worktree-refresh-churn": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON config/scripts/worktree-refresh-churn-benchmark.mjs",
|
||||
"bench:multi-workspace-typing": "pnpm run ensure:electron-runtime && node config/scripts/run-multi-workspace-typing-bench.mjs",
|
||||
"bench:ai-vault-typing": "pnpm run ensure:electron-runtime && node config/scripts/run-ai-vault-typing-bench.mjs",
|
||||
"bench:cold-park-reveal": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs",
|
||||
"bench:cold-park-resource": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs",
|
||||
"bench:compare": "node config/scripts/compare-benchmark-artifacts.mjs",
|
||||
|
||||
@@ -7,7 +7,9 @@ const mocks = vi.hoisted(() => ({
|
||||
removeClaude: vi.fn(),
|
||||
removeCodex: vi.fn(),
|
||||
statusClaude: vi.fn(),
|
||||
statusCodex: vi.fn()
|
||||
statusCodex: vi.fn(),
|
||||
refreshClaude: vi.fn(),
|
||||
refreshCodex: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./local-agent-cli-presence', () => ({
|
||||
@@ -26,6 +28,10 @@ vi.mock('./managed-agent-hook-registry', () => ({
|
||||
MANAGED_AGENT_HOOK_STATUS_READERS: [
|
||||
['claude', mocks.statusClaude],
|
||||
['codex', mocks.statusCodex]
|
||||
],
|
||||
MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: [
|
||||
['claude', mocks.refreshClaude],
|
||||
['codex', mocks.refreshCodex]
|
||||
]
|
||||
}))
|
||||
|
||||
@@ -51,6 +57,8 @@ describe('managed agent hook controls', () => {
|
||||
mocks.installCodex.mockReturnValue(status('codex', 'installed'))
|
||||
mocks.removeClaude.mockReturnValue(status('claude', 'not_installed'))
|
||||
mocks.removeCodex.mockReturnValue(status('codex', 'not_installed'))
|
||||
mocks.refreshClaude.mockResolvedValue(undefined)
|
||||
mocks.refreshCodex.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('installs only agents with positively detected CLIs', async () => {
|
||||
@@ -73,6 +81,78 @@ describe('managed agent hook controls', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('refreshes existing scripts for agents whose CLI is no longer detected', async () => {
|
||||
mocks.detect.mockResolvedValue({
|
||||
claude: { state: 'missing' },
|
||||
codex: { state: 'found' }
|
||||
})
|
||||
|
||||
await installManagedAgentHooks({ agentCmdOverrides: {} })
|
||||
|
||||
// Why (#11549 aftermath): the skipped agent's user-wide config still invokes the
|
||||
// script, so a stale (leaking) script must not be frozen by the presence gate.
|
||||
expect(mocks.refreshClaude).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.refreshCodex).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.installClaude).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refreshes existing scripts even when CLI detection rejects', async () => {
|
||||
mocks.detect.mockRejectedValue(new Error('detection unavailable'))
|
||||
|
||||
await installManagedAgentHooks({ agentCmdOverrides: {} })
|
||||
|
||||
expect(mocks.refreshClaude).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.refreshCodex).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('awaits each script refresh before probing for CLIs', async () => {
|
||||
let releaseRefresh: (() => void) | undefined
|
||||
mocks.refreshClaude.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseRefresh = resolve
|
||||
})
|
||||
)
|
||||
mocks.detect.mockResolvedValue({
|
||||
claude: { state: 'missing' },
|
||||
codex: { state: 'missing' }
|
||||
})
|
||||
|
||||
const install = installManagedAgentHooks({ agentCmdOverrides: {} })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mocks.detect).not.toHaveBeenCalled()
|
||||
releaseRefresh?.()
|
||||
await install
|
||||
expect(mocks.detect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps installing when a script refresh throws', async () => {
|
||||
mocks.refreshClaude.mockRejectedValue(new Error('disk full'))
|
||||
mocks.detect.mockResolvedValue({
|
||||
claude: { state: 'found' },
|
||||
codex: { state: 'found' }
|
||||
})
|
||||
|
||||
const results = await installManagedAgentHooks({ agentCmdOverrides: {} })
|
||||
|
||||
expect(mocks.installClaude).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.installCodex).toHaveBeenCalledTimes(1)
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({ agent: 'claude', state: 'installed' }),
|
||||
expect.objectContaining({ agent: 'codex', state: 'installed' })
|
||||
])
|
||||
})
|
||||
|
||||
it('only refreshes scripts for the selected agents', async () => {
|
||||
mocks.detect.mockResolvedValue({ codex: { state: 'found' } })
|
||||
|
||||
await installManagedAgentHooks({ agentCmdOverrides: {} }, { agents: ['codex'] })
|
||||
|
||||
expect(mocks.refreshClaude).not.toHaveBeenCalled()
|
||||
expect(mocks.refreshCodex).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('fails closed when CLI detection rejects', async () => {
|
||||
mocks.detect.mockRejectedValue(new Error('detection unavailable'))
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { detectLocalManagedAgentCliPresence } from './local-agent-cli-presence'
|
||||
import {
|
||||
MANAGED_AGENT_HOOK_INSTALLERS,
|
||||
MANAGED_AGENT_HOOK_REMOVERS,
|
||||
MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS,
|
||||
MANAGED_AGENT_HOOK_STATUS_READERS,
|
||||
type ManagedAgentHookInstaller
|
||||
} from './managed-agent-hook-registry'
|
||||
@@ -87,10 +88,29 @@ function runInstaller(
|
||||
}
|
||||
}
|
||||
|
||||
// Why (#11549 aftermath): a CLI that falls off PATH keeps its user-wide config invoking
|
||||
// Orca's script, but the presence gate below then skips install() forever, freezing the
|
||||
// script at whatever Orca generated last. Existing scripts are Orca-owned, so bring them
|
||||
// current before any gating; creating new ones remains install()'s presence-gated job.
|
||||
async function refreshExistingManagedScripts(options: InstallOptions): Promise<void> {
|
||||
const allowed = options.agents ? new Set(options.agents) : null
|
||||
for (const [agent, refresh] of MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS) {
|
||||
if (allowed !== null && !allowed.has(agent)) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
console.error(`[agent-hooks] Failed to refresh ${agent} managed script:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function installManagedAgentHooks(
|
||||
settings: ManagedHookSettings = null,
|
||||
options: InstallOptions = {}
|
||||
): Promise<AgentHookInstallStatus[]> {
|
||||
await refreshExistingManagedScripts(options)
|
||||
const installers = selectedInstallers(options)
|
||||
const disabled = new Set(normalizeDisabledTuiAgents(settings?.disabledTuiAgents))
|
||||
const enabledInstallers = installers.filter(([agent]) => !disabled.has(agent))
|
||||
|
||||
@@ -16,6 +16,7 @@ import { kimiHookService } from '../kimi/hook-service'
|
||||
import { openClaudeHookService } from '../openclaude/hook-service'
|
||||
|
||||
export type ManagedAgentHookInstaller = readonly [HookInstallAgent, () => AgentHookInstallStatus]
|
||||
export type ManagedAgentHookScriptRefresher = readonly [HookInstallAgent, () => Promise<void>]
|
||||
export type ManagedAgentHookRemover = readonly [HookInstallAgent, () => AgentHookInstallStatus]
|
||||
export type ManagedAgentHookStatusReader = readonly [HookInstallAgent, () => AgentHookInstallStatus]
|
||||
|
||||
@@ -36,6 +37,27 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[]
|
||||
['kimi', () => kimiHookService.install()]
|
||||
]
|
||||
|
||||
// Why: covers the shared launcher/statusline scripts under ~/.orca/agent-hooks — the files a
|
||||
// user-wide agent config keeps invoking after the CLI falls off PATH. Amp and Hermes write
|
||||
// provider-native plugin code into their own config dirs with their own install lifecycles,
|
||||
// not shared launchers, so they are deliberately absent. Enforced by the coverage test in
|
||||
// managed-hook-script-refresh.test.ts: a new installer that writes a launcher without adding
|
||||
// a refresher here fails that test.
|
||||
export const MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: readonly ManagedAgentHookScriptRefresher[] = [
|
||||
['claude', () => claudeHookService.refreshManagedScripts()],
|
||||
['openclaude', () => openClaudeHookService.refreshManagedScripts()],
|
||||
['codex', () => codexHookService.refreshManagedScripts()],
|
||||
['gemini', () => geminiHookService.refreshManagedScripts()],
|
||||
['antigravity', () => antigravityHookService.refreshManagedScripts()],
|
||||
['cursor', () => cursorHookService.refreshManagedScripts()],
|
||||
['droid', () => droidHookService.refreshManagedScripts()],
|
||||
['command-code', () => commandCodeHookService.refreshManagedScripts()],
|
||||
['grok', () => grokHookService.refreshManagedScripts()],
|
||||
['copilot', () => copilotHookService.refreshManagedScripts()],
|
||||
['devin', () => devinHookService.refreshManagedScripts()],
|
||||
['kimi', () => kimiHookService.refreshManagedScripts()]
|
||||
]
|
||||
|
||||
export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [
|
||||
['claude', () => claudeHookService.remove()],
|
||||
['openclaude', () => openClaudeHookService.remove()],
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import type * as NodeFsModule from 'node:fs'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import type * as NodeFsPromisesModule from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type * as NodeOsModule from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
home: '',
|
||||
syncCalls: [] as { name: string; target: string }[],
|
||||
blockAsyncReads: false
|
||||
}))
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeFsModule>()
|
||||
const wrapped: Record<string, unknown> = { ...actual }
|
||||
for (const [name, value] of Object.entries(actual)) {
|
||||
if (!name.endsWith('Sync') || typeof value !== 'function') {
|
||||
continue
|
||||
}
|
||||
const original = value as ((...args: unknown[]) => unknown) & Record<string, unknown>
|
||||
const recorder = (...args: unknown[]): unknown => {
|
||||
state.syncCalls.push({ name, target: typeof args[0] === 'string' ? args[0] : '' })
|
||||
return original(...args)
|
||||
}
|
||||
Object.assign(recorder, original)
|
||||
wrapped[name] = recorder
|
||||
}
|
||||
return { ...wrapped, default: wrapped }
|
||||
})
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeFsPromisesModule>()
|
||||
return {
|
||||
...actual,
|
||||
default: actual,
|
||||
readFile: (...args: Parameters<typeof actual.readFile>) =>
|
||||
state.blockAsyncReads && String(args[0]).startsWith(state.home)
|
||||
? new Promise<never>(() => {})
|
||||
: actual.readFile(...args)
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeOsModule>()
|
||||
return { ...actual, default: actual, homedir: () => state.home }
|
||||
})
|
||||
|
||||
vi.mock('electron', () => ({ app: { getPath: () => '/tmp/orca-user-data' } }))
|
||||
|
||||
import { MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS } from './managed-agent-hook-registry'
|
||||
|
||||
function syncCallsUnderHome(): string[] {
|
||||
return state.syncCalls
|
||||
.filter((call) => call.target.startsWith(state.home))
|
||||
.map((call) => `${call.name}(${call.target})`)
|
||||
}
|
||||
|
||||
describe('managed hook script refresh stays off the main thread', () => {
|
||||
beforeEach(async () => {
|
||||
state.home = await mkdtemp(join(tmpdir(), 'orca-hook-refresh-main-thread-'))
|
||||
state.syncCalls = []
|
||||
state.blockAsyncReads = false
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
state.blockAsyncReads = false
|
||||
await rm(state.home, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('uses no synchronous HOME filesystem calls for missing or stale scripts', async () => {
|
||||
const hooksDir = join(state.home, '.orca', 'agent-hooks')
|
||||
const claudeScript = join(
|
||||
hooksDir,
|
||||
process.platform === 'win32' ? 'claude-hook.cmd' : 'claude-hook.sh'
|
||||
)
|
||||
await mkdir(hooksDir, { recursive: true })
|
||||
await writeFile(claudeScript, 'stale', 'utf-8')
|
||||
state.syncCalls = []
|
||||
|
||||
for (const [, refresh] of MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS) {
|
||||
await refresh()
|
||||
}
|
||||
|
||||
expect(syncCallsUnderHome()).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the event loop responsive while a HOME read is stalled', async () => {
|
||||
state.blockAsyncReads = true
|
||||
const refresh = MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS[0][1]()
|
||||
let settled = false
|
||||
void refresh.then(() => {
|
||||
settled = true
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 5))
|
||||
|
||||
expect(settled).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,206 @@
|
||||
// Why (#11549 aftermath): a CLI that falls off PATH keeps its user-wide config invoking
|
||||
// Orca's script while the presence gate skips install() forever. These tests pin the
|
||||
// repair — existing scripts come current, missing ones are never created.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
existsSync,
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
utimesSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type * as osModule from 'node:os'
|
||||
|
||||
let isolatedUserDataDir = ''
|
||||
let previousUserDataPath: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
|
||||
isolatedUserDataDir = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-user-data-'))
|
||||
process.env.ORCA_USER_DATA_PATH = isolatedUserDataDir
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (previousUserDataPath === undefined) {
|
||||
delete process.env.ORCA_USER_DATA_PATH
|
||||
} else {
|
||||
process.env.ORCA_USER_DATA_PATH = previousUserDataPath
|
||||
}
|
||||
rmSync(isolatedUserDataDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const { homedirMock } = vi.hoisted(() => ({
|
||||
homedirMock: vi.fn<() => string>()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => '/tmp/orca-user-data'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof osModule>()
|
||||
return {
|
||||
...actual,
|
||||
homedir: homedirMock.mockImplementation(actual.homedir)
|
||||
}
|
||||
})
|
||||
|
||||
import { refreshManagedScriptIfPresent } from './managed-hook-script-refresh'
|
||||
import {
|
||||
MANAGED_AGENT_HOOK_INSTALLERS,
|
||||
MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS
|
||||
} from './managed-agent-hook-registry'
|
||||
import { ClaudeHookService } from '../claude/hook-service'
|
||||
|
||||
async function withPlatform<T>(platform: NodeJS.Platform, run: () => T | Promise<T>): Promise<T> {
|
||||
const original = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
if (original) {
|
||||
Object.defineProperty(process, 'platform', original)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const STALE_WINDOWS_HOOK = [
|
||||
'@echo off',
|
||||
'setlocal',
|
||||
'if "%ORCA_AGENT_HOOK_PORT%"=="" goto :orca_agent_hook_drain_stdin',
|
||||
':orca_agent_hook_drain_stdin',
|
||||
'"%SystemRoot%\\System32\\more.com" >nul 2>nul',
|
||||
'exit /b 0',
|
||||
''
|
||||
].join('\r\n')
|
||||
|
||||
describe('refreshManagedScriptIfPresent', () => {
|
||||
it('rewrites an existing script and refuses to create a missing one', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-unit-'))
|
||||
try {
|
||||
const present = join(dir, 'present.cmd')
|
||||
writeFileSync(present, 'stale')
|
||||
expect(await refreshManagedScriptIfPresent(present, 'fresh')).toBe(true)
|
||||
expect(readFileSync(present, 'utf8')).toBe('fresh')
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
chmodSync(present, 0o600)
|
||||
}
|
||||
const fixedTime = new Date(1_000)
|
||||
utimesSync(present, fixedTime, fixedTime)
|
||||
expect(await refreshManagedScriptIfPresent(present, 'fresh')).toBe(true)
|
||||
expect(statSync(present).mtimeMs).toBe(fixedTime.getTime())
|
||||
if (process.platform !== 'win32') {
|
||||
expect(statSync(present).mode & 0o777).toBe(0o755)
|
||||
}
|
||||
|
||||
const missing = join(dir, 'missing.cmd')
|
||||
expect(await refreshManagedScriptIfPresent(missing, 'fresh')).toBe(false)
|
||||
expect(existsSync(missing)).toBe(false)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('managed hook script refresh', () => {
|
||||
it('brings a stale leaking script current without touching agent config', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-'))
|
||||
homedirMock.mockReturnValue(home)
|
||||
try {
|
||||
// Why: the bug population has a script (from a past install) but no reachable
|
||||
// CLI — and possibly no config dir Orca may create. Seed only the script.
|
||||
const hooksDir = join(home, '.orca', 'agent-hooks')
|
||||
mkdirSync(hooksDir, { recursive: true })
|
||||
writeFileSync(join(hooksDir, 'claude-hook.cmd'), STALE_WINDOWS_HOOK)
|
||||
|
||||
await withPlatform('win32', () => new ClaudeHookService().refreshManagedScripts())
|
||||
|
||||
const refreshed = readFileSync(join(hooksDir, 'claude-hook.cmd'), 'utf8')
|
||||
expect(refreshed).toContain('if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0')
|
||||
expect(refreshed).not.toContain('if "%ORCA_AGENT_HOOK_PORT%"=="" goto')
|
||||
// Why: refresh must not resurrect config for a CLI the user may have removed.
|
||||
expect(existsSync(join(home, '.claude'))).toBe(false)
|
||||
// Why: the statusline script was never installed here, so it must not appear.
|
||||
expect(existsSync(join(hooksDir, 'claude-statusline.cmd'))).toBe(false)
|
||||
} finally {
|
||||
homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir())
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('covers every shared launcher script with a refresher', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-coverage-'))
|
||||
homedirMock.mockReturnValue(home)
|
||||
const previousGrokHome = process.env.GROK_HOME
|
||||
const previousKimiHome = process.env.KIMI_CODE_HOME
|
||||
delete process.env.GROK_HOME
|
||||
delete process.env.KIMI_CODE_HOME
|
||||
try {
|
||||
await withPlatform('win32', () => {
|
||||
for (const [, install] of MANAGED_AGENT_HOOK_INSTALLERS) {
|
||||
install()
|
||||
}
|
||||
})
|
||||
const hooksDir = join(home, '.orca', 'agent-hooks')
|
||||
const files = readdirSync(hooksDir)
|
||||
expect(files.length).toBeGreaterThan(0)
|
||||
const refresherAgents = MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS.map(([agent]) => agent)
|
||||
// Why: an installer that writes a shared launcher but skips the refresher list would
|
||||
// recreate the frozen-stale-script class this suite exists to prevent.
|
||||
for (const file of files) {
|
||||
expect(
|
||||
refresherAgents.some((agent) => file.startsWith(`${agent}-`)),
|
||||
`${file} is written to ~/.orca/agent-hooks but no refresher owns it`
|
||||
).toBe(true)
|
||||
}
|
||||
// Why: the reverse direction — a refresher naming an agent that writes nothing is a
|
||||
// stale registry entry, likely a renamed script file.
|
||||
for (const agent of refresherAgents) {
|
||||
expect(
|
||||
files.some((file) => file.startsWith(`${agent}-`)),
|
||||
`refresher for ${agent} matches no installed script`
|
||||
).toBe(true)
|
||||
}
|
||||
} finally {
|
||||
homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir())
|
||||
if (previousGrokHome === undefined) {
|
||||
delete process.env.GROK_HOME
|
||||
} else {
|
||||
process.env.GROK_HOME = previousGrokHome
|
||||
}
|
||||
if (previousKimiHome === undefined) {
|
||||
delete process.env.KIMI_CODE_HOME
|
||||
} else {
|
||||
process.env.KIMI_CODE_HOME = previousKimiHome
|
||||
}
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('creates nothing anywhere when no managed scripts exist', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-empty-'))
|
||||
homedirMock.mockReturnValue(home)
|
||||
try {
|
||||
await withPlatform('win32', async () => {
|
||||
for (const [agent, refresh] of MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS) {
|
||||
await expect(refresh(), `${agent} refresh on empty home`).resolves.toBeUndefined()
|
||||
}
|
||||
})
|
||||
// Why: a refresh pass on a machine with no prior installs must be a strict no-op.
|
||||
expect(readdirSync(home)).toEqual([])
|
||||
} finally {
|
||||
homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir())
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { chmod, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { grantDirAclAsync, isPermissionError } from '../win32-utils'
|
||||
|
||||
type ExistingScript = { exists: false } | { exists: true; content: string | null }
|
||||
|
||||
function isMissingPathError(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === 'ENOENT'
|
||||
}
|
||||
|
||||
async function readExistingScript(scriptPath: string): Promise<ExistingScript> {
|
||||
try {
|
||||
return { exists: true, content: await readFile(scriptPath, 'utf-8') }
|
||||
} catch (error) {
|
||||
if (isMissingPathError(error)) {
|
||||
return { exists: false }
|
||||
}
|
||||
try {
|
||||
await stat(scriptPath)
|
||||
return { exists: true, content: null }
|
||||
} catch (statError) {
|
||||
if (isMissingPathError(statError)) {
|
||||
return { exists: false }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function scriptStillExists(scriptPath: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(scriptPath)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (isMissingPathError(error)) {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function writeScriptWithAclRetry(scriptPath: string, content: string): Promise<void> {
|
||||
try {
|
||||
await writeFile(scriptPath, content, 'utf-8')
|
||||
} catch (error) {
|
||||
if (isPermissionError(error) && process.platform === 'win32') {
|
||||
try {
|
||||
await grantDirAclAsync(dirname(scriptPath))
|
||||
await writeFile(scriptPath, content, 'utf-8')
|
||||
return
|
||||
} catch {
|
||||
// Re-throw the original permission error.
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Why: refresh must not block Electron's main thread or create state for an absent CLI.
|
||||
export async function refreshManagedScriptIfPresent(
|
||||
scriptPath: string,
|
||||
content: string
|
||||
): Promise<boolean> {
|
||||
const existing = await readExistingScript(scriptPath)
|
||||
if (!existing.exists) {
|
||||
return false
|
||||
}
|
||||
if (existing.content === content) {
|
||||
if (process.platform !== 'win32') {
|
||||
await chmod(scriptPath, 0o755)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const tmpPath = join(dirname(scriptPath), `.${Date.now()}-${randomUUID()}.tmp`)
|
||||
try {
|
||||
await writeScriptWithAclRetry(tmpPath, content)
|
||||
if (process.platform !== 'win32') {
|
||||
await chmod(tmpPath, 0o755)
|
||||
}
|
||||
if (!(await scriptStillExists(scriptPath))) {
|
||||
return false
|
||||
}
|
||||
await rename(tmpPath, scriptPath)
|
||||
return true
|
||||
} finally {
|
||||
await rm(tmpPath, { force: true }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
resetAiVaultScannerWorkerForTests,
|
||||
scanAiVaultSessionsInWorker
|
||||
} from './session-scanner-worker-spawn'
|
||||
resetAiVaultScannerBackgroundForTests,
|
||||
scanAiVaultSessionsInBackground
|
||||
} from './session-scanner-background'
|
||||
import { getWslHomeAsync, listWslDistrosAsync } from '../wsl'
|
||||
import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
@@ -77,7 +77,7 @@ export async function listAiVaultSessions(
|
||||
start: async (scanSignal) => {
|
||||
const additionalCodexSessionsDirs =
|
||||
sources.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ?? []
|
||||
const result = await scanAiVaultSessionsInWorker(
|
||||
const result = await scanAiVaultSessionsInBackground(
|
||||
{
|
||||
limit: args?.limit,
|
||||
unlimited: args?.unlimited,
|
||||
@@ -142,5 +142,5 @@ export function resetAiVaultSessionListCacheForTests(): void {
|
||||
invalidateAiVaultSessionListCache()
|
||||
scanCoordinator = new AiVaultScanCoordinator()
|
||||
sources = {}
|
||||
resetAiVaultScannerWorkerForTests()
|
||||
resetAiVaultScannerBackgroundForTests()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type {
|
||||
AiVaultFirstUserPromptArgs,
|
||||
AiVaultFirstUserPromptResult
|
||||
} from '../../shared/ai-vault-types'
|
||||
import { readAiVaultFirstUserPromptInBackground } from './session-scanner-background'
|
||||
|
||||
export function handleAiVaultGetFirstUserPrompt(
|
||||
args?: AiVaultFirstUserPromptArgs
|
||||
): Promise<AiVaultFirstUserPromptResult> {
|
||||
if (!args || typeof args.filePath !== 'string' || typeof args.agent !== 'string') {
|
||||
return Promise.resolve({ prompt: null })
|
||||
}
|
||||
return readAiVaultFirstUserPromptInBackground({
|
||||
agent: args.agent,
|
||||
filePath: args.filePath,
|
||||
sessionId: typeof args.sessionId === 'string' ? args.sessionId : undefined,
|
||||
executionHostId: args.executionHostId,
|
||||
codexHome: args.codexHome
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { stat } from 'node:fs/promises'
|
||||
import type {
|
||||
AiVaultAgent,
|
||||
AiVaultFirstUserPromptArgs,
|
||||
AiVaultFirstUserPromptResult,
|
||||
AiVaultSession
|
||||
} from '../../shared/ai-vault-types'
|
||||
@@ -22,22 +21,6 @@ export type ReadAiVaultFirstUserPromptArgs = {
|
||||
|
||||
export type ReadAiVaultFirstUserPromptResult = AiVaultFirstUserPromptResult
|
||||
|
||||
/** IPC-safe entry: validates untyped payload then reads the full first prompt. */
|
||||
export async function handleAiVaultGetFirstUserPrompt(
|
||||
args?: AiVaultFirstUserPromptArgs
|
||||
): Promise<AiVaultFirstUserPromptResult> {
|
||||
if (!args || typeof args.filePath !== 'string' || typeof args.agent !== 'string') {
|
||||
return { prompt: null }
|
||||
}
|
||||
return readAiVaultFirstUserPrompt({
|
||||
agent: args.agent,
|
||||
filePath: args.filePath,
|
||||
sessionId: typeof args.sessionId === 'string' ? args.sessionId : undefined,
|
||||
executionHostId: args.executionHostId,
|
||||
codexHome: args.codexHome
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-parse one session transcript under full first-prompt capture and return
|
||||
* the untruncated first real user ask for copy/reuse.
|
||||
|
||||
@@ -86,8 +86,8 @@ export function scheduleSessionParseCachePersist(stats: SessionParseStats): void
|
||||
}
|
||||
}
|
||||
|
||||
/** Run any pending debounced save immediately and wait for it. Test-only. */
|
||||
export async function flushSessionParseCachePersistForTests(): Promise<void> {
|
||||
/** Run any pending debounced save immediately and wait for it before process exit. */
|
||||
export async function flushSessionParseCachePersist(): Promise<void> {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer)
|
||||
saveTimer = null
|
||||
@@ -99,6 +99,8 @@ export async function flushSessionParseCachePersistForTests(): Promise<void> {
|
||||
await lastSave
|
||||
}
|
||||
|
||||
export const flushSessionParseCachePersistForTests = flushSessionParseCachePersist
|
||||
|
||||
async function loadPersistedEntries(current: SessionParseCachePersistenceOptions): Promise<void> {
|
||||
await sweepOrphanedTempFiles(current.filePath)
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { shouldUseAiVaultServiceProcess } from './session-scanner-background'
|
||||
|
||||
const originalBackend = process.env.ORCA_AI_VAULT_SERVICE_PROCESS
|
||||
const originalNodeEnv = process.env.NODE_ENV
|
||||
|
||||
afterEach(() => {
|
||||
if (originalBackend === undefined) {
|
||||
delete process.env.ORCA_AI_VAULT_SERVICE_PROCESS
|
||||
} else {
|
||||
process.env.ORCA_AI_VAULT_SERVICE_PROCESS = originalBackend
|
||||
}
|
||||
process.env.NODE_ENV = originalNodeEnv
|
||||
})
|
||||
|
||||
describe('shouldUseAiVaultServiceProcess', () => {
|
||||
it('keeps unit tests on the worker fallback by default', () => {
|
||||
delete process.env.ORCA_AI_VAULT_SERVICE_PROCESS
|
||||
process.env.NODE_ENV = 'test'
|
||||
expect(shouldUseAiVaultServiceProcess()).toBe(false)
|
||||
})
|
||||
|
||||
it('defaults non-test hosts to the service process', () => {
|
||||
delete process.env.ORCA_AI_VAULT_SERVICE_PROCESS
|
||||
process.env.NODE_ENV = 'production'
|
||||
expect(shouldUseAiVaultServiceProcess()).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['1', true],
|
||||
['0', false]
|
||||
] as const)('honors the explicit %s kill switch', (value, expected) => {
|
||||
process.env.ORCA_AI_VAULT_SERVICE_PROCESS = value
|
||||
expect(shouldUseAiVaultServiceProcess()).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { AiVaultListResult, AiVaultSubagentListResult } from '../../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultSessionTitleRequest,
|
||||
AiVaultSessionTitlesResult
|
||||
} from '../../shared/ai-vault-session-title'
|
||||
import {
|
||||
readAiVaultFirstUserPrompt,
|
||||
type ReadAiVaultFirstUserPromptArgs,
|
||||
type ReadAiVaultFirstUserPromptResult
|
||||
} from './session-first-user-prompt-read'
|
||||
import {
|
||||
invalidateAiVaultServiceCache,
|
||||
listAiVaultSubagentSessionsInService,
|
||||
readAiVaultFirstUserPromptInService,
|
||||
resetAiVaultScannerServiceForTests,
|
||||
resolveAiVaultSessionTitlesInService,
|
||||
scanAiVaultSessionsInService
|
||||
} from './session-scanner-service-spawn'
|
||||
import type { AiVaultServiceSubagentRequest } from './session-scanner-service-protocol'
|
||||
import {
|
||||
resetAiVaultScannerWorkerForTests,
|
||||
resolveAiVaultSessionTitlesInWorker,
|
||||
scanAiVaultSessionsInWorker
|
||||
} from './session-scanner-worker-spawn'
|
||||
import type { AiVaultWorkerScanOptions } from './session-scanner-worker-protocol'
|
||||
import { listLocalAiVaultSubagentSessions } from './session-subagent-reader'
|
||||
|
||||
export function shouldUseAiVaultServiceProcess(): boolean {
|
||||
const configured = process.env.ORCA_AI_VAULT_SERVICE_PROCESS
|
||||
if (configured === '1') {
|
||||
return true
|
||||
}
|
||||
if (configured === '0') {
|
||||
return false
|
||||
}
|
||||
return process.env.NODE_ENV !== 'test'
|
||||
}
|
||||
|
||||
export function scanAiVaultSessionsInBackground(
|
||||
options: AiVaultWorkerScanOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultListResult> {
|
||||
return shouldUseAiVaultServiceProcess()
|
||||
? scanAiVaultSessionsInService(options, signal)
|
||||
: scanAiVaultSessionsInWorker(options, signal)
|
||||
}
|
||||
|
||||
export function resolveAiVaultSessionTitlesInBackground(
|
||||
requests: AiVaultSessionTitleRequest[],
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSessionTitlesResult> {
|
||||
return shouldUseAiVaultServiceProcess()
|
||||
? resolveAiVaultSessionTitlesInService(requests, signal)
|
||||
: resolveAiVaultSessionTitlesInWorker(requests, signal)
|
||||
}
|
||||
|
||||
export function listAiVaultSubagentSessionsInBackground(
|
||||
request: AiVaultServiceSubagentRequest
|
||||
): Promise<AiVaultSubagentListResult> {
|
||||
return shouldUseAiVaultServiceProcess()
|
||||
? listAiVaultSubagentSessionsInService(request)
|
||||
: listLocalAiVaultSubagentSessions(request)
|
||||
}
|
||||
|
||||
export function readAiVaultFirstUserPromptInBackground(
|
||||
request: ReadAiVaultFirstUserPromptArgs
|
||||
): Promise<ReadAiVaultFirstUserPromptResult> {
|
||||
return shouldUseAiVaultServiceProcess()
|
||||
? readAiVaultFirstUserPromptInService(request)
|
||||
: readAiVaultFirstUserPrompt(request)
|
||||
}
|
||||
|
||||
export function invalidateAiVaultBackgroundCache(paths: string[]): Promise<void> {
|
||||
return shouldUseAiVaultServiceProcess() ? invalidateAiVaultServiceCache(paths) : Promise.resolve()
|
||||
}
|
||||
|
||||
export function resetAiVaultScannerBackgroundForTests(): void {
|
||||
resetAiVaultScannerServiceForTests()
|
||||
resetAiVaultScannerWorkerForTests()
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import type {
|
||||
AiVaultServiceInit,
|
||||
AiVaultServiceLane,
|
||||
AiVaultServiceRequest
|
||||
} from './session-scanner-service-protocol'
|
||||
|
||||
export const AI_VAULT_SERVICE_READY_TIMEOUT_MS = 5_000
|
||||
export const AI_VAULT_SERVICE_SCAN_TIMEOUT_MS = 130_000
|
||||
export const AI_VAULT_SERVICE_INTERACTIVE_TIMEOUT_MS = 15_000
|
||||
export const AI_VAULT_SERVICE_MAX_CALLS = 16
|
||||
export const AI_VAULT_SERVICE_IDLE_TIMEOUT_MS = 10 * 60_000
|
||||
export const AI_VAULT_SERVICE_SHUTDOWN_TIMEOUT_MS = 2_000
|
||||
|
||||
export type AiVaultServiceProcessFactory = () => ChildProcess
|
||||
export type AiVaultServiceClientOptions = {
|
||||
processFactory: AiVaultServiceProcessFactory
|
||||
init: Omit<AiVaultServiceInit, 'type' | 'protocol'>
|
||||
idleTimeoutMs?: number
|
||||
onStderr?: (text: string) => void
|
||||
}
|
||||
|
||||
export type AiVaultServiceInvalidation = {
|
||||
resolve: () => void
|
||||
reject: (error: Error) => void
|
||||
timer: NodeJS.Timeout
|
||||
}
|
||||
|
||||
export class AiVaultServiceInvalidations {
|
||||
private readonly pending = new Map<number, AiVaultServiceInvalidation>()
|
||||
private generation = 0
|
||||
|
||||
get size(): number {
|
||||
return this.pending.size
|
||||
}
|
||||
|
||||
open(
|
||||
timeoutMs: number,
|
||||
onTimeout: (generation: number) => void,
|
||||
send: (generation: number) => void
|
||||
): Promise<void> {
|
||||
const generation = ++this.generation
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => onTimeout(generation), timeoutMs)
|
||||
timer.unref?.()
|
||||
this.pending.set(generation, { resolve, reject, timer })
|
||||
send(generation)
|
||||
})
|
||||
}
|
||||
|
||||
settle(generation: number): boolean {
|
||||
const entry = this.pending.get(generation)
|
||||
if (!entry) {
|
||||
return false
|
||||
}
|
||||
clearTimeout(entry.timer)
|
||||
this.pending.delete(generation)
|
||||
entry.resolve()
|
||||
return true
|
||||
}
|
||||
|
||||
rejectAll(error: Error): void {
|
||||
for (const entry of this.pending.values()) {
|
||||
clearTimeout(entry.timer)
|
||||
entry.reject(error)
|
||||
}
|
||||
this.pending.clear()
|
||||
}
|
||||
}
|
||||
|
||||
export function createAiVaultServiceReadyWaiter(
|
||||
timeoutMs: number,
|
||||
onTimeout: () => void
|
||||
): AiVaultServiceReadyWaiter {
|
||||
let resolve!: (child: ChildProcess) => void
|
||||
let reject!: (error: Error) => void
|
||||
const promise = new Promise<ChildProcess>((resolveReady, rejectReady) => {
|
||||
resolve = resolveReady
|
||||
reject = rejectReady
|
||||
})
|
||||
const timer = setTimeout(onTimeout, timeoutMs)
|
||||
timer.unref?.()
|
||||
return { promise, resolve, reject, timer }
|
||||
}
|
||||
|
||||
export function retireAiVaultServiceChild(child: ChildProcess): void {
|
||||
child.removeAllListeners('message')
|
||||
child.removeAllListeners('disconnect')
|
||||
child.removeAllListeners('error')
|
||||
child.removeAllListeners('exit')
|
||||
const killTimer = setTimeout(() => child.kill(), AI_VAULT_SERVICE_SHUTDOWN_TIMEOUT_MS)
|
||||
killTimer.unref?.()
|
||||
child.once('exit', () => clearTimeout(killTimer))
|
||||
child.send({ type: 'shutdown' }, () => undefined)
|
||||
child.unref()
|
||||
}
|
||||
|
||||
export function armAiVaultServiceCancellationTimeout(
|
||||
call: AiVaultServicePendingCall,
|
||||
onExpired: () => void
|
||||
): void {
|
||||
if (call.timer) {
|
||||
clearTimeout(call.timer)
|
||||
}
|
||||
call.timer = setTimeout(onExpired, AI_VAULT_SERVICE_SHUTDOWN_TIMEOUT_MS)
|
||||
call.timer.unref?.()
|
||||
}
|
||||
|
||||
/**
|
||||
* A cold start that faults before the request reached the child self-heals on
|
||||
* the scheduled respawn. Requeue once; the caller rejects when this returns false.
|
||||
*/
|
||||
export function requeueAiVaultServiceStart(
|
||||
call: AiVaultServicePendingCall,
|
||||
queue: AiVaultServicePendingCall[]
|
||||
): boolean {
|
||||
if (call.sent || call.cancelled || call.startRetried) {
|
||||
return false
|
||||
}
|
||||
call.startRetried = true
|
||||
queue.unshift(call)
|
||||
return true
|
||||
}
|
||||
|
||||
export function clearAiVaultServiceCall(call: AiVaultServicePendingCall): void {
|
||||
if (call.timer) {
|
||||
clearTimeout(call.timer)
|
||||
call.timer = null
|
||||
}
|
||||
if (call.signal && call.onAbort) {
|
||||
call.signal.removeEventListener('abort', call.onAbort)
|
||||
call.onAbort = null
|
||||
}
|
||||
}
|
||||
|
||||
export function rejectAiVaultServiceCall(call: AiVaultServicePendingCall, error: Error): void {
|
||||
clearAiVaultServiceCall(call)
|
||||
if (!call.cancelled) {
|
||||
call.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
export type AiVaultServicePendingCall = {
|
||||
request: AiVaultServiceRequest
|
||||
lane: AiVaultServiceLane
|
||||
signal?: AbortSignal
|
||||
resolve: (value: unknown) => void
|
||||
reject: (error: Error) => void
|
||||
timer: NodeJS.Timeout | null
|
||||
onAbort: (() => void) | null
|
||||
cancelled: boolean
|
||||
/** Whether the child received the request; an unsent call gets no reply. */
|
||||
sent: boolean
|
||||
startRetried: boolean
|
||||
}
|
||||
|
||||
export type AiVaultServiceReadyWaiter = {
|
||||
promise: Promise<ChildProcess>
|
||||
resolve: (child: ChildProcess) => void
|
||||
reject: (error: Error) => void
|
||||
timer: NodeJS.Timeout
|
||||
}
|
||||
|
||||
export class AiVaultServiceIdleRetirement {
|
||||
private timer: NodeJS.Timeout | null = null
|
||||
|
||||
clear(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
}
|
||||
|
||||
schedule(busy: boolean, timeoutMs: number, retire: () => void): void {
|
||||
if (busy || this.timer) {
|
||||
return
|
||||
}
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null
|
||||
retire()
|
||||
}, timeoutMs)
|
||||
this.timer.unref?.()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AiVaultScannerServiceClient } from './session-scanner-service-client'
|
||||
import { AI_VAULT_SERVICE_READY_TIMEOUT_MS } from './session-scanner-service-client-state'
|
||||
import {
|
||||
AiVaultServiceTestChild,
|
||||
aiVaultServiceRequestId,
|
||||
readyAiVaultServiceChild
|
||||
} from './session-scanner-service-test-child'
|
||||
|
||||
function setup(idleTimeoutMs?: number): {
|
||||
child: AiVaultServiceTestChild
|
||||
client: AiVaultScannerServiceClient
|
||||
} {
|
||||
const child = new AiVaultServiceTestChild()
|
||||
const client = new AiVaultScannerServiceClient({
|
||||
processFactory: () => child.asChildProcess(),
|
||||
init: { sessionParseCache: null },
|
||||
idleTimeoutMs
|
||||
})
|
||||
return { child, client }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('AiVaultScannerServiceClient', () => {
|
||||
it('waits for ready and runs cache and interactive lanes independently', async () => {
|
||||
const { child, client } = setup()
|
||||
const titles = client.request({ type: 'request', operation: 'titles', requests: [] })
|
||||
const subagents = client.request({
|
||||
type: 'request',
|
||||
operation: 'subagents',
|
||||
request: { agent: 'claude', parentFilePath: '/tmp/parent.jsonl' }
|
||||
})
|
||||
|
||||
expect(child.sent).toEqual([expect.objectContaining({ type: 'init', protocol: 1 })])
|
||||
readyAiVaultServiceChild(child)
|
||||
await Promise.resolve()
|
||||
expect(child.sent).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ operation: 'titles' }),
|
||||
expect.objectContaining({ operation: 'subagents' })
|
||||
])
|
||||
)
|
||||
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: aiVaultServiceRequestId(child, 'titles'),
|
||||
operation: 'titles',
|
||||
value: { titles: [] }
|
||||
})
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: aiVaultServiceRequestId(child, 'subagents'),
|
||||
operation: 'subagents',
|
||||
value: { sessions: [], issues: [] }
|
||||
})
|
||||
await expect(titles).resolves.toEqual({ titles: [] })
|
||||
await expect(subagents).resolves.toEqual({ sessions: [], issues: [] })
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('bounds active and queued calls together at sixteen', async () => {
|
||||
const { child, client } = setup()
|
||||
const calls = Array.from({ length: 16 }, () =>
|
||||
client.request({ type: 'request', operation: 'titles', requests: [] })
|
||||
)
|
||||
|
||||
await expect(
|
||||
client.request({ type: 'request', operation: 'titles', requests: [] })
|
||||
).rejects.toThrow('queue is full')
|
||||
readyAiVaultServiceChild(child)
|
||||
client.dispose()
|
||||
await Promise.all(calls.map((call) => expect(call).rejects.toThrow('disposed')))
|
||||
})
|
||||
|
||||
it('cancels active work and kills a child that ignores cancellation', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { child, client } = setup()
|
||||
const controller = new AbortController()
|
||||
const request = client.request(
|
||||
{ type: 'request', operation: 'titles', requests: [] },
|
||||
controller.signal
|
||||
)
|
||||
readyAiVaultServiceChild(child)
|
||||
await Promise.resolve()
|
||||
const id = aiVaultServiceRequestId(child, 'titles')
|
||||
|
||||
controller.abort()
|
||||
await expect(request).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(child.sent).toContainEqual({ type: 'cancel', id })
|
||||
vi.advanceTimersByTime(1_999)
|
||||
expect(child.killed).toBe(false)
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(child.killed).toBe(true)
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('drops a call cancelled before the child received it', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { child, client } = setup()
|
||||
const controller = new AbortController()
|
||||
const cancelled = client.request(
|
||||
{ type: 'request', operation: 'titles', requests: [] },
|
||||
controller.signal
|
||||
)
|
||||
|
||||
controller.abort()
|
||||
await expect(cancelled).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(child.sent).not.toContainEqual(expect.objectContaining({ type: 'cancel' }))
|
||||
|
||||
readyAiVaultServiceChild(child)
|
||||
const next = client.request({ type: 'request', operation: 'titles', requests: [] })
|
||||
await Promise.resolve()
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: aiVaultServiceRequestId(child, 'titles'),
|
||||
operation: 'titles',
|
||||
value: { titles: [] }
|
||||
})
|
||||
await expect(next).resolves.toEqual({ titles: [] })
|
||||
vi.advanceTimersByTime(2_000)
|
||||
|
||||
expect(child.killed).toBe(false)
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('retries once when the first cold start misses the ready deadline', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = new AiVaultScannerServiceClient({
|
||||
processFactory: () => {
|
||||
const child = new AiVaultServiceTestChild(12_345 + children.length)
|
||||
children.push(child)
|
||||
return child.asChildProcess()
|
||||
},
|
||||
init: { sessionParseCache: null }
|
||||
})
|
||||
const titles = client.request({ type: 'request', operation: 'titles', requests: [] })
|
||||
expect(children).toHaveLength(1)
|
||||
|
||||
vi.advanceTimersByTime(AI_VAULT_SERVICE_READY_TIMEOUT_MS)
|
||||
expect(children[0]!.killed).toBe(true)
|
||||
await Promise.resolve()
|
||||
vi.advanceTimersByTime(250)
|
||||
expect(children).toHaveLength(2)
|
||||
readyAiVaultServiceChild(children[1]!)
|
||||
await Promise.resolve()
|
||||
children[1]!.emit('message', {
|
||||
type: 'result',
|
||||
id: aiVaultServiceRequestId(children[1]!, 'titles'),
|
||||
operation: 'titles',
|
||||
value: { titles: [] }
|
||||
})
|
||||
|
||||
await expect(titles).resolves.toEqual({ titles: [] })
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('surfaces the startup error when the retried cold start also fails', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = new AiVaultScannerServiceClient({
|
||||
processFactory: () => {
|
||||
const child = new AiVaultServiceTestChild(12_345 + children.length)
|
||||
children.push(child)
|
||||
return child.asChildProcess()
|
||||
},
|
||||
init: { sessionParseCache: null }
|
||||
})
|
||||
const titles = client.request({ type: 'request', operation: 'titles', requests: [] })
|
||||
|
||||
vi.advanceTimersByTime(AI_VAULT_SERVICE_READY_TIMEOUT_MS)
|
||||
await Promise.resolve()
|
||||
vi.advanceTimersByTime(250)
|
||||
expect(children).toHaveLength(2)
|
||||
vi.advanceTimersByTime(AI_VAULT_SERVICE_READY_TIMEOUT_MS)
|
||||
|
||||
await expect(titles).rejects.toThrow('did not become ready')
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('acknowledges cache invalidation through the running child', async () => {
|
||||
const { child, client } = setup()
|
||||
const invalidation = client.invalidate(['/tmp/deleted.jsonl'])
|
||||
readyAiVaultServiceChild(child)
|
||||
await vi.waitFor(() =>
|
||||
expect(child.sent).toContainEqual({
|
||||
type: 'invalidate',
|
||||
generation: 1,
|
||||
paths: ['/tmp/deleted.jsonl']
|
||||
})
|
||||
)
|
||||
child.emit('message', { type: 'invalidated', generation: 1 })
|
||||
|
||||
await expect(invalidation).resolves.toBeUndefined()
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('leaves a scanning child alone when cache invalidation is slow to acknowledge', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { child, client } = setup()
|
||||
const scan = client.request({ type: 'request', operation: 'scan', options: {} })
|
||||
readyAiVaultServiceChild(child)
|
||||
await Promise.resolve()
|
||||
|
||||
const invalidation = client.invalidate(['/tmp/deleted.jsonl'])
|
||||
await Promise.resolve()
|
||||
vi.advanceTimersByTime(AI_VAULT_SERVICE_READY_TIMEOUT_MS)
|
||||
|
||||
// The scan owns liveness through its own 130s deadline; killing the child
|
||||
// here would abort it and burn a slot toward the restart circuit.
|
||||
await expect(invalidation).resolves.toBeUndefined()
|
||||
expect(child.killed).toBe(false)
|
||||
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: aiVaultServiceRequestId(child, 'scan'),
|
||||
operation: 'scan',
|
||||
value: { result: { sessions: [], issues: [], scannedAt: '2026-08-10' }, durationMs: 1 }
|
||||
})
|
||||
await expect(scan).resolves.toMatchObject({ result: { sessions: [] } })
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('replaces a child that does not acknowledge cache invalidation', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = new AiVaultScannerServiceClient({
|
||||
processFactory: () => {
|
||||
const child = new AiVaultServiceTestChild(12_345 + children.length)
|
||||
children.push(child)
|
||||
return child.asChildProcess()
|
||||
},
|
||||
init: { sessionParseCache: null }
|
||||
})
|
||||
const invalidation = client.invalidate(['/tmp/deleted.jsonl'])
|
||||
readyAiVaultServiceChild(children[0]!)
|
||||
await Promise.resolve()
|
||||
|
||||
vi.advanceTimersByTime(AI_VAULT_SERVICE_READY_TIMEOUT_MS)
|
||||
|
||||
await expect(invalidation).rejects.toThrow('cache invalidation timed out')
|
||||
expect(children[0]!.killed).toBe(true)
|
||||
const titles = client.request({ type: 'request', operation: 'titles', requests: [] })
|
||||
expect(children).toHaveLength(1)
|
||||
vi.advanceTimersByTime(250)
|
||||
expect(children).toHaveLength(2)
|
||||
readyAiVaultServiceChild(children[1]!)
|
||||
await Promise.resolve()
|
||||
children[1]!.emit('message', {
|
||||
type: 'result',
|
||||
id: aiVaultServiceRequestId(children[1]!, 'titles'),
|
||||
operation: 'titles',
|
||||
value: { titles: [] }
|
||||
})
|
||||
|
||||
await expect(titles).resolves.toEqual({ titles: [] })
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('keeps invalidation-only children alive through acknowledgement, then retires them', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = new AiVaultScannerServiceClient({
|
||||
processFactory: () => {
|
||||
const child = new AiVaultServiceTestChild(12_345 + children.length)
|
||||
children.push(child)
|
||||
return child.asChildProcess()
|
||||
},
|
||||
init: { sessionParseCache: null },
|
||||
idleTimeoutMs: 100
|
||||
})
|
||||
|
||||
const first = client.request({ type: 'request', operation: 'titles', requests: [] })
|
||||
readyAiVaultServiceChild(children[0]!)
|
||||
await Promise.resolve()
|
||||
children[0]!.emit('message', {
|
||||
type: 'result',
|
||||
id: aiVaultServiceRequestId(children[0]!, 'titles'),
|
||||
operation: 'titles',
|
||||
value: { titles: [] }
|
||||
})
|
||||
await first
|
||||
vi.advanceTimersByTime(100)
|
||||
|
||||
const invalidation = client.invalidate(['/tmp/deleted.jsonl'])
|
||||
readyAiVaultServiceChild(children[1]!)
|
||||
await Promise.resolve()
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(children[1]!.sent).not.toContainEqual({ type: 'shutdown' })
|
||||
children[1]!.emit('message', { type: 'invalidated', generation: 1 })
|
||||
await invalidation
|
||||
vi.advanceTimersByTime(100)
|
||||
|
||||
expect(children[1]!.sent).toContainEqual({ type: 'shutdown' })
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('faults the child on malformed output without affecting later processes', async () => {
|
||||
const { child, client } = setup()
|
||||
const request = client.request({ type: 'request', operation: 'titles', requests: [] })
|
||||
readyAiVaultServiceChild(child)
|
||||
await Promise.resolve()
|
||||
|
||||
child.emit('message', { nope: true })
|
||||
|
||||
await expect(request).rejects.toThrow('malformed')
|
||||
expect(child.killed).toBe(true)
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('retires an idle child gracefully, then kills it after the shutdown bound', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { child, client } = setup(100)
|
||||
const request = client.request({ type: 'request', operation: 'titles', requests: [] })
|
||||
readyAiVaultServiceChild(child)
|
||||
await Promise.resolve()
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: aiVaultServiceRequestId(child, 'titles'),
|
||||
operation: 'titles',
|
||||
value: { titles: [] }
|
||||
})
|
||||
await request
|
||||
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(child.sent).toContainEqual({ type: 'shutdown' })
|
||||
vi.advanceTimersByTime(2_000)
|
||||
expect(child.killed).toBe(true)
|
||||
client.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,325 @@
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { createAiVaultScanCancelledError } from './ai-vault-scan-cancellation'
|
||||
import {
|
||||
AI_VAULT_SERVICE_IDLE_TIMEOUT_MS,
|
||||
AI_VAULT_SERVICE_INTERACTIVE_TIMEOUT_MS,
|
||||
AI_VAULT_SERVICE_MAX_CALLS,
|
||||
AI_VAULT_SERVICE_READY_TIMEOUT_MS,
|
||||
AI_VAULT_SERVICE_SCAN_TIMEOUT_MS,
|
||||
AiVaultServiceIdleRetirement,
|
||||
AiVaultServiceInvalidations,
|
||||
armAiVaultServiceCancellationTimeout,
|
||||
clearAiVaultServiceCall,
|
||||
createAiVaultServiceReadyWaiter,
|
||||
rejectAiVaultServiceCall,
|
||||
requeueAiVaultServiceStart,
|
||||
retireAiVaultServiceChild,
|
||||
type AiVaultServiceClientOptions,
|
||||
type AiVaultServicePendingCall,
|
||||
type AiVaultServiceReadyWaiter
|
||||
} from './session-scanner-service-client-state'
|
||||
import { AiVaultServiceRestartPolicy } from './session-scanner-service-restart-policy'
|
||||
import {
|
||||
AI_VAULT_SERVICE_PROTOCOL_VERSION,
|
||||
aiVaultServiceLane,
|
||||
isAiVaultServiceChildMessage,
|
||||
type AiVaultServiceChildMessage,
|
||||
type AiVaultServiceInit,
|
||||
type AiVaultServiceRequest,
|
||||
type AiVaultServiceRequestBody,
|
||||
type AiVaultServiceResultValue
|
||||
} from './session-scanner-service-protocol'
|
||||
|
||||
export class AiVaultScannerServiceClient {
|
||||
private child: ChildProcess | null = null
|
||||
private readyWaiter: AiVaultServiceReadyWaiter | null = null
|
||||
private readonly active = new Map<AiVaultServicePendingCall['lane'], AiVaultServicePendingCall>()
|
||||
private readonly queue: AiVaultServicePendingCall[] = []
|
||||
private readonly invalidations = new AiVaultServiceInvalidations()
|
||||
private nextId = 1
|
||||
private readonly idleRetirement = new AiVaultServiceIdleRetirement()
|
||||
private readonly restartPolicy = new AiVaultServiceRestartPolicy()
|
||||
private disposed = false
|
||||
|
||||
constructor(private readonly options: AiVaultServiceClientOptions) {}
|
||||
|
||||
request<T>(body: AiVaultServiceRequestBody, signal?: AbortSignal): Promise<T> {
|
||||
if (this.disposed) {
|
||||
return Promise.reject(new Error('AI Vault service client was disposed.'))
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
return Promise.reject(createAiVaultScanCancelledError())
|
||||
}
|
||||
if (this.queue.length + this.active.size >= AI_VAULT_SERVICE_MAX_CALLS) {
|
||||
return Promise.reject(new Error('AI Vault service queue is full.'))
|
||||
}
|
||||
const request = { ...body, id: this.nextId++ } as AiVaultServiceRequest
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const call: AiVaultServicePendingCall = {
|
||||
request,
|
||||
lane: aiVaultServiceLane(request.operation),
|
||||
signal,
|
||||
resolve: resolve as (value: unknown) => void,
|
||||
reject,
|
||||
timer: null,
|
||||
onAbort: null,
|
||||
cancelled: false,
|
||||
sent: false,
|
||||
startRetried: false
|
||||
}
|
||||
if (signal) {
|
||||
call.onAbort = () => this.cancel(call)
|
||||
signal.addEventListener('abort', call.onAbort, { once: true })
|
||||
}
|
||||
this.queue.push(call)
|
||||
this.idleRetirement.clear()
|
||||
this.pump()
|
||||
})
|
||||
}
|
||||
|
||||
async invalidate(paths: string[]): Promise<void> {
|
||||
if (paths.length === 0 || this.disposed) {
|
||||
return
|
||||
}
|
||||
this.idleRetirement.clear()
|
||||
const child = await this.ensureChild()
|
||||
return this.invalidations.open(
|
||||
AI_VAULT_SERVICE_READY_TIMEOUT_MS,
|
||||
(generation) => this.onInvalidationDeadline(generation),
|
||||
(generation) => child.send({ type: 'invalidate', generation, paths })
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The deadline is a startup-sized budget, but a child mid-scan can be slow to
|
||||
* turn the channel around. Fork IPC ordering already guarantees the child
|
||||
* applies the invalidation before any request sent after it, so a busy child
|
||||
* owes nothing here — only an idle one that misses the deadline is wedged.
|
||||
*/
|
||||
private onInvalidationDeadline(generation: number): void {
|
||||
if (this.active.size > 0) {
|
||||
this.invalidations.settle(generation)
|
||||
return
|
||||
}
|
||||
this.onFault(new Error('AI Vault service cache invalidation timed out.'))
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
this.disposed = true
|
||||
this.restartPolicy.dispose()
|
||||
this.idleRetirement.clear()
|
||||
const error = new Error('AI Vault service client was disposed.')
|
||||
for (const call of [...this.active.values(), ...this.queue]) {
|
||||
rejectAiVaultServiceCall(call, error)
|
||||
}
|
||||
this.active.clear()
|
||||
this.queue.length = 0
|
||||
this.invalidations.rejectAll(error)
|
||||
this.retireChild()
|
||||
}
|
||||
|
||||
private pump(): void {
|
||||
if (this.restartPolicy.restartScheduled) {
|
||||
return
|
||||
}
|
||||
for (const lane of ['cache', 'interactive'] as const) {
|
||||
if (this.active.has(lane)) {
|
||||
continue
|
||||
}
|
||||
const index = this.queue.findIndex((call) => call.lane === lane)
|
||||
if (index < 0) {
|
||||
continue
|
||||
}
|
||||
const call = this.queue.splice(index, 1)[0]!
|
||||
this.active.set(lane, call)
|
||||
void this.ensureChild().then(
|
||||
(child) => this.sendCall(child, call),
|
||||
(error: Error) => {
|
||||
if (this.active.get(lane) !== call) {
|
||||
return
|
||||
}
|
||||
this.active.delete(lane)
|
||||
this.retryStartOrReject(call, error)
|
||||
this.pump()
|
||||
}
|
||||
)
|
||||
}
|
||||
this.scheduleIdleIfNeeded()
|
||||
}
|
||||
|
||||
private sendCall(child: ChildProcess, call: AiVaultServicePendingCall): void {
|
||||
if (call.cancelled || this.active.get(call.lane) !== call) {
|
||||
return
|
||||
}
|
||||
const timeoutMs =
|
||||
call.request.operation === 'scan'
|
||||
? AI_VAULT_SERVICE_SCAN_TIMEOUT_MS
|
||||
: AI_VAULT_SERVICE_INTERACTIVE_TIMEOUT_MS
|
||||
call.timer = setTimeout(() => {
|
||||
this.onFault(new Error(`AI Vault service timed out after ${timeoutMs}ms.`))
|
||||
}, timeoutMs)
|
||||
call.timer.unref?.()
|
||||
call.sent = true
|
||||
child.send(call.request)
|
||||
}
|
||||
|
||||
private retryStartOrReject(call: AiVaultServicePendingCall, error: Error): void {
|
||||
if (
|
||||
this.disposed ||
|
||||
!this.restartPolicy.restartScheduled ||
|
||||
!requeueAiVaultServiceStart(call, this.queue)
|
||||
) {
|
||||
rejectAiVaultServiceCall(call, error)
|
||||
}
|
||||
}
|
||||
|
||||
private ensureChild(): Promise<ChildProcess> {
|
||||
if (this.child && !this.readyWaiter) {
|
||||
return Promise.resolve(this.child)
|
||||
}
|
||||
if (this.readyWaiter) {
|
||||
return this.readyWaiter.promise
|
||||
}
|
||||
const startError = this.restartPolicy.startError()
|
||||
if (startError) {
|
||||
return Promise.reject(startError)
|
||||
}
|
||||
let child: ChildProcess
|
||||
try {
|
||||
child = this.options.processFactory()
|
||||
} catch (error) {
|
||||
this.restartPolicy.recordFault(() => this.pump())
|
||||
return Promise.reject(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
this.child = child
|
||||
const waiter = createAiVaultServiceReadyWaiter(AI_VAULT_SERVICE_READY_TIMEOUT_MS, () =>
|
||||
this.onFault(new Error('AI Vault service did not become ready.'))
|
||||
)
|
||||
this.readyWaiter = waiter
|
||||
child.on('message', (message) => this.onMessage(message))
|
||||
child.on('error', (error) => this.onFault(error))
|
||||
child.on('disconnect', () => this.onFault(new Error('AI Vault service disconnected.')))
|
||||
child.on('exit', (code) => this.onFault(new Error(`AI Vault service exited (${code}).`)))
|
||||
child.stderr?.on('data', (chunk: Buffer) => this.options.onStderr?.(String(chunk)))
|
||||
child.send({
|
||||
type: 'init',
|
||||
protocol: AI_VAULT_SERVICE_PROTOCOL_VERSION,
|
||||
...this.options.init
|
||||
} satisfies AiVaultServiceInit)
|
||||
return waiter.promise
|
||||
}
|
||||
|
||||
private onMessage(raw: unknown): void {
|
||||
if (!isAiVaultServiceChildMessage(raw)) {
|
||||
this.onFault(new Error('AI Vault service sent a malformed message.'))
|
||||
return
|
||||
}
|
||||
const message = raw as AiVaultServiceChildMessage
|
||||
if (message.type === 'ready') {
|
||||
const waiter = this.readyWaiter
|
||||
if (!waiter || !this.child) {
|
||||
return
|
||||
}
|
||||
clearTimeout(waiter.timer)
|
||||
this.readyWaiter = null
|
||||
waiter.resolve(this.child)
|
||||
return
|
||||
}
|
||||
if (message.type === 'invalidated') {
|
||||
if (this.invalidations.settle(message.generation)) {
|
||||
this.scheduleIdleIfNeeded()
|
||||
}
|
||||
return
|
||||
}
|
||||
const call = [...this.active.values()].find((entry) => entry.request.id === message.id)
|
||||
if (!call) {
|
||||
return
|
||||
}
|
||||
this.active.delete(call.lane)
|
||||
clearAiVaultServiceCall(call)
|
||||
if (!call.cancelled) {
|
||||
if (message.type === 'error') {
|
||||
call.reject(new Error(message.message))
|
||||
} else {
|
||||
call.resolve((message as { value: AiVaultServiceResultValue['value'] }).value)
|
||||
}
|
||||
}
|
||||
this.pump()
|
||||
}
|
||||
|
||||
private cancel(call: AiVaultServicePendingCall): void {
|
||||
if (call.cancelled) {
|
||||
return
|
||||
}
|
||||
call.cancelled = true
|
||||
call.reject(createAiVaultScanCancelledError())
|
||||
const queuedIndex = this.queue.indexOf(call)
|
||||
if (queuedIndex >= 0) {
|
||||
this.queue.splice(queuedIndex, 1)
|
||||
clearAiVaultServiceCall(call)
|
||||
this.pump()
|
||||
return
|
||||
}
|
||||
if (this.active.get(call.lane) === call) {
|
||||
// Why: a call cancelled before it reached the child gets no acknowledgement,
|
||||
// so waiting on one would kill a healthy service and stall the lane.
|
||||
if (!call.sent) {
|
||||
this.active.delete(call.lane)
|
||||
clearAiVaultServiceCall(call)
|
||||
this.pump()
|
||||
return
|
||||
}
|
||||
this.child?.send({ type: 'cancel', id: call.request.id })
|
||||
armAiVaultServiceCancellationTimeout(call, () =>
|
||||
this.onFault(new Error('AI Vault service did not cancel within 2000ms.'))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private onFault(error: Error): void {
|
||||
const child = this.child
|
||||
if (!child) {
|
||||
return
|
||||
}
|
||||
this.child = null
|
||||
child.removeAllListeners()
|
||||
child.kill()
|
||||
if (this.readyWaiter) {
|
||||
clearTimeout(this.readyWaiter.timer)
|
||||
this.readyWaiter.reject(error)
|
||||
this.readyWaiter = null
|
||||
}
|
||||
// Recorded before the pending calls are settled so retryStartOrReject can see
|
||||
// whether a respawn is actually coming.
|
||||
this.restartPolicy.recordFault(() => this.pump())
|
||||
const active = [...this.active.values()]
|
||||
this.active.clear()
|
||||
for (const call of active) {
|
||||
this.retryStartOrReject(call, error)
|
||||
}
|
||||
this.invalidations.rejectAll(error)
|
||||
}
|
||||
|
||||
private scheduleIdleIfNeeded(): void {
|
||||
this.idleRetirement.schedule(
|
||||
this.active.size > 0 || this.queue.length > 0 || this.invalidations.size > 0 || !this.child,
|
||||
this.options.idleTimeoutMs ?? AI_VAULT_SERVICE_IDLE_TIMEOUT_MS,
|
||||
() => this.retireChild()
|
||||
)
|
||||
}
|
||||
|
||||
private retireChild(): void {
|
||||
this.idleRetirement.clear()
|
||||
const child = this.child
|
||||
this.child = null
|
||||
if (!child) {
|
||||
return
|
||||
}
|
||||
retireAiVaultServiceChild(child)
|
||||
}
|
||||
}
|
||||
|
||||
export type { AiVaultServiceProcessFactory } from './session-scanner-service-client-state'
|
||||
@@ -0,0 +1,58 @@
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
resolveAiVaultServiceEntryPath,
|
||||
resolveAiVaultServiceEntryPathWithoutApp
|
||||
} from './session-scanner-service-entry-path'
|
||||
|
||||
describe('resolveAiVaultServiceEntryPath', () => {
|
||||
it('uses the adjacent electron-vite output when present', () => {
|
||||
const outMain = join(process.cwd(), 'out', 'main')
|
||||
const adjacent = join(outMain, 'session-scanner-service-entry.js')
|
||||
|
||||
expect(resolveAiVaultServiceEntryPath(outMain, false, (path) => path === adjacent)).toBe(
|
||||
adjacent
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the nested output from a project root', () => {
|
||||
expect(resolveAiVaultServiceEntryPath(process.cwd(), false, () => false)).toBe(
|
||||
join(process.cwd(), 'out', 'main', 'session-scanner-service-entry.js')
|
||||
)
|
||||
})
|
||||
|
||||
it('uses app.asar.unpacked for packaged Electron', () => {
|
||||
const appPath = join('C:', 'Orca', 'resources', 'app.asar')
|
||||
|
||||
expect(resolveAiVaultServiceEntryPath(appPath, true)).toBe(
|
||||
join(
|
||||
'C:',
|
||||
'Orca',
|
||||
'resources',
|
||||
'app.asar.unpacked',
|
||||
'out',
|
||||
'main',
|
||||
'session-scanner-service-entry.js'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('uses resourcesPath from packaged Electron-as-Node runtimes', () => {
|
||||
const resourcesPath = join('Applications', 'Orca.app', 'Contents', 'Resources')
|
||||
const entry = join(
|
||||
resourcesPath,
|
||||
'app.asar.unpacked',
|
||||
'out',
|
||||
'main',
|
||||
'session-scanner-service-entry.js'
|
||||
)
|
||||
|
||||
expect(
|
||||
resolveAiVaultServiceEntryPathWithoutApp(
|
||||
'/unrelated/cwd',
|
||||
resourcesPath,
|
||||
(path) => path === entry
|
||||
)
|
||||
).toBe(entry)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
type ElectronAppPath = { getAppPath(): string; isPackaged: boolean }
|
||||
|
||||
function loadElectronApp(): ElectronAppPath | null {
|
||||
try {
|
||||
return require('electron').app ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAiVaultServiceEntryPath(
|
||||
appPath: string,
|
||||
isPackaged: boolean,
|
||||
pathExists: (candidate: string) => boolean = existsSync
|
||||
): string {
|
||||
const basePath = isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath
|
||||
const adjacentEntry = join(basePath, 'session-scanner-service-entry.js')
|
||||
if (!isPackaged && pathExists(adjacentEntry)) {
|
||||
return adjacentEntry
|
||||
}
|
||||
return join(basePath, 'out', 'main', 'session-scanner-service-entry.js')
|
||||
}
|
||||
|
||||
export function resolveAiVaultServiceEntryPathWithoutApp(
|
||||
cwd: string,
|
||||
resourcesPath: string | undefined,
|
||||
pathExists: (candidate: string) => boolean = existsSync
|
||||
): string {
|
||||
if (resourcesPath) {
|
||||
const packagedEntry = join(
|
||||
resourcesPath,
|
||||
'app.asar.unpacked',
|
||||
'out',
|
||||
'main',
|
||||
'session-scanner-service-entry.js'
|
||||
)
|
||||
if (pathExists(packagedEntry)) {
|
||||
return packagedEntry
|
||||
}
|
||||
}
|
||||
return resolveAiVaultServiceEntryPath(cwd, false, pathExists)
|
||||
}
|
||||
|
||||
export function getAiVaultServiceEntryPath(): string {
|
||||
const app = loadElectronApp()
|
||||
return app
|
||||
? resolveAiVaultServiceEntryPath(app.getAppPath(), app.isPackaged)
|
||||
: resolveAiVaultServiceEntryPathWithoutApp(process.cwd(), process.resourcesPath)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AI_VAULT_SERVICE_PROTOCOL_VERSION } from './session-scanner-service-protocol'
|
||||
|
||||
const invalidateSessionParseCacheEntry = vi.hoisted(() => vi.fn())
|
||||
const scanAiVaultSessions = vi.hoisted(() => vi.fn())
|
||||
|
||||
// Only the invalidation hook is replaced; the title reader shares this module.
|
||||
vi.mock('./session-scanner-parse-cache', async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
invalidateSessionParseCacheEntry
|
||||
}))
|
||||
vi.mock('./session-scanner', () => ({ scanAiVaultSessions }))
|
||||
vi.mock('./session-parse-cache-persistence', () => ({
|
||||
flushSessionParseCachePersist: vi.fn(() => Promise.resolve()),
|
||||
initSessionParseCachePersistence: vi.fn()
|
||||
}))
|
||||
vi.mock('./session-subagent-reader', () => ({
|
||||
listLocalAiVaultSubagentSessions: vi.fn(() => Promise.resolve({ sessions: [], issues: [] }))
|
||||
}))
|
||||
|
||||
const sent: { type: string; id?: number }[] = []
|
||||
|
||||
function emit(message: unknown): void {
|
||||
process.emit('message', message as never, undefined as never)
|
||||
}
|
||||
|
||||
async function runScan(id: number): Promise<void> {
|
||||
emit({ type: 'request', id, operation: 'scan', options: {} })
|
||||
await vi.waitFor(() => expect(sent.some((message) => message.id === id)).toBe(true))
|
||||
}
|
||||
|
||||
function invalidationsFor(path: string): number {
|
||||
return invalidateSessionParseCacheEntry.mock.calls.filter((call) => call[0] === path).length
|
||||
}
|
||||
|
||||
describe('AI Vault service entry cache invalidation', () => {
|
||||
beforeAll(async () => {
|
||||
process.send = ((message: { type: string; id?: number }) => {
|
||||
sent.push(message)
|
||||
return true
|
||||
}) as typeof process.send
|
||||
await import('./session-scanner-service-entry')
|
||||
emit({ type: 'init', protocol: AI_VAULT_SERVICE_PROTOCOL_VERSION })
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
sent.length = 0
|
||||
invalidateSessionParseCacheEntry.mockClear()
|
||||
scanAiVaultSessions.mockResolvedValue({ sessions: [], issues: [], scannedAt: '2026-08-10' })
|
||||
})
|
||||
|
||||
it('re-applies an invalidation once and then stops re-evicting the path', async () => {
|
||||
emit({ type: 'invalidate', generation: 1, paths: ['/transcripts/a.jsonl'] })
|
||||
expect(invalidationsFor('/transcripts/a.jsonl')).toBe(1)
|
||||
|
||||
// The request that overlapped the invalidation still gets the re-apply, so a
|
||||
// read that started before it cannot leave pre-edit content cached.
|
||||
await runScan(1)
|
||||
expect(invalidationsFor('/transcripts/a.jsonl')).toBe(2)
|
||||
|
||||
// Every later request must not keep paying for a consumed invalidation.
|
||||
await runScan(2)
|
||||
expect(invalidationsFor('/transcripts/a.jsonl')).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps re-applying while another request is still executing', async () => {
|
||||
let releaseSlowScan: (() => void) | undefined
|
||||
scanAiVaultSessions.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releaseSlowScan = () =>
|
||||
resolve({ sessions: [], issues: [], scannedAt: '2026-08-10' } as never)
|
||||
})
|
||||
)
|
||||
// 'subagents' runs on the interactive lane, so it overlaps the cache-lane
|
||||
// scan; 'titles' would not, it shares the cache lane with scans.
|
||||
emit({ type: 'request', id: 10, operation: 'scan', options: {} })
|
||||
await vi.waitFor(() => expect(releaseSlowScan).toBeDefined())
|
||||
|
||||
emit({ type: 'invalidate', generation: 2, paths: ['/transcripts/b.jsonl'] })
|
||||
emit({ type: 'request', id: 11, operation: 'subagents', request: {} })
|
||||
await vi.waitFor(() => expect(sent.some((message) => message.id === 11)).toBe(true))
|
||||
|
||||
// The scan is still reading, so the path stays armed rather than draining.
|
||||
expect(invalidationsFor('/transcripts/b.jsonl')).toBe(2)
|
||||
|
||||
releaseSlowScan?.()
|
||||
await vi.waitFor(() => expect(sent.some((message) => message.id === 10)).toBe(true))
|
||||
expect(invalidationsFor('/transcripts/b.jsonl')).toBe(3)
|
||||
|
||||
await runScan(12)
|
||||
expect(invalidationsFor('/transcripts/b.jsonl')).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { AiVaultSessionTitle } from '../../shared/ai-vault-session-title'
|
||||
import { readAiVaultFirstUserPrompt } from './session-first-user-prompt-read'
|
||||
import {
|
||||
flushSessionParseCachePersist,
|
||||
initSessionParseCachePersistence
|
||||
} from './session-parse-cache-persistence'
|
||||
import { scanAiVaultSessions } from './session-scanner'
|
||||
import { invalidateSessionParseCacheEntry } from './session-scanner-parse-cache'
|
||||
import {
|
||||
AI_VAULT_SERVICE_PROTOCOL_VERSION,
|
||||
aiVaultServiceLane,
|
||||
cacheServiceTitle,
|
||||
isAiVaultServiceRequest,
|
||||
type AiVaultServiceChildMessage,
|
||||
type AiVaultServiceParentMessage,
|
||||
type AiVaultServiceRequest,
|
||||
type AiVaultServiceResultValue
|
||||
} from './session-scanner-service-protocol'
|
||||
import { readAiVaultSessionTitlesFromFiles } from './session-title-file-reader'
|
||||
import { resolveHostReadableAiVaultTitleRequests } from './session-title-request-paths'
|
||||
import { listLocalAiVaultSubagentSessions } from './session-subagent-reader'
|
||||
|
||||
if (!process.send) {
|
||||
throw new Error('AI Vault service requires a parent IPC channel.')
|
||||
}
|
||||
|
||||
const controllers = new Map<number, AbortController>()
|
||||
const cancelled = new Set<number>()
|
||||
const pending = new Set<number>()
|
||||
const titleIndex = new Map<string, AiVaultSessionTitle>()
|
||||
const invalidatedPaths = new Set<string>()
|
||||
let initialized = false
|
||||
let shuttingDown = false
|
||||
let cacheLane = Promise.resolve()
|
||||
let interactiveLane = Promise.resolve()
|
||||
|
||||
function send(message: AiVaultServiceChildMessage): void {
|
||||
process.send?.(message)
|
||||
}
|
||||
|
||||
function titleKey(request: { agent: string; sessionId: string }): string {
|
||||
return `${request.agent}\0${request.sessionId}`
|
||||
}
|
||||
|
||||
async function executeRequest(request: AiVaultServiceRequest): Promise<AiVaultServiceResultValue> {
|
||||
const controller = new AbortController()
|
||||
controllers.set(request.id, controller)
|
||||
try {
|
||||
if (cancelled.delete(request.id)) {
|
||||
controller.abort()
|
||||
}
|
||||
if (request.operation === 'titles') {
|
||||
const requests = await resolveHostReadableAiVaultTitleRequests(
|
||||
request.requests,
|
||||
controller.signal
|
||||
)
|
||||
return {
|
||||
operation: 'titles',
|
||||
value: await readAiVaultSessionTitlesFromFiles(requests, {
|
||||
signal: controller.signal,
|
||||
cache: {
|
||||
get: (entry) => titleIndex.get(titleKey(entry)) ?? null,
|
||||
set: (title) => cacheServiceTitle(titleIndex, title)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
if (request.operation === 'subagents') {
|
||||
return {
|
||||
operation: 'subagents',
|
||||
value: await listLocalAiVaultSubagentSessions(request.request)
|
||||
}
|
||||
}
|
||||
if (request.operation === 'firstPrompt') {
|
||||
return {
|
||||
operation: 'firstPrompt',
|
||||
value: await readAiVaultFirstUserPrompt(request.request)
|
||||
}
|
||||
}
|
||||
const startedAt = performance.now()
|
||||
const result = await scanAiVaultSessions({ ...request.options, signal: controller.signal })
|
||||
for (const session of result.sessions) {
|
||||
if ((session.agent === 'claude' || session.agent === 'codex') && session.title.trim()) {
|
||||
cacheServiceTitle(titleIndex, {
|
||||
agent: session.agent,
|
||||
sessionId: session.sessionId,
|
||||
title: session.title.trim()
|
||||
})
|
||||
}
|
||||
}
|
||||
return {
|
||||
operation: 'scan',
|
||||
value: { result, durationMs: performance.now() - startedAt }
|
||||
}
|
||||
} finally {
|
||||
controllers.delete(request.id)
|
||||
cancelled.delete(request.id)
|
||||
for (const path of invalidatedPaths) {
|
||||
invalidateSessionParseCacheEntry(path)
|
||||
}
|
||||
// Why: the re-apply only protects reads that overlapped the invalidation.
|
||||
// Once nothing else is executing it has done its job, and holding the paths
|
||||
// would re-evict them on every later request for the life of the process.
|
||||
if (controllers.size === 0) {
|
||||
invalidatedPaths.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRequest(request: AiVaultServiceRequest): Promise<void> {
|
||||
try {
|
||||
const value = await executeRequest(request)
|
||||
send({ type: 'result', id: request.id, ...value })
|
||||
} catch (error) {
|
||||
send({
|
||||
type: 'error',
|
||||
id: request.id,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
retryable: true
|
||||
})
|
||||
} finally {
|
||||
pending.delete(request.id)
|
||||
}
|
||||
}
|
||||
|
||||
function queueRequest(request: AiVaultServiceRequest): void {
|
||||
if (pending.size >= 16) {
|
||||
send({
|
||||
type: 'error',
|
||||
id: request.id,
|
||||
message: 'AI Vault service queue is full.',
|
||||
retryable: true
|
||||
})
|
||||
return
|
||||
}
|
||||
pending.add(request.id)
|
||||
if (aiVaultServiceLane(request.operation) === 'interactive') {
|
||||
interactiveLane = interactiveLane.then(() => handleRequest(request))
|
||||
return
|
||||
}
|
||||
cacheLane = cacheLane.then(() => handleRequest(request))
|
||||
}
|
||||
|
||||
async function shutdown(): Promise<void> {
|
||||
if (shuttingDown) {
|
||||
return
|
||||
}
|
||||
shuttingDown = true
|
||||
for (const controller of controllers.values()) {
|
||||
controller.abort()
|
||||
}
|
||||
await Promise.allSettled([cacheLane, interactiveLane])
|
||||
await flushSessionParseCachePersist()
|
||||
process.disconnect?.()
|
||||
}
|
||||
|
||||
process.on('message', (raw: AiVaultServiceParentMessage) => {
|
||||
if (raw?.type === 'init') {
|
||||
if (initialized || raw.protocol !== AI_VAULT_SERVICE_PROTOCOL_VERSION) {
|
||||
void shutdown()
|
||||
return
|
||||
}
|
||||
initialized = true
|
||||
if (raw.sessionParseCache) {
|
||||
initSessionParseCachePersistence(raw.sessionParseCache)
|
||||
}
|
||||
send({ type: 'ready', protocol: AI_VAULT_SERVICE_PROTOCOL_VERSION, pid: process.pid })
|
||||
return
|
||||
}
|
||||
if (!initialized || shuttingDown) {
|
||||
return
|
||||
}
|
||||
if (raw?.type === 'cancel') {
|
||||
cancelled.add(raw.id)
|
||||
controllers.get(raw.id)?.abort()
|
||||
return
|
||||
}
|
||||
if (raw?.type === 'invalidate') {
|
||||
for (const path of raw.paths) {
|
||||
invalidatedPaths.delete(path)
|
||||
invalidatedPaths.add(path)
|
||||
invalidateSessionParseCacheEntry(path)
|
||||
}
|
||||
while (invalidatedPaths.size > 4_096) {
|
||||
const oldest = invalidatedPaths.values().next().value
|
||||
if (oldest === undefined) {
|
||||
break
|
||||
}
|
||||
invalidatedPaths.delete(oldest)
|
||||
}
|
||||
titleIndex.clear()
|
||||
send({ type: 'invalidated', generation: raw.generation })
|
||||
return
|
||||
}
|
||||
if (raw?.type === 'shutdown') {
|
||||
void shutdown()
|
||||
return
|
||||
}
|
||||
if (isAiVaultServiceRequest(raw)) {
|
||||
queueRequest(raw)
|
||||
}
|
||||
})
|
||||
|
||||
process.on('disconnect', () => void shutdown())
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildAiVaultServiceEnv, buildRelayAiVaultServiceEnv } from './session-scanner-service-env'
|
||||
|
||||
describe('buildAiVaultServiceEnv', () => {
|
||||
it('drops Node flag injection variables so the forked heap cap and loader stand', () => {
|
||||
const env = buildAiVaultServiceEnv(
|
||||
{
|
||||
NODE_OPTIONS: '--max-old-space-size=8192 --require=/tmp/evil.js',
|
||||
NODE_REPL_EXTERNAL_MODULE: '/tmp/evil.js',
|
||||
NODE_PATH: '/tmp/evil-modules',
|
||||
PATH: '/usr/bin'
|
||||
},
|
||||
'linux'
|
||||
)
|
||||
|
||||
expect(env.NODE_OPTIONS).toBeUndefined()
|
||||
expect(env.NODE_REPL_EXTERNAL_MODULE).toBeUndefined()
|
||||
expect(env.NODE_PATH).toBeUndefined()
|
||||
expect(env.PATH).toBe('/usr/bin')
|
||||
})
|
||||
|
||||
it('drops an unrecognised variable rather than carrying a shell-exported secret', () => {
|
||||
const env = buildAiVaultServiceEnv(
|
||||
{ AWS_SECRET_ACCESS_KEY: 'shhh', HOME: '/home/dev' },
|
||||
'linux'
|
||||
)
|
||||
|
||||
expect(env.AWS_SECRET_ACCESS_KEY).toBeUndefined()
|
||||
expect(env.HOME).toBe('/home/dev')
|
||||
})
|
||||
|
||||
it('keeps the agent-home variables the scanner discovers sessions through', () => {
|
||||
const env = buildAiVaultServiceEnv(
|
||||
{
|
||||
CODEX_HOME: '/home/dev/.codex',
|
||||
COPILOT_HOME: '/home/dev/.copilot',
|
||||
DEVIN_HOME: '/home/dev/.devin',
|
||||
GROK_HOME: '/home/dev/.grok',
|
||||
KIMI_CODE_HOME: '/home/dev/.kimi-code',
|
||||
OMP_CODING_AGENT_DIR: '/home/dev/.omp/agent/sessions',
|
||||
OPENCLAW_STATE_DIR: '/home/dev/.openclaw',
|
||||
PI_CODING_AGENT_DIR: '/home/dev/.pi/agent/sessions',
|
||||
PRIME_AGENT_CODING_AGENT_DIR: '/home/dev/.prime/agent',
|
||||
PRIME_AGENT_CODING_AGENT_SESSION_DIR: '/home/dev/.prime/legacy-sessions',
|
||||
PRIME_AGENT_SESSION_DIR: '/home/dev/.prime/sessions'
|
||||
},
|
||||
'linux'
|
||||
)
|
||||
|
||||
expect(env).toEqual({
|
||||
CODEX_HOME: '/home/dev/.codex',
|
||||
COPILOT_HOME: '/home/dev/.copilot',
|
||||
DEVIN_HOME: '/home/dev/.devin',
|
||||
GROK_HOME: '/home/dev/.grok',
|
||||
KIMI_CODE_HOME: '/home/dev/.kimi-code',
|
||||
OMP_CODING_AGENT_DIR: '/home/dev/.omp/agent/sessions',
|
||||
OPENCLAW_STATE_DIR: '/home/dev/.openclaw',
|
||||
PI_CODING_AGENT_DIR: '/home/dev/.pi/agent/sessions',
|
||||
PRIME_AGENT_CODING_AGENT_DIR: '/home/dev/.prime/agent',
|
||||
PRIME_AGENT_CODING_AGENT_SESSION_DIR: '/home/dev/.prime/legacy-sessions',
|
||||
PRIME_AGENT_SESSION_DIR: '/home/dev/.prime/sessions',
|
||||
ELECTRON_RUN_AS_NODE: '1'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the OpenCode data-directory and database overrides', () => {
|
||||
const env = buildAiVaultServiceEnv(
|
||||
{ XDG_DATA_HOME: '/home/dev/data', OPENCODE_DB: 'opencode-alt.db' },
|
||||
'linux'
|
||||
)
|
||||
|
||||
expect(env.XDG_DATA_HOME).toBe('/home/dev/data')
|
||||
expect(env.OPENCODE_DB).toBe('opencode-alt.db')
|
||||
})
|
||||
|
||||
it('runs the forked Electron binary as plain Node', () => {
|
||||
expect(buildAiVaultServiceEnv({}, 'linux').ELECTRON_RUN_AS_NODE).toBe('1')
|
||||
})
|
||||
|
||||
it('ignores a POSIX variable that only matches an allowed name by case', () => {
|
||||
expect(buildAiVaultServiceEnv({ codex_home: '/tmp/spoof' }, 'linux')).toEqual({
|
||||
ELECTRON_RUN_AS_NODE: '1'
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves a lowercased Windows variable the OS would still honour', () => {
|
||||
const env = buildAiVaultServiceEnv({ codex_home: 'C:\\codex', Path: 'C:\\bin' }, 'win32')
|
||||
|
||||
expect(env.CODEX_HOME).toBe('C:\\codex')
|
||||
expect(env.PATH).toBe('C:\\bin')
|
||||
})
|
||||
|
||||
it('spells SystemRoot the way Windows Node expects', () => {
|
||||
expect(buildAiVaultServiceEnv({ SystemRoot: 'C:\\Windows' }, 'win32').SystemRoot).toBe(
|
||||
'C:\\Windows'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not mutate the caller environment', () => {
|
||||
const baseEnv = { NODE_OPTIONS: '--inspect', HOME: '/home/dev' }
|
||||
buildAiVaultServiceEnv(baseEnv, 'linux')
|
||||
|
||||
expect(baseEnv.NODE_OPTIONS).toBe('--inspect')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildRelayAiVaultServiceEnv', () => {
|
||||
it('drops Node flag injection variables', () => {
|
||||
const env = buildRelayAiVaultServiceEnv(
|
||||
{ NODE_OPTIONS: '--max-old-space-size=8192', NODE_PATH: '/tmp/evil', HOME: '/home/ada' },
|
||||
'linux'
|
||||
)
|
||||
|
||||
expect(env.NODE_OPTIONS).toBeUndefined()
|
||||
expect(env.NODE_PATH).toBeUndefined()
|
||||
expect(env.HOME).toBe('/home/ada')
|
||||
})
|
||||
|
||||
// The sidecar takes remoteHome and hostPlatform from its init message, so an
|
||||
// agent-home override on the remote host is not part of how it finds roots.
|
||||
it('withholds the agent-home variables the desktop child needs', () => {
|
||||
const env = buildRelayAiVaultServiceEnv(
|
||||
{ CODEX_HOME: '/remote/.codex', PATH: '/usr/bin' },
|
||||
'linux'
|
||||
)
|
||||
|
||||
expect(env.CODEX_HOME).toBeUndefined()
|
||||
expect(env.PATH).toBe('/usr/bin')
|
||||
})
|
||||
|
||||
it('stays plain Node rather than an Electron child', () => {
|
||||
expect(buildRelayAiVaultServiceEnv({}, 'linux').ELECTRON_RUN_AS_NODE).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Environments for the AI Vault service children.
|
||||
*
|
||||
* Deliberately allowlists, never `...process.env` (the plugin worker takes the
|
||||
* same stance): the children are forked with a heap cap and no loader, and an
|
||||
* ambient NODE_OPTIONS would raise the cap or `--require` code straight into
|
||||
* them. Shell-exported secrets have no business in a transcript reader either.
|
||||
*/
|
||||
|
||||
// What Node and libuv need to start and resolve a home, temp dir and locale.
|
||||
const RUNTIME_ENV_ALLOWLIST = [
|
||||
'PATH',
|
||||
'HOME',
|
||||
'USERPROFILE',
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
'LC_CTYPE',
|
||||
'TZ',
|
||||
'TMPDIR',
|
||||
'TEMP',
|
||||
'TMP',
|
||||
// Why: Windows Node/libuv need these to resolve DLLs and the machine root.
|
||||
'SYSTEMROOT',
|
||||
'SYSTEMDRIVE',
|
||||
'WINDIR',
|
||||
'COMSPEC',
|
||||
'PATHEXT',
|
||||
'PROCESSOR_ARCHITECTURE',
|
||||
'NUMBER_OF_PROCESSORS'
|
||||
] as const
|
||||
|
||||
// Why: the desktop child resolves agent roots from its own environment, so
|
||||
// dropping one hides every session of a user who relocated that agent's home.
|
||||
const AGENT_ROOT_ENV_ALLOWLIST = [
|
||||
'CODEX_HOME',
|
||||
'COPILOT_HOME',
|
||||
'DEVIN_HOME',
|
||||
'GROK_HOME',
|
||||
'KIMI_CODE_HOME',
|
||||
'OMP_CODING_AGENT_DIR',
|
||||
'OPENCLAW_STATE_DIR',
|
||||
'OPENCODE_DB',
|
||||
'PI_CODING_AGENT_DIR',
|
||||
'PRIME_AGENT_CODING_AGENT_DIR',
|
||||
'PRIME_AGENT_CODING_AGENT_SESSION_DIR',
|
||||
'PRIME_AGENT_SESSION_DIR',
|
||||
// Why: OpenCode has no home variable — its store hangs off the XDG data dir,
|
||||
// so this one is an agent root here rather than generic runtime state.
|
||||
'XDG_DATA_HOME'
|
||||
] as const
|
||||
|
||||
function pickAllowedEnv(
|
||||
keys: readonly string[],
|
||||
baseEnv: NodeJS.ProcessEnv,
|
||||
platform: NodeJS.Platform
|
||||
): NodeJS.ProcessEnv {
|
||||
const windowsLookup = new Map<string, string>()
|
||||
if (platform === 'win32') {
|
||||
// Why: Windows resolves env names case-insensitively, so a lowercased
|
||||
// `codex_home` still reaches the child; folding on POSIX instead would
|
||||
// promote an attacker-set `path` over the real one.
|
||||
for (const [key, value] of Object.entries(baseEnv)) {
|
||||
if (typeof value === 'string') {
|
||||
windowsLookup.set(key.toUpperCase(), value)
|
||||
}
|
||||
}
|
||||
}
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
for (const key of keys) {
|
||||
const value = platform === 'win32' ? windowsLookup.get(key) : baseEnv[key]
|
||||
if (value !== undefined) {
|
||||
env[key === 'SYSTEMROOT' ? 'SystemRoot' : key] = value
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
/** Desktop: forked from the Electron binary, so it also needs run-as-node. */
|
||||
export function buildAiVaultServiceEnv(
|
||||
baseEnv: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): NodeJS.ProcessEnv {
|
||||
const env = pickAllowedEnv(
|
||||
[...RUNTIME_ENV_ALLOWLIST, ...AGENT_ROOT_ENV_ALLOWLIST],
|
||||
baseEnv,
|
||||
platform
|
||||
)
|
||||
env.ELECTRON_RUN_AS_NODE = '1'
|
||||
return env
|
||||
}
|
||||
|
||||
/** Relay: the sidecar takes every root from its init message, not the environment. */
|
||||
export function buildRelayAiVaultServiceEnv(
|
||||
baseEnv: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): NodeJS.ProcessEnv {
|
||||
return pickAllowedEnv(RUNTIME_ENV_ALLOWLIST, baseEnv, platform)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { constants, setPriority } from 'node:os'
|
||||
|
||||
export function lowerAiVaultServicePriority(pid: number | undefined): boolean {
|
||||
if (pid === undefined || !Number.isSafeInteger(pid) || pid <= 0) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
setPriority(pid, constants.priority.PRIORITY_BELOW_NORMAL)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { AiVaultListResult, AiVaultSubagentListResult } from '../../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultSessionTitle,
|
||||
AiVaultSessionTitleRequest,
|
||||
AiVaultSessionTitlesResult
|
||||
} from '../../shared/ai-vault-session-title'
|
||||
import type { ReadAiVaultFirstUserPromptArgs } from './session-first-user-prompt-read'
|
||||
import type { SessionParseCachePersistenceOptions } from './session-parse-cache-persistence'
|
||||
import type { AiVaultWorkerScanOptions } from './session-scanner-worker-protocol'
|
||||
|
||||
export const AI_VAULT_SERVICE_PROTOCOL_VERSION = 1
|
||||
|
||||
export type AiVaultServiceLane = 'cache' | 'interactive'
|
||||
export type AiVaultServiceOperation = 'scan' | 'titles' | 'subagents' | 'firstPrompt'
|
||||
|
||||
export type AiVaultServiceSubagentRequest = {
|
||||
agent: 'claude' | 'omp'
|
||||
parentFilePath: string
|
||||
}
|
||||
|
||||
export type AiVaultServiceInit = {
|
||||
type: 'init'
|
||||
protocol: typeof AI_VAULT_SERVICE_PROTOCOL_VERSION
|
||||
sessionParseCache: SessionParseCachePersistenceOptions | null
|
||||
}
|
||||
|
||||
export type AiVaultServiceRequestBody =
|
||||
| { type: 'request'; operation: 'scan'; options: AiVaultWorkerScanOptions }
|
||||
| {
|
||||
type: 'request'
|
||||
operation: 'titles'
|
||||
requests: AiVaultSessionTitleRequest[]
|
||||
}
|
||||
| {
|
||||
type: 'request'
|
||||
operation: 'subagents'
|
||||
request: AiVaultServiceSubagentRequest
|
||||
}
|
||||
| {
|
||||
type: 'request'
|
||||
operation: 'firstPrompt'
|
||||
request: ReadAiVaultFirstUserPromptArgs
|
||||
}
|
||||
|
||||
export type AiVaultServiceRequest = AiVaultServiceRequestBody & { id: number }
|
||||
|
||||
export type AiVaultServiceParentMessage =
|
||||
| AiVaultServiceInit
|
||||
| AiVaultServiceRequest
|
||||
| { type: 'cancel'; id: number }
|
||||
| { type: 'invalidate'; generation: number; paths: string[] }
|
||||
| { type: 'shutdown' }
|
||||
|
||||
export type AiVaultServiceResultValue =
|
||||
| { operation: 'scan'; value: { result: AiVaultListResult; durationMs: number } }
|
||||
| { operation: 'titles'; value: AiVaultSessionTitlesResult }
|
||||
| { operation: 'subagents'; value: AiVaultSubagentListResult }
|
||||
| { operation: 'firstPrompt'; value: { prompt: string | null } }
|
||||
|
||||
export type AiVaultServiceChildMessage =
|
||||
| {
|
||||
type: 'ready'
|
||||
protocol: typeof AI_VAULT_SERVICE_PROTOCOL_VERSION
|
||||
pid: number
|
||||
}
|
||||
| ({ type: 'result'; id: number } & AiVaultServiceResultValue)
|
||||
| { type: 'error'; id: number; message: string; retryable: boolean }
|
||||
| { type: 'invalidated'; generation: number }
|
||||
|
||||
export function aiVaultServiceLane(operation: AiVaultServiceOperation): AiVaultServiceLane {
|
||||
return operation === 'subagents' || operation === 'firstPrompt' ? 'interactive' : 'cache'
|
||||
}
|
||||
|
||||
export function isAiVaultServiceRequest(value: unknown): value is AiVaultServiceRequest {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
const message = value as Record<string, unknown>
|
||||
return (
|
||||
message.type === 'request' &&
|
||||
Number.isSafeInteger(message.id) &&
|
||||
(message.operation === 'scan' ||
|
||||
message.operation === 'titles' ||
|
||||
message.operation === 'subagents' ||
|
||||
message.operation === 'firstPrompt')
|
||||
)
|
||||
}
|
||||
|
||||
export function isAiVaultServiceChildMessage(value: unknown): value is AiVaultServiceChildMessage {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
const message = value as Record<string, unknown>
|
||||
if (message.type === 'ready') {
|
||||
return message.protocol === AI_VAULT_SERVICE_PROTOCOL_VERSION && Number.isInteger(message.pid)
|
||||
}
|
||||
if (message.type === 'invalidated') {
|
||||
return Number.isSafeInteger(message.generation)
|
||||
}
|
||||
return (message.type === 'result' || message.type === 'error') && Number.isSafeInteger(message.id)
|
||||
}
|
||||
|
||||
export function cacheServiceTitle(
|
||||
titleIndex: Map<string, AiVaultSessionTitle>,
|
||||
title: AiVaultSessionTitle,
|
||||
maxEntries = 4_096
|
||||
): void {
|
||||
const key = `${title.agent}\0${title.sessionId}`
|
||||
titleIndex.delete(key)
|
||||
titleIndex.set(key, title)
|
||||
while (titleIndex.size > maxEntries) {
|
||||
const oldest = titleIndex.keys().next().value
|
||||
if (oldest === undefined) {
|
||||
break
|
||||
}
|
||||
titleIndex.delete(oldest)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AiVaultServiceRestartPolicy } from './session-scanner-service-restart-policy'
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
describe('AiVaultServiceRestartPolicy', () => {
|
||||
it('keeps one pending restart when faults overlap', () => {
|
||||
vi.useFakeTimers()
|
||||
const policy = new AiVaultServiceRestartPolicy()
|
||||
const restart = vi.fn()
|
||||
|
||||
policy.recordFault(restart)
|
||||
policy.recordFault(restart)
|
||||
vi.advanceTimersByTime(10_000)
|
||||
|
||||
expect(restart).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('cancels the pending restart on dispose after overlapping faults', () => {
|
||||
vi.useFakeTimers()
|
||||
const policy = new AiVaultServiceRestartPolicy()
|
||||
const restart = vi.fn()
|
||||
|
||||
policy.recordFault(restart)
|
||||
policy.recordFault(restart)
|
||||
policy.dispose()
|
||||
vi.advanceTimersByTime(10_000)
|
||||
|
||||
expect(restart).not.toHaveBeenCalled()
|
||||
expect(policy.restartScheduled).toBe(false)
|
||||
})
|
||||
|
||||
it('opens the circuit after three faults inside the window', () => {
|
||||
let now = 0
|
||||
const policy = new AiVaultServiceRestartPolicy(() => now)
|
||||
|
||||
expect(policy.startError()).toBeNull()
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
policy.recordFault(() => undefined)
|
||||
now += 1_000
|
||||
}
|
||||
|
||||
expect(policy.startError()?.message).toContain('circuit is open')
|
||||
now += 60_000
|
||||
expect(policy.startError()).toBeNull()
|
||||
policy.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
const AI_VAULT_SERVICE_FAULT_WINDOW_MS = 60_000
|
||||
const AI_VAULT_SERVICE_RESTART_DELAYS_MS = [250, 1_000, 5_000] as const
|
||||
|
||||
export class AiVaultServiceRestartPolicy {
|
||||
private faults: number[] = []
|
||||
private circuitUntil = 0
|
||||
private timer: NodeJS.Timeout | null = null
|
||||
|
||||
constructor(private readonly now: () => number = Date.now) {}
|
||||
|
||||
get restartScheduled(): boolean {
|
||||
return this.timer !== null
|
||||
}
|
||||
|
||||
startError(): Error | null {
|
||||
return this.now() < this.circuitUntil
|
||||
? new Error('AI Vault service restart circuit is open.')
|
||||
: null
|
||||
}
|
||||
|
||||
recordFault(restart: () => void): void {
|
||||
const now = this.now()
|
||||
this.faults = [
|
||||
...this.faults.filter((time) => now - time < AI_VAULT_SERVICE_FAULT_WINDOW_MS),
|
||||
now
|
||||
]
|
||||
if (this.faults.length >= 3) {
|
||||
this.circuitUntil = now + AI_VAULT_SERVICE_FAULT_WINDOW_MS
|
||||
}
|
||||
const delay = AI_VAULT_SERVICE_RESTART_DELAYS_MS[Math.min(this.faults.length - 1, 2)]
|
||||
// Why: a second fault before the pending restart fires would otherwise strand
|
||||
// the old timer, leaving a restart that dispose() can no longer cancel.
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
}
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null
|
||||
restart()
|
||||
}, delay)
|
||||
this.timer.unref?.()
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const forkMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('node:child_process', () => ({ fork: forkMock }))
|
||||
vi.mock('node:fs', () => ({ existsSync: () => true }))
|
||||
|
||||
const { spawnAiVaultServiceProcess } = await import('./session-scanner-service-spawn')
|
||||
|
||||
function forkOptions(): { env?: NodeJS.ProcessEnv; execArgv?: string[] } {
|
||||
return forkMock.mock.calls.at(-1)?.[2] ?? {}
|
||||
}
|
||||
|
||||
describe('spawnAiVaultServiceProcess', () => {
|
||||
beforeEach(() => {
|
||||
forkMock.mockReset()
|
||||
forkMock.mockReturnValue({ pid: undefined, unref: vi.fn() } as unknown as ChildProcess)
|
||||
})
|
||||
|
||||
it('keeps NODE_OPTIONS out of the child so the heap cap and loader stand', () => {
|
||||
vi.stubEnv('NODE_OPTIONS', '--max-old-space-size=8192 --require=/tmp/evil.js')
|
||||
spawnAiVaultServiceProcess()
|
||||
const options = forkOptions()
|
||||
|
||||
// Asserted first: omitting `env` entirely inherits everything, and would
|
||||
// leave the NODE_OPTIONS assertion below passing for the wrong reason.
|
||||
expect(options.env).toBeDefined()
|
||||
expect(options.env?.NODE_OPTIONS).toBeUndefined()
|
||||
expect(options.execArgv).toEqual(['--max-old-space-size=384'])
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('still passes through a relocated agent home', () => {
|
||||
vi.stubEnv('CODEX_HOME', '/home/dev/elsewhere/.codex')
|
||||
spawnAiVaultServiceProcess()
|
||||
|
||||
expect(forkOptions().env?.CODEX_HOME).toBe('/home/dev/elsewhere/.codex')
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('runs the forked Electron binary as plain Node', () => {
|
||||
spawnAiVaultServiceProcess()
|
||||
|
||||
expect(forkOptions().env?.ELECTRON_RUN_AS_NODE).toBe('1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { fork, type ChildProcess } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import type { AiVaultListResult, AiVaultSubagentListResult } from '../../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultSessionTitleRequest,
|
||||
AiVaultSessionTitlesResult
|
||||
} from '../../shared/ai-vault-session-title'
|
||||
import { withSpan } from '../observability/tracer'
|
||||
import type {
|
||||
ReadAiVaultFirstUserPromptArgs,
|
||||
ReadAiVaultFirstUserPromptResult
|
||||
} from './session-first-user-prompt-read'
|
||||
import { getSessionParseCachePersistenceOptions } from './session-parse-cache-persistence'
|
||||
import { buildAiVaultServiceEnv } from './session-scanner-service-env'
|
||||
import { AiVaultScannerServiceClient } from './session-scanner-service-client'
|
||||
import { getAiVaultServiceEntryPath } from './session-scanner-service-entry-path'
|
||||
import { lowerAiVaultServicePriority } from './session-scanner-service-priority'
|
||||
import type { AiVaultServiceSubagentRequest } from './session-scanner-service-protocol'
|
||||
import type { AiVaultWorkerScanOptions } from './session-scanner-worker-protocol'
|
||||
|
||||
export function spawnAiVaultServiceProcess(): ChildProcess {
|
||||
const entryPath = getAiVaultServiceEntryPath()
|
||||
if (!existsSync(entryPath)) {
|
||||
throw new Error(`AI Vault service entry not found: ${entryPath}`)
|
||||
}
|
||||
const child = fork(entryPath, [], {
|
||||
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
|
||||
execArgv: ['--max-old-space-size=384'],
|
||||
env: buildAiVaultServiceEnv(),
|
||||
...(process.platform === 'win32' ? { windowsHide: true } : {})
|
||||
})
|
||||
lowerAiVaultServicePriority(child.pid)
|
||||
child.unref()
|
||||
return child
|
||||
}
|
||||
|
||||
let sharedClient: AiVaultScannerServiceClient | null = null
|
||||
|
||||
function getSharedClient(): AiVaultScannerServiceClient {
|
||||
sharedClient ??= new AiVaultScannerServiceClient({
|
||||
processFactory: spawnAiVaultServiceProcess,
|
||||
init: { sessionParseCache: getSessionParseCachePersistenceOptions() },
|
||||
onStderr: (text) => console.error('[ai-vault-service]', text.trimEnd())
|
||||
})
|
||||
return sharedClient
|
||||
}
|
||||
|
||||
export function scanAiVaultSessionsInService(
|
||||
options: AiVaultWorkerScanOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultListResult> {
|
||||
return withSpan('aiVault.scan.service', async (span) => {
|
||||
const value = await getSharedClient().request<{
|
||||
result: AiVaultListResult
|
||||
durationMs: number
|
||||
}>({ type: 'request', operation: 'scan', options }, signal)
|
||||
span.setAttribute('serviceDurationMs', value.durationMs)
|
||||
span.setAttribute('sessions', value.result.sessions.length)
|
||||
return value.result
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveAiVaultSessionTitlesInService(
|
||||
requests: AiVaultSessionTitleRequest[],
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSessionTitlesResult> {
|
||||
return getSharedClient().request({ type: 'request', operation: 'titles', requests }, signal)
|
||||
}
|
||||
|
||||
export function listAiVaultSubagentSessionsInService(
|
||||
request: AiVaultServiceSubagentRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSubagentListResult> {
|
||||
return getSharedClient().request({ type: 'request', operation: 'subagents', request }, signal)
|
||||
}
|
||||
|
||||
export function readAiVaultFirstUserPromptInService(
|
||||
request: ReadAiVaultFirstUserPromptArgs,
|
||||
signal?: AbortSignal
|
||||
): Promise<ReadAiVaultFirstUserPromptResult> {
|
||||
return getSharedClient().request({ type: 'request', operation: 'firstPrompt', request }, signal)
|
||||
}
|
||||
|
||||
export function invalidateAiVaultServiceCache(paths: string[]): Promise<void> {
|
||||
return sharedClient?.invalidate(paths) ?? Promise.resolve()
|
||||
}
|
||||
|
||||
export function resetAiVaultScannerServiceForTests(): void {
|
||||
sharedClient?.dispose()
|
||||
sharedClient = null
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
|
||||
export class AiVaultServiceTestChild extends EventEmitter {
|
||||
readonly sent: unknown[] = []
|
||||
readonly stderr = new EventEmitter()
|
||||
readonly pid: number
|
||||
killed = false
|
||||
unrefed = false
|
||||
|
||||
constructor(pid = 12_345) {
|
||||
super()
|
||||
this.pid = pid
|
||||
}
|
||||
|
||||
send(message: unknown, callback?: (error: Error | null) => void): boolean {
|
||||
this.sent.push(message)
|
||||
callback?.(null)
|
||||
return true
|
||||
}
|
||||
|
||||
kill(): boolean {
|
||||
this.killed = true
|
||||
return true
|
||||
}
|
||||
|
||||
unref(): this {
|
||||
this.unrefed = true
|
||||
return this
|
||||
}
|
||||
|
||||
asChildProcess(): ChildProcess {
|
||||
return this as unknown as ChildProcess
|
||||
}
|
||||
}
|
||||
|
||||
export function readyAiVaultServiceChild(child: AiVaultServiceTestChild): void {
|
||||
child.emit('message', { type: 'ready', protocol: 1, pid: child.pid })
|
||||
}
|
||||
|
||||
export function aiVaultServiceRequestId(child: AiVaultServiceTestChild, operation: string): number {
|
||||
const request = child.sent.find(
|
||||
(message) =>
|
||||
typeof message === 'object' &&
|
||||
message !== null &&
|
||||
(message as { operation?: string }).operation === operation
|
||||
) as { id?: number } | undefined
|
||||
if (request?.id === undefined) {
|
||||
throw new Error(`No ${operation} request was sent.`)
|
||||
}
|
||||
return request.id
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { AiVaultSubagentListResult } from '../../shared/ai-vault-types'
|
||||
import { listClaudeSubagentSessions } from './session-scanner-claude-subagents'
|
||||
import { listOmpSubagentSessions } from './session-scanner-omp-subagent-listing'
|
||||
import type { AiVaultServiceSubagentRequest } from './session-scanner-service-protocol'
|
||||
|
||||
export function listLocalAiVaultSubagentSessions(
|
||||
request: AiVaultServiceSubagentRequest
|
||||
): Promise<AiVaultSubagentListResult> {
|
||||
return request.agent === 'claude'
|
||||
? listClaudeSubagentSessions({ parentFilePath: request.parentFilePath })
|
||||
: listOmpSubagentSessions({ parentFilePath: request.parentFilePath })
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type AiVaultSessionTitleRequest,
|
||||
type AiVaultSessionTitlesResult
|
||||
} from '../../shared/ai-vault-session-title'
|
||||
import { resolveAiVaultSessionTitlesInWorker } from './session-scanner-worker-spawn'
|
||||
import { resolveAiVaultSessionTitlesInBackground } from './session-scanner-background'
|
||||
|
||||
const TRANSCRIPT_PATH_MAX_LENGTH = 32_768
|
||||
|
||||
@@ -42,5 +42,5 @@ export async function resolveLocalAiVaultSessionTitles(
|
||||
deduped.set(key, normalized)
|
||||
}
|
||||
}
|
||||
return resolveAiVaultSessionTitlesInWorker([...deduped.values()], signal)
|
||||
return resolveAiVaultSessionTitlesInBackground([...deduped.values()], signal)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
writeHooksJsonRemote,
|
||||
writeManagedScriptRemote
|
||||
} from '../agent-hooks/installer-utils-remote'
|
||||
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
|
||||
import {
|
||||
buildPosixHookPayloadCapture,
|
||||
buildWindowsHookEnvironmentGuardLines,
|
||||
@@ -296,6 +297,18 @@ function removeInstalledConfig(config: HooksConfig): void {
|
||||
}
|
||||
|
||||
export class AntigravityHookService {
|
||||
async refreshManagedScripts(): Promise<void> {
|
||||
await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript())
|
||||
if (process.platform === 'win32') {
|
||||
for (const event of ANTIGRAVITY_EVENTS) {
|
||||
await refreshManagedScriptIfPresent(
|
||||
getWindowsWrapperScriptPath(event),
|
||||
getWindowsWrapperScript(event.eventName)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getStatus(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
import { measureClipboardTextByteLength } from '../../shared/clipboard-text'
|
||||
import { yieldToEventLoop } from '../../shared/event-loop-yield'
|
||||
import { getUtf8ChunkEndIndex } from '../../shared/utf8-byte-limits'
|
||||
import type { CdpCommandSender } from './snapshot-engine'
|
||||
|
||||
export const BROWSER_TEXT_INSERT_CHUNK_BYTES = 64 * 1024
|
||||
|
||||
function getUtf8ByteLengthForCodePoint(codePoint: number): number {
|
||||
if (codePoint <= 0x7f) {
|
||||
return 1
|
||||
}
|
||||
if (codePoint <= 0x7ff) {
|
||||
return 2
|
||||
}
|
||||
if (codePoint <= 0xffff) {
|
||||
return 3
|
||||
}
|
||||
return 4
|
||||
}
|
||||
|
||||
export function splitBrowserTextInsertionChunks(
|
||||
text: string,
|
||||
maxChunkBytes = BROWSER_TEXT_INSERT_CHUNK_BYTES
|
||||
@@ -38,24 +26,11 @@ export function* iterateBrowserTextInsertionChunks(
|
||||
return
|
||||
}
|
||||
|
||||
let currentStart = 0
|
||||
let currentBytes = 0
|
||||
let index = 0
|
||||
while (index < text.length) {
|
||||
const codePoint = text.codePointAt(index) ?? 0
|
||||
const codeUnitLength = codePoint > 0xffff ? 2 : 1
|
||||
const nextIndex = index + codeUnitLength
|
||||
const characterBytes = getUtf8ByteLengthForCodePoint(codePoint)
|
||||
if (currentBytes > 0 && currentBytes + characterBytes > normalizedMax) {
|
||||
yield text.slice(currentStart, index)
|
||||
currentStart = index
|
||||
currentBytes = 0
|
||||
}
|
||||
currentBytes += characterBytes
|
||||
index = nextIndex
|
||||
}
|
||||
if (currentStart < text.length) {
|
||||
yield text.slice(currentStart)
|
||||
let startIndex = 0
|
||||
while (startIndex < text.length) {
|
||||
const endIndex = getUtf8ChunkEndIndex(text, startIndex, normalizedMax)
|
||||
yield text.slice(startIndex, endIndex)
|
||||
startIndex = endIndex
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,8 +43,7 @@ export async function insertTextThroughCdp(
|
||||
let chunk = chunks.next()
|
||||
while (!chunk.done) {
|
||||
await sender('Input.insertText', { text: chunk.value })
|
||||
// Why: browser automation text can be paste-sized; yielding keeps the main
|
||||
// process responsive between bounded CDP payloads.
|
||||
// Keep paste-sized insertion from monopolizing the main process.
|
||||
chunk = chunks.next()
|
||||
if (options?.yieldBetweenChunks !== false && !chunk.done) {
|
||||
await yieldToEventLoop()
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ClaudeUsagePersistedState } from './types'
|
||||
import type * as Scanner from './scanner'
|
||||
|
||||
const { getPathMock, writeOpens, writeGate } = vi.hoisted(() => ({
|
||||
getPathMock: vi.fn(() => '/tmp/orca-test-userdata'),
|
||||
// Why only mode 'w': the durable write also opens the directory read-only to fsync it, so counting
|
||||
// every open would hide a regression back to multiple full-cache rewrites per scan.
|
||||
writeOpens: { value: 0, inFlight: 0, maxConcurrent: 0 },
|
||||
writeGate: {
|
||||
blocked: false,
|
||||
waiters: [] as (() => void)[]
|
||||
}
|
||||
const { getPathMock } = vi.hoisted(() => ({
|
||||
getPathMock: vi.fn(() => '/tmp/orca-test-userdata')
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
@@ -23,29 +15,6 @@ vi.mock('electron', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async () => {
|
||||
const actual = await vi.importActual<typeof FsPromises>('node:fs/promises')
|
||||
return {
|
||||
...actual,
|
||||
open: (async (...args: Parameters<typeof actual.open>) => {
|
||||
if (args[1] !== 'w') {
|
||||
return actual.open(...args)
|
||||
}
|
||||
writeOpens.value += 1
|
||||
writeOpens.inFlight += 1
|
||||
writeOpens.maxConcurrent = Math.max(writeOpens.maxConcurrent, writeOpens.inFlight)
|
||||
try {
|
||||
if (writeGate.blocked) {
|
||||
await new Promise<void>((resolve) => writeGate.waiters.push(resolve))
|
||||
}
|
||||
return await actual.open(...args)
|
||||
} finally {
|
||||
writeOpens.inFlight -= 1
|
||||
}
|
||||
}) as typeof actual.open
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./scanner', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof Scanner>()),
|
||||
scanClaudeUsageFiles: vi.fn()
|
||||
@@ -54,12 +23,15 @@ vi.mock('./scanner', async (importOriginal) => ({
|
||||
import { ClaudeUsageStore, initClaudeUsagePath } from './store'
|
||||
import { scanClaudeUsageFiles } from './scanner'
|
||||
|
||||
function createStoreWithState(state: Partial<ClaudeUsagePersistedState>): ClaudeUsageStore {
|
||||
const store = new ClaudeUsageStore({
|
||||
function createBackingStore(): ConstructorParameters<typeof ClaudeUsageStore>[0] {
|
||||
return {
|
||||
getRepos: () => [],
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getWorktreeMeta: () => undefined
|
||||
} as never)
|
||||
getAllWorktreeMeta: () => ({})
|
||||
}
|
||||
}
|
||||
|
||||
function createStoreWithState(state: Partial<ClaudeUsagePersistedState>): ClaudeUsageStore {
|
||||
const store = new ClaudeUsageStore(createBackingStore())
|
||||
|
||||
;(store as unknown as { state: ClaudeUsagePersistedState }).state = {
|
||||
schemaVersion: 1,
|
||||
@@ -86,11 +58,6 @@ describe('ClaudeUsageStore', () => {
|
||||
tempUserData = mkdtempSync(join(tmpdir(), 'orca-claude-usage-store-'))
|
||||
getPathMock.mockReturnValue(tempUserData)
|
||||
initClaudeUsagePath()
|
||||
writeOpens.value = 0
|
||||
writeOpens.inFlight = 0
|
||||
writeOpens.maxConcurrent = 0
|
||||
writeGate.blocked = false
|
||||
writeGate.waiters = []
|
||||
vi.mocked(scanClaudeUsageFiles).mockReset()
|
||||
vi.mocked(scanClaudeUsageFiles).mockResolvedValue({
|
||||
processedFiles: [],
|
||||
@@ -106,6 +73,17 @@ describe('ClaudeUsageStore', () => {
|
||||
rmSync(tempUserData, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('defaults a null legacy opt-in while invalidating the cache', () => {
|
||||
writeFileSync(
|
||||
join(tempUserData, 'orca-claude-usage.json'),
|
||||
JSON.stringify({ schemaVersion: 4, scanState: { enabled: null } })
|
||||
)
|
||||
|
||||
const store = new ClaudeUsageStore(createBackingStore())
|
||||
|
||||
expect(store.getScanState().enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('reports no data for Orca scope when only non-Orca usage exists', async () => {
|
||||
const store = createStoreWithState({
|
||||
sessions: [
|
||||
@@ -617,64 +595,7 @@ describe('ClaudeUsageStore', () => {
|
||||
expect(refreshMock).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('persists setEnabled via async durable write without leaving tmp files', async () => {
|
||||
const store = createStoreWithState({
|
||||
schemaVersion: 5,
|
||||
scanState: {
|
||||
enabled: false,
|
||||
lastScanStartedAt: null,
|
||||
lastScanCompletedAt: null,
|
||||
lastScanError: null
|
||||
}
|
||||
})
|
||||
|
||||
await store.setEnabled(true)
|
||||
|
||||
expect(writeOpens.value).toBe(1)
|
||||
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
|
||||
const persisted = JSON.parse(
|
||||
readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')
|
||||
)
|
||||
expect(persisted.scanState.enabled).toBe(true)
|
||||
// Pretty-print preserved for human inspection of the analytics cache.
|
||||
expect(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).toContain('\n')
|
||||
})
|
||||
|
||||
it('vetoes a stale concurrent async write so the newer snapshot wins', async () => {
|
||||
const store = createStoreWithState({
|
||||
schemaVersion: 5,
|
||||
scanState: {
|
||||
enabled: true,
|
||||
lastScanStartedAt: null,
|
||||
lastScanCompletedAt: null,
|
||||
lastScanError: null
|
||||
}
|
||||
})
|
||||
const internals = store as unknown as {
|
||||
writeToDisk: () => Promise<void>
|
||||
state: ClaudeUsagePersistedState
|
||||
}
|
||||
|
||||
writeGate.blocked = true
|
||||
const first = internals.writeToDisk()
|
||||
await vi.waitFor(() => expect(writeGate.waiters.length).toBe(1))
|
||||
|
||||
internals.state.scanState.enabled = false
|
||||
writeGate.blocked = false
|
||||
const second = internals.writeToDisk()
|
||||
writeGate.waiters.splice(0).forEach((resolve) => resolve())
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(
|
||||
JSON.parse(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).scanState
|
||||
.enabled
|
||||
).toBe(false)
|
||||
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
|
||||
// Serialized, so the superseded write can be skipped safely rather than racing the newer one.
|
||||
expect(writeOpens.maxConcurrent).toBe(1)
|
||||
})
|
||||
|
||||
it('persists a successful refresh with one full-cache write', async () => {
|
||||
it('adapts Claude scans to pretty-printed cache persistence', async () => {
|
||||
const store = createStoreWithState({
|
||||
schemaVersion: 5,
|
||||
scanState: {
|
||||
@@ -687,25 +608,7 @@ describe('ClaudeUsageStore', () => {
|
||||
|
||||
await store.refresh(true)
|
||||
|
||||
// Why exactly one: scan start used to rewrite the whole 20 MB cache before any result changed.
|
||||
expect(writeOpens.value).toBe(1)
|
||||
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
|
||||
expect(
|
||||
JSON.parse(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).scanState
|
||||
).toMatchObject({
|
||||
lastScanStartedAt: new Date('2026-04-09T12:00:00.000-04:00').getTime(),
|
||||
lastScanCompletedAt: new Date('2026-04-09T12:00:00.000-04:00').getTime(),
|
||||
lastScanError: null
|
||||
})
|
||||
})
|
||||
|
||||
it('sweeps a usage temp file orphaned by a crash between write and rename', async () => {
|
||||
const orphan = join(tempUserData, 'orca-claude-usage.json.999.1.abc.tmp')
|
||||
writeFileSync(orphan, '{}')
|
||||
|
||||
createStoreWithState({})
|
||||
await vi.waitFor(() =>
|
||||
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
|
||||
)
|
||||
expect(scanClaudeUsageFiles).toHaveBeenCalledWith([], [])
|
||||
expect(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).toContain('\n')
|
||||
})
|
||||
})
|
||||
|
||||
+37
-187
@@ -1,14 +1,11 @@
|
||||
/* eslint-disable max-lines -- Why: this store is the single main-process owner for Claude usage persistence, scan gating, and query semantics. Keeping those policy decisions together avoids split-brain range/scope logic across multiple files. */
|
||||
/* eslint-disable max-lines -- Why: Claude pricing, range, scope, breakdown, and automation-attribution policies remain one cohesive store. */
|
||||
import { app } from 'electron'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer'
|
||||
import type {
|
||||
ClaudeUsageBreakdownKind,
|
||||
ClaudeUsageBreakdownRow,
|
||||
ClaudeUsageDailyPoint,
|
||||
ClaudeUsageRange,
|
||||
ClaudeUsageScanState,
|
||||
ClaudeUsageScope,
|
||||
ClaudeUsageSessionRow,
|
||||
ClaudeUsageSnapshot,
|
||||
@@ -16,16 +13,15 @@ import type {
|
||||
} from '../../shared/claude-usage-types'
|
||||
import type { AutomationRunUsage } from '../../shared/automations-types'
|
||||
import type { Store } from '../persistence'
|
||||
import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata'
|
||||
import type { ClaudeUsagePersistedState } from './types'
|
||||
import { createWorktreeRefs } from '../usage/usage-worktree-refs'
|
||||
import { getSessionProjectLabel, scanClaudeUsageFiles } from './scanner'
|
||||
import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range'
|
||||
import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle'
|
||||
|
||||
// Why: v5 widens Claude ownership keys (message-id / uuid fallbacks). Older
|
||||
// caches either lack ownership or used narrower keys and can under/over-count
|
||||
// after fork reclaim (#8006).
|
||||
const SCHEMA_VERSION = 5
|
||||
const STALE_MS = 5 * 60_000
|
||||
const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000
|
||||
|
||||
// Why: capture the path after configureDevUserDataPath() but before app.setName()
|
||||
@@ -130,6 +126,21 @@ function getDefaultState(): ClaudeUsagePersistedState {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePersistedState(state: ClaudeUsagePersistedState): ClaudeUsagePersistedState {
|
||||
if (state.schemaVersion === SCHEMA_VERSION) {
|
||||
return state
|
||||
}
|
||||
// Scanner changes invalidate totals, but preserving enabled keeps existing tracking on.
|
||||
const defaults = getDefaultState()
|
||||
return {
|
||||
...defaults,
|
||||
scanState: {
|
||||
...defaults.scanState,
|
||||
enabled: state.scanState.enabled ?? defaults.scanState.enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function initClaudeUsagePath(): void {
|
||||
_claudeUsageFile = join(app.getPath('userData'), 'orca-claude-usage.json')
|
||||
}
|
||||
@@ -288,120 +299,22 @@ function estimateCostUsd(
|
||||
)
|
||||
}
|
||||
|
||||
function getRangeCutoff(range: ClaudeUsageRange): string | null {
|
||||
if (range === 'all') {
|
||||
return null
|
||||
}
|
||||
const days = range === '7d' ? 7 : range === '30d' ? 30 : 90
|
||||
const now = new Date()
|
||||
now.setHours(0, 0, 0, 0)
|
||||
now.setDate(now.getDate() - (days - 1))
|
||||
const year = now.getFullYear()
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(now.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function getLocalDay(timestamp: string): string | null {
|
||||
const parsed = new Date(timestamp)
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null
|
||||
}
|
||||
const year = parsed.getFullYear()
|
||||
const month = String(parsed.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(parsed.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function getWorktreeFingerprint(worktreesByRepo: Map<string, UsageWorktreeRef[]>): string {
|
||||
const rows = [...worktreesByRepo.entries()]
|
||||
.flatMap(([repoId, worktrees]) =>
|
||||
worktrees.map((worktree) =>
|
||||
JSON.stringify({
|
||||
repoId,
|
||||
worktreeId: worktree.worktreeId,
|
||||
path: worktree.path,
|
||||
displayName: worktree.displayName
|
||||
})
|
||||
)
|
||||
)
|
||||
.sort()
|
||||
return JSON.stringify(rows)
|
||||
}
|
||||
|
||||
export class ClaudeUsageStore {
|
||||
private state: ClaudeUsagePersistedState
|
||||
private readonly store: Store
|
||||
private scanPromise: Promise<void> | null = null
|
||||
// Why: the 20 MB usage JSON must not block the Electron main thread; the writer serializes writes
|
||||
// and vetoes superseded renames.
|
||||
private readonly writer = new UsageCacheSnapshotWriter('[claude-usage]', getClaudeUsageFile)
|
||||
|
||||
constructor(store: Store) {
|
||||
this.store = store
|
||||
this.state = this.load()
|
||||
}
|
||||
|
||||
private load(): ClaudeUsagePersistedState {
|
||||
try {
|
||||
const usageFile = getClaudeUsageFile()
|
||||
if (!existsSync(usageFile)) {
|
||||
return getDefaultState()
|
||||
}
|
||||
const parsed = JSON.parse(readFileSync(usageFile, 'utf-8')) as ClaudeUsagePersistedState
|
||||
if (parsed.schemaVersion !== SCHEMA_VERSION) {
|
||||
// Why: scanner semantics affect persisted totals, so old Claude caches
|
||||
// must be rebuilt after parser/source changes instead of reused briefly.
|
||||
// Preserve scanState.enabled so existing users keep tracking on across
|
||||
// schema bumps; the next refresh will repopulate the analytics.
|
||||
const defaults = getDefaultState()
|
||||
return {
|
||||
...defaults,
|
||||
scanState: {
|
||||
...defaults.scanState,
|
||||
enabled: parsed.scanState?.enabled ?? defaults.scanState.enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
...getDefaultState(),
|
||||
...parsed,
|
||||
scanState: {
|
||||
...getDefaultState().scanState,
|
||||
...parsed.scanState
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Why: Claude usage is a local analytics feature, not primary workspace
|
||||
// state. A corrupt cache should degrade to a fresh rebuild instead of
|
||||
// preventing Orca from booting, but we leave the file on disk for debugging.
|
||||
console.error('[claude-usage] Failed to load persisted state, starting fresh:', error)
|
||||
return getDefaultState()
|
||||
}
|
||||
}
|
||||
|
||||
private writeToDisk(): Promise<void> {
|
||||
// Pretty-print preserved: humans inspect this analytics cache on disk.
|
||||
return this.writer.write(() => JSON.stringify(this.state, null, 2))
|
||||
}
|
||||
|
||||
/** Await queued cache writes so quit does not drop the final snapshot. */
|
||||
flush(): Promise<void> {
|
||||
return this.writer.flush()
|
||||
}
|
||||
|
||||
async setEnabled(enabled: boolean): Promise<ClaudeUsageScanState> {
|
||||
this.state.scanState.enabled = enabled
|
||||
await this.writeToDisk()
|
||||
return this.getScanState()
|
||||
}
|
||||
|
||||
getScanState(): ClaudeUsageScanState {
|
||||
return {
|
||||
...this.state.scanState,
|
||||
isScanning: this.scanPromise !== null,
|
||||
hasAnyClaudeData: this.state.sessions.length > 0 || this.state.dailyAggregates.length > 0
|
||||
}
|
||||
export class ClaudeUsageStore extends UsageProviderStoreLifecycle<
|
||||
'processedFiles',
|
||||
ClaudeUsagePersistedState,
|
||||
'hasAnyClaudeData'
|
||||
> {
|
||||
constructor(store: Pick<Store, 'getRepos' | 'getAllWorktreeMeta'>) {
|
||||
super(store, {
|
||||
logTag: '[claude-usage]',
|
||||
resolveCacheFile: getClaudeUsageFile,
|
||||
createDefaultState: getDefaultState,
|
||||
normalizeState: normalizePersistedState,
|
||||
sourceKey: 'processedFiles',
|
||||
dataPresenceKey: 'hasAnyClaudeData',
|
||||
jsonIndent: 2,
|
||||
scan: scanClaudeUsageFiles
|
||||
})
|
||||
}
|
||||
|
||||
getSnapshot(
|
||||
@@ -419,63 +332,6 @@ export class ClaudeUsageStore {
|
||||
}
|
||||
}
|
||||
|
||||
async refresh(force = false): Promise<ClaudeUsageScanState> {
|
||||
if (!this.state.scanState.enabled) {
|
||||
return this.getScanState()
|
||||
}
|
||||
const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint()
|
||||
if (!force && this.state.scanState.lastScanCompletedAt) {
|
||||
const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt
|
||||
if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) {
|
||||
return this.getScanState()
|
||||
}
|
||||
}
|
||||
await this.runScan()
|
||||
return this.getScanState()
|
||||
}
|
||||
|
||||
private async runScan(): Promise<void> {
|
||||
if (this.scanPromise) {
|
||||
await this.scanPromise
|
||||
return
|
||||
}
|
||||
|
||||
this.state.scanState.lastScanStartedAt = Date.now()
|
||||
this.state.scanState.lastScanError = null
|
||||
|
||||
// Why no write here: persisting scan-start would rewrite the whole multi-MB cache before a single
|
||||
// result changed. The completion/failure write below persists the same fields.
|
||||
|
||||
// Why: assign scanPromise before any await so concurrent refresh shares one scan.
|
||||
this.scanPromise = (async () => {
|
||||
try {
|
||||
const repos = this.store.getRepos()
|
||||
const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos)
|
||||
const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo)
|
||||
const result = await scanClaudeUsageFiles(
|
||||
createWorktreeRefs(repos, worktreesByRepo),
|
||||
this.state.worktreeFingerprint === worktreeFingerprint ? this.state.processedFiles : []
|
||||
)
|
||||
this.state.processedFiles = result.processedFiles
|
||||
this.state.sessions = result.sessions
|
||||
this.state.dailyAggregates = result.dailyAggregates
|
||||
this.state.worktreeFingerprint = worktreeFingerprint
|
||||
this.state.scanState.lastScanCompletedAt = Date.now()
|
||||
this.state.scanState.lastScanError = null
|
||||
// Why swallow: persistence is a cache concern. A disk failure must not turn a good scan into
|
||||
// a scan error and reject refresh() for every query caller; writeToDisk already logs it.
|
||||
await this.writeToDisk().catch(() => {})
|
||||
} catch (error) {
|
||||
this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error)
|
||||
await this.writeToDisk().catch(() => {})
|
||||
} finally {
|
||||
this.scanPromise = null
|
||||
}
|
||||
})()
|
||||
|
||||
await this.scanPromise
|
||||
}
|
||||
|
||||
async getSummary(scope: ClaudeUsageScope, range: ClaudeUsageRange): Promise<ClaudeUsageSummary> {
|
||||
await this.refresh(false)
|
||||
return this.buildSummary(scope, range)
|
||||
@@ -843,7 +699,7 @@ export class ClaudeUsageStore {
|
||||
}
|
||||
|
||||
private getFilteredDaily(scope: ClaudeUsageScope, range: ClaudeUsageRange) {
|
||||
const cutoff = getRangeCutoff(range)
|
||||
const cutoff = getUsageRangeCutoff(range)
|
||||
return this.state.dailyAggregates.filter((entry) => {
|
||||
if (cutoff && entry.day < cutoff) {
|
||||
return false
|
||||
@@ -856,12 +712,12 @@ export class ClaudeUsageStore {
|
||||
}
|
||||
|
||||
private getFilteredSessions(scope: ClaudeUsageScope, range: ClaudeUsageRange) {
|
||||
const cutoff = getRangeCutoff(range)
|
||||
const cutoff = getUsageRangeCutoff(range)
|
||||
return this.state.sessions.filter((session) => {
|
||||
// Why: daily aggregates use local calendar days, so session filtering has
|
||||
// to use the same conversion or the sessions table/counts can disagree
|
||||
// with the chart around UTC day boundaries.
|
||||
const day = getLocalDay(session.lastTimestamp)
|
||||
const day = getLocalUsageDay(session.lastTimestamp)
|
||||
if (!day) {
|
||||
return false
|
||||
}
|
||||
@@ -883,10 +739,4 @@ export class ClaudeUsageStore {
|
||||
Boolean(lastScanError) || lastScanCompletedAt === null || lastScanCompletedAt < completedAt
|
||||
)
|
||||
}
|
||||
|
||||
private async getCurrentWorktreeFingerprint(): Promise<string> {
|
||||
const repos = this.store.getRepos()
|
||||
const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos)
|
||||
return getWorktreeFingerprint(worktreesByRepo)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
writeHooksJsonRemote,
|
||||
writeManagedScriptRemote
|
||||
} from '../agent-hooks/installer-utils-remote'
|
||||
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
|
||||
import {
|
||||
buildPosixHookPayloadCapture,
|
||||
buildWindowsHookEnvironmentGuardLines,
|
||||
@@ -171,6 +172,18 @@ export class ClaudeHookService {
|
||||
return { agent: this.options.agent, state, configPath, managedHooksPresent, detail }
|
||||
}
|
||||
|
||||
async refreshManagedScripts(): Promise<void> {
|
||||
await refreshManagedScriptIfPresent(
|
||||
getManagedScriptPath(this.options.settings),
|
||||
getManagedScript('local', { skipWhenDevinImportsClaude: this.options.agent === 'claude' })
|
||||
)
|
||||
// Why: no agent gate — the statusline script only ever exists for claude, so presence is the gate.
|
||||
await refreshManagedScriptIfPresent(
|
||||
getStatusLineScriptPath(this.options.settings),
|
||||
getManagedStatusLineScript('local')
|
||||
)
|
||||
}
|
||||
|
||||
install(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath(this.options.settings)
|
||||
const scriptPath = getManagedScriptPath(this.options.settings)
|
||||
|
||||
@@ -489,7 +489,7 @@ describe('CliInstaller', () => {
|
||||
const registryReader = new WindowsUserPathRegistryReader({
|
||||
platform: 'win32',
|
||||
registryLoader: async () => ({
|
||||
HK: { CU: 0x80000001 },
|
||||
HK: { CU: 0x80000001, LM: 0x80000002 },
|
||||
getRegistryKey: () => ({
|
||||
Path: { name: 'Path', type: 2, value: registryPath }
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
|
||||
function registryModule(pathValue?: string, type = 2) {
|
||||
return {
|
||||
HK: { CU: 0x80000001 },
|
||||
HK: { CU: 0x80000001, LM: 0x80000002 },
|
||||
REG: { SZ: 1, EXPAND_SZ: 2 },
|
||||
getRegistryKey: vi.fn(() =>
|
||||
pathValue === undefined ? {} : { Path: { name: 'Path', type, value: pathValue } }
|
||||
|
||||
@@ -1,26 +1,17 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import {
|
||||
loadWindowsNativeRegistry,
|
||||
WINDOWS_REG_EXPAND_SZ,
|
||||
WINDOWS_REG_SZ,
|
||||
type WindowsNativeRegistryModule
|
||||
} from '../windows-native-registry'
|
||||
|
||||
export type WindowsUserPathReadResult =
|
||||
| { state: 'success'; value: string | null; expandable: boolean }
|
||||
| { state: 'unknown'; detail: string }
|
||||
|
||||
type RegistryValue = {
|
||||
name?: unknown
|
||||
type?: unknown
|
||||
value?: unknown
|
||||
}
|
||||
|
||||
type WindowsRegistryModule = {
|
||||
HK: { CU: number }
|
||||
getRegistryKey: (
|
||||
root: number,
|
||||
path: string
|
||||
) => Record<string, RegistryValue | undefined> | null | undefined
|
||||
}
|
||||
|
||||
type WindowsUserPathRegistryReaderOptions = {
|
||||
platform?: NodeJS.Platform
|
||||
registryLoader?: () => Promise<WindowsRegistryModule>
|
||||
registryLoader?: () => Promise<WindowsNativeRegistryModule>
|
||||
now?: () => number
|
||||
cacheTtlMs?: number
|
||||
}
|
||||
@@ -28,19 +19,9 @@ type WindowsUserPathRegistryReaderOptions = {
|
||||
const DEFAULT_CACHE_TTL_MS = 1_000
|
||||
const USER_ENVIRONMENT_KEY = 'Environment'
|
||||
const USER_PATH_VALUE = 'Path'
|
||||
const REG_SZ = 1
|
||||
const REG_EXPAND_SZ = 2
|
||||
const requireFromMain = createRequire(__filename)
|
||||
|
||||
async function loadWindowsRegistryModule(): Promise<WindowsRegistryModule> {
|
||||
// Why: the optional dependency is not present in non-Windows installs, so
|
||||
// TypeScript and the main bundle must not resolve it eagerly.
|
||||
return requireFromMain('windows-native-registry') as WindowsRegistryModule
|
||||
}
|
||||
|
||||
export class WindowsUserPathRegistryReader {
|
||||
private readonly platform: NodeJS.Platform
|
||||
private readonly registryLoader: () => Promise<WindowsRegistryModule>
|
||||
private readonly registryLoader: () => Promise<WindowsNativeRegistryModule>
|
||||
private readonly now: () => number
|
||||
private readonly cacheTtlMs: number
|
||||
private cached: { readAt: number; result: WindowsUserPathReadResult } | null = null
|
||||
@@ -49,7 +30,7 @@ export class WindowsUserPathRegistryReader {
|
||||
|
||||
constructor(options: WindowsUserPathRegistryReaderOptions = {}) {
|
||||
this.platform = options.platform ?? process.platform
|
||||
this.registryLoader = options.registryLoader ?? loadWindowsRegistryModule
|
||||
this.registryLoader = options.registryLoader ?? (async () => loadWindowsNativeRegistry())
|
||||
this.now = options.now ?? Date.now
|
||||
this.cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS
|
||||
}
|
||||
@@ -123,7 +104,7 @@ export class WindowsUserPathRegistryReader {
|
||||
return { state: 'success', value: null, expandable: false }
|
||||
}
|
||||
if (
|
||||
(pathEntry.type !== REG_SZ && pathEntry.type !== REG_EXPAND_SZ) ||
|
||||
(pathEntry.type !== WINDOWS_REG_SZ && pathEntry.type !== WINDOWS_REG_EXPAND_SZ) ||
|
||||
typeof pathEntry.value !== 'string'
|
||||
) {
|
||||
return {
|
||||
@@ -134,7 +115,7 @@ export class WindowsUserPathRegistryReader {
|
||||
return {
|
||||
state: 'success',
|
||||
value: pathEntry.value || null,
|
||||
expandable: pathEntry.type === REG_EXPAND_SZ
|
||||
expandable: pathEntry.type === WINDOWS_REG_EXPAND_SZ
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user