From 85d7cf3cc1e3737a7c00db2f3847ef8dc41daa83 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:23:13 -0400 Subject: [PATCH 01/34] fix(mobile): preserve delivery ambiguity across transport cutover (#20280) * fix(mobile): preserve delivery ambiguity across transport cutover Let physical close settle requests and retain its error as the cutover cause, copying only an existing delivery-unknown mark. Pin sent and unsent caller outcomes and both cutover predicate carriers. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): pin the RpcClient.close() settlement contract close() was declared `() => void` with no stated obligation. That was harmless while migrateTo rejected pendings itself; now that it does not, close() is the retiring generation's only settlement path, so a type-compatible implementation that leaves a request pending strands its caller for good. States the obligation on the declaration and pins it for both trackers the real implementations reject through. Dropping the delivery-unknown flag, dropping the relay mark, or leaving pendings in the map each fail a test. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the cutover cause without a type assertion main's new casting gate rejects `(error as Error).cause`; narrow instead so the assertion still distinguishes a missing cause from an unmarked one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../rpc-client-delivery-ambiguity.test.ts | 53 ++++++++++++++++++- mobile/src/transport/rpc-client.ts | 13 +++++ .../stable-logical-rpc-client.test.ts | 1 + .../transport/stable-logical-rpc-client.ts | 29 +++++----- .../worktree/home-host-worktree-fetch.test.ts | 6 ++- 5 files changed, 84 insertions(+), 18 deletions(-) diff --git a/mobile/src/transport/rpc-client-delivery-ambiguity.test.ts b/mobile/src/transport/rpc-client-delivery-ambiguity.test.ts index 5cdaf75b945..29456c2eaa1 100644 --- a/mobile/src/transport/rpc-client-delivery-ambiguity.test.ts +++ b/mobile/src/transport/rpc-client-delivery-ambiguity.test.ts @@ -1,6 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { connect } from './rpc-client' import { isRpcDeliveryUnknown } from './rpc-delivery-ambiguity' +import { + createStableLogicalRpcClient, + isLogicalClientCutoverError, + LogicalClientCutoverError +} from './stable-logical-rpc-client' vi.mock('./e2ee', () => ({ generateKeyPair: () => ({ @@ -72,7 +77,7 @@ function hasSentRequest(socket: MockWebSocket, method: string): boolean { function connectAuthenticated(): { client: ReturnType; socket: MockWebSocket } { const client = connect('ws://desktop.invalid', 'token', 'server-key') - const socket = mockSockets[0]! + const socket = mockSockets[mockSockets.length - 1]! socket.open() socket.receive(JSON.stringify({ type: 'e2ee_ready' })) socket.receive('encrypted:{"type":"e2ee_authenticated"}') @@ -94,6 +99,52 @@ describe('mobile rpc-client delivery ambiguity marking', () => { globalThis.WebSocket = originalWebSocket }) + it.each([true, false])( + 'preserves physical delivery evidence at the cutover caller (sent=%s)', + async (sent) => { + const physical = sent + ? connectAuthenticated() + : { + client: connect('ws://desktop.invalid', 'token', 'server-key'), + socket: mockSockets[0]! + } + const client = createStableLogicalRpcClient(physical.client, 'lan') + const replacement = connectAuthenticated() + const requestError = client + .sendRequest('worktree.create', { name: 'new' }) + .catch((error: unknown) => error) + await Promise.resolve() + expect(hasSentRequest(physical.socket, 'worktree.create')).toBe(sent) + + await client.migrateTo(replacement.client, 'relay') + + const error = await requestError + expect(isLogicalClientCutoverError(error)).toBe(true) + expect(isRpcDeliveryUnknown(error)).toBe(sent) + expect(error).toBeInstanceOf(LogicalClientCutoverError) + expect(isRpcDeliveryUnknown(error instanceof Error ? error.cause : null)).toBe(sent) + expect(hasSentRequest(replacement.socket, 'worktree.create')).toBe(false) + expect( + physical.socket.sent.filter((payload) => payload.includes('worktree.create')) + ).toHaveLength(sent ? 1 : 0) + client.close() + } + ) + + it('recognizes a cutover by class even when its message changes', () => { + const error = new LogicalClientCutoverError() + error.message = 'wrapped migration' + expect(isLogicalClientCutoverError(error)).toBe(true) + }) + + it('recognizes a cutover message from another bundle copy', () => { + expect(isLogicalClientCutoverError(new Error('RPC interrupted by connection migration'))).toBe( + true + ) + expect(isLogicalClientCutoverError(new Error('Client closed'))).toBe(false) + expect(isLogicalClientCutoverError('RPC interrupted by connection migration')).toBe(false) + }) + it('marks in-flight requests as delivery-unknown when the socket drops', async () => { const { client, socket } = connectAuthenticated() const requestError = client.sendRequest('terminal.send', { terminal: 't' }).then( diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 38941483c4d..05fdbbac8fa 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -32,6 +32,19 @@ export type RpcClient = UnvalidatedRpcRequestPort & { getLastInboundAt?: () => number | null onStateChange: (listener: (state: ConnectionState) => void) => () => void notifyForeground: (reason?: ForegroundNudgeReason) => void + /** + * Must settle every pending `sendRequest` promise before returning. + * + * `StableLogicalRpcClient.migrateTo` no longer rejects pendings itself — the physical + * sender is the only layer that knows whether a request reached the wire, so + * `previous.close()` is the sole settlement path for the retiring generation. An + * implementation that leaves a request pending strands its caller for good. + * + * Requests that did reach the wire must reject with a delivery-unknown error + * (`markRpcDeliveryUnknown`), since the host may already have executed them. Pinned + * against the real clients in `rpc-client-delivery-ambiguity.test.ts` (direct) and + * `mobile-relay-rpc-session.test.ts` (relay) — a new implementation needs its own case. + */ close: () => void } diff --git a/mobile/src/transport/stable-logical-rpc-client.test.ts b/mobile/src/transport/stable-logical-rpc-client.test.ts index cbbf01ada2a..6a22b3654a2 100644 --- a/mobile/src/transport/stable-logical-rpc-client.test.ts +++ b/mobile/src/transport/stable-logical-rpc-client.test.ts @@ -90,6 +90,7 @@ describe('stable logical RPC client', () => { const nextSession = new FakeSession('connecting') const pending = deferred() oldSession.sendRequest.mockReturnValue(pending.promise) + oldSession.close.mockImplementation(() => pending.reject(new Error('Client closed'))) nextSession.sendRequest.mockResolvedValue(success('next')) const client = createStableLogicalRpcClient(oldSession, 'lan') const stream = vi.fn() diff --git a/mobile/src/transport/stable-logical-rpc-client.ts b/mobile/src/transport/stable-logical-rpc-client.ts index 3a896af6f5b..769481aa80c 100644 --- a/mobile/src/transport/stable-logical-rpc-client.ts +++ b/mobile/src/transport/stable-logical-rpc-client.ts @@ -7,12 +7,16 @@ import { import { waitForAuthenticated } from './replacement-session-authentication' import { projectMobileRpcRequestParams } from './mobile-rpc-request-projection' import { LogicalClientConnectionPath } from './logical-client-connection-path' +import { isRpcDeliveryUnknown, markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' export type MobileConnectionPath = 'lan' | 'tailscale' | 'relay' export class LogicalClientCutoverError extends Error { - constructor() { - super('RPC interrupted by connection migration') + constructor(cause?: unknown) { + super('RPC interrupted by connection migration', { cause }) + if (isRpcDeliveryUnknown(cause)) { + markRpcDeliveryUnknown(this) + } } } @@ -33,10 +37,6 @@ type SubscriptionRecord = { cancelled: boolean } -type PendingRequest = { - reject: (error: Error) => void -} - export type StableLogicalRpcClient = RpcClient & { migrateTo( session: RpcClient, @@ -75,7 +75,6 @@ export function createStableLogicalRpcClient( let nextSubscriptionId = 0 let activeStateUnsubscribe: (() => void) | null = null const subscriptions = new Map() - const pendingRequests = new Set() const stateListeners = new Set<(state: ConnectionState) => void>() let state = initialSession.getState() const connectionPath = new LogicalClientConnectionPath(() => state === 'connected') @@ -90,22 +89,23 @@ export function createStableLogicalRpcClient( if (suspended) { return Promise.reject(new Error('Client suspended')) } + const requestGeneration = generation const session = activeSession return new Promise((resolve, reject) => { - const pending = { reject } - pendingRequests.add(pending) void session .sendRequest(method, projectMobileRpcRequestParams(method, params), options) .then( (response) => { - pendingRequests.delete(pending) // A correlated response is definitive even if close/cutover won the // callback race after the physical promise had already settled. resolve(response) }, (error: unknown) => { - pendingRequests.delete(pending) - reject(error) + // Why: the retiring physical session settles this, so keep its error as the + // cause — it is the only evidence of whether the frame reached the wire. + reject( + requestGeneration !== generation ? new LogicalClientCutoverError(error) : error + ) } ) }) @@ -260,15 +260,12 @@ export function createStableLogicalRpcClient( suspended = false previousStateUnsubscribe?.() bindActiveState(nextSession, nextGeneration) - for (const pending of pendingRequests) { - pending.reject(new LogicalClientCutoverError()) - } - pendingRequests.clear() state = nextSession.getState() connectionPath.clearAfterConnected() for (const listener of stateListeners) { listener(state) } + // Only the physical sender knows whether a pending request reached the wire. previous.close() }, diff --git a/mobile/src/worktree/home-host-worktree-fetch.test.ts b/mobile/src/worktree/home-host-worktree-fetch.test.ts index 5799de96366..6238554769a 100644 --- a/mobile/src/worktree/home-host-worktree-fetch.test.ts +++ b/mobile/src/worktree/home-host-worktree-fetch.test.ts @@ -39,7 +39,11 @@ function fakeSession(): FakeSession { getLastConnectedAt: () => null, onStateChange: () => () => {}, notifyForeground: () => {}, - close: () => {} + close: () => { + for (const settle of pending.splice(0)) { + settle(new Error('Client closed')) + } + } } } return fake From 22f56f7c2a5aa11812fc0bfec4af02a475205531 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:23:16 -0400 Subject: [PATCH 02/34] fix(runtime): reject malformed file Base64 padding (#20283) Require padded file-write payloads to end on a Base64 quartet boundary. Cover both RPC methods and padded final upload chunks, and document client compatibility evidence. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- docs/reference/runtime-file-base64-padding.md | 92 +++++++++++++++++++ .../rpc/methods/files-base64-padding.test.ts | 58 ++++++++++++ ...untime-file-client-external-import.test.ts | 2 +- .../rpc-contract/files-mutation-params.ts | 5 +- 4 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 docs/reference/runtime-file-base64-padding.md create mode 100644 src/main/runtime/rpc/methods/files-base64-padding.test.ts diff --git a/docs/reference/runtime-file-base64-padding.md b/docs/reference/runtime-file-base64-padding.md new file mode 100644 index 00000000000..3715855eaa0 --- /dev/null +++ b/docs/reference/runtime-file-base64-padding.md @@ -0,0 +1,92 @@ +# Runtime file Base64 padding + +Padded runtime file writes must have a length divisible by four. Empty strings and +unpadded Base64 with length modulo four equal to zero, two, or three remain valid. +The change rejects exactly the previously accepted strings containing trailing +padding whose total length modulo four is two or three. It does not enforce +canonical unused pad bits or change the alphabet. + +## Boundary evidence + +| Input | Before | After | +| ------------------------- | ------ | ------ | +| `A=` | Accept | Reject | +| `AA==` | Accept | Accept | +| `AAA=` | Accept | Accept | +| `AAAA` | Accept | Accept | +| `''` | Accept | Accept | +| `A` | Reject | Reject | +| `==` | Accept | Reject | +| `AA=A` (interior padding) | Reject | Reject | +| `AA=` | Accept | Reject | +| `A==` | Accept | Reject | +| `AAAA==` | Accept | Reject | +| `AA`, `AAA` (unpadded) | Accept | Accept | + +`Buffer.from('A=', 'base64')` decodes to zero bytes. Rejecting malformed padding at +the RPC boundary prevents an accepted request from silently writing different bytes. + +## Caller census + +| Caller / surface | Reachability and compatibility verdict | +| ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Desktop `runtime-file-import-client.ts` → `uploadRuntimeFileWithoutClobber` → `writeRuntimeBase64File` | The only production producer of `files.writeBase64` and `files.writeBase64Chunk`. Staging in `filesystem-runtime-upload-staging.ts` encodes the complete file with `buffer.toString('base64')`; newly rejected values cannot be produced. | +| Desktop single-frame uploads | Sends the staged string unchanged when its length is at most 512 × 1024 characters. Standard Node Base64 always has length divisible by four. Empty files remain accepted. | +| Desktop chunked uploads | Slices the encoded stream at 512 × 1024 = 524,288 characters, divisible by four. Every offset and every complete chunk is quartet-aligned. The final chunk is the difference of two multiples of four, including when it ends in `=` or `==`. No separately assembled final chunk or per-chunk padding is added. | +| Web implementation of `stageExternalPathsForRuntimeUpload` | Returns an empty source list; no file-write payload is produced. | +| Mobile file editor | Uses `files.writeTerminalArtifact` with text content and its separate schema. Does not reach this predicate. | +| Mobile clipboard / image attachments | Uses `clipboard.startImageUpload`, `clipboard.appendImageUploadChunk`, `clipboard.commitImageUpload`, and the `clipboard.saveImageAsTempFile` fallback. Their validator is `isValidBase64` in `clipboard-params.ts`, not `isValidRuntimeFileBase64`. Unchanged, including the mobile normalizer's existing permissive padding behavior. | +| CLI | No producer of either Base64 file-write method. File commands in `src/cli/handlers/file.ts` call `files.open` / `files.openDiff`; other CLI RPC call sites do not construct Base64 file writes. | +| Generated params catalog | References both schemas; `RpcParams` consumers use inferred types. Mobile's entry point is `export type` only, so no new client-side parsing occurs. | +| RPC dispatch | `files-mutation-methods.ts` registers both schemas. The chunk schema extends the whole-file schema; these are the only runtime consumers of the predicate. Direct runtime/provider calls do not parse these schemas. | + +Repository-wide searches covered method names, schema names, the predicate and its +pattern, and all callers of the upload/staging functions. Targeted history search +on `HEAD` under `mobile/src` found no introduction/removal of the affected methods +or predicate. The local release refs `mobile-ios-v0.0.27` and `mobile-v0.0.13` also +contain no callers of either Base64 file-write method; the iOS ref uses the separate +clipboard and terminal-artifact methods above. No shipped mobile producer of a +newly rejected value was found in this source/history audit. + +## Remote and workspace compatibility + +Old desktop clients using the audited producer send valid quartets to a new host. +A new client still sends the same bytes to an old host. No method, field, opcode, +or host-published content changes. This follows the mixed-version requirements in +[remote-wire-compatibility.md](./remote-wire-compatibility.md). + +The RPC validation runs before workspace resolution and provider selection, so the +same rule applies to folder workspaces, git worktrees, local hosts, and SSH hosts. +SSH ownership fences and provider writes are unchanged. Arbitrary external RPC +callers sending malformed padding will now receive a validation error; valid +padded and unpadded payloads remain accepted. + +## Regression evidence + +`src/main/runtime/rpc/methods/files-base64-padding.test.ts` exercises both actual RPC +registrations, asserts rejected input never reaches the writer, and verifies +accepted content is forwarded unchanged. With the original predicate, the test +run produced **16 failed / 16 passed**; all 16 failures were newly rejected padding +shapes accepted by the old implementation. This was run before editing the predicate. + +The existing desktop external-import test now uses a final `AA==` chunk after a +524,288-character first chunk, pinning padded final-chunk forwarding in the real +upload path. No producer changes or clipboard validation changes were necessary. + +## Validation results + +All test/typecheck commands used `ORCA_BACKGROUND_LAUNCH=1`. + +- `pnpm tc`: exit 0; all root typecheck projects passed. +- `pnpm exec vitest run src/main/runtime/rpc`: 277 files passed, one failed; + 2,419 tests passed, two timed out, one skipped. Both timeouts were in the unchanged + `terminal-output-frame-chunks-equivalence.test.ts` (5s surrogate-range test and + 30s 800-payload fuzz test). +- `pnpm --dir mobile typecheck`: exit 0 (`tsc --noEmit`). +- `pnpm run check:code-quality:changed`: exit 0; code quality, type-aware code + quality, and React Doctor each reported zero new findings across three source files. +- Focused run with `--config config/vitest.config.ts --maxWorkers=2`: all three + files / 58 tests passed, covering padding, desktop external imports, and the + terminal-output equivalence file that timed out in the initial run. +- Full RPC rerun with `--maxWorkers=2`: exit 0; all 278 files passed, + 2,421 tests passed and one skipped (198.34s). No timeout overrides were needed. diff --git a/src/main/runtime/rpc/methods/files-base64-padding.test.ts b/src/main/runtime/rpc/methods/files-base64-padding.test.ts new file mode 100644 index 00000000000..03f3b73e786 --- /dev/null +++ b/src/main/runtime/rpc/methods/files-base64-padding.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { RpcDispatcher } from '../dispatcher' +import { FILE_MUTATION_METHODS } from './files-mutation-methods' + +describe.each([ + ['files.writeBase64', 'writeFileExplorerFileBase64'], + ['files.writeBase64Chunk', 'writeFileExplorerFileBase64Chunk'] +] as const)('%s base64 padding', (method, runtimeMethod) => { + it.each([ + ['A=', false], + ['AA=', false], + ['A==', false], + ['==', false], + ['AAAAA=', false], + ['AAAAAA=', false], + ['AAAAA==', false], + ['AAAA==', false], + ['AA==', true], + ['AAA=', true], + ['AAAA', true], + ['', true], + ['A', false], + ['AA=A', false], + ['AA', true], + ['AAA', true] + ])('validates %j before writing (accepted: %s)', async (contentBase64, accepted) => { + const write = vi.fn().mockResolvedValue({ ok: true }) + const runtime = { + getRuntimeId: () => 'test-runtime', + [runtimeMethod]: write + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_MUTATION_METHODS }) + + const response = await dispatcher.dispatch({ + id: 'padding', + authToken: 'tok', + method, + params: { + worktree: 'id:wt-1', + relativePath: 'upload.bin', + contentBase64, + append: true + } + }) + + expect(response).toMatchObject({ ok: accepted }) + expect(write).toHaveBeenCalledTimes(accepted ? 1 : 0) + if (accepted) { + expect(write).toHaveBeenCalledWith( + 'id:wt-1', + 'upload.bin', + contentBase64, + ...(method === 'files.writeBase64Chunk' ? [true] : []) + ) + } + }) +}) diff --git a/src/renderer/src/runtime/runtime-file-client-external-import.test.ts b/src/renderer/src/runtime/runtime-file-client-external-import.test.ts index ff8bd580d1f..53e6b45e1da 100644 --- a/src/renderer/src/runtime/runtime-file-client-external-import.test.ts +++ b/src/renderer/src/runtime/runtime-file-client-external-import.test.ts @@ -182,7 +182,7 @@ describe('runtime file client', () => { it('chunks large staged runtime uploads below the WebSocket frame budget', async () => { const firstChunk = 'A'.repeat(512 * 1024) - const secondChunk = 'BBBBBBBB' + const secondChunk = 'AA==' fsStageExternalPathsForRuntimeUpload.mockResolvedValue({ sources: [ { diff --git a/src/shared/rpc-contract/files-mutation-params.ts b/src/shared/rpc-contract/files-mutation-params.ts index 554d1f387e0..a2fbc120b20 100644 --- a/src/shared/rpc-contract/files-mutation-params.ts +++ b/src/shared/rpc-contract/files-mutation-params.ts @@ -5,7 +5,10 @@ export const RUNTIME_FILE_BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/ export function isValidRuntimeFileBase64(value: unknown): value is string { return ( - typeof value === 'string' && value.length % 4 !== 1 && RUNTIME_FILE_BASE64_PATTERN.test(value) + typeof value === 'string' && + value.length % 4 !== 1 && + (!value.includes('=') || value.length % 4 === 0) && + RUNTIME_FILE_BASE64_PATTERN.test(value) ) } From 149164b74f760ba86d8a7576a619951d7a35f1f0 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 13 Sep 2026 15:43:12 -0700 Subject: [PATCH 03/34] fix(tasks): preserve repository results under GitHub search quota (#20460) * fix(tasks): preserve repository results under GitHub search quota * fix(github): preserve search budget on count fallback --------- Co-authored-by: m4air --- .../__fixtures__/work-item-search-api.ts | 214 +++++++++++++++ .../work-item-search-metadata.json | 248 ++++++++++++++++++ src/main/github/client-issue-source.test.ts | 3 + .../client-work-items-query-paging.test.ts | 3 + src/main/github/client-work-items.test.ts | 3 + .../github/client/fetch/work-item-fetch.ts | 6 +- .../github/client/list/count-work-items.ts | 24 +- .../github/client/list/list-work-items.ts | 3 +- .../client/list/work-item-issue-page.ts | 138 ++++++++++ .../github/client/list/work-item-pages.ts | 70 ++--- .../client/list/work-item-search-batch.ts | 194 ++++++++++++++ .../client/list/work-item-search-page.ts | 128 +++++++++ src/main/github/github-api-repository.ts | 5 +- ...k-item-search-fallback-environment.test.ts | 86 ++++++ .../github/work-item-search-freshness.test.ts | 32 +++ .../github/work-item-search-isolation.test.ts | 140 ++++++++++ .../work-item-search-pagination.test.ts | 71 +++++ .../github/work-item-search-semantics.test.ts | 114 ++++++++ .../github/work-item-search-test-harness.ts | 78 ++++++ ...github-work-item-search-burst.unit.test.ts | 87 ++++++ 20 files changed, 1598 insertions(+), 49 deletions(-) create mode 100644 src/main/github/__fixtures__/work-item-search-api.ts create mode 100644 src/main/github/__fixtures__/work-item-search-metadata.json create mode 100644 src/main/github/client/list/work-item-issue-page.ts create mode 100644 src/main/github/client/list/work-item-search-batch.ts create mode 100644 src/main/github/client/list/work-item-search-page.ts create mode 100644 src/main/github/work-item-search-fallback-environment.test.ts create mode 100644 src/main/github/work-item-search-freshness.test.ts create mode 100644 src/main/github/work-item-search-isolation.test.ts create mode 100644 src/main/github/work-item-search-pagination.test.ts create mode 100644 src/main/github/work-item-search-semantics.test.ts create mode 100644 src/main/github/work-item-search-test-harness.ts create mode 100644 tests/e2e/github-work-item-search-burst.unit.test.ts diff --git a/src/main/github/__fixtures__/work-item-search-api.ts b/src/main/github/__fixtures__/work-item-search-api.ts new file mode 100644 index 00000000000..e48a5818cb9 --- /dev/null +++ b/src/main/github/__fixtures__/work-item-search-api.ts @@ -0,0 +1,214 @@ +import { z } from 'zod' +type Captured = { args: string[]; cwd?: string; fixtureCredential?: string } +type Issue = Record +export class WorkItemSearchApi { + calls: Captured[] = [] + restSearches = 0 + graphqlCalls = 0 + graphqlFields = 0 + restDetails = 0 + rejected = 0 + graphqlAvailable = true + searchAvailable = true + rowsPerRepo = 120 + specialNodes: Issue[] | undefined + aliasErrorRepo: string | undefined + expectedSearch: string | undefined + reportedCount: number | undefined + private nextCursor = 0 + private cursors = new Map() + private cache = new Map() + + private rows(query: string): Issue[] { + if (this.expectedSearch && query.replace(/ sort:created-desc$/, '') !== this.expectedSearch) { + throw new Error(`Unexpected fixture search ${query}`) + } + const repo = /repo:([^\s]+)/.exec(query)?.[1] ?? 'unknown/repo' + return ( + this.specialNodes ?? + Array.from({ length: this.rowsPerRepo }, (_, index) => ({ + __typename: 'Issue', + number: 10000 - index, + title: `${repo} issue ${index}`, + state: 'OPEN', + url: `https://github.com/${repo}/issues/${10000 - index}`, + updatedAt: '2026-09-11T00:00:00Z', + author: { + __typename: 'User', + login: 'author', + avatarUrl: 'https://avatars.githubusercontent.com/u/42?u=profile&v=4' + }, + labels: { nodes: [{ name: 'bug' }], pageInfo: { hasNextPage: false } }, + assignees: { nodes: [], pageInfo: { hasNextPage: false } } + })) + ) + } + + async capture( + _binary: string, + args: string[], + options: { cwd?: string; env?: NodeJS.ProcessEnv } + ): Promise<{ stdout: string; stderr: string }> { + const credential = options.env?.GH_TOKEN + this.calls.push({ + args: [...args], + cwd: options.cwd, + fixtureCredential: credential?.startsWith('fixture-') ? credential : undefined + }) + if (args.includes('rate_limit')) { + const bucket = { limit: 5000, remaining: 4500, reset: 3600 } + return { + stdout: JSON.stringify({ + resources: { + core: bucket, + graphql: { ...bucket, remaining: this.graphqlAvailable ? 4500 : 0 }, + search: { + limit: 30, + remaining: this.searchAvailable ? Math.max(0, 30 - this.restSearches) : 0, + reset: 60 + } + } + }), + stderr: '' + } + } + if (args[0] === 'pr') { + return { stdout: '[]', stderr: '' } + } + const endpoint = args.find((arg) => arg.startsWith('search/issues?')) + if (endpoint) { + const cached = args.includes('--cache') + ? this.cache.get(JSON.stringify([options.cwd, args])) + : undefined + if (cached !== undefined) { + return { stdout: cached, stderr: '' } + } + this.restSearches++ + if (!this.searchAvailable || this.restSearches > 30) { + this.rejected++ + throw Object.assign(new Error('HTTP 403: API rate limit exceeded'), { + stderr: 'HTTP 403: API rate limit exceeded' + }) + } + const url = new URL(endpoint, 'https://api.github.com') + const query = url.searchParams.get('q') ?? '' + const rows = this.rows(query) + const limit = Number(url.searchParams.get('per_page') ?? 1) + const page = Number(url.searchParams.get('page') ?? 1) + if (page * limit > 1000) { + throw Object.assign( + new Error('Only the first 1000 search results are available (HTTP 422)'), + { stderr: 'Only the first 1000 search results are available (HTTP 422)' } + ) + } + const stdout = args.includes('.total_count') + ? String(this.reportedCount ?? rows.length) + : JSON.stringify(rows.slice((page - 1) * limit, page * limit).map(this.restIssue)) + if (args.includes('--cache')) { + this.cache.set(JSON.stringify([options.cwd, args]), stdout) + } + return { stdout, stderr: '' } + } + const detail = args.find((arg) => /^repos\/.+\/issues\/\d+$/.test(arg)) + if (detail) { + this.restDetails++ + const row = this.rows('repo:fixture/repo').find( + (row) => row.number === Number(detail.split('/').at(-1)) + ) + return { + stdout: JSON.stringify({ + ...this.restIssue(row!), + labels: Array.from({ length: 125 }, (_, index) => ({ name: `label-${index}` })) + }), + stderr: '' + } + } + if (!args.includes('graphql')) { + throw new Error(`Unexpected fixture request ${args.join(' ')}`) + } + this.graphqlCalls++ + if (!this.graphqlAvailable) { + throw Object.assign(new Error('HTTP 403: API rate limit exceeded'), { + stderr: 'HTTP 403: API rate limit exceeded' + }) + } + const query = args.find((arg) => arg.startsWith('query='))?.slice(6) ?? '' + const fields = [ + ...query.matchAll( + /(r\d+): search\(type: ISSUE, query: ("(?:[^"\\]|\\.)*"), first: (\d+)(?:, after: ("(?:[^"\\]|\\.)*"))?\)/g + ) + ] + if (!fields.length) { + throw new Error(`Unexpected GraphQL fixture query ${query}`) + } + const data: Record = { rateLimit: { cost: 1 } } + const errors: unknown[] = [] + for (let index = 0; index < fields.length; index++) { + const field = fields[index] + this.graphqlFields++ + const search = z.string().parse(JSON.parse(field[2])) + if (this.aliasErrorRepo && search.includes(`repo:${this.aliasErrorRepo} `)) { + data[field[1]] = null + errors.push({ message: 'fixture repository search unavailable', path: [field[1]] }) + continue + } + const first = Number(field[3]) + const cursor = field[4] ? z.string().parse(JSON.parse(field[4])) : undefined + const saved = cursor ? this.cursors.get(cursor) : undefined + if (cursor && (!saved || saved.query !== search)) { + throw new Error('Unknown or cross-query opaque cursor') + } + const offset = saved?.offset ?? 0 + const rows = this.rows(search) + const page = rows.slice(offset, offset + first) + const next = offset + page.length + const endCursor = `opaque:${++this.nextCursor}:cursor` + this.cursors.set(endCursor, { query: search, offset: next }) + const selection = query.slice(field.index, fields[index + 1]?.index ?? query.length) + data[field[1]] = { + issueCount: this.reportedCount ?? rows.length, + pageInfo: { hasNextPage: next < rows.length, endCursor }, + ...(selection.includes(' nodes {') ? { nodes: page } : {}) + } + } + const stdout = JSON.stringify({ data, ...(errors.length ? { errors } : {}) }) + if (errors.length) { + throw Object.assign(new Error('GraphQL partial failure'), { + stdout, + stderr: 'GraphQL partial failure' + }) + } + return { stdout, stderr: '' } + } + + private restIssue(row: Issue): Issue { + const actor = (value: unknown) => { + const user = z + .object({ + __typename: z.string().optional(), + login: z.string(), + avatarUrl: z.string().optional() + }) + .nullable() + .parse(value) + return user + ? { + login: user.login + (user.__typename === 'Bot' ? '[bot]' : ''), + avatar_url: user.avatarUrl?.replace(/\?u=[^&]+&/, '?') + } + : null + } + return { + ...row, + state: String(row.state).toLowerCase(), + html_url: row.url, + updated_at: row.updatedAt, + user: actor(row.author), + labels: z.object({ nodes: z.array(z.unknown()) }).parse(row.labels).nodes, + assignees: z + .object({ nodes: z.array(z.unknown()) }) + .parse(row.assignees) + .nodes.map(actor) + } + } +} diff --git a/src/main/github/__fixtures__/work-item-search-metadata.json b/src/main/github/__fixtures__/work-item-search-metadata.json new file mode 100644 index 00000000000..d8fa785f853 --- /dev/null +++ b/src/main/github/__fixtures__/work-item-search-metadata.json @@ -0,0 +1,248 @@ +[ + { + "graphql": { + "number": 19933, + "title": "[Bug]: Browser element annotations cleared and overlay removed when scrolling the page (no reload)", + "state": "OPEN", + "url": "https://github.com/stablyai/orca/issues/19933", + "updatedAt": "2026-09-12T01:57:20Z", + "labels": { + "nodes": [ + { + "name": "bug" + }, + { + "name": "os:linux" + } + ], + "pageInfo": { + "hasNextPage": false + } + }, + "author": { + "__typename": "User", + "login": "peeraponw", + "avatarUrl": "https://avatars.githubusercontent.com/u/13129669?u=bf337820b1f6d507dfac4e48db7cf61e6c2c8b95&v=4" + }, + "assignees": { + "nodes": [ + { + "login": "AmethystLiang", + "avatarUrl": "https://avatars.githubusercontent.com/u/6427696?u=86a210ddf931a6a557a4664cc17a97dc02c9de36&v=4" + } + ], + "pageInfo": { + "hasNextPage": false + } + }, + "__typename": "Issue" + }, + "rest": { + "number": 19933, + "title": "[Bug]: Browser element annotations cleared and overlay removed when scrolling the page (no reload)", + "state": "open", + "html_url": "https://github.com/stablyai/orca/issues/19933", + "updated_at": "2026-09-12T01:57:20Z", + "labels": [ + { + "name": "bug" + }, + { + "name": "os:linux" + } + ], + "user": { + "login": "peeraponw", + "avatar_url": "https://avatars.githubusercontent.com/u/13129669?v=4" + }, + "assignees": [ + { + "login": "AmethystLiang", + "avatar_url": "https://avatars.githubusercontent.com/u/6427696?v=4" + } + ] + } + }, + { + "graphql": { + "number": 19932, + "title": "[Bug] Incorrect status after /usage", + "state": "OPEN", + "url": "https://github.com/stablyai/orca/issues/19932", + "updatedAt": "2026-09-10T21:44:41Z", + "labels": { + "nodes": [ + { + "name": "bug" + } + ], + "pageInfo": { + "hasNextPage": false + } + }, + "author": { + "__typename": "Bot", + "login": "orca-discord-issues", + "avatarUrl": "https://avatars.githubusercontent.com/u/127256420?v=4" + }, + "assignees": { + "nodes": [], + "pageInfo": { + "hasNextPage": false + } + }, + "__typename": "Issue" + }, + "rest": { + "number": 19932, + "title": "[Bug] Incorrect status after /usage", + "state": "open", + "html_url": "https://github.com/stablyai/orca/issues/19932", + "updated_at": "2026-09-10T21:44:41Z", + "labels": [ + { + "name": "bug" + } + ], + "user": { + "login": "orca-discord-issues[bot]", + "avatar_url": "https://avatars.githubusercontent.com/u/127256420?v=4" + }, + "assignees": [] + } + }, + { + "graphql": { + "number": 19926, + "title": "[Bug]: Copying assistant response adds visual-wrap line breaks when pasted into Google Chat (macOS)", + "state": "OPEN", + "url": "https://github.com/stablyai/orca/issues/19926", + "updatedAt": "2026-09-11T17:14:42Z", + "labels": { + "nodes": [], + "pageInfo": { + "hasNextPage": false + } + }, + "author": { + "__typename": "User", + "login": "JanPlessow", + "avatarUrl": "https://avatars.githubusercontent.com/u/202702070?u=6cdc81f81e27630db038e80eea4924c9ded7ddc2&v=4" + }, + "assignees": { + "nodes": [], + "pageInfo": { + "hasNextPage": false + } + }, + "__typename": "Issue" + }, + "rest": { + "number": 19926, + "title": "[Bug]: Copying assistant response adds visual-wrap line breaks when pasted into Google Chat (macOS)", + "state": "open", + "html_url": "https://github.com/stablyai/orca/issues/19926", + "updated_at": "2026-09-11T17:14:42Z", + "labels": [], + "user": { + "login": "JanPlessow", + "avatar_url": "https://avatars.githubusercontent.com/u/202702070?v=4" + }, + "assignees": [] + } + }, + { + "graphql": { + "number": 19919, + "title": "[Bug]: v1.4.199 Windows NSIS installer reports success but never installs Orca.exe (partial install)", + "state": "OPEN", + "url": "https://github.com/stablyai/orca/issues/19919", + "updatedAt": "2026-09-10T20:08:25Z", + "labels": { + "nodes": [], + "pageInfo": { + "hasNextPage": false + } + }, + "author": { + "__typename": "User", + "login": "Subdij", + "avatarUrl": "https://avatars.githubusercontent.com/u/105368200?u=d379763ab273d375d0cfff0215daf325bd59f4d6&v=4" + }, + "assignees": { + "nodes": [], + "pageInfo": { + "hasNextPage": false + } + }, + "__typename": "Issue" + }, + "rest": { + "number": 19919, + "title": "[Bug]: v1.4.199 Windows NSIS installer reports success but never installs Orca.exe (partial install)", + "state": "open", + "html_url": "https://github.com/stablyai/orca/issues/19919", + "updated_at": "2026-09-10T20:08:25Z", + "labels": [], + "user": { + "login": "Subdij", + "avatar_url": "https://avatars.githubusercontent.com/u/105368200?v=4" + }, + "assignees": [] + } + }, + { + "graphql": { + "number": 19918, + "title": "[Bug]: Svelte files do not get parsed or highlighted correctly", + "state": "OPEN", + "url": "https://github.com/stablyai/orca/issues/19918", + "updatedAt": "2026-09-10T20:06:53Z", + "labels": { + "nodes": [ + { + "name": "bug" + }, + { + "name": "os:macos" + } + ], + "pageInfo": { + "hasNextPage": false + } + }, + "author": { + "__typename": "User", + "login": "futuraprime", + "avatarUrl": "https://avatars.githubusercontent.com/u/181752?v=4" + }, + "assignees": { + "nodes": [], + "pageInfo": { + "hasNextPage": false + } + }, + "__typename": "Issue" + }, + "rest": { + "number": 19918, + "title": "[Bug]: Svelte files do not get parsed or highlighted correctly", + "state": "open", + "html_url": "https://github.com/stablyai/orca/issues/19918", + "updated_at": "2026-09-10T20:06:53Z", + "labels": [ + { + "name": "bug" + }, + { + "name": "os:macos" + } + ], + "user": { + "login": "futuraprime", + "avatar_url": "https://avatars.githubusercontent.com/u/181752?v=4" + }, + "assignees": [] + } + } +] diff --git a/src/main/github/client-issue-source.test.ts b/src/main/github/client-issue-source.test.ts index 17820ddd0f1..d9c593be3a0 100644 --- a/src/main/github/client-issue-source.test.ts +++ b/src/main/github/client-issue-source.test.ts @@ -2,6 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubApiRepositoryModule from './github-api-repository' import type * as GhUtils from './gh-utils' +// Keep legacy REST request/failure coverage; API-boundary suites exercise the GraphQL path. +vi.mock('./client/list/work-item-search-page', () => ({ usesGraphqlWorkItemSearch: () => false })) + const { execFileAsyncMock, ghExecFileAsyncMock, diff --git a/src/main/github/client-work-items-query-paging.test.ts b/src/main/github/client-work-items-query-paging.test.ts index 0e8b62bdfcf..6ff7ace0cc9 100644 --- a/src/main/github/client-work-items-query-paging.test.ts +++ b/src/main/github/client-work-items-query-paging.test.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubApiRepositoryModule from './github-api-repository' +// Keep legacy REST request/failure coverage; API-boundary suites exercise the GraphQL path. +vi.mock('./client/list/work-item-search-page', () => ({ usesGraphqlWorkItemSearch: () => false })) + const { execFileAsyncMock, ghExecFileAsyncMock, diff --git a/src/main/github/client-work-items.test.ts b/src/main/github/client-work-items.test.ts index f26ccfc12d9..642b5c2aa4e 100644 --- a/src/main/github/client-work-items.test.ts +++ b/src/main/github/client-work-items.test.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubApiRepositoryModule from './github-api-repository' +// Keep legacy REST request/failure coverage; API-boundary suites exercise the GraphQL path. +vi.mock('./client/list/work-item-search-page', () => ({ usesGraphqlWorkItemSearch: () => false })) + const { execFileAsyncMock, ghExecFileAsyncMock, diff --git a/src/main/github/client/fetch/work-item-fetch.ts b/src/main/github/client/fetch/work-item-fetch.ts index aa4dd0794a8..f0c227d05e7 100644 --- a/src/main/github/client/fetch/work-item-fetch.ts +++ b/src/main/github/client/fetch/work-item-fetch.ts @@ -22,11 +22,13 @@ export async function fetchIssueWorkItem( ownerRepo: GitHubApiRepository | null, number: number, connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} + localGitOptions: LocalGitExecOptions = {}, + environment?: NodeJS.ProcessEnv ): Promise { const ghOptions = { ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(ownerRepo) + ...githubHostExecOptions(ownerRepo), + ...(environment ? { env: environment } : {}) } if (ownerRepo) { const { stdout } = await ghExecFileAsync( diff --git a/src/main/github/client/list/count-work-items.ts b/src/main/github/client/list/count-work-items.ts index e742597685e..570220586f9 100644 --- a/src/main/github/client/list/count-work-items.ts +++ b/src/main/github/client/list/count-work-items.ts @@ -12,7 +12,8 @@ import { } from '../../gh-utils' import { githubHostExecOptions, - resolveIssueGitHubApiRepositorySource + resolveIssueGitHubApiRepositorySource, + type GitHubRepoExecOptions } from '../../github-api-repository' import { getRateLimit, @@ -23,6 +24,7 @@ import { import { sameOwnerRepo } from './../github-exec-scope' import { resolvePrWorkItemSource } from './work-item-list-request' import { buildSearchQueryString, defaultOpenWorkItemQuery } from './work-item-search-query' +import { searchWorkItemCount, usesGraphqlWorkItemSearch } from './work-item-search-page' export async function countWorkItemsForQuery( repoPath: string, ownerRepo: OwnerRepo, @@ -31,10 +33,23 @@ export async function countWorkItemsForQuery( localGitOptions: LocalGitExecOptions = {} ): Promise { const searchQ = buildSearchQueryString(ownerRepo, query) - const ghOptions = { + const ghOptions: GitHubRepoExecOptions = { ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), ...githubHostExecOptions(ownerRepo) } + if (usesGraphqlWorkItemSearch(ownerRepo, ghOptions)) { + ghOptions.env = { ...process.env } + try { + return await searchWorkItemCount(searchQ, ghOptions) + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw error + } + } + } + if (repositoryRateLimitGuard(ownerRepo, 'search', ghOptions).blocked) { + return 0 + } const { stdout } = await ghExecFileAsync( [ 'api', @@ -85,7 +100,10 @@ export async function countWorkItems( if (spendsSharedGitHubComQuota(ownerRepo, ghOptions)) { await getRateLimit() } - if (repositoryRateLimitGuard(ownerRepo, 'search', ghOptions).blocked) { + if ( + !usesGraphqlWorkItemSearch(ownerRepo, ghOptions) && + repositoryRateLimitGuard(ownerRepo, 'search', ghOptions).blocked + ) { return 0 } diff --git a/src/main/github/client/list/list-work-items.ts b/src/main/github/client/list/list-work-items.ts index e28037a0233..10192262f6a 100644 --- a/src/main/github/client/list/list-work-items.ts +++ b/src/main/github/client/list/list-work-items.ts @@ -58,7 +58,8 @@ export async function listWorkItems( limit, requestedPage, connectionId, - localGitOptions + localGitOptions, + noCache ) const errors = diff --git a/src/main/github/client/list/work-item-issue-page.ts b/src/main/github/client/list/work-item-issue-page.ts new file mode 100644 index 00000000000..ac1273887d2 --- /dev/null +++ b/src/main/github/client/list/work-item-issue-page.ts @@ -0,0 +1,138 @@ +import { z } from 'zod' +import type { ParsedTaskQuery } from '../../../../shared/task-query' +import { ghExecFileAsync, type LocalGitExecOptions, type OwnerRepo } from '../../gh-utils' +import { noteRepositoryRateLimitSpend } from '../../rate-limit' +import type { GitHubRepoExecOptions } from '../../github-api-repository' +import { fetchIssueWorkItem } from '../fetch/work-item-fetch' +import { mapIssueWorkItem } from '../map/work-item' +import type { MainWorkItem } from '../map/work-item-field-coercion' +import { buildWorkItemListRequest } from './work-item-list-request' +import { buildSearchQueryString } from './work-item-search-query' +import { searchWorkItemPage, usesGraphqlWorkItemSearch } from './work-item-search-page' + +type Actor = { __typename?: string; login: string; avatarUrl?: string } +type IssueNode = { + __typename: string + number: number + title: string + state: string + url: string + updatedAt: string + author: Actor | null + labels: { nodes: { name: string }[]; pageInfo: { hasNextPage: boolean } } + assignees: { nodes: Actor[]; pageInfo: { hasNextPage: boolean } } +} +const ISSUE_NODE_SELECTION = `__typename ... on Issue { + number title state url updatedAt + author { __typename login avatarUrl } + labels(first: 100) { nodes { name } pageInfo { hasNextPage } } + assignees(first: 100) { nodes { __typename login avatarUrl } pageInfo { hasNextPage } } +}` + +function restActor(actor: Actor | null): Record | null { + if (!actor) { + return null + } + const login = + actor.__typename === 'Bot' && !actor.login.endsWith('[bot]') + ? `${actor.login}[bot]` + : actor.login + let avatar = actor.avatarUrl + if (avatar) { + const url = new URL(avatar) + if (url.hostname === 'avatars.githubusercontent.com') { + url.searchParams.delete('u') + avatar = url.toString() + } + } + return { login, avatar_url: avatar } +} + +export async function listIssueWorkItemPage(args: { + repoPath: string + ownerRepo: OwnerRepo + query: ParsedTaskQuery + limit: number + page: number + options: GitHubRepoExecOptions + connectionId?: string | null + localGitOptions?: LocalGitExecOptions + noCache?: boolean +}): Promise { + const preferGraphql = usesGraphqlWorkItemSearch(args.ownerRepo, args.options) + const options = preferGraphql + ? { ...args.options, env: { ...(args.options.env ?? process.env) } } + : args.options + if (preferGraphql) { + try { + const nodes = await searchWorkItemPage({ + search: buildSearchQueryString(args.ownerRepo, { ...args.query, scope: 'issue' }), + nodeSelection: ISSUE_NODE_SELECTION, + limit: args.limit, + page: args.page, + options, + noCache: args.noCache + }) + const items: MainWorkItem[] = [] + for (const node of nodes) { + if (!node || node.__typename !== 'Issue') { + throw new Error('GitHub issue search response missing issue') + } + if ( + !Number.isSafeInteger(node.number) || + node.number <= 0 || + typeof node.title !== 'string' || + typeof node.url !== 'string' || + typeof node.updatedAt !== 'string' || + !['OPEN', 'CLOSED'].includes(node.state) + ) { + throw new Error('GitHub issue search response missing fields') + } + if (!node.labels?.pageInfo || !node.assignees?.pageInfo) { + throw new Error('GitHub issue search response missing association completeness') + } + if (node.labels.pageInfo.hasNextPage || node.assignees.pageInfo.hasNextPage) { + const complete = await fetchIssueWorkItem( + args.repoPath, + args.ownerRepo, + node.number, + args.connectionId, + args.localGitOptions, + options.env + ) + if (!complete) { + throw new Error('GitHub issue detail response missing issue') + } + items.push(complete) + continue + } + items.push( + mapIssueWorkItem({ + ...node, + state: node.state.toLowerCase(), + user: restActor(node.author), + labels: node.labels.nodes, + assignees: node.assignees.nodes.map(restActor) + }) + ) + } + return items + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw error + } + // REST retains exact search semantics when GraphQL is unavailable for this credential. + } + } + const request = buildWorkItemListRequest({ kind: 'issue', ...args }) + if (args.noCache) { + request.args.splice(1, 2) + } + const { stdout } = await ghExecFileAsync(request.args, options) + noteRepositoryRateLimitSpend(args.ownerRepo, 'search', 1, options) + return z + .array(z.record(z.string(), z.unknown())) + .parse(JSON.parse(stdout)) + .filter((item) => !('pull_request' in item)) + .map(mapIssueWorkItem) +} diff --git a/src/main/github/client/list/work-item-pages.ts b/src/main/github/client/list/work-item-pages.ts index 63d5e7a0098..73871ae1a45 100644 --- a/src/main/github/client/list/work-item-pages.ts +++ b/src/main/github/client/list/work-item-pages.ts @@ -15,12 +15,13 @@ import { githubHostExecOptions } from '../../github-api-repository' import { githubPRStackExecutionScope } from './../github-exec-scope' import { hydrateWorkItemRepositoryMergeMetadata } from './../detect/hydrate-work-item-merge-metadata' import type { MainWorkItem } from './../map/work-item-field-coercion' -import { mapIssueWorkItem, mapPullRequestWorkItem } from './../map/work-item' +import { mapPullRequestWorkItem } from './../map/work-item' import { buildWorkItemListRequest, assertSshRepoHasResolvedGitHubSource, type PartialWorkItemsResult } from './work-item-list-request' +import { listIssueWorkItemPage } from './work-item-issue-page' export async function listRecentWorkItems( repoPath: string, issueOwnerRepo: OwnerRepo | null, @@ -34,15 +35,6 @@ export async function listRecentWorkItems( const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) assertSshRepoHasResolvedGitHubSource({ connectionId, issueOwnerRepo, prOwnerRepo }) const recentQuery = parseTaskQuery('is:open') - const issueRequest = issueOwnerRepo - ? buildWorkItemListRequest({ - kind: 'issue', - ownerRepo: issueOwnerRepo, - limit, - query: recentQuery, - page - }) - : null const prRequest = prOwnerRepo ? buildWorkItemListRequest({ kind: 'pr', @@ -52,18 +44,22 @@ export async function listRecentWorkItems( page }) : null - if (noCache && issueRequest) { - issueRequest.args.splice(1, 2) - } // Why: unresolved sources must stay empty — an unscoped Search API would return other public repos' issues (#9660). // Why: allSettled so a 403 on the issue side doesn't zero the PR half (partial results + banner). const [issuesSettled, prsSettled] = await Promise.allSettled([ - issueRequest && issueOwnerRepo - ? ghExecFileAsync(issueRequest.args, { - ...ghOptions, - ...githubHostExecOptions(issueOwnerRepo) + issueOwnerRepo + ? listIssueWorkItemPage({ + repoPath, + ownerRepo: issueOwnerRepo, + query: recentQuery, + limit, + page, + options: { ...ghOptions, ...githubHostExecOptions(issueOwnerRepo) }, + connectionId, + localGitOptions, + noCache }) - : Promise.resolve({ stdout: '[]' }), + : Promise.resolve([]), prRequest && prOwnerRepo ? ghExecFileAsync(prRequest.args, { ...ghOptions, @@ -75,15 +71,7 @@ export async function listRecentWorkItems( let issues: MainWorkItem[] = [] let issuesError: ClassifiedError | undefined if (issuesSettled.status === 'fulfilled') { - try { - issues = (JSON.parse(issuesSettled.value.stdout) as Record[]) - // Why: search/issues can still return PRs (pull_request marker) even with is:issue; filter them out. - .filter((item) => !('pull_request' in item)) - .map(mapIssueWorkItem) - } catch (err) { - // Why: a malformed issue payload must not discard the successfully fetched PR half. - issuesError = classifyListIssuesError(err instanceof Error ? err.message : String(err)) - } + issues = issuesSettled.value } else { const stderr = issuesSettled.reason instanceof Error @@ -130,7 +118,8 @@ export async function listQueriedWorkItems( limit: number, page?: number, connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} + localGitOptions: LocalGitExecOptions = {}, + noCache?: boolean ): Promise { const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) assertSshRepoHasResolvedGitHubSource({ connectionId, issueOwnerRepo, prOwnerRepo }) @@ -154,27 +143,24 @@ export async function listQueriedWorkItems( if (!issueOwnerRepo) { return { items: [] } } - const request = buildWorkItemListRequest({ - kind: 'issue', - ownerRepo: issueOwnerRepo, - limit, - query, - page: page ?? 1 - }) try { - const { stdout } = await ghExecFileAsync(request.args, { - ...ghOptions, - ...githubHostExecOptions(issueOwnerRepo) + const items = await listIssueWorkItemPage({ + repoPath, + ownerRepo: issueOwnerRepo, + query, + limit, + page: page ?? 1, + options: { ...ghOptions, ...githubHostExecOptions(issueOwnerRepo) }, + connectionId, + localGitOptions, + noCache }) - const items = (JSON.parse(stdout) as Record[]) - .filter((item) => !('pull_request' in item)) - .map(mapIssueWorkItem) successfulRequestCount += 1 return { items } } catch (err) { const stderr = err instanceof Error ? err.message : String(err) if (classifyGitHubUnavailable(stderr)) { - availabilityError ??= err + availabilityError = err } else { nonAvailabilityFailureCount += 1 } diff --git a/src/main/github/client/list/work-item-search-batch.ts b/src/main/github/client/list/work-item-search-batch.ts new file mode 100644 index 00000000000..7f2154b328a --- /dev/null +++ b/src/main/github/client/list/work-item-search-batch.ts @@ -0,0 +1,194 @@ +import { z } from 'zod' +import { createHash } from 'node:crypto' +import { BoundedMap } from '../../../../shared/bounded-map' +import { runCoalescedProbe, type CoalescedProbes } from '../../../git/coalesced-probe' +import { createGhRateLimitBlockedError } from '../../../git/gh-rate-limit-breaker' +import { extractExecError, ghExecFileAsync } from '../../gh-utils' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from '../../rate-limit' +import type { GitHubRepoExecOptions } from '../../github-api-repository' + +const envelopeSchema = z.object({ + data: z.record(z.string(), z.unknown()).nullish(), + errors: z + .array( + z.object({ + message: z.string().optional(), + path: z.array(z.union([z.string(), z.number()])).optional() + }) + ) + .optional() +}) +type Envelope = z.infer +type SearchRequest = { + search: string + first: number + after?: string + selection: string + options: GitHubRepoExecOptions + environment?: NodeJS.ProcessEnv + noCache?: boolean +} +type PendingSearch = { + request: SearchRequest + environment: NodeJS.ProcessEnv + resolve: (value: unknown) => void + reject: (error: unknown) => void +} + +export const WORK_ITEM_SEARCH_CACHE_MS = 120_000 +const MAX_BATCH = 10 +// Leave room for Windows argv escaping and the gh executable path. +const MAX_BATCH_QUERY_CHARS = 12_000 +const pending = new Map() +type SearchResponse = { at: number; value: T } +const inFlight: CoalescedProbes> = new Map() +const responses = new BoundedMap({ + maxEntries: 512, + maxBytes: 16 * 1024 * 1024, + sizeOf: (value, key) => Buffer.byteLength(key) + Buffer.byteLength(JSON.stringify(value)) +}) + +export function workItemSearchScope( + options: GitHubRepoExecOptions, + environment: NodeJS.ProcessEnv = options.env ?? process.env +): string { + // gh wrappers and credential selection can depend on cwd and the inherited environment. + return createHash('sha256') + .update( + JSON.stringify([ + options, + process.cwd(), + Object.entries(environment).sort(([a], [b]) => a.localeCompare(b)) + ]) + ) + .digest('hex') +} + +export function requestWorkItemSearch(request: SearchRequest): Promise> { + const environment = { ...(request.environment ?? request.options.env ?? process.env) } + const scope = workItemSearchScope(request.options, environment) + const key = JSON.stringify([ + scope, + request.search, + request.first, + request.after, + request.selection + ]) + const cached = request.noCache ? undefined : responses.get(key) + if (cached && Date.now() - cached.at < WORK_ITEM_SEARCH_CACHE_MS) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The cache key includes the complete selection; callers validate its response shape. + return Promise.resolve(cached as SearchResponse) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Coalescing uses the complete selection key; callers validate the selected response. + return runCoalescedProbe(inFlight, `${key}:${Boolean(request.noCache)}`, async (ownsKey) => { + const value = await new Promise((resolve, reject) => { + const batchKey = `${scope}:${Boolean(request.noCache)}` + let queue = pending.get(batchKey) + if (!queue) { + queue = [] + pending.set(batchKey, queue) + setTimeout(() => flushSearches(batchKey), 0) + } + queue.push({ request, environment, resolve, reject }) + }) + const response = { at: Date.now(), value } + if (!request.noCache && ownsKey()) { + responses.set(key, response) + } + return response + }) as Promise> +} + +function flushSearches(key: string): void { + const queue = pending.get(key) + pending.delete(key) + if (!queue) { + return + } + let batch: PendingSearch[] = [] + let characters = 0 + for (const entry of queue) { + const size = searchSelection(entry.request, batch.length).length + if (batch.length && (batch.length === MAX_BATCH || characters + size > MAX_BATCH_QUERY_CHARS)) { + void executeSearches(batch) + batch = [] + characters = 0 + } + batch.push(entry) + characters += size + } + if (batch.length) { + void executeSearches(batch) + } +} + +function searchSelection(request: SearchRequest, index: number): string { + const after = request.after ? `, after: ${JSON.stringify(request.after)}` : '' + return `r${index}: search(type: ISSUE, query: ${JSON.stringify(request.search)}, first: ${request.first}${after}) { ${request.selection} }` +} + +async function executeSearches(batch: PendingSearch[]): Promise { + const { options } = batch[0].request + try { + const guard = repositoryRateLimitGuard(options, 'graphql', options) + if (guard.blocked) { + throw createGhRateLimitBlockedError('graphql', guard.resetAt * 1000) + } + const selections = batch.map(({ request }, index) => searchSelection(request, index)) + const query = `query { ${selections.join('\n')} rateLimit { cost } }` + // One response cache owns age; a gh cache hit would otherwise renew an older response. + const args = ['api', 'graphql', '-f', `query=${query}`] + let envelope: Envelope + try { + const { stdout } = await ghExecFileAsync(args, { + ...options, + env: batch[0].environment, + idempotent: true + }) + envelope = envelopeSchema.parse(JSON.parse(stdout)) + } catch (error) { + const { stdout } = extractExecError(error) + if (!stdout) { + throw error + } + try { + envelope = envelopeSchema.parse(JSON.parse(stdout)) + } catch { + throw error + } + if (!envelope.data || !envelope.errors?.length) { + throw error + } + } + const rateLimit = envelope.data?.rateLimit + const cost = + rateLimit && typeof rateLimit === 'object' && 'cost' in rateLimit ? rateLimit.cost : undefined + noteRepositoryRateLimitSpend( + options, + 'graphql', + typeof cost === 'number' && Number.isFinite(cost) && cost >= 0 ? cost : batch.length, + options + ) + for (const [index, entry] of batch.entries()) { + const alias = `r${index}` + const errors = envelope.errors?.filter( + (error) => !error.path?.length || error.path[0] === alias + ) + const value = envelope.data?.[alias] + if (errors?.length || value === undefined || value === null) { + entry.reject( + new Error( + errors?.map((error) => error.message).join('; ') || + 'GitHub search response missing data' + ) + ) + } else { + entry.resolve(value) + } + } + } catch (error) { + for (const entry of batch) { + entry.reject(error) + } + } +} diff --git a/src/main/github/client/list/work-item-search-page.ts b/src/main/github/client/list/work-item-search-page.ts new file mode 100644 index 00000000000..e03c16e777b --- /dev/null +++ b/src/main/github/client/list/work-item-search-page.ts @@ -0,0 +1,128 @@ +import { BoundedMap } from '../../../../shared/bounded-map' +import { isDefaultGitHubHost } from '../../../../shared/github/repository-identity-key' +import type { OwnerRepo } from '../../gh-utils' +import type { GitHubRepoExecOptions } from '../../github-api-repository' +import { + requestWorkItemSearch, + workItemSearchScope, + WORK_ITEM_SEARCH_CACHE_MS +} from './work-item-search-batch' + +type PageInfo = { endCursor: string | null; hasNextPage: boolean } +export type SearchConnection = { issueCount: number; pageInfo: PageInfo; nodes: T[] } +const cursors = new BoundedMap({ + maxEntries: 1024, + maxBytes: 1024 * 1024, + sizeOf: (value, key) => Buffer.byteLength(key) + Buffer.byteLength(value.cursor) + 8 +}) + +export function usesGraphqlWorkItemSearch( + ownerRepo: OwnerRepo, + options: GitHubRepoExecOptions +): boolean { + return isDefaultGitHubHost( + ownerRepo.host ?? options.host ?? options.env?.GH_HOST ?? process.env.GH_HOST + ) +} + +export async function searchWorkItemCount( + search: string, + options: GitHubRepoExecOptions +): Promise { + const { value: result } = await requestWorkItemSearch<{ issueCount: number }>({ + search, + first: 1, + selection: 'issueCount', + options + }) + if (!Number.isSafeInteger(result.issueCount) || result.issueCount < 0) { + throw new Error('GitHub search response missing count') + } + return result.issueCount +} + +export async function searchWorkItemPage(args: { + search: string + nodeSelection: string + limit: number + page: number + options: GitHubRepoExecOptions + noCache?: boolean +}): Promise { + const { options, noCache } = args + const environment = { ...(options.env ?? process.env) } + if (!Number.isSafeInteger(args.limit) || args.limit < 1) { + throw new Error('Invalid GitHub search page limit') + } + const limit = Math.min(100, args.limit) + const offset = (args.page - 1) * limit + if (offset + limit > 1000) { + throw new Error('Only the first 1000 search results are available (HTTP 422)') + } + const search = `${args.search} sort:created-desc` + const scope = JSON.stringify([workItemSearchScope(options, environment), search]) + let position = 0 + let after: string | undefined + if (!noCache) { + for (let at = offset; at > 0; at--) { + const cached = cursors.get(`${scope}:${at}`) + if (cached && Date.now() - cached.at < WORK_ITEM_SEARCH_CACHE_MS) { + position = at + after = cached.cursor + break + } + } + } + const remember = (position: number, info: PageInfo, at: number): void => { + if ( + typeof info.hasNextPage !== 'boolean' || + (info.endCursor !== null && typeof info.endCursor !== 'string') + ) { + throw new Error('GitHub search response invalid pagination') + } + if (info.hasNextPage && !info.endCursor) { + throw new Error('GitHub search response missing cursor') + } + if (!noCache && info.endCursor) { + cursors.set(`${scope}:${position}`, { at, cursor: info.endCursor }) + } + } + while (position < offset) { + const first = Math.min(100, offset - position) + const { value: skipped, at } = await requestWorkItemSearch>({ + search, + first, + after, + selection: 'issueCount pageInfo { endCursor hasNextPage }', + options, + environment, + noCache + }) + if (!skipped.pageInfo || !Number.isSafeInteger(skipped.issueCount)) { + throw new Error('GitHub search response missing pagination') + } + if (skipped.issueCount <= offset) { + return [] + } + if (!skipped.pageInfo.hasNextPage) { + throw new Error('GitHub search pagination ended before requested page') + } + position += first + remember(position, skipped.pageInfo, at) + after = skipped.pageInfo.endCursor ?? undefined + } + const { value: result, at } = await requestWorkItemSearch>({ + search, + first: limit, + after, + selection: `issueCount pageInfo { endCursor hasNextPage } nodes { ${args.nodeSelection} }`, + options, + environment, + noCache + }) + if (!Array.isArray(result.nodes) || !result.pageInfo) { + throw new Error('GitHub search response missing page') + } + remember(offset + result.nodes.length, result.pageInfo, at) + return result.nodes +} diff --git a/src/main/github/github-api-repository.ts b/src/main/github/github-api-repository.ts index 7e394789714..7e72ec9da9e 100644 --- a/src/main/github/github-api-repository.ts +++ b/src/main/github/github-api-repository.ts @@ -32,7 +32,10 @@ export { githubRepositoryWebHost } from './github-repository-host' export type GitHubApiRepository = GitHubOwnerRepo -export type GitHubRepoExecOptions = ReturnType & { host?: string } +export type GitHubRepoExecOptions = ReturnType & { + host?: string + env?: NodeJS.ProcessEnv +} export type GitHubRepoExecution = { ownerRepo: GitHubApiRepository | null ghOptions: GitHubRepoExecOptions diff --git a/src/main/github/work-item-search-fallback-environment.test.ts b/src/main/github/work-item-search-fallback-environment.test.ts new file mode 100644 index 00000000000..9f57048d9f6 --- /dev/null +++ b/src/main/github/work-item-search-fallback-environment.test.ts @@ -0,0 +1,86 @@ +import { expect, it, vi } from 'vitest' +import { api, capture } from './work-item-search-test-harness' +import { listWorkItems } from './client/list/list-work-items' +import { countWorkItems } from './client/list/count-work-items' +import metadata from './__fixtures__/work-item-search-metadata.json' + +it('preserves the Search budget floor when the preferred count fails', async () => { + api.restSearches = 29 + api.aliasErrorRepo = 'fixture/repo' + + expect(await countWorkItems('fixture/repo', 'is:issue')).toBe(0) + expect(api.calls.some((call) => call.args.includes('graphql'))).toBe(true) + expect(api.calls.some((call) => call.args.some((arg) => arg.startsWith('search/issues?')))).toBe( + false + ) + expect(api.restSearches).toBe(29) +}) + +it('still counts through GraphQL when the REST Search budget is below its floor', async () => { + api.restSearches = 29 + + expect(await countWorkItems('fixture/repo', 'is:issue')).toBe(120) + expect(api.restSearches).toBe(29) +}) + +it('keeps REST fallback on the credential captured for the failed preferred search', async () => { + vi.stubEnv('GH_TOKEN', 'fixture-original-credential') + api.aliasErrorRepo = 'fixture/repo' + capture.mockImplementation(async (binary, args, options) => { + if (args.includes('graphql')) { + vi.stubEnv('GH_TOKEN', 'fixture-later-credential') + } + return api.capture(binary, args, options) + }) + expect((await listWorkItems('fixture/repo', 24, 'is:issue')).items).toHaveLength(24) + const queries = api.calls.filter( + (call) => + call.args.includes('graphql') || call.args.some((arg) => arg.startsWith('search/issues?')) + ) + expect(queries.map((call) => call.fixtureCredential)).toEqual([ + 'fixture-original-credential', + 'fixture-original-credential' + ]) +}) + +it('keeps count fallback on its captured credential', async () => { + vi.stubEnv('GH_TOKEN', 'fixture-original-credential') + api.aliasErrorRepo = 'fixture/repo' + capture.mockImplementation(async (binary, args, options) => { + if (args.includes('graphql')) { + vi.stubEnv('GH_TOKEN', 'fixture-later-credential') + } + return api.capture(binary, args, options) + }) + expect(await countWorkItems('fixture/repo', 'is:issue')).toBe(120) + const queries = api.calls.filter( + (call) => + call.args.includes('graphql') || call.args.some((arg) => arg.startsWith('search/issues?')) + ) + expect(queries.map((call) => call.fixtureCredential)).toEqual([ + 'fixture-original-credential', + 'fixture-original-credential' + ]) +}) + +it('hydrates oversized associations with the preferred page credential', async () => { + vi.stubEnv('GH_TOKEN', 'fixture-original-credential') + const row = structuredClone(metadata[0].graphql) + api.specialNodes = [{ ...row, labels: { ...row.labels, pageInfo: { hasNextPage: true } } }] + capture.mockImplementation(async (binary, args, options) => { + if (args.includes('graphql')) { + vi.stubEnv('GH_TOKEN', 'fixture-later-credential') + } + return api.capture(binary, args, options) + }) + const result = await listWorkItems('fixture/repo', 24, 'is:issue') + expect(result.items[0].labels).toHaveLength(125) + const queries = api.calls.filter( + (call) => + call.args.includes('graphql') || call.args.some((arg) => /^repos\/.+\/issues\/\d+$/.test(arg)) + ) + expect(queries.map((call) => call.fixtureCredential)).toEqual([ + 'fixture-original-credential', + 'fixture-original-credential' + ]) +}) diff --git a/src/main/github/work-item-search-freshness.test.ts b/src/main/github/work-item-search-freshness.test.ts new file mode 100644 index 00000000000..bbc59bc471e --- /dev/null +++ b/src/main/github/work-item-search-freshness.test.ts @@ -0,0 +1,32 @@ +import { expect, it, vi } from 'vitest' +import { api, capture } from './work-item-search-test-harness' +import { searchWorkItemCount } from './client/list/work-item-search-page' + +it('does not renew a gh-cached response after bounded response-cache eviction', async () => { + const ghCache = new Map< + string, + { expires: number; response: { stdout: string; stderr: string } } + >() + capture.mockImplementation(async (binary, args, options) => { + const key = JSON.stringify([options.cwd, options.env?.GH_TOKEN, args]) + const cached = args.includes('--cache') ? ghCache.get(key) : undefined + if (cached && cached.expires > Date.now()) { + return cached.response + } + const response = await api.capture(binary, args, options) + if (args.includes('--cache')) { + ghCache.set(key, { expires: Date.now() + 120000, response }) + } + return response + }) + const search = 'repo:fixture/first is:issue' + expect(await searchWorkItemCount(search, {})).toBe(120) + for (let index = 0; index < 512; index++) { + await searchWorkItemCount(`repo:fixture/evict-${index} is:issue`, {}) + } + api.reportedCount = 121 + vi.setSystemTime(119000) + await searchWorkItemCount(search, {}) + vi.setSystemTime(120001) + expect(await searchWorkItemCount(search, {})).toBe(121) +}) diff --git a/src/main/github/work-item-search-isolation.test.ts b/src/main/github/work-item-search-isolation.test.ts new file mode 100644 index 00000000000..de11bdacc2e --- /dev/null +++ b/src/main/github/work-item-search-isolation.test.ts @@ -0,0 +1,140 @@ +import { expect, it, vi } from 'vitest' +import { api, sourceContext } from './work-item-search-test-harness' +import { listWorkItems } from './client/list/list-work-items' +import { countWorkItems } from './client/list/count-work-items' +import { searchWorkItemCount, usesGraphqlWorkItemSearch } from './client/list/work-item-search-page' +import { recordGhPrimaryRateLimit, ghRateLimitScopeKey } from '../git/gh-rate-limit-breaker' + +it('coalesces matching searches and batches independent queries in one execution context', async () => { + const queries = Array.from({ length: 25 }, (_, i) => `repo:fixture/repo-${i} is:issue`) + const counts = await Promise.all( + queries.flatMap((search) => [searchWorkItemCount(search, {}), searchWorkItemCount(search, {})]) + ) + expect(counts).toEqual(Array(50).fill(120)) + expect(api.graphqlCalls).toBe(3) + expect(api.graphqlFields).toBe(25) + expect(api.calls.every((call) => call.cwd === undefined)).toBe(true) +}) + +it('isolates native cwd, WSL distro, host, admission context and inherited credentials', async () => { + const search = 'repo:fixture/repo is:issue' + const options = [ + { cwd: 'folder-a' }, + { cwd: 'folder-b' }, + { wslDistro: 'Ubuntu' }, + { wslDistro: 'Debian' }, + { host: 'github.example.com' }, + { admissionTier: 'interactive' as const } + ] + await Promise.all(options.map((option) => searchWorkItemCount(search, option))) + expect(api.graphqlCalls).toBe(options.length) + await searchWorkItemCount(search, { cwd: 'folder-a' }) + expect(api.graphqlCalls).toBe(options.length) + vi.stubEnv('GH_TOKEN', 'fixture-rotated-credential') + await searchWorkItemCount(search, { cwd: 'folder-a' }) + expect(api.graphqlCalls).toBe(options.length + 1) + expect(api.calls.some((call) => call.args.includes('github.example.com'))).toBe(true) +}) + +it('keeps SSH GitHub execution client-side without passing remote cwd', async () => { + const results = await Promise.all( + Array.from({ length: 8 }, (_, i) => + listWorkItems(`/remote/repo-${i}`, 24, 'is:issue', 1, undefined, `ssh-${i}`) + ) + ) + expect(results.every((result) => result.items.length === 24)).toBe(true) + expect(api.graphqlCalls).toBe(2) + expect(api.calls.every((call) => call.cwd === undefined)).toBe(true) +}) + +it('leaves GHES on REST and unresolved/non-GitHub sources empty', async () => { + sourceContext.host = 'github.example.com' + expect((await listWorkItems('fixture/repo', 24, 'is:issue')).items).toHaveLength(24) + expect(await countWorkItems('fixture/repo', 'is:issue')).toBe(120) + expect(api.graphqlCalls).toBe(0) + expect(api.restSearches).toBe(2) + expect( + api.calls + .filter((call) => !call.args.includes('rate_limit')) + .every((call) => call.args.includes('github.example.com')) + ).toBe(true) + expect( + usesGraphqlWorkItemSearch({ owner: 'fixture', repo: 'repo', host: 'gitlab.com' }, {}) + ).toBe(false) + sourceContext.available = false + expect((await listWorkItems('folder/without-git', 24, 'is:issue')).items).toEqual([]) + expect(await countWorkItems('folder/without-git')).toBe(0) + await expect( + listWorkItems('/remote/unresolved', 24, 'is:issue', 1, undefined, 'ssh') + ).rejects.toThrow() + expect(api.restSearches).toBe(2) +}) + +it('preserves successful aliases when one repository needs REST fallback', async () => { + api.aliasErrorRepo = 'fixture/repo-1' + const results = await Promise.all( + Array.from({ length: 4 }, (_, i) => + listWorkItems(`/remote/repo-${i}`, 24, 'is:issue', 1, undefined, 'ssh') + ) + ) + expect(results.every((result) => result.items.length === 24 && !result.errors)).toBe(true) + expect(api.graphqlCalls).toBe(1) + expect(api.restSearches).toBe(1) + expect( + api.calls + .find((call) => call.args.some((arg) => arg.startsWith('search/issues?'))) + ?.args.join(' ') + ).toContain('repo%3Afixture%2Frepo-1') +}) + +it('falls back on GraphQL quota exhaustion and respects independent runner breaker scopes', async () => { + recordGhPrimaryRateLimit('graphql', 3600000, ghRateLimitScopeKey('native', 'github.com')) + expect((await listWorkItems('fixture/repo', 24, 'is:issue')).items).toHaveLength(24) + expect(api.graphqlCalls).toBe(0) + expect(api.restSearches).toBe(1) + expect( + ( + await listWorkItems('fixture/repo', 24, 'is:issue', 1, undefined, undefined, false, { + wslDistro: 'Ubuntu' + }) + ).items + ).toHaveLength(24) + expect(api.graphqlCalls).toBe(1) + expect(api.restSearches).toBe(1) +}) + +it('keeps count/list search usable when REST Search is exhausted and reports both-bucket failures', async () => { + api.searchAvailable = false + expect(await countWorkItems('fixture/repo', 'is:issue')).toBe(120) + expect((await listWorkItems('fixture/repo', 24, 'is:issue')).items).toHaveLength(24) + expect(api.restSearches).toBe(0) + api.graphqlAvailable = false + await expect( + listWorkItems('fixture/repo', 24, 'is:issue', 1, undefined, undefined, true) + ).rejects.toThrow(/rate limit exceeded/) +}) + +it('splits long search predicates within Windows command-line headroom', async () => { + const queries = Array.from( + { length: 4 }, + (_, index) => `repo:fixture/repo-${index} is:issue ${'word '.repeat(1400)}` + ) + expect(await Promise.all(queries.map((query) => searchWorkItemCount(query, {})))).toEqual([ + 120, 120, 120, 120 + ]) + expect(api.graphqlCalls).toBe(4) + expect(api.calls.every((call) => call.args.join(' ').length < 12000)).toBe(true) +}) + +it('executes queued requests with the credential environment captured at enqueue time', async () => { + vi.stubEnv('GH_TOKEN', 'fixture-first-credential') + const first = searchWorkItemCount('repo:fixture/repo is:issue', {}) + vi.stubEnv('GH_TOKEN', 'fixture-second-credential') + const second = searchWorkItemCount('repo:fixture/repo is:issue', {}) + expect(await Promise.all([first, second])).toEqual([120, 120]) + expect(api.graphqlCalls).toBe(2) + expect(api.calls.map((call) => call.fixtureCredential)).toEqual([ + 'fixture-first-credential', + 'fixture-second-credential' + ]) +}) diff --git a/src/main/github/work-item-search-pagination.test.ts b/src/main/github/work-item-search-pagination.test.ts new file mode 100644 index 00000000000..5e07878e5e6 --- /dev/null +++ b/src/main/github/work-item-search-pagination.test.ts @@ -0,0 +1,71 @@ +import { expect, it, vi } from 'vitest' +import { api } from './work-item-search-test-harness' +import { listWorkItems } from './client/list/list-work-items' +import { countWorkItems } from './client/list/count-work-items' + +function issuePage(page: number, noCache = false, limit = 24) { + return listWorkItems( + 'fixture/repo', + limit, + 'is:issue is:open', + page, + undefined, + undefined, + noCache + ) +} + +it('restores a cold numbered page using only API-issued opaque cursors', async () => { + const third = await issuePage(3) + expect(third.items.map((item) => item.number)).toEqual( + Array.from({ length: 24 }, (_, i) => 9952 - i) + ) + expect(api.graphqlCalls).toBe(2) + expect(api.calls[0].args.join(' ')).not.toContain(' nodes {') + expect(api.calls[1].args.join(' ')).toContain('after: "opaque:1:cursor"') + expect((await issuePage(4)).items[0].number).toBe(9928) + expect(api.graphqlCalls).toBe(3) + expect((await issuePage(6)).items).toEqual([]) + expect(api.restSearches).toBe(0) +}) + +it('walks long jumps without node hydration and retains the authoritative 1000-result window', async () => { + api.rowsPerRepo = 1400 + const last = await issuePage(10, false, 100) + expect(last.items).toHaveLength(100) + expect(last.items[0].number).toBe(9100) + expect(api.graphqlCalls).toBe(10) + expect(api.calls.filter((call) => call.args.join(' ').includes(' nodes {'))).toHaveLength(1) + expect(await countWorkItems('fixture/repo', 'is:issue is:open')).toBe(1400) + const outside = await issuePage(11, false, 100) + expect(outside.items).toEqual([]) + expect(outside.errors?.issues).toMatchObject({ + type: 'validation_error', + message: 'Invalid request — Only the first 1000 search results are available (HTTP 422)' + }) + expect(api.restSearches).toBe(1) +}) + +it('bypasses both page and cursor caches on refresh and expires retained entries', async () => { + await issuePage(3) + expect(api.graphqlCalls).toBe(2) + await issuePage(3) + expect(api.graphqlCalls).toBe(2) + await issuePage(3, true) + expect(api.graphqlCalls).toBe(4) + expect(api.calls.slice(-2).every((call) => !call.args.includes('--cache'))).toBe(true) + vi.setSystemTime(120001) + await issuePage(3) + expect(api.graphqlCalls).toBe(6) + expect(api.restSearches).toBe(0) +}) + +it('does not renew cursor freshness when re-reading a cached page', async () => { + await issuePage(1) + vi.setSystemTime(119000) + await issuePage(1) + vi.setSystemTime(120001) + await issuePage(2) + expect(api.graphqlCalls).toBe(3) + expect(api.calls[1].args.join(' ')).not.toContain('after:') +}) diff --git a/src/main/github/work-item-search-semantics.test.ts b/src/main/github/work-item-search-semantics.test.ts new file mode 100644 index 00000000000..e5df22cddd3 --- /dev/null +++ b/src/main/github/work-item-search-semantics.test.ts @@ -0,0 +1,114 @@ +import { expect, it } from 'vitest' +import { api } from './work-item-search-test-harness' +import { listWorkItems } from './client/list/list-work-items' +import { countWorkItems } from './client/list/count-work-items' +import { mapIssueWorkItem } from './client/map/work-item' +import metadata from './__fixtures__/work-item-search-metadata.json' + +it('matches the saved REST projection for users, bots, avatars, assignees and labels', async () => { + api.specialNodes = metadata.map((pair) => pair.graphql) + api.reportedCount = 2748 + const result = await listWorkItems('fixture/repo', 5, 'is:issue') + expect(result.items).toEqual(metadata.map((pair) => mapIssueWorkItem(pair.rest))) + expect(await countWorkItems('fixture/repo', 'is:issue')).toBe(2748) + expect(api.restSearches).toBe(0) +}) + +it('preserves deleted authors and hydrates associations beyond GraphQL connection limits', async () => { + const row = structuredClone(metadata[0].graphql) + api.specialNodes = [ + { ...row, author: null, labels: { ...row.labels, pageInfo: { hasNextPage: true } } } + ] + const result = await listWorkItems('fixture/repo', 24, 'is:issue') + expect(result.items).toHaveLength(1) + expect(result.items[0].author).toBeNull() + expect(result.items[0].labels).toEqual( + Array.from({ length: 125 }, (_, index) => `label-${index}`) + ) + expect(api.restDetails).toBe(1) + expect(api.restSearches).toBe(0) +}) + +it('passes every issue predicate to the server for both results and full counts', async () => { + api.expectedSearch = + 'repo:fixture/repo is:issue is:closed assignee:"some user" author:"some author" label:"needs review" label:bug in:"title,body" "needle phrase"' + api.specialNodes = [ + { ...metadata[0].graphql, number: 7, state: 'CLOSED', title: 'old needle phrase' } + ] + const query = + 'is:issue is:closed assignee:"some user" author:"some author" label:"needs review" label:bug in:"title,body" "needle phrase"' + expect((await listWorkItems('fixture/repo', 24, query)).items).toMatchObject([ + { number: 7, state: 'closed', title: 'old needle phrase' } + ]) + expect(await countWorkItems('fixture/repo', query)).toBe(1) + expect(api.restSearches).toBe(0) +}) + +it.each([ + ['is:issue state:all', 'repo:fixture/repo is:issue'], + ['is:issue is:open label:bug', 'repo:fixture/repo is:issue is:open label:bug'] +])('retains state/scope semantics for %s', async (query, expected) => { + api.expectedSearch = expected + expect((await listWorkItems('fixture/repo', 24, query)).items).toHaveLength(24) + expect(await countWorkItems('fixture/repo', query)).toBe(120) + expect(api.restSearches).toBe(0) +}) + +it.each([ + [ + 'is:draft', + 'is:pr is:open draft:true sort:created-desc', + 'repo:fixture/repo is:pull-request is:open draft:true' + ], + [ + 'is:pr is:closed', + 'is:pr is:closed -is:merged sort:created-desc', + 'repo:fixture/repo is:pull-request is:closed -is:merged' + ], + ['is:merged', 'is:pr is:merged sort:created-desc', 'repo:fixture/repo is:pull-request is:merged'], + [ + 'review-requested:"some user" reviewed-by:someone', + 'is:pr review-requested:"some user" reviewed-by:someone sort:created-desc', + 'repo:fixture/repo is:pull-request review-requested:"some user" reviewed-by:someone' + ] +])('keeps rich PR lists and full count predicates for %s', async (query, prSearch, countSearch) => { + expect((await listWorkItems('fixture/repo', 24, query)).items).toEqual([]) + expect(api.graphqlCalls).toBe(0) + expect(api.restSearches).toBe(0) + expect(api.calls[0].args).toContain(prSearch) + expect(api.calls[0].args).toContain('--json') + api.expectedSearch = countSearch + expect(await countWorkItems('fixture/repo', query)).toBe(120) + expect(api.graphqlCalls).toBe(1) +}) + +it('falls back with the original numbered query when GraphQL is unavailable', async () => { + api.graphqlAvailable = false + api.expectedSearch = 'repo:fixture/repo is:issue is:closed label:"needs review" "exact phrase"' + const result = await listWorkItems( + 'fixture/repo', + 24, + 'is:issue is:closed label:"needs review" "exact phrase"', + 3, + undefined, + undefined, + true + ) + expect(result.items[0].number).toBe(9952) + const call = api.calls.find((call) => call.args.some((arg) => arg.startsWith('search/issues?'))) + expect(call?.args).toEqual([ + 'api', + '--hostname', + 'github.com', + `search/issues?q=${encodeURIComponent(api.expectedSearch)}&sort=created&order=desc&per_page=24&page=3`, + '--jq', + '.items' + ]) +}) + +it('falls back for malformed GraphQL rows instead of presenting a truncated result', async () => { + api.specialNodes = [{ ...metadata[0].graphql, __typename: 'PullRequest' }] + const result = await listWorkItems('fixture/repo', 24, 'is:issue') + expect(result.items).toHaveLength(1) + expect(api.restSearches).toBe(1) +}) diff --git a/src/main/github/work-item-search-test-harness.ts b/src/main/github/work-item-search-test-harness.ts new file mode 100644 index 00000000000..d3783f0afe1 --- /dev/null +++ b/src/main/github/work-item-search-test-harness.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, vi } from 'vitest' +import type { Mock } from 'vitest' +import { randomUUID } from 'node:crypto' +import { basename } from 'node:path' +import type * as GithubApiRepositoryModule from './github-api-repository' +import { WorkItemSearchApi } from './__fixtures__/work-item-search-api' + +const { + capture, + sourceContext +}: { + capture: Mock + sourceContext: { host: string; available: boolean } +} = vi.hoisted(() => ({ + capture: vi.fn(), + sourceContext: { host: 'github.com', available: true } +})) +vi.mock('../git/command-runner/exec-file-capture', () => ({ + execFileCaptureToTermination: capture +})) +vi.mock('../git/command-runner/wsl-command-resolution', () => ({ + resolveCommand: (binary: string, args: string[], cwd?: string, distro?: string) => ({ + binary, + args, + cwd, + wsl: distro ? { distro } : null, + wslMode: null + }), + resolveDefaultWslCli: () => null +})) +vi.mock('../git/runner', async () => ({ + ghExecFileAsync: (await import('../git/command-runner/gh-exec-file')).ghExecFileAsync, + gitExecFileAsync: vi.fn() +})) +vi.mock('./github-api-repository', async (importOriginal) => { + const actual = await importOriginal() + const source = (repoPath: string) => + sourceContext.available + ? { owner: 'fixture', repo: basename(repoPath), host: sourceContext.host } + : null + return { + ...actual, + resolveIssueGitHubApiRepositorySource: async (repoPath: string) => ({ + source: source(repoPath), + fellBack: false + }), + getOriginGitHubApiRepository: async (repoPath: string) => source(repoPath), + getGitHubApiRepositoryForRemote: async () => null + } +}) + +import { _resetRateLimitCache } from './rate-limit' +import { clearGhRateLimitBlock, ghRateLimitScopeKey } from '../git/gh-rate-limit-breaker' +export let api: WorkItemSearchApi +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(0) + vi.stubEnv('GH_HOST', 'github.com') + vi.stubEnv('ORCA_WORK_ITEM_SEARCH_FIXTURE', randomUUID()) + _resetRateLimitCache() + for (const runtime of ['native', 'wsl:ubuntu', 'wsl:debian']) { + for (const host of ['github.com', 'github.example.com']) { + for (const bucket of ['core', 'graphql', 'search'] as const) { + clearGhRateLimitBlock(bucket, ghRateLimitScopeKey(runtime, host)) + } + } + } + sourceContext.host = 'github.com' + sourceContext.available = true + api = new WorkItemSearchApi() + capture.mockReset().mockImplementation(api.capture.bind(api)) +}) +afterEach(() => { + vi.useRealTimers() + vi.unstubAllEnvs() +}) + +export { capture, sourceContext } diff --git a/tests/e2e/github-work-item-search-burst.unit.test.ts b/tests/e2e/github-work-item-search-burst.unit.test.ts new file mode 100644 index 00000000000..bcebb88ec58 --- /dev/null +++ b/tests/e2e/github-work-item-search-burst.unit.test.ts @@ -0,0 +1,87 @@ +import { z } from 'zod' +import { expect, it } from 'vitest' +import { join } from 'node:path' +import { api } from '../../src/main/github/work-item-search-test-harness' +import { listWorkItems } from '../../src/main/github/client/list/list-work-items' +import { countWorkItems } from '../../src/main/github/client/list/count-work-items' +import { + createTestStore, + mockApi +} from '../../src/renderer/src/store/slices/github-slice-test-harness' + +const repos = Array.from({ length: 36 }, (_, index) => ({ + repoId: `repo-${index}`, + path: join('fixture', `repo-${index}`) +})) +function connectedStore() { + mockApi.gh.listWorkItems.mockImplementation((...params: unknown[]) => { + const args = z + .object({ + repoPath: z.string(), + limit: z.number(), + query: z.string().optional(), + page: z.number().optional(), + noCache: z.boolean().optional() + }) + .parse(params[0]) + return listWorkItems( + args.repoPath, + args.limit, + args.query, + args.page, + undefined, + undefined, + args.noCache + ) + }) + mockApi.gh.countWorkItems.mockImplementation((...params: unknown[]) => { + const args = z.object({ repoPath: z.string(), query: z.string().optional() }).parse(params[0]) + return countWorkItems(args.repoPath, args.query) + }) + return createTestStore() +} + +it.each(['counts-first', 'lists-first'] as const)( + 'returns every repo and full totals with %s', + async (order) => { + const store = connectedStore() + const lists = () => + store.getState().fetchWorkItemsAcrossRepos(repos, 24, 1000, '', { force: true }) + const counts = () => store.getState().countWorkItemsAcrossRepos(repos, '', 24) + const pending = + order === 'counts-first' + ? { total: counts(), items: lists() } + : { items: lists(), total: counts() } + const [items, total] = await Promise.all([pending.items, pending.total]) + expect(items).toMatchObject({ failedCount: 0 }) + expect(items.items).toHaveLength(36 * 24) + expect(new Set(items.items.map((item) => item.repoId)).size).toBe(36) + expect(total).toEqual({ totalCount: 36 * 120, totalPages: 5 }) + expect(api.restSearches).toBe(0) + expect(api.rejected).toBe(0) + expect(api.graphqlCalls).toBeLessThanOrEqual(72) + } +) + +it('retains all repos on repeat, bypass-cache refresh and independent page jumps', async () => { + const store = connectedStore() + const fetch = (noCache = false) => + store + .getState() + .fetchWorkItemsAcrossRepos(repos, 24, 1000, 'is:issue is:open', { force: true, noCache }) + expect((await fetch()).items).toHaveLength(36 * 24) + const firstCalls = api.graphqlCalls + expect((await fetch()).items).toHaveLength(36 * 24) + expect(api.graphqlCalls).toBe(firstCalls) + for (let repeat = 0; repeat < 3; repeat++) { + expect((await fetch(true)).items).toHaveLength(36 * 24) + } + expect(api.graphqlCalls).toBe(firstCalls + 3 * 36) + const page3 = await store + .getState() + .fetchWorkItemsNextPage(repos, 24, 1000, 'is:issue is:open', 3) + expect(page3.items).toHaveLength(36 * 24) + expect(page3.items.every((item) => item.number <= 9952 && item.number >= 9929)).toBe(true) + expect(page3.failedCount).toBe(0) + expect(api.restSearches).toBe(0) +}) From 09187fcad8e1fb9395536ec9f9063d4486569a45 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 13 Sep 2026 15:43:21 -0700 Subject: [PATCH 04/34] fix(ai-vault): stream oversized remote session transcripts (#20455) * fix(ai-vault): stream oversized remote session transcripts * fix(build): bundle streamed JSON parser in desktop main --------- Co-authored-by: m4air --- electron.vite.config.ts | 1 + package.json | 1 + pnpm-lock.yaml | 8 + .../ai-vault/remote-session-content-lines.ts | 37 +++- .../remote-session-document-parsers.ts | 51 +++++ .../remote-session-large-transcripts.test.ts | 188 ++++++++++++++++++ .../remote-session-scan-concurrency.ts | 27 ++- .../remote-session-scanner-cline-source.ts | 17 ++ .../remote-session-scanner-sources.ts | 73 ++++--- .../ai-vault/remote-session-scanner-types.ts | 15 +- src/main/ai-vault/remote-session-scanner.ts | 14 +- .../remote-session-stream-lifecycle.test.ts | 95 +++++++++ .../remote-session-transcript-read.ts | 49 +++++ ...session-document-stream-boundaries.test.ts | 106 ++++++++++ .../session-document-stream-parity.test.ts | 95 +++++++++ ...session-document-stream-projection.test.ts | 48 +++++ src/main/ai-vault/session-document-stream.ts | 143 +++++++++++++ .../session-scanner-antigravity-parser.ts | 7 +- .../ai-vault/session-scanner-cline-parser.ts | 106 ++++++++-- .../ai-vault/session-scanner-codex-parser.ts | 2 +- .../session-scanner-copilot-parser.ts | 7 +- .../ai-vault/session-scanner-cursor-parser.ts | 7 +- .../ai-vault/session-scanner-devin-parser.ts | 105 +++++++--- .../ai-vault/session-scanner-droid-parser.ts | 7 +- .../session-scanner-gemini-parsers.ts | 29 ++- .../ai-vault/session-scanner-graph-parsers.ts | 7 +- .../ai-vault/session-scanner-hermes-parser.ts | 59 +++++- .../session-scanner-primary-parsers.ts | 7 +- .../native-chat/transcript-stream-lines.ts | 104 ++++++---- src/relay/ai-vault-service-filesystem.ts | 2 + src/relay/ai-vault-transcript-stream.ts | 34 ++++ 31 files changed, 1307 insertions(+), 144 deletions(-) create mode 100644 src/main/ai-vault/remote-session-document-parsers.ts create mode 100644 src/main/ai-vault/remote-session-large-transcripts.test.ts create mode 100644 src/main/ai-vault/remote-session-stream-lifecycle.test.ts create mode 100644 src/main/ai-vault/remote-session-transcript-read.ts create mode 100644 src/main/ai-vault/session-document-stream-boundaries.test.ts create mode 100644 src/main/ai-vault/session-document-stream-parity.test.ts create mode 100644 src/main/ai-vault/session-document-stream-projection.test.ts create mode 100644 src/main/ai-vault/session-document-stream.ts create mode 100644 src/relay/ai-vault-transcript-stream.ts diff --git a/electron.vite.config.ts b/electron.vite.config.ts index d900e6cba16..2ba0afaefc5 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -8,6 +8,7 @@ import { createPlainNodeEntryGuardPlugin } from './config/build-plugins/plain-no import packageJson from './package.json' with { type: 'json' } const BUNDLED_MAIN_DEPENDENCIES = new Set([ + '@streamparser/json', '@xterm/headless', '@xterm/addon-serialize', 'tldts', diff --git a/package.json b/package.json index ca0b2eca73c..e48079f8a1c 100644 --- a/package.json +++ b/package.json @@ -170,6 +170,7 @@ "@floating-ui/dom": "1.7.6", "@linear/sdk": "^82.1.0", "@parcel/watcher": "^2.5.6", + "@streamparser/json": "0.0.26", "@xterm/addon-serialize": "0.15.0-beta.300", "@xterm/headless": "6.1.0-beta.302", "agent-browser": "~0.27.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d86a167957..586c9449212 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,6 +137,9 @@ importers: '@parcel/watcher': specifier: ^2.5.6 version: 2.5.6 + '@streamparser/json': + specifier: 0.0.26 + version: 0.0.26 '@xterm/addon-serialize': specifier: 0.15.0-beta.300 version: 0.15.0-beta.300(patch_hash=851eac3d75e6d8c013b9f4c053e61d824b23965cb19ecc28e335e05059f3a294)(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4)) @@ -2675,6 +2678,9 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@streamparser/json@0.0.26': + resolution: {integrity: sha512-46597LNFI+MFdUnzX2QJWwmdTRdq0XVD+vVNJTtGVzIrnCuhG9pFo1OAzbNBqci8UJgk/X5KJZ6LcV+y7PTuDQ==} + '@swc/core-darwin-arm64@1.15.46': resolution: {integrity: sha512-IsISIT22EfktVJrlvIpnAxG2u/A9aob9l99HMlx80x72WlFmFPk1V3UhkEzx86eJP8hw049KTFv/RISho2cq2Q==} engines: {node: '>=10'} @@ -9010,6 +9016,8 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@streamparser/json@0.0.26': {} + '@swc/core-darwin-arm64@1.15.46': optional: true diff --git a/src/main/ai-vault/remote-session-content-lines.ts b/src/main/ai-vault/remote-session-content-lines.ts index c20c6ec10ac..d62e8988018 100644 --- a/src/main/ai-vault/remote-session-content-lines.ts +++ b/src/main/ai-vault/remote-session-content-lines.ts @@ -1,6 +1,9 @@ +import { splitTranscriptStreamLines } from '../native-chat/transcript-stream-lines' import { setImmediate as yieldToEventLoop } from 'node:timers/promises' import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' +export type RemoteSessionContent = string | AsyncIterable + const REMOTE_CONTENT_YIELD_LINE_COUNT = 200 const REMOTE_CONTENT_YIELD_CHAR_COUNT = 256 * 1024 @@ -9,9 +12,12 @@ const REMOTE_CONTENT_YIELD_CHAR_COUNT = 256 * 1024 * cancelled scan stops mid-transcript instead of parsing megabytes for a caller * that already left. */ export function remoteSessionContentLines( - content: string, + content: RemoteSessionContent, signal?: AbortSignal ): Iterable | AsyncIterable { + if (typeof content !== 'string') { + return content + } return signal ? cancellableContentLines(content, signal) : content.split(/\r?\n/) } @@ -61,3 +67,32 @@ async function yieldUnlessCancelled(signal: AbortSignal): Promise { await yieldToEventLoop() throwIfAiVaultScanCancelled(signal) } + +export class BinarySessionTranscriptError extends Error { + constructor() { + super('Binary session transcript') + } +} + +export async function* streamedSessionContentLines( + bytes: AsyncIterable, + signal?: AbortSignal +): AsyncGenerator { + let count = 0 + let chars = 0 + for await (const record of splitTranscriptStreamLines(bytes)) { + throwIfAiVaultScanCancelled(signal) + const line = + record.line.endsWith('\r') && (record.terminated || signal) + ? record.line.slice(0, -1) + : record.line + yield line + chars += line.length + if (++count >= REMOTE_CONTENT_YIELD_LINE_COUNT || chars >= REMOTE_CONTENT_YIELD_CHAR_COUNT) { + await yieldToEventLoop() + throwIfAiVaultScanCancelled(signal) + count = 0 + chars = 0 + } + } +} diff --git a/src/main/ai-vault/remote-session-document-parsers.ts b/src/main/ai-vault/remote-session-document-parsers.ts new file mode 100644 index 00000000000..1dcd78ba08b --- /dev/null +++ b/src/main/ai-vault/remote-session-document-parsers.ts @@ -0,0 +1,51 @@ +import type { AiVaultAgent } from '../../shared/ai-vault-types' +import { parseDevinSessionDocument } from './session-scanner-devin-parser' +import { parseHermesSessionDocument } from './session-scanner-hermes-parser' +import { + parseGeminiSessionDocument, + parseGeminiJsonlSessionLines +} from './session-scanner-gemini-parsers' +import type { RemoteSessionSource } from './remote-session-scanner-types' + +export function remoteSessionDocumentParsers( + agent: AiVaultAgent +): Pick { + const parse = + agent === 'hermes' + ? parseHermesSessionDocument + : agent === 'devin' + ? parseDevinSessionDocument + : agent === 'gemini' + ? parseGeminiSessionDocument + : null + if (!parse) { + return {} + } + return { + parseDocument: (file, bytes, context) => + parse( + file, + bytes, + context.hostPlatform.os, + { + executionHostId: context.executionHostId, + executionHostPlatform: context.hostPlatform.os + }, + context.signal + ), + ...(agent === 'gemini' + ? { + parseLines: (file, lines, context) => + parseGeminiJsonlSessionLines({ + file, + lines, + platform: context.hostPlatform.os, + options: { + executionHostId: context.executionHostId, + executionHostPlatform: context.hostPlatform.os + } + }) + } + : {}) + } +} diff --git a/src/main/ai-vault/remote-session-large-transcripts.test.ts b/src/main/ai-vault/remote-session-large-transcripts.test.ts new file mode 100644 index 00000000000..62d8b8c5ed1 --- /dev/null +++ b/src/main/ai-vault/remote-session-large-transcripts.test.ts @@ -0,0 +1,188 @@ +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { describe, it, expect } from 'vitest' +import { createRelayAiVaultFilesystemProvider } from '../../relay/ai-vault-service-filesystem' +import { scanRemoteAiVaultSessions } from './remote-session-scanner' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' + +const platform = getRemoteHostPlatform( + process.platform === 'win32' + ? 'win32-x64' + : process.platform === 'darwin' + ? 'darwin-arm64' + : 'linux-x64' +) +const jsonl = (rows: unknown[]) => `${rows.map((row) => JSON.stringify(row)).join('\n')}\n` +const filler = jsonl([{ type: 'irrelevant_event', payload: 'x'.repeat(1024) }]).repeat(11000) + +describe('large remote history through real relay filesystem', () => { + it('lists a large Codex rollout with middle messages and usage intact', async () => { + const home = await mkdtemp(join(tmpdir(), 'orca-history-17744-')) + try { + const path = join(home, '.codex', 'sessions', 'large.jsonl') + await mkdir(dirname(path), { recursive: true }) + await writeFile( + path, + jsonl([ + { + type: 'session_meta', + timestamp: '2026-09-13T01:00:00Z', + payload: { id: 'large', cwd: '/repo' } + }, + { + type: 'response_item', + timestamp: '2026-09-13T01:00:01Z', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'Keep my history' }] + } + } + ]) + + filler.slice(0, filler.length / 2) + + jsonl([ + { + type: 'response_item', + timestamp: '2026-09-13T01:02:00Z', + payload: { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'Middle answer' }] + } + }, + { + type: 'event_msg', + timestamp: '2026-09-13T01:03:00Z', + payload: { + type: 'token_count', + info: { + total_token_usage: { input_tokens: 123, output_tokens: 45, total_tokens: 168 } + } + } + } + ]) + + filler.slice(filler.length / 2) + ) + const result = await scanRemoteAiVaultSessions({ + provider: createRelayAiVaultFilesystemProvider(), + executionHostId: 'ssh:synthetic-17744', + remoteHome: home, + hostPlatform: platform, + unlimited: true + }) + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + sessionId: 'large', + messageCount: 2, + totalTokens: 168, + title: 'Keep my history' + }) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + + it.each(['hermes', 'devin', 'gemini', 'cline'] as const)( + 'lists large %s documents with every message counted', + async (agent) => { + const home = await mkdtemp(join(tmpdir(), 'orca-history-17744-document-')) + try { + const messages = Array.from({ length: 11000 }, () => ({ + role: 'assistant', + content: 'x'.repeat(1024) + })) + messages.splice(5000, 0, { role: 'user', content: 'A middle user turn' }) + let path: string, record: unknown + if (agent === 'hermes') { + path = join(home, '.hermes', 'sessions', 'large.json') + record = { session_id: 'large', cwd: '/repo', model: 'test-model', messages } + } else if (agent === 'devin') { + path = join(home, '.local', 'share', 'devin', 'cli', 'transcripts', 'large.json') + record = { + session_id: 'large', + working_directory: '/repo', + steps: messages.map((message) => ({ + ...message, + metadata: { + is_user_input: message.role === 'user', + metrics: { input_tokens: 2, output_tokens: 1 } + } + })) + } + } else if (agent === 'gemini') { + path = join(home, '.gemini', 'tmp', 'large.json') + record = { + sessionId: 'large', + messages: messages.map((message) => ({ + type: message.role === 'assistant' ? 'gemini' : 'user', + content: message.content + })) + } + } else { + path = join(home, '.cline', 'data', 'sessions', 'large', 'large.json') + record = { session_id: 'large', cwd: '/repo' } + await mkdir(dirname(path), { recursive: true }) + await writeFile(path.replace('.json', '.messages.json'), JSON.stringify({ messages })) + } + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, JSON.stringify(record)) + const result = await scanRemoteAiVaultSessions({ + provider: createRelayAiVaultFilesystemProvider(), + executionHostId: `ssh:large-${agent}`, + remoteHome: home, + hostPlatform: platform, + unlimited: true + }) + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ agent, sessionId: 'large', messageCount: 11001 }) + if (agent === 'devin') { + expect(result.sessions[0].totalTokens).toBe(33003) + } + } finally { + await rm(home, { recursive: true, force: true }) + } + } + ) + it('keeps normal-size reads on their existing path and supports providers without streaming', async () => { + const home = await mkdtemp(join(tmpdir(), 'orca-history-legacy-')) + try { + const directory = join(home, '.codex', 'sessions') + await mkdir(directory, { recursive: true }) + const content = jsonl([{ type: 'session_meta', payload: { id: 'small', cwd: '/repo' } }]) + await writeFile(join(directory, 'small.jsonl'), content) + const provider = createRelayAiVaultFilesystemProvider() + provider.readTranscriptBytes = () => { + throw new Error('Small file must keep its existing read path') + } + const small = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:small-original', + remoteHome: home, + hostPlatform: platform, + unlimited: true + }) + expect(small.issues).toEqual([]) + expect(small.sessions.map((session) => session.sessionId)).toEqual(['small']) + await writeFile(join(directory, 'large.jsonl'), content + filler) + const legacy = { readDir: provider.readDir, readFile: provider.readFile, stat: provider.stat } + const fallback = await scanRemoteAiVaultSessions({ + provider: legacy, + executionHostId: 'ssh:legacy-original', + remoteHome: home, + hostPlatform: platform, + unlimited: true + }) + expect(fallback.sessions.map((session) => session.sessionId)).toEqual(['small']) + expect( + fallback.issues.some( + (issue) => issue.path.endsWith('large.jsonl') && issue.message.includes('10MB limit') + ) + ).toBe(true) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/ai-vault/remote-session-scan-concurrency.ts b/src/main/ai-vault/remote-session-scan-concurrency.ts index 77b8e371b51..e74375259f5 100644 --- a/src/main/ai-vault/remote-session-scan-concurrency.ts +++ b/src/main/ai-vault/remote-session-scan-concurrency.ts @@ -16,7 +16,32 @@ export function limitRemoteScanFilesystemConcurrency( return { readDir: (dirPath) => gate(() => provider.readDir(dirPath)), readFile: (filePath) => gate(() => provider.readFile(filePath)), - stat: (filePath) => gate(() => provider.stat(filePath)) + stat: (filePath) => gate(() => provider.stat(filePath)), + ...(provider.readTranscriptBytes + ? { + readTranscriptBytes: async function* (path: string, signal?: AbortSignal) { + let enter!: () => void + let release!: () => void + const entered = new Promise((resolve) => { + enter = resolve + }) + const released = new Promise((resolve) => { + release = resolve + }) + const held = gate(async () => { + enter() + await released + }) + await entered + try { + yield* provider.readTranscriptBytes!(path, signal) + } finally { + release() + await held + } + } + } + : {}) } } diff --git a/src/main/ai-vault/remote-session-scanner-cline-source.ts b/src/main/ai-vault/remote-session-scanner-cline-source.ts index f2f79089f9a..f568aa06ce9 100644 --- a/src/main/ai-vault/remote-session-scanner-cline-source.ts +++ b/src/main/ai-vault/remote-session-scanner-cline-source.ts @@ -6,6 +6,7 @@ import type { RemoteSessionSource } from './remote-session-scanner-types' import { clineMessagesPathForMetadata, isClineSessionMetadataPath, + parseClineSessionDocuments, parseClineSessionContent } from './session-scanner-cline-parser' @@ -20,6 +21,22 @@ export function remoteClineSource( filePredicate: isClineSessionMetadataPath, contentDependencyPath: clineMessagesPathForMetadata, directoryPredicate: (_name, depth) => depth === 0, + parseDocument: (file, bytes, context) => + parseClineSessionDocuments( + file, + bytes, + () => + context.provider.readTranscriptBytes!( + clineMessagesPathForMetadata(file.path), + context.signal + ), + context.hostPlatform.os, + { + executionHostId: context.executionHostId, + executionHostPlatform: context.hostPlatform.os + }, + context.signal + ), parse: async (file, content, context) => { let messagesContent: string | null = null try { diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index 2c21a93db9a..92b9e9b4dc5 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -1,3 +1,5 @@ +import { remoteSessionDocumentParsers } from './remote-session-document-parsers' +import type { RemoteSessionContent } from './remote-session-content-lines' import type { AiVaultAgent, AiVaultSession } from '../../shared/ai-vault-types' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' import { joinRemotePath } from '../ssh/ssh-remote-platform' @@ -24,9 +26,9 @@ import type { RemoteSessionSource } from './remote-session-scanner-types' -type RemoteContentParser = ( +type RemoteContentParser = ( file: FileWithMtime, - content: string, + content: T, platform: NodeJS.Platform, options: RemoteParserOptions, // Line-based parsers iterate cancellably; whole-document parsers ignore it. @@ -134,22 +136,28 @@ function remoteAntigravitySource( ): RemoteSessionSource { const cliRoot = joinRemotePath(hostPlatform, remoteHome, '.gemini', 'antigravity-cli') const historyPath = joinRemotePath(hostPlatform, cliRoot, 'history.jsonl') + const parse = async ( + file: FileWithMtime, + content: RemoteSessionContent, + context: RemoteScannerContext + ) => { + const session = await parseAntigravitySessionContent( + file, + content, + context.hostPlatform.os, + parserOptions(context), + context.signal + ) + return session ? context.antigravityWorkspaceResolver.enrich(session, historyPath) : null + } return { agent: 'antigravity', rootDir: joinRemotePath(hostPlatform, cliRoot, 'brain'), extensions: ['.jsonl'], filePredicate: isAntigravityTranscriptPath, fixedChildFileSegments: ['.system_generated', 'logs', 'transcript.jsonl'], - parse: async (file, content, context) => { - const session = await parseAntigravitySessionContent( - file, - content, - context.hostPlatform.os, - parserOptions(context), - context.signal - ) - return session ? context.antigravityWorkspaceResolver.enrich(session, historyPath) : null - } + parse, + parseLines: parse } } @@ -169,6 +177,7 @@ function source( extensions, filePredicate, directoryPredicate, + ...remoteSessionDocumentParsers(agent), parse: (file, content, context) => Promise.resolve( parseContent(file, content, context.hostPlatform.os, parserOptions(context), context.signal) @@ -181,10 +190,16 @@ function jsonlSource( remoteHome: string, hostPlatform: RemoteHostPlatform, segments: readonly string[], - parseContent: RemoteContentParser, + parseContent: RemoteContentParser, filePredicate?: (path: string) => boolean ): RemoteSessionSource { - return source(agent, remoteHome, hostPlatform, segments, ['.jsonl'], parseContent, filePredicate) + return { + ...source(agent, remoteHome, hostPlatform, segments, ['.jsonl'], parseContent, filePredicate), + parseLines: (file, lines, context) => + Promise.resolve( + parseContent(file, lines, context.hostPlatform.os, parserOptions(context), context.signal) + ) + } } function remoteCodexSources( @@ -202,12 +217,12 @@ function remoteCodexSources( 'codex-runtime-home', 'home' ) - ].map((codexHome) => ({ - agent: 'codex', - rootDir: joinRemotePath(hostPlatform, codexHome, 'sessions'), - codexHome, - extensions: ['.jsonl'], - parse: (file, content, context) => + ].map((codexHome) => { + const parse = ( + file: FileWithMtime, + content: RemoteSessionContent, + context: RemoteScannerContext + ) => parseCodexSessionContent({ file, content, @@ -218,7 +233,15 @@ function remoteCodexSources( signal: context.signal, readIndexedTitle: remoteCodexIndexedTitleReader(codexHome, context) }) - })) + return { + agent: 'codex', + rootDir: joinRemotePath(hostPlatform, codexHome, 'sessions'), + codexHome, + extensions: ['.jsonl'], + parse, + parseLines: parse + } + }) } function remoteOpenClawSources( @@ -246,7 +269,7 @@ function parserOptions(context: RemoteScannerContext): RemoteParserOptions { function piParser( file: FileWithMtime, - content: string, + content: RemoteSessionContent, platform: NodeJS.Platform, options: RemoteParserOptions, signal?: AbortSignal @@ -256,7 +279,7 @@ function piParser( function ompParser( file: FileWithMtime, - content: string, + content: RemoteSessionContent, platform: NodeJS.Platform, options: RemoteParserOptions, signal?: AbortSignal @@ -266,7 +289,7 @@ function ompParser( function primeAgentParser( file: FileWithMtime, - content: string, + content: RemoteSessionContent, platform: NodeJS.Platform, options: RemoteParserOptions, signal?: AbortSignal @@ -276,7 +299,7 @@ function primeAgentParser( function openClawParser( file: FileWithMtime, - content: string, + content: RemoteSessionContent, platform: NodeJS.Platform, options: RemoteParserOptions, signal?: AbortSignal diff --git a/src/main/ai-vault/remote-session-scanner-types.ts b/src/main/ai-vault/remote-session-scanner-types.ts index 405ea7f4626..7f78cbb5d60 100644 --- a/src/main/ai-vault/remote-session-scanner-types.ts +++ b/src/main/ai-vault/remote-session-scanner-types.ts @@ -18,7 +18,10 @@ export type RemoteScannerContext = { export type RemoteSessionFilesystemProvider = Pick< IFilesystemProvider, 'readDir' | 'readFile' | 'stat' -> +> & { + /** Available only beside the execution host's disk; never opens a client path. */ + readTranscriptBytes?: (path: string, signal?: AbortSignal) => AsyncIterable +} export type RemoteParserOptions = { executionHostId: ExecutionHostId @@ -42,6 +45,16 @@ export type RemoteSessionSource = { // artifact dir): count subagent transcripts from the walked listing and drop // them from candidates instead of indexing them as sessions. partitionSubagentTranscripts?: (paths: readonly string[]) => SubagentTranscriptPartition + parseDocument?: ( + file: FileWithMtime, + bytes: AsyncIterable, + context: RemoteScannerContext + ) => Promise + parseLines?: ( + file: FileWithMtime, + lines: AsyncIterable, + context: RemoteScannerContext + ) => Promise parse: ( file: FileWithMtime, content: string, diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts index 259c84d66ef..971bc20f70e 100644 --- a/src/main/ai-vault/remote-session-scanner.ts +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -1,3 +1,5 @@ +import { parseRemoteSessionTranscript } from './remote-session-transcript-read' +import { BinarySessionTranscriptError } from './remote-session-content-lines' import type { AiVaultListResult, AiVaultScanIssue, @@ -239,14 +241,7 @@ async function parseRemoteSessionCandidate( const session = await parseRemoteSessionFileCached({ candidate, hostKey: remoteSessionParseHostKey(context), - parse: async () => { - const read = await context.provider.readFile(candidate.file.path) - throwIfAiVaultScanCancelled(context.signal) - if (read.isBinary) { - return null - } - return await candidate.source.parse(candidate.file, read.content, context) - }, + parse: () => parseRemoteSessionTranscript(candidate, context), refreshReusedSession: reusedCodexTitleRefresh(candidate, context) }) throwIfAiVaultScanCancelled(context.signal) @@ -260,6 +255,9 @@ async function parseRemoteSessionCandidate( return session } catch (err) { throwIfAiVaultScanCancelled(context.signal) + if (err instanceof BinarySessionTranscriptError) { + return null + } recordSessionScanIssue(issues, { executionHostId: context.executionHostId, agent: candidate.source.agent, diff --git a/src/main/ai-vault/remote-session-stream-lifecycle.test.ts b/src/main/ai-vault/remote-session-stream-lifecycle.test.ts new file mode 100644 index 00000000000..46668e3574e --- /dev/null +++ b/src/main/ai-vault/remote-session-stream-lifecycle.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi } from 'vitest' +import { streamedSessionContentLines } from './remote-session-content-lines' +import { readStreamedSessionDocument } from './session-document-stream' +import { limitRemoteScanFilesystemConcurrency } from './remote-session-scan-concurrency' + +describe('stream lifetime and retained document work', () => { + it('releases the source when a line consumer finishes early', async () => { + let closed = false + async function* bytes() { + try { + yield Buffer.from('one\ntwo\n') + yield Buffer.from('three\n') + } finally { + closed = true + } + } + for await (const line of streamedSessionContentLines(bytes())) { + expect(line).toBe('one') + break + } + await vi.waitFor(() => expect(closed).toBe(true)) + }) + it('propagates disk failure and closes the source', async () => { + let closed = false + async function* bytes() { + try { + yield Buffer.from('one\n') + throw new Error('disk read failed') + } finally { + closed = true + } + } + await expect( + (async () => { + for await (const _ of streamedSessionContentLines(bytes())) { + /* consume */ + } + })() + ).rejects.toThrow('disk read failed') + expect(closed).toBe(true) + }) + it('cancellation discards a document fold and releases its source', async () => { + const controller = new AbortController() + let closed = false + async function* bytes() { + try { + yield Buffer.from('{"messages":[{"role":"user"}') + controller.abort() + yield Buffer.from(']}') + } finally { + closed = true + } + } + await expect( + readStreamedSessionDocument({ + bytes: bytes(), + arrayKey: 'messages', + fields: [], + create: () => ({ count: 0 }), + consume: (state) => { + state.count++ + }, + signal: controller.signal + }) + ).rejects.toThrow() + expect(closed).toBe(true) + }) + it('holds one filesystem slot for the stream lifetime and releases it on return', async () => { + let entered = 0 + async function* bytes() { + entered++ + yield Buffer.from('a') + yield Buffer.from('b') + } + const provider = limitRemoteScanFilesystemConcurrency( + { + readDir: async () => [], + readFile: async () => ({ content: '', isBinary: false }), + stat: async () => ({ size: 0, type: 'file', mtime: 0 }), + readTranscriptBytes: bytes + }, + 1 + ) + const first = provider.readTranscriptBytes!('/one')[Symbol.asyncIterator](), + second = provider.readTranscriptBytes!('/two')[Symbol.asyncIterator]() + await first.next() + const pending = second.next() + await Promise.resolve() + expect(entered).toBe(1) + await first.return!() + await pending + expect(entered).toBe(2) + await second.return!() + }) +}) diff --git a/src/main/ai-vault/remote-session-transcript-read.ts b/src/main/ai-vault/remote-session-transcript-read.ts new file mode 100644 index 00000000000..42ea29af409 --- /dev/null +++ b/src/main/ai-vault/remote-session-transcript-read.ts @@ -0,0 +1,49 @@ +import { streamedSessionContentLines } from './remote-session-content-lines' +import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' +import type { RemoteSessionCandidate, RemoteScannerContext } from './remote-session-scanner-types' +import type { AiVaultSession } from '../../shared/ai-vault-types' + +const LEGACY_SESSION_TEXT_LIMIT_BYTES = 10 * 1024 * 1024 + +export async function parseRemoteSessionTranscript( + candidate: RemoteSessionCandidate, + context: RemoteScannerContext +): Promise { + const sidecar = candidate.file.sidecar + const exceedsWholeReadLimit = + (candidate.file.sizeBytes ?? 0) > LEGACY_SESSION_TEXT_LIMIT_BYTES || + (typeof sidecar === 'object' && sidecar.sizeBytes > LEGACY_SESSION_TEXT_LIMIT_BYTES) + if ( + exceedsWholeReadLimit && + candidate.source.parseDocument && + !candidate.file.path.endsWith('.jsonl') && + context.provider.readTranscriptBytes + ) { + return candidate.source.parseDocument( + candidate.file, + context.provider.readTranscriptBytes(candidate.file.path, context.signal), + context + ) + } + if ( + exceedsWholeReadLimit && + candidate.file.path.endsWith('.jsonl') && + candidate.source.parseLines && + context.provider.readTranscriptBytes + ) { + return candidate.source.parseLines( + candidate.file, + streamedSessionContentLines( + context.provider.readTranscriptBytes(candidate.file.path, context.signal), + context.signal + ), + context + ) + } + const read = await context.provider.readFile(candidate.file.path) + throwIfAiVaultScanCancelled(context.signal) + if (read.isBinary) { + return null + } + return await candidate.source.parse(candidate.file, read.content, context) +} diff --git a/src/main/ai-vault/session-document-stream-boundaries.test.ts b/src/main/ai-vault/session-document-stream-boundaries.test.ts new file mode 100644 index 00000000000..67901707cbd --- /dev/null +++ b/src/main/ai-vault/session-document-stream-boundaries.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest' +import { + parseHermesSessionContent, + parseHermesSessionDocument +} from './session-scanner-hermes-parser' +import { + parseClineSessionContent, + parseClineSessionDocuments +} from './session-scanner-cline-parser' +import { + remoteSessionContentLines, + streamedSessionContentLines +} from './remote-session-content-lines' + +const file = { + path: '/fixture/session/session.json', + mtimeMs: 0, + modifiedAt: new Date(0).toISOString() +} +const options = { + executionHostId: 'ssh:independent-review' as const, + executionHostPlatform: 'linux' as const +} +async function* bytes(data: Buffer | string, size = 3) { + const b = typeof data === 'string' ? Buffer.from(data) : data + for (let i = 0; i < b.length; i += size) { + yield b.subarray(i, i + size) + } +} +async function outcome(run: () => unknown) { + try { + return { value: await run() } + } catch (error) { + return { error: error instanceof Error ? error.name : typeof error } + } +} +async function lines(content: Iterable | AsyncIterable) { + const result: string[] = [] + for await (const line of content) { + result.push(line) + } + return result +} + +describe('independent JSON boundary review', () => { + for (const content of ['', ' \t\r\n']) { + it(`preserves empty-document parse outcome ${JSON.stringify(content)}`, async () => { + expect( + await outcome(() => parseHermesSessionDocument(file, bytes(content), 'linux', options)) + ).toEqual(await outcome(() => parseHermesSessionContent(file, content, 'linux', options))) + }) + } + for (const invalid of [[255], [195], [237, 160, 128], [240, 128, 128, 128], [226, 40, 161]]) { + it(`preserves legacy replacement decoding for UTF8 ${invalid.join('-')}`, async () => { + const data = Buffer.concat([ + Buffer.from('{"session_id":"id","messages":[{"role":"user","content":"before '), + Buffer.from(invalid), + Buffer.from(' after"}]}') + ]) + expect( + await outcome(() => parseHermesSessionDocument(file, bytes(data, 1), 'linux', options)) + ).toEqual( + await outcome(() => + parseHermesSessionContent(file, data.toString('utf8'), 'linux', options) + ) + ) + }) + } + it('ignores errors in an overwritten Cline messages array', async () => { + const metadata = '{"session_id":"id","prompt":"fallback"}' + const messages = '{"messages":[{"role":"user","content":"discarded","ts":1e300}],"messages":[]}' + expect( + await outcome(() => + parseClineSessionDocuments(file, bytes(metadata), () => bytes(messages), 'linux', options) + ) + ).toEqual( + await outcome(() => parseClineSessionContent(file, metadata, messages, 'linux', options)) + ) + }) + it('does not turn a bare carriage return into a JSONL record boundary', async () => { + const content = '{"role":"user","content":"first"}\r{"role":"assistant","content":"second"}' + expect(await lines(streamedSessionContentLines(bytes(content)))).toEqual( + await lines(remoteSessionContentLines(content)) + ) + }) + it('preserves escaped surrogate, duplicate nested key, and prototype-looking key values', async () => { + const content = String.raw`{"session_id":"id","__proto__":{"polluted":true},"messages":[{"role":"assistant","role":"user","content":"\ud800X\udc00 \ud83d\udc0b","__proto__":{"role":"assistant"}}]}` + expect(await parseHermesSessionDocument(file, bytes(content, 1), 'linux', options)).toEqual( + await parseHermesSessionContent(file, content, 'linux', options) + ) + expect(Reflect.get({}, 'polluted')).toBeUndefined() + }) + for (const content of [ + '{"messages":[],}', + '{"messages":[1,]}', + '{"messages":[01]}', + '{"messages":[NaN]}', + '{} {}' + ]) { + it(`rejects malformed JSON ${content}`, async () => { + expect( + await outcome(() => parseHermesSessionDocument(file, bytes(content), 'linux', options)) + ).toEqual(await outcome(() => parseHermesSessionContent(file, content, 'linux', options))) + }) + } +}) diff --git a/src/main/ai-vault/session-document-stream-parity.test.ts b/src/main/ai-vault/session-document-stream-parity.test.ts new file mode 100644 index 00000000000..441d25d2752 --- /dev/null +++ b/src/main/ai-vault/session-document-stream-parity.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest' +import { + parseHermesSessionContent, + parseHermesSessionDocument +} from './session-scanner-hermes-parser' +import { parseDevinSessionContent, parseDevinSessionDocument } from './session-scanner-devin-parser' +import { + parseGeminiSessionContent, + parseGeminiSessionDocument +} from './session-scanner-gemini-parsers' +import { + parseClineSessionContent, + parseClineSessionDocuments +} from './session-scanner-cline-parser' + +const file = { + path: '/sessions/identity/identity.json', + mtimeMs: 1000, + modifiedAt: new Date(1000).toISOString() +} +const options = { executionHostId: 'ssh:parity' as const, executionHostPlatform: 'darwin' as const } +async function* bytes(content: string) { + const data = Buffer.from(content) + for (let i = 0; i < data.length; i += 3) { + yield data.subarray(i, i + 3) + } +} +const fixtures = [ + { + agent: 'hermes', + parse: parseHermesSessionContent, + stream: parseHermesSessionDocument, + content: + '{"messages":[{"role":"user","content":"Ü🐋 first"},{"role":"assistant","content":"answer"}],"session_id":"id","cwd":"/repo","model":"root","session_start":"2026-01-01T00:00:00Z","last_updated":"2026-01-02T00:00:00Z","message_count":99}' + }, + { + agent: 'devin', + parse: parseDevinSessionContent, + stream: parseDevinSessionDocument, + content: + '{"steps":[{"role":"assistant","text":"answer","metadata":{"generation_model":"step","created_at":"2026-01-02T00:00:00Z","metrics":{"input_tokens":10,"output_tokens":20}}},{"metadata":{"is_user_input":true},"text":"Ü🐋 prompt"}],"agent":{"model_name":"root"},"session_id":"id","working_directory":"/repo"}' + }, + { + agent: 'gemini', + parse: parseGeminiSessionContent, + stream: parseGeminiSessionDocument, + content: + '{"messages":[{"type":"user","content":"Ü🐋 first","timestamp":"2026-01-02T00:00:00Z"},{"type":"gemini","content":"answer","tokens":{"input":10,"output":20}}],"sessionId":"id","startTime":"2026-01-01T00:00:00Z","lastUpdated":"2026-01-03T00:00:00Z"}' + } +] +describe('streamed whole-document parser equivalence', () => { + for (const fixture of fixtures) { + it(`${fixture.agent}: field order and UTF8 chunk boundaries preserve every output field`, async () => { + expect(await fixture.stream(file, bytes(fixture.content), 'darwin', options)).toEqual( + await fixture.parse(file, fixture.content, 'darwin', options) + ) + }) + for (const last of [ + '[]', + 'null', + '[{"role":"user","type":"user","content":"last","text":"last","metadata":{"is_user_input":true}}]' + ]) { + it(`${fixture.agent}: duplicate arrays use their final value ${last}`, async () => { + const key = fixture.agent === 'devin' ? 'steps' : 'messages' + const content = `${fixture.content.slice(0, -1)},"${key}":${last}}` + expect(await fixture.stream(file, bytes(content), 'darwin', options)).toEqual( + await fixture.parse(file, content, 'darwin', options) + ) + }) + } + it(`${fixture.agent}: rejects a malformed tail after valid messages`, async () => { + const content = fixture.content.slice(0, -1) + await expect(fixture.stream(file, bytes(content), 'darwin', options)).rejects.toThrow() + }) + } + it('Cline preserves sidecar semantics, metadata field order and duplicate arrays', async () => { + const metadata = + '{"session_id":"id","cwd":"/repo","started_at":"2026-01-01T00:00:00Z","prompt":"fallback"}' + for (const messages of [ + '{"messages":[{"role":"user","content":"Ü🐋 first","ts":"2026-01-02T00:00:00Z"},{"role":"assistant","content":"answer","modelInfo":{"id":"sidecar"}}],"updated_at":"2026-01-03T00:00:00Z"}', + '{"messages":[{"role":"user","content":"old"}],"messages":[]}', + '{"messages":[{"role":"user","content":"partial"}]' + ]) { + expect( + await parseClineSessionDocuments( + file, + bytes(metadata), + () => bytes(messages), + 'darwin', + options + ) + ).toEqual(parseClineSessionContent(file, metadata, messages, 'darwin', options)) + } + }) +}) diff --git a/src/main/ai-vault/session-document-stream-projection.test.ts b/src/main/ai-vault/session-document-stream-projection.test.ts new file mode 100644 index 00000000000..2234153da37 --- /dev/null +++ b/src/main/ai-vault/session-document-stream-projection.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest' +import { parseDevinSessionContent, parseDevinSessionDocument } from './session-scanner-devin-parser' +import { readStreamedSessionDocument } from './session-document-stream' +const file = { path: '/devin/test.json', modifiedAt: new Date(0).toISOString(), mtimeMs: 0 } +const options = { + executionHostId: 'ssh:projection' as const, + executionHostPlatform: 'darwin' as const +} +async function* bytes(content: string) { + const b = Buffer.from(content) + for (let i = 0; i < b.length; i += 7) { + yield b.subarray(i, i + 7) + } +} +describe('Devin consumed metadata projection', () => { + for (const suffix of [ + '{}', + 'null', + '[]', + '{"model":"last"}', + '{"model_name":"last-name","model":"fallback"}', + '{"model_name":[],"model":123}', + '{"model":"old","model":"last"}' + ]) { + it(`preserves duplicate root agent ${suffix}`, async () => { + const content = `{"agent":{"model_name":"old"},"steps":[{"role":"assistant","text":"message","metadata":{"generation_model":"step"}}],"generation_model":"root-fallback","agent":${suffix}}` + expect(await parseDevinSessionDocument(file, bytes(content), 'darwin', options)).toEqual( + parseDevinSessionContent(file, content, 'darwin', options) + ) + }) + } + it('retains only model fields from the agent object', async () => { + const result = await readStreamedSessionDocument({ + bytes: bytes( + '{"agent":{"ignored":{"many":[1,2,3]},"model_name":"root","model":"fallback"},"steps":[]}' + ), + arrayKey: 'steps', + fields: [], + objectFields: { agent: ['model_name', 'model'] }, + create: () => 0, + consume: () => {} + }) + expect(result).toEqual({ + record: { agent: { model_name: 'root', model: 'fallback' } }, + state: 0 + }) + }) +}) diff --git a/src/main/ai-vault/session-document-stream.ts b/src/main/ai-vault/session-document-stream.ts new file mode 100644 index 00000000000..4d12ab0fcc2 --- /dev/null +++ b/src/main/ai-vault/session-document-stream.ts @@ -0,0 +1,143 @@ +import { StringDecoder } from 'node:string_decoder' +import { JSONParser, TokenizerError, TokenParserError, TokenType } from '@streamparser/json' +import { setImmediate as yieldToEventLoop } from 'node:timers/promises' +import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' + +/** Fold one root array while retaining only the root fields the agent parser uses. */ +export async function readStreamedSessionDocument(args: { + bytes: AsyncIterable + arrayKey: string + fields: readonly string[] + objectFields?: Readonly> + create: () => T + consume: (state: T, value: unknown) => void + signal?: AbortSignal +}): Promise<{ record: Record; state: T } | null> { + const parser = new JSONParser({ + paths: [ + ...args.fields.map((field) => `$.${field}`), + ...Object.entries(args.objectFields ?? {}).flatMap(([root, fields]) => + fields.map((field) => `$.${root}.${field}`) + ), + ...(args.arrayKey ? [`$.${args.arrayKey}`, `$.${args.arrayKey}.*`] : []) + ], + keepStack: false, + stringBufferSize: 64 * 1024 + }) + const record: Record = Object.create(null) + const fields = new Set(args.fields) + let depth = 0 + let expectingRootKey = false + parser.onToken = ({ token, value }) => { + if (depth === 1 && expectingRootKey && token === TokenType.STRING) { + if (typeof value === 'string' && Object.hasOwn(args.objectFields ?? {}, value)) { + record[value] = Object.create(null) + } + expectingRootKey = false + } + if (token === TokenType.LEFT_BRACE || token === TokenType.LEFT_BRACKET) { + if (depth === 0 && token === TokenType.LEFT_BRACE) { + expectingRootKey = true + } + depth++ + } else if (token === TokenType.RIGHT_BRACE || token === TokenType.RIGHT_BRACKET) { + depth-- + } else if (token === TokenType.COMMA && depth === 1) { + expectingRootKey = true + } + } + let state = args.create() + let currentArray: unknown = null + let consumeFailure: { error: unknown } | undefined + const decoder = new StringDecoder('utf8') + let objectRoot: boolean | undefined + parser.onValue = ({ key, value, parent, stack }) => { + if (stack.length === 2 && stack[1].key === args.arrayKey && Array.isArray(parent)) { + if (parent !== currentArray) { + state = args.create() + consumeFailure = undefined + currentArray = parent + } + if (!consumeFailure) { + try { + args.consume(state, value) + } catch (error) { + consumeFailure = { error } + } + } + // The parser's array cursor is independent of retained array slots. + parent.pop() + } else if ( + stack.length === 2 && + typeof stack[1].key === 'string' && + typeof key === 'string' && + parent && + !Array.isArray(parent) + ) { + const root = stack[1].key + const projected = record[root] + if ( + Object.hasOwn(args.objectFields ?? {}, root) && + args.objectFields?.[root]?.includes(key) && + projected && + typeof projected === 'object' + ) { + Reflect.set(projected, key, value) + } + } else if (stack.length === 1 && typeof key === 'string') { + if (key === args.arrayKey) { + if (value !== currentArray || !Array.isArray(value)) { + state = args.create() + consumeFailure = undefined + } + currentArray = null + } else if (fields.has(key)) { + record[key] = value + } + if (parent && typeof parent === 'object') { + Reflect.deleteProperty(parent, key) + } + } + } + for await (const chunk of args.bytes) { + throwIfAiVaultScanCancelled(args.signal) + if (objectRoot === undefined) { + const first = chunk.find((byte) => byte !== 32 && byte !== 9 && byte !== 10 && byte !== 13) + if (first !== undefined) { + objectRoot = first === 123 + } + } + parseJson(() => parser.write(decoder.write(chunk))) + await yieldToEventLoop() + } + const tail = decoder.end() + if (tail) { + parseJson(() => parser.write(tail)) + } + if (objectRoot === undefined) { + throw new SyntaxError('Unexpected end of JSON input') + } + if (!parser.isEnded) { + parseJson(() => parser.end(), true) + } + throwIfAiVaultScanCancelled(args.signal) + if (consumeFailure) { + throw consumeFailure.error + } + return objectRoot ? { record, state } : null +} + +function parseJson(run: () => void, ending = false): void { + try { + run() + } catch (error) { + if ( + error instanceof TokenizerError || + error instanceof TokenParserError || + (ending && error instanceof Error) + ) { + throw new SyntaxError(error.message) + } + throw error + } +} diff --git a/src/main/ai-vault/session-scanner-antigravity-parser.ts b/src/main/ai-vault/session-scanner-antigravity-parser.ts index 126b56ce19a..775cc228370 100644 --- a/src/main/ai-vault/session-scanner-antigravity-parser.ts +++ b/src/main/ai-vault/session-scanner-antigravity-parser.ts @@ -1,4 +1,7 @@ -import { remoteSessionContentLines } from './remote-session-content-lines' +import { + remoteSessionContentLines, + type RemoteSessionContent +} from './remote-session-content-lines' import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' @@ -42,7 +45,7 @@ export async function parseAntigravitySessionFile( export async function parseAntigravitySessionContent( file: FileWithMtime, - content: string, + content: RemoteSessionContent, platform: NodeJS.Platform = process.platform, options: ParserSessionOptions = {}, signal?: AbortSignal diff --git a/src/main/ai-vault/session-scanner-cline-parser.ts b/src/main/ai-vault/session-scanner-cline-parser.ts index 462108cfed9..5ef97f09cb0 100644 --- a/src/main/ai-vault/session-scanner-cline-parser.ts +++ b/src/main/ai-vault/session-scanner-cline-parser.ts @@ -1,3 +1,6 @@ +import { isMissingRemoteSessionPathError } from './remote-session-file-stat' +import { BinarySessionTranscriptError } from './remote-session-content-lines' +import { readStreamedSessionDocument } from './session-document-stream' import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' import type { AiVaultSession } from '../../shared/ai-vault-types' @@ -8,7 +11,7 @@ import { finalizeSession, updateTimeline } from './session-scanner-accumulator' -import type { FileWithMtime } from './session-scanner-types' +import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' import type { TranscriptMessageSink } from './session-transcript-consumers' import { arrayValue, @@ -93,21 +96,7 @@ export function parseClineSessionContent( if (messages) { updateTimeline(accumulator, messages.updated_at) for (const value of arrayValue(messages.messages)) { - const message = asRecord(value) - const role = message?.role - if (!message || (role !== 'user' && role !== 'assistant')) { - continue - } - accumulator.messageCount++ - updateTimeline(accumulator, message.ts) - const content = message.content - if (role === 'user' && !accumulator.fallbackTitle) { - accumulator.fallbackTitle = normalizeTitleText(extractContentText(content) ?? '') - } - if (role === 'assistant' && !accumulator.model) { - accumulator.model = extractString(asRecord(message.modelInfo)?.id) - } - addPreviewContent(accumulator, role, content, message.ts) + consumeClineSessionMessage(accumulator, value) } } accumulator.fallbackTitle ??= normalizeTitleText(extractString(metadata.prompt) ?? '') @@ -122,3 +111,88 @@ function parseJsonRecord(content: string): Record | null { return null } } + +function consumeClineSessionMessage(accumulator: SessionAccumulator, value: unknown): void { + const message = asRecord(value) + const role = message?.role + if (!message || (role !== 'user' && role !== 'assistant')) { + return + } + accumulator.messageCount++ + updateTimeline(accumulator, message.ts) + const content = message.content + if (role === 'user' && !accumulator.fallbackTitle) { + accumulator.fallbackTitle = normalizeTitleText(extractContentText(content) ?? '') + } + if (role === 'assistant' && !accumulator.model) { + accumulator.model = extractString(asRecord(message.modelInfo)?.id) + } + addPreviewContent(accumulator, role, content, message.ts) +} + +export async function parseClineSessionDocuments( + file: FileWithMtime, + metadataBytes: AsyncIterable, + readMessages: () => AsyncIterable, + platform: NodeJS.Platform, + options: ParserSessionOptions, + signal?: AbortSignal +): Promise { + let metadata: Record + try { + const parsed = await readStreamedSessionDocument({ + bytes: metadataBytes, + arrayKey: '', + fields: ['session_id', 'cwd', 'workspace_root', 'model', 'started_at', 'prompt'], + create: () => null, + consume: () => {}, + signal + }) + if (!parsed) { + return null + } + metadata = parsed.record + } catch (error) { + if (error instanceof SyntaxError) { + return null + } + throw error + } + const create = (): SessionAccumulator => { + const pathSegments = file.path.replace(/\\/g, '/').split('/').filter(Boolean) + const accumulator = createAccumulator({ + agent: 'cline', + file, + sessionId: extractString(metadata.session_id) ?? pathSegments.at(-2) ?? '' + }) + accumulator.cwd = extractString(metadata.cwd) ?? extractString(metadata.workspace_root) + accumulator.model = extractString(metadata.model) + updateTimeline(accumulator, metadata.started_at) + return accumulator + } + let accumulator = create() + try { + const parsed = await readStreamedSessionDocument({ + bytes: readMessages(), + arrayKey: 'messages', + fields: ['updated_at'], + create, + consume: consumeClineSessionMessage, + signal + }) + if (parsed) { + accumulator = parsed.state + updateTimeline(accumulator, parsed.record.updated_at) + } + } catch (error) { + if ( + !(error instanceof SyntaxError) && + !(error instanceof BinarySessionTranscriptError) && + !isMissingRemoteSessionPathError(error) + ) { + throw error + } + } + accumulator.fallbackTitle ??= normalizeTitleText(extractString(metadata.prompt) ?? '') + return finalizeSession(accumulator, platform, options) +} diff --git a/src/main/ai-vault/session-scanner-codex-parser.ts b/src/main/ai-vault/session-scanner-codex-parser.ts index b3571d4a337..beff5eb06b0 100644 --- a/src/main/ai-vault/session-scanner-codex-parser.ts +++ b/src/main/ai-vault/session-scanner-codex-parser.ts @@ -66,7 +66,7 @@ export async function parseCodexSessionFile( export async function parseCodexSessionContent(args: { file: FileWithMtime - content: string + content: string | AsyncIterable platform?: NodeJS.Platform codexHome?: string | null executionHostId?: ExecutionHostId diff --git a/src/main/ai-vault/session-scanner-copilot-parser.ts b/src/main/ai-vault/session-scanner-copilot-parser.ts index 239c5983457..a4d34fa5b13 100644 --- a/src/main/ai-vault/session-scanner-copilot-parser.ts +++ b/src/main/ai-vault/session-scanner-copilot-parser.ts @@ -1,4 +1,7 @@ -import { remoteSessionContentLines } from './remote-session-content-lines' +import { + remoteSessionContentLines, + type RemoteSessionContent +} from './remote-session-content-lines' import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' @@ -45,7 +48,7 @@ export async function parseCopilotSessionFile( export async function parseCopilotSessionContent( file: FileWithMtime, - content: string, + content: RemoteSessionContent, platform: NodeJS.Platform = process.platform, options: ParserSessionOptions = {}, signal?: AbortSignal diff --git a/src/main/ai-vault/session-scanner-cursor-parser.ts b/src/main/ai-vault/session-scanner-cursor-parser.ts index bfa15caa530..a05817e2dc0 100644 --- a/src/main/ai-vault/session-scanner-cursor-parser.ts +++ b/src/main/ai-vault/session-scanner-cursor-parser.ts @@ -1,4 +1,7 @@ -import { remoteSessionContentLines } from './remote-session-content-lines' +import { + remoteSessionContentLines, + type RemoteSessionContent +} from './remote-session-content-lines' import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' @@ -43,7 +46,7 @@ export async function parseCursorSessionFile( export async function parseCursorSessionContent( file: FileWithMtime, - content: string, + content: RemoteSessionContent, platform: NodeJS.Platform = process.platform, options: ParserSessionOptions = {}, signal?: AbortSignal diff --git a/src/main/ai-vault/session-scanner-devin-parser.ts b/src/main/ai-vault/session-scanner-devin-parser.ts index 12e40ef3fc2..d3a62b08a59 100644 --- a/src/main/ai-vault/session-scanner-devin-parser.ts +++ b/src/main/ai-vault/session-scanner-devin-parser.ts @@ -1,7 +1,8 @@ +import { readStreamedSessionDocument } from './session-document-stream' import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' -import type { FileWithMtime } from './session-scanner-types' +import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' import type { TranscriptMessageSink } from './session-transcript-consumers' import { addPreviewContent, @@ -70,38 +71,8 @@ function parseDevinSessionRecord( extractString(agentRecord?.model) ?? extractString(record.generation_model) accumulator.cwd = extractString(record.working_directory) - const steps = arrayValue(record.steps) - for (const step of steps) { - const stepRecord = asRecord(step) - if (!stepRecord) { - continue - } - const metadata = asRecord(stepRecord.metadata) - updateTimeline(accumulator, extractString(metadata?.created_at)) - const metrics = asRecord(metadata?.metrics) - accumulator.model ??= - extractString(metadata?.generation_model) ?? extractString(metrics?.generation_model) - accumulator.totalTokens += devinStepTokenTotal(metadata, metrics) - const isUser = metadata?.is_user_input === true - if (isUser) { - accumulator.messageCount++ - const text = - extractDevinStepText(stepRecord) ?? - extractContentText(stepRecord.content) ?? - extractString(stepRecord.text) - const titleCandidate = normalizeTitleText(text ?? '') - if (titleCandidate) { - accumulator.title ??= titleCandidate - } - addPreviewContent(accumulator, 'user', text ?? stepRecord.content) - } else if (extractString(stepRecord.role) === 'assistant' || stepRecord.tool_calls) { - accumulator.messageCount++ - addPreviewContent( - accumulator, - 'assistant', - extractDevinStepText(stepRecord) ?? stepRecord.content - ) - } + for (const step of arrayValue(record.steps)) { + consumeDevinSessionStep(accumulator, step) } return finalizeSession(accumulator, platform, options) } @@ -147,3 +118,71 @@ function numberFromDevinMetadata( } return 0 } + +export function consumeDevinSessionStep(accumulator: SessionAccumulator, step: unknown): void { + const stepRecord = asRecord(step) + if (!stepRecord) { + return + } + const metadata = asRecord(stepRecord.metadata) + updateTimeline(accumulator, extractString(metadata?.created_at)) + const metrics = asRecord(metadata?.metrics) + accumulator.model ??= + extractString(metadata?.generation_model) ?? extractString(metrics?.generation_model) + accumulator.totalTokens += devinStepTokenTotal(metadata, metrics) + const isUser = metadata?.is_user_input === true + if (isUser) { + accumulator.messageCount++ + const text = + extractDevinStepText(stepRecord) ?? + extractContentText(stepRecord.content) ?? + extractString(stepRecord.text) + const titleCandidate = normalizeTitleText(text ?? '') + if (titleCandidate) { + accumulator.title ??= titleCandidate + } + addPreviewContent(accumulator, 'user', text ?? stepRecord.content) + } else if (extractString(stepRecord.role) === 'assistant' || stepRecord.tool_calls) { + accumulator.messageCount++ + addPreviewContent( + accumulator, + 'assistant', + extractDevinStepText(stepRecord) ?? stepRecord.content + ) + } +} + +export async function parseDevinSessionDocument( + file: FileWithMtime, + bytes: AsyncIterable, + platform: NodeJS.Platform, + options: ParserSessionOptions, + signal?: AbortSignal +): Promise { + const parsed = await readStreamedSessionDocument({ + bytes, + arrayKey: 'steps', + fields: ['session_id', 'sessionId', 'generation_model', 'working_directory'], + objectFields: { agent: ['model_name', 'model'] }, + create: () => + createAccumulator({ agent: 'devin', file, sessionId: sessionIdFromFileName(file.path) }), + consume: consumeDevinSessionStep, + signal + }) + if (!parsed) { + return null + } + const { record, state: accumulator } = parsed + accumulator.sessionId = + extractString(record.session_id) ?? + extractString(record.sessionId) ?? + sessionIdFromFileName(file.path) + const agentRecord = asRecord(record.agent) + accumulator.model = + extractString(agentRecord?.model_name) ?? + extractString(agentRecord?.model) ?? + extractString(record.generation_model) ?? + accumulator.model + accumulator.cwd = extractString(record.working_directory) + return finalizeSession(accumulator, platform, options) +} diff --git a/src/main/ai-vault/session-scanner-droid-parser.ts b/src/main/ai-vault/session-scanner-droid-parser.ts index 748a8a6bcc9..7865ecc1827 100644 --- a/src/main/ai-vault/session-scanner-droid-parser.ts +++ b/src/main/ai-vault/session-scanner-droid-parser.ts @@ -1,4 +1,7 @@ -import { remoteSessionContentLines } from './remote-session-content-lines' +import { + remoteSessionContentLines, + type RemoteSessionContent +} from './remote-session-content-lines' import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' @@ -50,7 +53,7 @@ export async function parseDroidSessionFile( export async function parseDroidSessionContent( file: FileWithMtime, - content: string, + content: RemoteSessionContent, platform: NodeJS.Platform = process.platform, options: ParserSessionOptions = {}, signal?: AbortSignal diff --git a/src/main/ai-vault/session-scanner-gemini-parsers.ts b/src/main/ai-vault/session-scanner-gemini-parsers.ts index efc9eef9e10..a1d7e259f79 100644 --- a/src/main/ai-vault/session-scanner-gemini-parsers.ts +++ b/src/main/ai-vault/session-scanner-gemini-parsers.ts @@ -1,3 +1,4 @@ +import { readStreamedSessionDocument } from './session-document-stream' import { remoteSessionContentLines } from './remote-session-content-lines' import { openTranscriptReadStream, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' import { createInterface } from 'node:readline' @@ -135,7 +136,7 @@ export function createGeminiJsonlSessionResumeState( ) } -async function parseGeminiJsonlSessionLines(args: { +export async function parseGeminiJsonlSessionLines(args: { file: FileWithMtime lines: AsyncIterable | Iterable platform: NodeJS.Platform @@ -173,3 +174,29 @@ export function consumeGeminiMessage( accumulator.totalTokens += tokenTotal(record.tokens) } } + +export async function parseGeminiSessionDocument( + file: FileWithMtime, + bytes: AsyncIterable, + platform: NodeJS.Platform, + options: ResumableParseFinalizeOptions, + signal?: AbortSignal +): Promise { + const parsed = await readStreamedSessionDocument({ + bytes, + arrayKey: 'messages', + fields: ['sessionId', 'startTime', 'lastUpdated'], + create: () => + createAccumulator({ agent: 'gemini', file, sessionId: sessionIdFromFileName(file.path) }), + consume: (state, value) => consumeGeminiMessage(state, asRecord(value)), + signal + }) + if (!parsed) { + return null + } + const { record, state: accumulator } = parsed + accumulator.sessionId = extractString(record.sessionId) ?? sessionIdFromFileName(file.path) + updateTimeline(accumulator, extractString(record.startTime)) + updateTimeline(accumulator, extractString(record.lastUpdated)) + return finalizeSession(accumulator, platform, options) +} diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts index 581e885fd96..3ca4e754106 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -1,4 +1,7 @@ -import { remoteSessionContentLines } from './remote-session-content-lines' +import { + remoteSessionContentLines, + type RemoteSessionContent +} from './remote-session-content-lines' import { openTranscriptReadStream, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' import { basename, dirname, join } from 'node:path' import { createInterface } from 'node:readline' @@ -195,7 +198,7 @@ export async function parseMessageGraphSessionFile( export async function parseMessageGraphSessionContent( agent: MessageGraphAgent, file: FileWithMtime, - content: string, + content: RemoteSessionContent, platform: NodeJS.Platform = process.platform, options: ParserSessionOptions = {}, signal?: AbortSignal diff --git a/src/main/ai-vault/session-scanner-hermes-parser.ts b/src/main/ai-vault/session-scanner-hermes-parser.ts index f532fa8241d..784c53f0408 100644 --- a/src/main/ai-vault/session-scanner-hermes-parser.ts +++ b/src/main/ai-vault/session-scanner-hermes-parser.ts @@ -1,7 +1,8 @@ +import { readStreamedSessionDocument } from './session-document-stream' import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' -import type { FileWithMtime } from './session-scanner-types' +import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' import type { TranscriptMessageSink } from './session-transcript-consumers' import { addPreviewContent, @@ -69,18 +70,56 @@ async function parseHermesSessionRecord( updateTimeline(accumulator, extractString(record.session_start)) updateTimeline(accumulator, extractString(record.last_updated)) for (const message of arrayValue(record.messages)) { - const messageRecord = asRecord(message) - const role = extractString(messageRecord?.role) - if (role === 'user' || role === 'assistant') { - accumulator.messageCount++ - if (role === 'user') { - accumulator.title ??= extractContentText(messageRecord?.content) - } - addPreviewContent(accumulator, role, messageRecord?.content) - } + consumeHermesSessionMessage(accumulator, message) } if (accumulator.messageCount === 0) { accumulator.messageCount = numberValue(record.message_count) } return finalizeSession(accumulator, platform, options) } + +export function consumeHermesSessionMessage( + accumulator: SessionAccumulator, + message: unknown +): void { + const messageRecord = asRecord(message) + const role = extractString(messageRecord?.role) + if (role === 'user' || role === 'assistant') { + accumulator.messageCount++ + if (role === 'user') { + accumulator.title ??= extractContentText(messageRecord?.content) + } + addPreviewContent(accumulator, role, messageRecord?.content) + } +} + +export async function parseHermesSessionDocument( + file: FileWithMtime, + bytes: AsyncIterable, + platform: NodeJS.Platform, + options: ParserSessionOptions, + signal?: AbortSignal +): Promise { + const parsed = await readStreamedSessionDocument({ + bytes, + arrayKey: 'messages', + fields: ['session_id', 'model', 'cwd', 'session_start', 'last_updated', 'message_count'], + create: () => + createAccumulator({ agent: 'hermes', file, sessionId: sessionIdFromFileName(file.path) }), + consume: consumeHermesSessionMessage, + signal + }) + if (!parsed) { + return null + } + const { record, state: accumulator } = parsed + accumulator.sessionId = extractString(record.session_id) ?? sessionIdFromFileName(file.path) + accumulator.model = extractString(record.model) + accumulator.cwd = extractString(record.cwd) + updateTimeline(accumulator, extractString(record.session_start)) + updateTimeline(accumulator, extractString(record.last_updated)) + if (accumulator.messageCount === 0) { + accumulator.messageCount = numberValue(record.message_count) + } + return finalizeSession(accumulator, platform, options) +} diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts index 62149920b5a..acd556b5504 100644 --- a/src/main/ai-vault/session-scanner-primary-parsers.ts +++ b/src/main/ai-vault/session-scanner-primary-parsers.ts @@ -1,4 +1,7 @@ -import { remoteSessionContentLines } from './remote-session-content-lines' +import { + remoteSessionContentLines, + type RemoteSessionContent +} from './remote-session-content-lines' import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' @@ -229,7 +232,7 @@ export async function parseClaudeSessionFile( export async function parseClaudeSessionContent( file: FileWithMtime, - content: string, + content: RemoteSessionContent, platform: NodeJS.Platform = process.platform, options: ParserSessionOptions = {}, signal?: AbortSignal diff --git a/src/main/native-chat/transcript-stream-lines.ts b/src/main/native-chat/transcript-stream-lines.ts index 5396ecd076a..4a58b4e180f 100644 --- a/src/main/native-chat/transcript-stream-lines.ts +++ b/src/main/native-chat/transcript-stream-lines.ts @@ -13,44 +13,17 @@ export async function decodeTranscriptStream( includeTrailingLine: boolean ): Promise<{ messages: NativeChatMessage[]; consumedBytes: number }> { const messages: NativeChatMessage[] = [] - // Why: a Buffer chunk can end mid-codepoint, and decoding it standalone would - // both corrupt the line and shift `consumedBytes` (which seeds fallback ids). - const decoder = new StringDecoder('utf8') - let pending: string[] = [] let consumedBytes = 0 - + const framer = createTranscriptLineFramer((line, byteLength, terminated) => { + if (terminated || includeTrailingLine) { + decodeLine(line, consumedBytes) + consumedBytes += byteLength + } + }) for await (const chunk of stream) { - const text = typeof chunk === 'string' ? chunk : decoder.write(Buffer.from(chunk)) - // Only the new chunk is scanned; partial records wait in `pending` unrescanned. - let lineStart = 0 - let newlineIndex = text.indexOf('\n') - while (newlineIndex !== -1) { - let segment = text.slice(lineStart, newlineIndex + 1) - if (pending.length > 0) { - // Join a fragmented record only once, including split string surrogate pairs. - pending.push(segment) - segment = pending.join('') - pending = [] - } - decodeLine(segment.slice(0, -1), consumedBytes) - consumedBytes += Buffer.byteLength(segment, 'utf8') - lineStart = newlineIndex + 1 - newlineIndex = text.indexOf('\n', lineStart) - } - if (lineStart < text.length) { - pending.push(text.slice(lineStart)) - } - } - const tail = decoder.end() - if (tail) { - pending.push(tail) - } - - if (includeTrailingLine && pending.length > 0) { - const line = pending.join('') - decodeLine(line, consumedBytes) - consumedBytes += Buffer.byteLength(line, 'utf8') + framer.write(chunk) } + framer.end() return { messages, consumedBytes } @@ -65,3 +38,64 @@ export async function decodeTranscriptStream( } } } + +type TranscriptLine = { line: string; byteLength: number; terminated: boolean } + +export async function* splitTranscriptStreamLines( + stream: AsyncIterable +): AsyncGenerator { + let records: TranscriptLine[] = [] + const framer = createTranscriptLineFramer((line, byteLength, terminated) => { + records.push({ line, byteLength, terminated }) + }) + for await (const chunk of stream) { + framer.write(chunk) + for (const record of records) { + yield record + } + records = [] + } + framer.end() + for (const record of records) { + yield record + } +} + +/** Frame chunks synchronously so native decoding avoids a promise per record. */ +function createTranscriptLineFramer( + emit: (line: string, byteLength: number, terminated: boolean) => void +): { write(chunk: Buffer | string): void; end(): void } { + const decoder = new StringDecoder('utf8') + let pending: string[] = [] + return { write, end } + + function write(chunk: Buffer | string): void { + const text = typeof chunk === 'string' ? chunk : decoder.write(chunk) + let lineStart = 0 + let newlineIndex = text.indexOf('\n') + while (newlineIndex !== -1) { + let segment = text.slice(lineStart, newlineIndex + 1) + if (pending.length > 0) { + pending.push(segment) + segment = pending.join('') + pending = [] + } + emit(segment.slice(0, -1), Buffer.byteLength(segment, 'utf8'), true) + lineStart = newlineIndex + 1 + newlineIndex = text.indexOf('\n', lineStart) + } + if (lineStart < text.length) { + pending.push(text.slice(lineStart)) + } + } + + function end(): void { + const tail = decoder.end() + if (tail) { + pending.push(tail) + } + const line = pending.join('') + emit(line, Buffer.byteLength(line, 'utf8'), false) + pending = [] + } +} diff --git a/src/relay/ai-vault-service-filesystem.ts b/src/relay/ai-vault-service-filesystem.ts index 9315b2e4b2f..fd5ff90a755 100644 --- a/src/relay/ai-vault-service-filesystem.ts +++ b/src/relay/ai-vault-service-filesystem.ts @@ -1,3 +1,4 @@ +import { readRelayTranscriptBytes } from './ai-vault-transcript-stream' import { lstat, readdir } from 'node:fs/promises' import type { RemoteSessionFilesystemProvider } from '../main/ai-vault/remote-session-scanner-types' import { readRelayFileContent } from './fs-handler-file-read' @@ -13,6 +14,7 @@ export function createRelayAiVaultFilesystemProvider(): RemoteSessionFilesystemP })) }, readFile: readRelayFileContent, + readTranscriptBytes: readRelayTranscriptBytes, async stat(filePath) { const stats = await lstat(filePath) return { diff --git a/src/relay/ai-vault-transcript-stream.ts b/src/relay/ai-vault-transcript-stream.ts new file mode 100644 index 00000000000..df040a166fa --- /dev/null +++ b/src/relay/ai-vault-transcript-stream.ts @@ -0,0 +1,34 @@ +import { open } from 'node:fs/promises' +import { throwIfAiVaultScanCancelled } from '../main/ai-vault/ai-vault-scan-cancellation' +import { BinarySessionTranscriptError } from '../main/ai-vault/remote-session-content-lines' +import { BINARY_PROBE_BYTES, isBinaryBuffer } from './fs-handler-utils' + +/** The same open handle supplies the probe and stream, including across renames. */ +export async function* readRelayTranscriptBytes( + path: string, + signal?: AbortSignal +): AsyncGenerator { + throwIfAiVaultScanCancelled(signal) + const handle = await open(path, 'r') + try { + const probe = Buffer.alloc(BINARY_PROBE_BYTES) + const { bytesRead } = await handle.read(probe, 0, probe.length, 0) + if (isBinaryBuffer(probe.subarray(0, bytesRead))) { + throw new BinarySessionTranscriptError() + } + const input = handle.createReadStream({ start: 0, autoClose: false, signal }) + try { + for await (const chunk of input) { + throwIfAiVaultScanCancelled(signal) + if (!Buffer.isBuffer(chunk)) { + throw new TypeError('Expected transcript byte buffer') + } + yield chunk + } + } finally { + input.destroy() + } + } finally { + await handle.close() + } +} From e944e7653787b9a70c095437374eb71dc90363dd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:55:41 -0700 Subject: [PATCH 05/34] fix(grok): stop replayed Claude/Cursor hooks reporting Grok panes as Claude (#20507) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(grok): stop replayed Claude/Cursor hooks reporting Grok panes as Claude Grok's hook discovery reads ~/.claude/settings.json (and the Cursor equivalent) for vendor compatibility, and that is on by default. So inside every Grok pane Orca's managed Claude hook fires in addition to Orca's managed Grok hook, and both POST the same Grok envelope. The Claude-routed copy lands last and wins, so the pane's agent type is resolved from the POST route as "claude" and no Grok-specific normalization runs for it. Guard the managed Claude and Cursor scripts on GROK_HOOK_EVENT, which Grok's hook runner stamps into every hook subprocess it spawns — including replayed vendor configs — after any user-supplied environment, so a hook cannot spoof it. This mirrors the existing DEVIN_PROJECT_DIR guard in the same script, which solves the identical problem for another agent that imports Claude hooks. Placement is load-bearing: the guard sits after the stdin capture, so Grok's writer never blocks, and before both the spool write and the HTTP POST, so a replayed event cannot leave a spool entry that replays later. The Windows variants jump to the stdin-drain label rather than exiting, because abandoning stdin there hangs the writer. The guard is scoped to agent === 'claude'; OpenClaude reuses ClaudeHookService with its own settings file, which Grok does not replay, so it is unaffected. Verified live against Grok 1.0.25 in a dev instance: the pane's reported agent type goes from "claude" to "grok" on every turn-end, including the hidden follow-up turns Grok runs when background work finishes. The guard pushed hook-service.ts past the 300-line cap, so the script builder moves to a sibling hook-script.ts. That mirrors the existing split under src/main/cursor/, where the service owns install/status and the script module owns script text. * fix(agent-hooks): preserve Windows background worker stdin contract --- config/tsconfig.cli.json | 2 + .../agent-hooks/grok-replay-guard.test.ts | 136 ++++++++++++++++++ src/main/agent-hooks/grok-replay-guard.ts | 14 ++ src/main/claude/hook-script.ts | 93 ++++++++++++ src/main/claude/hook-service.test.ts | 5 + src/main/claude/hook-service.ts | 103 +++---------- src/main/cursor/hook-script.ts | 6 + src/main/cursor/hook-service.test.ts | 1 + 8 files changed, 274 insertions(+), 86 deletions(-) create mode 100644 src/main/agent-hooks/grok-replay-guard.test.ts create mode 100644 src/main/agent-hooks/grok-replay-guard.ts create mode 100644 src/main/claude/hook-script.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 70d52e0802c..9cf3779c03c 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -4,6 +4,8 @@ "../src/cli/**/*", "../src/shared/**/*", "../src/main/agent-state-file-reader.ts", + "../src/main/agent-hooks/grok-replay-guard.ts", + "../src/main/claude/hook-script.ts", "../src/main/agent-hooks/hook-stdin-contract.ts", "../src/main/agent-hooks/hook-post-command.ts", "../src/main/agent-hooks/hook-config-write-path.ts", diff --git a/src/main/agent-hooks/grok-replay-guard.test.ts b/src/main/agent-hooks/grok-replay-guard.test.ts new file mode 100644 index 00000000000..1da54f287d2 --- /dev/null +++ b/src/main/agent-hooks/grok-replay-guard.test.ts @@ -0,0 +1,136 @@ +import { spawnSync } from 'node:child_process' +import { chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { + getPath: () => '/tmp/userData' + } +})) + +import { getManagedScript as getClaudeManagedScript } from '../claude/hook-service' +import { getManagedScript as getCursorManagedScript } from '../cursor/hook-script' + +const POSIX_GROK_GUARD = 'if [ -n "$GROK_HOOK_EVENT" ]; then' +const WINDOWS_GROK_GUARD = 'if not "%GROK_HOOK_EVENT%"=="" goto :orca_agent_hook_drain_stdin' +const CLAUDE_SCRIPT_OPTIONS = { + skipWhenDevinImportsClaude: true, + skipWhenGrokImportsClaude: true +} + +function withPlatform(platform: NodeJS.Platform, run: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(process, 'platform')! + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) + try { + return run() + } finally { + Object.defineProperty(process, 'platform', descriptor) + } +} + +function expectGuardBeforeTransport( + script: string, + guard: string, + response: string, + spool?: string +): void { + const guardIndex = script.indexOf(guard) + expect(guardIndex).toBeGreaterThan(script.indexOf(response)) + expect(guardIndex).toBeLessThan(script.indexOf('curl')) + if (spool) { + expect(guardIndex).toBeLessThan(script.indexOf(spool)) + } +} + +function runPosixHook( + script: string, + grokHookEvent: string +): { + curlCalled: boolean + stdout: string +} { + const dir = mkdtempSync(join(tmpdir(), 'orca-grok-replay-')) + const scriptPath = join(dir, 'hook.sh') + const curlPath = join(dir, 'curl') + const curlLog = join(dir, 'curl.log') + try { + writeFileSync(scriptPath, script) + writeFileSync( + curlPath, + '#!/bin/sh\n{ command -p cat 2>/dev/null || cat; } >/dev/null\nprintf "called\\n" >> "$CURL_LOG"\n' + ) + chmodSync(scriptPath, 0o755) + chmodSync(curlPath, 0o755) + + const result = spawnSync('/bin/sh', [scriptPath], { + encoding: 'utf8', + input: '{"hook_event_name":"Stop"}', + env: { + ...process.env, + PATH: `${dir}:${process.env.PATH ?? ''}`, + CURL_LOG: curlLog, + GROK_HOOK_EVENT: grokHookEvent, + ORCA_AGENT_HOOK_ENDPOINT: '', + ORCA_AGENT_HOOK_PORT: '1234', + ORCA_AGENT_HOOK_TOKEN: 'token', + ORCA_PANE_KEY: 'tab:leaf' + } + }) + + expect(result.error).toBeUndefined() + expect(result.status).toBe(0) + return { curlCalled: existsSync(curlLog), stdout: result.stdout } + } finally { + rmSync(dir, { recursive: true, force: true }) + } +} + +describe('Grok vendor hook replay guard', () => { + it('precedes spooling and HTTP in the generated POSIX Claude and Cursor scripts', () => { + const claude = getClaudeManagedScript('posix', CLAUDE_SCRIPT_OPTIONS) + const cursor = getCursorManagedScript('posix') + + expectGuardBeforeTransport(claude, POSIX_GROK_GUARD, 'printf "{}\\n"', 'spool_hook_event') + expectGuardBeforeTransport(cursor, POSIX_GROK_GUARD, 'printf "{}\\n"', 'spool_hook_event') + }) + + it('precedes HTTP while preserving fail-open output in generated Windows scripts', () => { + const { claude, cursor } = withPlatform('win32', () => ({ + claude: getClaudeManagedScript('local', CLAUDE_SCRIPT_OPTIONS), + cursor: getCursorManagedScript('local') + })) + + expectGuardBeforeTransport(claude, WINDOWS_GROK_GUARD, 'echo {}') + expectGuardBeforeTransport(cursor, WINDOWS_GROK_GUARD, '(echo {})') + const backgroundWorkerGuardIndex = claude.indexOf('CLAUDE_JOB_DIR') + expect(backgroundWorkerGuardIndex).toBeGreaterThan(-1) + expect(backgroundWorkerGuardIndex).toBeLessThan(claude.indexOf(WINDOWS_GROK_GUARD)) + }) + + it.skipIf(process.platform === 'win32')( + 'drops Grok-replayed hooks without suppressing their protocol response', + () => { + for (const script of [ + getClaudeManagedScript('posix', CLAUDE_SCRIPT_OPTIONS), + getCursorManagedScript('posix') + ]) { + const result = runPosixHook(script, 'Stop') + expect(result.curlCalled).toBe(false) + expect(result.stdout).toBe('{}\n') + } + } + ) + + it.skipIf(process.platform === 'win32')('leaves non-Grok hook delivery unchanged', () => { + for (const script of [ + getClaudeManagedScript('posix', CLAUDE_SCRIPT_OPTIONS), + getCursorManagedScript('posix') + ]) { + const result = runPosixHook(script, '') + expect(result.curlCalled).toBe(true) + expect(result.stdout).toBe('{}\n') + } + }) +}) diff --git a/src/main/agent-hooks/grok-replay-guard.ts b/src/main/agent-hooks/grok-replay-guard.ts new file mode 100644 index 00000000000..6309ed6e584 --- /dev/null +++ b/src/main/agent-hooks/grok-replay-guard.ts @@ -0,0 +1,14 @@ +import { WINDOWS_HOOK_STDIN_DRAIN_LABEL } from './hook-stdin-contract' + +export function buildPosixGrokReplayGuardLines(): string[] { + return [ + // Why: Grok imports vendor hooks; only its native hook may report the event as Grok. + 'if [ -n "$GROK_HOOK_EVENT" ]; then', + ' exit 0', + 'fi' + ] +} + +export function buildWindowsGrokReplayGuardLines(): string[] { + return [`if not "%GROK_HOOK_EVENT%"=="" goto :${WINDOWS_HOOK_STDIN_DRAIN_LABEL}`] +} diff --git a/src/main/claude/hook-script.ts b/src/main/claude/hook-script.ts new file mode 100644 index 00000000000..efbe71fc9e3 --- /dev/null +++ b/src/main/claude/hook-script.ts @@ -0,0 +1,93 @@ +/** The managed Claude-compatible hook script, built for local, POSIX-remote and Windows targets. + * Split from hook-service.ts so the service owns install/status and this owns script text, + * mirroring the same split under src/main/cursor/. */ +import { buildWindowsAgentHookCurlPostCommand } from '../agent-hooks/installer-utils' +import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command' +import { + buildPosixGrokReplayGuardLines, + buildWindowsGrokReplayGuardLines +} from '../agent-hooks/grok-replay-guard' +import { + WINDOWS_HOOK_STDIN_DRAIN_LABEL, + buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, + buildWindowsHookEnvironmentGuardLines, + buildWindowsHookStdinDrainEpilogue +} from '../agent-hooks/hook-stdin-contract' + +export function getManagedScript( + target: 'local' | 'posix' = 'local', + options: { + skipWhenDevinImportsClaude?: boolean + skipWhenGrokImportsClaude?: boolean + } = {} +): string { + if (target === 'local' && process.platform === 'win32') { + return [ + '@echo off', + 'setlocal', + // Why: Claude-compatible permission hooks fail closed on empty stdout (#14818). + 'echo {}', + // Why: refresh endpoint coordinates for PTYs surviving an Orca restart. + 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', + // Why (#11549): the env guards must outrank the Devin skip — the Devin skip parks in more.com, + // and outside an Orca pane the caller can abandon stdin, so more.com never returns. + ...buildWindowsHookEnvironmentGuardLines(), + // Why: a backgrounded session runs in a daemon worker that inherited the dispatching + // pane's env, so ORCA_PANE_KEY names a pane this session does not run in (#9236). + // Why exit, not the drain label: the drain parks in more.com and a worker is outside + // an Orca pane — the abandoned-stdin hang #11549 guards against. + 'if not "%CLAUDE_JOB_DIR%"=="" exit /b 0', + ...(options.skipWhenGrokImportsClaude ? buildWindowsGrokReplayGuardLines() : []), + ...(options.skipWhenDevinImportsClaude + ? [ + // Why: Devin imports .claude hooks by default; skip Orca's managed hook there so status posts stay attributed to Devin. + `if not "%DEVIN_PROJECT_DIR%"=="" goto :${WINDOWS_HOOK_STDIN_DRAIN_LABEL}` + ] + : []), + // Why: use curl.exe to avoid an extra PowerShell startup per hook. + buildWindowsAgentHookCurlPostCommand('claude'), + 'exit /b 0', + ...buildWindowsHookStdinDrainEpilogue(), + '' + ].join('\r\n') + } + + return [ + '#!/bin/sh', + // Why: Claude-compatible permission hooks fail closed on empty stdout (#14818). + 'printf "{}\\n"', + ...buildPosixHookPayloadCapture(), + ...(options.skipWhenGrokImportsClaude ? buildPosixGrokReplayGuardLines() : []), + ...buildPosixHookSpoolLines('claude'), + ...(options.skipWhenDevinImportsClaude + ? [ + // Why: Devin imports .claude hooks by default; skip Orca's managed hook there so status posts stay attributed to Devin. + 'if [ -n "$DEVIN_PROJECT_DIR" ]; then', + ' exit 0', + 'fi' + ] + : []), + // Why: a backgrounded session runs in a daemon worker that inherited the dispatching + // pane's env, so ORCA_PANE_KEY names a pane this session does not run in (#9236). + 'if [ -n "$CLAUDE_JOB_DIR" ]; then', + ' exit 0', + 'fi', + // Why: refresh endpoint coordinates for PTYs surviving an Orca restart. + // Why: suppress parse errors so they neither leak nor trip outer set -e. + 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', + ' unset ORCA_AGENT_HOOK_TRANSPORT', + ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', + 'fi', + 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', + ' exit 0', + 'fi', + // Why: keep full hook JSON off the command line and avoid IDS-friendly URL-encoded paths. + ...buildPosixAgentHookPostCommand('claude').map((line, index, lines) => + index === lines.length - 1 ? `${line} >/dev/null 2>&1 || spool_hook_event` : line + ), + 'exit 0', + '' + ].join('\n') +} diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index a4e48c98120..2a07aff4c8a 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -262,6 +262,7 @@ describe('ClaudeHookService.install', () => { 'utf-8' ) expect(managedScript).toContain('DEVIN_PROJECT_DIR') + expect(managedScript).toContain('GROK_HOOK_EVENT') // Why: guard and Devin-skip paths must still return neutral JSON (#14818). expect(managedScript).toMatch( process.platform === 'win32' @@ -711,6 +712,7 @@ describe('ClaudeHookService.installRemote', () => { const script = fs.files.get('/home/dev/.orca/agent-hooks/claude-hook.sh') expect(script).toContain('#!/bin/sh') expect(script).toContain('DEVIN_PROJECT_DIR') + expect(script).toContain('GROK_HOOK_EVENT') // Why: remote guard paths must still return neutral JSON (#14818). expect(script!.indexOf('printf "{}\\n"')).toBe( script!.indexOf('#!/bin/sh') + '#!/bin/sh\n'.length @@ -813,6 +815,9 @@ describe('OpenClaudeHookService-compatible install', () => { expect( readFileSync(join(tmpHome, '.orca', 'agent-hooks', OPENCLAUDE_SCRIPT_FILE_NAME), 'utf-8') ).not.toContain('DEVIN_PROJECT_DIR') + expect( + readFileSync(join(tmpHome, '.orca', 'agent-hooks', OPENCLAUDE_SCRIPT_FILE_NAME), 'utf-8') + ).not.toContain('GROK_HOOK_EVENT') // Why: the statusline usage feed is Claude-only; OpenClaude installs must not set statusLine. expect(parsed.statusLine).toBeUndefined() expect(existsSync(join(tmpHome, '.claude', 'settings.json'))).toBe(false) diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index b3ae8d01136..571531aa2d9 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -3,26 +3,20 @@ import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' import { buildManagedCommandHook, - buildWindowsAgentHookCurlPostCommand, readHooksJson, writeHooksJson, - writeManagedScript, - type HooksConfig + type HooksConfig, + writeManagedScript } from '../agent-hooks/installer-utils' -import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command' import { readHooksJsonRemote, writeHooksJsonRemote, writeManagedScriptRemote } from '../agent-hooks/installer-utils-remote' import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' -import { - buildPosixHookPayloadCapture, - buildPosixHookSpoolLines, - buildWindowsHookEnvironmentGuardLines, - buildWindowsHookStdinDrainEpilogue, - WINDOWS_HOOK_STDIN_DRAIN_LABEL -} from '../agent-hooks/hook-stdin-contract' +import { getManagedScript } from './hook-script' + +export { getManagedScript } import { getManagedStatusLineScript } from './statusline-script' import { applyManagedHooks, @@ -59,78 +53,6 @@ const DEFAULT_CLAUDE_HOOK_SERVICE_OPTIONS: ClaudeHookServiceOptions = { settings: CLAUDE_HOOK_SETTINGS } -function getManagedScript( - target: 'local' | 'posix' = 'local', - options: { skipWhenDevinImportsClaude?: boolean } = {} -): string { - if (target === 'local' && process.platform === 'win32') { - return [ - '@echo off', - 'setlocal', - // Why: Claude-compatible permission hooks fail closed on empty stdout (#14818). - 'echo {}', - // Why: refresh endpoint coordinates for PTYs surviving an Orca restart. - 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', - // Why (#11549): the env guards must outrank the Devin skip — the Devin skip parks in more.com, - // and outside an Orca pane the caller can abandon stdin, so more.com never returns. - ...buildWindowsHookEnvironmentGuardLines(), - // Why: a backgrounded session runs in a daemon worker that inherited the dispatching - // pane's env, so ORCA_PANE_KEY names a pane this session does not run in (#9236). - // Why exit, not the drain label: the drain parks in more.com and a worker is outside - // an Orca pane — the abandoned-stdin hang #11549 guards against. - 'if not "%CLAUDE_JOB_DIR%"=="" exit /b 0', - ...(options.skipWhenDevinImportsClaude - ? [ - // Why: Devin imports .claude hooks by default; skip Orca's managed hook there so status posts stay attributed to Devin. - `if not "%DEVIN_PROJECT_DIR%"=="" goto :${WINDOWS_HOOK_STDIN_DRAIN_LABEL}` - ] - : []), - // Why: use curl.exe to avoid an extra PowerShell startup per hook. - buildWindowsAgentHookCurlPostCommand('claude'), - 'exit /b 0', - ...buildWindowsHookStdinDrainEpilogue(), - '' - ].join('\r\n') - } - - return [ - '#!/bin/sh', - // Why: Claude-compatible permission hooks fail closed on empty stdout (#14818). - 'printf "{}\\n"', - ...buildPosixHookPayloadCapture(), - ...buildPosixHookSpoolLines('claude'), - ...(options.skipWhenDevinImportsClaude - ? [ - // Why: Devin imports .claude hooks by default; skip Orca's managed hook there so status posts stay attributed to Devin. - 'if [ -n "$DEVIN_PROJECT_DIR" ]; then', - ' exit 0', - 'fi' - ] - : []), - // Why: a backgrounded session runs in a daemon worker that inherited the dispatching - // pane's env, so ORCA_PANE_KEY names a pane this session does not run in (#9236). - 'if [ -n "$CLAUDE_JOB_DIR" ]; then', - ' exit 0', - 'fi', - // Why: refresh endpoint coordinates for PTYs surviving an Orca restart. - // Why: suppress parse errors so they neither leak nor trip outer set -e. - 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', - ' unset ORCA_AGENT_HOOK_TRANSPORT', - ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', - 'fi', - 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', - ' spool_hook_event', - ' exit 0', - 'fi', - // Why: keep full hook JSON off the command line and avoid IDS-friendly URL-encoded paths. - ...buildPosixAgentHookPostCommand('claude').map((line, index, lines) => - index === lines.length - 1 ? `${line} >/dev/null 2>&1 || spool_hook_event` : line - ), - 'exit 0', - '' - ].join('\n') -} - export class ClaudeHookService { private readonly options: ClaudeHookServiceOptions @@ -188,7 +110,10 @@ export class ClaudeHookService { async refreshManagedScripts(): Promise { await refreshManagedScriptIfPresent( getManagedScriptPath(this.options.settings), - getManagedScript('local', { skipWhenDevinImportsClaude: this.options.agent === 'claude' }) + getManagedScript('local', { + skipWhenDevinImportsClaude: this.options.agent === 'claude', + skipWhenGrokImportsClaude: this.options.agent === 'claude' + }) ) // Why: no agent gate — the statusline script only ever exists for claude, so presence is the gate. await refreshManagedScriptIfPresent( @@ -219,7 +144,10 @@ export class ClaudeHookService { ) writeManagedScript( scriptPath, - getManagedScript('local', { skipWhenDevinImportsClaude: this.options.agent === 'claude' }) + getManagedScript('local', { + skipWhenDevinImportsClaude: this.options.agent === 'claude', + skipWhenGrokImportsClaude: this.options.agent === 'claude' + }) ) // Why: the statusline usage feed is Claude-only — OpenClaude data would be misattributed to the Claude provider. if (this.options.agent === 'claude') { @@ -281,7 +209,10 @@ export class ClaudeHookService { await writeManagedScriptRemote( sftp, remoteScriptPath, - getManagedScript('posix', { skipWhenDevinImportsClaude: this.options.agent === 'claude' }) + getManagedScript('posix', { + skipWhenDevinImportsClaude: this.options.agent === 'claude', + skipWhenGrokImportsClaude: this.options.agent === 'claude' + }) ) // Why: no statusline install here — this path serves SSH remotes and WSL guests, whose relay hook // listener doesn't route /statusline/claude, and an SSH box's Claude login can be a different diff --git a/src/main/cursor/hook-script.ts b/src/main/cursor/hook-script.ts index 94563337131..739026aa6a8 100644 --- a/src/main/cursor/hook-script.ts +++ b/src/main/cursor/hook-script.ts @@ -9,6 +9,10 @@ import { buildWindowsHookEnvironmentGuardLines, buildWindowsHookStdinDrainEpilogue } from '../agent-hooks/hook-stdin-contract' +import { + buildPosixGrokReplayGuardLines, + buildWindowsGrokReplayGuardLines +} from '../agent-hooks/grok-replay-guard' import { getCursorHookResponse, type CursorEvent } from './hook-events' const CURSOR_HOOK_RESPONSE_ENV = 'ORCA_CURSOR_HOOK_RESPONSE' @@ -43,6 +47,7 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string { // Why: source current endpoint coordinates for PTYs surviving an Orca restart. 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', ...buildWindowsHookEnvironmentGuardLines(), + ...buildWindowsGrokReplayGuardLines(), buildWindowsAgentHookPostCommand('cursor'), 'exit /b 0', ...buildWindowsHookStdinDrainEpilogue(), @@ -59,6 +64,7 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' printf "{}\\n"', 'fi', ...buildPosixHookPayloadCapture(), + ...buildPosixGrokReplayGuardLines(), ...buildPosixHookSpoolLines('cursor'), // Why: refresh endpoint coordinates so surviving PTYs keep reporting. 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', diff --git a/src/main/cursor/hook-service.test.ts b/src/main/cursor/hook-service.test.ts index f5f25295a60..3f93e91f7fe 100644 --- a/src/main/cursor/hook-service.test.ts +++ b/src/main/cursor/hook-service.test.ts @@ -131,6 +131,7 @@ describe('CursorHookService', () => { 'utf8' ) expect(script).toContain('/hook/cursor') + expect(script).toContain('GROK_HOOK_EVENT') if (process.platform === 'win32') { expect(script).toContain('%SystemRoot%\\System32\\curl.exe') } else { From a28cd9eae5eff34a16a58626e5bbbd36e1c59596 Mon Sep 17 00:00:00 2001 From: Jared Meek <13214123+atreidesend@users.noreply.github.com> Date: Mon, 14 Sep 2026 00:58:57 +0200 Subject: [PATCH 06/34] fix(browser): press keys through a US-layout CDP key table instead of a subprocess per keystroke (#15310) Typing in the remote browser pane spawned an agent-browser process per keystroke -- ~160ms each, so a 17-character password took seconds -- and some keys arrived half-formed: F-keys, Insert and ContextMenu dispatched windowsVirtualKeyCode 0, Shift+1 typed '1' instead of '!', and non-ASCII printables reported success while typing nothing at all. keypress now resolves the key name through a US-layout table and dispatches the Input.dispatchKeyEvent pair over the electron debugger, the same transport mouseClick already uses. Two fallbacks keep the old behavior reachable: - a single printable BMP character outside the table dispatches in process as an IME-style event (keyCode 229 with the character as text, the shape composed input already has when it reaches pages) - anything else -- media keys, surrogate pairs, unrecognized names -- goes to the helper exactly as before, and only that path pays for creating the helper session Virtual key codes come from the table, never from the character's own char code: charCodeAt puts '&' on 38 (VK_UP) and '.' on 46 (VK_DELETE), which Blink runs as caret commands that swallow the character. Dispatch failures normalize the way evaluate's already do -- a gone page becomes browser_tab_not_found, anything else browser_error -- because attach and sendCommand reject with plain Errors that the RPC layer would report as runtime_error, and the pane only reclaims a dead page when it sees a browser_* code. Result shape is unchanged and no wire, schema or RPC surface moves, so mixed-version client/server pairs see no difference. Pages can observe the fidelity fixes: Shift+a now types 'A', Shift+1 now types '!', Alt+ no longer carries text, and editing keys arrive as rawKeyDown. Each matches what a real US keyboard produces. Verified against the shipped agent-browser 0.27 binary on the same browser: every difference is a fix, nothing regressed. macOS editing shortcuts (Cmd+A) do not fire through either path -- Blink runs those off the native responder chain and neither sends CDP `commands` -- so that gap is unchanged, not introduced. Co-authored-by: Neil Co-authored-by: Claude Fable 5 --- ...ent-browser-bridge-interaction-commands.ts | 69 ++++- ...gent-browser-bridge-keypress-input.test.ts | 292 ++++++++++++++++++ .../browser/cdp-keyboard-us-layout.test.ts | 133 ++++++++ src/main/browser/cdp-keyboard-us-layout.ts | 251 +++++++++++++++ 4 files changed, 742 insertions(+), 3 deletions(-) create mode 100644 src/main/browser/agent-browser-bridge-keypress-input.test.ts create mode 100644 src/main/browser/cdp-keyboard-us-layout.test.ts create mode 100644 src/main/browser/cdp-keyboard-us-layout.ts diff --git a/src/main/browser/agent-browser-bridge-interaction-commands.ts b/src/main/browser/agent-browser-bridge-interaction-commands.ts index 51854878c0b..97fa3892ab9 100644 --- a/src/main/browser/agent-browser-bridge-interaction-commands.ts +++ b/src/main/browser/agent-browser-bridge-interaction-commands.ts @@ -12,6 +12,8 @@ import type { } from '../../shared/runtime-types' import { BrowserError } from './cdp-bridge' import { WAIT_PROCESS_TIMEOUT_GRACE_MS } from './agent-browser-bridge-types' +import { acquireElectronDebugger } from './electron-debugger-lease' +import { parseCdpKeyEvent, imeFallbackKeyEvent } from './cdp-keyboard-us-layout' import { AgentBrowserBridgeCaptureCommands } from './agent-browser-bridge-capture-commands' export abstract class AgentBrowserBridgeInteractionCommands extends AgentBrowserBridgeCaptureCommands { @@ -170,9 +172,70 @@ export abstract class AgentBrowserBridgeInteractionCommands extends AgentBrowser worktreeId?: string, browserPageId?: string ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['press', key])) as BrowserKeypressResult - }) + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (sessionName, target) => { + const parsed = parseCdpKeyEvent(key) ?? imeFallbackKeyEvent(key) + if (!parsed) { + // Why: a key name the table cannot express must not dispatch keyCode 0 and + // report success — route it to the helper, creating its session only now so + // the direct path never pays for it. + await this.ensureSession(sessionName, target.browserPageId, target.webContentsId) + return (await this.execAgentBrowser(sessionName, ['press', key])) as BrowserKeypressResult + } + const wc = this.getWebContents(target.webContentsId) + if (!wc || wc.isDestroyed()) { + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${target.browserPageId} is no longer available` + ) + } + const event = { + windowsVirtualKeyCode: parsed.keyCode, + nativeVirtualKeyCode: parsed.keyCode, + key: parsed.key, + code: parsed.code, + modifiers: parsed.modifiers, + location: parsed.location + } + let releaseDebugger = (): void => {} + try { + releaseDebugger = acquireElectronDebugger(wc).release + await wc.debugger.sendCommand('Input.dispatchKeyEvent', { + // Why: rawKeyDown is the no-character form; sending keyDown without text + // makes Blink synthesize an empty input for editing keys. + type: parsed.text === null ? 'rawKeyDown' : 'keyDown', + ...event, + ...(parsed.text === null ? {} : { text: parsed.text, unmodifiedText: parsed.text }) + }) + await wc.debugger.sendCommand('Input.dispatchKeyEvent', { + type: 'keyUp', + ...event, + // Why: the self bit is keydown-only -- Blink reports shiftKey false on the Shift keyup. + modifiers: parsed.modifiers & ~parsed.selfModifier + }) + return { pressed: key } + } catch (error) { + // Why: attach/dispatch reject with plain Errors, which the RPC layer would report as + // runtime_error — the helper path this replaced always produced a browser_* code, and + // the pane only reclaims a dead page when it sees one. + if (error instanceof BrowserError) { + throw error + } + if (!this.getWebContents(target.webContentsId)) { + throw this.createPageUnavailableError(sessionName) + } + throw new BrowserError( + 'browser_error', + `Failed to press ${key} in browser page ${target.browserPageId}: ${error instanceof Error ? error.message : String(error)}` + ) + } finally { + releaseDebugger() + } + }, + { ensureSession: false } + ) } async pdf(worktreeId?: string, browserPageId?: string): Promise { diff --git a/src/main/browser/agent-browser-bridge-keypress-input.test.ts b/src/main/browser/agent-browser-bridge-keypress-input.test.ts new file mode 100644 index 00000000000..3be23f31c76 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-keypress-input.test.ts @@ -0,0 +1,292 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { execFileMock, webContentsFromIdMock, existsSyncMock, readFileSyncMock, stdinWrites } = + vi.hoisted(() => { + const stdinWrites: string[] = [] + return { + execFileMock: vi.fn(), + webContentsFromIdMock: vi.fn(), + existsSyncMock: vi.fn(() => false), + readFileSyncMock: vi.fn(() => Buffer.from('')), + stdinWrites + } + }) + +vi.mock('child_process', () => ({ execFile: execFileMock })) +vi.mock('fs', () => ({ + existsSync: existsSyncMock, + readFileSync: readFileSyncMock, + accessSync: vi.fn(), + chmodSync: vi.fn(), + constants: { X_OK: 1 } +})) +vi.mock('os', () => ({ platform: () => 'darwin', arch: () => 'arm64' })) +vi.mock('electron', () => { + return { + app: { getPath: vi.fn(() => '/app'), getAppPath: vi.fn(() => '/project'), isPackaged: false }, + webContents: { fromId: webContentsFromIdMock } + } +}) +const { CdpWsProxyMock } = vi.hoisted(() => { + const instances: unknown[] = [] + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const MockClass = vi.fn().mockImplementation(function (this: any, _wc: unknown) { + this._wc = _wc + this.start = vi.fn(async () => 'ws://127.0.0.1:9222') + this.stop = vi.fn(async () => {}) + this.getPort = vi.fn(() => 9222) + instances.push(this) + }) + return { CdpWsProxyMock: Object.assign(MockClass, { instances }) } +}) + +vi.mock('./cdp-ws-proxy', () => ({ + CdpWsProxy: CdpWsProxyMock +})) + +import { AgentBrowserBridge } from './agent-browser-bridge' +import { + createSucceedWith, + mockBrowserManager, + mockWebContents, + overrideBridgeWebContentsLookup, + resetAgentBrowserBridgeMocks, + type MockWebContents +} from './agent-browser-bridge-test-harness' + +overrideBridgeWebContentsLookup(AgentBrowserBridge.prototype, webContentsFromIdMock) + +const succeedWith = createSucceedWith(execFileMock, stdinWrites) + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((entry) => typeof entry === 'string') +} + +function keyEventCalls(wc: MockWebContents): Record[] { + return wc.debugger.sendCommand.mock.calls + .filter(([method]) => method === 'Input.dispatchKeyEvent') + .map(([, params]) => params) + .filter(isRecord) +} + +describe('AgentBrowserBridge keypress input', () => { + let bridge: AgentBrowserBridge + let wc: MockWebContents + + beforeEach(() => { + resetAgentBrowserBridgeMocks({ + webContentsFromIdMock, + existsSyncMock, + readFileSyncMock, + stdinWrites, + cdpWsProxyInstances: CdpWsProxyMock.instances + }) + bridge = new AgentBrowserBridge(mockBrowserManager()) + bridge.setActiveTab(100) + wc = mockWebContents(100) + wc.debugger.sendCommand.mockResolvedValue({}) + webContentsFromIdMock.mockImplementation((id: number) => (id === 100 ? wc : null)) + }) + + it('dispatches a printable key over CDP without spawning agent-browser', async () => { + await expect(bridge.keypress('a', undefined, 'tab-1')).resolves.toEqual({ pressed: 'a' }) + + expect(execFileMock).not.toHaveBeenCalled() + expect(CdpWsProxyMock.instances).toHaveLength(0) + // Why: exactly two CDP calls, so the dispatch pair is the whole interaction. + expect(wc.debugger.sendCommand.mock.calls).toHaveLength(2) + expect(keyEventCalls(wc)).toEqual([ + { + type: 'keyDown', + windowsVirtualKeyCode: 65, + nativeVirtualKeyCode: 65, + key: 'a', + code: 'KeyA', + modifiers: 0, + location: 0, + text: 'a', + unmodifiedText: 'a' + }, + { + type: 'keyUp', + windowsVirtualKeyCode: 65, + nativeVirtualKeyCode: 65, + key: 'a', + code: 'KeyA', + modifiers: 0, + location: 0 + } + ]) + }) + + it('types & as shifted 7 instead of colliding with the ArrowUp virtual key code', async () => { + await bridge.keypress('&', undefined, 'tab-1') + + expect(keyEventCalls(wc)[0]).toMatchObject({ + type: 'keyDown', + windowsVirtualKeyCode: 55, + modifiers: 8, + text: '&' + }) + }) + + it('dispatches editing and navigation keys as rawKeyDown with no text', async () => { + await bridge.keypress('ArrowDown', undefined, 'tab-1') + + expect(keyEventCalls(wc)[0]).toMatchObject({ + type: 'rawKeyDown', + windowsVirtualKeyCode: 40, + key: 'ArrowDown' + }) + expect(keyEventCalls(wc)[0]).not.toHaveProperty('text') + }) + + it('carries modifier masks for shortcuts', async () => { + await expect(bridge.keypress('Ctrl+Shift+K', undefined, 'tab-1')).resolves.toEqual({ + pressed: 'Ctrl+Shift+K' + }) + + expect(keyEventCalls(wc)[0]).toMatchObject({ + type: 'rawKeyDown', + windowsVirtualKeyCode: 75, + modifiers: 10 + }) + }) + + it('reports the modifier bit on a bare Shift keydown but not on its keyup', async () => { + await bridge.keypress('Shift', undefined, 'tab-1') + + expect(keyEventCalls(wc)[0]).toMatchObject({ + type: 'rawKeyDown', + windowsVirtualKeyCode: 16, + code: 'ShiftLeft', + modifiers: 8, + location: 1 + }) + expect(keyEventCalls(wc)[1]).toMatchObject({ type: 'keyUp', modifiers: 0, location: 1 }) + }) + + it('keeps held modifiers on the keyup of a non-modifier shortcut key', async () => { + await bridge.keypress('Ctrl+Shift+K', undefined, 'tab-1') + + expect(keyEventCalls(wc)[1]).toMatchObject({ type: 'keyUp', modifiers: 10 }) + }) + + it('presses Enter with its carriage-return text so fields submit', async () => { + await bridge.keypress('Enter', undefined, 'tab-1') + + expect(keyEventCalls(wc)[0]).toMatchObject({ + type: 'keyDown', + windowsVirtualKeyCode: 13, + text: '\r' + }) + }) + + it('dispatches a non-US printable character as an IME-style event in process', async () => { + await expect(bridge.keypress('é', undefined, 'tab-1')).resolves.toEqual({ pressed: 'é' }) + + expect(execFileMock).not.toHaveBeenCalled() + expect(keyEventCalls(wc)[0]).toMatchObject({ + type: 'keyDown', + windowsVirtualKeyCode: 229, + key: 'é', + code: '', + text: 'é', + unmodifiedText: 'é' + }) + expect(keyEventCalls(wc)[1]).toMatchObject({ type: 'keyUp', windowsVirtualKeyCode: 229 }) + }) + + it('keeps the helper for a surrogate-pair character', async () => { + succeedWith({ pressed: '👍' }) + + await expect(bridge.keypress('👍', undefined, 'tab-1')).resolves.toEqual({ pressed: '👍' }) + + expect(keyEventCalls(wc)).toHaveLength(0) + }) + + it('falls back to agent-browser for a key name the table cannot express', async () => { + succeedWith({ pressed: 'MediaPlayPause' }) + + await expect(bridge.keypress('MediaPlayPause', undefined, 'tab-1')).resolves.toEqual({ + pressed: 'MediaPlayPause' + }) + + expect(keyEventCalls(wc)).toHaveLength(0) + const pressCall = execFileMock.mock.calls + .map(([, commandArgs]) => commandArgs) + .filter(isStringArray) + .find((commandArgs) => commandArgs.includes('press')) + expect(pressCall).toBeDefined() + const args = pressCall ?? [] + expect(args[args.indexOf('press') + 1]).toBe('MediaPlayPause') + }) + + it('rejects with tab not found when the page is gone', async () => { + webContentsFromIdMock.mockReturnValue(null) + + await expect(bridge.keypress('a', undefined, 'tab-1')).rejects.toMatchObject({ + code: 'browser_tab_not_found' + }) + }) + + // Why: one keypress looks the page up three times — the queued target, the + // automation-visibility refresh, then the dispatch guard. Serving the first N keeps the + // later ones on the guard; the trailing assertions fail loudly if that count ever moves. + function killPageAfterLookups(lookups: number): () => number { + let remaining = lookups + webContentsFromIdMock.mockImplementation((id: number) => { + if (id !== 100 || remaining === 0) { + return null + } + remaining -= 1 + return wc + }) + return () => remaining + } + + it('rejects with tab not found when the page dies after its target is resolved', async () => { + const remaining = killPageAfterLookups(2) + + await expect(bridge.keypress('a', undefined, 'tab-1')).rejects.toMatchObject({ + code: 'browser_tab_not_found' + }) + expect(remaining()).toBe(0) + expect(keyEventCalls(wc)).toHaveLength(0) + }) + + it('rejects with tab not found when the page dies mid-dispatch', async () => { + const remaining = killPageAfterLookups(3) + wc.debugger.sendCommand.mockRejectedValue(new Error('Inspected target navigated or closed')) + + await expect(bridge.keypress('a', undefined, 'tab-1')).rejects.toMatchObject({ + code: 'browser_tab_not_found' + }) + expect(remaining()).toBe(0) + }) + + it('reports a dispatch failure on a live page as a browser error', async () => { + wc.debugger.sendCommand.mockRejectedValue(new Error('Debugger is not attached to the target')) + + await expect(bridge.keypress('a', undefined, 'tab-1')).rejects.toMatchObject({ + code: 'browser_error', + message: expect.stringContaining('Debugger is not attached to the target') + }) + }) + + it('reports a debugger attach failure as a browser error', async () => { + wc.debugger.isAttached.mockReturnValue(false) + wc.debugger.attach.mockImplementation(() => { + throw new Error('Another debugger is already attached to the debug target') + }) + + await expect(bridge.keypress('a', undefined, 'tab-1')).rejects.toMatchObject({ + code: 'browser_error' + }) + expect(keyEventCalls(wc)).toHaveLength(0) + }) +}) diff --git a/src/main/browser/cdp-keyboard-us-layout.test.ts b/src/main/browser/cdp-keyboard-us-layout.test.ts new file mode 100644 index 00000000000..b9bcffa50ec --- /dev/null +++ b/src/main/browser/cdp-keyboard-us-layout.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect } from 'vitest' +import { imeFallbackKeyEvent, parseCdpKeyEvent } from './cdp-keyboard-us-layout' + +describe('parseCdpKeyEvent', () => { + it('maps every printable ASCII character to a key event that types that character', () => { + const broken: string[] = [] + for (let charCode = 32; charCode <= 126; charCode++) { + const ch = String.fromCharCode(charCode) + const parsed = parseCdpKeyEvent(ch) + if (!parsed || parsed.text !== ch || parsed.keyCode === 0) { + broken.push(ch) + } + } + expect(broken).toEqual([]) + }) + + it.each([ + ['#', 51], + ['$', 52], + ['%', 53], + ['&', 55], + ["'", 222], + ['(', 57], + ['.', 190] + ])( + 'gives %s the US-layout key code %i instead of its own char code', + (ch: string, keyCode: number) => { + // Why: charCodeAt-derived codes put '&' on VK_UP (38) and '.' on VK_DELETE (46), + // which Blink executes as caret commands that swallow the character. + expect(parseCdpKeyEvent(ch)).toMatchObject({ keyCode, text: ch }) + } + ) + + it.each([ + ['Ctrl+A', { keyCode: 65, key: 'a', modifiers: 2, text: null }], + ['Control+a', { keyCode: 65, key: 'a', modifiers: 2, text: null }], + ['Shift+Home', { keyCode: 36, key: 'Home', modifiers: 8, text: null }], + ['Alt+ArrowDown', { keyCode: 40, key: 'ArrowDown', modifiers: 1, text: null }], + ['Ctrl+Shift+K', { keyCode: 75, key: 'K', modifiers: 10, text: null }], + ['Meta+r', { keyCode: 82, key: 'r', modifiers: 4, text: null }], + ['Control+Shift+r', { keyCode: 82, key: 'R', modifiers: 10, text: null }] + ])('parses the shortcut %s', (raw: string, expected: object) => { + expect(parseCdpKeyEvent(raw)).toMatchObject(expected) + }) + + it('treats a capital letter in a shortcut as the key name, not a shift request', () => { + expect(parseCdpKeyEvent('Ctrl+A')).toMatchObject({ key: 'a', modifiers: 2 }) + expect(parseCdpKeyEvent('Ctrl+Shift+A')).toMatchObject({ key: 'A', modifiers: 10 }) + }) + + it('shifts a bare capital letter and reports the shifted character as text', () => { + expect(parseCdpKeyEvent('R')).toMatchObject({ keyCode: 82, key: 'R', modifiers: 8, text: 'R' }) + expect(parseCdpKeyEvent('Shift+a')).toMatchObject({ key: 'A', modifiers: 8, text: 'A' }) + }) + + it('maps shifted punctuation onto its base key with shift held', () => { + expect(parseCdpKeyEvent('Shift+1')).toMatchObject({ keyCode: 49, key: '!', text: '!' }) + expect(parseCdpKeyEvent('+')).toMatchObject({ keyCode: 187, modifiers: 8, text: '+' }) + }) + + it.each([ + ['Enter', { keyCode: 13, text: '\r' }], + ['Space', { keyCode: 32, key: ' ', text: ' ' }], + ['Esc', { keyCode: 27, key: 'Escape', text: null }], + ['PgDn', { keyCode: 34, key: 'PageDown', text: null }], + ['ContextMenu', { keyCode: 93, text: null }], + ['F5', { keyCode: 116, key: 'F5', code: 'F5', text: null }], + ['F12', { keyCode: 123, text: null }] + ])('parses the named key %s', (raw: string, expected: object) => { + expect(parseCdpKeyEvent(raw)).toMatchObject(expected) + }) + + it.each([ + ['Shift', { keyCode: 16, key: 'Shift', code: 'ShiftLeft', modifiers: 8, selfModifier: 8 }], + ['Ctrl', { keyCode: 17, key: 'Control', code: 'ControlLeft', modifiers: 2, selfModifier: 2 }], + ['Alt', { keyCode: 18, key: 'Alt', code: 'AltLeft', modifiers: 1, selfModifier: 1 }], + ['Meta', { keyCode: 91, key: 'Meta', code: 'MetaLeft', modifiers: 4, selfModifier: 4 }] + ])( + 'reports the own modifier bit and left-side location for a bare %s press', + (raw: string, expected: object) => { + expect(parseCdpKeyEvent(raw)).toMatchObject({ ...expected, location: 1, text: null }) + } + ) + + it('adds the self bit on top of held modifiers for a modifier-only chord', () => { + expect(parseCdpKeyEvent('Ctrl+Shift')).toMatchObject({ + keyCode: 16, + modifiers: 10, + selfModifier: 8 + }) + }) + + it('reports no self bit or location for non-modifier keys', () => { + expect(parseCdpKeyEvent('Enter')).toMatchObject({ location: 0, selfModifier: 0 }) + expect(parseCdpKeyEvent('a')).toMatchObject({ location: 0, selfModifier: 0 }) + expect(parseCdpKeyEvent('Ctrl+A')).toMatchObject({ location: 0, selfModifier: 0 }) + }) + + it.each([['MediaPlayPause'], ['F25'], [''], ['NoSuchKey']])( + 'returns null for %s so the caller can fall back', + (raw: string) => { + expect(parseCdpKeyEvent(raw)).toBeNull() + } + ) +}) + +describe('imeFallbackKeyEvent', () => { + it.each([['é'], ['ß'], ['ñ'], ['ü'], ['漢'], ['한']])( + 'gives %s the IME key event form with keyCode 229 and its text', + (ch: string) => { + expect(imeFallbackKeyEvent(ch)).toEqual({ + keyCode: 229, + key: ch, + code: '', + modifiers: 0, + location: 0, + selfModifier: 0, + text: ch + }) + } + ) + + it.each([ + ['a table-covered ASCII character', 'a'], + ['a surrogate-pair emoji', '👍'], + ['a combining sequence', 'e\u0301'], + ['a multi-character name', 'MediaPlayPause'], + ['a chord with a non-US character', 'Ctrl+é'], + ['an empty string', ''] + ])('returns null for %s so the helper keeps its behavior', (_name: string, raw: string) => { + expect(imeFallbackKeyEvent(raw)).toBeNull() + }) +}) diff --git a/src/main/browser/cdp-keyboard-us-layout.ts b/src/main/browser/cdp-keyboard-us-layout.ts new file mode 100644 index 00000000000..23de58a7241 --- /dev/null +++ b/src/main/browser/cdp-keyboard-us-layout.ts @@ -0,0 +1,251 @@ +// Why: deriving a virtual key code from a character's own char code collides with editing +// keys — '&' (38) arrives as VK_UP and '.' (46) as VK_DELETE, so Blink runs the caret +// command and silently drops the character. This table maps Orca key names ("a", "&", +// "Ctrl+Shift+K", "Alt+ArrowDown", "F5") to the CDP key event a US-layout keyboard +// would produce; anything it cannot express returns null so the caller can fall back. + +const CDP_MODIFIER_BITS: Record = { + alt: 1, + option: 1, + ctrl: 2, + control: 2, + cmd: 4, + command: 4, + meta: 4, + super: 4, + win: 4, + shift: 8 +} + +// name -> [windowsVirtualKeyCode, key, code, text] +const CDP_NAMED_KEYS: Record = { + enter: [13, 'Enter', 'Enter', '\r'], + return: [13, 'Enter', 'Enter', '\r'], + tab: [9, 'Tab', 'Tab', null], + backspace: [8, 'Backspace', 'Backspace', null], + delete: [46, 'Delete', 'Delete', null], + del: [46, 'Delete', 'Delete', null], + escape: [27, 'Escape', 'Escape', null], + esc: [27, 'Escape', 'Escape', null], + space: [32, ' ', 'Space', ' '], + spacebar: [32, ' ', 'Space', ' '], + arrowup: [38, 'ArrowUp', 'ArrowUp', null], + up: [38, 'ArrowUp', 'ArrowUp', null], + arrowdown: [40, 'ArrowDown', 'ArrowDown', null], + down: [40, 'ArrowDown', 'ArrowDown', null], + arrowleft: [37, 'ArrowLeft', 'ArrowLeft', null], + left: [37, 'ArrowLeft', 'ArrowLeft', null], + arrowright: [39, 'ArrowRight', 'ArrowRight', null], + right: [39, 'ArrowRight', 'ArrowRight', null], + home: [36, 'Home', 'Home', null], + end: [35, 'End', 'End', null], + pageup: [33, 'PageUp', 'PageUp', null], + pgup: [33, 'PageUp', 'PageUp', null], + pagedown: [34, 'PageDown', 'PageDown', null], + pgdn: [34, 'PageDown', 'PageDown', null], + pgdown: [34, 'PageDown', 'PageDown', null], + insert: [45, 'Insert', 'Insert', null], + ins: [45, 'Insert', 'Insert', null], + contextmenu: [93, 'ContextMenu', 'ContextMenu', null], + apps: [93, 'ContextMenu', 'ContextMenu', null], + capslock: [20, 'CapsLock', 'CapsLock', null], + numlock: [144, 'NumLock', 'NumLock', null], + scrolllock: [145, 'ScrollLock', 'ScrollLock', null], + pause: [19, 'Pause', 'Pause', null], + printscreen: [44, 'PrintScreen', 'PrintScreen', null], + shift: [16, 'Shift', 'ShiftLeft', null], + control: [17, 'Control', 'ControlLeft', null], + ctrl: [17, 'Control', 'ControlLeft', null], + alt: [18, 'Alt', 'AltLeft', null], + option: [18, 'Alt', 'AltLeft', null], + meta: [91, 'Meta', 'MetaLeft', null], + cmd: [91, 'Meta', 'MetaLeft', null], + command: [91, 'Meta', 'MetaLeft', null] +} + +// Characters a US keyboard produces with shift held, and the base key they share. +const US_SHIFTED_CHARS: Record = { + '~': '`', + '!': '1', + '@': '2', + '#': '3', + $: '4', + '%': '5', + '^': '6', + '&': '7', + '*': '8', + '(': '9', + ')': '0', + _: '-', + '+': '=', + '{': '[', + '}': ']', + '|': '\\', + ':': ';', + '"': "'", + '<': ',', + '>': '.', + '?': '/' +} + +const US_SHIFT_OF: Record = {} +for (const shifted of Object.keys(US_SHIFTED_CHARS)) { + US_SHIFT_OF[US_SHIFTED_CHARS[shifted]] = shifted +} + +// char -> [windowsVirtualKeyCode, code], for the keys that are not letters or digits. +const US_PUNCTUATION_KEYS: Record = { + ' ': [32, 'Space'], + ';': [186, 'Semicolon'], + '=': [187, 'Equal'], + ',': [188, 'Comma'], + '-': [189, 'Minus'], + '.': [190, 'Period'], + '/': [191, 'Slash'], + '`': [192, 'Backquote'], + '[': [219, 'BracketLeft'], + '\\': [220, 'Backslash'], + ']': [221, 'BracketRight'], + "'": [222, 'Quote'] +} + +type UsKeyboardKey = { + keyCode: number + code: string + shift: boolean +} + +function usKeyboardKeyForChar(ch: string): UsKeyboardKey | null { + if (ch >= 'a' && ch <= 'z') { + return { keyCode: ch.charCodeAt(0) - 32, code: `Key${ch.toUpperCase()}`, shift: false } + } + if (ch >= 'A' && ch <= 'Z') { + return { keyCode: ch.charCodeAt(0), code: `Key${ch}`, shift: true } + } + if (ch >= '0' && ch <= '9') { + return { keyCode: ch.charCodeAt(0), code: `Digit${ch}`, shift: false } + } + if (Object.hasOwn(US_SHIFTED_CHARS, ch)) { + const base = usKeyboardKeyForChar(US_SHIFTED_CHARS[ch]) + return base === null ? null : { keyCode: base.keyCode, code: base.code, shift: true } + } + if (Object.hasOwn(US_PUNCTUATION_KEYS, ch)) { + return { keyCode: US_PUNCTUATION_KEYS[ch][0], code: US_PUNCTUATION_KEYS[ch][1], shift: false } + } + return null +} + +export type CdpKeyEvent = { + keyCode: number + key: string + code: string + modifiers: number + // Why: 1 = left-side key -- the table pins bare modifiers to ShiftLeft/ControlLeft/etc. + location: number + // Why: a modifier key's own bit is set during its keydown but already cleared on its keyup. + selfModifier: number + // Why: null means the key produces no character (a rawKeyDown, not a keyDown with text). + text: string | null +} + +// Why: printable characters outside the table (accented letters, non-latin scripts) +// still have an in-process form -- the IME convention, keyCode 229 with the text, +// which is how composed input already reaches pages. One BMP code point only: +// surrogate pairs and combining sequences keep the helper's behavior. +export function imeFallbackKeyEvent(raw: string): CdpKeyEvent | null { + if (raw.length !== 1) { + return null + } + const codePoint = raw.charCodeAt(0) + if (codePoint < 0xa0 || (codePoint >= 0xd800 && codePoint <= 0xdfff)) { + return null + } + return { keyCode: 229, key: raw, code: '', modifiers: 0, location: 0, selfModifier: 0, text: raw } +} + +export function parseCdpKeyEvent(raw: string): CdpKeyEvent | null { + if (raw.length === 0) { + return null + } + let rest = raw + let modifiers = 0 + while (rest.length > 1) { + const plus = rest.indexOf('+') + if (plus <= 0) { + break + } + const name = rest.slice(0, plus).toLowerCase() + if (!Object.hasOwn(CDP_MODIFIER_BITS, name)) { + break + } + modifiers |= CDP_MODIFIER_BITS[name] + rest = rest.slice(plus + 1) + } + if (rest.length === 0) { + return null + } + + let keyCode: number + let key: string + let code: string + let text: string | null + let location = 0 + let selfModifier = 0 + if (rest.length === 1) { + const mapped = usKeyboardKeyForChar(rest) + if (mapped === null) { + return null + } + keyCode = mapped.keyCode + key = rest + code = mapped.code + text = rest + // Why: a capital letter in a shortcut is how people write the key, not a request for + // shift — Ctrl+A means select-all (key 'a'), never Ctrl+Shift+A. Shifted punctuation + // is different: on a US keyboard shift is the only way to produce the character. + const capitalShortcut = rest >= 'A' && rest <= 'Z' && (modifiers & ~8) !== 0 + if (capitalShortcut) { + key = rest.toLowerCase() + text = key + } else if (mapped.shift) { + modifiers |= 8 + } + } else if (Object.hasOwn(CDP_NAMED_KEYS, rest.toLowerCase())) { + const name = rest.toLowerCase() + const named = CDP_NAMED_KEYS[name] + keyCode = named[0] + key = named[1] + code = named[2] + text = named[3] + // Why: Blink reports a modifier's own bit during its keydown (shiftKey is true while + // Shift goes down), and the table's modifier entries are the left-side keys. + selfModifier = CDP_MODIFIER_BITS[name] ?? 0 + if (selfModifier !== 0) { + modifiers |= selfModifier + location = 1 + } + } else { + const functionKey = /^f([1-9]|1[0-9]|2[0-4])$/i.exec(rest) + if (functionKey === null) { + return null + } + keyCode = 111 + Number(functionKey[1]) + key = `F${functionKey[1]}` + code = key + text = null + } + + if (text !== null && (modifiers & 8) !== 0) { + text = Object.hasOwn(US_SHIFT_OF, text) ? US_SHIFT_OF[text] : text.toUpperCase() + // Why: Shift+a is the "A" key as far as the page is concerned. + if (rest.length === 1) { + key = text + } + } + // Why: with ctrl, alt or meta held the press is a shortcut and produces no character. + if ((modifiers & ~8) !== 0) { + text = null + } + + return { keyCode, key, code, modifiers, location, selfModifier, text } +} From c853e10e0c798be0408e8db08d8fdbd432eb6b5a Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:05:42 -0400 Subject: [PATCH 07/34] fix(rpc): validate provider-specific fields in TaskProviderIdentity (#20284) * fix(rpc): validate task provider identity fields Validate provider-specific field types while preserving nullable scopes and unknown identity fields. Record the producer census and pin validation with regression tests. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(rpc): reject a blank GitHub owner or repo normalizeTaskProviderIdentity treats a blank owner or repo as no identity at all, but the schema accepted '' and whitespace-only, so the two disagreed about the same payload. Refined rather than trimmed: trimming would rewrite the parsed value and change what the handler receives. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(rpc): re-measure the identity evidence counts The blank-field commit added seven tests, so the recorded 74/16/58 described the commit before it. Re-ran both: 81 tests, and the discriminant-only mutation now gives 17 failures / 64 passes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(rpc): correct the remaining stale gate count The blank-field commit moved the full-RPC total too; 2,463 was the count before it. Re-ran: 278 files, 2,470 passed, one skipped. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../task-provider-identity-validation.md | 81 ++++++++++ .../methods/task-provider-identity.test.ts | 141 ++++++++++++++++++ src/shared/rpc-contract/automation-params.ts | 52 ++++++- 3 files changed, 266 insertions(+), 8 deletions(-) create mode 100644 docs/reference/task-provider-identity-validation.md create mode 100644 src/main/runtime/rpc/methods/task-provider-identity.test.ts diff --git a/docs/reference/task-provider-identity-validation.md b/docs/reference/task-provider-identity-validation.md new file mode 100644 index 00000000000..4dba0760aab --- /dev/null +++ b/docs/reference/task-provider-identity-validation.md @@ -0,0 +1,81 @@ +# Task provider identity RPC validation + +The automation RPC identity schema follows `src/shared/task-provider-identity.ts`, +re-exported by `src/shared/task-source-context.ts`. Only GitHub requires fields beyond +`provider`: `owner` and `repo` are strings, and `host` is optional. GitLab, Linear, +and Jira fields are optional nullable strings. Requiring a GitLab project or a +Linear workspace/Jira site would contradict the domain type and account-wide scopes. + +The discriminated union validates these existing field types without trimming, +coercing, or stripping identity fields. Unknown fields pass through as they did under +`z.custom`, including fields from newer clients. Absent and explicit-null identities +remain distinct. The schema does not infer providers from owner/repo or require a +git worktree, repository slug, or local execution host for a source context. + +## Producer census + +Paths below are relative to the repository root. Searches covered production +`providerIdentity`, `TaskProviderIdentity`, `sourceContext`, and +`linkedTaskSourceContext` uses across desktop, shared code, mobile, and CLI. + +| Producer or forwarding path | Populated verdict | +| ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/shared/project-host-setup-projection.ts`: `getProjectProviderIdentity` | GitHub owner/repo populated together, or no identity. Supplies project identities consumed by desktop and migration. | +| `src/renderer/src/components/task-page-source-context.tsx`: `getTaskPageRepoSourceContext` | GitHub fields populated through projection, or null. GitLab uses explicit provider and `buildGitLabProviderIdentity`; projectId/project/webUrl populated, namespace can be null. | +| Same file: `buildGitLabProviderIdentity` | GitLab fields come from project path/host; missing path components become null. No required GitLab field is invented. | +| `src/renderer/src/hooks/composer-state/source-context-state.ts` | Derived GitHub context carries a complete project identity or null. Jira folder/project-group context explicitly has null identity. Draft/linked contexts are forwarded. | +| `src/renderer/src/components/use-task-page-source-availability.ts` | Linear workspaceId/name and Jira siteId/URL can be null for account-wide selections; team/project fields are not populated. Valid under the existing optional-field contract. | +| `src/renderer/src/components/task-page-jira-item-source-context.ts` | Bound issue context populates siteId, siteUrl, projectKey. | +| `src/renderer/src/components/new-workspace/use-jira-url-source.ts` | Bound URL issue context populates siteId, siteUrl, projectKey. | +| `src/renderer/src/components/worktree-jump-palette-create-worktree.ts` | Linear teamId/key populated; workspaceId/name may be null. | +| `src/shared/task-source-context.ts`: normalize/build functions | GitHub missing owner/repo becomes null identity; other providers' missing fields become null. Provider mismatch becomes null, never an inferred provider. | +| `src/cli/handlers/automation-handler-flags.ts`, `src/cli/handlers/automations.ts` | Explicit JSON source-context input is normalized before create/update. GitHub fields populated or null identity; other fields nullable. Omitted/null context preserved by flag handling. | +| `src/main/persistence/scheduling-automations/automation-context-migration.ts` | Builds source context from projected complete GitHub identity, or null context. | +| Desktop automation save/scoped-list/host clients and web transport | Forward existing source contexts, not new identity constructors. `automation-orca-save.ts` forwards the current automation context or null. Legacy arbitrary malformed RPC input is deliberately rejected by the new schema. | +| Mobile | No task-provider identity/source-context constructor or sender found. `mobile/src/components/new-workspace-project-targets.ts` uses project identity solely for display. | + +## Compatibility evidence and limits + +Read `docs/reference/remote-wire-compatibility.md` before changing validation. +A source search against release tag `v1.4.199` also finds no mobile +`sourceContext`/`linkedTaskSourceContext` sender; its sole `providerIdentity` use +is the display-only project target above. The released CLI flag reader also calls +`normalizeTaskSourceContext`. This is source-level evidence for the checked release, +not a claim to have executed every historical mobile binary. + +No shipped mobile producer with a newly rejected payload was found. No new required +field was added to the domain contract. GitLab, Linear, and Jira discriminant-only +identities remain valid. Folder-workspace null/absent identities remain valid on +both local and SSH hosts. No execution/status logic or client-side parsing changed. + +## Regression evidence + +`src/main/runtime/rpc/methods/task-provider-identity.test.ts` checks unchanged valid +identities for all four providers, required GitHub fields, every declared field's +type, optional/null non-GitHub fields, unknown-field preservation, explicit GitLab +discrimination with owner/repo present, local/SSH folder contexts, and update patches. + +The focused run passed 81 tests. Temporarily replacing GitHub's field validators +with optional `z.unknown()` validators (discriminant-only acceptance) caused 17 +failures and 64 passes. The mutation was restored before running the gates. + +Counts re-measured at `cf4f77f` after the blank-field commit added seven tests; +the earlier 74/16/58 figures described the commit before it. + +## Gate results + +All commands ran with `ORCA_BACKGROUND_LAUNCH=1`. + +- `pnpm tc`: exit 0; completed the repository typecheck runner. +- `pnpm exec vitest run src/main/runtime/rpc`: exit 1; 277 files passed, + one failed; 2,462 tests passed, one failed, one skipped. The only failure was + the unrelated `structured-agent-session-adoption-replay.test.ts` hitting its + 5,000 ms timeout. All identity tests passed. +- `pnpm --dir mobile typecheck`: exit 0; `tsc --noEmit` passed. +- `pnpm run check:code-quality:changed`: exit 0; zero new code-quality, + type-aware, or React Doctor findings across the two changed code files. +- Isolated retry of `structured-agent-session-adoption-replay.test.ts`: exit 0; + one test passed, with the test body completing in 358 ms. +- Full RPC retry with `pnpm exec vitest run src/main/runtime/rpc --maxWorkers=4`: + exit 0; all 278 files passed, 2,470 tests passed, one skipped (97.24 seconds). + The bounded-concurrency rerun resolved the timeout without changing test code. diff --git a/src/main/runtime/rpc/methods/task-provider-identity.test.ts b/src/main/runtime/rpc/methods/task-provider-identity.test.ts new file mode 100644 index 00000000000..1cf6d54381c --- /dev/null +++ b/src/main/runtime/rpc/methods/task-provider-identity.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest' +import { + AutomationUpdate, + TaskProviderIdentity, + TaskSourceContext +} from '../../../../shared/rpc-contract/automation-params' +import type { TaskProviderIdentity as ProviderIdentity } from '../../../../shared/task-source-context' + +const identities = [ + { provider: 'github', owner: 'Acme', repo: 'Orca', host: 'github.example.com' }, + { + provider: 'gitlab', + projectId: '123', + namespace: 'acme/team', + project: 'orca', + webUrl: 'https://gitlab.example.com/acme/team/orca' + }, + { + provider: 'linear', + workspaceId: 'workspace', + workspaceName: 'Acme', + teamId: 'team', + teamKey: 'ENG' + }, + { provider: 'jira', siteId: 'site', siteUrl: 'https://acme.atlassian.net', projectKey: 'ENG' } +] satisfies ProviderIdentity[] + +describe('task provider identity RPC validation', () => { + it.each(identities)('preserves valid $provider identities', (identity) => { + expect(TaskProviderIdentity.parse(identity)).toEqual(identity) + }) + + it.each(['owner', 'repo'])('requires the GitHub %s', (field) => { + const identity: Record = { ...identities[0] } + delete identity[field] + expect(TaskProviderIdentity.safeParse(identity).success).toBe(false) + expect(TaskProviderIdentity.safeParse({ ...identity, [field]: null }).success).toBe(false) + }) + + for (const identity of identities) { + for (const field of Object.keys(identity).filter((key) => key !== 'provider')) { + it.each([42, false, [], {}])( + `rejects non-string ${identity.provider}.${field}: %j`, + (value) => { + expect(TaskProviderIdentity.safeParse({ ...identity, [field]: value }).success).toBe( + false + ) + } + ) + } + } + + it.each(['gitlab', 'linear', 'jira'])('keeps %s fields optional and nullable', (provider) => { + expect(TaskProviderIdentity.parse({ provider })).toEqual({ provider }) + const full = identities.find((identity) => identity.provider === provider)! + const nullable = Object.fromEntries( + Object.keys(full).map((key) => [key, key === 'provider' ? provider : null]) + ) + expect(TaskProviderIdentity.parse(nullable)).toEqual(nullable) + }) + + it('preserves unknown fields and never infers GitHub from owner/repo', () => { + const identity = { provider: 'gitlab', owner: 'acme', repo: 'orca', futureField: 'value' } + expect(TaskProviderIdentity.parse(identity)).toEqual(identity) + }) + + it.each([{}, { provider: 'github' }, { provider: 'unknown' }, [], 'github', 1])( + 'rejects invalid identities: %j', + (identity) => { + expect(TaskProviderIdentity.safeParse(identity).success).toBe(false) + } + ) + + it('preserves absent and explicit-null identities in folder contexts on local and SSH hosts', () => { + expect(TaskProviderIdentity.parse(undefined)).toBeUndefined() + expect(TaskProviderIdentity.parse(null)).toBeNull() + for (const hostId of ['local', 'ssh:host']) { + const context = { kind: 'task-source', provider: 'github', projectId: 'folder', hostId } + expect(TaskSourceContext.parse(context)).not.toHaveProperty('providerIdentity') + expect(TaskSourceContext.parse({ ...context, providerIdentity: null })).toEqual({ + ...context, + providerIdentity: null + }) + } + }) + + it('validates identities in automation updates without collapsing absent and null patches', () => { + expect(AutomationUpdate.parse({ id: 'automation', updates: {} }).updates).not.toHaveProperty( + 'sourceContext' + ) + expect( + AutomationUpdate.parse({ id: 'automation', updates: { sourceContext: null } }).updates + .sourceContext + ).toBeNull() + expect( + AutomationUpdate.safeParse({ + id: 'automation', + updates: { + sourceContext: { + kind: 'task-source', + provider: 'github', + projectId: 'project', + hostId: 'local', + providerIdentity: { provider: 'github' } + } + } + }).success + ).toBe(false) + }) +}) + +describe('github identity blank fields', () => { + // The normalizer treats a blank owner or repo as no identity, so the schema must agree. + it.each(['', ' ', '\t'])('rejects a blank owner %j', (owner) => { + expect( + TaskProviderIdentity.safeParse({ provider: 'github', owner, repo: 'orca' }).success + ).toBe(false) + }) + + it.each(['', ' '])('rejects a blank repo %j', (repo) => { + expect( + TaskProviderIdentity.safeParse({ provider: 'github', owner: 'stablyai', repo }).success + ).toBe(false) + }) + + it('still accepts a populated identity', () => { + expect( + TaskProviderIdentity.safeParse({ provider: 'github', owner: 'stablyai', repo: 'orca' }) + .success + ).toBe(true) + }) + + it('leaves the parsed value untrimmed, so no wire bytes change', () => { + const parsed = TaskProviderIdentity.safeParse({ + provider: 'github', + owner: ' stablyai ', + repo: 'orca' + }) + expect(parsed.success && parsed.data?.owner).toBe(' stablyai ') + }) +}) diff --git a/src/shared/rpc-contract/automation-params.ts b/src/shared/rpc-contract/automation-params.ts index fff5d5db615..d2ddd4aaad6 100644 --- a/src/shared/rpc-contract/automation-params.ts +++ b/src/shared/rpc-contract/automation-params.ts @@ -14,7 +14,6 @@ import { MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, normalizeAutomationPrecheckTimeoutSeconds } from '../automation-precheck' -import type { TaskProviderIdentity as SharedTaskProviderIdentity } from '../task-source-context' export const TuiAgent = requiredString('Missing provider').refine(isTuiAgent, { message: 'Unknown provider' @@ -58,14 +57,51 @@ export const OptionalNullablePlainString = z .pipe(z.union([z.string(), z.null(), z.undefined()])) .optional() +// A GitHub identity is only usable with both fields present and non-blank. +const GithubIdentityField = z.string().refine((value) => value.trim().length > 0, { + message: 'Required' +}) + export const TaskProviderIdentity = z - .custom( - (value) => - value !== null && - typeof value === 'object' && - 'provider' in value && - ['github', 'gitlab', 'linear', 'jira'].includes(String(value.provider)) - ) + .discriminatedUnion('provider', [ + z + .object({ + provider: z.literal('github'), + // Why refine, not .trim(): normalizeTaskProviderIdentity treats a blank owner or repo as + // no identity at all, so blank must be rejected here — but trimming would rewrite the + // parsed value and change what the handler receives. + owner: GithubIdentityField, + repo: GithubIdentityField, + host: z.string().optional() + }) + .passthrough(), + z + .object({ + provider: z.literal('gitlab'), + projectId: z.string().nullable().optional(), + namespace: z.string().nullable().optional(), + project: z.string().nullable().optional(), + webUrl: z.string().nullable().optional() + }) + .passthrough(), + z + .object({ + provider: z.literal('linear'), + workspaceId: z.string().nullable().optional(), + workspaceName: z.string().nullable().optional(), + teamId: z.string().nullable().optional(), + teamKey: z.string().nullable().optional() + }) + .passthrough(), + z + .object({ + provider: z.literal('jira'), + siteId: z.string().nullable().optional(), + siteUrl: z.string().nullable().optional(), + projectKey: z.string().nullable().optional() + }) + .passthrough() + ]) .optional() .nullable() From 241fb9ed9d8308d64b4450725732d2ec0f273876 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 13 Sep 2026 16:27:06 -0700 Subject: [PATCH 08/34] perf(terminal): batch file-link checks on their owning host (#20463) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(terminal): batch file-link existence checks on their owning host * test(relay): allow additive filesystem capabilities * fix(web): keep terminal file links working under batched existence checks createShellApi omitted pathsExist, so withFallback answered the new batch call with a truthy proxy resolving to undefined and the whole hover batch rejected — dropping every link on lines with an out-of-worktree path. * test(web): assert the shim without type assertions --------- Co-authored-by: m4air Co-authored-by: m4air Co-authored-by: Neil --- .../filesystem/filesystem-read-handlers.ts | 36 ++++++ src/main/ipc/shell.ts | 6 + .../providers/filesystem-provider-contract.ts | 2 + .../ssh-filesystem-path-existence.ts | 45 +++++++ .../ssh-filesystem-provider-capabilities.ts | 6 + src/main/providers/ssh-filesystem-provider.ts | 6 + ...l-path-existence-batch.integration.test.ts | 113 +++++++++++++++++ src/main/runtime/rpc/methods/files.ts | 7 ++ .../runtime/runtime-file-command-surface.ts | 2 + ...time-file-commands-search-runtime-files.ts | 14 +++ .../runtime-file-path-existence.test.ts | 97 +++++++++++++++ .../runtime/runtime-file-path-existence.ts | 39 ++++++ src/preload/api/filesystem-api.ts | 5 + src/preload/api/fs-bridge.ts | 5 + src/preload/api/shell-api.ts | 1 + src/preload/api/shell-bridge.ts | 2 + .../fs-handler-file-range-dispatch.test.ts | 2 +- src/relay/fs-handler.ts | 5 +- src/relay/fs-path-existence.ts | 22 ++++ .../terminal-pane/terminal-link-handlers.ts | 9 +- .../terminal-link-provider-batching.test.ts | 92 ++++++++++++++ .../terminal-path-existence-batch.test.ts | 68 ++++++++++ .../terminal-path-existence-batch.ts | 117 ++++++++++++++++++ .../runtime/runtime-file-metadata-client.ts | 14 ++- .../runtime-path-existence-batch.test.ts | 88 +++++++++++++ .../runtime/runtime-path-existence-batch.ts | 81 ++++++++++++ ...untime-path-existence-error-parity.test.ts | 79 ++++++++++++ .../runtime-path-existence-pairing.test.ts | 60 +++++++++ ...ntime-path-existence-queue-pairing.test.ts | 96 ++++++++++++++ .../src/web/preload-api/web-shell-api.ts | 20 +-- .../src/web/web-shell-paths-exist.test.ts | 21 ++++ src/shared/path-existence-batch.ts | 40 ++++++ src/shared/protocol-version.ts | 1 + src/shared/rpc-contract/files-params.ts | 5 + .../rpc-params-catalog.generated.ts | 2 + 35 files changed, 1187 insertions(+), 21 deletions(-) create mode 100644 src/main/providers/ssh-filesystem-path-existence.ts create mode 100644 src/main/providers/terminal-path-existence-batch.integration.test.ts create mode 100644 src/main/runtime/runtime-file-path-existence.test.ts create mode 100644 src/main/runtime/runtime-file-path-existence.ts create mode 100644 src/relay/fs-path-existence.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-link-provider-batching.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-path-existence-batch.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-path-existence-batch.ts create mode 100644 src/renderer/src/runtime/runtime-path-existence-batch.test.ts create mode 100644 src/renderer/src/runtime/runtime-path-existence-batch.ts create mode 100644 src/renderer/src/runtime/runtime-path-existence-error-parity.test.ts create mode 100644 src/renderer/src/runtime/runtime-path-existence-pairing.test.ts create mode 100644 src/renderer/src/runtime/runtime-path-existence-queue-pairing.test.ts create mode 100644 src/renderer/src/web/web-shell-paths-exist.test.ts create mode 100644 src/shared/path-existence-batch.ts diff --git a/src/main/ipc/filesystem/filesystem-read-handlers.ts b/src/main/ipc/filesystem/filesystem-read-handlers.ts index 938370a2816..2850ba659b4 100644 --- a/src/main/ipc/filesystem/filesystem-read-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-read-handlers.ts @@ -1,3 +1,8 @@ +import { + capturePathExistence, + validatePathExistenceBatch, + type PathExistenceResult +} from '../../../shared/path-existence-batch' import { ipcMain } from 'electron' import { readdir, readFile, stat } from 'node:fs/promises' import { extname } from 'node:path' @@ -147,6 +152,37 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext } ) + ipcMain.handle( + 'fs:pathsExist', + async ( + _event, + args: { filePaths: string[]; connectionId?: string } + ): Promise => { + validatePathExistenceBatch(args.filePaths) + const provider = args.connectionId ? requireSshFilesystemProvider(args.connectionId) : null + if (provider?.pathsExist) { + return provider.pathsExist(args.filePaths) + } + return Promise.all( + args.filePaths.map((filePath) => + capturePathExistence(async () => { + try { + await (provider + ? provider.stat(filePath) + : stat(await resolveAuthorizedPath(filePath, store))) + return true + } catch (error) { + if (isENOENT(error)) { + return false + } + throw error + } + }) + ) + ) + } + ) + ipcMain.handle( 'fs:pathExists', async (_event, args: { filePath: string; connectionId?: string }): Promise => { diff --git a/src/main/ipc/shell.ts b/src/main/ipc/shell.ts index 80f02552f18..7ed49c4af7c 100644 --- a/src/main/ipc/shell.ts +++ b/src/main/ipc/shell.ts @@ -1,3 +1,4 @@ +import { validatePathExistenceBatch } from '../../shared/path-existence-batch' import { ipcMain, shell, dialog } from 'electron' import { constants, copyFile, readFile, stat } from 'node:fs/promises' import { basename, extname, isAbsolute, normalize, posix, win32 } from 'node:path' @@ -204,6 +205,11 @@ export function registerShellHandlers(store: Store): void { await openWithSystemDefault(target.path) }) + ipcMain.handle('shell:pathsExist', async (_event, paths: string[]): Promise => { + validatePathExistenceBatch(paths) + return Promise.all(paths.map(pathExists)) + }) + ipcMain.handle('shell:pathExists', async (_event, filePath: string): Promise => { return pathExists(filePath) }) diff --git a/src/main/providers/filesystem-provider-contract.ts b/src/main/providers/filesystem-provider-contract.ts index e42bd5b07c9..ae4a59eb7bb 100644 --- a/src/main/providers/filesystem-provider-contract.ts +++ b/src/main/providers/filesystem-provider-contract.ts @@ -1,3 +1,4 @@ +import type { PathExistenceResult } from '../../shared/path-existence-batch' import type { SearchOptions, SearchResult } from '../../shared/code-search-types' import type { DocPreviewFileAccessRequest, @@ -86,6 +87,7 @@ export type IFilesystemProvider = { ): Promise writeFileBase64(filePath: string, contentBase64: string): Promise writeFileBase64Chunk(filePath: string, contentBase64: string, append: boolean): Promise + pathsExist?(filePaths: string[]): Promise stat(filePath: string): Promise lstat?(filePath: string): Promise deletePath(targetPath: string, recursive?: boolean): Promise diff --git a/src/main/providers/ssh-filesystem-path-existence.ts b/src/main/providers/ssh-filesystem-path-existence.ts new file mode 100644 index 00000000000..08570fc17b3 --- /dev/null +++ b/src/main/providers/ssh-filesystem-path-existence.ts @@ -0,0 +1,45 @@ +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { isMethodNotFoundError } from '../ssh/ssh-filesystem-stream-reader' +import { isENOENT } from '../ipc/filesystem-path-containment' +import { + capturePathExistence, + requirePathExistenceResults, + validatePathExistenceBatch, + type PathExistenceResult +} from '../../shared/path-existence-batch' +import { probeSshPathExistenceBatchCapability } from './ssh-filesystem-provider-capabilities' + +export async function readSshPathExistenceBatch( + mux: SshChannelMultiplexer, + paths: string[], + stat: (path: string) => Promise +): Promise { + validatePathExistenceBatch(paths) + if (await probeSshPathExistenceBatchCapability(mux)) { + try { + return requirePathExistenceResults( + await mux.request('fs.pathsExist', { filePaths: paths }), + paths.length + ) + } catch (error) { + if (!isMethodNotFoundError(error)) { + throw error + } + } + } + return Promise.all( + paths.map((path) => + capturePathExistence(async () => { + try { + await stat(path) + return true + } catch (error) { + if (isENOENT(error)) { + return false + } + throw error + } + }) + ) + ) +} diff --git a/src/main/providers/ssh-filesystem-provider-capabilities.ts b/src/main/providers/ssh-filesystem-provider-capabilities.ts index 2703c4d16c7..5d57bd588bb 100644 --- a/src/main/providers/ssh-filesystem-provider-capabilities.ts +++ b/src/main/providers/ssh-filesystem-provider-capabilities.ts @@ -62,3 +62,9 @@ export function probeSshRangedReadCapability( (capabilities) => capabilities?.rangedReadVersion === 1 ) } + +export function probeSshPathExistenceBatchCapability(mux: SshChannelMultiplexer): Promise { + return readSshFsCapabilities(mux).then( + (capabilities) => capabilities?.pathExistenceBatchVersion === 1 + ) +} diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts index f6208ea00e9..f688d2789b1 100644 --- a/src/main/providers/ssh-filesystem-provider.ts +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -1,3 +1,5 @@ +import { readSshPathExistenceBatch } from './ssh-filesystem-path-existence' +import type { PathExistenceResult } from '../../shared/path-existence-batch' import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import { isMethodNotFoundError, readFileViaStream } from '../ssh/ssh-filesystem-stream-reader' import { uploadBuffer } from '../ssh/sftp-upload' @@ -217,6 +219,10 @@ export class SshFilesystemProvider implements IFilesystemProvider { } } + pathsExist(filePaths: string[]): Promise { + return readSshPathExistenceBatch(this.mux, filePaths, (path) => this.stat(path)) + } + async stat(filePath: string): Promise { return (await this.mux.request('fs.stat', { filePath })) as FileStat } diff --git a/src/main/providers/terminal-path-existence-batch.integration.test.ts b/src/main/providers/terminal-path-existence-batch.integration.test.ts new file mode 100644 index 00000000000..c67b627d682 --- /dev/null +++ b/src/main/providers/terminal-path-existence-batch.integration.test.ts @@ -0,0 +1,113 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { mkdtemp, writeFile, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { pathsExistOnRelay } from '../../relay/fs-path-existence' +import { statRelayPath } from '../../relay/fs-path-metadata-requests' +import { readSshPathExistenceBatch } from './ssh-filesystem-path-existence' +import { JsonRpcErrorCode } from '../ssh/relay-protocol' +const handlers = vi.hoisted(() => new Map Promise>()) +vi.mock('electron', () => ({ + ipcMain: { + handle: (name: string, fn: (...args: unknown[]) => Promise) => handlers.set(name, fn) + }, + shell: {}, + dialog: {} +})) +import { registerShellHandlers } from '../ipc/shell' +let root: string | undefined +afterEach(async () => { + if (root) { + await rm(root, { recursive: true, force: true }) + } + root = undefined + handlers.clear() +}) +async function fixture() { + root = await mkdtemp(join(tmpdir(), 'orca-link-batch-')) + const paths = Array.from({ length: 8 }, (_, i) => join(root!, `file-${i}.ts`)) + await Promise.all(paths.map((path) => writeFile(path, 'fixture'))) + return paths +} +it('one actual shell IPC handler probes eight distinct temporary files and retains scalar answers', async () => { + const paths = await fixture() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This registration fixture never invokes unrelated store operations. + registerShellHandlers({} as never) + const all = [...paths, join(root!, 'missing'), root!] + expect(await handlers.get('shell:pathsExist')!(null, all)).toEqual( + await Promise.all(all.map((path) => handlers.get('shell:pathExists')!(null, path))) + ) + expect(await handlers.get('shell:pathsExist')!(null, all)).toEqual([ + ...paths.map(() => true), + false, + true + ]) + await expect(handlers.get('shell:pathsExist')!(null, Array(129).fill('x'))).rejects.toThrow( + 'Invalid' + ) +}) +it('one real relay batch serves eight distinct SSH paths after one shared capability probe', async () => { + const paths = await fixture() + const request = vi.fn(async (method: string, params: Record) => + method === 'fs.getCapabilities' ? { pathExistenceBatchVersion: 1 } : pathsExistOnRelay(params) + ) + const scalar = vi.fn((path: string) => statRelayPath({ filePath: path })) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture implements request, the only multiplexer operation exercised here. + const mux = { request } as never + expect(await readSshPathExistenceBatch(mux, paths, scalar)).toEqual( + paths.map(() => ({ exists: true })) + ) + expect(request.mock.calls.map((c) => c[0])).toEqual(['fs.getCapabilities', 'fs.pathsExist']) + expect(scalar).not.toHaveBeenCalled() + await rm(paths[0]) + expect(await readSshPathExistenceBatch(mux, [paths[0]], scalar)).toEqual([{ exists: false }]) + await writeFile(paths[0], 'new') + expect(await readSshPathExistenceBatch(mux, [paths[0]], scalar)).toEqual([{ exists: true }]) + expect(request.mock.calls.filter((c) => c[0] === 'fs.getCapabilities')).toHaveLength(1) +}) +it('old relay falls back on the same host without retrying a missing capability document', async () => { + const paths = await fixture() + const request = vi + .fn() + .mockRejectedValue( + Object.assign(new Error('method not found'), { code: JsonRpcErrorCode.MethodNotFound }) + ) + const scalar = vi.fn((path: string) => statRelayPath({ filePath: path })) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture implements request, the only multiplexer operation exercised here. + const mux = { request } as never + expect(await readSshPathExistenceBatch(mux, paths, scalar)).toEqual( + paths.map(() => ({ exists: true })) + ) + expect(scalar).toHaveBeenCalledTimes(8) + await readSshPathExistenceBatch(mux, [paths[0]], scalar) + expect(request).toHaveBeenCalledTimes(1) +}) +it('connection failure is neither a missing path nor permission to use local/scalar fallback', async () => { + const scalar = vi.fn() + const request = vi.fn().mockRejectedValue(new Error('connection closed')) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture implements request, the only multiplexer operation exercised here. + const mux = { request } as never + await expect(readSshPathExistenceBatch(mux, ['/remote/path'], scalar)).rejects.toThrow( + 'connection closed' + ) + expect(scalar).not.toHaveBeenCalled() + request + .mockResolvedValueOnce({ pathExistenceBatchVersion: 1 }) + .mockResolvedValueOnce([{ error: 'EACCES denied' }]) + expect(await readSshPathExistenceBatch(mux, ['/remote/path'], scalar)).toEqual([ + { error: 'EACCES denied' } + ]) + expect(request).toHaveBeenCalledTimes(3) +}) +it('malformed batch replies fail rather than manufacturing negative cache entries', async () => { + const scalar = vi.fn() + const request = vi + .fn() + .mockResolvedValueOnce({ pathExistenceBatchVersion: 1 }) + .mockResolvedValueOnce([]) + await expect( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture implements request, the only multiplexer operation exercised here. + readSshPathExistenceBatch({ request } as never, ['/remote/path'], scalar) + ).rejects.toThrow('Invalid path existence response') + expect(scalar).not.toHaveBeenCalled() +}) diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts index 055c02b3265..29e2f7b071f 100644 --- a/src/main/runtime/rpc/methods/files.ts +++ b/src/main/runtime/rpc/methods/files.ts @@ -7,6 +7,7 @@ import { limitQuickOpenSearchReplyBySerializedBytes } from '../../../../shared/q import { FileOpen, WorktreeSelector } from './files-target-schemas' import { FILE_TERMINAL_ARTIFACT_METHODS } from './files-terminal-artifact-methods' import { + FilePathsExist, DocPreviewFileRead, FileListAll, FileOpenDiff, @@ -164,6 +165,12 @@ export const FILE_METHODS = [ params: WorktreeSelector, handler: async (params, { runtime }) => runtime.listRuntimeMarkdownDocuments(params.worktree) }), + defineMethod({ + name: 'files.pathsExist', + params: FilePathsExist, + handler: async (params, { runtime }) => + runtime.pathsExistRuntimeFiles(params.worktree, params.relativePaths) + }), defineMethod({ name: 'files.stat', params: FileTreePath, diff --git a/src/main/runtime/runtime-file-command-surface.ts b/src/main/runtime/runtime-file-command-surface.ts index cd891301175..554eed008a3 100644 --- a/src/main/runtime/runtime-file-command-surface.ts +++ b/src/main/runtime/runtime-file-command-surface.ts @@ -30,6 +30,7 @@ type RuntimeFileCommandName = | 'searchRuntimeFiles' | 'listRuntimeFiles' | 'listRuntimeMarkdownDocuments' + | 'pathsExistRuntimeFiles' | 'statRuntimeFile' export type RuntimeFileCommandSurface = Pick @@ -68,6 +69,7 @@ export function installRuntimeFileCommandSurface( searchRuntimeFiles: commands.searchRuntimeFiles.bind(commands), listRuntimeFiles: commands.listRuntimeFiles.bind(commands), listRuntimeMarkdownDocuments: commands.listRuntimeMarkdownDocuments.bind(commands), + pathsExistRuntimeFiles: commands.pathsExistRuntimeFiles.bind(commands), statRuntimeFile: commands.statRuntimeFile.bind(commands) } satisfies RuntimeFileCommandSurface) } diff --git a/src/main/runtime/runtime-file-commands-search-runtime-files.ts b/src/main/runtime/runtime-file-commands-search-runtime-files.ts index 5cd1f6246a8..fd79941c1ae 100644 --- a/src/main/runtime/runtime-file-commands-search-runtime-files.ts +++ b/src/main/runtime/runtime-file-commands-search-runtime-files.ts @@ -13,6 +13,11 @@ import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from '../ipc/markdown-documents' +import { + validatePathExistenceBatch, + type PathExistenceResult +} from '../../shared/path-existence-batch' +import { readRuntimeFilePathExistence } from './runtime-file-path-existence' import { stat } from 'node:fs/promises' import { resolveAuthorizedPath } from '../ipc/filesystem-auth' @@ -80,6 +85,15 @@ export class RuntimeFileCommandsWithSearchRuntimeFiles extends RuntimeFileComman return listMarkdownDocuments(target.worktree.path) } + async pathsExistRuntimeFiles( + worktreeSelector: string, + relativePaths: string[] + ): Promise { + validatePathExistenceBatch(relativePaths) + const targets = await this.resolveFileExplorerPaths(worktreeSelector, relativePaths) + return readRuntimeFilePathExistence(targets, () => this.host.requireStore()) + } + async statRuntimeFile( worktreeSelector: string, relativePath: string diff --git a/src/main/runtime/runtime-file-path-existence.test.ts b/src/main/runtime/runtime-file-path-existence.test.ts new file mode 100644 index 00000000000..d74c58bfdae --- /dev/null +++ b/src/main/runtime/runtime-file-path-existence.test.ts @@ -0,0 +1,97 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { mkdtemp, writeFile, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { RuntimeFileCommands } from './orca-runtime-files' +import { RpcDispatcher } from './rpc/dispatcher' +import { FILE_METHODS } from './rpc/methods/files' +import { + registerSshFilesystemProvider, + unregisterSshFilesystemProvider +} from '../providers/ssh-filesystem-dispatch' +import { pathsExistOnRelay } from '../../relay/fs-path-existence' +import { statRelayPath } from '../../relay/fs-path-metadata-requests' +let root: string | undefined +const connection = 'batch-fixture-host' +afterEach(async () => { + unregisterSshFilesystemProvider(connection) + if (root) { + await rm(root, { recursive: true, force: true }) + } + root = undefined +}) +async function setup(legacy = false) { + root = await mkdtemp(join(tmpdir(), 'orca-runtime-batch-')) + const names = Array.from({ length: 8 }, (_, i) => `file-${i}.ts`) + await Promise.all(names.map((name) => writeFile(join(root!, name), 'fixture'))) + const provider = { + pathsExist: legacy + ? undefined + : vi.fn((paths: string[]) => pathsExistOnRelay({ filePaths: paths })), + stat: vi.fn((filePath: string) => statRelayPath({ filePath })) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The registered fixture implements the stat and optional batch operations exercised here. + registerSshFilesystemProvider(connection, provider as never) + const resolveTarget = vi.fn(async () => ({ + worktree: { id: 'folder-1', path: root, kind: 'folder', repoId: 'folder-repo' }, + executionHostId: `ssh:${connection}` + })) + const host = { + getRuntimeId: () => 'runtime-fixture', + requireStore: vi.fn(() => { + throw new Error('Local store should not be read') + }), + resolveRuntimeFileTarget: resolveTarget + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture supplies runtime identity, target resolution and the guarded store accessor used by these reads. + const commands = new RuntimeFileCommands(host as never) + const runtime = { + getRuntimeId: host.getRuntimeId, + pathsExistRuntimeFiles: commands.pathsExistRuntimeFiles.bind(commands), + statRuntimeFile: commands.statRuntimeFile.bind(commands) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Dispatch is limited to the two file methods implemented by this fixture. + const dispatcher = new RpcDispatcher({ runtime: runtime as never, methods: FILE_METHODS }) + const dispatch = (relativePaths: string[]) => + dispatcher.dispatch({ + id: 'batch-1', + authToken: 'fixture', + method: 'files.pathsExist', + params: { worktree: 'id:folder-1', relativePaths } + }) + return { names, provider, resolveTarget, host, dispatch } +} +it('actual RPC dispatch resolves one folder owner and sends one provider batch for eight real files', async () => { + const f = await setup() + expect(await f.dispatch(f.names)).toMatchObject({ + ok: true, + result: f.names.map(() => ({ exists: true })) + }) + expect(f.resolveTarget).toHaveBeenCalledTimes(1) + expect(f.resolveTarget).toHaveBeenCalledWith('id:folder-1') + expect(f.provider.pathsExist).toHaveBeenCalledTimes(1) + expect(f.provider.stat).not.toHaveBeenCalled() + expect(f.host.requireStore).not.toHaveBeenCalled() + expect(await f.dispatch(['../escape'])).toMatchObject({ ok: false }) + expect(f.provider.pathsExist).toHaveBeenCalledTimes(1) +}) +it('legacy provider preserves all answers through scoped scalar stats', async () => { + const f = await setup(true) + expect(await f.dispatch([...f.names, 'missing'])).toMatchObject({ + ok: true, + result: [...f.names.map(() => ({ exists: true })), { exists: false }] + }) + expect(f.provider.stat).toHaveBeenCalledTimes(9) + expect(f.host.requireStore).not.toHaveBeenCalled() +}) +it('unavailable SSH never falls back to matching local files; oversized input never reaches provider', async () => { + const f = await setup() + unregisterSshFilesystemProvider(connection) + expect(await f.dispatch(f.names)).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('Remote connection dropped') } + }) + expect(f.host.requireStore).not.toHaveBeenCalled() + expect(await f.dispatch(Array(129).fill('file-0.ts'))).toMatchObject({ ok: false }) + expect(f.provider.pathsExist).not.toHaveBeenCalled() +}) diff --git a/src/main/runtime/runtime-file-path-existence.ts b/src/main/runtime/runtime-file-path-existence.ts new file mode 100644 index 00000000000..197cf7ac814 --- /dev/null +++ b/src/main/runtime/runtime-file-path-existence.ts @@ -0,0 +1,39 @@ +import { stat } from 'node:fs/promises' +import { capturePathExistence, type PathExistenceResult } from '../../shared/path-existence-batch' +import { resolveAuthorizedPath } from '../ipc/filesystem-auth' +import { isENOENT } from '../ipc/filesystem-path-containment' +import type { RuntimeFileCommandHost } from './runtime-file-command-host' +import { + requireRuntimeFileProvider, + type RuntimeFileExplorerPath +} from './runtime-file-command-target' + +export async function readRuntimeFilePathExistence( + targets: readonly RuntimeFileExplorerPath[], + requireStore: RuntimeFileCommandHost['requireStore'] +): Promise { + if (targets.length === 0) { + return [] + } + const provider = requireRuntimeFileProvider(targets[0]) + if (provider?.pathsExist) { + return provider.pathsExist(targets.map((target) => target.path)) + } + return Promise.all( + targets.map((target) => + capturePathExistence(async () => { + try { + await (provider + ? provider.stat(target.path) + : stat(await resolveAuthorizedPath(target.path, requireStore()))) + return true + } catch (error) { + if (isENOENT(error)) { + return false + } + throw error + } + }) + ) + ) +} diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts index 0312bc87fd0..478f3040f72 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -1,3 +1,4 @@ +import type { PathExistenceResult } from '../../shared/path-existence-batch' import type { SearchOptions, SearchResult } from '../../shared/code-search-types' import type { DirEntry, @@ -111,6 +112,10 @@ export type FilesystemApi = { filePath: string connectionId?: string }) => Promise<{ size: number; isDirectory: boolean; mtime: number }> + pathsExist?: (args: { + filePaths: string[] + connectionId?: string + }) => Promise pathExists: (args: { filePath: string; connectionId?: string }) => Promise listFiles: (args: { rootPath: string diff --git a/src/preload/api/fs-bridge.ts b/src/preload/api/fs-bridge.ts index c67e9abd9e3..207b34d8519 100644 --- a/src/preload/api/fs-bridge.ts +++ b/src/preload/api/fs-bridge.ts @@ -1,3 +1,4 @@ +import type { PathExistenceResult } from '../../shared/path-existence-batch' import { ipcRenderer } from 'electron' import type { SshMutationExpectation } from '../../shared/ssh-types' import type { SearchResult } from '../../shared/code-search-types' @@ -116,6 +117,10 @@ export const fsApi = { connectionId?: string }): Promise<{ size: number; isDirectory: boolean; mtime: number }> => ipcRenderer.invoke('fs:stat', args), + pathsExist: (args: { + filePaths: string[] + connectionId?: string + }): Promise => ipcRenderer.invoke('fs:pathsExist', args), pathExists: (args: { filePath: string; connectionId?: string }): Promise => ipcRenderer.invoke('fs:pathExists', args), listFiles: (args: { diff --git a/src/preload/api/shell-api.ts b/src/preload/api/shell-api.ts index d466ac26163..49e811aeb65 100644 --- a/src/preload/api/shell-api.ts +++ b/src/preload/api/shell-api.ts @@ -19,6 +19,7 @@ export type ShellApi = { openUrl: (url: string) => Promise openFilePath: (path: string) => Promise openFileUri: (uri: string) => Promise + pathsExist?: (paths: string[]) => Promise pathExists: (path: string) => Promise pickAttachment: () => Promise pickImage: () => Promise diff --git a/src/preload/api/shell-bridge.ts b/src/preload/api/shell-bridge.ts index 21ccda3fd83..6cd250042a6 100644 --- a/src/preload/api/shell-bridge.ts +++ b/src/preload/api/shell-bridge.ts @@ -23,6 +23,8 @@ export const shellApi = { openFileUri: (uri: string): Promise => ipcRenderer.invoke('shell:openFileUri', uri), + pathsExist: (paths: string[]): Promise => + ipcRenderer.invoke('shell:pathsExist', paths), pathExists: (path: string): Promise => ipcRenderer.invoke('shell:pathExists', path), pickAttachment: (): Promise => ipcRenderer.invoke('shell:pickAttachment'), diff --git a/src/relay/fs-handler-file-range-dispatch.test.ts b/src/relay/fs-handler-file-range-dispatch.test.ts index 3f1de4693a4..20f5d69dd21 100644 --- a/src/relay/fs-handler-file-range-dispatch.test.ts +++ b/src/relay/fs-handler-file-range-dispatch.test.ts @@ -149,7 +149,7 @@ describe('fs.getCapabilities', () => { // is additive. Dropping the pre-existing key would strand an older desktop's // quick-open probe on a host that still serves it. it('advertises ranged reads without dropping the existing capability', async () => { - await expect(underTest.call('fs.getCapabilities', {})).resolves.toEqual({ + await expect(underTest.call('fs.getCapabilities', {})).resolves.toMatchObject({ quickOpenSearchVersion: 1, rangedReadVersion: 1 }) diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index d20d0d073d6..6286c71941e 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -1,3 +1,4 @@ +import { pathsExistOnRelay } from './fs-path-existence' import { tmpdir } from 'node:os' import type { RelayDispatcher, RequestContext } from './dispatcher' import type { RelayContext } from './context' @@ -89,6 +90,7 @@ export class FsHandler { this.dispatcher.onRequest('fs.tempDir', () => this.tempDir()) this.dispatcher.onRequest('fs.writeFile', (p) => writeRelayFile(p)) this.dispatcher.onRequest('fs.writeTerminalArtifact', (p) => this.writeTerminalArtifact(p)) + this.dispatcher.onRequest('fs.pathsExist', pathsExistOnRelay) this.dispatcher.onRequest('fs.stat', (p) => statRelayPath(p)) this.dispatcher.onRequest('fs.lstat', (p) => lstatRelayPath(p)) this.dispatcher.onRequest('fs.deletePath', (p) => deleteRelayPath(p, this.watchRegistry)) @@ -102,7 +104,8 @@ export class FsHandler { this.dispatcher.onRequest('fs.search', (p) => this.search(p)) this.dispatcher.onRequest('fs.getCapabilities', async () => ({ quickOpenSearchVersion: 1, - rangedReadVersion: 1 + rangedReadVersion: 1, + pathExistenceBatchVersion: 1 })) this.dispatcher.onRequest('fs.listFiles', (p, c) => this.listFiles(p, c)) this.dispatcher.onRequest('fs.workspaceSpaceScan', (p, c) => this.workspaceSpaceScan(p, c)) diff --git a/src/relay/fs-path-existence.ts b/src/relay/fs-path-existence.ts new file mode 100644 index 00000000000..48ee51cda2b --- /dev/null +++ b/src/relay/fs-path-existence.ts @@ -0,0 +1,22 @@ +import { statRelayPath } from './fs-path-metadata-requests' +import { capturePathExistence, validatePathExistenceBatch } from '../shared/path-existence-batch' + +export async function pathsExistOnRelay(params: Record) { + const paths = params.filePaths + validatePathExistenceBatch(paths) + return Promise.all( + paths.map((filePath) => + capturePathExistence(async () => { + try { + await statRelayPath({ filePath }) + return true + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return false + } + throw error + } + }) + ) + ) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts index b3890bb18b5..75523e406a7 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts @@ -1,3 +1,4 @@ +import { createTerminalPathExistenceBatch } from './terminal-path-existence-batch' import type { IDisposable, ILink, ILinkProvider, Terminal } from '@xterm/xterm' import { extractTerminalFileLinkCandidates, @@ -5,7 +6,7 @@ import { resolveTerminalFileLink } from '@/lib/terminal-links' import type { PaneManager } from '@/lib/pane-manager/pane-manager' -import { isRemoteRuntimeFileOperation, runtimePathExists } from '@/runtime/runtime-file-client' +import { isRemoteRuntimeFileOperation } from '@/runtime/runtime-file-client' import { buildCandidateLogicalLinesForBufferPosition, dedupeLogicalLines, @@ -128,6 +129,7 @@ export function createFilePathLinkProvider( return } + const pathExists = createTerminalPathExistenceBatch() void Promise.all( logicalLines.flatMap((logicalLine) => extractTerminalFileLinkCandidates(logicalLine.text).map( @@ -172,10 +174,7 @@ export function createFilePathLinkProvider( if (!worktreeRootLink) { const cachedExists = readTerminalPathExistsCache(pathExistsCache, cacheKey) const exists = - cachedExists ?? - (fileContext.connectionId || isRemoteRuntimePath - ? await runtimePathExists(fileContext, mappedPath) - : await window.api.shell.pathExists(mappedPath)) + cachedExists ?? (await pathExists(fileContext, mappedPath, isRemoteRuntimePath)) writeTerminalPathExistsCache(pathExistsCache, cacheKey, exists) if (!exists) { return null diff --git a/src/renderer/src/components/terminal-pane/terminal-link-provider-batching.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-provider-batching.test.ts new file mode 100644 index 00000000000..d79c91aae04 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-provider-batching.test.ts @@ -0,0 +1,92 @@ +import type { ILink } from '@xterm/xterm' +import { expect, it, vi } from 'vitest' +import { createTerminalLinkTestDoubles } from './terminal-link-handlers-test-fixtures' +import { + createProvider, + createProviderSetup, + makeBufferLine +} from './terminal-link-provider-buffer-fixtures' +import { + createDeferred, + flushAsyncWork, + installTerminalLinkTestEnvironment +} from './terminal-link-handlers-test-harness' + +const doubles = createTerminalLinkTestDoubles() +const { storeState } = doubles + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => storeState + } +})) + +vi.mock('@/lib/language-detect', () => ({ + detectLanguage: (filePath: string) => (filePath.endsWith('.md') ? 'markdown' : 'plaintext') +})) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorkspace: vi.fn(), + activateAndRevealWorktree: vi.fn() +})) + +vi.mock('@/lib/connection-context', () => ({ + getConnectionId: vi.fn(() => null) +})) + +installTerminalLinkTestEnvironment(doubles) + +it.each([false, true])('batches all cold hover candidates repeated=%s', async (repeated) => { + const text = Array.from( + { length: 8 }, + (_, i) => `./${repeated ? 'same' : `file${i}`}.ts:${i + 1}` + ).join(' ') + const batch = vi.fn(async (paths: string[]) => paths.map(() => true)) + window.api.shell.pathsExist = batch + const { provider } = createProviderSetup([makeBufferLine(text)], new Map()) + const links = await new Promise((resolve) => + provider.provideLinks(1, (links) => resolve(links ?? [])) + ) + expect(batch).toHaveBeenCalledTimes(1) + expect(batch.mock.calls[0][0]).toHaveLength(repeated ? 1 : 8) + expect(window.api.shell.pathExists).not.toHaveBeenCalled() + expect(links).toHaveLength(8) + expect(links.map((link) => link.text)).toEqual( + Array.from({ length: 8 }, (_, i) => `./${repeated ? 'same' : `file${i}`}.ts:${i + 1}`) + ) + expect(new Set(links.map((link) => JSON.stringify(link.range))).size).toBe(8) +}) + +it('preserves warm positive and negative cache answers across hover turns', async () => { + const batch = vi.fn(async (paths: string[]) => paths.map((path) => !path.endsWith('missing.ts'))) + window.api.shell.pathsExist = batch + const cache = new Map() + const { provider } = createProviderSetup([makeBufferLine('./present.ts ./missing.ts')], cache) + const hover = () => + new Promise((resolve) => provider.provideLinks(1, (links) => resolve(links ?? []))) + expect((await hover()).map((link) => link.text)).toEqual(['./present.ts']) + expect((await hover()).map((link) => link.text)).toEqual(['./present.ts']) + expect(batch).toHaveBeenCalledTimes(1) + expect(batch.mock.calls[0][0]).toHaveLength(2) + expect([...cache.values()].sort()).toEqual([false, true]) +}) + +it('drops stale wrapped links while a batch is pending', async () => { + const rows = [ + makeBufferLine('open src/components/'), + makeBufferLine('terminal-link-handlers.ts', { isWrapped: true }) + ] + const exists = createDeferred() + const batch = vi.fn(() => exists.promise) + window.api.shell.pathsExist = batch + const provider = createProvider(rows) + const callback = vi.fn() + provider.provideLinks(1, callback) + await flushAsyncWork() + expect(batch).toHaveBeenCalledTimes(1) + rows[0] = makeBufferLine('changed src/other/') + exists.resolve([true]) + await flushAsyncWork() + await flushAsyncWork() + expect(callback).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-path-existence-batch.test.ts b/src/renderer/src/components/terminal-pane/terminal-path-existence-batch.test.ts new file mode 100644 index 00000000000..2945a8c2500 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-path-existence-batch.test.ts @@ -0,0 +1,68 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { createTerminalPathExistenceBatch } from '@/components/terminal-pane/terminal-path-existence-batch' +import { requirePathExistenceResults } from '../../../../shared/path-existence-batch' +const remote = vi.hoisted(() => vi.fn()) +vi.mock('@/runtime/runtime-path-existence-batch', () => ({ runtimePathsExist: remote })) +const context = { settings: null, worktreeId: 'folder:/work', worktreePath: '/work' } +afterEach(() => { + vi.unstubAllGlobals() + remote.mockReset() +}) +it('chunks 257 paths without truncation and preserves each result position', async () => { + const batch = vi.fn(async (paths: string[]) => + paths.map((path) => Number(path.slice(1)) % 2 === 0) + ) + vi.stubGlobal('window', { api: { shell: { pathsExist: batch } } }) + const enqueue = createTerminalPathExistenceBatch() + const results = await Promise.all( + Array.from({ length: 257 }, (_, i) => enqueue(context, `/${i}`, false)) + ) + expect(batch.mock.calls.map((call) => call[0].length)).toEqual([128, 128, 1]) + expect(results).toEqual(Array.from({ length: 257 }, (_, i) => i % 2 === 0)) +}) +it('isolates identical paths by runtime, worktree, connection, and hover turn', async () => { + remote.mockImplementation(async (_context, paths: string[]) => + paths.map(() => ({ exists: true })) + ) + const enqueue = createTerminalPathExistenceBatch() + const contexts = [ + { ...context, settings: { activeRuntimeEnvironmentId: 'a' } }, + { ...context, settings: { activeRuntimeEnvironmentId: 'b' } }, + { + ...context, + settings: { activeRuntimeEnvironmentId: 'a' }, + worktreeId: 'folder:/other', + worktreePath: '/other' + }, + { ...context, connectionId: 'ssh-one' }, + { ...context, connectionId: 'ssh-two' } + ] + await Promise.all( + contexts.flatMap((ctx) => [enqueue(ctx, '/same', true), enqueue(ctx, '/same', true)]) + ) + expect(remote).toHaveBeenCalledTimes(5) + expect(remote.mock.calls.map((call) => call[0])).toEqual(contexts) + expect(remote.mock.calls.every((call) => call[1].length === 1)).toBe(true) + await enqueue(contexts[0], '/same', true) + expect(remote).toHaveBeenCalledTimes(6) +}) +it('rejects transport failure without turning it into a missing path', async () => { + remote.mockRejectedValue(new Error('connection closed')) + const enqueue = createTerminalPathExistenceBatch() + await expect( + Promise.all([ + enqueue({ ...context, connectionId: 'ssh' }, '/a', true), + enqueue({ ...context, connectionId: 'ssh' }, '/b', true) + ]) + ).rejects.toThrow('connection closed') + expect(remote).toHaveBeenCalledTimes(1) +}) +it.each([ + { rows: [{ exists: true, error: 'denied' }] }, + { rows: [{ exists: 'yes', error: 'denied' }] }, + { rows: [{ exists: false }, {}] } +])('rejects ambiguous or incomplete wire results $rows', ({ rows }) => { + expect(() => requirePathExistenceResults(rows, rows.length)).toThrow( + 'Invalid path existence response' + ) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-path-existence-batch.ts b/src/renderer/src/components/terminal-pane/terminal-path-existence-batch.ts new file mode 100644 index 00000000000..010fb36bf82 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-path-existence-batch.ts @@ -0,0 +1,117 @@ +import { + PATH_EXISTENCE_BATCH_MAX, + type PathExistenceResult +} from '../../../../shared/path-existence-batch' +import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client-types' +import { runtimePathsExist } from '@/runtime/runtime-path-existence-batch' +import { getActiveRuntimeTarget } from '@/runtime/runtime-client-target' +import { captureRuntimeEnvironmentRequestRevision } from '@/runtime/runtime-environment-revision' + +type PendingPath = { + path: string + resolve: (exists: boolean) => void + reject: (error: unknown) => void + promise: Promise +} +type PathGroup = { + context: RuntimeFileOperationArgs + remote: boolean + pairingRevision?: number + paths: Map +} + +/** One hover turn shares a host request; no answers survive into another turn. */ +export function createTerminalPathExistenceBatch(): ( + context: RuntimeFileOperationArgs, + path: string, + remote: boolean +) => Promise { + const groups = new Map() + let queued = false + return (context, path, remote) => { + const target = getActiveRuntimeTarget(context.settings) + const pairingRevision = + target.kind === 'environment' + ? captureRuntimeEnvironmentRequestRevision(target.environmentId) + : undefined + const key = JSON.stringify([ + context.settings?.activeRuntimeEnvironmentId, + context.worktreeId, + context.worktreePath, + context.connectionId, + pairingRevision, + remote + ]) + let group = groups.get(key) + if (!group) { + group = { context, remote, pairingRevision, paths: new Map() } + groups.set(key, group) + } + const existing = group.paths.get(path) + if (existing) { + return existing.promise + } + let resolve!: (exists: boolean) => void + let reject!: (error: unknown) => void + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + group.paths.set(path, { path, resolve, reject, promise }) + if (!queued) { + queued = true + queueMicrotask(() => { + void flush() + }) + } + return promise + } + + async function flush(): Promise { + const ready = [...groups.values()] + groups.clear() + queued = false + await Promise.all( + ready.flatMap((group) => { + const pending = [...group.paths.values()] + return Array.from( + { length: Math.ceil(pending.length / PATH_EXISTENCE_BATCH_MAX) }, + (_, index) => + run( + group, + pending.slice( + index * PATH_EXISTENCE_BATCH_MAX, + (index + 1) * PATH_EXISTENCE_BATCH_MAX + ) + ) + ) + }) + ) + } + async function run(group: PathGroup, pending: PendingPath[]): Promise { + try { + const paths = pending.map((row) => row.path) + let results: PathExistenceResult[] + if (group.context.connectionId || group.remote) { + results = await runtimePathsExist(group.context, paths, group.pairingRevision) + } else { + const values = window.api.shell.pathsExist + ? await window.api.shell.pathsExist(paths) + : await Promise.all(paths.map((path) => window.api.shell.pathExists(path))) + if (values.length !== paths.length || values.some((value) => typeof value !== 'boolean')) { + throw new Error('Invalid local path existence response') + } + results = values.map((exists) => ({ exists })) + } + results.forEach((result, index) => { + if ('exists' in result) { + pending[index].resolve(result.exists) + } else { + pending[index].reject(new Error(result.error)) + } + }) + } catch (error) { + pending.forEach((row) => row.reject(error)) + } + } +} diff --git a/src/renderer/src/runtime/runtime-file-metadata-client.ts b/src/renderer/src/runtime/runtime-file-metadata-client.ts index d072e800cb8..e9d2e867b77 100644 --- a/src/renderer/src/runtime/runtime-file-metadata-client.ts +++ b/src/renderer/src/runtime/runtime-file-metadata-client.ts @@ -43,6 +43,13 @@ export async function statRuntimePath( ) } +export function isMissingRuntimePathError(error: unknown): boolean { + const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase() + return ( + message.includes('enoent') || message.includes('not found') || message.includes('no such file') + ) +} + export async function runtimePathExists( context: RuntimeFileOperationArgs, absolutePath: string, @@ -66,12 +73,7 @@ export async function runtimePathExists( ) return true } catch (err) { - const message = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase() - if ( - message.includes('enoent') || - message.includes('not found') || - message.includes('no such file') - ) { + if (isMissingRuntimePathError(err)) { return false } throw err diff --git a/src/renderer/src/runtime/runtime-path-existence-batch.test.ts b/src/renderer/src/runtime/runtime-path-existence-batch.test.ts new file mode 100644 index 00000000000..877e5a0f986 --- /dev/null +++ b/src/renderer/src/runtime/runtime-path-existence-batch.test.ts @@ -0,0 +1,88 @@ +import { expect, it } from 'vitest' +import { runtimePathsExist } from '@/runtime/runtime-path-existence-batch' +import { + installRuntimeFileClientEnvironment, + runtimeEnvironmentCall, + runtimeEnvironmentTransportCall, + fsPathExists +} from '@/runtime/runtime-file-client-test-harness' +import { + RUNTIME_PROTOCOL_VERSION, + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION +} from '../../../shared/protocol-version' +installRuntimeFileClientEnvironment() +const context = { + settings: { activeRuntimeEnvironmentId: 'owner-a' }, + worktreeId: 'folder-1', + worktreePath: '/folder' +} +const paths = Array.from({ length: 8 }, (_, i) => `/folder/file-${i}.ts`) +function status(capabilities: string[]) { + runtimeEnvironmentTransportCall.mockImplementation(async (args) => + args.method === 'status.get' + ? { + id: 'status', + ok: true, + result: { + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities + }, + _meta: { runtimeId: 'owner-runtime' } + } + : runtimeEnvironmentCall(args) + ) +} +it('paired runtime receives one eight-path request scoped to the folder workspace', async () => { + status(['files.pathsExist']) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'batch', + ok: true, + result: paths.map(() => ({ exists: true })) + }) + expect(await runtimePathsExist(context, paths)).toEqual(paths.map(() => ({ exists: true }))) + expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1) + expect(runtimeEnvironmentCall.mock.calls[0][0]).toMatchObject({ + selector: 'owner-a', + method: 'files.pathsExist', + params: { worktree: 'id:folder-1', relativePaths: paths.map((p) => p.slice('/folder/'.length)) } + }) + expect(fsPathExists).not.toHaveBeenCalled() +}) +it('old paired host retains scalar stats on that runtime and does not call the new method', async () => { + status([]) + runtimeEnvironmentCall.mockResolvedValue({ id: 'stat', ok: true, result: { size: 1 } }) + expect(await runtimePathsExist(context, paths)).toEqual(paths.map(() => ({ exists: true }))) + expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(8) + expect( + runtimeEnvironmentCall.mock.calls.every( + ([args]) => args.method === 'files.stat' && args.selector === 'owner-a' + ) + ).toBe(true) + expect(fsPathExists).not.toHaveBeenCalled() +}) +it('new method missing after capability discovery falls back only for method_not_found', async () => { + status(['files.pathsExist']) + runtimeEnvironmentCall.mockImplementation(async (args) => + args.method === 'files.pathsExist' + ? { id: 'batch', ok: false, error: { code: 'method_not_found', message: 'Unknown method' } } + : { id: 'stat', ok: true, result: { size: 1 } } + ) + expect(await runtimePathsExist(context, paths)).toEqual(paths.map(() => ({ exists: true }))) + expect( + runtimeEnvironmentCall.mock.calls.filter(([args]) => args.method === 'files.stat') + ).toHaveLength(8) +}) +it('remote errors stay errors, and an out-of-scope path cannot read the local filesystem', async () => { + status(['files.pathsExist']) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'batch', + ok: true, + result: [{ error: 'SSH connection closed' }] + }) + expect(await runtimePathsExist(context, [paths[0]])).toEqual([{ error: 'SSH connection closed' }]) + expect(await runtimePathsExist(context, ['/outside/file.ts'])).toEqual([ + { error: expect.stringContaining('outside') } + ]) + expect(fsPathExists).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/runtime/runtime-path-existence-batch.ts b/src/renderer/src/runtime/runtime-path-existence-batch.ts new file mode 100644 index 00000000000..280bcb4817c --- /dev/null +++ b/src/renderer/src/runtime/runtime-path-existence-batch.ts @@ -0,0 +1,81 @@ +import { + capturePathExistence, + PATH_EXISTENCE_BATCH_CAPABILITY, + requirePathExistenceResults, + type PathExistenceResult +} from '../../../shared/path-existence-batch' +import type { RuntimeFileOperationArgs } from './runtime-file-client-types' +import { assertLocalFilesystemFallbackAllowed, getRemoteFileArgs } from './runtime-file-routing' +import { isMissingRuntimePathError, runtimePathExists } from './runtime-file-metadata-client' +import { captureRuntimeEnvironmentRequestRevision } from './runtime-environment-revision' +import { + callRuntimeRpc, + runtimeEnvironmentSupportsCapability, + RuntimeRpcCallError +} from './runtime-rpc-client' + +export async function runtimePathsExist( + context: RuntimeFileOperationArgs, + paths: string[], + expectedPairingRevision?: number +): Promise { + const routes = paths.map((path) => getRemoteFileArgs(context, path)) + const first = routes[0] + const expectedEnvironmentPairingRevision = first + ? captureRuntimeEnvironmentRequestRevision(first.target.environmentId, expectedPairingRevision) + : undefined + const fallback = () => + Promise.all( + paths.map((path) => + capturePathExistence(() => + runtimePathExists(context, path, expectedEnvironmentPairingRevision) + ) + ) + ) + if (!first || routes.some((route) => !route)) { + if (routes.every((route) => !route) && window.api.fs.pathsExist) { + // Scalar routing performs the same ownership fence before local IPC. + assertLocalFilesystemFallbackAllowed(context) + return requirePathExistenceResults( + await window.api.fs.pathsExist({ filePaths: paths, connectionId: context.connectionId }), + paths.length + ) + } + return fallback() + } + try { + if ( + !(await runtimeEnvironmentSupportsCapability( + first.target.environmentId, + PATH_EXISTENCE_BATCH_CAPABILITY, + 15_000 + )) + ) { + return fallback() + } + const result = requirePathExistenceResults( + await callRuntimeRpc( + first.target, + 'files.pathsExist', + { + worktree: first.worktreeSelector, + relativePaths: routes.map((route) => route!.relativePath) + }, + { timeoutMs: 15_000, expectedEnvironmentPairingRevision } + ), + paths.length + ) + // Preserve runtimePathExists's legacy missing-error interpretation. + return result.map((row) => + 'error' in row && isMissingRuntimePathError(row.error) ? { exists: false } : row + ) + } catch (error) { + if (error instanceof RuntimeRpcCallError && error.code === 'method_not_found') { + return fallback() + } + if (isMissingRuntimePathError(error)) { + return paths.map(() => ({ exists: false })) + } + throw error + } +} diff --git a/src/renderer/src/runtime/runtime-path-existence-error-parity.test.ts b/src/renderer/src/runtime/runtime-path-existence-error-parity.test.ts new file mode 100644 index 00000000000..9cb469c4871 --- /dev/null +++ b/src/renderer/src/runtime/runtime-path-existence-error-parity.test.ts @@ -0,0 +1,79 @@ +import { expect, it } from 'vitest' +import { runtimePathsExist } from './runtime-path-existence-batch' +import { runtimePathExists } from './runtime-file-metadata-client' +import { + installRuntimeFileClientEnvironment, + runtimeEnvironmentCall, + runtimeEnvironmentTransportCall +} from './runtime-file-client-test-harness' +import { + RUNTIME_PROTOCOL_VERSION, + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION +} from '../../../shared/protocol-version' +installRuntimeFileClientEnvironment() +for (const mode of ['scalar', 'batch'] as const) { + it(`${mode} preserves missing-owner error interpretation`, async () => { + runtimeEnvironmentTransportCall.mockImplementation(async (args) => + args.method === 'status.get' + ? { + id: 'status', + ok: true, + result: { + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: ['files.pathsExist'] + }, + _meta: { runtimeId: 'owner-runtime' } + } + : runtimeEnvironmentCall(args) + ) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'missing', + ok: false, + error: { code: 'not_found', message: 'Worktree not found: id:folder-1' } + }) + const context = { + settings: { activeRuntimeEnvironmentId: 'owner' }, + worktreeId: 'folder-1', + worktreePath: '/folder' + } + if (mode === 'scalar') { + expect(await runtimePathExists(context, '/folder/file.ts')).toBe(false) + } else { + expect(await runtimePathsExist(context, ['/folder/file.ts'])).toEqual([{ exists: false }]) + } + }) +} + +for (const stage of ['status', 'operation'] as const) { + it(`preserves missing errors from ${stage} and still rejects permission/transport failures`, async () => { + let message = 'Worktree not found: id:folder-1' + const failure = () => ({ id: 'failure', ok: false, error: { code: 'not_found', message } }) + runtimeEnvironmentTransportCall.mockImplementation(async (args) => + args.method === 'status.get' + ? stage === 'status' + ? failure() + : { + id: 'status', + ok: true, + result: { + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: ['files.pathsExist'] + }, + _meta: { runtimeId: 'owner-runtime' } + } + : runtimeEnvironmentCall(args) + ) + runtimeEnvironmentCall.mockImplementation(async () => failure()) + const context = { + settings: { activeRuntimeEnvironmentId: 'owner' }, + worktreeId: 'folder-1', + worktreePath: '/folder' + } + expect(await runtimePathsExist(context, ['/folder/file.ts'])).toEqual([{ exists: false }]) + for (message of ['permission denied', 'SSH connection closed']) { + await expect(runtimePathsExist(context, ['/folder/file.ts'])).rejects.toThrow(message) + } + }) +} diff --git a/src/renderer/src/runtime/runtime-path-existence-pairing.test.ts b/src/renderer/src/runtime/runtime-path-existence-pairing.test.ts new file mode 100644 index 00000000000..6cdc453ec45 --- /dev/null +++ b/src/renderer/src/runtime/runtime-path-existence-pairing.test.ts @@ -0,0 +1,60 @@ +import { expect, it } from 'vitest' +import { runtimePathsExist } from './runtime-path-existence-batch' +import { runtimePathExists } from './runtime-file-metadata-client' +import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision' +import { + installRuntimeFileClientEnvironment, + runtimeEnvironmentCall, + runtimeEnvironmentTransportCall +} from './runtime-file-client-test-harness' +import { + RUNTIME_PROTOCOL_VERSION, + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION +} from '../../../shared/protocol-version' + +installRuntimeFileClientEnvironment() +for (const mode of ['scalar', 'batch', 'legacy', 'missing-method']) { + const batch = mode !== 'scalar' + it(`${mode} preserves the owner captured before discovery`, async () => { + replaceRuntimeEnvironmentRevisions([{ id: 'owner', createdAt: 10, pairingRevision: 10 }]) + runtimeEnvironmentTransportCall.mockImplementation(async (args) => { + if (args.method === 'status.get') { + replaceRuntimeEnvironmentRevisions([{ id: 'owner', createdAt: 10, pairingRevision: 20 }]) + return { + id: 'status', + ok: true, + result: { + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: mode === 'legacy' ? [] : ['files.pathsExist'] + }, + _meta: { runtimeId: 'original-owner' } + } + } + return runtimeEnvironmentCall(args) + }) + runtimeEnvironmentCall.mockImplementation(async (args) => { + if (mode === 'missing-method' && args.method === 'files.pathsExist') { + replaceRuntimeEnvironmentRevisions([{ id: 'owner', createdAt: 10, pairingRevision: 30 }]) + return { + id: 'missing', + ok: false, + error: { code: 'method_not_found', message: 'Unknown method' } + } + } + return { id: 'result', ok: true, result: [{ exists: true }] } + }) + const context = { + settings: { activeRuntimeEnvironmentId: 'owner' }, + worktreeId: 'folder-1', + worktreePath: '/folder' + } + await (batch + ? runtimePathsExist(context, ['/folder/file.ts']) + : runtimePathExists(context, '/folder/file.ts')) + expect(runtimeEnvironmentCall.mock.calls.length).toBeGreaterThan(0) + for (const [request] of runtimeEnvironmentCall.mock.calls) { + expect(request.expectedEnvironmentPairingRevision).toBe(10) + } + }) +} diff --git a/src/renderer/src/runtime/runtime-path-existence-queue-pairing.test.ts b/src/renderer/src/runtime/runtime-path-existence-queue-pairing.test.ts new file mode 100644 index 00000000000..f972b51eb36 --- /dev/null +++ b/src/renderer/src/runtime/runtime-path-existence-queue-pairing.test.ts @@ -0,0 +1,96 @@ +import { expect, it } from 'vitest' +import { createTerminalPathExistenceBatch } from '../components/terminal-pane/terminal-path-existence-batch' +import { runtimePathExists } from './runtime-file-metadata-client' +import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision' +import { + installRuntimeFileClientEnvironment, + runtimeEnvironmentCall, + runtimeEnvironmentTransportCall +} from './runtime-file-client-test-harness' +import { + RUNTIME_PROTOCOL_VERSION, + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION +} from '../../../shared/protocol-version' + +installRuntimeFileClientEnvironment() +for (const mode of ['scalar', 'queue', 'legacy', 'missing-method'] as const) { + it(`${mode} retains the pairing owner when a hover waits for its queued flush`, async () => { + replaceRuntimeEnvironmentRevisions([{ id: 'owner', createdAt: 10, pairingRevision: 10 }]) + runtimeEnvironmentTransportCall.mockImplementation(async (args) => { + if (args.method === 'status.get') { + return { + id: 'status', + ok: true, + result: { + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: mode === 'legacy' ? [] : ['files.pathsExist'] + }, + _meta: { runtimeId: 'owner-runtime' } + } + } + return runtimeEnvironmentCall(args) + }) + runtimeEnvironmentCall.mockImplementation(async (args) => { + if (mode === 'missing-method' && args.method === 'files.pathsExist') { + replaceRuntimeEnvironmentRevisions([{ id: 'owner', createdAt: 10, pairingRevision: 30 }]) + return { + id: 'missing', + ok: false, + error: { code: 'method_not_found', message: 'Unknown method' } + } + } + return { id: 'result', ok: true, result: [{ exists: true }] } + }) + const context = { + settings: { activeRuntimeEnvironmentId: 'owner' }, + worktreeId: 'folder-1', + worktreePath: '/folder' + } + const pending = + mode === 'scalar' + ? runtimePathExists(context, '/folder/file.ts') + : createTerminalPathExistenceBatch()(context, '/folder/file.ts', true) + replaceRuntimeEnvironmentRevisions([{ id: 'owner', createdAt: 10, pairingRevision: 20 }]) + await pending + expect(runtimeEnvironmentCall.mock.calls.length).toBeGreaterThan(0) + for (const [request] of runtimeEnvironmentCall.mock.calls) { + expect(request.expectedEnvironmentPairingRevision).toBe(10) + } + }) +} + +it('keeps two queued hovers on distinct revisions of the same environment', async () => { + runtimeEnvironmentTransportCall.mockImplementation(async (args) => + args.method === 'status.get' + ? { + id: 'status', + ok: true, + result: { + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: ['files.pathsExist'] + }, + _meta: { runtimeId: 'owner-runtime' } + } + : runtimeEnvironmentCall(args) + ) + runtimeEnvironmentCall.mockResolvedValue({ id: 'result', ok: true, result: [{ exists: true }] }) + const context = { + settings: { activeRuntimeEnvironmentId: 'owner' }, + worktreeId: 'folder-1', + worktreePath: '/folder' + } + const enqueue = createTerminalPathExistenceBatch() + replaceRuntimeEnvironmentRevisions([{ id: 'owner', createdAt: 10, pairingRevision: 10 }]) + const first = enqueue(context, '/folder/file.ts', true) + replaceRuntimeEnvironmentRevisions([{ id: 'owner', createdAt: 10, pairingRevision: 20 }]) + const second = enqueue(context, '/folder/file.ts', true) + expect(first).not.toBe(second) + await Promise.all([first, second]) + expect( + runtimeEnvironmentCall.mock.calls + .map(([args]) => args.expectedEnvironmentPairingRevision) + .sort() + ).toEqual([10, 20]) +}) diff --git a/src/renderer/src/web/preload-api/web-shell-api.ts b/src/renderer/src/web/preload-api/web-shell-api.ts index 59b067fb329..138ebe9abb4 100644 --- a/src/renderer/src/web/preload-api/web-shell-api.ts +++ b/src/renderer/src/web/preload-api/web-shell-api.ts @@ -1,6 +1,15 @@ import type { PreloadApi } from '../../../../preload/api-types' import { resolveRuntimeFilePath } from './web-runtime-worktree-catalog' +async function pathExistsOnRuntime(path: string): Promise { + try { + await resolveRuntimeFilePath(path) + return true + } catch { + return false + } +} + export function createShellApi(): NonNullable['shell']> { const openResult = { ok: true } as const return { @@ -12,14 +21,9 @@ export function createShellApi(): NonNullable['shell']> { openFilePath: () => Promise.resolve(false), openFileUri: (uri) => Promise.resolve(window.open(uri, '_blank', 'noopener,noreferrer') as never), - pathExists: async (path) => { - try { - await resolveRuntimeFilePath(path) - return true - } catch { - return false - } - }, + pathExists: async (path) => pathExistsOnRuntime(path), + // Without this the fallback proxy answers `undefined` and the caller's batch rejects. + pathsExist: (paths) => Promise.all(paths.map((path) => pathExistsOnRuntime(path))), pickAttachment: () => Promise.resolve(null), pickImage: () => Promise.resolve(null), pickRepoIconImage: () => Promise.resolve(null), diff --git a/src/renderer/src/web/web-shell-paths-exist.test.ts b/src/renderer/src/web/web-shell-paths-exist.test.ts new file mode 100644 index 00000000000..dc0819afa7d --- /dev/null +++ b/src/renderer/src/web/web-shell-paths-exist.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('./preload-api/web-runtime-worktree-catalog', () => ({ + resolveRuntimeFilePath: vi.fn(async (path: string) => { + if (path.includes('missing')) { + throw new Error('not found') + } + return path + }) +})) + +describe('web shell path existence', () => { + it('defines pathsExist so withFallback cannot substitute an undefined-returning proxy', async () => { + const { createShellApi } = await import('./preload-api/web-shell-api') + const { withFallback } = await import('./preload-api/web-fallback-api') + const shell = withFallback(createShellApi(), ['shell']) + expect(shell.pathsExist).toBeTypeOf('function') + const values = await shell.pathsExist?.(['/repo/present.ts', '/repo/missing.ts']) + expect(values).toEqual([true, false]) + }) +}) diff --git a/src/shared/path-existence-batch.ts b/src/shared/path-existence-batch.ts new file mode 100644 index 00000000000..3f499e33162 --- /dev/null +++ b/src/shared/path-existence-batch.ts @@ -0,0 +1,40 @@ +export const PATH_EXISTENCE_BATCH_MAX = 128 +export const PATH_EXISTENCE_BATCH_CAPABILITY = 'files.pathsExist' +export type PathExistenceResult = { exists: boolean } | { error: string } + +export function validatePathExistenceBatch(paths: unknown): asserts paths is string[] { + if ( + !Array.isArray(paths) || + paths.length > PATH_EXISTENCE_BATCH_MAX || + paths.some((path) => typeof path !== 'string') + ) { + throw new Error('Invalid path existence batch') + } +} + +export async function capturePathExistence( + check: () => Promise +): Promise { + try { + return { exists: await check() } + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) } + } +} + +export function requirePathExistenceResults(value: unknown, count: number): PathExistenceResult[] { + if (!Array.isArray(value) || value.length !== count) { + throw new Error('Invalid path existence response') + } + return value.map((row: unknown): PathExistenceResult => { + if (row && typeof row === 'object') { + if ('exists' in row && typeof row.exists === 'boolean' && !('error' in row)) { + return { exists: row.exists } + } + if ('error' in row && typeof row.error === 'string' && !('exists' in row)) { + return { error: row.error } + } + } + throw new Error('Invalid path existence response') + }) +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index aaec08cf46f..99908de9b84 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -239,6 +239,7 @@ export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ ] as const export const RUNTIME_CAPABILITIES = [ + 'files.pathsExist', 'runtime.status.compat.v1', 'runtime.environments.v1', REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY, diff --git a/src/shared/rpc-contract/files-params.ts b/src/shared/rpc-contract/files-params.ts index 6ae6267caa0..80fdba2a1ac 100644 --- a/src/shared/rpc-contract/files-params.ts +++ b/src/shared/rpc-contract/files-params.ts @@ -1,7 +1,12 @@ +import { PATH_EXISTENCE_BATCH_MAX } from '../path-existence-batch' import { z } from 'zod' import { QUICK_OPEN_REMOTE_QUERY_MAX_CODE_UNITS } from '../quick-open-path-search' import { FileOpen, WorktreeSelector } from './files-target-params' +export const FilePathsExist = WorktreeSelector.extend({ + relativePaths: z.array(z.string()).max(PATH_EXISTENCE_BATCH_MAX) +}) + export const FilePathSearch = WorktreeSelector.extend({ query: z.string().max(QUICK_OPEN_REMOTE_QUERY_MAX_CODE_UNITS).default(''), limit: z.number().int().positive().max(32).default(16), diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index eaaab7e66ec..deae9bc0eaa 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -169,6 +169,7 @@ import { FileListAll, FileOpenDiff, FilePathSearch, + FilePathsExist, FileReadChunk, FileSearch, FileTreePath, @@ -737,6 +738,7 @@ export const RPC_PARAMS_BY_METHOD = { 'files.listMarkdownDocuments': WorktreeSelector, 'files.open': FileOpen, 'files.openDiff': FileOpenDiff, + 'files.pathsExist': FilePathsExist, 'files.read': FileOpen, 'files.readChunk': FileReadChunk, 'files.readDir': FileTreePath, From 5e70014da8ee6aa6635fe4f031baa7dbaca17231 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:41:51 -0700 Subject: [PATCH 09/34] feat(native-chat): support file drag and drop (#20494) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(native-chat): support workspace file drops * fix(native-chat): report OS file drops that attach nothing #15782 is a silent failure on the Finder route, and that route still swallowed every way it could fail: - the preload handler returned with no feedback when the OS handed us file items `webUtils.getPathForFile` could read no path from (promised or virtual files). It now sends the existing `rejected` payload with a new `unresolved-paths` reason, which the global drop toast names. - the composer's external-attach path dropped the batch with no notice when every path failed authorization, when an upload came back empty, and (new in this branch) when the owner changed mid-flight. Each exit now sets a notice; only a disabled composer stays quiet, because it has no notice surface. Also stops `resolveNativeChatAttachmentOwnerForWorktree` throwing out of a drop/IME handler when an SSH connection's generation is gone mid-attach — that is an unknown owner, which the resolver already models as `not-ready`. * refactor(native-chat): one owner-identity check for composer attachments The branch had two near-identical "is this still the same owner" helpers, one per attach route, and they disagreed: the workspace-drop copy ignored the SSH connection generation, so a reconnect between the drop and the IME flush read as the same owner and the path landed on a new connection. Collapses both onto one predicate in the pure ownership module (the store/toast-free seam both routes already depend on), which compares the full SSH expectation and never treats `not-ready` as a match. * perf(file-explorer): resolve drag ownership at dragstart, not per render The virtualized row list resolved the selection's source execution host on every render — the virtualizer re-renders on every scroll frame, so a large multi-selection paid a full projection scan plus a route allocation per selected path per frame, and per visible row on top of that. Only `onDragStart` ever read the result. Rows now receive a resolver they call with the paths they are about to drag. The three copies of the "stamp only if both halves resolve" guard (explorer row, both combined-diff row shapes) collapse into one helper next to the writer. * fix(native-chat): refuse a guarded composer drop visibly The drop handlers claimed the drag (preventDefault + stopPropagation) before checking `disabled`, so a guarded composer told the browser it accepted the drop, left the copy cursor up, and then did nothing — the same silent swallow this branch exists to remove. Dragover now answers `none` when the composer is guarded, so the cursor refuses and no drop event follows. It still claims the event either way: the composer sits inside the terminal surface, which accepts the same drag and would paste the paths into the shell instead. Drops `stopImmediatePropagation`. The capture-phase `stopPropagation` already keeps the event off the editor below, so the stronger form only risked suppressing unrelated listeners on the React root. The fake DataTransfer in the test now starts at a dropEffect we never write, so asserting `none` or `copy` proves the handler set it. * fix(native-chat): decide attachment ownership per path, not per batch A queued batch can mix sources — a workspace drop the target host owns and a client-local paste it cannot read — because IME composition holds both until it settles. Collapsing the batch to one verdict refused the whole thing on a remote target, including the drop the user was entitled to make. The verdict now follows the path it belongs to: owned paths attach, client-local ones are refused, and the refusal is reported rather than dropped. A stale owner still refuses everything, since that means the target moved under all of them. Also guards the empty-batch case, which previously read as "every path owned". * refactor(combined-diff): resolve drag ownership from the live workspace The combined diff captured an execution host into the open-file record at tab open and drilled it through three components to reach the row. That host was never persisted, so after a restart every drag from a restored diff was refused until the tab was reopened, and the capture failure was swallowed into an undefined source with no trace. Rows now resolve the owner the same way the source-control rows already do, from the workspace the diff belongs to at the moment of the drag. That deletes the prop drilling, the store capture and its bare catch, and leaves one way to answer "who owns these paths" for every live listing. The file explorer keeps its per-node owner: its tree is a cache that can still be showing a previous host's listing, which is exactly what that field records. * revert(file-explorer): drop the workspace-id tree reset Resetting and reloading the tree when the workspace id changes at an unchanged path is not needed for the drag source to be correct. The tree already records the workspace whose root listing it committed, so a cache left over from a previous workspace stamps that workspace and the composer refuses the drop — the intended answer, reached without touching the reset rule. That rule clears selection, the name filter and undo history, which is more file-explorer behaviour change than this feature asked for. * test(native-chat): stop the external-attach mock hiding new notices The hook's test replaced the whole attachment-owner module with a hand-written stub, so the two notices added alongside the owner-change guards resolved to undefined. Calling them threw inside the async attach loop — an unhandled rejection, which leaves every test in the file reported as passing while the run as a whole fails. CI caught it; a local run reporting only pass/fail counts does not. The mock now spreads the real module, so a notice added later cannot go missing from it, and both owner-change tests assert the string a user would read instead of only asserting that nothing attached. * test(native-chat): guard the last-path owner change on a one-file drop The owner flipping while the final path is authorizing has no next loop iteration to catch it, so the post-loop check is all that stands between a single-file drop and a path attached to a host that no longer owns it — and a one-file drop is the ordinary shape. No test covered that exit. Removing the post-loop check now turns this red; before it, only the multi-path exit was guarded. * fix(native-chat): keep a mixed attachment batch in attach order applyResolvedPaths partitioned a queued batch into a target-owned half and a client-local half and concatenated them. An IME-delayed batch that mixed a workspace drop with a paste made earlier in the same composition was therefore inserted owned-first, so the dropped reference jumped ahead of the pasted one in the draft. Filter against the two verdicts in place instead. Membership is unchanged, the order the user attached in survives, and the two intermediate arrays go away. * fix(file-explorer): name the owner of a dragged path whose row is hidden A multi-selection outlives the rows that showed it. Nothing prunes selectedPaths when a directory collapses, when the name filter narrows, or when dotfiles are hidden, and the drag still carries every selected path. Drag-source resolution read those owners from the row projection, which is built from visible rows only, so one hidden path collapsed the whole drag to an unstamped one and the composer refused it as coming from another workspace. The owner was never unknowable — the dir cache the projection is built from still records which host listed that path. Fall back to it when the path has no visible row. A path in neither (a name-filter synthetic node for a directory that was never listed) still fails closed. * fix(native-chat): ask which workspace the composer serves now The IME-flush ownership check compared the workspace id captured when the drop happened against the same captured value, so for a structured pane the comparison could only ever hold. The live protection came from the host and owner checks beside it; this one asked nothing. Read the id through a ref so the check means what it reads as. A pane whose structured target moves between the drop and the composition settling now refuses the queued path instead of attaching it. * fix(native-chat): ask which workspace an external attach lands on The post-await ownership gate resolved the owner through the render closure, so it re-asked the workspace the attach started in and compared the answer with itself. A tab moved to another workspace mid-authorization passed the gate, and the paths landed in a composer that no longer served that workspace. Read the pane through a ref and compare the workspace identity as well as the owner: two workspaces can both report a local owner, so the owner alone cannot tell them apart. * test(native-chat): read the real notice on a workspace drop The drop tests hand-built their attachment-upload mock and hand-copied the not-ready wording into it, so the assertion tracked the copy rather than the string a user reads: rewording the real notice left all 15 tests green. Spread the real module and override only the owner resolver, matching the two sibling test files in this directory. Rewording the notice now fails the test. * docs(native-chat): restore the hook's doc comment to the hook The workspace comparison landed between the doc block and the function it describes, leaving the comment attached to a type alias. * test(native-chat): cover the upload window for a moved pane The workspace-currency gate guards two windows and only the authorize loop was covered. The upload window is the longer one: the paths go to the worktree the attach captured, so a pane that moved workspaces meanwhile must not receive remote paths living under the workspace it left. * test(native-chat): pin the two untested attachment refusals Refusing an already-blocked target at the drop rather than queueing it had no test: queued paths that can never attach still spend the pending budget, and the next legitimate drop is then turned away for being one too many. Also pins the immediate already-false ownership verdict. Today's only caller settles ownership synchronously so it cannot arrive false, but the hook exports this entry point and the fallback is not a refusal — a false verdict is not "owned", so a remote target blames client-local attachments for an ownership failure. Verified: removing the branch reports the wrong notice. * docs(native-chat): say which rule the ownership refusal follows The per-path comment sat directly above the batch-wide ownership refusal while describing the blocked-target logic below it, so the refusal read as a contradiction of the line under it rather than as the file's stated rule. Name the rule at the refusal: a failed ownership verdict refuses the whole completion, the same way the pending-limit rejection does. --------- Co-authored-by: Merge Sim --- src/preload/preload-runtime-support.ts | 16 +- .../combined-diff/CombinedDiffViewer.tsx | 1 + .../combined-diff-file-tree-row-drag.test.tsx | 87 +++ .../combined-diff-file-tree-row.tsx | 9 + .../combined-diff-file-tree-rows.tsx | 3 + .../browse-files/combined-diff-file-tree.tsx | 3 + .../native-chat/NativeChatComposer.tsx | 9 + .../native-chat/NativeChatComposerField.tsx | 13 +- .../native-chat-attachment-upload.test.ts | 12 + .../native-chat-attachment-upload.ts | 30 +- .../native-chat-composer-drop-scope.test.tsx | 101 +++- ...chat-composer-workspace-file-drop.test.tsx | 506 ++++++++++++++++++ ...ative-chat-resolved-path-ownership.test.ts | 41 ++ .../native-chat-resolved-path-ownership.ts | 35 ++ ...-native-chat-composer-attachments.test.tsx | 138 ++++- .../use-native-chat-composer-attachments.ts | 164 ++---- ...-native-chat-external-attachments.test.tsx | 175 +++++- .../use-native-chat-external-attachments.ts | 74 ++- ...e-native-chat-resolved-path-attachments.ts | 205 +++++++ .../use-native-chat-workspace-file-drop.ts | 167 ++++++ .../FileExplorerFilesTreePane.tsx | 2 + .../right-sidebar/FileExplorerRow.tsx | 14 +- .../right-sidebar/FileExplorerVirtualRows.tsx | 54 +- .../file-explorer-drag-scroll-marker.test.tsx | 155 +++++- .../file-explorer-operation-owner.ts | 6 + .../listing/branch-entry-row.tsx | 2 + .../listing/uncommitted-entry-row.tsx | 2 + .../useFileExplorerTree.stale-dirs.test.tsx | 22 + .../right-sidebar/useFileExplorerTree.ts | 7 + .../src/hooks/useGlobalFileDrop.test.ts | 14 + src/renderer/src/hooks/useGlobalFileDrop.ts | 13 + src/renderer/src/i18n/locales/en.json | 9 +- .../src/lib/workspace-file-drag-source.ts | 16 + .../src/lib/workspace-file-drag.test.ts | 30 +- src/renderer/src/lib/workspace-file-drag.ts | 80 +++ src/shared/native-file-drop.ts | 15 +- 36 files changed, 2053 insertions(+), 177 deletions(-) create mode 100644 src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row-drag.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-resolved-path-ownership.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-resolved-path-ownership.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-resolved-path-attachments.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-workspace-file-drop.ts create mode 100644 src/renderer/src/lib/workspace-file-drag-source.ts diff --git a/src/preload/preload-runtime-support.ts b/src/preload/preload-runtime-support.ts index 27ce7d77bc2..6a9462473c1 100644 --- a/src/preload/preload-runtime-support.ts +++ b/src/preload/preload-runtime-support.ts @@ -13,7 +13,8 @@ import { resolveNativeFileDropPath, type NativeDropResolution, type NativeFileDropPayload, - type NativeFileDropPathEntry + type NativeFileDropPathEntry, + type NativeFileDropRejectedPayload } from '../shared/native-file-drop' /** Joins the synchronous unload checkpoint with its durable renderer write. */ @@ -133,7 +134,18 @@ export function installNativeFileDropHandlers(): void { paths.push(filePath) } } - if (paths.length === 0 || resolution?.target === 'rejected') { + if (resolution?.target === 'rejected') { + return + } + if (paths.length === 0) { + // The OS offered file items we could read no path from (promised or + // virtual files). Report it — silence here is #15782. + ipcRenderer.send('terminal:file-dropped-from-preload', { + byteLength: 0, + pathCount: files.length, + reason: 'unresolved-paths', + target: 'rejected' + } satisfies NativeFileDropRejectedPayload) return } const payload = createNativeFileDropPayload(resolution, paths) diff --git a/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx index 82e7ece6cfb..449f89cb70f 100644 --- a/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx @@ -348,6 +348,7 @@ export default function CombinedDiffViewer({ ({ + executionHostId: 'local' +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } })) +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getExecutionHostIdForWorktree: () => testState.executionHostId +})) + +const { CombinedDiffFileTreeRow } = await import('./combined-diff-file-tree-row') +const { readWorkspaceFileDragSource } = await import('@/lib/workspace-file-drag') + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +const roots: Root[] = [] +afterEach(() => { + roots.splice(0).forEach((root) => act(() => root.unmount())) + document.body.replaceChildren() + testState.executionHostId = 'local' +}) + +function renderRow(sourceWorkspaceId?: string): HTMLDivElement { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + act(() => { + root.render( + {}} + onNavigate={() => {}} + /> + ) + }) + return container +} + +function dragRow(container: HTMLDivElement): DataTransfer { + const transfer = new DataTransfer() + const event = new Event('dragstart', { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'dataTransfer', { value: transfer }) + act(() => { + container.querySelector('[draggable="true"]')?.dispatchEvent(event) + }) + return transfer +} + +describe('combined diff rows stamp their drag source', () => { + // The tab's entry list is a snapshot, but the paths it drags belong to the + // workspace as it is owned now — the same answer the source-control rows give. + it('stamps the live owner of the workspace the diff belongs to', () => { + testState.executionHostId = 'runtime:env-1' + expect(readWorkspaceFileDragSource(dragRow(renderRow('wt-1')))).toEqual({ + executionHostId: 'runtime:env-1', + workspaceId: 'wt-1' + }) + }) + + it('leaves the drag unstamped when the owner or the workspace is unknown', () => { + expect(readWorkspaceFileDragSource(dragRow(renderRow(undefined)))).toBeNull() + testState.executionHostId = 'runtime:unresolved-owner' + expect(readWorkspaceFileDragSource(dragRow(renderRow('wt-1')))).toBeNull() + }) +}) diff --git a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row.tsx b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row.tsx index dfb417e2518..fe6d5644db1 100644 --- a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row.tsx +++ b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row.tsx @@ -6,6 +6,7 @@ import { getFileTypeIcon } from '@/lib/file-type-icons' import { basename, dirname, joinPath } from '@/lib/path' import { cn } from '@/lib/utils' import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag' +import { writeWorkspaceFileDragSourceForWorkspace } from '@/lib/workspace-file-drag-source' import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types' import type { GitFileStatus, @@ -35,6 +36,7 @@ export const CombinedDiffFileTreeRow = memo(function CombinedDiffFileTreeRow({ node, mode, worktreePath, + sourceWorkspaceId, activeSectionKey, sectionIndexByKey, isCollapsed, @@ -45,6 +47,7 @@ export const CombinedDiffFileTreeRow = memo(function CombinedDiffFileTreeRow({ node: CombinedDiffTreeNode mode: CombinedDiffFileTreeMode worktreePath: string + sourceWorkspaceId?: string activeSectionKey: string | null sectionIndexByKey: ReadonlyMap isCollapsed: boolean @@ -62,6 +65,9 @@ export const CombinedDiffFileTreeRow = memo(function CombinedDiffFileTreeRow({ draggable onDragStart={(event) => { event.dataTransfer.setData(WORKSPACE_FILE_PATH_MIME, joinPath(worktreePath, node.path)) + if (sourceWorkspaceId) { + writeWorkspaceFileDragSourceForWorkspace(event.dataTransfer, sourceWorkspaceId) + } event.dataTransfer.effectAllowed = 'copy' }} > @@ -117,6 +123,9 @@ export const CombinedDiffFileTreeRow = memo(function CombinedDiffFileTreeRow({ WORKSPACE_FILE_PATH_MIME, joinPath(worktreePath, node.entry.path) ) + if (sourceWorkspaceId) { + writeWorkspaceFileDragSourceForWorkspace(event.dataTransfer, sourceWorkspaceId) + } event.dataTransfer.effectAllowed = 'copy' }} onClick={() => onNavigate(node.entry)} diff --git a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-rows.tsx b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-rows.tsx index 229f9561ac0..3b5abc6113c 100644 --- a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-rows.tsx +++ b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-rows.tsx @@ -19,6 +19,7 @@ export function CombinedDiffFileTreeRows({ rows, mode, worktreePath, + sourceWorkspaceId, activeSectionKey, sectionIndexByKey, collapsedDirectoryKeys, @@ -30,6 +31,7 @@ export function CombinedDiffFileTreeRows({ rows: readonly CombinedDiffTreeNode[] mode: CombinedDiffFileTreeMode worktreePath: string + sourceWorkspaceId?: string activeSectionKey: string | null sectionIndexByKey: ReadonlyMap collapsedDirectoryKeys: ReadonlySet @@ -50,6 +52,7 @@ export function CombinedDiffFileTreeRows({ node={node} mode={mode} worktreePath={worktreePath} + sourceWorkspaceId={sourceWorkspaceId} activeSectionKey={activeSectionKey} sectionIndexByKey={sectionIndexByKey} isCollapsed={collapsedDirectoryKeys.has(node.key)} diff --git a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.tsx b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.tsx index 304aca3d19c..6c19b55d527 100644 --- a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.tsx +++ b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.tsx @@ -36,6 +36,7 @@ const EMPTY_TREE_ROWS: CombinedDiffTreeNode[] = [] export function CombinedDiffFileTree({ mode, worktreePath, + sourceWorkspaceId, entries, sectionIndexByKey, activeSectionKey, @@ -46,6 +47,7 @@ export function CombinedDiffFileTree({ }: { mode: CombinedDiffFileTreeMode worktreePath: string + sourceWorkspaceId?: string entries: readonly CombinedDiffFileTreeEntry[] sectionIndexByKey: ReadonlyMap activeSectionKey: string | null @@ -200,6 +202,7 @@ export function CombinedDiffFileTree({ const sharedRowProps = { mode, worktreePath, + sourceWorkspaceId, activeSectionKey, sectionIndexByKey, collapsedDirectoryKeys, diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 4833f89fc94..2f0688dfede 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -33,6 +33,7 @@ import { useNativeChatPtyComposerSend } from './use-native-chat-pty-composer-sen import { useNativeChatStructuredComposerSend } from './use-native-chat-structured-composer-send' import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event' import { useNativeChatComposerAppMenuSelection } from './use-native-chat-composer-app-menu-selection' +import { useNativeChatWorkspaceFileDrop } from './use-native-chat-workspace-file-drop' export type { NativeChatComposerHandle, @@ -173,6 +174,13 @@ const NativeChatComposerPane = forwardRef attachment.pending) @@ -413,6 +421,7 @@ const NativeChatComposerPane = forwardRef removeImageAttachment(id)} onAttach={pickAttachment} + workspaceFileDropHandlers={workspaceFileDropHandlers} onDictationToggle={toggleDictation} onDictationHoldStart={startHoldDictation} onDictationHoldEnd={stopHoldDictation} diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index f51bf4c5400..5ea20e3d0cb 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -1,6 +1,11 @@ import { NativeChatPromptEditor } from './NativeChatPromptEditor' import type { NativeChatComposerInput } from './native-chat-composer-input' -import type { ClipboardEventHandler, KeyboardEventHandler, RefObject } from 'react' +import type { + ClipboardEventHandler, + DragEventHandler, + KeyboardEventHandler, + RefObject +} from 'react' import { useLayoutEffect, useRef } from 'react' import { ImageOff } from 'lucide-react' import type { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event' @@ -48,6 +53,10 @@ export type NativeChatComposerFieldProps = { onAcceptMention: () => void onRemoveImageAttachment: (id: string) => void onAttach: () => void + workspaceFileDropHandlers?: { + onDragOverCapture: DragEventHandler + onDropCapture: DragEventHandler + } onDictationToggle: () => void onDictationHoldStart: () => void onDictationHoldEnd: () => void @@ -120,6 +129,7 @@ export function NativeChatComposerField({ onAcceptMention, onRemoveImageAttachment, onAttach, + workspaceFileDropHandlers, onDictationToggle, onDictationHoldStart, onDictationHoldEnd, @@ -185,6 +195,7 @@ export function NativeChatComposerField({ ) : null}
{ }) }) + it('reports not-ready instead of throwing when the SSH generation is gone', () => { + expect( + resolveNativeChatAttachmentOwner( + state({ + repos: [{ id: 'repo', connectionId: 'conn-1' }] as never, + sshConnectionStates: new Map() + }), + 'tab-1' + ) + ).toEqual({ kind: 'not-ready' }) + }) + it('reports not-ready when an SSH worktree has no known path yet', () => { expect( resolveNativeChatAttachmentOwner( diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts index 8157a5190ff..b7564ba7d9c 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts @@ -82,11 +82,17 @@ export function resolveNativeChatAttachmentOwnerForWorktree( if (!worktreePath) { return { kind: 'not-ready' } } - return { - kind: 'ssh', - connectionId, - worktreePath, - ...captureDirectSshMutationExpectation(state, connectionId) + try { + return { + kind: 'ssh', + connectionId, + worktreePath, + ...captureDirectSshMutationExpectation(state, connectionId) + } + } catch { + // The connection's generation is gone (disconnect mid-attach). That is an + // unknown owner, not a reason to throw out of the drop/IME handler. + return { kind: 'not-ready' } } } @@ -97,6 +103,20 @@ export function nativeChatWorktreeNotReadyNotice(): string { ) } +export function nativeChatAttachmentOwnerChangedNotice(): string { + return translate( + 'components.native-chat.composer.attachmentOwnerChanged', + 'This workspace changed hosts while attaching — drop the files again.' + ) +} + +export function nativeChatAttachmentUnreadableNotice(): string { + return translate( + 'components.native-chat.composer.attachmentUnreadable', + "Couldn't read the dropped files." + ) +} + export function nativeChatLocalAttachmentUnsupportedNotice(): string { return translate( 'components.native-chat.composer.localAttachmentUnsupported', diff --git a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx index e535a59fad0..c7c7dec050c 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx @@ -3,7 +3,10 @@ import { EventEmitter } from 'node:events' import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, render, screen } from '@testing-library/react' -import { useRef } from 'react' +import { useRef, useState } from 'react' +import type * as AttachmentUploadModule from './native-chat-attachment-upload' +import type { NativeChatComposerInput } from './native-chat-composer-input' +import { NativeChatPromptEditor } from './NativeChatPromptEditor' import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments' import { NativeChatImageAttachmentPreview } from './NativeChatImageAttachmentPreview' import { resetLocalImageSrcStateForTests } from '../editor/useLocalImageSrc' @@ -30,7 +33,9 @@ const intake = vi.hoisted(() => ({ upload: vi.fn() })) vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } })) -vi.mock('./native-chat-attachment-upload', () => ({ +// Keeps the real notice strings so the silent-failure guards assert what users see. +vi.mock('./native-chat-attachment-upload', async (importOriginal) => ({ + ...(await importOriginal()), resolveNativeChatAttachmentOwner: () => intake.owner, uploadNativeChatAttachmentPaths: intake.upload })) @@ -49,7 +54,8 @@ import { // Uses the production drop listener, subscriber fan-out, attachment hook, and scope cache. function ComposerProbe({ pane, hidden = false }: { pane: string; hidden?: boolean }) { - const textareaRef = useRef(null) + const textareaRef = useRef(null) + const [notice, setNotice] = useState(null) const attachments = useNativeChatComposerAttachments({ attachmentScopeKey: pane, allowWithoutTarget: true, @@ -60,22 +66,28 @@ function ComposerProbe({ pane, hidden = false }: { pane: string; hidden?: boolea textareaRef, setCaret: () => {}, setDraft: () => {}, - setNotice: () => {} + setNotice }) const { attachExternalPaths } = useNativeChatExternalAttachments({ terminalTabId: pane, disabled: false, attachResolvedPaths: attachments.attachResolvedPaths, - setNotice: () => {} + setNotice }) useNativeChatFileAttachmentActions(pane, attachExternalPaths) return (