diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml index 46edfc54111..e36ec4c65d8 100644 --- a/.github/actions/install-node-dependencies/action.yml +++ b/.github/actions/install-node-dependencies/action.yml @@ -39,6 +39,9 @@ runs: with: install: false + # Why both lockfiles: setup-node keys the pnpm store on the root lockfile alone, so + # jobs that also install mobile restored a store with none of the React Native tree + # in it and re-downloaded the lot on every run. - name: Setup Node.js id: default-node if: inputs.node-version == '' @@ -46,6 +49,9 @@ runs: with: node-version-file: package.json cache: pnpm + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml - name: Setup requested Node.js id: requested-node @@ -54,6 +60,9 @@ runs: with: node-version: ${{ inputs.node-version }} cache: pnpm + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml - name: Validate native runtime shell: bash diff --git a/.github/workflows/cloud-operate-relay-production-rehome-job.yml b/.github/workflows/cloud-operate-relay-production-rehome-job.yml index 1ceadcece12..682953af5e7 100644 --- a/.github/workflows/cloud-operate-relay-production-rehome-job.yml +++ b/.github/workflows/cloud-operate-relay-production-rehome-job.yml @@ -26,6 +26,9 @@ permissions: defaults: run: + # `shell: bash` adds pipefail; without it `node ... | tee` reports tee's exit code and a + # thrown inspect/apply passed green (Aug 28-29 and Sep 3 2026 runs). + shell: bash working-directory: cloud jobs: diff --git a/.github/workflows/cloud-prove-relay-asia-staging.yml b/.github/workflows/cloud-prove-relay-asia-staging.yml index 9a66e967b57..56677a98600 100644 --- a/.github/workflows/cloud-prove-relay-asia-staging.yml +++ b/.github/workflows/cloud-prove-relay-asia-staging.yml @@ -55,9 +55,11 @@ jobs: - name: Validate the exact staging proof request shell: bash + env: + CONFIRMATION: ${{ inputs.confirmation }} run: | set -euo pipefail - test "${{ inputs.confirmation }}" = PROVE_ASIA_STAGING + test "${CONFIRMATION}" = PROVE_ASIA_STAGING [[ "${IMAGE_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]] [[ "${INITIAL_SELECTOR_GENERATION}" =~ ^[1-9][0-9]*$ ]] [[ "${PROMOTE_ATTEMPT_ID}" =~ ^[A-Za-z0-9_-]{8,128}$ ]] diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index bde0b5e05d8..ed37cefb434 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -28,6 +28,7 @@ jobs: outputs: should_run: ${{ steps.filter.outputs.should_run }} native_cache_changed: ${{ steps.filter.outputs.native_cache_changed }} + mobile_dependencies: ${{ steps.filter.outputs.mobile_dependencies }} static_analysis: ${{ steps.filter.outputs.static_analysis }} typecheck: ${{ steps.filter.outputs.typecheck }} git_compatibility: ${{ steps.filter.outputs.git_compatibility }} @@ -95,6 +96,25 @@ jobs: - name: Enforce type-aware code-quality baseline run: pnpm run audit:code-quality:type-aware + # Why: the changed-code gate lints mobile files too, and its type-aware pass + # resolves types from mobile/node_modules. Mobile is a separate pnpm project, + # so the root install above leaves it empty and every mobile type degrades to + # an `error` type — reported as phantom findings against the changed lines. + # Why no --ignore-scripts, unlike the root install: mobile's postinstall generates + # the gitignored terminal/mermaid webview engine modules that tracked source imports, + # and skipping it degrades those very types the step exists to resolve. The drift + # guard mirrors the root install so a stale mobile lockfile fails by name — mobile's + # lockfile carries patchedDependencies that a silent rewrite would drop. + - name: Install mobile dependencies + if: needs.code_paths.outputs.mobile_dependencies == 'true' + working-directory: mobile + run: | + pnpm install --frozen-lockfile + if [ "$(git -C "$GITHUB_WORKSPACE" rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; then + git -C "$GITHUB_WORKSPACE" diff --exit-code -- \ + mobile/package.json mobile/pnpm-lock.yaml mobile/pnpm-workspace.yaml + fi + - name: Enforce changed-code quality run: pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}" @@ -360,7 +380,7 @@ jobs: - uses: ./.github/actions/install-node-dependencies # Why: the check rebuilds every package in the manifest from a pinned upstream - # commit — @xterm/xterm and the two addons, each built twice (once unmodified to + # commit — @xterm/xterm and its three addons, each built twice (once unmodified to # prove the toolchain still reproduces the published bundles, once patched). Caching # the npm metadata and the shallow clone keeps the repeated cost to the builds # themselves; the key is the manifest, so a commit, package or toolchain bump diff --git a/.gitignore b/.gitignore index 3fb72a6486a..8be3fc5b6f4 100644 --- a/.gitignore +++ b/.gitignore @@ -158,6 +158,7 @@ src/renderer/src/i18n/locales/.zh-catalog-cache.json src/renderer/src/i18n/locales/.ko-catalog-cache.json src/renderer/src/i18n/locales/.ja-catalog-cache.json src/renderer/src/i18n/locales/.es-catalog-cache.json +src/renderer/src/i18n/locales/.fr-catalog-cache.json # Bench result JSONs are working artifacts tests/tools/benchmarks/results/terminal-pipeline-*.json diff --git a/AGENTS.md b/AGENTS.md index 8b0156ba6b1..6915b246bcc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,18 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh - **WSL commands**: build argv with `buildWslExecArgs` (always `--exec` — under `--`, `wsl.exe` expands `$name` in every argument and silently rewrites the script), and fence anything whose stdout you parse with `buildWslCapturedLoginShellCommand`, because the interactive login shell prints the distro banner to stdout. See [`docs/reference/wsl-command-execution.md`](./docs/reference/wsl-command-execution.md). - **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. See [`docs/reference/linux-glibc-compatibility.md`](./docs/reference/linux-glibc-compatibility.md); packaging fails if a bundled native binary needs newer glibc. +## Localization (i18n) + +All user-facing copy is localized. `src/renderer/src/i18n/locales/en.json` is the source of truth; the `zh`, `ja`, `ko`, and `es` catalogs mirror its keys. Strings reach the UI through `translate('auto.', 'English fallback')` — never hardcode display text. + +When you touch user-facing copy, keep all five catalogs in sync: + +- **New strings** — wrap them in `translate(...)` with an English fallback, run `pnpm sync:localization-catalog` to register the keys in `en.json` and add placeholders to every other locale, then `pnpm bootstrap:-catalog` (e.g. `bootstrap:ja-catalog`) to translate the placeholders. +- **Reworded strings** — changing the value of an existing key updates only `en.json`. The other locales keep the key with its old translation, and **the lint checks will not catch this**: `verify:localization-catalog` enforces key _parity_, not translation _freshness_. Update the same key in `zh/ja/ko/es` by hand, or re-translate it via `bootstrap:-catalog`. +- **Removed strings** — delete the key from _every_ locale; the parity check rejects a key that exists in one catalog but not another. + +Before pushing copy changes, run `pnpm verify:localization-catalog` and `pnpm verify:localization-coverage` (both also run in `pnpm lint`). + ## SSH Use Case All changes must consider the SSH use case. Don't assume local-only execution. Before changing anything that reports on, stops, or lists remote work, follow [`docs/reference/ssh-execution-boundary.md`](./docs/reference/ssh-execution-boundary.md): the execution host owns everything that touches execution, and loss of contact is never evidence of process death — the verdict vocabulary is `live` / `unverifiable` / `exited`, with no synonyms. diff --git a/README.md b/README.md index 7a3cbe2360c..805c94295cc 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@

- 中文 · 日本語 · 한국어 · Español · Français · Português + 中文 · 日本語 · 한국어 · Español · Français · Português · Українська

diff --git a/cloud/.gitleaks.toml b/cloud/.gitleaks.toml index fc5c725c37d..0bb1f966fae 100644 --- a/cloud/.gitleaks.toml +++ b/cloud/.gitleaks.toml @@ -13,3 +13,10 @@ description = "Cloud SQL rollout lease holder keys in the action's unit tests" regexTarget = "secret" paths = ['''\.github/actions/cloud-sql-rollout-lease/[a-z-]+\.test\.mjs$'''] regexes = ['''^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/[0-9]+$'''] + +# RFC 6455 §1.3 example handshake nonce ("the sample nonce" in base64), sent by the raw-socket +# upgrade tests; the generic key rule reads any base64 header value as a secret. +[[allowlists]] +description = "RFC 6455 example Sec-WebSocket-Key in upgrade tests" +regexTarget = "secret" +regexes = ['''^dGhlIHNhbXBsZSBub25jZQ==$'''] diff --git a/cloud/apps/relay-ops/src/incident-monitor.test.ts b/cloud/apps/relay-ops/src/incident-monitor.test.ts index 51153bb63e1..ea5ad55b645 100644 --- a/cloud/apps/relay-ops/src/incident-monitor.test.ts +++ b/cloud/apps/relay-ops/src/incident-monitor.test.ts @@ -123,6 +123,19 @@ describe('incident monitor evaluator', () => { }) }) + // Why: sweeps no longer reach the retry wrapper, so any exhaustion left in this + // counter is a request path that terminally failed. It must still freeze. + it('freezes on a single exhausted request-path transaction', () => { + const sample = healthySample() + sample.sources['relay-logs']!.signals['relay.postgres_retry_exhausted'] = signal(1) + expect(evaluateIncidentSample(sample, startedAt)).toMatchObject({ + status: 'freeze', + failures: [ + expect.objectContaining({ signal: 'relay.postgres_retry_exhausted', threshold: 0 }) + ] + }) + }) + it('allows missing auth readiness and legacy existing-only connections', () => { const sample = healthySample() const legacySelector = { diff --git a/cloud/apps/relay-ops/src/incident-monitor.ts b/cloud/apps/relay-ops/src/incident-monitor.ts index 6073e351511..a1936f5df6c 100644 --- a/cloud/apps/relay-ops/src/incident-monitor.ts +++ b/cloud/apps/relay-ops/src/incident-monitor.ts @@ -38,6 +38,20 @@ export const INCIDENT_MONITOR_THRESHOLDS = { // margin; relayPostgresRetryExhausted below stays at zero tolerance, so any // transaction that terminally fails still freezes the gate. relayPostgresRetries: 300, + // Why: this bar stays at zero. Cell-inventory contention reaches the retry + // wrapper from exactly two kinds of caller, and neither is a sweep tick that + // can shrug the failure off: + // - request paths, which take a wait bounded at CELL_INVENTORY_LOCK_TIMEOUT_MS + // (assignment, control activation, activity, admin drain/evacuate/supersede); + // - sweep-reachable code that a request also enters, which keeps the pool + // lock_timeout so it cannot fail faster than before this change: the + // completeEvacuation site that waits, reconcileReservationAccounting, and + // placement re-entered from evacuateDeadCells. + // Sweep-only sites take the inventory NOWAIT, so their contention becomes + // database_lock_unavailable, which is not a retryable abort and never reaches + // this counter. The relay's cell-inventory-lock census test holds that split. + // Splitting the metric by the phase label PR #423 put on the log payload would + // need a labelled log-based metric, which this signal's counter does not carry. relayPostgresRetryExhausted: 0, // Why: public admission is a per-instance semaphore, so fleet assignment capacity is // concurrency x instances. A floor of 1 let the 2026-08-04 collapse from five instances diff --git a/cloud/apps/relay/src/assignment-store.ts b/cloud/apps/relay/src/assignment-store.ts index 0b2b1ef72a9..96d66eb7dc2 100644 --- a/cloud/apps/relay/src/assignment-store.ts +++ b/cloud/apps/relay/src/assignment-store.ts @@ -31,7 +31,12 @@ import { } from './assignment-connection-headroom-query.js' import { AssignmentIdentityQueue } from './assignment-identity-queue.js' import type { RelayCellConfig } from './config.js' -import type { RelayDatabase, RelayTransactionOptions, SqlRow } from './database.js' +import type { + RelayDatabase, + RelayLockOptions, + RelayTransactionOptions, + SqlRow +} from './database.js' import type { RegionalRehomeSafetySnapshot } from './relay-observability.js' import { combineRegionalRehomeSafety, @@ -316,6 +321,25 @@ const ACTIVITY_REQUEST_UNITS: Record = { } const ASSIGNMENT_LOCK_RETRY_DEADLINE_MS = 15_000 +// Why: one global FOR UPDATE over a 23-row table serialises every director and +// cell. At the 1s pool lock_timeout each blocked waiter also holds a pooled +// client for a full second, so the queue converts contention into pool +// exhaustion. The lock is held to COMMIT and the assignment path runs many +// statements after taking it, and no hold-time telemetry existed before this +// change, so 500ms is a first value to tune once cellInventoryHoldMsMax lands. +export const CELL_INVENTORY_LOCK_TIMEOUT_MS = 500 + +// The same inventory lock is taken by live requests and by background sweeps, +// and the right failure mode differs per caller. +export type CellInventoryLockMode = + // Bound the wait so a blocked request stops occupying a pooled client. + | 'request' + // Never queue: the caller handles database_lock_unavailable and moves on. + | 'nowait' + // A sweep can enter here, so keep the pool default. Failing sooner would turn + // ordinary contention into a 55P03 the retry wrapper reports as terminal, and + // one terminal failure freezes the incident gate. + | 'pool-default' // Why: stranded detection (issue #225) needs a grant old enough that a real // attach would have registered (the 90s activity lease covers dial + // activation), yet recent enough to prove an active retry loop rather than @@ -536,29 +560,35 @@ export class RelayAssignmentStore { async assign( identity: AssignmentIdentity, preferredRegion?: RelayRegion, - placementRegion: RelayRegion = preferredRegion ?? RELAY_DEFAULT_REGION + placementRegion: RelayRegion = preferredRegion ?? RELAY_DEFAULT_REGION, + // evacuateDeadCells re-enters placement from a sweep; it must not take the + // bounded wait, whose 55P03 would surface as a terminal sweep failure. + lockMode: CellInventoryLockMode = 'request' ): Promise { - const sticky = await this.assignStickyWithLockRetry(identity, preferredRegion) + const sticky = await this.assignStickyWithLockRetry(identity, lockMode, preferredRegion) if (sticky) return sticky // Only placement needs the global inventory critical section; queueing those // attempts locally avoids turning true placement bursts into NOWAIT storms. return await this.serializeAssignment( - async () => await this.assignWithLockRetry(identity, preferredRegion, placementRegion) + async () => + await this.assignWithLockRetry(identity, lockMode, preferredRegion, placementRegion) ) } private async assignStickyWithLockRetry( identity: AssignmentIdentity, + lockMode: CellInventoryLockMode, preferredRegion?: RelayRegion ): Promise { return await this.withAssignmentLockRetry( async (inventoryFirst) => - await this.assignStickyOnce(identity, inventoryFirst, preferredRegion) + await this.assignStickyOnce(identity, inventoryFirst, lockMode, preferredRegion) ) } private async assignWithLockRetry( identity: AssignmentIdentity, + lockMode: CellInventoryLockMode, preferredRegion?: RelayRegion, placementRegion: RelayRegion = preferredRegion ?? RELAY_DEFAULT_REGION ): Promise { @@ -566,7 +596,13 @@ export class RelayAssignmentStore { let inventoryScope: AssignmentInventoryScope = 'none' while (true) { try { - return await this.assignOnce(identity, inventoryScope, preferredRegion, placementRegion) + return await this.assignOnce( + identity, + inventoryScope, + lockMode, + preferredRegion, + placementRegion + ) } catch (error) { if (error instanceof AssignmentInventoryScopeChanged) { inventoryScope = 'all' @@ -604,12 +640,13 @@ export class RelayAssignmentStore { private async assignStickyOnce( identity: AssignmentIdentity, inventoryFirst: boolean, + lockMode: CellInventoryLockMode, preferredRegion?: RelayRegion ): Promise { const now = this.now() return await this.database.transaction(async (transaction) => { const lockedCells = inventoryFirst - ? await this.lockCellInventory(transaction) + ? await this.lockCellInventory(transaction, lockMode) : undefined const existing = await this.assignmentRow(transaction, identity, inventoryFirst) if (!existing) return null @@ -751,6 +788,7 @@ export class RelayAssignmentStore { private async assignOnce( identity: AssignmentIdentity, inventoryScope: AssignmentInventoryScope, + lockMode: CellInventoryLockMode, preferredRegion?: RelayRegion, placementRegion: RelayRegion = preferredRegion ?? RELAY_DEFAULT_REGION ): Promise { @@ -760,9 +798,9 @@ export class RelayAssignmentStore { return await this.database.transaction(async (transaction) => { let lockedCells = inventoryScope === 'all' - ? await this.lockCellInventory(transaction) + ? await this.lockCellInventory(transaction, lockMode) : inventoryScope === 'general' - ? await this.lockGeneralCellInventory(transaction) + ? await this.lockGeneralCellInventory(transaction, lockMode) : undefined const existing = await this.assignmentRow( transaction, @@ -779,7 +817,7 @@ export class RelayAssignmentStore { let connectionHeadroomReassignment = false let strandedReassignment = false if (existing && !mayNormallyReassign(activity(existing), now)) { - lockedCells ??= await this.lockCellInventory(transaction, true) + lockedCells ??= await this.lockCellInventory(transaction, 'nowait') const admission = await cellAdmissionStates(transaction) const currentRow = lockedCells.find( (row) => text(row, 'cell_id') === text(existing, 'cell_id') @@ -859,8 +897,8 @@ export class RelayAssignmentStore { } lockedCells ??= existing - ? await this.lockCellInventory(transaction, true) - : await this.lockGeneralCellInventory(transaction, true) + ? await this.lockCellInventory(transaction, 'nowait') + : await this.lockGeneralCellInventory(transaction, 'nowait') const target = await this.leastLoadedCell( transaction, lockedCells, @@ -2114,7 +2152,7 @@ export class RelayAssignmentStore { ORDER BY migration.user_id, migration.relay_host_id`, [input.cellId] ) - const cells = await this.lockCellInventory(transaction) + const cells = await this.lockCellInventory(transaction, 'request') for (const migrationRow of migrations) { const identity = { userId: text(migrationRow, 'user_id'), @@ -2608,10 +2646,12 @@ export class RelayAssignmentStore { let moved = 0 for (const row of rows) { try { - const assignment = await this.assign({ - userId: text(row, 'user_id'), - relayHostId: text(row, 'relay_host_id') - }) + const assignment = await this.assign( + { userId: text(row, 'user_id'), relayHostId: text(row, 'relay_host_id') }, + undefined, + undefined, + 'pool-default' + ) if (assignment.cellId !== text(row, 'cell_id')) moved++ } catch (error) { if (!(error instanceof Error && error.message === 'relay_capacity_exhausted')) throw error @@ -3162,7 +3202,7 @@ export class RelayAssignmentStore { ) const requestDelta = ACTIVITY_REQUEST_UNITS[kind] * (after - before) if (requestDelta !== 0) { - await this.lockCellInventory(transaction) + await this.lockCellInventory(transaction, 'request') await this.adjustCellReservation(transaction, text(row, 'cell_id'), requestDelta) } }) @@ -3223,7 +3263,7 @@ export class RelayAssignmentStore { } const units = ACTIVITY_REQUEST_UNITS[input.kind] if (existing) { - await this.lockCellInventory(transaction) + await this.lockCellInventory(transaction, 'request') await this.removeActivityLease(transaction, identity, existing, now) await this.adjustCellReservation(transaction, input.cellId, units) } @@ -3540,7 +3580,7 @@ export class RelayAssignmentStore { ) await this.touchAssignment(transaction, identity, expiresAt, now) } else { - await this.lockCellInventory(transaction) + await this.lockCellInventory(transaction, 'request') await this.adjustCellReservation(transaction, input.cellId, 1) await this.adjustActivityCount(transaction, identity, 'control', 1, expiresAt, now) await transaction.query( @@ -3614,7 +3654,7 @@ export class RelayAssignmentStore { } if (sourceCellId === targetCellId) throw new Error('target_matches_source') await this.lockAssignmentActivities(transaction, identity) - const cells = await this.lockCellInventory(transaction) + const cells = await this.lockCellInventory(transaction, 'request') const target = cells.find((row) => text(row, 'cell_id') === targetCellId) if (!target || integer(target, 'enabled') !== 1) throw new Error('target_cell_unavailable') if (!(await this.cellIsLive(transaction, targetCellId, now))) { @@ -3822,7 +3862,7 @@ export class RelayAssignmentStore { let lockedCells: SqlRow[] | undefined if (inventoryFirst) { try { - lockedCells = await this.lockCellInventory(transaction) + lockedCells = await this.lockCellInventory(transaction, 'request') } catch (error) { if (isDatabaseLockTimeout(error)) { throw new Error('database_lock_unavailable') @@ -3863,7 +3903,7 @@ export class RelayAssignmentStore { if (activityUnitsForCell(activityLeases, input.sourceCellId) > 0) { throw new Error('migration_source_still_active') } - const cells = lockedCells ?? (await this.lockCellInventory(transaction, true)) + const cells = lockedCells ?? (await this.lockCellInventory(transaction, 'nowait')) const source = cells.find((cell) => text(cell, 'cell_id') === input.sourceCellId) const target = cells.find((cell) => text(cell, 'cell_id') === input.targetCellId) if (!source || integer(source, 'enabled') !== 0) { @@ -3967,7 +4007,7 @@ export class RelayAssignmentStore { const now = this.now() return await this.database.transaction(async (transaction) => { const lockedCells = inventoryFirst - ? await this.lockCellInventory(transaction) + ? await this.lockCellInventory(transaction, 'request') : undefined const assignment = await this.assignmentRow(transaction, identity, inventoryFirst) const existing = ( @@ -4041,7 +4081,7 @@ export class RelayAssignmentStore { ) { throw new Error('migration_activity_topology_mismatch') } - const cells = lockedCells ?? (await this.lockCellInventory(transaction, true)) + const cells = lockedCells ?? (await this.lockCellInventory(transaction, 'nowait')) const source = cells.find((cell) => text(cell, 'cell_id') === input.sourceCellId) const currentTarget = cells.find( (cell) => text(cell, 'cell_id') === input.currentTargetCellId @@ -4458,7 +4498,7 @@ export class RelayAssignmentStore { throw new Error('migration_activity_topology_mismatch') } } - if (obsoleteLeases.length > 0) await this.lockCellInventory(transaction) + if (obsoleteLeases.length > 0) await this.lockCellInventory(transaction, 'request') for (const lease of obsoleteLeases) { await this.removeActivityLease(transaction, identity, lease, now) } @@ -4634,7 +4674,7 @@ export class RelayAssignmentStore { ) { throw new Error('migration_activity_lease_shape_mismatch') } - await this.lockCellInventory(transaction) + await this.lockCellInventory(transaction, 'request') await this.adjustCellReservation( transaction, input.currentTargetCellId, @@ -4723,7 +4763,7 @@ export class RelayAssignmentStore { if (this.requireLiveCells) { let cells: SqlRow[] try { - cells = await this.lockCellInventory(transaction, true) + cells = await this.lockCellInventory(transaction, 'nowait') } catch (error) { if (isDatabaseLockUnavailable(error)) { // Mixed-version workers may still hold a cell-first lock; defer @@ -4779,7 +4819,7 @@ export class RelayAssignmentStore { ) if (!targetIsActive) throw new Error('migration_target_not_active') const lease = activityLeaseById(activityLeases, migrationActivityId(assignmentEpoch)) - if (lease && !cellsLocked) await this.lockCellInventory(transaction) + if (lease && !cellsLocked) await this.lockCellInventory(transaction, 'pool-default') if (lease) await this.removeActivityLease(transaction, identity, lease, now) await transaction.query( `UPDATE relay_assignment_migrations SET completed_at = ?, updated_at = ? @@ -4801,7 +4841,7 @@ export class RelayAssignmentStore { const sourceCellId = text(assignment, 'cell_id') if (sourceCellId === targetCellId) throw new Error('target_matches_source') await this.lockAssignmentActivities(transaction, identity) - const cells = await this.lockCellInventory(transaction) + const cells = await this.lockCellInventory(transaction, 'request') const admission = await cellAdmissionStates(transaction) const targetRow = cells.find( (row) => @@ -5051,7 +5091,13 @@ export class RelayAssignmentStore { } this.pendingRegionalRehomeDisableLog = null const candidateSkips: RegionalRehomeCandidateSkip[] = [] + // A Postgres transaction is unusable after a NOWAIT abort, so a contended + // tick abandons the candidate it stopped on plus every one behind it. + let candidatesTotal = 0 + let candidatesFinished = 0 const claimResult = await this.database.transaction(async (transaction) => { + candidatesTotal = 0 + candidatesFinished = 0 candidateSkips.length = 0 await this.initializeRegionalRehomeControl(transaction, now) const control = ( @@ -5122,6 +5168,7 @@ export class RelayAssignmentStore { ) )[0] if (retry) { + candidatesTotal = 1 const fleetSafety = await this.lockedRegionalRehomeFleetSafety(transaction, now) if ( !(await this.regionalRehomeSafetyAllowsClaim( @@ -5190,6 +5237,7 @@ export class RelayAssignmentStore { ) )[0] if (redrain) { + candidatesTotal = 1 const fleetSafety = await this.lockedRegionalRehomeFleetSafety(transaction, now) if ( !(await this.regionalRehomeSafetyAllowsClaim( @@ -5253,6 +5301,7 @@ export class RelayAssignmentStore { LIMIT 10`, [preferenceCutoff, now - this.heartbeatTtlMs, now] ) + candidatesTotal = candidates.length for (const candidate of candidates) { const claimed = await this.startRegionalRehomeCandidate(transaction, { identity: { @@ -5268,6 +5317,7 @@ export class RelayAssignmentStore { now, skips: candidateSkips }) + candidatesFinished++ if (!claimed) continue await this.markRegionalRehomeDispatchClaimed( transaction, @@ -5283,6 +5333,21 @@ export class RelayAssignmentStore { await this.markRegionalRehomeTickSkipped(transaction, now, intervalMs) } return null + }).catch((error: unknown): RegionalRehomeAttempt | null => { + // Only inventory contention is swallowed here; every other failure keeps + // its existing propagation and its dispatch-failure accounting. + if (!isDatabaseLockUnavailable(error)) throw error + // The dispatch tick runs every second; losing one to inventory contention + // costs a second of latency and never loses durable rehome state. The + // rolled-back transaction never disabled anything, so its pending disable + // log would describe a decision that did not happen. + candidateSkips.length = 0 + this.pendingRegionalRehomeDisableLog = null + warnSweepCellInventoryBusy( + 'claim-regional-rehome', + Math.max(1, candidatesTotal - candidatesFinished) + ) + return null }) const pendingDisableLog = this.pendingRegionalRehomeDisableLog this.pendingRegionalRehomeDisableLog = null @@ -5343,7 +5408,7 @@ export class RelayAssignmentStore { } const activityLeases = await this.lockAssignmentActivities(transaction, input.identity) assertAssignmentActivityCounts(assignment, activityLeases, 0) - const cells = await this.lockCellInventory(transaction) + const cells = await this.lockCellInventory(transaction, 'nowait') const admission = await cellAdmissionStates(transaction) const regions = new Map( (await transaction.query(`SELECT cell_id, region FROM relay_cell_regions`)).map((row) => [ @@ -5630,7 +5695,7 @@ export class RelayAssignmentStore { transaction: RelayDatabase, now: number ): Promise { - const cells = await this.lockCellInventory(transaction) + const cells = await this.lockCellInventory(transaction, 'nowait') const admission = await cellAdmissionStates(transaction) const regions = new Map( (await transaction.query(`SELECT cell_id, region FROM relay_cell_regions`)).map((row) => [ @@ -5874,6 +5939,7 @@ export class RelayAssignmentStore { [...quarantined, limit] ) let completed = 0 + let inventoryBusy = 0 for (const candidate of candidates) { // One poisoned row must not stall every later candidate: an invariant // throw here blocked fleet completions head-of-line in production. @@ -5890,9 +5956,14 @@ export class RelayAssignmentStore { if (changed) completed++ this.regionalRehomeCandidateQuarantine.delete(attemptId) } catch (error) { + if (isDatabaseLockUnavailable(error)) { + inventoryBusy++ + continue + } this.recordRegionalRehomeCandidateFailure('complete', attemptId, now, error) } } + warnSweepCellInventoryBusy('complete-ready-regional-rehomes', inventoryBusy) return completed } @@ -6112,7 +6183,7 @@ export class RelayAssignmentStore { leases, migration ) - const cells = await this.lockCellInventory(transaction) + const cells = await this.lockCellInventory(transaction, 'nowait') const target = cells.find((cell) => text(cell, 'cell_id') === targetCellId) const admission = await cellAdmissionStates(transaction) if ( @@ -6261,6 +6332,7 @@ export class RelayAssignmentStore { [now - REGIONAL_REHOME_MAX_REFRESH_MS, ...quarantined, limit] ) let aborted = 0 + let inventoryBusy = 0 for (const candidate of candidates) { const identity = { userId: text(candidate, 'user_id'), @@ -6318,7 +6390,7 @@ export class RelayAssignmentStore { integer(lease, 'expires_at') > now ) if (targetActive) return false - const cells = await this.lockCellInventory(transaction) + const cells = await this.lockCellInventory(transaction, 'nowait') const source = cells.find((cell) => text(cell, 'cell_id') === sourceCellId) const admission = await cellAdmissionStates(transaction) if ( @@ -6376,10 +6448,12 @@ export class RelayAssignmentStore { }) this.regionalRehomeCandidateQuarantine.delete(attemptId) } catch (error) { - this.recordRegionalRehomeCandidateFailure('abort', attemptId, now, error) + if (isDatabaseLockUnavailable(error)) inventoryBusy++ + else this.recordRegionalRehomeCandidateFailure('abort', attemptId, now, error) } if (changed) aborted++ } + warnSweepCellInventoryBusy('abort-expired-regional-rehomes', inventoryBusy) return aborted } @@ -6396,6 +6470,7 @@ export class RelayAssignmentStore { [now, now, abandonedBefore, abandonedBefore] ) let aborted = 0 + let inventoryBusy = 0 for (const candidate of candidates) { const didAbort = await this.database.transaction(async (transaction) => { const identity = { @@ -6480,7 +6555,7 @@ export class RelayAssignmentStore { ] .map((activityId) => activityLeaseById(activityLeases, activityId)) .filter((lease): lease is SqlRow => lease !== undefined) - if (obsoleteLeases.length > 0) await this.lockCellInventory(transaction) + if (obsoleteLeases.length > 0) await this.lockCellInventory(transaction, 'nowait') for (const lease of obsoleteLeases) { await this.removeActivityLease(transaction, identity, lease, now) } @@ -6498,7 +6573,7 @@ export class RelayAssignmentStore { ) return true } - const cells = await this.lockCellInventory(transaction) + const cells = await this.lockCellInventory(transaction, 'nowait') const sourceCellId = text(row, 'source_cell_id') const admissionRows = await transaction.query( `SELECT cell_id, admission_state, updated_at FROM relay_cell_admission @@ -6595,9 +6670,15 @@ export class RelayAssignmentStore { [now, now, identity.userId, identity.relayHostId, assignmentEpoch] ) return true + }).catch((error: unknown): boolean => { + // Expiry is durable; another director settling this row is not a failure. + if (!isDatabaseLockUnavailable(error)) throw error + inventoryBusy++ + return false }) if (didAbort) aborted++ } + warnSweepCellInventoryBusy('abort-expired-evacuations', inventoryBusy) return aborted } @@ -6665,7 +6746,7 @@ export class RelayAssignmentStore { const activityLeases = await this.lockAssignmentActivities(transaction, identity, true) const lease = activityLeaseById(activityLeases, text(candidate, 'activity_id')) if (!lease || integer(lease, 'expires_at') > now) return false - await this.lockCellInventory(transaction, true) + await this.lockCellInventory(transaction, 'nowait') await this.removeActivityLease(transaction, identity, lease, now) return true }) @@ -6709,7 +6790,7 @@ export class RelayAssignmentStore { [now], { failIfUnavailable: true } ) - if (expired.length > 0) await this.lockCellInventory(transaction, true) + if (expired.length > 0) await this.lockCellInventory(transaction, 'nowait') for (const row of expired) { await this.adjustCellReservation(transaction, text(row, 'cell_id'), -requestUnits(row)) await transaction.query( @@ -6782,7 +6863,7 @@ export class RelayAssignmentStore { targetCellId ] ) - const cells = await this.lockCellInventory(transaction) + const cells = await this.lockCellInventory(transaction, 'pool-default') const assignmentKeys = new Set( assignments.map((row) => assignmentKey(text(row, 'user_id'), text(row, 'relay_host_id')) @@ -6861,30 +6942,32 @@ export class RelayAssignmentStore { private async lockCellInventory( database: RelayDatabase, - failIfUnavailable = false + mode: CellInventoryLockMode ): Promise { // Every capacity-changing assignment takes the tiny cell inventory in one // order; dynamically locking only the selected target allowed cross-cell cycles. - return await database.queryLocked( + const rows = await database.queryLocked( `SELECT * FROM relay_cells ORDER BY cell_id ASC`, [], - { failIfUnavailable } + cellInventoryLockOptions(mode) ) + return rows } private async lockGeneralCellInventory( database: RelayDatabase, - failIfUnavailable = false + mode: CellInventoryLockMode ): Promise { - return await database.queryLocked( + const rows = await database.queryLocked( `SELECT * FROM relay_cells WHERE cell_id IN ( SELECT cell_id FROM relay_cell_admission WHERE admission_state = 'general' ) ORDER BY cell_id ASC`, [], - { failIfUnavailable } + cellInventoryLockOptions(mode) ) + return rows } private async leastLoadedCell( @@ -6892,7 +6975,7 @@ export class RelayAssignmentStore { lockedCells: SqlRow[] | undefined, preferredRegion: RelayRegion ): Promise { - const rows = lockedCells ?? (await this.lockCellInventory(database)) + const rows = lockedCells ?? (await this.lockCellInventory(database, 'pool-default')) const regions = new Map( (await database.query(`SELECT cell_id, region FROM relay_cell_regions`)).map((row) => [ text(row, 'cell_id'), @@ -7507,7 +7590,7 @@ export class RelayAssignmentStore { ) { throw new Error('activity_lease_shape_mismatch') } - const cells = await this.lockCellInventory(database) + const cells = await this.lockCellInventory(database, 'request') await database.query( `DELETE FROM relay_assignment_activity_leases WHERE user_id = ? AND relay_host_id = ? AND activity_kind = 'control' @@ -7886,6 +7969,23 @@ function isDatabaseLockUnavailable(error: unknown): boolean { return error instanceof Error && error.message === 'database_lock_unavailable' } +export function cellInventoryLockOptions(mode: CellInventoryLockMode): RelayLockOptions { + if (mode === 'nowait') return { failIfUnavailable: true, measureHoldMs: true } + if (mode === 'pool-default') return { measureHoldMs: true } + return { lockTimeoutMs: CELL_INVENTORY_LOCK_TIMEOUT_MS, measureHoldMs: true } +} + +// Background sweeps take the cell inventory NOWAIT so they never queue ahead of +// assignment traffic. A skipped candidate is re-derived from durable state on +// the next tick, so it is ordinary contention, not a sweep failure: one summary +// line per tick, never an error and never a quarantine. +function warnSweepCellInventoryBusy(sweep: string, skipped: number): void { + if (skipped === 0) return + console.warn( + JSON.stringify({ event: 'orca_relay_sweep_cell_inventory_busy', sweep, skipped }) + ) +} + function isDatabaseLockTimeout(error: unknown): boolean { return String((error as { code?: unknown }).code) === '55P03' } diff --git a/cloud/apps/relay/src/cell-inventory-hold-samples.test.ts b/cloud/apps/relay/src/cell-inventory-hold-samples.test.ts new file mode 100644 index 00000000000..abd37dcbb29 --- /dev/null +++ b/cloud/apps/relay/src/cell-inventory-hold-samples.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { + CellInventoryHoldSamples, + emptyCellInventoryHoldCounts +} from './cell-inventory-hold-samples.js' + +// Nearest rank, computed in integer arithmetic so it cannot inherit the float +// error the implementation's `0.95 * n` could in principle carry. +function nearestRankP95(sorted: number[]): number { + return sorted[Math.ceil((95 * sorted.length) / 100) - 1]! +} + +function samplesOf(values: number[]): CellInventoryHoldSamples { + const samples = new CellInventoryHoldSamples() + for (const value of values) samples.record(value) + return samples +} + +describe('cell inventory hold samples', () => { + it('reports nothing before the first hold', () => { + expect(new CellInventoryHoldSamples().readCounts()).toEqual( + emptyCellInventoryHoldCounts() + ) + }) + + // Why: the 500ms bound will be tuned against this percentile, so an off-by-one + // here reads as a hold the fleet never had. + it('places p95 at the nearest rank for every window size', () => { + for (let size = 1; size <= 400; size++) { + const values = Array.from({ length: size }, (_, index) => index + 1) + const shuffled = [...values].reverse() + + const counts = samplesOf(shuffled).readCounts() + + expect(counts.cellInventoryHoldMsP95).toBe(nearestRankP95(values)) + expect(counts.cellInventoryHoldMsMax).toBe(size) + expect(counts.cellInventoryHolds).toBe(size) + } + }) + + it('never reports a p95 above the max', () => { + for (let size = 1; size <= 200; size++) { + const counts = samplesOf(Array.from({ length: size }, (_, i) => i + 1)).readCounts() + + expect(counts.cellInventoryHoldMsP95).toBeLessThanOrEqual(counts.cellInventoryHoldMsMax) + } + }) + + it('ignores a hold that is not a finite, non-negative duration', () => { + const samples = samplesOf([Number.NaN, Number.POSITIVE_INFINITY, -1]) + + expect(samples.readCounts()).toEqual(emptyCellInventoryHoldCounts()) + }) + + // Why: the reservoir is bounded, so a heavy flush interval keeps the most + // recent holds rather than growing without limit or freezing on the oldest. + it('keeps the most recent holds once the reservoir is full', () => { + const counts = samplesOf(Array.from({ length: 2_100 }, (_, index) => index + 1)).readCounts() + + expect(counts.cellInventoryHolds).toBe(2_048) + expect(counts.cellInventoryHoldMsMax).toBe(2_100) + }) + + it('resets the window on consume so each flush reports its own holds', () => { + const samples = samplesOf([5, 10]) + + expect(samples.consumeCounts().cellInventoryHolds).toBe(2) + expect(samples.consumeCounts()).toEqual(emptyCellInventoryHoldCounts()) + }) +}) diff --git a/cloud/apps/relay/src/cell-inventory-hold-samples.ts b/cloud/apps/relay/src/cell-inventory-hold-samples.ts new file mode 100644 index 00000000000..14941032d80 --- /dev/null +++ b/cloud/apps/relay/src/cell-inventory-hold-samples.ts @@ -0,0 +1,46 @@ +// Why: the cell inventory lock is held to COMMIT, and the assignment path runs +// many statements after taking it. Tuning the request-path wait bound needs the +// hold distribution, and no runtime metric carried it before this change. +export type CellInventoryHoldCounts = { + cellInventoryHoldMsMax: number + cellInventoryHoldMsP95: number + cellInventoryHolds: number +} + +// Bounded so a flush interval with heavy assignment traffic cannot grow the array +// without limit; the reservoir keeps the most recent holds. +const MAX_SAMPLES = 2_048 + +export function emptyCellInventoryHoldCounts(): CellInventoryHoldCounts { + return { cellInventoryHoldMsMax: 0, cellInventoryHoldMsP95: 0, cellInventoryHolds: 0 } +} + +export class CellInventoryHoldSamples { + private samples: number[] = [] + + record(holdMs: number): void { + if (!Number.isFinite(holdMs) || holdMs < 0) return + if (this.samples.length === MAX_SAMPLES) this.samples.shift() + this.samples.push(holdMs) + } + + consumeCounts(): CellInventoryHoldCounts { + const counts = this.readCounts() + this.samples = [] + return counts + } + + readCounts(): CellInventoryHoldCounts { + if (this.samples.length === 0) return emptyCellInventoryHoldCounts() + const sorted = [...this.samples].sort((left, right) => left - right) + return { + cellInventoryHoldMsMax: round(sorted[sorted.length - 1]!), + cellInventoryHoldMsP95: round(sorted[Math.ceil(0.95 * sorted.length) - 1] ?? 0), + cellInventoryHolds: sorted.length + } + } +} + +function round(value: number): number { + return Number(value.toFixed(3)) +} diff --git a/cloud/apps/relay/src/cell-inventory-lock-census.test.ts b/cloud/apps/relay/src/cell-inventory-lock-census.test.ts new file mode 100644 index 00000000000..b2b5b65684c --- /dev/null +++ b/cloud/apps/relay/src/cell-inventory-lock-census.test.ts @@ -0,0 +1,205 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { cellInventoryLockOptions, type CellInventoryLockMode } from './assignment-store.js' + +// Which entry points can reach a call site. A site a sweep can enter must never +// take the bounded wait: its 55P03 becomes a terminal transaction failure, and +// the incident monitor freezes on a single one. +type Reachability = 'request' | 'sweep' | 'both' | 'orphan' + +// 'caller' is not a CellInventoryLockMode: those sites take the mode threaded +// from `assign`, which is 'request' for a client and 'pool-default' for the +// evacuateDeadCells sweep. +type CensusMode = CellInventoryLockMode | 'caller' + +type CensusEntry = { method: string; mode: CensusMode; reach: Reachability } + +// Every lockCellInventory / lockGeneralCellInventory call site in +// assignment-store.ts, in source order. A new site fails this test until it is +// classified here, which is the point. +const CENSUS: CensusEntry[] = [ + { method: 'assignStickyOnce', mode: 'caller', reach: 'both' }, + { method: 'assignOnce', mode: 'caller', reach: 'both' }, + { method: 'assignOnce', mode: 'caller', reach: 'both' }, + { method: 'assignOnce', mode: 'nowait', reach: 'both' }, + { method: 'assignOnce', mode: 'nowait', reach: 'both' }, + { method: 'assignOnce', mode: 'nowait', reach: 'both' }, + { method: 'refreshDrainMigrationLeasesOnce', mode: 'request', reach: 'request' }, + // Reachable from neither: changeActivity has no production callers, only tests. + { method: 'changeActivity', mode: 'request', reach: 'orphan' }, + { method: 'acquireActivity', mode: 'request', reach: 'request' }, + { method: 'activateControl', mode: 'request', reach: 'request' }, + { method: 'startEvacuation', mode: 'request', reach: 'request' }, + { method: 'completeEvacuationFromDeadSourceOnce', mode: 'request', reach: 'request' }, + { method: 'completeEvacuationFromDeadSourceOnce', mode: 'nowait', reach: 'request' }, + { method: 'supersedeRegisteredEvacuationOnce', mode: 'request', reach: 'request' }, + { method: 'supersedeRegisteredEvacuationOnce', mode: 'nowait', reach: 'request' }, + { method: 'prepareRegisteredCellSupersession', mode: 'request', reach: 'request' }, + { method: 'prepareRegisteredCellSupersession', mode: 'request', reach: 'request' }, + { method: 'completeEvacuation', mode: 'nowait', reach: 'both' }, + { method: 'completeEvacuation', mode: 'pool-default', reach: 'both' }, + { method: 'rebalanceDormant', mode: 'request', reach: 'request' }, + { method: 'startRegionalRehomeCandidate', mode: 'nowait', reach: 'sweep' }, + { method: 'lockedRegionalRehomeFleetSafety', mode: 'nowait', reach: 'sweep' }, + { method: 'completeRegionalRehomeCandidate', mode: 'nowait', reach: 'sweep' }, + { method: 'abortExpiredRegionalRehomes', mode: 'nowait', reach: 'sweep' }, + { method: 'abortExpiredEvacuations', mode: 'nowait', reach: 'sweep' }, + { method: 'abortExpiredEvacuations', mode: 'nowait', reach: 'sweep' }, + { method: 'releaseExpiredActivityLeases', mode: 'nowait', reach: 'sweep' }, + { method: 'releaseExpiredActivity', mode: 'nowait', reach: 'sweep' }, + { method: 'reconcileReservationAccounting', mode: 'pool-default', reach: 'both' }, + { method: 'leastLoadedCell', mode: 'pool-default', reach: 'both' }, + { method: 'removeSupersededSameCellControls', mode: 'request', reach: 'request' } +] + +// The background sweeps, and nothing else. A method reachable from one of these +// can be entered by a sweep tick, whatever else can also enter it. Both lists are +// read from source, so a new sweep step or a new route widens the derivation here +// instead of silently widening what a bounded wait can be entered from. +const SWEEP_ENTRY_FILES = ['./assignment-cleanup-steps.ts', './regional-rehome-worker.ts'] +const REQUEST_ENTRY_FILES = [ + './app.ts', + './relay-server.ts', + './host-session-registry.ts', + './cell-admission-startup.ts' +] + +const DECLARATION = /^ {2}(?:private |public )?(?:static )?(?:async )?([A-Za-z_][\w]*)[(<]/ + +function storeSource(): string[] { + return readFileSync(new URL('./assignment-store.ts', import.meta.url), 'utf8').split('\n') +} + +function entryPoints(files: string[]): string[] { + return files.flatMap((file) => + [ + ...readFileSync(new URL(file, import.meta.url), 'utf8').matchAll( + /assignments\.([A-Za-z_][\w]*)\(/g + ) + ].map((call) => call[1]!) + ) +} + +// Same-class call graph: store methods only ever reach each other through `this.`. +function storeCallGraph(lines: string[]): Map> { + const bounds: { name: string; start: number }[] = [] + lines.forEach((line, index) => { + const declaration = DECLARATION.exec(line) + if (declaration) bounds.push({ name: declaration[1]!, start: index }) + }) + const callees = new Map>() + bounds.forEach((method, index) => { + const end = bounds[index + 1]?.start ?? lines.length + const names = callees.get(method.name) ?? new Set() + for (const call of lines.slice(method.start, end).join('\n').matchAll( + /this\.([A-Za-z_][\w]*)\s*\(/g + )) { + names.add(call[1]!) + } + callees.set(method.name, names) + }) + return callees +} + +function closure(callees: Map>, roots: string[]): Set { + const reached = new Set() + const pending = [...roots] + while (pending.length > 0) { + const name = pending.pop()! + if (reached.has(name)) continue + reached.add(name) + for (const callee of callees.get(name) ?? []) if (!reached.has(callee)) pending.push(callee) + } + return reached +} + +// Why: a hand-written reachability column is a claim, not a check. Derive both +// directions, so a new sweep edge into a bounded site fails here instead of in +// production, and so 'sweep' and 'both' stop being asserted by hand. +function derivedReachability(lines: string[]): (method: string) => Reachability { + const callees = storeCallGraph(lines) + const sweep = closure(callees, entryPoints(SWEEP_ENTRY_FILES)) + const request = closure(callees, entryPoints(REQUEST_ENTRY_FILES)) + return (method) => + sweep.has(method) + ? request.has(method) + ? 'both' + : 'sweep' + : request.has(method) + ? 'request' + : 'orphan' +} + +function readCallSites(): { method: string; mode: CensusMode }[] { + const sites: { method: string; mode: CensusMode }[] = [] + let method = '' + for (const line of storeSource()) { + const declaration = DECLARATION.exec(line) + if (declaration) method = declaration[1]! + if (/private async lock(General)?CellInventory\(/.test(line)) continue + const call = /lock(?:General)?CellInventory\(\s*\w+\s*,\s*(?:'([a-z-]+)'|(\w+))\s*\)/.exec(line) + if (!call) continue + sites.push({ method, mode: (call[1] ?? 'caller') as CensusMode }) + } + return sites +} + +describe('cell inventory lock call-site census', () => { + it('classifies every call site exactly as recorded', () => { + expect(readCallSites()).toEqual( + CENSUS.map(({ method, mode }) => ({ method, mode })) + ) + }) + + it('leaves no call site taking the inventory without naming a mode', () => { + const source = readFileSync(new URL('./assignment-store.ts', import.meta.url), 'utf8') + const unclassified = source + .split('\n') + .filter((line) => /lock(?:General)?CellInventory\(\s*\w+\s*\)/.test(line)) + .filter((line) => !line.includes('private async')) + + expect(unclassified).toEqual([]) + }) + + it('derives the same reachability the census claims', () => { + const reachOf = derivedReachability(storeSource()) + + expect(readCallSites().map(({ method }) => reachOf(method))).toEqual( + CENSUS.map((entry) => entry.reach) + ) + }) + + // Why: this is the whole point of the classification. A shorter wait on a + // sweep-reachable site turns contention into a terminal transaction failure, + // and relayPostgresRetryExhausted freezes the incident gate at zero. + // Why: the hold distribution is what the 500ms bound will be tuned against, so + // a mode that stops asking for it goes unmeasured in exactly the lane that + // matters. Nothing else in the suite reads the pool-default branch. + it('measures the hold in every lock mode', () => { + const modes: CellInventoryLockMode[] = ['request', 'nowait', 'pool-default'] + + expect(modes.map((mode) => cellInventoryLockOptions(mode).measureHoldMs)).toEqual([ + true, + true, + true + ]) + }) + + it('never puts a sweep-reachable site on the bounded wait', () => { + const reachOf = derivedReachability(storeSource()) + const bounded = readCallSites().filter( + (site) => site.mode === 'request' && ['sweep', 'both'].includes(reachOf(site.method)) + ) + + expect(bounded).toEqual([]) + }) + + it('routes every sweep-only site to NOWAIT so it can skip the tick', () => { + const reachOf = derivedReachability(storeSource()) + const queueing = readCallSites().filter( + (site) => reachOf(site.method) === 'sweep' && site.mode !== 'nowait' + ) + + expect(queueing).toEqual([]) + }) +}) diff --git a/cloud/apps/relay/src/cell-inventory-lock-contention.test.ts b/cloud/apps/relay/src/cell-inventory-lock-contention.test.ts new file mode 100644 index 00000000000..783d8a62e8b --- /dev/null +++ b/cloud/apps/relay/src/cell-inventory-lock-contention.test.ts @@ -0,0 +1,541 @@ +import { readFileSync } from 'node:fs' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const fakes = vi.hoisted(() => ({ + statements: [] as string[], + query: vi.fn(async (sql: string) => { + fakes.statements.push(sql) + return { rows: [], rowCount: 0 } + }), + release: vi.fn(), + end: vi.fn(async () => undefined) +})) + +vi.mock('pg', () => ({ + default: { + Pool: class { + totalCount = 1 + idleCount = 1 + waitingCount = 0 + end = fakes.end + on = vi.fn() + connect = vi.fn(async () => ({ query: fakes.query, release: fakes.release })) + } + } +})) + +const { CELL_INVENTORY_LOCK_TIMEOUT_MS, RelayAssignmentStore } = await import( + './assignment-store.js' +) +const { consumeRelayCellInventoryHold, openInMemoryRelayDatabase, openRelayDatabase, POSTGRES_LOCK_TIMEOUT_MS } = + await import('./database.js') +const RESTORE = `SET LOCAL lock_timeout = '${POSTGRES_LOCK_TIMEOUT_MS}ms'` +type RelayDatabase = import('./database.js').RelayDatabase +type RelayLockOptions = import('./database.js').RelayLockOptions +type RelayTransactionOptions = import('./database.js').RelayTransactionOptions +type SqlRow = import('./database.js').SqlRow + +const CELL_INVENTORY_SQL = 'SELECT * FROM relay_cells ORDER BY cell_id ASC' + +// The assignment path locks the general-admission subset; both forms are the +// same ordered scan of the same 23-row table and share its lock queue. +function locksCellInventory(sql: string): boolean { + return sql.trim().startsWith('SELECT * FROM relay_cells') && sql.includes('ORDER BY cell_id ASC') +} +const CELLS = [ + { id: 'cell-a', url: 'https://relay-a.example.com', capacityRequests: 10 }, + { id: 'cell-b', url: 'https://relay-b.example.com', capacityRequests: 10 } +] +const identity = { userId: 'user-a', relayHostId: 'host000000000001' } + +async function openFakePostgres(): Promise { + const database = await openRelayDatabase({ + databaseUrl: 'postgresql://relay:secret@127.0.0.1:5432/relay', + dataDir: './unused' + }) + fakes.statements.length = 0 + return database +} + +afterEach(() => { + fakes.statements.length = 0 + fakes.query.mockReset() + fakes.query.mockImplementation(async (sql: string) => { + fakes.statements.push(sql) + return { rows: [], rowCount: 0 } + }) +}) + +describe('bounded cell-inventory lock wait', () => { + // Why: a bound at or above the pool default would fence nothing, and one far + // below the hold time would convert ordinary contention into terminal failures. + it('keeps the request bound strictly inside the pool default', () => { + expect(CELL_INVENTORY_LOCK_TIMEOUT_MS).toBe(500) + expect(CELL_INVENTORY_LOCK_TIMEOUT_MS).toBeLessThan(POSTGRES_LOCK_TIMEOUT_MS) + }) + + // Why: SET LOCAL lasts to COMMIT. Left in place it would govern every later + // locked statement in the transaction and misattribute their 55P03s. + it('restores the pool default before the next statement in the transaction', async () => { + const database = await openFakePostgres() + + await database.transaction(async (transaction) => { + await transaction.queryLocked(CELL_INVENTORY_SQL, [], { lockTimeoutMs: 150 }) + await transaction.queryLocked('SELECT * FROM relay_assignments', []) + }) + + expect(fakes.statements).toEqual([ + 'BEGIN', + "SET LOCAL lock_timeout = '150ms'", + `${CELL_INVENTORY_SQL} FOR UPDATE`, + RESTORE, + 'SELECT * FROM relay_assignments FOR UPDATE', + 'COMMIT' + ]) + await database.close() + }) + + it('restores the pool default when the bounded lock itself times out', async () => { + const database = await openFakePostgres() + fakes.query.mockImplementation(async (sql: string) => { + fakes.statements.push(sql) + if (sql.includes('FOR UPDATE')) { + throw Object.assign(new Error('lock timeout'), { code: '55P03' }) + } + return { rows: [], rowCount: 0 } + }) + + await expect( + database.transaction(async (transaction) => { + await transaction.queryLocked(CELL_INVENTORY_SQL, [], { lockTimeoutMs: 150 }) + }) + ).rejects.toMatchObject({ code: '55P03' }) + + // The retry wrapper makes three attempts; each one must leave the default back. + expect(fakes.statements.filter((sql) => sql.startsWith('SET LOCAL'))).toEqual( + Array.from({ length: 3 }, () => ["SET LOCAL lock_timeout = '150ms'", RESTORE]).flat() + ) + await database.close() + }) + + it('rejects a lock bound that is not a positive whole number of milliseconds', async () => { + const database = await openFakePostgres() + + for (const lockTimeoutMs of [0, -1, 1.5, Number.NaN]) { + await expect( + database.transaction( + async (transaction) => + await transaction.queryLocked(CELL_INVENTORY_SQL, [], { lockTimeoutMs }) + ) + ).rejects.toThrow('invalid_lock_timeout') + } + await database.close() + }) + + it('skips the timeout for a NOWAIT lock, which never queues', async () => { + const database = await openFakePostgres() + + await database.transaction(async (transaction) => { + await transaction.queryLocked(CELL_INVENTORY_SQL, [], { + failIfUnavailable: true, + lockTimeoutMs: 150 + }) + }) + + expect(fakes.statements.filter((sql) => sql.startsWith('SET LOCAL'))).toEqual([]) + await database.close() + }) + + it('skips the timeout outside a transaction, where SET LOCAL cannot survive', async () => { + const database = await openFakePostgres() + + await database.queryLocked(CELL_INVENTORY_SQL, [], { lockTimeoutMs: 150 }) + + expect(fakes.statements).toEqual([`${CELL_INVENTORY_SQL} FOR UPDATE`]) + await database.close() + }) + + it('ignores the timeout on SQLite, which has no SET LOCAL', async () => { + const database = await openInMemoryRelayDatabase() + + const rows = await database.transaction( + async (transaction) => + await transaction.queryLocked(CELL_INVENTORY_SQL, [], { lockTimeoutMs: 150 }) + ) + + expect(rows).toEqual([]) + await database.close() + }) + + // Why: testing the helper alone would pass with the store still queueing for + // the pool's one-second default. + // Why: testing the helper alone would pass with the request path still queueing + // for the pool's full second. + it('never lets a request path take the unbounded wait', async () => { + const database = await openInMemoryRelayDatabase() + const probe = new InventoryLockProbe(database) + const store = new RelayAssignmentStore(probe, () => 1_000) + await store.reconcileCells(CELLS) + probe.inventoryLocks.length = 0 + + // Assignment takes the general-admission subset; evacuation takes them all. + await store.assign(identity) + const generalLocks = probe.inventoryLocks.length + await store.startEvacuation(identity, 'cell-b') + + expect(generalLocks).toBeGreaterThan(0) + expect(probe.inventoryLocks.length).toBeGreaterThan(generalLocks) + for (const options of probe.inventoryLocks) { + const bounded = options?.lockTimeoutMs === CELL_INVENTORY_LOCK_TIMEOUT_MS + expect(bounded || options?.failIfUnavailable === true).toBe(true) + } + await database.close() + }) + + // Why: evacuateDeadCells re-enters placement from a sweep. A 55P03 there would + // be reported as a terminal sweep failure and freeze the incident gate. + it('keeps the pool default when a sweep re-enters placement', async () => { + const requestModes = await recordAssignInventoryModes(async (store) => { + await store.assign(identity) + }) + const sweepModes = await recordAssignInventoryModes(async (store) => { + await store.assign(identity, undefined, undefined, 'pool-default') + }) + + // The inventory-first retry is the lane that carries the caller's mode. + expect(requestModes).toContain(CELL_INVENTORY_LOCK_TIMEOUT_MS) + expect(sweepModes).not.toContain(CELL_INVENTORY_LOCK_TIMEOUT_MS) + expect(sweepModes.filter((mode) => mode === 'nowait').length).toBe( + requestModes.filter((mode) => mode === 'nowait').length + ) + }) + + it('sends the sweep that re-enters placement down the unbounded lane', async () => { + const database = await openInMemoryRelayDatabase() + const probe = new InventoryLockProbe(database) + let now = 1_000 + const store = new RelayAssignmentStore(probe, () => now, { + requireLiveCells: true, + heartbeatTtlMs: 45_000 + }) + await store.reconcileCells(CELLS) + for (const cell of CELLS) { + await store.recordCellHeartbeat({ + cellId: cell.id, + cellUrl: cell.url, + cellIncarnation: `1111111${cell.id.slice(-1)}-1111-4111-8111-111111111111`, + startedAt: 50, + ready: true, + observedRequests: 0 + }) + } + await store.assign(identity) + // Let every heartbeat lapse so the sweep sees the assigned cell as dead. + now += 45_001 + probe.inventoryLocks.length = 0 + probe.failActivityLockOnce = true + + await store.evacuateDeadCells() + + expect(probe.inventoryLocks).not.toEqual([]) + for (const options of probe.inventoryLocks) { + expect(options?.lockTimeoutMs).toBeUndefined() + } + await database.close() + }) + + // Why: the SQLite hold test cannot reach PostgresDatabase.transaction, which is + // the only path production ever takes. + it('records the hold on the PostgreSQL transaction path', async () => { + const database = await openFakePostgres() + + await database.transaction(async (transaction) => { + await transaction.queryLocked(CELL_INVENTORY_SQL, [], { + lockTimeoutMs: 150, + measureHoldMs: true + }) + }) + + expect(consumeRelayCellInventoryHold(database).cellInventoryHolds).toBe(1) + await database.close() + }) + + it('records no hold for a PostgreSQL transaction that took no measured lock', async () => { + const database = await openFakePostgres() + + await database.transaction(async (transaction) => { + await transaction.queryLocked(CELL_INVENTORY_SQL, [], { lockTimeoutMs: 150 }) + }) + + expect(consumeRelayCellInventoryHold(database).cellInventoryHolds).toBe(0) + await database.close() + }) + + // Why: index.ts boots a server on import, so its wiring can only be read. An + // unspread hold metric is invisible: the flush simply omits the fields. + it('spreads the hold counts into the runtime metrics flush', () => { + const source = readFileSync(new URL('./index.ts', import.meta.url), 'utf8') + const flush = /observability\.start\(\(\) => \(\{([^}]*)\}\)\)/.exec(source) + + expect(flush?.[1]).toContain('...consumeRelayCellInventoryHold(database)') + }) + + // Why: 500ms is a first value, not a measurement. Tuning it needs the hold + // distribution, which no runtime metric carried. + it('reports how long the inventory lock was held to COMMIT', async () => { + const database = await openInMemoryRelayDatabase() + const store = new RelayAssignmentStore(database, () => 1_000) + await store.reconcileCells(CELLS) + consumeRelayCellInventoryHold(database) + + await store.assign(identity) + + const counts = consumeRelayCellInventoryHold(database) + expect(counts.cellInventoryHolds).toBeGreaterThan(0) + expect(counts.cellInventoryHoldMsMax).toBeGreaterThanOrEqual(counts.cellInventoryHoldMsP95) + expect(counts.cellInventoryHoldMsMax).toBeGreaterThan(0) + // Consuming resets the window so the next flush reports its own holds. + expect(consumeRelayCellInventoryHold(database).cellInventoryHolds).toBe(0) + await database.close() + }) +}) + +// Why: the incident monitor freezes at zero exhausted transactions. A sweep that +// steps aside must not spend the retry budget or report a terminal failure. +describe('sweep lock skips stay off the transaction retry counters', () => { + it('reports neither a retry nor an exhaustion when NOWAIT finds the lock held', async () => { + const database = await openFakePostgres() + fakes.query.mockImplementation(async (sql: string) => { + fakes.statements.push(sql) + if (sql.includes('FOR UPDATE NOWAIT')) { + throw Object.assign(new Error('could not obtain lock'), { code: '55P03' }) + } + return { rows: [], rowCount: 0 } + }) + const events: string[] = [] + const warn = vi.spyOn(console, 'warn').mockImplementation((line: unknown) => { + try { + events.push(String((JSON.parse(line as string) as { event?: unknown }).event)) + } catch { + // non-JSON lines are not transaction telemetry + } + }) + + try { + await expect( + database.transaction(async (transaction) => { + await transaction.queryLocked(CELL_INVENTORY_SQL, [], { failIfUnavailable: true }) + }) + ).rejects.toThrow('database_lock_unavailable') + } finally { + warn.mockRestore() + } + + expect(events).not.toContain('orca_relay_postgres_transaction_retry') + expect(events).not.toContain('orca_relay_postgres_transaction_exhausted') + expect(fakes.statements.filter((sql) => sql === 'BEGIN')).toHaveLength(1) + await database.close() + }) +}) + +describe('background sweeps skip a contended cell inventory', () => { + it('takes the inventory NOWAIT and skips the tick instead of queueing', async () => { + const database = await openInMemoryRelayDatabase() + const probe = new InventoryLockProbe(database) + let now = 1_000 + const store = new RelayAssignmentStore(probe, () => now) + await store.reconcileCells(CELLS) + const assignment = await store.assign(identity) + await store.activateControl(identity, { + cellId: assignment.cellId, + assignmentEpoch: assignment.assignmentEpoch, + generation: 1 + }) + await store.startEvacuation(identity, 'cell-b') + now += 24 * 60 * 60_000 + probe.inventoryLocks.length = 0 + probe.failNoWait = true + const warnings = collectWarnings('orca_relay_sweep_cell_inventory_busy') + + let aborted: number + try { + aborted = await store.abortExpiredEvacuations() + } finally { + warnings.restore() + } + + expect(aborted).toBe(0) + expect(probe.inventoryLocks).not.toEqual([]) + expect(probe.inventoryLocks.every((options) => options?.failIfUnavailable === true)).toBe( + true + ) + expect(warnings.entries).toEqual([ + { event: 'orca_relay_sweep_cell_inventory_busy', sweep: 'abort-expired-evacuations', skipped: 1 } + ]) + await database.close() + }) + + // Why: a summary line on every quiet tick would bury the contended ones. + it('says nothing on a tick that skipped no candidate', async () => { + const database = await openInMemoryRelayDatabase() + let now = 1_000 + const store = new RelayAssignmentStore(database, () => now) + await store.reconcileCells(CELLS) + const assignment = await store.assign(identity) + await store.activateControl(identity, { + cellId: assignment.cellId, + assignmentEpoch: assignment.assignmentEpoch, + generation: 1 + }) + await store.startEvacuation(identity, 'cell-b') + now += 24 * 60 * 60_000 + const warnings = collectWarnings('orca_relay_sweep_cell_inventory_busy') + + let aborted: number + try { + aborted = await store.abortExpiredEvacuations() + } finally { + warnings.restore() + } + + expect(aborted).toBe(1) + expect(warnings.entries).toEqual([]) + await database.close() + }) + + it('still aborts the expired evacuation once the inventory is free', async () => { + const database = await openInMemoryRelayDatabase() + const probe = new InventoryLockProbe(database) + let now = 1_000 + const store = new RelayAssignmentStore(probe, () => now) + await store.reconcileCells(CELLS) + const assignment = await store.assign(identity) + await store.activateControl(identity, { + cellId: assignment.cellId, + assignmentEpoch: assignment.assignmentEpoch, + generation: 1 + }) + await store.startEvacuation(identity, 'cell-b') + now += 24 * 60 * 60_000 + + expect(await store.abortExpiredEvacuations()).toBe(1) + await database.close() + }) +}) + +// Returns each inventory lock the run took, as its bound or 'nowait'. +async function recordAssignInventoryModes( + drive: (store: InstanceType) => Promise +): Promise<(number | 'nowait' | 'pool-default')[]> { + const database = await openInMemoryRelayDatabase() + const probe = new InventoryLockProbe(database) + const store = new RelayAssignmentStore(probe, () => 1_000) + await store.reconcileCells(CELLS) + probe.inventoryLocks.length = 0 + probe.failActivityLockOnce = true + await drive(store) + await database.close() + return probe.inventoryLocks.map((options) => + options?.failIfUnavailable ? 'nowait' : (options?.lockTimeoutMs ?? 'pool-default') + ) +} + +function collectWarnings(event: string) { + const entries: Record[] = [] + const original = console.warn + console.warn = (line: unknown, ...rest: unknown[]) => { + try { + const parsed = JSON.parse(line as string) as Record + if (parsed.event === event) return void entries.push(parsed) + } catch { + // fall through to the real console for non-JSON lines + } + original(line, ...rest) + } + return { entries, restore: () => (console.warn = original) } +} + +const ACTIVITY_LEASE_SQL = 'SELECT * FROM relay_assignment_activity_leases' + +class InventoryLockProbe implements RelayDatabase { + readonly inventoryLocks: (RelayLockOptions | undefined)[] = [] + failNoWait = false + // Forces the next assign attempt down its inventory-first retry, the only lane + // that reaches the threaded lock mode. + failActivityLockOnce = false + + constructor(private readonly delegate: RelayDatabase) {} + + async query(sql: string, params?: unknown[]): Promise { + return await this.delegate.query(sql, params) + } + + async queryLocked( + sql: string, + params?: unknown[], + options?: RelayLockOptions + ): Promise { + if (locksCellInventory(sql)) { + this.inventoryLocks.push(options) + if (this.failNoWait && options?.failIfUnavailable) { + throw new Error('database_lock_unavailable') + } + } + if (this.failActivityLockOnce && sql.trim().startsWith(ACTIVITY_LEASE_SQL) && options?.failIfUnavailable) { + this.failActivityLockOnce = false + throw new Error('database_lock_unavailable') + } + return await this.delegate.queryLocked(sql, params, options) + } + + async transaction( + operation: (transaction: RelayDatabase) => Promise, + options?: RelayTransactionOptions + ): Promise { + return await this.delegate.transaction( + async (transaction) => await operation(new InventoryLockProbeTransaction(transaction, this)), + options + ) + } + + async close(): Promise {} +} + +class InventoryLockProbeTransaction implements RelayDatabase { + constructor( + private readonly delegate: RelayDatabase, + private readonly probe: InventoryLockProbe + ) {} + + async query(sql: string, params?: unknown[]): Promise { + return await this.delegate.query(sql, params) + } + + async queryLocked( + sql: string, + params?: unknown[], + options?: RelayLockOptions + ): Promise { + if (locksCellInventory(sql)) { + this.probe.inventoryLocks.push(options) + if (this.probe.failNoWait && options?.failIfUnavailable) { + throw new Error('database_lock_unavailable') + } + } + if ( + this.probe.failActivityLockOnce && + sql.trim().startsWith(ACTIVITY_LEASE_SQL) && + options?.failIfUnavailable + ) { + this.probe.failActivityLockOnce = false + throw new Error('database_lock_unavailable') + } + return await this.delegate.queryLocked(sql, params, options) + } + + async transaction(operation: (transaction: RelayDatabase) => Promise): Promise { + return await operation(this) + } + + async close(): Promise {} +} diff --git a/cloud/apps/relay/src/database-postgres-timeout.test.ts b/cloud/apps/relay/src/database-postgres-timeout.test.ts index a04a9eea042..7aba1234f7f 100644 --- a/cloud/apps/relay/src/database-postgres-timeout.test.ts +++ b/cloud/apps/relay/src/database-postgres-timeout.test.ts @@ -118,6 +118,39 @@ describe('PostgreSQL schema startup', () => { expect(query).toHaveBeenCalledTimes(2) }) + it.each([ + ['42710', 'CREATE TABLE IF NOT EXISTS test'], + ['42P07', 'CREATE TABLE IF NOT EXISTS test'], + ['42P07', 'CREATE INDEX IF NOT EXISTS test_index ON test(id)'], + ['42P07', 'CREATE UNIQUE INDEX IF NOT EXISTS test_index ON test(id)'] + ])('retries the committed-winner %s collision for %s', async (code, statement) => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const collision = Object.assign(new Error('already exists'), { code }) + const query = vi + .fn<(statement: string) => Promise>() + .mockRejectedValueOnce(collision) + .mockResolvedValue(undefined) + + await applyPostgresSchema([statement], query, { wait: async () => undefined }) + + expect(query).toHaveBeenCalledTimes(2) + }) + + it.each([ + ['42710', 'CREATE INDEX IF NOT EXISTS test_index ON test(id)'], + ['42710', 'CREATE TABLE test'], + ['42P07', 'CREATE TABLE test'], + ['42P07', 'CREATE INDEX test_index ON test(id)'] + ])('does not retry %s for %s', async (code, statement) => { + const error = Object.assign(new Error('already exists'), { code }) + const query = vi.fn<(statement: string) => Promise>().mockRejectedValue(error) + const pause = vi.fn(async () => undefined) + + await expect(applyPostgresSchema([statement], query, { wait: pause })).rejects.toBe(error) + + expect(pause).not.toHaveBeenCalled() + }) + it.each([ ['pg_type_typname_nsp_index', 'CREATE TABLE test'], ['pg_class_relname_nsp_index', 'CREATE INDEX test_index ON test(id)'] diff --git a/cloud/apps/relay/src/database.ts b/cloud/apps/relay/src/database.ts index f7208863f42..326ab010ccb 100644 --- a/cloud/apps/relay/src/database.ts +++ b/cloud/apps/relay/src/database.ts @@ -1,4 +1,5 @@ import { mkdirSync } from 'node:fs' +import { performance } from 'node:perf_hooks' import { join } from 'node:path' import { DatabaseSync } from 'node:sqlite' import pg from 'pg' @@ -8,9 +9,37 @@ import { type PostgresPoolPressureCounts } from './postgres-pool-pressure.js' import { applyPostgresSchema } from './postgres-schema-startup.js' +import { + CellInventoryHoldSamples, + emptyCellInventoryHoldCounts, + type CellInventoryHoldCounts +} from './cell-inventory-hold-samples.js' + +export const POSTGRES_LOCK_TIMEOUT_MS = 1_000 + +function setLocalLockTimeout(milliseconds: number): string { + if (!Number.isInteger(milliseconds) || milliseconds < 1) { + throw new Error('invalid_lock_timeout') + } + return `SET LOCAL lock_timeout = '${milliseconds}ms'` +} export type SqlRow = Record -export type RelayLockOptions = { failIfUnavailable?: boolean } +export type RelayLockOptions = { + failIfUnavailable?: boolean + // Only honoured inside a transaction: SET LOCAL is a no-op in autocommit. + lockTimeoutMs?: number + // Report how long this lock is held to COMMIT. The hold, not the wait, is what + // forms the queue, and nothing measured it before. + measureHoldMs?: boolean +} + +// A transaction that can report how long it held a measured lock before COMMIT. +type HoldMeasuringTransaction = { consumeHoldMs(): number | undefined } + +function measuredHoldMs(transaction: unknown): number | undefined { + return (transaction as HoldMeasuringTransaction).consumeHoldMs?.() +} export type RelayTransactionOptions = { reportRetries?: boolean } export interface RelayDatabase { @@ -612,9 +641,23 @@ function postgresTransactionErrorPhase(error: unknown): string { class SqliteTransaction implements RelayDatabase { readonly dialect = 'sqlite' as const + private heldFromMs: number | undefined constructor(protected readonly database: DatabaseSync) {} + consumeHoldMs(): number | undefined { + if (this.heldFromMs === undefined) return undefined + const holdMs = performance.now() - this.heldFromMs + this.heldFromMs = undefined + return holdMs + } + + protected noteHeld(options: RelayLockOptions): void { + if (options.measureHoldMs && this.heldFromMs === undefined) { + this.heldFromMs = performance.now() + } + } + async query(sql: string, params: unknown[] = []): Promise { const statement = this.database.prepare(sql) const bound = params.map((value) => (value === undefined ? null : value)) as never[] @@ -626,9 +669,11 @@ class SqliteTransaction implements RelayDatabase { async queryLocked( sql: string, params: unknown[] = [], - _options: RelayLockOptions = {} + options: RelayLockOptions = {} ): Promise { - return await this.query(sql, params) + const rows = await this.query(sql, params) + this.noteHeld(options) + return rows } async transaction( @@ -643,6 +688,11 @@ class SqliteTransaction implements RelayDatabase { class SqliteDatabase extends SqliteTransaction { private tail: Promise = Promise.resolve() + private readonly holds = new CellInventoryHoldSamples() + + consumeHoldCounts(): CellInventoryHoldCounts { + return this.holds.consumeCounts() + } override async query(sql: string, params: unknown[] = []): Promise { await this.tail @@ -655,9 +705,11 @@ class SqliteDatabase extends SqliteTransaction { this.tail = new Promise((resolve) => (release = resolve)) await previous this.database.exec('BEGIN IMMEDIATE') + const transaction = new SqliteTransaction(this.database) try { - const result = await operation(new SqliteTransaction(this.database)) + const result = await operation(transaction) this.database.exec('COMMIT') + this.holds.record(measuredHoldMs(transaction) ?? Number.NaN) return result } catch (error) { this.database.exec('ROLLBACK') @@ -675,9 +727,17 @@ class SqliteDatabase extends SqliteTransaction { class PostgresTransaction implements RelayDatabase { readonly dialect = 'postgres' as const + private heldFromMs: number | undefined constructor(protected readonly client: pg.PoolClient) {} + consumeHoldMs(): number | undefined { + if (this.heldFromMs === undefined) return undefined + const holdMs = performance.now() - this.heldFromMs + this.heldFromMs = undefined + return holdMs + } + async query(sql: string, params: unknown[] = []): Promise { try { const result = await this.client.query(postgresSql(sql), params) @@ -693,11 +753,21 @@ class PostgresTransaction implements RelayDatabase { params: unknown[] = [], options: RelayLockOptions = {} ): Promise { + // SET LOCAL lasts to COMMIT, so a bound left in place would silently govern + // every later locked statement in the transaction and misattribute its 55P03s. + const bounded = options.lockTimeoutMs !== undefined && !options.failIfUnavailable try { - return await this.query( + // A blocked waiter holds its pooled client for the whole lock_timeout, so + // hot tiny-table locks bound their own wait well under the pool default. + if (bounded) await this.query(setLocalLockTimeout(options.lockTimeoutMs!)) + const rows = await this.query( `${sql} FOR UPDATE${options.failIfUnavailable ? ' NOWAIT' : ''}`, params ) + if (options.measureHoldMs && this.heldFromMs === undefined) { + this.heldFromMs = performance.now() + } + return rows } catch (error) { if ( options.failIfUnavailable && @@ -706,6 +776,10 @@ class PostgresTransaction implements RelayDatabase { throw new Error('database_lock_unavailable') } throw error + } finally { + // Restore on the error path too: the transaction may still be retried or + // continue with unrelated locks after a caught lock failure. + if (bounded) await this.query(setLocalLockTimeout(POSTGRES_LOCK_TIMEOUT_MS)).catch(() => undefined) } } @@ -723,7 +797,6 @@ const POSTGRES_TRANSACTION_ATTEMPTS = 3 const POSTGRES_RETRY_MAX_DELAY_MS = 25 const POSTGRES_CONNECTION_TIMEOUT_MS = 2_000 const POSTGRES_STATEMENT_TIMEOUT_MS = 5_000 -const POSTGRES_LOCK_TIMEOUT_MS = 1_000 const POSTGRES_IDLE_TRANSACTION_TIMEOUT_MS = 5_000 function retryablePostgresTransactionError(error: unknown): boolean { @@ -749,6 +822,11 @@ async function waitForPostgresRetry(random: () => number = Math.random): Promise class PostgresDatabase implements RelayDatabase { readonly dialect = 'postgres' as const private readonly pressure: PostgresPoolPressure + private readonly holds = new CellInventoryHoldSamples() + + consumeHoldCounts(): CellInventoryHoldCounts { + return this.holds.consumeCounts() + } constructor(private readonly pool: pg.Pool) { this.pressure = new PostgresPoolPressure(pool) @@ -770,6 +848,8 @@ class PostgresDatabase implements RelayDatabase { options: RelayLockOptions = {} ): Promise { try { + // No transaction here, so options.lockTimeoutMs cannot apply: SET LOCAL + // would be discarded at the autocommit boundary before the lock is taken. return await this.query( `${sql} FOR UPDATE${options.failIfUnavailable ? ' NOWAIT' : ''}`, params @@ -791,10 +871,12 @@ class PostgresDatabase implements RelayDatabase { ): Promise { for (let attempt = 1; attempt <= POSTGRES_TRANSACTION_ATTEMPTS; attempt++) { const client = await this.pressure.connect() + const transaction = new PostgresTransaction(client) try { await client.query('BEGIN') - const result = await operation(new PostgresTransaction(client)) + const result = await operation(transaction) await client.query('COMMIT') + this.holds.record(measuredHoldMs(transaction) ?? Number.NaN) return result } catch (error) { await client.query('ROLLBACK').catch(() => undefined) @@ -852,6 +934,13 @@ export function consumeRelayDatabasePoolPressure( : emptyPostgresPoolPressureCounts() } +export function consumeRelayCellInventoryHold( + database: RelayDatabase +): CellInventoryHoldCounts { + const holder = database as { consumeHoldCounts?: () => CellInventoryHoldCounts } + return holder.consumeHoldCounts?.() ?? emptyCellInventoryHoldCounts() +} + export function readRelayDatabasePoolPressure( database: RelayDatabase ): PostgresPoolPressureCounts { diff --git a/cloud/apps/relay/src/index.ts b/cloud/apps/relay/src/index.ts index 635f3ae9b38..541884362c2 100644 --- a/cloud/apps/relay/src/index.ts +++ b/cloud/apps/relay/src/index.ts @@ -10,12 +10,14 @@ import { roleOwnsAssignmentMaintenance } from './cell-admission-startup.js' import { + consumeRelayCellInventoryHold, consumeRelayDatabasePoolPressure, openRelayDatabase, readRelayDatabasePoolPressure } from './database.js' import { runAssignmentCleanup } from './assignment-cleanup-steps.js' import { runRelayBackgroundOperation } from './relay-background-operation.js' +import { jitteredSweepIntervalMs } from './relay-sweep-schedule.js' import { observedRelayRequests } from './relay-observability.js' import { startRegionalRehomeWorker } from './regional-rehome-worker.js' import { createRelayServer } from './relay-server.js' @@ -54,7 +56,7 @@ const cleanupTimer = setInterval( const assignmentCleanupTimer = roleOwnsAssignmentMaintenance(config.role) ? setInterval(() => { void runAssignmentCleanup(assignments) - }, 30_000) + }, jitteredSweepIntervalMs(30_000)) : null const inventorySnapshotTimer = roleOwnsAssignmentMaintenance(config.role) ? setInterval(() => { @@ -78,7 +80,8 @@ inventorySnapshotTimer?.unref() migrationInventoryTimer?.unref() observability.start(() => ({ ...runtimeCounts(), - ...consumeRelayDatabasePoolPressure(database) + ...consumeRelayDatabasePoolPressure(database), + ...consumeRelayCellInventoryHold(database) })) const regionalRehomeWorker = startRegionalRehomeWorker(config, assignments, { safetySnapshot: () => ({ diff --git a/cloud/apps/relay/src/postgres-schema-concurrency-postgres.test.ts b/cloud/apps/relay/src/postgres-schema-concurrency-postgres.test.ts index 5208f137ae8..9ed3cb2f324 100644 --- a/cloud/apps/relay/src/postgres-schema-concurrency-postgres.test.ts +++ b/cloud/apps/relay/src/postgres-schema-concurrency-postgres.test.ts @@ -34,22 +34,28 @@ describePostgres('PostgreSQL schema concurrency', () => { }) it('opens five directors when one new table is absent', async () => { - const initial = await openRelayDatabase({ databaseUrl: scopedUrl, dataDir: '' }) - await initial.query(`DROP TABLE relay_cell_legacy_fence_adoptions`) - await initial.close() + // Which catalog step the race loser fails on depends on scheduling, so run several rounds and + // keep the loser's SQLSTATE in the failure instead of a bare boolean. + for (let round = 0; round < 10; round += 1) { + const initial = await openRelayDatabase({ databaseUrl: scopedUrl, dataDir: '' }) + await initial.query(`DROP TABLE relay_cell_legacy_fence_adoptions`) + await initial.close() - const results = await Promise.allSettled( - Array.from({ length: 5 }, async (): Promise => - await openRelayDatabase({ databaseUrl: scopedUrl, dataDir: '' }) + const results = await Promise.allSettled( + Array.from({ length: 5 }, async (): Promise => + await openRelayDatabase({ databaseUrl: scopedUrl, dataDir: '' }) + ) + ) + const databases = results.flatMap((result) => + result.status === 'fulfilled' ? [result.value] : [] ) - ) - const databases = results.flatMap((result) => - result.status === 'fulfilled' ? [result.value] : [] - ) - try { - expect(results.every((result) => result.status === 'fulfilled')).toBe(true) - } finally { await Promise.all(databases.map(async (database) => await database.close())) + const rejections = results.flatMap((result) => + result.status === 'rejected' + ? [{ round, code: (result.reason as { code?: unknown }).code, message: String(result.reason) }] + : [] + ) + expect(rejections).toEqual([]) } - }) + }, 60_000) }) diff --git a/cloud/apps/relay/src/postgres-schema-startup.ts b/cloud/apps/relay/src/postgres-schema-startup.ts index 5e6260ad2fb..ba9efc6a792 100644 --- a/cloud/apps/relay/src/postgres-schema-startup.ts +++ b/cloud/apps/relay/src/postgres-schema-startup.ts @@ -22,15 +22,37 @@ function wait(delayMs: number): Promise { return new Promise((resolve) => setTimeout(resolve, delayMs)) } +const CREATE_TABLE_IF_NOT_EXISTS = /^\s*CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\b/i +const CREATE_INDEX_IF_NOT_EXISTS = /^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+IF\s+NOT\s+EXISTS\b/i + +// `IF NOT EXISTS` only checks the name before the catalog inserts, so the loser of a concurrent +// CREATE can fail on the catalog unique index (23505) or, when the winner has already committed by +// the time the loser reaches TypeCreate/heap_create_with_catalog, on the name check those routines +// repeat (42710 duplicate type, 42P07 duplicate relation). Each is a no-op on the next attempt. +function concurrentCreateCollision( + value: { code?: unknown; constraint?: unknown }, + statement: string +): boolean { + if (CREATE_TABLE_IF_NOT_EXISTS.test(statement)) { + return ( + (value.code === '23505' && value.constraint === 'pg_type_typname_nsp_index') || + value.code === '42710' || + value.code === '42P07' + ) + } + if (CREATE_INDEX_IF_NOT_EXISTS.test(statement)) { + return ( + (value.code === '23505' && value.constraint === 'pg_class_relname_nsp_index') || + value.code === '42P07' + ) + } + return false +} + function retryableSchemaError(error: unknown, statement: string): boolean { const value = error as { code?: unknown; constraint?: unknown } return ( - RETRYABLE_SCHEMA_CODES.has(String(value.code)) || - (value.code === '23505' && - ((value.constraint === 'pg_type_typname_nsp_index' && - /^\s*CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\b/i.test(statement)) || - (value.constraint === 'pg_class_relname_nsp_index' && - /^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+IF\s+NOT\s+EXISTS\b/i.test(statement)))) + RETRYABLE_SCHEMA_CODES.has(String(value.code)) || concurrentCreateCollision(value, statement) ) } diff --git a/cloud/apps/relay/src/regional-rehome-store.test.ts b/cloud/apps/relay/src/regional-rehome-store.test.ts index 43f293b1131..26f711189ee 100644 --- a/cloud/apps/relay/src/regional-rehome-store.test.ts +++ b/cloud/apps/relay/src/regional-rehome-store.test.ts @@ -5,7 +5,12 @@ import { REGIONAL_REHOME_QUARANTINE_MS, REGIONAL_REHOME_REDRAIN_SEND_LIMIT } from './assignment-store.js' -import { openInMemoryRelayDatabase, type RelayDatabase, type SqlRow } from './database.js' +import { + openInMemoryRelayDatabase, + type RelayDatabase, + type RelayLockOptions, + type SqlRow +} from './database.js' import { REGIONAL_REHOME_SQL_FAILURES_LIMIT, REGIONAL_REHOME_SQL_FAILURES_PER_CELL_LIMIT @@ -556,6 +561,341 @@ describe('regional rehome assignment state', () => { await context.database.close() }) + it('skips a rehome dispatch tick on a contended cell inventory', async () => { + const probe = new CellInventoryLockProbe() + const context = await setup({ wrap: (database) => probe.wrap(database) }) + const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } + await activatePreferredSource(context, identity) + probe.reset() + probe.failNoWait = true + const busy = collectEventWarnings('orca_relay_sweep_cell_inventory_busy') + + let attempt: unknown + try { + attempt = await context.store.claimRegionalRehome() + } finally { + busy.restore() + } + + expect(attempt).toBeNull() + expect(probe.locks).not.toEqual([]) + expect(probe.locks.every((options) => options?.failIfUnavailable === true)).toBe(true) + expect(busy.entries).toEqual([ + { + event: 'orca_relay_sweep_cell_inventory_busy', + sweep: 'claim-regional-rehome', + skipped: 1 + } + ]) + + probe.failNoWait = false + expect(await context.store.claimRegionalRehome()).toMatchObject({ + sourceCellId: source.id, + targetCellId: target.id + }) + await context.database.close() + }) + + // Why: the redrain lane reaches the inventory through the fleet-safety read + // rather than through candidate selection, so it needs its own coverage. + // Why: one contended candidate must cost its own tick, not the whole page. The + // sweeps are explicitly per-candidate isolated for exactly this reason. + it('completes the candidates behind a contended one', async () => { + const probe = new CellInventoryLockProbe() + const context = await setup({ wrap: (database) => probe.wrap(database) }) + const identities = [ + { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }, + { userId: 'user-2', relayHostId: 'ponmlkjihgfedcba' } + ] + for (const identity of identities) { + // Dispatch is rate limited, so each claim needs its own interval. + context.advance(60_000) + await freshHeartbeats(context) + const sourceControl = await activatePreferredSource(context, identity) + const attempt = await context.store.claimRegionalRehome() + await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') + await context.store.activateControl(identity, { + cellId: target.id, + assignmentEpoch: 2, + generation: 1 + }) + await context.store.markMigrationTargetRegistered(identity, { + cellId: target.id, + assignmentEpoch: 2 + }) + await context.store.releaseActivity(identity, sourceControl) + } + probe.reset() + probe.failNoWaitTimes = 1 + const busy = collectEventWarnings('orca_relay_sweep_cell_inventory_busy') + + let completed: number + try { + completed = await context.store.completeReadyRegionalRehomes() + } finally { + busy.restore() + } + + expect(completed).toBe(1) + expect(busy.entries).toEqual([ + { + event: 'orca_relay_sweep_cell_inventory_busy', + sweep: 'complete-ready-regional-rehomes', + skipped: 1 + } + ]) + await context.database.close() + }) + + // Why: with `continue` replaced by `break` a single contended candidate drops + // the rest of the page. Two in a row prove the sweep resumes, not just that it + // survived one, and that the summary counts both. + it('completes a candidate behind two contended ones', async () => { + const probe = new CellInventoryLockProbe() + const context = await setup({ wrap: (database) => probe.wrap(database) }) + const identities = [ + { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }, + { userId: 'user-2', relayHostId: 'ponmlkjihgfedcba' }, + { userId: 'user-3', relayHostId: 'aaaabbbbccccdddd' } + ] + for (const identity of identities) { + // Dispatch is rate limited, so each claim needs its own interval. + context.advance(60_000) + await freshHeartbeats(context) + const sourceControl = await activatePreferredSource(context, identity) + const attempt = await context.store.claimRegionalRehome() + await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') + await context.store.activateControl(identity, { + cellId: target.id, + assignmentEpoch: 2, + generation: 1 + }) + await context.store.markMigrationTargetRegistered(identity, { + cellId: target.id, + assignmentEpoch: 2 + }) + await context.store.releaseActivity(identity, sourceControl) + } + probe.reset() + probe.failNoWaitTimes = 2 + const busy = collectEventWarnings('orca_relay_sweep_cell_inventory_busy') + + let completed: number + try { + completed = await context.store.completeReadyRegionalRehomes() + } finally { + busy.restore() + } + + expect(completed).toBe(1) + expect(busy.entries).toEqual([ + { + event: 'orca_relay_sweep_cell_inventory_busy', + sweep: 'complete-ready-regional-rehomes', + skipped: 2 + } + ]) + await context.database.close() + }) + + // Why: only inventory contention is ordinary. Every other failure must keep its + // existing propagation and its dispatch-failure accounting. + it('propagates a claim failure that is not inventory contention', async () => { + const probe = new CellInventoryLockProbe() + const context = await setup({ wrap: (database) => probe.wrap(database) }) + await activatePreferredSource(context, { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }) + probe.reset() + probe.failWith = new Error('relay_capacity_exhausted') + const busy = collectEventWarnings('orca_relay_sweep_cell_inventory_busy') + + try { + await expect(context.store.claimRegionalRehome()).rejects.toThrow( + 'relay_capacity_exhausted' + ) + } finally { + busy.restore() + } + + expect(busy.entries).toEqual([]) + await context.database.close() + }) + + // Why: the transaction dies at the first contended candidate, so every + // candidate behind it is abandoned too. Reporting one would understate the tick. + it('reports every candidate the contended tick abandoned', async () => { + const probe = new CellInventoryLockProbe() + const context = await setup({ wrap: (database) => probe.wrap(database) }) + await activatePreferredSource(context, { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }) + await activatePreferredSource(context, { userId: 'user-2', relayHostId: 'ponmlkjihgfedcba' }) + await activatePreferredSource(context, { userId: 'user-3', relayHostId: 'aaaabbbbccccdddd' }) + probe.reset() + probe.failNoWait = true + const busy = collectEventWarnings('orca_relay_sweep_cell_inventory_busy') + + try { + expect(await context.store.claimRegionalRehome()).toBeNull() + } finally { + busy.restore() + } + + expect(busy.entries).toEqual([ + { + event: 'orca_relay_sweep_cell_inventory_busy', + sweep: 'claim-regional-rehome', + skipped: 3 + } + ]) + await context.database.close() + }) + + it('skips a redrain tick on a contended cell inventory', async () => { + const probe = new CellInventoryLockProbe() + const context = await setup({ wrap: (database) => probe.wrap(database) }) + const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } + await activatePreferredSource(context, identity) + const attempt = await context.store.claimRegionalRehome() + await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') + await context.store.activateControl(identity, { + cellId: target.id, + assignmentEpoch: 2, + generation: 1 + }) + await context.store.markMigrationTargetRegistered(identity, { + cellId: target.id, + assignmentEpoch: 2 + }) + context.advance(60 * 60_000 + 1) + await freshHeartbeats(context) + probe.reset() + probe.failNoWait = true + const busy = collectEventWarnings('orca_relay_sweep_cell_inventory_busy') + + let redrain: unknown + try { + redrain = await context.store.claimRegionalRehome() + } finally { + busy.restore() + } + + expect(redrain).toBeNull() + expect(probe.locks).not.toEqual([]) + expect(probe.locks.every((options) => options?.failIfUnavailable === true)).toBe(true) + expect(busy.entries).toEqual([ + { + event: 'orca_relay_sweep_cell_inventory_busy', + sweep: 'claim-regional-rehome', + skipped: 1 + } + ]) + + probe.failNoWait = false + expect(await context.store.claimRegionalRehome()).toMatchObject({ + attemptId: attempt!.attemptId, + sendAttempts: 2 + }) + await context.database.close() + }) + + it('skips a completion tick on a contended cell inventory without quarantining it', async () => { + const probe = new CellInventoryLockProbe() + const context = await setup({ wrap: (database) => probe.wrap(database) }) + const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } + const sourceControl = await activatePreferredSource(context, identity) + const attempt = await context.store.claimRegionalRehome() + await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') + await context.store.activateControl(identity, { + cellId: target.id, + assignmentEpoch: 2, + generation: 1 + }) + await context.store.markMigrationTargetRegistered(identity, { + cellId: target.id, + assignmentEpoch: 2 + }) + await context.store.releaseActivity(identity, sourceControl) + probe.reset() + probe.failNoWait = true + const busy = collectEventWarnings('orca_relay_sweep_cell_inventory_busy') + const failures = collectCandidateFailureWarnings() + + let completed: number + try { + completed = await context.store.completeReadyRegionalRehomes() + } finally { + failures.restore() + busy.restore() + } + + expect(completed).toBe(0) + expect(probe.locks).not.toEqual([]) + expect(probe.locks.every((options) => options?.failIfUnavailable === true)).toBe(true) + expect(failures.entries).toEqual([]) + expect(busy.entries).toEqual([ + { + event: 'orca_relay_sweep_cell_inventory_busy', + sweep: 'complete-ready-regional-rehomes', + skipped: 1 + } + ]) + + probe.failNoWait = false + expect(await context.store.completeReadyRegionalRehomes()).toBe(1) + await context.database.close() + }) + + // Why: a contended inventory is another director settling the same row, not a + // poisoned candidate. Quarantining on it would exclude a healthy attempt from + // the sweep's LIMIT pages for 15 minutes. + it('skips an abort tick on a contended cell inventory without quarantining it', async () => { + const probe = new CellInventoryLockProbe() + const context = await setup({ wrap: (database) => probe.wrap(database) }) + const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } + const sourceControl = await activatePreferredSource(context, identity) + const attempt = await context.store.claimRegionalRehome() + await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') + const targetControl = await context.store.activateControl(identity, { + cellId: target.id, + assignmentEpoch: 2, + generation: 1 + }) + await context.store.markMigrationTargetRegistered(identity, { + cellId: target.id, + assignmentEpoch: 2 + }) + await context.store.releaseActivity(identity, sourceControl) + await context.store.releaseActivity(identity, targetControl) + context.advance(24 * 60 * 60_000) + await heartbeat(context.store, source, sourceIncarnation, 1, 2) + probe.reset() + probe.failNoWait = true + const busy = collectEventWarnings('orca_relay_sweep_cell_inventory_busy') + const failures = collectCandidateFailureWarnings() + + let aborted: number + try { + aborted = await context.store.abortExpiredRegionalRehomes() + } finally { + failures.restore() + busy.restore() + } + + expect(aborted).toBe(0) + expect(probe.locks).not.toEqual([]) + expect(probe.locks.every((options) => options?.failIfUnavailable === true)).toBe(true) + expect(failures.entries).toEqual([]) + expect(busy.entries).toEqual([ + { + event: 'orca_relay_sweep_cell_inventory_busy', + sweep: 'abort-expired-regional-rehomes', + skipped: 1 + } + ]) + + probe.failNoWait = false + expect(await context.store.abortExpiredRegionalRehomes()).toBe(1) + await context.database.close() + }) + it('rolls back an inactive registered target only after the 24-hour bound', async () => { const context = await setup() const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } @@ -1208,10 +1548,12 @@ function collectDisableWarnings() { } } -async function setup(options: { sourceProtocol?: number } = {}) { +async function setup( + options: { sourceProtocol?: number; wrap?: (database: RelayDatabase) => RelayDatabase } = {} +) { let clock = 1_000_000 const database = await openInMemoryRelayDatabase() - const store = new RelayAssignmentStore(database, () => clock, { + const store = new RelayAssignmentStore(options.wrap?.(database) ?? database, () => clock, { requireLiveCells: true, heartbeatTtlMs: 45_000 }) @@ -1397,3 +1739,43 @@ async function heartbeat( } }) } + +class CellInventoryLockProbe { + readonly locks: (RelayLockOptions | undefined)[] = [] + failNoWait = false + // Contends the first N candidates only, so the sweep must carry on past them. + failNoWaitTimes = 0 + failWith: Error | null = null + + reset(): void { + this.locks.length = 0 + } + + wrap(database: RelayDatabase): RelayDatabase { + const probe = this + const decorate = (delegate: RelayDatabase): RelayDatabase => ({ + query: async (sql, params) => await delegate.query(sql, params), + queryLocked: async (sql, params, options) => { + if (sql.trim() === 'SELECT * FROM relay_cells ORDER BY cell_id ASC') { + probe.locks.push(options) + if (probe.failWith) throw probe.failWith + if (options?.failIfUnavailable && probe.failNoWaitTimes > 0) { + probe.failNoWaitTimes-- + throw new Error('database_lock_unavailable') + } + if (probe.failNoWait && options?.failIfUnavailable) { + throw new Error('database_lock_unavailable') + } + } + return await delegate.queryLocked(sql, params, options) + }, + transaction: async (operation, options) => + await delegate.transaction( + async (transaction) => await operation(decorate(transaction)), + options + ), + close: async () => undefined + }) + return decorate(database) + } +} diff --git a/cloud/apps/relay/src/regional-rehome-worker.ts b/cloud/apps/relay/src/regional-rehome-worker.ts index 97c63a61025..47a2748cff4 100644 --- a/cloud/apps/relay/src/regional-rehome-worker.ts +++ b/cloud/apps/relay/src/regional-rehome-worker.ts @@ -3,6 +3,7 @@ import type { RelayAssignmentStore } from './assignment-store.js' import type { RelayConfig } from './config.js' import { googleMetadataIdentityToken } from './google-metadata-identity-token.js' import type { RegionalRehomeSafetySnapshot } from './relay-observability.js' +import { jitteredSweepIntervalMs } from './relay-sweep-schedule.js' type RegionalRehomeWorkerOptions = { fetch?: typeof fetch @@ -10,6 +11,7 @@ type RegionalRehomeWorkerOptions = { now?: () => number intervalMs?: number requestTimeoutMs?: number + random?: () => number safetySnapshot?: () => RegionalRehomeSafetySnapshot } @@ -109,7 +111,10 @@ export function startRegionalRehomeWorker( inFlight = false } } - const timer = setInterval(() => void run(), options.intervalMs ?? 1_000) + const timer = setInterval( + () => void run(), + options.intervalMs ?? jitteredSweepIntervalMs(1_000, options.random) + ) timer.unref() void run() return { diff --git a/cloud/apps/relay/src/relay-observability.ts b/cloud/apps/relay/src/relay-observability.ts index 6125ede8d1a..2266217d607 100644 --- a/cloud/apps/relay/src/relay-observability.ts +++ b/cloud/apps/relay/src/relay-observability.ts @@ -1,6 +1,7 @@ import { monitorEventLoopDelay, performance } from 'node:perf_hooks' import type { RelayRegion } from '@orca-cloud/relay-contract' import type { ControlRenewalOutcome } from './assignment-store.js' +import type { CellInventoryHoldCounts } from './cell-inventory-hold-samples.js' import type { PostgresPoolPressureCounts } from './postgres-pool-pressure.js' import type { RelayReadinessObservation } from './relay-readiness.js' @@ -20,7 +21,9 @@ export function observedRelayRequests(counts: RelayRuntimeCounts): number { return counts.preAuthConnections + counts.controls + counts.splices + counts.pendingSplices } -export type RelayProcessCounts = RelayRuntimeCounts & PostgresPoolPressureCounts +export type RelayProcessCounts = RelayRuntimeCounts & + PostgresPoolPressureCounts & + Partial export type RegionalRehomeRuntimeSafety = { observedAt: number diff --git a/cloud/apps/relay/src/relay-server.ts b/cloud/apps/relay/src/relay-server.ts index a14240cfa6a..32a83962d81 100644 --- a/cloud/apps/relay/src/relay-server.ts +++ b/cloud/apps/relay/src/relay-server.ts @@ -31,6 +31,16 @@ import { createRelayTokenVerifier, readBearer } from './relay-token-verifier.js' import { closeRelayWebSocket } from './relay-websocket-close.js' import { ProcessQueuedByteBudget } from './splice-forwarder.js' +// A malformed percent-escape in the request target must be a client error, never a URIError +// thrown out of the `upgrade` listener (which is uncaught and kills the process). +function decodePathSegment(value: string): string | null { + try { + return decodeURIComponent(value) + } catch { + return null + } +} + function rejectUpgrade(socket: NodeJS.WritableStream, status: number, message: string): void { socket.write(`HTTP/1.1 ${status} ${message}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`) if ('destroy' in socket && typeof socket.destroy === 'function') socket.destroy() @@ -278,8 +288,8 @@ export function createRelayServer( return } if (url.pathname.startsWith('/v1/connect/')) { - const hostId = decodeURIComponent(url.pathname.slice('/v1/connect/'.length)) - if (!/^[A-Za-z0-9_-]{16}$/.test(hostId)) { + const hostId = decodePathSegment(url.pathname.slice('/v1/connect/'.length)) + if (hostId === null || !/^[A-Za-z0-9_-]{16}$/.test(hostId)) { rejectUpgrade(socket, 429, 'Too Many Requests') return } @@ -373,7 +383,7 @@ export function createRelayServer( rejectUpgrade(socket, 404, 'Not Found') return } - const connId = decodeURIComponent(url.pathname.slice('/v1/host/data/'.length)) + const connId = decodePathSegment(url.pathname.slice('/v1/host/data/'.length)) if (!connId || connId.length > 128) { rejectUpgrade(socket, 429, 'Too Many Requests') return diff --git a/cloud/apps/relay/src/relay-sweep-schedule.test.ts b/cloud/apps/relay/src/relay-sweep-schedule.test.ts new file mode 100644 index 00000000000..d5ef450cc43 --- /dev/null +++ b/cloud/apps/relay/src/relay-sweep-schedule.test.ts @@ -0,0 +1,55 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it, vi } from 'vitest' +import { startRegionalRehomeWorker } from './regional-rehome-worker.js' +import { jitteredSweepIntervalMs, SWEEP_JITTER_FRACTION } from './relay-sweep-schedule.js' + +describe('sweep schedule jitter', () => { + it('spreads instances across a bounded window above the base period', () => { + expect(jitteredSweepIntervalMs(30_000, () => 0)).toBe(30_000) + expect(jitteredSweepIntervalMs(30_000, () => 0.5)).toBe(33_000) + // Math.random() never returns 1, so the open bound is the real ceiling. + expect(jitteredSweepIntervalMs(30_000, () => 0.999)).toBeLessThan(36_000) + }) + + // Why: a shorter period would raise the very lock traffic the offset spreads. + it('never schedules a sweep sooner than its base period', () => { + for (const random of [0, 0.25, 0.5, 0.75, 0.999]) { + expect(jitteredSweepIntervalMs(1_000, () => random)).toBeGreaterThanOrEqual(1_000) + } + expect(SWEEP_JITTER_FRACTION).toBeGreaterThan(0) + }) + + it('jitters the regional rehome dispatch tick, which every director runs each second', () => { + const timers: number[] = [] + const setIntervalSpy = vi + .spyOn(globalThis, 'setInterval') + .mockImplementation(((_handler: unknown, delayMs?: number) => { + timers.push(delayMs ?? 0) + return { unref: () => undefined, [Symbol.dispose]: () => undefined } as never + }) as never) + + try { + startRegionalRehomeWorker( + { + role: 'director', + rehomeAudience: 'https://rehome.example.test', + rehomeDirectorServiceAccount: 'rehome@example.test' + } as never, + { claimRegionalRehome: async () => null } as never, + { random: () => 0.5, safetySnapshot: () => ({}) as never } + ) + } finally { + setIntervalSpy.mockRestore() + } + + expect(timers).toEqual([1_100]) + }) + + // Why: index.ts boots a server on import, so its wiring can only be read. + it('jitters the director assignment cleanup tick', () => { + const source = readFileSync(new URL('./index.ts', import.meta.url), 'utf8') + const cleanup = /runAssignmentCleanup\(assignments\)\s*\},\s*([^\n]*?)\)\n/.exec(source) + + expect(cleanup?.[1]).toBe('jitteredSweepIntervalMs(30_000)') + }) +}) diff --git a/cloud/apps/relay/src/relay-sweep-schedule.ts b/cloud/apps/relay/src/relay-sweep-schedule.ts new file mode 100644 index 00000000000..f73e6b69ead --- /dev/null +++ b/cloud/apps/relay/src/relay-sweep-schedule.ts @@ -0,0 +1,13 @@ +// Why: every director instance boots from the same rollout, so its periodic +// sweeps land on the same wall-clock second across instances and pile onto the +// one global cell-inventory lock together. A per-process offset spreads the +// arrivals; the sweeps are idempotent, so a slightly longer period is free. +export const SWEEP_JITTER_FRACTION = 0.2 + +export function jitteredSweepIntervalMs( + baseMs: number, + random: () => number = Math.random +): number { + // Only ever longer: a shorter period would raise the very load being spread. + return baseMs + Math.floor(random() * baseMs * SWEEP_JITTER_FRACTION) +} diff --git a/cloud/apps/relay/src/relay-upgrade-malformed-uri.blackbox.test.ts b/cloud/apps/relay/src/relay-upgrade-malformed-uri.blackbox.test.ts new file mode 100644 index 00000000000..24635a68e6f --- /dev/null +++ b/cloud/apps/relay/src/relay-upgrade-malformed-uri.blackbox.test.ts @@ -0,0 +1,122 @@ +import { connect, createServer as createNetServer } from 'node:net' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RelayConfig } from './config.js' +import type { RelayDatabase } from './database.js' +import { createRelayServer } from './relay-server.js' + +async function unusedPort(): Promise { + const server = createNetServer() + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('missing test port') + await new Promise((resolve) => server.close(() => resolve())) + return address.port +} + +function rawUpgrade(port: number, target: string): Promise<{ status: string; closed: boolean }> { + return new Promise((resolve, reject) => { + const socket = connect(port, '127.0.0.1') + let data = '' + socket.once('connect', () => { + socket.write( + `GET ${target} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: Upgrade\r\n` + + 'Upgrade: websocket\r\nSec-WebSocket-Version: 13\r\n' + + // RFC 6455 §1.3 example nonce; allowlisted in cloud/.gitleaks.toml. + 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n' + ) + }) + socket.on('data', (chunk) => { + data += chunk.toString() + }) + socket.once('close', () => resolve({ status: data.split('\r\n')[0] ?? '', closed: true })) + socket.once('error', reject) + setTimeout(() => { + socket.destroy() + resolve({ status: data.split('\r\n')[0] ?? '', closed: false }) + }, 1_500).unref() + }) +} + +describe('relay upgrade with a malformed request target', () => { + const cleanup: Array<() => Promise | void> = [] + + afterEach(async () => { + for (const close of cleanup.splice(0).reverse()) await close() + vi.restoreAllMocks() + }) + + it('rejects an undecodable /v1/connect path without an uncaught exception', async () => { + const port = await unusedPort() + const relayUrl = `http://127.0.0.1:${port}` + const database: RelayDatabase = { + query: vi.fn(async () => []), + queryLocked: vi.fn(async () => []), + transaction: vi.fn(async (operation) => await operation(database)), + close: vi.fn(async () => undefined) + } + const config = { + port, + publicUrl: relayUrl, + cellUrl: relayUrl, + authIssuer: 'https://auth.example.com', + authAudience: 'orca-relay', + jwksUrl: 'https://auth.example.com/jwks', + assignmentSigningKey: new Uint8Array(32), + role: 'cell', + cellId: 'production-gce-c3', + cells: [{ id: 'production-gce-c3', url: relayUrl, capacityRequests: 4_000 }], + adminAudience: `${relayUrl}/admin`, + deployServiceAccount: 'deploy@example.com', + runtimeServiceAccount: 'runtime@example.com', + connectionHardCap: 600, + connectionUnobservedBound: 60, + adminJwksUrl: 'https://auth.example.com/admin-jwks', + databasePoolMax: 10, + publicAssignmentsEnabled: true, + publicAssignmentConcurrency: 2, + publicAssignmentQueueMax: 128, + publicAssignmentWaitMs: 4_000, + publicResolveConcurrency: 1, + publicResolveWaitMs: 5_000, + publicAssignmentRetryAfterSeconds: 5, + dataDir: './test-data' + } satisfies RelayConfig + const relay = createRelayServer(config, database, { + connectionLedgerLimits: { hardCap: 5, controlReserve: 1 } + }) + relay.server.listen(port, '127.0.0.1') + await new Promise((resolve) => relay.server.once('listening', resolve)) + cleanup.push(() => new Promise((resolve) => relay.server.close(() => resolve()))) + vi.spyOn(console, 'log').mockImplementation(() => undefined) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + // Vitest installs its own uncaughtException listener; capture ours first so the test reports + // the exception as a verdict instead of dying with it. + const uncaught: unknown[] = [] + const onUncaught = (error: unknown): void => { + uncaught.push(error) + } + process.prependListener('uncaughtException', onUncaught) + cleanup.push(() => { + process.off('uncaughtException', onUncaught) + }) + + const results = [] + for (const target of [ + '/v1/connect/%', + '/v1/connect/%E0%A4%A', + '/v1/connect/%C0%AF', + '/v1/host/data/%' + ]) { + results.push(await rawUpgrade(port, target)) + } + // A malformed percent-escape must be a client error, never a process-level throw. + expect(uncaught).toEqual([]) + for (const result of results) { + expect(result.status).toMatch(/^HTTP\/1\.1 4\d\d/) + } + // The server must still serve a well-formed upgrade afterwards. + const after = await rawUpgrade(port, '/v1/connect/abcdefghijklmnop') + expect(after.status).toMatch(/^HTTP\/1\.1 101/) + }) +}) diff --git a/cloud/dev/scripts/relay-regional-rehome-workflow.test.mjs b/cloud/dev/scripts/relay-regional-rehome-workflow.test.mjs index 5eab757255a..e31403f6dd6 100644 --- a/cloud/dev/scripts/relay-regional-rehome-workflow.test.mjs +++ b/cloud/dev/scripts/relay-regional-rehome-workflow.test.mjs @@ -191,3 +191,10 @@ test('director rollout has a strict one-time identity bootstrap', () => { assert.ok(candidateProof > 0 && candidateProof < trafficMove) assert.equal(script.indexOf('verifyRehomeDisabled', trafficMove), -1) }) + +test('rehome job pipes every control result through tee under pipefail', () => { + const job = workflow('operate-relay-production-rehome-job.yml') + // Without `shell: bash` the step exit code is tee's, so a thrown inspect/apply passes green. + assert.match(job, /defaults:\n run:\n(?: #.*\n)* shell: bash\n/) + assert.ok((job.match(/\| tee "\$\{RUNNER_TEMP\}/g) ?? []).length >= 5) +}) diff --git a/cloud/dev/scripts/relay-repository.mjs b/cloud/dev/scripts/relay-repository.mjs index bf41bed8012..7e8b01e4799 100644 --- a/cloud/dev/scripts/relay-repository.mjs +++ b/cloud/dev/scripts/relay-repository.mjs @@ -15,9 +15,16 @@ export function relayWorkflowFile(name) { return `${RELAY_WORKFLOW_FILE_PREFIX}${name}` } +// Repository-relative path for a repository that renames its copies with `prefix`. Terraform's +// trusted prefix is a variable and need not be this checkout's, so callers rendering a +// workflow_ref from Terraform pass it in rather than assuming the local one. +export function prefixedRelayWorkflowPath(prefix, name) { + return `.github/workflows/${prefix}${name}` +} + // Repository-relative path, the shape GitHub reports in workflow_ref and evidence payloads. export function relayWorkflowPath(name) { - return `.github/workflows/${relayWorkflowFile(name)}` + return prefixedRelayWorkflowPath(RELAY_WORKFLOW_FILE_PREFIX, name) } export function relayWorkflowUrl(name) { diff --git a/cloud/dev/scripts/relay-repository.test.mjs b/cloud/dev/scripts/relay-repository.test.mjs index 56383db4a1e..cf33f869773 100644 --- a/cloud/dev/scripts/relay-repository.test.mjs +++ b/cloud/dev/scripts/relay-repository.test.mjs @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url' import { RELAY_GITHUB_REPOSITORY, RELAY_WORKFLOW_FILE_PREFIX, + prefixedRelayWorkflowPath, readRelayWorkflow, relayWorkflowFile, relayWorkflowPath, @@ -21,6 +22,8 @@ test('workflow identity is derived, never restated', () => { assert.equal(relayWorkflowFile('deploy-relay-staging.yml'), `${RELAY_WORKFLOW_FILE_PREFIX}deploy-relay-staging.yml`) assert.equal(relayWorkflowPath('deploy-relay-staging.yml'), `.github/workflows/${relayWorkflowFile('deploy-relay-staging.yml')}`) assert.ok(relayWorkflowUrl('deploy-relay-staging.yml').pathname.endsWith(relayWorkflowPath('deploy-relay-staging.yml'))) + // A caller rendering Terraform's trusted ref supplies that prefix instead of this checkout's. + assert.equal(prefixedRelayWorkflowPath('cloud-', 'deploy-relay-staging.yml'), '.github/workflows/cloud-deploy-relay-staging.yml') assert.match(readRelayWorkflow('deploy-relay-staging.yml'), /^name:/m) assert.match(RELAY_GITHUB_REPOSITORY, /^[\w.-]+\/[\w.-]+$/) }) diff --git a/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs b/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs index 75bbb9ac8d5..0e777b06c38 100644 --- a/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs +++ b/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs @@ -39,14 +39,17 @@ const launchDigest = '5aedbca5c86de24c8b4d4bf7e3b444b76c712f281ede916cb9d90f70ca // so a file-wide count no longer isolates Asia. const asiaCells = ['production-gce-c27', 'production-gce-c28', 'production-gce-c29'] -function productionCell(cellId) { - const start = productionTfvars.indexOf(`"${cellId}"`) +function cellBlock(tfvars, cellId) { + const start = tfvars.indexOf(`"${cellId}"`) assert.notEqual(start, -1, `${cellId} is missing`) - return productionTfvars.slice(start, productionTfvars.indexOf('\n }', start)) + return tfvars.slice(start, tfvars.indexOf('\n }', start)) } +const productionCell = (cellId) => cellBlock(productionTfvars, cellId) + +// Scoped to C4 by name: staging C3 serves this digest too since its 2026-09-03 re-pin. test('pins staging C4 and all production Asia cells to the same launch image', () => { - assert.equal(stagingTfvars.match(new RegExp(launchDigest, 'g'))?.length, 1) + assert.match(cellBlock(stagingTfvars, 'staging-gce-c4'), new RegExp(`relay@sha256:${launchDigest}"`)) for (const cellId of asiaCells) { assert.match(productionCell(cellId), new RegExp(`relay@sha256:${launchDigest}"`), cellId) } diff --git a/cloud/dev/scripts/relay-staging-deploy-identity.test.mjs b/cloud/dev/scripts/relay-staging-deploy-identity.test.mjs index fece62f9ea2..8afbfb5ca94 100644 --- a/cloud/dev/scripts/relay-staging-deploy-identity.test.mjs +++ b/cloud/dev/scripts/relay-staging-deploy-identity.test.mjs @@ -2,11 +2,7 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' import { readWorkflow, workflowFiles } from './cloud-sql-rollout-lock-census.mjs' -import { - RELAY_WORKFLOW_FILE_PREFIX, - relayWorkflowFile, - relayWorkflowPath -} from './relay-repository.mjs' +import { prefixedRelayWorkflowPath, relayWorkflowFile } from './relay-repository.mjs' const identity = readFileSync( new URL('../../infra/terraform/relay-staging-deploy-iam.tf', import.meta.url), @@ -128,8 +124,11 @@ test('the rendered attribute condition stays inside the provider limit', () => { `assertion.repository_id == '${variableDefault('github_repo_id')}'`, `assertion.repository_owner_id == '${variableDefault('github_owner_id')}'` ] + // The prefix is the Terraform variable, not this checkout's own workflow filenames: the + // condition names the files as the trusted repository carries them. + const prefix = variableDefault('github_workflow_file_prefix') const workflowRefs = providerWorkflowFiles().map( - (file) => `${repository}/${relayWorkflowPath(file)}@refs/heads/main` + (file) => `${repository}/${prefixedRelayWorkflowPath(prefix, file)}@refs/heads/main` ) const rendered = [ ...claims, @@ -138,9 +137,7 @@ test('the rendered attribute condition stays inside the provider limit', () => { `(${workflowRefs.map((ref) => `assertion.workflow_ref == '${ref}'`).join(' || ')})` ].join(' && ') assert.ok(rendered.length < 4096, `rendered condition is ${rendered.length} characters`) - // 797 is the private repository's rendered length. This copy prefixes every workflow filename, - // which is the only difference, so the pin still moves the moment a workflow is added or dropped. - assert.equal(rendered.length, 797 + workflowRefs.length * RELAY_WORKFLOW_FILE_PREFIX.length) + assert.equal(rendered.length, 791) }) // Why: the census is the point. A binding added here without a workflow step behind it, or one diff --git a/cloud/dev/scripts/workload-identity-attribute-conditions.test.mjs b/cloud/dev/scripts/workload-identity-attribute-conditions.test.mjs index f25e442d8c0..1d3f3ce4d79 100644 --- a/cloud/dev/scripts/workload-identity-attribute-conditions.test.mjs +++ b/cloud/dev/scripts/workload-identity-attribute-conditions.test.mjs @@ -13,13 +13,13 @@ const EXPECTED_CONDITIONS = { staging: { relay: { github_staging_relay_capacity: - "assertion.ref == 'refs/heads/main' && assertion.environment == 'staging' && ((assertion.repository == 'stablyai/orca-cloud' && assertion.repository_id == '1273841466' && assertion.repository_owner_id == '127256420' && (assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/bootstrap-relay-staging-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/prove-relay-staging-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/recover-relay-staging-c4-image.yml@refs/heads/main')) || (assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && (assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-bootstrap-relay-staging-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-prove-relay-staging-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-recover-relay-staging-c4-image.yml@refs/heads/main')))", + "assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.ref == 'refs/heads/main' && assertion.environment == 'staging' && (assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-bootstrap-relay-staging-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-prove-relay-staging-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-recover-relay-staging-c4-image.yml@refs/heads/main')", github_staging_relay_deploy: - "assertion.ref == 'refs/heads/main' && assertion.environment == 'staging' && ((assertion.repository == 'stablyai/orca-cloud' && assertion.repository_id == '1273841466' && assertion.repository_owner_id == '127256420' && (assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/bootstrap-relay-staging-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-staging-gce-candidate.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-staging.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/operate-relay-asia-admission.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/power-relay-staging.yml@refs/heads/main')) || (assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && (assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-bootstrap-relay-staging-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-staging-gce-candidate.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-staging.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-operate-relay-asia-admission.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-power-relay-staging.yml@refs/heads/main')))", + "assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.ref == 'refs/heads/main' && assertion.environment == 'staging' && (assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-bootstrap-relay-staging-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-staging-gce-candidate.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-staging.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-operate-relay-asia-admission.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-power-relay-staging.yml@refs/heads/main')", github_relay_asia_topology: - "assertion.ref == 'refs/heads/main' && assertion.environment == 'staging' && assertion.event_name == 'workflow_dispatch' && ((assertion.repository == 'stablyai/orca-cloud' && assertion.repository_id == '1273841466' && assertion.repository_owner_id == '127256420' && assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-asia-topology.yml@refs/heads/main') || (assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-asia-topology.yml@refs/heads/main'))", + "assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.ref == 'refs/heads/main' && assertion.environment == 'staging' && assertion.event_name == 'workflow_dispatch' && assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-asia-topology.yml@refs/heads/main'", github_relay_asia_proof: - "assertion.ref == 'refs/heads/main' && assertion.environment == 'staging' && assertion.event_name == 'workflow_dispatch' && ((assertion.repository == 'stablyai/orca-cloud' && assertion.repository_id == '1273841466' && assertion.repository_owner_id == '127256420' && assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/prove-relay-asia-staging.yml@refs/heads/main') || (assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-prove-relay-asia-staging.yml@refs/heads/main'))", + "assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.ref == 'refs/heads/main' && assertion.environment == 'staging' && assertion.event_name == 'workflow_dispatch' && assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-prove-relay-asia-staging.yml@refs/heads/main'", }, // The relay root creates this provider only in production, so staging has exactly one // definition and it lives here. @@ -31,15 +31,15 @@ const EXPECTED_CONDITIONS = { production: { relay: { github: - "assertion.ref == 'refs/heads/main' && assertion.environment == 'production' && ((assertion.repository == 'stablyai/orca-cloud' && assertion.repository_id == '1273841466' && assertion.repository_owner_id == '127256420' && ((assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-fence-broker.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-production-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-production-director.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-production-multi-target.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-production.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/operate-relay-asia-admission.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/publish-relay-production.yml@refs/heads/main') || (assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/operate-relay-production-rehome.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca-cloud/.github/workflows/operate-relay-production-rehome-job.yml@refs/heads/main') || (assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-production-same-cap.yml@refs/heads/main' && (assertion.job_workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-production-same-cap-job.yml@refs/heads/main' || assertion.job_workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-production-same-cap.yml@refs/heads/main')))) || (assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && ((assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-fence-broker.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-director.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-multi-target.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-operate-relay-asia-admission.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-publish-relay-production.yml@refs/heads/main') || (assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-operate-relay-production-rehome.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-operate-relay-production-rehome-job.yml@refs/heads/main') || (assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-same-cap.yml@refs/heads/main' && (assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml@refs/heads/main' || assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-same-cap.yml@refs/heads/main')))))", + "assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.ref == 'refs/heads/main' && assertion.environment == 'production' && ((assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-fence-broker.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-director.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-multi-target.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-operate-relay-asia-admission.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-publish-relay-production.yml@refs/heads/main') || (assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-operate-relay-production-rehome.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-operate-relay-production-rehome-job.yml@refs/heads/main') || (assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-same-cap.yml@refs/heads/main' && (assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml@refs/heads/main' || assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-same-cap.yml@refs/heads/main')))", github_monitor: - "assertion.ref == 'refs/heads/main' && assertion.environment == 'production' && ((assertion.repository == 'stablyai/orca-cloud' && assertion.repository_id == '1273841466' && assertion.repository_owner_id == '127256420' && assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/monitor-relay-production.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca-cloud/.github/workflows/monitor-relay-production-job.yml@refs/heads/main') || (assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-monitor-relay-production.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-monitor-relay-production-job.yml@refs/heads/main'))", + "assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.ref == 'refs/heads/main' && assertion.environment == 'production' && assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-monitor-relay-production.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-monitor-relay-production-job.yml@refs/heads/main'", github_fence: - "assertion.ref == 'refs/heads/main' && assertion.environment == 'production' && ((assertion.repository == 'stablyai/orca-cloud' && assertion.repository_id == '1273841466' && assertion.repository_owner_id == '127256420' && assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-production-multi-target.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-production-multi-target.yml@refs/heads/main') || (assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-multi-target.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-multi-target.yml@refs/heads/main'))", + "assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.ref == 'refs/heads/main' && assertion.environment == 'production' && assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-multi-target.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-multi-target.yml@refs/heads/main'", github_production_relay_capacity: - "assertion.ref == 'refs/heads/main' && assertion.environment == 'production' && ((assertion.repository == 'stablyai/orca-cloud' && assertion.repository_id == '1273841466' && assertion.repository_owner_id == '127256420' && ((assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-production-capacity.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-production-capacity-job.yml@refs/heads/main') || (assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-production-same-cap.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-production-same-cap-job.yml@refs/heads/main'))) || (assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && ((assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-capacity.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-capacity-job.yml@refs/heads/main') || (assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-same-cap.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml@refs/heads/main'))))", + "assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.ref == 'refs/heads/main' && assertion.environment == 'production' && ((assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-capacity.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-capacity-job.yml@refs/heads/main') || (assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-same-cap.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml@refs/heads/main'))", github_relay_asia_topology: - "assertion.ref == 'refs/heads/main' && assertion.environment == 'production' && assertion.event_name == 'workflow_dispatch' && ((assertion.repository == 'stablyai/orca-cloud' && assertion.repository_id == '1273841466' && assertion.repository_owner_id == '127256420' && assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-asia-topology.yml@refs/heads/main') || (assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-asia-topology.yml@refs/heads/main'))", + "assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.ref == 'refs/heads/main' && assertion.environment == 'production' && assertion.event_name == 'workflow_dispatch' && assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-asia-topology.yml@refs/heads/main'", }, apps: { github_production_app_deploy: @@ -48,20 +48,21 @@ const EXPECTED_CONDITIONS = { }, } -// Every repository the relay root accepts while the public extraction runs, with the workflow-ref -// head each one contributes. The apps root is not part of the dual accept. -const ACCEPTED_REPOSITORIES = [ - { - claims: - "assertion.repository == 'stablyai/orca-cloud' && assertion.repository_id == '1273841466' && assertion.repository_owner_id == '127256420'", - workflowHead: 'stablyai/orca-cloud/.github/workflows/' - }, - { +// The one repository each root trusts, with the workflow-ref head it contributes. The relay root +// moved to the public repository, where the workflow files carry the `cloud-` prefix; the apps +// root still deploys from the private one. +const ROOT_REPOSITORIES = { + relay: { claims: "assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420'", workflowHead: 'stablyai/orca/.github/workflows/cloud-' + }, + apps: { + claims: + "assertion.repository == 'stablyai/orca-cloud' && assertion.repository_id == '1273841466' && assertion.repository_owner_id == '127256420'", + workflowHead: 'stablyai/orca-cloud/.github/workflows/' } -] +} // [root, provider, condition] for every provider the environment creates, across all roots. async function flatten(environment) { @@ -103,9 +104,7 @@ for (const environment of Object.keys(EXPECTED_CONDITIONS)) { test(`${environment} pins repository, branch, and environment on every provider`, async () => { for (const [root, provider, condition] of await flatten(environment)) { for (const pin of [ - "assertion.repository == 'stablyai/orca-cloud'", - "assertion.repository_id == '1273841466'", - "assertion.repository_owner_id == '127256420'", + ROOT_REPOSITORIES[root].claims, "assertion.ref == 'refs/heads/main'", `assertion.environment == '${environment}'` ]) { @@ -131,27 +130,23 @@ for (const environment of Object.keys(EXPECTED_CONDITIONS)) { }) } -// Why: the dual accept is only safe if each OR arm carries its own repository claims. An arm that -// inherited them, or a workflow ref that named the other repository, would let one repository's -// workflows run under the other's proof. +// Why: the cutover left one arm per relay provider. A leftover `stablyai/orca-cloud` claim or +// workflow ref would keep trusting a repository whose relay workflows are retired, and an unprefixed +// ref would name a file the public repository does not have. for (const environment of Object.keys(EXPECTED_CONDITIONS)) { - test(`${environment} admits both repositories through every relay provider`, async () => { + test(`${environment} admits only the public repository through every relay provider`, async () => { + const { claims, workflowHead } = ROOT_REPOSITORIES.relay const rendered = await renderAttributeConditions(environment) for (const [provider, condition] of Object.entries(rendered.relay)) { - assert.ok( - condition.startsWith("assertion.ref == 'refs/heads/main' && "), - `${provider} does not lead with the repository-independent claims` - ) + assert.ok(condition.startsWith(`${claims} && `), `${provider} does not lead with the claims`) + assert.doesNotMatch(condition, /stablyai\/orca-cloud|1273841466/, `${provider} keeps an old arm`) const refs = [...condition.matchAll(/(?:job_)?workflow_ref == '([^']+)'/g)].map( (match) => match[1] ) - const perRepository = ACCEPTED_REPOSITORIES.map((repository) => { - assert.ok(condition.includes(`(${repository.claims} && `), `${provider} misses an arm`) - return refs.filter((ref) => ref.startsWith(repository.workflowHead)).length - }) - assert.equal(refs.length, perRepository[0] + perRepository[1], `${provider} names a stray ref`) - assert.equal(perRepository[0], perRepository[1], `${provider} arms are not the same size`) - assert.ok(perRepository[0] > 0, `${provider} names no workflow`) + assert.ok(refs.length > 0, `${provider} names no workflow`) + for (const ref of refs) { + assert.ok(ref.startsWith(workflowHead), `${provider} names a stray ref ${ref}`) + } } }) } diff --git a/cloud/infra/terraform/README.md b/cloud/infra/terraform/README.md index 2e2b9b02011..8aa7aa0a95c 100644 --- a/cloud/infra/terraform/README.md +++ b/cloud/infra/terraform/README.md @@ -37,35 +37,26 @@ identity (`google_service_account.github_deploy`, its provider, and its bindings with production-only counts; staging's copies are declared by `infra/terraform-apps`. An untargeted plan is orderable again; the `Plan:` line still reflects the standing cell-template drift backlog. -### Dual-accept Workload Identity during the public extraction +### Workload Identity trusts the public repository -While the relay source moves to the public `stablyai/orca` repository, every relay Workload -Identity provider accepts the same workflows from both repositories. `github_accepted_repositories` -lists the extra repositories; `relay-github-workflow-trust.tf` renders one parenthesised OR arm per -accepted repository, each arm carrying that repository's own `repository`, `repository_id`, and -`repository_owner_id` claims plus its exact workflow refs. `ref`, `environment`, and `event_name` -stay outside the OR. Workflow files keep their names in the private repo and take the -`workflow_file_prefix` (`cloud-`) in the public one. +The cutover closed on 2026-09-03. Every relay Workload Identity provider now accepts exactly one +repository, `stablyai/orca` (`1183888342`, owner `127256420`), and every workflow ref it names is +built from `github_workflow_file_prefix` (`cloud-`), which is the rename the public repo applies to +the workflow files it carries. `github_repo`, `github_repo_id`, and that prefix are set in both +`environments/*.tfvars` as well as defaulted here, and `github_accepted_repositories` is empty. +Nothing in this root trusts `stablyai/orca-cloud` any more; the apps and foundation roots still do, +because the app workflows still live there. -Adding a repository is a tfvars edit: no provider block changes, and the rendered strings are -pinned by `dev/scripts/workload-identity-attribute-conditions.test.mjs`. An empty list renders -byte-identically to the single-repository form, which is what makes the arms reviewable against -the pre-extraction condition. +`github_accepted_repositories` stays available for the next repository move. Each entry renders its +own parenthesised OR arm in `relay-github-workflow-trust.tf`, carrying that repository's own +`repository`, `repository_id`, and `repository_owner_id` claims plus its exact workflow refs, while +`ref`, `environment`, and `event_name` stay outside the OR. An empty list renders byte-identically +to the single-repository form, so adding and removing a repository is a tfvars edit with no provider +block change. The rendered strings are pinned by +`dev/scripts/workload-identity-attribute-conditions.test.mjs`. -Closing the cutover is an owner step, in this order: - -1. Retire the private workflows, so nothing runs from `stablyai/orca-cloud` any more. -2. Point `github_owner`, `github_repo`, `github_repo_id`, and `github_owner_id` at - `stablyai/orca` (`1183888342`, owner `127256420`), and set `workflow_file_prefix` for it by - moving the surviving entry's prefix onto the primary: the public files keep the `cloud-` names, - so the primary prefix becomes `cloud-` unless the files are renamed back. -3. Empty `github_accepted_repositories` in both `environments/*.tfvars`. -4. Re-render and update the pinned conditions, then apply. Each provider goes back to a single - arm, and `google_service_account_iam_member.github_accepted_repository_workload_identity_user` - is destroyed as the primary `attribute.repository` binding takes over. - -Step 2 and step 3 must land in the same apply: dropping the accepted entry before repointing the -primary would revoke the public repository mid-flight. +Repointing the primary and emptying the list must land in the same apply: dropping the accepted +entry before repointing the primary would revoke the surviving repository mid-flight. ### `ORCA_RELAY_IMAGE_DIGEST` is not Terraform-owned diff --git a/cloud/infra/terraform/environments/production.tfvars b/cloud/infra/terraform/environments/production.tfvars index 60d36e3d832..8e442c75900 100644 --- a/cloud/infra/terraform/environments/production.tfvars +++ b/cloud/infra/terraform/environments/production.tfvars @@ -5,19 +5,11 @@ region = "us-central1" artifact_repository_id = "orca-cloud" -# Dual accept while the relay source moves to the public stablyai/orca repository: the same -# workflows are trusted from both repos, and the public copies carry a `cloud-` file prefix. -# Remove this entry once the private workflows are retired and point github_owner/github_repo, -# github_repo_id, and github_owner_id at the surviving repository. -github_accepted_repositories = [ - { - owner = "stablyai" - repo = "orca" - repo_id = "1183888342" - owner_id = "127256420" - workflow_file_prefix = "cloud-" - } -] +# The relay source lives in the public stablyai/orca repository, where the workflows carry a +# `cloud-` file prefix. github_owner and github_owner_id keep their defaults. +github_repo = "orca" +github_repo_id = "1183888342" +github_workflow_file_prefix = "cloud-" # Our first-party auth service. auth.onorca.dev is PropelAuth's prod domain, so # our service lives at login.onorca.dev (desktop points ORCA_CLOUD_API_URL here). diff --git a/cloud/infra/terraform/environments/staging.tfvars b/cloud/infra/terraform/environments/staging.tfvars index 3ee108fe874..4a32458fcd5 100644 --- a/cloud/infra/terraform/environments/staging.tfvars +++ b/cloud/infra/terraform/environments/staging.tfvars @@ -5,19 +5,11 @@ region = "us-central1" artifact_repository_id = "orca-cloud" -# Dual accept while the relay source moves to the public stablyai/orca repository: the same -# workflows are trusted from both repos, and the public copies carry a `cloud-` file prefix. -# Remove this entry once the private workflows are retired and point github_owner/github_repo, -# github_repo_id, and github_owner_id at the surviving repository. -github_accepted_repositories = [ - { - owner = "stablyai" - repo = "orca" - repo_id = "1183888342" - owner_id = "127256420" - workflow_file_prefix = "cloud-" - } -] +# The relay source lives in the public stablyai/orca repository, where the workflows carry a +# `cloud-` file prefix. github_owner and github_owner_id keep their defaults. +github_repo = "orca" +github_repo_id = "1183888342" +github_workflow_file_prefix = "cloud-" auth_base_url = "https://auth-staging.onorca.dev" @@ -67,7 +59,7 @@ relay_gce_cells = { boot_disk_gb = 30 boot_image = "https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-7" capacity_requests = 4000 - image = "us-central1-docker.pkg.dev/onorca-cloud-staging/orca-cloud/relay@sha256:9fba2a189ab3fa29853800830e77c7551ab3aaa8f43f3cd9adbdea28b876a8b9" + image = "us-central1-docker.pkg.dev/onorca-cloud-staging/orca-cloud/relay@sha256:5aedbca5c86de24c8b4d4bf7e3b444b76c712f281ede916cb9d90f70cad1e563" initially_enabled = false connection_hard_cap = 1000 connection_unobserved_bound = 60 diff --git a/cloud/infra/terraform/relay-shared.tf b/cloud/infra/terraform/relay-shared.tf index d4f0e5dac15..b1afc4d1890 100644 --- a/cloud/infra/terraform/relay-shared.tf +++ b/cloud/infra/terraform/relay-shared.tf @@ -14,16 +14,16 @@ locals { "assertion.repository_owner_id == '${var.github_owner_id}'", ] - # Dual accept during the public extraction: the primary repository first, then every repository - # var.github_accepted_repositories adds. Each one renders its own OR arm in every provider - # condition, so both repos can run the same workflows through the same identities. A repository - # that imports these workflows may rename the files, hence the per-repository prefix. + # The primary repository first, then every repository var.github_accepted_repositories adds. + # Each one renders its own OR arm in every provider condition, so a repository move can trust + # both repos at once. A repository that imports these workflows may rename the files, hence the + # per-repository prefix; the primary's is var.github_workflow_file_prefix. relay_github_accepted_repositories = concat([{ owner = var.github_owner repo = var.github_repo repo_id = var.github_repo_id owner_id = var.github_owner_id - workflow_file_prefix = "" + workflow_file_prefix = var.github_workflow_file_prefix }], var.github_accepted_repositories) relay_github_single_repository = length(local.relay_github_accepted_repositories) == 1 diff --git a/cloud/infra/terraform/variables.tf b/cloud/infra/terraform/variables.tf index c3e06ee280f..68f6c555bd3 100644 --- a/cloud/infra/terraform/variables.tf +++ b/cloud/infra/terraform/variables.tf @@ -22,14 +22,14 @@ variable "github_owner" { variable "github_repo" { type = string description = "GitHub repo allowed to deploy through Workload Identity Federation." - default = "orca-cloud" + default = "orca" } # Numeric IDs survive a rename or transfer of the repository; every provider pins them next to the name. variable "github_repo_id" { type = string description = "Numeric GitHub repository ID of github_owner/github_repo." - default = "1273841466" + default = "1183888342" validation { condition = can(regex("^[0-9]+$", var.github_repo_id)) @@ -48,12 +48,24 @@ variable "github_owner_id" { } } -# Additional repositories whose identical workflows the same identities must accept while the -# public extraction runs. Each entry renders its own OR arm in every provider condition, so the -# private repo keeps working while the public one takes over. `workflow_file_prefix` is the rename -# the importing repository applies to the workflow files it copies. Empty is the steady state: -# the final step of the cutover is to empty this list again and point github_owner/github_repo, -# github_repo_id, and github_owner_id at the surviving repository. +# The rename the relay repository applies to the workflow files it carries. The public repo keeps +# the workflows under `cloud-` names, so every relay workflow_ref is built from this head. +variable "github_workflow_file_prefix" { + type = string + description = "Filename prefix on github_owner/github_repo's copies of the relay workflows." + default = "cloud-" + + validation { + condition = can(regex("^[a-z0-9-]*$", var.github_workflow_file_prefix)) + error_message = "github_workflow_file_prefix must be lowercase letters, digits, or hyphens." + } +} + +# Additional repositories whose identical workflows the same identities must accept during a +# repository move. Each entry renders its own OR arm in every provider condition, so both repos +# can run the same workflows through the same identities. `workflow_file_prefix` is the rename the +# importing repository applies to the workflow files it copies. Empty is the steady state, and is +# where the public extraction left it: stablyai/orca is now the primary and only repository. variable "github_accepted_repositories" { type = list(object({ owner = string diff --git a/config/i18next.config.ts b/config/i18next.config.ts index 577375bd2e9..87e302ac213 100644 --- a/config/i18next.config.ts +++ b/config/i18next.config.ts @@ -16,7 +16,7 @@ export default defineConfig({ ], output, defaultNS: false, - functions: ['t', '*.t', 'translate', 'translateMain'], + functions: ['t', '*.t', 'translate', 'translateMain', 'translateSearchKeyword'], useTranslationNames: ['useTranslation'], sort: true, disablePlurals: true, diff --git a/config/localization-coverage-allowlist.json b/config/localization-coverage-allowlist.json index 57694b2033f..a10139d218f 100644 --- a/config/localization-coverage-allowlist.json +++ b/config/localization-coverage-allowlist.json @@ -55,6 +55,13 @@ "dynamic": false, "count": 1 }, + { + "filePath": "src/renderer/src/components/settings/appearance-search.ts", + "kind": "object-property:keywords", + "text": "Langue", + "dynamic": false, + "count": 1 + }, { "filePath": "src/renderer/src/components/settings/terminal-advanced-platform-search.ts", "kind": "object-property:keywords", diff --git a/config/patches/@xterm__addon-search@0.17.0-beta.300.patch b/config/patches/@xterm__addon-search@0.17.0-beta.300.patch new file mode 100644 index 00000000000..c2843ec6b3d --- /dev/null +++ b/config/patches/@xterm__addon-search@0.17.0-beta.300.patch @@ -0,0 +1,275 @@ +diff --git a/lib/addon-search.js b/lib/addon-search.js +index d939cf1a65f3de449059efbf4fc5c3c3515f533f..8d2b66d265c256d4ebd507624d6a29b8075929ce 100644 +--- a/lib/addon-search.js ++++ b/lib/addon-search.js +@@ -1,2 +1,2 @@ +-!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.SearchAddon=t():e.SearchAddon=t()}(globalThis,()=>(()=>{"use strict";var e={578(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.IntervalTimer=t.MicrotaskTimer=t.TimeoutTimer=void 0,t.timeout=function(e){return new Promise(t=>setTimeout(t,e))},t.disposableTimeout=function(e,t=0,s){const r=setTimeout(()=>{e(),s&&n.dispose()},t),n=(0,i.toDisposable)(()=>{clearTimeout(r)});return s?.add(n),n};const i=s(426);t.TimeoutTimer=class{constructor(){this._token=-1,this._isDisposed=!1}dispose(){this.cancel(),this._isDisposed=!0}cancel(){-1!==this._token&&(clearTimeout(this._token),this._token=-1)}cancelAndSet(e,t){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed TimeoutTimer");this.cancel(),this._token=setTimeout(()=>{this._token=-1,e()},t)}setIfNotSet(e,t){if(this._isDisposed)throw new Error("Calling setIfNotSet on a disposed TimeoutTimer");-1===this._token&&(this._token=setTimeout(()=>{this._token=-1,e()},t))}},t.MicrotaskTimer=class{constructor(){this._isScheduled=!1,this._isDisposed=!1}dispose(){this.cancel(),this._isDisposed=!0}cancel(){this._isScheduled=!1}set(e){if(this._isDisposed)throw new Error("Calling set on a disposed MicrotaskTimer");this._isScheduled||(this._isScheduled=!0,queueMicrotask(()=>{this._isScheduled&&(this._isScheduled=!1,e())}))}},t.IntervalTimer=class{constructor(){this._isDisposed=!1}cancel(){this._disposable?.dispose(),this._disposable=void 0}cancelAndSet(e,t,s=globalThis){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed IntervalTimer");this.cancel();const i=s.setInterval(()=>{e()},t);this._disposable={dispose:()=>{s.clearInterval(i),this._disposable=void 0}}}dispose(){this.cancel(),this._isDisposed=!0}}},414(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.EventUtils=t.Emitter=void 0;const i=s(426);var r;t.Emitter=class{constructor(){this._listeners=[],this._disposed=!1}get event(){return this._event||(this._event=(e,t,s)=>{if(this._disposed)return(0,i.toDisposable)(()=>{});const r={fn:e,thisArgs:t};this._listeners=this._listeners.slice(),this._listeners.push(r);const n=(0,i.toDisposable)(()=>{const e=this._listeners.indexOf(r);-1!==e&&(this._listeners=this._listeners.slice(),this._listeners.splice(e,1))});return s&&(Array.isArray(s)?s.push(n):s.add(n)),n}),this._event}fire(e){if(this._disposed||!this._listeners.length)return;if(1===this._listeners.length)return void this._listeners[0].fn.call(this._listeners[0].thisArgs,e);const t=this._listeners;for(let s=0,i=t.length;st.fire(e))},e.map=function(e,t){return(s,i,r)=>e(e=>s.call(i,t(e)),void 0,r)},e.any=function(...e){return(t,s,r)=>{const n=new i.DisposableStore;for(const i of e)n.add(i(e=>t.call(s,e)));return r&&(Array.isArray(r)?r.push(n):r.add(n)),n}},e.runAndSubscribe=function(e,t,s){return t(s),e(e=>t(e))}}(r||(t.EventUtils=r={}))},426(e,t){function s(e){return{dispose:e}}function i(e){if(!e)return e;if(Array.isArray(e)){for(const t of e)t.dispose();return[]}return e.dispose(),e}Object.defineProperty(t,"__esModule",{value:!0}),t.MutableDisposable=t.Disposable=t.DisposableStore=void 0,t.toDisposable=s,t.dispose=i,t.combinedDisposable=function(...e){return s(()=>i(e))};class r{constructor(){this._disposables=new Set,this._isDisposed=!1}get isDisposed(){return this._isDisposed}add(e){return this._isDisposed?e.dispose():this._disposables.add(e),e}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(const e of this._disposables)e.dispose();this._disposables.clear()}}clear(){for(const e of this._disposables)e.dispose();this._disposables.clear()}}t.DisposableStore=r;class n{constructor(){this._store=new r}dispose(){this._store.dispose()}_register(e){return this._store.add(e)}}t.Disposable=n,n.None=Object.freeze({dispose(){}}),t.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}}},864(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.DecorationManager=void 0;const i=s(426);class r extends i.Disposable{constructor(e){super(),this._terminal=e,this._highlightDecorations=[],this._highlightedLines=new Set,this._register((0,i.toDisposable)(()=>this.clearHighlightDecorations()))}createHighlightDecorations(e,t){this.clearHighlightDecorations();for(const s of e){const e=this._createResultDecorations(s,t,!1);if(e)for(const t of e)this._storeDecoration(t,s)}}createActiveDecoration(e,t){const s=this._createResultDecorations(e,t,!0);if(s)return{decorations:s,match:e,dispose(){(0,i.dispose)(s)}}}clearHighlightDecorations(){(0,i.dispose)(this._highlightDecorations),this._highlightDecorations=[],this._highlightedLines.clear()}_storeDecoration(e,t){this._highlightedLines.add(e.marker.line),this._highlightDecorations.push({decoration:e,match:t,dispose(){e.dispose()}})}_applyStyles(e,t,s){e.classList.contains("xterm-find-result-decoration")||(e.classList.add("xterm-find-result-decoration"),t&&(e.style.outline=`1px solid ${t}`)),s&&e.classList.add("xterm-find-active-result-decoration")}_createResultDecorations(e,t,s){const r=[];let n=e.col,o=e.size,a=-this._terminal.buffer.active.baseY-this._terminal.buffer.active.cursorY+e.row;for(;o>0;){const e=Math.min(this._terminal.cols-n,o);r.push([a,n,e]),n=0,o-=e,a++}const h=[];for(const e of r){const r=this._terminal.registerMarker(e[0]),n=this._terminal.registerDecoration({marker:r,x:e[1],width:e[2],layer:s?"top":"bottom",backgroundColor:s?t.activeMatchBackground:t.matchBackground,overviewRulerOptions:this._highlightedLines.has(r.line)?void 0:{color:s?t.activeMatchColorOverviewRuler:t.matchOverviewRuler,position:"center"}});if(n){const e=[];e.push(r),e.push(n.onRender(e=>this._applyStyles(e,s?t.activeMatchBorder:t.matchBorder,!1))),e.push(n.onDispose(()=>(0,i.dispose)(e))),h.push(n)}}return 0===h.length?void 0:h}}t.DecorationManager=r},615(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.SearchEngine=void 0,t.SearchEngine=class{constructor(e,t){this._terminal=e,this._lineCache=t}find(e,t,s,i){if(!e||0===e.length)return void this._terminal.clearSelection();if(s>=this._terminal.cols)throw new Error(`Invalid col: ${s} to search in terminal of ${this._terminal.cols} cols`);this._lineCache.initLinesCache();const r={startRow:t,startCol:s};let n=this._findInLine(e,r,i);if(!n)for(let s=t+1;s=0&&(a.startRow=s,h=this._findInLine(e,a,t,o),!h);s--);}if(!h&&r!==this._terminal.buffer.active.baseY+this._terminal.rows-1)for(let s=this._terminal.buffer.active.baseY+this._terminal.rows-1;s>=r&&(a.startRow=s,h=this._findInLine(e,a,t,o),!h);s--);return h}_isWholeWord(e,t,s){return(0===e||" ~!@#$%^&*()+`-=[]{}|\\;:\"',./<>?".includes(t[e-1]))&&(e+s.length===t.length||" ~!@#$%^&*()+`-=[]{}|\\;:\"',./<>?".includes(t[e+s.length]))}_findInLine(e,t,s={},i=!1){const r=t.startRow,n=t.startCol,o=this._terminal.buffer.active.getLine(r);if(o?.isWrapped)return i?void(t.startCol+=this._terminal.cols):(t.startRow--,t.startCol+=this._terminal.cols,this._findInLine(e,t,s));let a=this._lineCache.getLineFromCache(r);a||(a=this._lineCache.translateBufferLineToStringWithWrap(r,!0),this._lineCache.setLineInCache(r,a));const[h,l]=a,c=this._bufferColsToStringOffset(r,n);let d=e,_=h;s.regex||(d=s.caseSensitive?e:e.toLowerCase(),_=s.caseSensitive?h:h.toLowerCase());let u=-1;if(s.regex){const t=RegExp(d,s.caseSensitive?"g":"gi");let r;if(i)for(;r=t.exec(_.slice(0,c));)u=t.lastIndex-r[0].length,e=r[0],t.lastIndex-=e.length-1;else r=t.exec(_.slice(c)),r&&r[0].length>0&&(u=c+(t.lastIndex-r[0].length),e=r[0])}else i?c-d.length>=0&&(u=_.lastIndexOf(d,c-d.length)):u=_.indexOf(d,c);if(u>=0){if(s.wholeWord&&!this._isWholeWord(u,_,e))return;let t=0;for(;t=l[t+1];)t++;let i=t;for(;i=l[i+1];)i++;const n=u-l[t],o=u+e.length-l[i],a=this._stringLengthToBufferSize(r+t,n);return{term:e,col:a,row:r+t,size:this._stringLengthToBufferSize(r+i,o)-a+this._terminal.cols*(i-t)}}}_stringLengthToBufferSize(e,t){const s=this._terminal.buffer.active.getLine(e);if(!s)return 0;for(let e=0;e1&&(t-=r.length-1);const n=s.getCell(e+1);n&&0===n.getWidth()&&t++}return t}_bufferColsToStringOffset(e,t){let s=e,i=0,r=this._terminal.buffer.active.getLine(s);for(;t>0&&r;){for(let e=0;ethis._destroyLinesCache()))}initLinesCache(){this._linesCache||(this._linesCache=new Array(this._terminal.buffer.active.length),this._linesCacheDisposables.value=(0,i.combinedDisposable)(this._terminal.onLineFeed(()=>this._destroyLinesCache()),this._terminal.onCursorMove(()=>this._destroyLinesCache()),this._terminal.onResize(()=>this._destroyLinesCache()))),this._lastAccessTimestamp=Date.now(),this._linesCacheTimeout.value||this._scheduleLinesCacheTimeout(15e3)}_destroyLinesCache(){this._linesCache=void 0,this._lastAccessTimestamp=0,this._linesCacheDisposables.clear(),this._linesCacheTimeout.clear()}_scheduleLinesCacheTimeout(e){this._linesCacheTimeout.value=(0,r.disposableTimeout)(()=>{if(!this._linesCache)return;const e=Date.now()-this._lastAccessTimestamp;e>=15e3?this._destroyLinesCache():this._scheduleLinesCacheTimeout(15e3-e)},e)}getLineFromCache(e){return this._linesCache?.[e]}setLineInCache(e,t){this._linesCache&&(this._linesCache[e]=t)}translateBufferLineToStringWithWrap(e,t){const s=[],i=[0];let r=this._terminal.buffer.active.getLine(e);for(;r;){const n=this._terminal.buffer.active.getLine(e+1),o=!!n&&n.isWrapped;let a=r.translateToString(!o&&t);if(o&&n){const e=r.getCell(r.length-1);e&&0===e.getCode()&&1===e.getWidth()&&2===n.getCell(0)?.getWidth()&&(a=a.slice(0,-1))}if(s.push(a),!o)break;i.push(i[i.length-1]+a.length),e++,r=n}return[s.join(""),i]}}t.SearchLineCache=n},438(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.SearchResultTracker=void 0;const i=s(414),r=s(426);class n extends r.Disposable{constructor(){super(...arguments),this._searchResults=[],this._onDidChangeResults=this._register(new i.Emitter)}get onDidChangeResults(){return this._onDidChangeResults.event}get searchResults(){return this._searchResults}get selectedDecoration(){return this._selectedDecoration}set selectedDecoration(e){this._selectedDecoration=e}updateResults(e,t){this._searchResults=e.slice(0,t)}clearResults(){this._searchResults=[]}clearSelectedDecoration(){this._selectedDecoration&&(this._selectedDecoration.dispose(),this._selectedDecoration=void 0)}findResultIndex(e){for(let t=0;t0)}didOptionsChange(e){return!this._lastSearchOptions||!!e&&(this._lastSearchOptions.caseSensitive!==e.caseSensitive||this._lastSearchOptions.regex!==e.regex||this._lastSearchOptions.wholeWord!==e.wholeWord)}shouldUpdateHighlighting(e,t){return!!t?.decorations&&(void 0===this._cachedSearchTerm||e!==this._cachedSearchTerm||this.didOptionsChange(t))}clearCachedTerm(){this._cachedSearchTerm=void 0}reset(){this._cachedSearchTerm=void 0,this._lastSearchOptions=void 0}}}},t={};function s(i){var r=t[i];if(void 0!==r)return r.exports;var n=t[i]={exports:{}};return e[i](n,n.exports,s),n.exports}var i={};return(()=>{var e=i;Object.defineProperty(e,"__esModule",{value:!0}),e.SearchAddon=void 0;const t=s(414),r=s(426),n=s(578),o=s(149),a=s(772),h=s(615),l=s(864),c=s(438);class d extends r.Disposable{get onDidChangeResults(){return this._resultTracker.onDidChangeResults}constructor(e){super(),this._highlightTimeout=this._register(new r.MutableDisposable),this._lineCache=this._register(new r.MutableDisposable),this._state=new a.SearchState,this._resultTracker=this._register(new c.SearchResultTracker),this._onAfterSearch=this._register(new t.Emitter),this.onAfterSearch=this._onAfterSearch.event,this._onBeforeSearch=this._register(new t.Emitter),this.onBeforeSearch=this._onBeforeSearch.event,this._highlightLimit=e?.highlightLimit??1e3}activate(e){this._terminal=e,this._lineCache.value=new o.SearchLineCache(e),this._engine=new h.SearchEngine(e,this._lineCache.value),this._decorationManager=new l.DecorationManager(e),this._register(this._terminal.onWriteParsed(()=>this._updateMatches())),this._register(this._terminal.onResize(()=>this._updateMatches())),this._register((0,r.toDisposable)(()=>this.clearDecorations()))}_updateMatches(){this._highlightTimeout.clear(),this._state.cachedSearchTerm&&this._state.lastSearchOptions?.decorations&&(this._highlightTimeout.value=(0,n.disposableTimeout)(()=>{const e=this._state.cachedSearchTerm;this._state.clearCachedTerm(),this.findPrevious(e,{...this._state.lastSearchOptions,incremental:!0},{noScroll:!0})},200))}clearDecorations(e){this._resultTracker.clearSelectedDecoration(),this._decorationManager?.clearHighlightDecorations(),this._resultTracker.clearResults(),e||this._state.clearCachedTerm()}clearActiveDecoration(){this._resultTracker.clearSelectedDecoration()}findNext(e,t,s){if(!this._terminal||!this._engine)throw new Error("Cannot use addon until it has been loaded");this._onBeforeSearch.fire(),this._state.lastSearchOptions=t,this._state.shouldUpdateHighlighting(e,t)&&this._highlightAllMatches(e,t);const i=this._findNextAndSelect(e,t,s);return this._fireResults(t),this._state.cachedSearchTerm=e,this._onAfterSearch.fire(),i}_highlightAllMatches(e,t){if(!this._terminal||!this._engine||!this._decorationManager)throw new Error("Cannot use addon until it has been loaded");if(!this._state.isValidSearchTerm(e))return void this.clearDecorations();this.clearDecorations(!0);const s=[];let i,r=this._engine.find(e,0,0,t);for(;r&&(i?.row!==r.row||i?.col!==r.col)&&!(s.length>=this._highlightLimit);){i=r,s.push(i);const n=this._terminal.cols;let o=i.col+i.size,a=i.row;o>=n&&(a+=Math.floor(o/n),o%=n),r=this._engine.find(e,a,o,t)}this._resultTracker.updateResults(s,this._highlightLimit),t.decorations&&this._decorationManager.createHighlightDecorations(s,t.decorations)}_findNextAndSelect(e,t,s){if(!this._terminal||!this._engine)return!1;if(!this._state.isValidSearchTerm(e))return this._terminal.clearSelection(),this.clearDecorations(),!1;const i=this._engine.findNextWithSelection(e,t,this._state.cachedSearchTerm);return this._selectResult(i,t?.decorations,s?.noScroll)}findPrevious(e,t,s){if(!this._terminal||!this._engine)throw new Error("Cannot use addon until it has been loaded");this._onBeforeSearch.fire(),this._state.lastSearchOptions=t,this._state.shouldUpdateHighlighting(e,t)&&this._highlightAllMatches(e,t);const i=this._findPreviousAndSelect(e,t,s);return this._fireResults(t),this._state.cachedSearchTerm=e,this._onAfterSearch.fire(),i}_fireResults(e){this._resultTracker.fireResultsChanged(!!e?.decorations)}_findPreviousAndSelect(e,t,s){if(!this._terminal||!this._engine)return!1;if(!this._state.isValidSearchTerm(e))return this._terminal.clearSelection(),this.clearDecorations(),!1;const i=this._engine.findPreviousWithSelection(e,t,this._state.cachedSearchTerm);return this._selectResult(i,t?.decorations,s?.noScroll)}_selectResult(e,t,s){if(!this._terminal||!this._decorationManager)return!1;if(this._resultTracker.clearSelectedDecoration(),!e)return this._terminal.clearSelection(),!1;if(this._terminal.select(e.col,e.row,e.size),t){const s=this._decorationManager.createActiveDecoration(e,t);s&&(this._resultTracker.selectedDecoration=s)}if(!s&&(e.row>=this._terminal.buffer.active.viewportY+this._terminal.rows||e.row(()=>{"use strict";var e={578(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.IntervalTimer=t.MicrotaskTimer=t.TimeoutTimer=void 0,t.timeout=function(e){return new Promise(t=>setTimeout(t,e))},t.disposableTimeout=function(e,t=0,s){const r=setTimeout(()=>{e(),s&&o.dispose()},t),o=(0,i.toDisposable)(()=>{clearTimeout(r)});return s?.add(o),o};const i=s(426);t.TimeoutTimer=class{constructor(){this._token=-1,this._isDisposed=!1}dispose(){this.cancel(),this._isDisposed=!0}cancel(){-1!==this._token&&(clearTimeout(this._token),this._token=-1)}cancelAndSet(e,t){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed TimeoutTimer");this.cancel(),this._token=setTimeout(()=>{this._token=-1,e()},t)}setIfNotSet(e,t){if(this._isDisposed)throw new Error("Calling setIfNotSet on a disposed TimeoutTimer");-1===this._token&&(this._token=setTimeout(()=>{this._token=-1,e()},t))}},t.MicrotaskTimer=class{constructor(){this._isScheduled=!1,this._isDisposed=!1}dispose(){this.cancel(),this._isDisposed=!0}cancel(){this._isScheduled=!1}set(e){if(this._isDisposed)throw new Error("Calling set on a disposed MicrotaskTimer");this._isScheduled||(this._isScheduled=!0,queueMicrotask(()=>{this._isScheduled&&(this._isScheduled=!1,e())}))}},t.IntervalTimer=class{constructor(){this._isDisposed=!1}cancel(){this._disposable?.dispose(),this._disposable=void 0}cancelAndSet(e,t,s=globalThis){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed IntervalTimer");this.cancel();const i=s.setInterval(()=>{e()},t);this._disposable={dispose:()=>{s.clearInterval(i),this._disposable=void 0}}}dispose(){this.cancel(),this._isDisposed=!0}}},414(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.EventUtils=t.Emitter=void 0;const i=s(426);var r;t.Emitter=class{constructor(){this._listeners=[],this._disposed=!1}get event(){return this._event||(this._event=(e,t,s)=>{if(this._disposed)return(0,i.toDisposable)(()=>{});const r={fn:e,thisArgs:t};this._listeners=this._listeners.slice(),this._listeners.push(r);const o=(0,i.toDisposable)(()=>{const e=this._listeners.indexOf(r);-1!==e&&(this._listeners=this._listeners.slice(),this._listeners.splice(e,1))});return s&&(Array.isArray(s)?s.push(o):s.add(o)),o}),this._event}fire(e){if(this._disposed||!this._listeners.length)return;if(1===this._listeners.length)return void this._listeners[0].fn.call(this._listeners[0].thisArgs,e);const t=this._listeners;for(let s=0,i=t.length;st.fire(e))},e.map=function(e,t){return(s,i,r)=>e(e=>s.call(i,t(e)),void 0,r)},e.any=function(...e){return(t,s,r)=>{const o=new i.DisposableStore;for(const i of e)o.add(i(e=>t.call(s,e)));return r&&(Array.isArray(r)?r.push(o):r.add(o)),o}},e.runAndSubscribe=function(e,t,s){return t(s),e(e=>t(e))}}(r||(t.EventUtils=r={}))},426(e,t){function s(e){return{dispose:e}}function i(e){if(!e)return e;if(Array.isArray(e)){for(const t of e)t.dispose();return[]}return e.dispose(),e}Object.defineProperty(t,"__esModule",{value:!0}),t.MutableDisposable=t.Disposable=t.DisposableStore=void 0,t.toDisposable=s,t.dispose=i,t.combinedDisposable=function(...e){return s(()=>i(e))};class r{constructor(){this._disposables=new Set,this._isDisposed=!1}get isDisposed(){return this._isDisposed}add(e){return this._isDisposed?e.dispose():this._disposables.add(e),e}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(const e of this._disposables)e.dispose();this._disposables.clear()}}clear(){for(const e of this._disposables)e.dispose();this._disposables.clear()}}t.DisposableStore=r;class o{constructor(){this._store=new r}dispose(){this._store.dispose()}_register(e){return this._store.add(e)}}t.Disposable=o,o.None=Object.freeze({dispose(){}}),t.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}}},864(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.DecorationManager=void 0;const i=s(426);class r extends i.Disposable{constructor(e){super(),this._terminal=e,this._highlightDecorations=[],this._highlightedLines=new Set,this._register((0,i.toDisposable)(()=>this.clearHighlightDecorations()))}createHighlightDecorations(e,t){this.clearHighlightDecorations();for(const s of e){const e=this._createResultDecorations(s,t,!1);if(e)for(const t of e)this._storeDecoration(t,s)}}createActiveDecoration(e,t){const s=this._createResultDecorations(e,t,!0);if(s)return{decorations:s,match:e,dispose(){(0,i.dispose)(s)}}}clearHighlightDecorations(){(0,i.dispose)(this._highlightDecorations),this._highlightDecorations=[],this._highlightedLines.clear()}_storeDecoration(e,t){this._highlightedLines.add(e.marker.line),this._highlightDecorations.push({decoration:e,match:t,dispose(){e.dispose()}})}_applyStyles(e,t,s){e.classList.contains("xterm-find-result-decoration")||(e.classList.add("xterm-find-result-decoration"),t&&(e.style.outline=`1px solid ${t}`)),s&&e.classList.add("xterm-find-active-result-decoration")}_createResultDecorations(e,t,s){const r=[];let o=e.col,n=e.size,a=-this._terminal.buffer.active.baseY-this._terminal.buffer.active.cursorY+e.row;for(;n>0;){const e=Math.min(this._terminal.cols-o,n);r.push([a,o,e]),o=0,n-=e,a++}const h=[];for(const e of r){const r=this._terminal.registerMarker(e[0]),o=this._terminal.registerDecoration({marker:r,x:e[1],width:e[2],layer:s?"top":"bottom",backgroundColor:s?t.activeMatchBackground:t.matchBackground,overviewRulerOptions:this._highlightedLines.has(r.line)?void 0:{color:s?t.activeMatchColorOverviewRuler:t.matchOverviewRuler,position:"center"}});if(o){const e=[];e.push(r),e.push(o.onRender(e=>this._applyStyles(e,s?t.activeMatchBorder:t.matchBorder,!1))),e.push(o.onDispose(()=>(0,i.dispose)(e))),h.push(o)}}return 0===h.length?void 0:h}}t.DecorationManager=r},615(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.SearchEngine=void 0,t.SearchEngine=class{constructor(e,t){this._terminal=e,this._lineCache=t}find(e,t,s,i){if(!e||0===e.length)return void this._terminal.clearSelection();if(s>=this._terminal.cols)throw new Error(`Invalid col: ${s} to search in terminal of ${this._terminal.cols} cols`);this._lineCache.initLinesCache();const r={startRow:t,startCol:s};let o=this._findInLine(e,r,i);if(!o)for(let s=t+1;s0&&this._isRowCoveredByEarlierSearch(s)||(n.startRow=s,n.startCol=0,a=this._findInLine(e,n,t),!a));s++);return!a&&i&&(n.startRow=i.start.y,n.startCol=0,a=this._findInLine(e,n,t)),a}findPreviousWithSelection(e,t,s){if(!e||0===e.length)return void this._terminal.clearSelection();const i=this._terminal.getSelectionPosition();this._terminal.clearSelection();let r=this._terminal.buffer.active.baseY+this._terminal.rows-1;const o=this._terminal.cols,n=!0;this._lineCache.initLinesCache();const a={startRow:r,startCol:o};let h;if(i&&(a.startRow=r=i.start.y,a.startCol=i.start.x,s!==e&&(h=this._findInLine(e,a,t,!1),h||(a.startRow=r=i.end.y,a.startCol=i.end.x))),h??=this._findInLine(e,a,t,n),!h){a.startCol=Math.max(a.startCol,this._terminal.cols);for(let s=r-1;s>=0&&(a.startRow=s,h=this._findInLine(e,a,t,n),!h);s--);}if(!h&&r!==this._terminal.buffer.active.baseY+this._terminal.rows-1)for(let s=this._terminal.buffer.active.baseY+this._terminal.rows-1;s>=r&&(a.startRow=s,h=this._findInLine(e,a,t,n),!h);s--);return h}_isWholeWord(e,t,s){return(0===e||" ~!@#$%^&*()+`-=[]{}|\\;:\"',./<>?".includes(t[e-1]))&&(e+s.length===t.length||" ~!@#$%^&*()+`-=[]{}|\\;:\"',./<>?".includes(t[e+s.length]))}_satisfiesWholeWord(e,t,s,i){return!i.wholeWord||this._isWholeWord(e,t,s)}_isRowCoveredByEarlierSearch(e){return!0===this._terminal.buffer.active.getLine(e)?.isWrapped}_findInLine(e,t,s={},i=!1){if(i){if(t.startRow>0&&this._terminal.buffer.active.getLine(t.startRow)?.isWrapped)return void(t.startCol+=this._terminal.cols)}else for(;t.startRow>0&&this._terminal.buffer.active.getLine(t.startRow)?.isWrapped;)t.startRow--,t.startCol+=this._terminal.cols;const r=t.startRow,o=t.startCol;let n=this._lineCache.getLineFromCache(r);n||(n=this._lineCache.translateBufferLineToStringWithWrap(r,!0),this._lineCache.setLineInCache(r,n));const[a,h]=n,l=this._bufferColsToStringOffset(r,o,h);let c=e,d=a;s.regex||(c=s.caseSensitive?e:e.toLowerCase(),d=s.caseSensitive?a:a.toLowerCase());let _=-1;if(s.regex){const t=RegExp(c,s.caseSensitive?"g":"gi");let r;if(i)for(;r=t.exec(d.slice(0,l));){const i=t.lastIndex-r[0].length;r[0].length>0&&this._satisfiesWholeWord(i,d,r[0],s)&&(_=i,e=r[0]),t.lastIndex=i+1}else for(t.lastIndex=l;r=t.exec(d);){const i=t.lastIndex-r[0].length;if(r[0].length>0&&this._satisfiesWholeWord(i,d,r[0],s)){_=i,e=r[0];break}t.lastIndex=i+1}}else if(i){let e=l-c.length>=0?d.lastIndexOf(c,l-c.length):-1;for(;e>=0&&!this._satisfiesWholeWord(e,d,c,s);)e=e>0?d.lastIndexOf(c,e-1):-1;_=e}else{let e=d.indexOf(c,l);for(;e>=0&&!this._satisfiesWholeWord(e,d,c,s);)e=d.indexOf(c,e+1);_=e}if(_>=0){let t=0;for(;t=h[t+1];)t++;let s=t;for(;s=h[s+1];)s++;const i=_-h[t],o=_+e.length-h[s],n=this._stringLengthToBufferSize(r+t,i);return{term:e,col:n,row:r+t,size:this._stringLengthToBufferSize(r+s,o)-n+this._terminal.cols*(s-t)}}}_stringLengthToBufferSize(e,t){const s=this._terminal.buffer.active.getLine(e);if(!s)return 0;for(let e=0;e1&&(t-=r.length-1);const o=s.getCell(e+1);o&&0===o.getWidth()&&t++}return t}_bufferColsToStringOffset(e,t,s){const i=Math.min(Math.floor(t/this._terminal.cols),s.length-1);let r=s[i];const o=this._terminal.buffer.active.getLine(e+i);if(o){const e=Math.min(t-i*this._terminal.cols,this._terminal.cols);for(let t=0;tthis._destroyLinesCache()))}initLinesCache(){this._linesCache||(this._linesCache=new Array(this._terminal.buffer.active.length),this._linesCacheDisposables.value=(0,i.combinedDisposable)(this._terminal.onLineFeed(()=>this._destroyLinesCache()),this._terminal.onCursorMove(()=>this._destroyLinesCache()),this._terminal.onResize(()=>this._destroyLinesCache()))),this._lastAccessTimestamp=Date.now(),this._linesCacheTimeout.value||this._scheduleLinesCacheTimeout(15e3)}_destroyLinesCache(){this._linesCache=void 0,this._lastAccessTimestamp=0,this._linesCacheDisposables.clear(),this._linesCacheTimeout.clear()}_scheduleLinesCacheTimeout(e){this._linesCacheTimeout.value=(0,r.disposableTimeout)(()=>{if(!this._linesCache)return;const e=Date.now()-this._lastAccessTimestamp;e>=15e3?this._destroyLinesCache():this._scheduleLinesCacheTimeout(15e3-e)},e)}getLineFromCache(e){return this._linesCache?.[e]}setLineInCache(e,t){this._linesCache&&(this._linesCache[e]=t)}translateBufferLineToStringWithWrap(e,t){const s=[],i=[0],r=this._terminal.buffer.active.length;let o=this._terminal.buffer.active.getLine(e);for(;o;){const n=e+10)}didOptionsChange(e){return!this._lastSearchOptions||!!e&&(this._lastSearchOptions.caseSensitive!==e.caseSensitive||this._lastSearchOptions.regex!==e.regex||this._lastSearchOptions.wholeWord!==e.wholeWord)}shouldUpdateHighlighting(e,t){return!!t?.decorations&&(void 0===this._cachedSearchTerm||e!==this._cachedSearchTerm||this.didOptionsChange(t))}clearCachedTerm(){this._cachedSearchTerm=void 0}reset(){this._cachedSearchTerm=void 0,this._lastSearchOptions=void 0}}}},t={};function s(i){var r=t[i];if(void 0!==r)return r.exports;var o=t[i]={exports:{}};return e[i](o,o.exports,s),o.exports}var i={};return(()=>{var e=i;Object.defineProperty(e,"__esModule",{value:!0}),e.SearchAddon=void 0;const t=s(414),r=s(426),o=s(578),n=s(149),a=s(772),h=s(615),l=s(864),c=s(438);class d extends r.Disposable{get onDidChangeResults(){return this._resultTracker.onDidChangeResults}constructor(e){super(),this._highlightTimeout=this._register(new r.MutableDisposable),this._lineCache=this._register(new r.MutableDisposable),this._state=new a.SearchState,this._resultTracker=this._register(new c.SearchResultTracker),this._onAfterSearch=this._register(new t.Emitter),this.onAfterSearch=this._onAfterSearch.event,this._onBeforeSearch=this._register(new t.Emitter),this.onBeforeSearch=this._onBeforeSearch.event,this._highlightLimit=e?.highlightLimit??1e3}activate(e){this._terminal=e,this._lineCache.value=new n.SearchLineCache(e),this._engine=new h.SearchEngine(e,this._lineCache.value),this._decorationManager=new l.DecorationManager(e),this._register(this._terminal.onWriteParsed(()=>this._updateMatches())),this._register(this._terminal.onResize(()=>this._updateMatches())),this._register((0,r.toDisposable)(()=>this.clearDecorations()))}_updateMatches(){this._highlightTimeout.clear(),this._state.cachedSearchTerm&&this._state.lastSearchOptions?.decorations&&(this._highlightTimeout.value=(0,o.disposableTimeout)(()=>{const e=this._state.cachedSearchTerm;this._state.clearCachedTerm(),this.findPrevious(e,{...this._state.lastSearchOptions,incremental:!0},{noScroll:!0})},200))}clearDecorations(e){this._resultTracker.clearSelectedDecoration(),this._decorationManager?.clearHighlightDecorations(),this._resultTracker.clearResults(),e||this._state.clearCachedTerm()}clearActiveDecoration(){this._resultTracker.clearSelectedDecoration()}findNext(e,t,s){if(!this._terminal||!this._engine)throw new Error("Cannot use addon until it has been loaded");this._onBeforeSearch.fire(),this._state.lastSearchOptions=t,this._state.shouldUpdateHighlighting(e,t)&&this._highlightAllMatches(e,t);const i=this._findNextAndSelect(e,t,s);return this._fireResults(t),this._state.cachedSearchTerm=e,this._onAfterSearch.fire(),i}_highlightAllMatches(e,t){if(!this._terminal||!this._engine||!this._decorationManager)throw new Error("Cannot use addon until it has been loaded");if(!this._state.isValidSearchTerm(e))return void this.clearDecorations();this.clearDecorations(!0);const s=[];let i,r=this._engine.find(e,0,0,t);for(;r&&(i?.row!==r.row||i?.col!==r.col)&&!(s.length>=this._highlightLimit);){i=r,s.push(i);const o=this._terminal.cols;let n=i.col+i.size,a=i.row;n>=o&&(a+=Math.floor(n/o),n%=o),r=this._engine.find(e,a,n,t)}this._resultTracker.updateResults(s,this._highlightLimit),t.decorations&&this._decorationManager.createHighlightDecorations(s,t.decorations)}_findNextAndSelect(e,t,s){if(!this._terminal||!this._engine)return!1;if(!this._state.isValidSearchTerm(e))return this._terminal.clearSelection(),this.clearDecorations(),!1;const i=this._engine.findNextWithSelection(e,t,this._state.cachedSearchTerm);return this._selectResult(i,t?.decorations,s?.noScroll)}findPrevious(e,t,s){if(!this._terminal||!this._engine)throw new Error("Cannot use addon until it has been loaded");this._onBeforeSearch.fire(),this._state.lastSearchOptions=t,this._state.shouldUpdateHighlighting(e,t)&&this._highlightAllMatches(e,t);const i=this._findPreviousAndSelect(e,t,s);return this._fireResults(t),this._state.cachedSearchTerm=e,this._onAfterSearch.fire(),i}_fireResults(e){this._resultTracker.fireResultsChanged(!!e?.decorations)}_findPreviousAndSelect(e,t,s){if(!this._terminal||!this._engine)return!1;if(!this._state.isValidSearchTerm(e))return this._terminal.clearSelection(),this.clearDecorations(),!1;const i=this._engine.findPreviousWithSelection(e,t,this._state.cachedSearchTerm);return this._selectResult(i,t?.decorations,s?.noScroll)}_selectResult(e,t,s){if(!this._terminal||!this._decorationManager)return!1;if(this._resultTracker.clearSelectedDecoration(),!e)return this._terminal.clearSelection(),!1;if(this._terminal.select(e.col,e.row,e.size),t){const s=this._decorationManager.createActiveDecoration(e,t);s&&(this._resultTracker.selectedDecoration=s)}if(!s&&(e.row>=this._terminal.buffer.active.viewportY+this._terminal.rows||e.row {\nreturn ","/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal async helpers for xterm.js core.\n */\n\nimport { DisposableStore, IDisposable, toDisposable } from './Lifecycle';\n\nexport function timeout(millis: number): Promise {\n return new Promise(resolve => setTimeout(resolve, millis));\n}\n\n/**\n * Creates a timeout that can be disposed using its returned value.\n * @param handler The timeout handler.\n * @param timeout An optional timeout in milliseconds.\n * @param store An optional {@link DisposableStore} that will have the timeout disposable managed\n * automatically.\n */\nexport function disposableTimeout(handler: () => void, timeout = 0, store?: DisposableStore): IDisposable {\n const timer = setTimeout(() => {\n handler();\n if (store) {\n disposable.dispose();\n }\n }, timeout);\n const disposable = toDisposable(() => {\n clearTimeout(timer);\n });\n store?.add(disposable);\n return disposable;\n}\n\nexport class TimeoutTimer implements IDisposable {\n private _token: any = -1;\n private _isDisposed = false;\n\n public dispose(): void {\n this.cancel();\n this._isDisposed = true;\n }\n\n public cancel(): void {\n if (this._token !== -1) {\n clearTimeout(this._token);\n this._token = -1;\n }\n }\n\n public cancelAndSet(runner: () => void, timeout: number): void {\n if (this._isDisposed) {\n throw new Error('Calling cancelAndSet on a disposed TimeoutTimer');\n }\n this.cancel();\n this._token = setTimeout(() => {\n this._token = -1;\n runner();\n }, timeout);\n }\n\n public setIfNotSet(runner: () => void, timeout: number): void {\n if (this._isDisposed) {\n throw new Error('Calling setIfNotSet on a disposed TimeoutTimer');\n }\n if (this._token !== -1) {\n return;\n }\n this._token = setTimeout(() => {\n this._token = -1;\n runner();\n }, timeout);\n }\n}\n\n/**\n * Schedules a single runner on the microtask queue. Unlike {@link TimeoutTimer}, a scheduled\n * microtask cannot be unqueued; {@link cancel} prevents the runner from executing if it has not\n * run yet.\n */\nexport class MicrotaskTimer implements IDisposable {\n private _isScheduled = false;\n private _isDisposed = false;\n\n public dispose(): void {\n this.cancel();\n this._isDisposed = true;\n }\n\n public cancel(): void {\n this._isScheduled = false;\n }\n\n public set(runner: () => void): void {\n if (this._isDisposed) {\n throw new Error('Calling set on a disposed MicrotaskTimer');\n }\n if (this._isScheduled) {\n return;\n }\n this._isScheduled = true;\n queueMicrotask(() => {\n if (!this._isScheduled) {\n return;\n }\n this._isScheduled = false;\n runner();\n });\n }\n}\n\nexport class IntervalTimer implements IDisposable {\n private _disposable: IDisposable | undefined;\n private _isDisposed = false;\n\n public cancel(): void {\n this._disposable?.dispose();\n this._disposable = undefined;\n }\n\n public cancelAndSet(runner: () => void, interval: number, context: Window | typeof globalThis = globalThis): void {\n if (this._isDisposed) {\n throw new Error('Calling cancelAndSet on a disposed IntervalTimer');\n }\n this.cancel();\n const handle = context.setInterval(() => {\n runner();\n }, interval);\n this._disposable = {\n dispose: () => {\n context.clearInterval(handle as any);\n this._disposable = undefined;\n }\n };\n }\n\n public dispose(): void {\n this.cancel();\n this._isDisposed = true;\n }\n}\n","/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal event utilities for xterm.js core.\n * Simplified from VS Code's event.ts - no leak detection/profiling.\n */\n\nimport { IDisposable, DisposableStore, toDisposable } from './Lifecycle';\n\nexport interface IEvent {\n (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore): IDisposable;\n}\n\nexport class Emitter {\n private _listeners: { fn: (e: T) => any, thisArgs: any }[] = [];\n private _disposed = false;\n private _event: IEvent | undefined;\n\n public get event(): IEvent {\n if (this._event) {\n return this._event;\n }\n this._event = (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n if (this._disposed) {\n return toDisposable(() => {});\n }\n\n const entry = { fn: listener, thisArgs };\n this._listeners = this._listeners.slice();\n this._listeners.push(entry);\n\n const result = toDisposable(() => {\n const idx = this._listeners.indexOf(entry);\n if (idx !== -1) {\n this._listeners = this._listeners.slice();\n this._listeners.splice(idx, 1);\n }\n });\n\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(result);\n } else {\n disposables.add(result);\n }\n }\n\n return result;\n };\n return this._event;\n }\n\n public fire(event: T): void {\n if (this._disposed || !this._listeners.length) {\n return;\n }\n if (this._listeners.length === 1) {\n this._listeners[0].fn.call(this._listeners[0].thisArgs, event);\n return;\n }\n const listeners = this._listeners;\n for (let i = 0, len = listeners.length; i < len; ++i) {\n listeners[i].fn.call(listeners[i].thisArgs, event);\n }\n }\n\n public dispose(): void {\n if (this._disposed) {\n return;\n }\n this._disposed = true;\n this._listeners.length = 0;\n }\n}\n\nexport namespace EventUtils {\n export function forward(from: IEvent, to: Emitter): IDisposable {\n return from(e => to.fire(e));\n }\n\n export function map(event: IEvent, map: (i: I) => O): IEvent {\n return (listener: (e: O) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n return event(i => listener.call(thisArgs, map(i)), undefined, disposables);\n };\n }\n\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent {\n return (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n const store = new DisposableStore();\n for (const event of events) {\n store.add(event(e => listener.call(thisArgs, e)));\n }\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(store);\n } else {\n disposables.add(store);\n }\n }\n return store;\n };\n }\n\n export function runAndSubscribe(event: IEvent, handler: (e: T) => void, initial: T): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void, initial?: T): IDisposable {\n handler(initial);\n return event(e => handler(e));\n }\n}\n","/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal lifecycle utilities for xterm.js core.\n * Simplified from VS Code's lifecycle.ts - no tracking/leak detection.\n */\n\nexport interface IDisposable {\n dispose(): void;\n}\n\nexport function toDisposable(fn: () => void): IDisposable {\n return { dispose: fn };\n}\n\nexport function dispose(disposable: T): T;\nexport function dispose(disposable: T | undefined): T | undefined;\nexport function dispose(disposables: T[]): T[];\nexport function dispose(arg: T | T[] | undefined): T | T[] | undefined {\n if (!arg) {\n return arg;\n }\n if (Array.isArray(arg)) {\n for (const d of arg) {\n d.dispose();\n }\n return [];\n }\n arg.dispose();\n return arg;\n}\n\nexport function combinedDisposable(...disposables: IDisposable[]): IDisposable {\n return toDisposable(() => dispose(disposables));\n}\n\nexport class DisposableStore implements IDisposable {\n private readonly _disposables = new Set();\n private _isDisposed = false;\n\n public get isDisposed(): boolean {\n return this._isDisposed;\n }\n\n public add(o: T): T {\n if (this._isDisposed) {\n o.dispose();\n } else {\n this._disposables.add(o);\n }\n return o;\n }\n\n public dispose(): void {\n if (this._isDisposed) {\n return;\n }\n this._isDisposed = true;\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n\n public clear(): void {\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n}\n\nexport abstract class Disposable implements IDisposable {\n public static readonly None: IDisposable = Object.freeze({ dispose() { } });\n\n protected readonly _store = new DisposableStore();\n\n public dispose(): void {\n this._store.dispose();\n }\n\n protected _register(o: T): T {\n return this._store.add(o);\n }\n}\n\nexport class MutableDisposable implements IDisposable {\n private _value: T | undefined;\n private _isDisposed = false;\n\n public get value(): T | undefined {\n return this._isDisposed ? undefined : this._value;\n }\n\n public set value(value: T | undefined) {\n if (this._isDisposed || value === this._value) {\n return;\n }\n this._value?.dispose();\n this._value = value;\n }\n\n public clear(): void {\n this.value = undefined;\n }\n\n public dispose(): void {\n this._isDisposed = true;\n this._value?.dispose();\n this._value = undefined;\n }\n}\n","/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { Terminal, IDisposable, IDecoration } from '@xterm/xterm';\nimport type { ISearchDecorationOptions } from '@xterm/addon-search';\nimport { dispose, Disposable, toDisposable } from 'common/Lifecycle';\nimport type { ISearchResult } from './SearchEngine';\n\n/**\n * Interface for managing a highlight decoration.\n */\ninterface IHighlight extends IDisposable {\n decoration: IDecoration;\n match: ISearchResult;\n}\n\n/**\n * Interface for managing multiple decorations for a single match.\n */\ninterface IMultiHighlight extends IDisposable {\n decorations: IDecoration[];\n match: ISearchResult;\n}\n\n/**\n * Manages visual decorations for search results including highlighting and active selection\n * indicators. This class handles the creation, styling, and disposal of search-related decorations.\n */\nexport class DecorationManager extends Disposable {\n private _highlightDecorations: IHighlight[] = [];\n private _highlightedLines: Set = new Set();\n\n constructor(private readonly _terminal: Terminal) {\n super();\n this._register(toDisposable(() => this.clearHighlightDecorations()));\n }\n\n /**\n * Creates decorations for all provided search results.\n * @param results The search results to create decorations for.\n * @param options The decoration options.\n */\n public createHighlightDecorations(results: ISearchResult[], options: ISearchDecorationOptions): void {\n this.clearHighlightDecorations();\n\n for (const match of results) {\n const decorations = this._createResultDecorations(match, options, false);\n if (decorations) {\n for (const decoration of decorations) {\n this._storeDecoration(decoration, match);\n }\n }\n }\n }\n\n /**\n * Creates decorations for the currently active search result.\n * @param result The active search result.\n * @param options The decoration options.\n * @returns The multi-highlight decoration or undefined if creation failed.\n */\n public createActiveDecoration(result: ISearchResult, options: ISearchDecorationOptions): IMultiHighlight | undefined {\n const decorations = this._createResultDecorations(result, options, true);\n if (decorations) {\n return { decorations, match: result, dispose() { dispose(decorations); } };\n }\n return undefined;\n }\n\n /**\n * Clears all highlight decorations.\n */\n public clearHighlightDecorations(): void {\n dispose(this._highlightDecorations);\n this._highlightDecorations = [];\n this._highlightedLines.clear();\n }\n\n /**\n * Stores a decoration and tracks it for management.\n * @param decoration The decoration to store.\n * @param match The search result this decoration represents.\n */\n private _storeDecoration(decoration: IDecoration, match: ISearchResult): void {\n this._highlightedLines.add(decoration.marker.line);\n this._highlightDecorations.push({ decoration, match, dispose() { decoration.dispose(); } });\n }\n\n /**\n * Applies styles to the decoration when it is rendered.\n * @param element The decoration's element.\n * @param borderColor The border color to apply.\n * @param isActiveResult Whether the element is part of the active search result.\n */\n private _applyStyles(element: HTMLElement, borderColor: string | undefined, isActiveResult: boolean): void {\n if (!element.classList.contains('xterm-find-result-decoration')) {\n element.classList.add('xterm-find-result-decoration');\n if (borderColor) {\n element.style.outline = `1px solid ${borderColor}`;\n }\n }\n if (isActiveResult) {\n element.classList.add('xterm-find-active-result-decoration');\n }\n }\n\n /**\n * Creates a decoration for the result and applies styles\n * @param result the search result for which to create the decoration\n * @param options the options for the decoration\n * @param isActiveResult whether this is the currently active result\n * @returns the decorations or undefined if the marker has already been disposed of\n */\n private _createResultDecorations(result: ISearchResult, options: ISearchDecorationOptions, isActiveResult: boolean): IDecoration[] | undefined {\n // Gather decoration ranges for this match as it could wrap\n const decorationRanges: [number, number, number][] = [];\n let currentCol = result.col;\n let remainingSize = result.size;\n let markerOffset = -this._terminal.buffer.active.baseY - this._terminal.buffer.active.cursorY + result.row;\n while (remainingSize > 0) {\n const amountThisRow = Math.min(this._terminal.cols - currentCol, remainingSize);\n decorationRanges.push([markerOffset, currentCol, amountThisRow]);\n currentCol = 0;\n remainingSize -= amountThisRow;\n markerOffset++;\n }\n\n // Create the decorations\n const decorations: IDecoration[] = [];\n for (const range of decorationRanges) {\n const marker = this._terminal.registerMarker(range[0]);\n const decoration = this._terminal.registerDecoration({\n marker,\n x: range[1],\n width: range[2],\n layer: isActiveResult ? 'top' : 'bottom',\n backgroundColor: isActiveResult ? options.activeMatchBackground : options.matchBackground,\n overviewRulerOptions: this._highlightedLines.has(marker.line) ? undefined : {\n color: isActiveResult ? options.activeMatchColorOverviewRuler : options.matchOverviewRuler,\n position: 'center'\n }\n });\n if (decoration) {\n const disposables: IDisposable[] = [];\n disposables.push(marker);\n disposables.push(decoration.onRender((e) => this._applyStyles(e, isActiveResult ? options.activeMatchBorder : options.matchBorder, false)));\n disposables.push(decoration.onDispose(() => dispose(disposables)));\n decorations.push(decoration);\n }\n }\n\n return decorations.length === 0 ? undefined : decorations;\n }\n}\n\n\n","/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { Terminal } from '@xterm/xterm';\nimport type { ISearchOptions } from '@xterm/addon-search';\nimport type { SearchLineCache } from './SearchLineCache';\n\n/**\n * Represents the position to start a search from.\n */\ninterface ISearchPosition {\n startCol: number;\n startRow: number;\n}\n\n/**\n * Represents a search result with its position and content.\n */\nexport interface ISearchResult {\n term: string;\n col: number;\n row: number;\n size: number;\n}\n\n/**\n * Configuration constants for the search engine functionality.\n */\nconst enum Constants {\n /**\n * Characters that are considered non-word characters for search boundary detection. These\n * characters are used to determine word boundaries when performing whole-word searches. Includes\n * common punctuation, symbols, and whitespace characters.\n */\n NON_WORD_CHARACTERS = ' ~!@#$%^&*()+`-=[]{}|\\\\;:\"\\',./<>?'\n}\n\n/**\n * Core search engine that handles finding text within terminal content.\n * This class is responsible for the actual search algorithms and position calculations.\n */\nexport class SearchEngine {\n constructor(\n private readonly _terminal: Terminal,\n private readonly _lineCache: SearchLineCache\n ) {}\n\n /**\n * Find the first occurrence of a term starting from a specific position.\n * @param term The search term.\n * @param startRow The row to start searching from.\n * @param startCol The column to start searching from.\n * @param searchOptions Search options.\n * @returns The search result if found, undefined otherwise.\n */\n public find(term: string, startRow: number, startCol: number, searchOptions?: ISearchOptions): ISearchResult | undefined {\n if (!term || term.length === 0) {\n this._terminal.clearSelection();\n return undefined;\n }\n if (startCol >= this._terminal.cols) {\n throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`);\n }\n\n this._lineCache.initLinesCache();\n\n const searchPosition: ISearchPosition = {\n startRow,\n startCol\n };\n\n // Search startRow\n let result = this._findInLine(term, searchPosition, searchOptions);\n // Search from startRow + 1 to end\n if (!result) {\n for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {\n searchPosition.startRow = y;\n searchPosition.startCol = 0;\n result = this._findInLine(term, searchPosition, searchOptions);\n if (result) {\n break;\n }\n }\n }\n return result;\n }\n\n /**\n * Find the next occurrence of a term with wrapping and selection management.\n * @param term The search term.\n * @param searchOptions Search options.\n * @param cachedSearchTerm The cached search term to determine incremental behavior.\n * @returns The search result if found, undefined otherwise.\n */\n public findNextWithSelection(term: string, searchOptions?: ISearchOptions, cachedSearchTerm?: string): ISearchResult | undefined {\n if (!term || term.length === 0) {\n this._terminal.clearSelection();\n return undefined;\n }\n\n const prevSelectedPos = this._terminal.getSelectionPosition();\n this._terminal.clearSelection();\n\n let startCol = 0;\n let startRow = 0;\n if (prevSelectedPos) {\n if (cachedSearchTerm === term) {\n startCol = prevSelectedPos.end.x;\n startRow = prevSelectedPos.end.y;\n } else {\n startCol = prevSelectedPos.start.x;\n startRow = prevSelectedPos.start.y;\n }\n }\n\n this._lineCache.initLinesCache();\n\n const searchPosition: ISearchPosition = {\n startRow,\n startCol\n };\n\n // Search startRow\n let result = this._findInLine(term, searchPosition, searchOptions);\n // Search from startRow + 1 to end\n if (!result) {\n for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {\n searchPosition.startRow = y;\n searchPosition.startCol = 0;\n result = this._findInLine(term, searchPosition, searchOptions);\n if (result) {\n break;\n }\n }\n }\n // If we hit the bottom and didn't search from the very top wrap back up\n if (!result && startRow !== 0) {\n for (let y = 0; y < startRow; y++) {\n searchPosition.startRow = y;\n searchPosition.startCol = 0;\n result = this._findInLine(term, searchPosition, searchOptions);\n if (result) {\n break;\n }\n }\n }\n\n // If there is only one result, wrap back and return selection if it exists.\n if (!result && prevSelectedPos) {\n searchPosition.startRow = prevSelectedPos.start.y;\n searchPosition.startCol = 0;\n result = this._findInLine(term, searchPosition, searchOptions);\n }\n\n return result;\n }\n\n /**\n * Find the previous occurrence of a term with wrapping and selection management.\n * @param term The search term.\n * @param searchOptions Search options.\n * @param cachedSearchTerm The cached search term to determine if expansion should occur.\n * @returns The search result if found, undefined otherwise.\n */\n public findPreviousWithSelection(term: string, searchOptions?: ISearchOptions, cachedSearchTerm?: string): ISearchResult | undefined {\n if (!term || term.length === 0) {\n this._terminal.clearSelection();\n return undefined;\n }\n\n const prevSelectedPos = this._terminal.getSelectionPosition();\n this._terminal.clearSelection();\n\n let startRow = this._terminal.buffer.active.baseY + this._terminal.rows - 1;\n const startCol = this._terminal.cols;\n const isReverseSearch = true;\n\n this._lineCache.initLinesCache();\n const searchPosition: ISearchPosition = {\n startRow,\n startCol\n };\n\n let result: ISearchResult | undefined;\n if (prevSelectedPos) {\n searchPosition.startRow = startRow = prevSelectedPos.start.y;\n searchPosition.startCol = prevSelectedPos.start.x;\n if (cachedSearchTerm !== term) {\n // Try to expand selection to right first.\n result = this._findInLine(term, searchPosition, searchOptions, false);\n if (!result) {\n // If selection was not able to be expanded to the right, then try reverse search\n searchPosition.startRow = startRow = prevSelectedPos.end.y;\n searchPosition.startCol = prevSelectedPos.end.x;\n }\n }\n }\n\n result ??= this._findInLine(term, searchPosition, searchOptions, isReverseSearch);\n\n // Search from startRow - 1 to top\n if (!result) {\n searchPosition.startCol = Math.max(searchPosition.startCol, this._terminal.cols);\n for (let y = startRow - 1; y >= 0; y--) {\n searchPosition.startRow = y;\n result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);\n if (result) {\n break;\n }\n }\n }\n // If we hit the top and didn't search from the very bottom wrap back down\n if (!result && startRow !== (this._terminal.buffer.active.baseY + this._terminal.rows - 1)) {\n for (let y = (this._terminal.buffer.active.baseY + this._terminal.rows - 1); y >= startRow; y--) {\n searchPosition.startRow = y;\n result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);\n if (result) {\n break;\n }\n }\n }\n\n return result;\n }\n\n /**\n * A found substring is a whole word if it doesn't have an alphanumeric character directly\n * adjacent to it.\n * @param searchIndex starting index of the potential whole word substring\n * @param line entire string in which the potential whole word was found\n * @param term the substring that starts at searchIndex\n */\n private _isWholeWord(searchIndex: number, line: string, term: string): boolean {\n return ((searchIndex === 0) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex - 1]))) &&\n (((searchIndex + term.length) === line.length) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex + term.length])));\n }\n\n /**\n * Searches a line for a search term. Takes the provided terminal line and searches the text line,\n * which may contain subsequent terminal lines if the text is wrapped. If the provided line number\n * is part of a wrapped text line that started on an earlier line then it is skipped since it will\n * be properly searched when the terminal line that the text starts on is searched.\n * @param term The search term.\n * @param searchPosition The position to start the search.\n * @param searchOptions Search options.\n * @param isReverseSearch Whether the search should start from the right side of the terminal and\n * search to the left.\n * @returns The search result if it was found.\n */\n private _findInLine(term: string, searchPosition: ISearchPosition, searchOptions: ISearchOptions = {}, isReverseSearch: boolean = false): ISearchResult | undefined {\n const row = searchPosition.startRow;\n const col = searchPosition.startCol;\n\n // Ignore wrapped lines, only consider on unwrapped line (first row of command string).\n const firstLine = this._terminal.buffer.active.getLine(row);\n if (firstLine?.isWrapped) {\n if (isReverseSearch) {\n searchPosition.startCol += this._terminal.cols;\n return;\n }\n\n // This will iterate until we find the line start.\n // When we find it, we will search using the calculated start column.\n searchPosition.startRow--;\n searchPosition.startCol += this._terminal.cols;\n return this._findInLine(term, searchPosition, searchOptions);\n }\n let cache = this._lineCache.getLineFromCache(row);\n if (!cache) {\n cache = this._lineCache.translateBufferLineToStringWithWrap(row, true);\n this._lineCache.setLineInCache(row, cache);\n }\n const [stringLine, offsets] = cache;\n\n const offset = this._bufferColsToStringOffset(row, col);\n let searchTerm = term;\n let searchStringLine = stringLine;\n if (!searchOptions.regex) {\n searchTerm = searchOptions.caseSensitive ? term : term.toLowerCase();\n searchStringLine = searchOptions.caseSensitive ? stringLine : stringLine.toLowerCase();\n }\n\n let resultIndex = -1;\n if (searchOptions.regex) {\n const searchRegex = RegExp(searchTerm, searchOptions.caseSensitive ? 'g' : 'gi');\n let foundTerm: RegExpExecArray | null;\n if (isReverseSearch) {\n // This loop will get the resultIndex of the _last_ regex match in the range 0..offset\n while (foundTerm = searchRegex.exec(searchStringLine.slice(0, offset))) {\n resultIndex = searchRegex.lastIndex - foundTerm[0].length;\n term = foundTerm[0];\n searchRegex.lastIndex -= (term.length - 1);\n }\n } else {\n foundTerm = searchRegex.exec(searchStringLine.slice(offset));\n if (foundTerm && foundTerm[0].length > 0) {\n resultIndex = offset + (searchRegex.lastIndex - foundTerm[0].length);\n term = foundTerm[0];\n }\n }\n } else {\n if (isReverseSearch) {\n if (offset - searchTerm.length >= 0) {\n resultIndex = searchStringLine.lastIndexOf(searchTerm, offset - searchTerm.length);\n }\n } else {\n resultIndex = searchStringLine.indexOf(searchTerm, offset);\n }\n }\n\n if (resultIndex >= 0) {\n if (searchOptions.wholeWord && !this._isWholeWord(resultIndex, searchStringLine, term)) {\n return;\n }\n\n // Adjust the row number and search index if needed since a \"line\" of text can span multiple\n // rows\n let startRowOffset = 0;\n while (startRowOffset < offsets.length - 1 && resultIndex >= offsets[startRowOffset + 1]) {\n startRowOffset++;\n }\n let endRowOffset = startRowOffset;\n while (endRowOffset < offsets.length - 1 && resultIndex + term.length >= offsets[endRowOffset + 1]) {\n endRowOffset++;\n }\n const startColOffset = resultIndex - offsets[startRowOffset];\n const endColOffset = resultIndex + term.length - offsets[endRowOffset];\n const startColIndex = this._stringLengthToBufferSize(row + startRowOffset, startColOffset);\n const endColIndex = this._stringLengthToBufferSize(row + endRowOffset, endColOffset);\n const size = endColIndex - startColIndex + this._terminal.cols * (endRowOffset - startRowOffset);\n\n return {\n term,\n col: startColIndex,\n row: row + startRowOffset,\n size\n };\n }\n }\n\n private _stringLengthToBufferSize(row: number, offset: number): number {\n const line = this._terminal.buffer.active.getLine(row);\n if (!line) {\n return 0;\n }\n for (let i = 0; i < offset; i++) {\n const cell = line.getCell(i);\n if (!cell) {\n break;\n }\n // Adjust the searchIndex to normalize emoji into single chars\n const char = cell.getChars();\n if (char.length > 1) {\n offset -= char.length - 1;\n }\n // Adjust the searchIndex for empty characters following wide unicode\n // chars (eg. CJK)\n const nextCell = line.getCell(i + 1);\n if (nextCell && nextCell.getWidth() === 0) {\n offset++;\n }\n }\n return offset;\n }\n\n private _bufferColsToStringOffset(startRow: number, cols: number): number {\n let lineIndex = startRow;\n let offset = 0;\n let line = this._terminal.buffer.active.getLine(lineIndex);\n while (cols > 0 && line) {\n for (let i = 0; i < cols && i < this._terminal.cols; i++) {\n const cell = line.getCell(i);\n if (!cell) {\n break;\n }\n if (cell.getWidth()) {\n // Treat null characters as whitespace to align with the translateToString API\n offset += cell.getCode() === 0 ? 1 : cell.getChars().length;\n }\n }\n lineIndex++;\n line = this._terminal.buffer.active.getLine(lineIndex);\n if (line && !line.isWrapped) {\n break;\n }\n cols -= this._terminal.cols;\n }\n return offset;\n }\n}\n","/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { Terminal } from '@xterm/xterm';\nimport { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\nimport { disposableTimeout } from 'common/Async';\n\nexport type LineCacheEntry = [\n /**\n * The string representation of a line (as opposed to the buffer cell representation).\n */\n lineAsString: string,\n /**\n * The offsets where each line starts when the entry describes a wrapped line.\n */\n lineOffsets: number[]\n];\n\n/**\n * Configuration constants for the search line cache functionality.\n */\nconst enum Constants {\n /**\n * Time-to-live for cached search results in milliseconds. After this duration, cached search\n * results will be invalidated to ensure they remain consistent with terminal content changes.\n */\n LINES_CACHE_TIME_TO_LIVE = 15000\n}\n\nexport class SearchLineCache extends Disposable {\n /**\n * translateBufferLineToStringWithWrap is a fairly expensive call.\n * We memoize the calls into an array that has a time based ttl.\n * _linesCache is also invalidated when the terminal cursor moves.\n */\n private _linesCache: LineCacheEntry[] | undefined;\n private _linesCacheTimeout = this._register(new MutableDisposable());\n private _linesCacheDisposables = this._register(new MutableDisposable());\n // Track access to avoid recreating a timeout on every init call which occurs once per search\n // result (findNext/findPrevious -> _highlightAllMatches -> find loop).\n private _lastAccessTimestamp = 0;\n\n constructor(private readonly _terminal: Terminal) {\n super();\n this._register(toDisposable(() => this._destroyLinesCache()));\n }\n\n /**\n * Sets up a line cache with a ttl\n */\n public initLinesCache(): void {\n if (!this._linesCache) {\n this._linesCache = new Array(this._terminal.buffer.active.length);\n this._linesCacheDisposables.value = combinedDisposable(\n this._terminal.onLineFeed(() => this._destroyLinesCache()),\n this._terminal.onCursorMove(() => this._destroyLinesCache()),\n this._terminal.onResize(() => this._destroyLinesCache())\n );\n }\n\n this._lastAccessTimestamp = Date.now();\n if (!this._linesCacheTimeout.value) {\n this._scheduleLinesCacheTimeout(Constants.LINES_CACHE_TIME_TO_LIVE);\n }\n }\n\n private _destroyLinesCache(): void {\n this._linesCache = undefined;\n this._lastAccessTimestamp = 0;\n this._linesCacheDisposables.clear();\n this._linesCacheTimeout.clear();\n }\n\n private _scheduleLinesCacheTimeout(delay: number): void {\n this._linesCacheTimeout.value = disposableTimeout(() => {\n if (!this._linesCache) {\n return;\n }\n const now = Date.now();\n const elapsed = now - this._lastAccessTimestamp;\n if (elapsed >= Constants.LINES_CACHE_TIME_TO_LIVE) {\n this._destroyLinesCache();\n return;\n }\n this._scheduleLinesCacheTimeout(Constants.LINES_CACHE_TIME_TO_LIVE - elapsed);\n }, delay);\n }\n\n public getLineFromCache(row: number): LineCacheEntry | undefined {\n return this._linesCache?.[row];\n }\n\n public setLineInCache(row: number, entry: LineCacheEntry): void {\n if (this._linesCache) {\n this._linesCache[row] = entry;\n }\n }\n\n /**\n * Translates a buffer line to a string, including subsequent lines if they are wraps.\n * Wide characters will count as two columns in the resulting string. This\n * function is useful for getting the actual text underneath the raw selection\n * position.\n * @param lineIndex The index of the line being translated.\n * @param trimRight Whether to trim whitespace to the right.\n */\n public translateBufferLineToStringWithWrap(lineIndex: number, trimRight: boolean): LineCacheEntry {\n const strings = [];\n const lineOffsets = [0];\n let line = this._terminal.buffer.active.getLine(lineIndex);\n while (line) {\n const nextLine = this._terminal.buffer.active.getLine(lineIndex + 1);\n const lineWrapsToNext = nextLine ? nextLine.isWrapped : false;\n let string = line.translateToString(!lineWrapsToNext && trimRight);\n if (lineWrapsToNext && nextLine) {\n const lastCell = line.getCell(line.length - 1);\n const lastCellIsNull = lastCell && lastCell.getCode() === 0 && lastCell.getWidth() === 1;\n // a wide character wrapped to the next line\n if (lastCellIsNull && nextLine.getCell(0)?.getWidth() === 2) {\n string = string.slice(0, -1);\n }\n }\n strings.push(string);\n if (lineWrapsToNext) {\n lineOffsets.push(lineOffsets[lineOffsets.length - 1] + string.length);\n } else {\n break;\n }\n lineIndex++;\n line = nextLine;\n }\n return [strings.join(''), lineOffsets];\n }\n}\n","/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ISearchResultChangeEvent } from '@xterm/addon-search';\nimport type { IDisposable } from '@xterm/xterm';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { Disposable } from 'common/Lifecycle';\nimport type { ISearchResult } from './SearchEngine';\n\n/**\n * Interface for managing a currently selected decoration.\n */\ninterface ISelectedDecoration extends IDisposable {\n match: ISearchResult;\n}\n\n/**\n * Tracks search results, manages result indexing, and fires events when results change.\n * This class provides centralized management of search result state and notifications.\n */\nexport class SearchResultTracker extends Disposable {\n private _searchResults: ISearchResult[] = [];\n private _selectedDecoration: ISelectedDecoration | undefined;\n\n private readonly _onDidChangeResults = this._register(new Emitter());\n public get onDidChangeResults(): IEvent { return this._onDidChangeResults.event; }\n\n /**\n * Gets the current search results.\n */\n public get searchResults(): ReadonlyArray {\n return this._searchResults;\n }\n\n /**\n * Gets the currently selected decoration.\n */\n public get selectedDecoration(): ISelectedDecoration | undefined {\n return this._selectedDecoration;\n }\n\n /**\n * Sets the currently selected decoration.\n */\n public set selectedDecoration(decoration: ISelectedDecoration | undefined) {\n this._selectedDecoration = decoration;\n }\n\n /**\n * Updates the search results with a new set of results.\n * @param results The new search results.\n * @param maxResults The maximum number of results to track.\n */\n public updateResults(results: ISearchResult[], maxResults: number): void {\n this._searchResults = results.slice(0, maxResults);\n }\n\n /**\n * Clears all search results.\n */\n public clearResults(): void {\n this._searchResults = [];\n }\n\n /**\n * Clears the selected decoration.\n */\n public clearSelectedDecoration(): void {\n if (this._selectedDecoration) {\n this._selectedDecoration.dispose();\n this._selectedDecoration = undefined;\n }\n }\n\n /**\n * Finds the index of a result in the current results array.\n * @param result The result to find.\n * @returns The index of the result, or -1 if not found.\n */\n public findResultIndex(result: ISearchResult): number {\n for (let i = 0; i < this._searchResults.length; i++) {\n const match = this._searchResults[i];\n if (match.row === result.row && match.col === result.col && match.size === result.size) {\n return i;\n }\n }\n return -1;\n }\n\n /**\n * Fires a result change event with the current state.\n * @param hasDecorations Whether decorations are enabled.\n */\n public fireResultsChanged(hasDecorations: boolean): void {\n if (!hasDecorations) {\n return;\n }\n\n let resultIndex = -1;\n if (this._selectedDecoration) {\n resultIndex = this.findResultIndex(this._selectedDecoration.match);\n }\n\n this._onDidChangeResults.fire({\n resultIndex,\n resultCount: this._searchResults.length\n });\n }\n\n /**\n * Resets all state.\n */\n public reset(): void {\n this.clearSelectedDecoration();\n this.clearResults();\n }\n}\n","/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ISearchOptions } from '@xterm/addon-search';\n\n/**\n * Manages search state including cached search terms, options tracking, and validation.\n * This class provides a centralized way to handle search state consistency and option changes.\n */\nexport class SearchState {\n private _cachedSearchTerm: string | undefined;\n private _lastSearchOptions: ISearchOptions | undefined;\n\n /**\n * Gets the currently cached search term.\n */\n public get cachedSearchTerm(): string | undefined {\n return this._cachedSearchTerm;\n }\n\n /**\n * Sets the cached search term.\n */\n public set cachedSearchTerm(term: string | undefined) {\n this._cachedSearchTerm = term;\n }\n\n /**\n * Gets the last search options used.\n */\n public get lastSearchOptions(): ISearchOptions | undefined {\n return this._lastSearchOptions;\n }\n\n /**\n * Sets the last search options used.\n */\n public set lastSearchOptions(options: ISearchOptions | undefined) {\n this._lastSearchOptions = options;\n }\n\n /**\n * Validates a search term to ensure it's not empty or invalid.\n * @param term The search term to validate.\n * @returns true if the term is valid for searching.\n */\n public isValidSearchTerm(term: string): boolean {\n return !!(term && term.length > 0);\n }\n\n /**\n * Determines if search options have changed compared to the last search.\n * @param newOptions The new search options to compare.\n * @returns true if the options have changed.\n */\n public didOptionsChange(newOptions?: ISearchOptions): boolean {\n if (!this._lastSearchOptions) {\n return true;\n }\n if (!newOptions) {\n return false;\n }\n if (this._lastSearchOptions.caseSensitive !== newOptions.caseSensitive) {\n return true;\n }\n if (this._lastSearchOptions.regex !== newOptions.regex) {\n return true;\n }\n if (this._lastSearchOptions.wholeWord !== newOptions.wholeWord) {\n return true;\n }\n return false;\n }\n\n /**\n * Determines if a new search should trigger highlighting updates.\n * @param term The search term.\n * @param options The search options.\n * @returns true if highlighting should be updated.\n */\n public shouldUpdateHighlighting(term: string, options?: ISearchOptions): boolean {\n if (!options?.decorations) {\n return false;\n }\n return this._cachedSearchTerm === undefined ||\n term !== this._cachedSearchTerm ||\n this.didOptionsChange(options);\n }\n\n /**\n * Clears the cached search term.\n */\n public clearCachedTerm(): void {\n this._cachedSearchTerm = undefined;\n }\n\n /**\n * Resets all state.\n */\n public reset(): void {\n this._cachedSearchTerm = undefined;\n this._lastSearchOptions = undefined;\n }\n}\n","// The module cache\nvar __webpack_module_cache__ = {};\n\n// The require function\nfunction __webpack_require__(moduleId) {\n\t// Check if module is in cache\n\tvar cachedModule = __webpack_module_cache__[moduleId];\n\tif (cachedModule !== undefined) {\n\t\treturn cachedModule.exports;\n\t}\n\t// Create a new module (and put it into the cache)\n\tvar module = __webpack_module_cache__[moduleId] = {\n\t\t// no module.id needed\n\t\t// no module.loaded needed\n\t\texports: {}\n\t};\n\n\t// Execute the module function\n\t__webpack_modules__[moduleId](module, module.exports, __webpack_require__);\n\n\t// Return the exports of the module\n\treturn module.exports;\n}\n\n","/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { Terminal, IDisposable, ITerminalAddon } from '@xterm/xterm';\nimport type { SearchAddon as ISearchApi, ISearchOptions, ISearchAddonOptions, ISearchResultChangeEvent, ISearchDecorationOptions } from '@xterm/addon-search';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\nimport { disposableTimeout } from 'common/Async';\nimport { SearchLineCache } from './SearchLineCache';\nimport { SearchState } from './SearchState';\nimport { SearchEngine, type ISearchResult } from './SearchEngine';\nimport { DecorationManager } from './DecorationManager';\nimport { SearchResultTracker } from './SearchResultTracker';\n\ninterface IInternalSearchOptions {\n noScroll: boolean;\n}\n\n/**\n * Configuration constants for the search addon functionality.\n */\nconst enum Constants {\n /**\n * Default maximum number of search results to highlight simultaneously. This limit prevents\n * performance degradation when searching for very common terms that would result in excessive\n * highlighting decorations.\n */\n DEFAULT_HIGHLIGHT_LIMIT = 1000\n}\n\nexport class SearchAddon extends Disposable implements ITerminalAddon, ISearchApi {\n private _terminal: Terminal | undefined;\n private _highlightLimit: number;\n private _highlightTimeout = this._register(new MutableDisposable());\n private _lineCache = this._register(new MutableDisposable());\n\n // Component instances\n private _state = new SearchState();\n private _engine: SearchEngine | undefined;\n private _decorationManager: DecorationManager | undefined;\n private _resultTracker = this._register(new SearchResultTracker());\n\n private readonly _onAfterSearch = this._register(new Emitter());\n public readonly onAfterSearch = this._onAfterSearch.event;\n private readonly _onBeforeSearch = this._register(new Emitter());\n public readonly onBeforeSearch = this._onBeforeSearch.event;\n\n public get onDidChangeResults(): IEvent {\n return this._resultTracker.onDidChangeResults;\n }\n\n constructor(options?: Partial) {\n super();\n\n this._highlightLimit = options?.highlightLimit ?? Constants.DEFAULT_HIGHLIGHT_LIMIT;\n }\n\n public activate(terminal: Terminal): void {\n this._terminal = terminal;\n this._lineCache.value = new SearchLineCache(terminal);\n this._engine = new SearchEngine(terminal, this._lineCache.value);\n this._decorationManager = new DecorationManager(terminal);\n this._register(this._terminal.onWriteParsed(() => this._updateMatches()));\n this._register(this._terminal.onResize(() => this._updateMatches()));\n this._register(toDisposable(() => this.clearDecorations()));\n }\n\n private _updateMatches(): void {\n this._highlightTimeout.clear();\n if (this._state.cachedSearchTerm && this._state.lastSearchOptions?.decorations) {\n this._highlightTimeout.value = disposableTimeout(() => {\n const term = this._state.cachedSearchTerm;\n this._state.clearCachedTerm();\n this.findPrevious(term!, { ...this._state.lastSearchOptions, incremental: true }, { noScroll: true });\n }, 200);\n }\n }\n\n public clearDecorations(retainCachedSearchTerm?: boolean): void {\n this._resultTracker.clearSelectedDecoration();\n this._decorationManager?.clearHighlightDecorations();\n this._resultTracker.clearResults();\n if (!retainCachedSearchTerm) {\n this._state.clearCachedTerm();\n }\n }\n\n public clearActiveDecoration(): void {\n this._resultTracker.clearSelectedDecoration();\n }\n\n /**\n * Find the next instance of the term, then scroll to and select it. If it\n * doesn't exist, do nothing.\n * @param term The search term.\n * @param searchOptions Search options.\n * @returns Whether a result was found.\n */\n public findNext(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {\n if (!this._terminal || !this._engine) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n\n this._onBeforeSearch.fire();\n\n this._state.lastSearchOptions = searchOptions;\n\n if (this._state.shouldUpdateHighlighting(term, searchOptions)) {\n this._highlightAllMatches(term, searchOptions!);\n }\n\n const found = this._findNextAndSelect(term, searchOptions, internalSearchOptions);\n this._fireResults(searchOptions);\n this._state.cachedSearchTerm = term;\n\n this._onAfterSearch.fire();\n\n return found;\n }\n\n private _highlightAllMatches(term: string, searchOptions: ISearchOptions): void {\n if (!this._terminal || !this._engine || !this._decorationManager) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n if (!this._state.isValidSearchTerm(term)) {\n this.clearDecorations();\n return;\n }\n\n // new search, clear out the old decorations\n this.clearDecorations(true);\n\n const results: ISearchResult[] = [];\n let prevResult: ISearchResult | undefined = undefined;\n let result = this._engine.find(term, 0, 0, searchOptions);\n\n while (result && (prevResult?.row !== result.row || prevResult?.col !== result.col)) {\n if (results.length >= this._highlightLimit) {\n break;\n }\n prevResult = result;\n results.push(prevResult);\n const cols = this._terminal.cols;\n let nextCol = prevResult.col + prevResult.size;\n let nextRow = prevResult.row;\n if (nextCol >= cols) {\n nextRow += Math.floor(nextCol / cols);\n nextCol = nextCol % cols;\n }\n result = this._engine.find(term, nextRow, nextCol, searchOptions);\n }\n\n this._resultTracker.updateResults(results, this._highlightLimit);\n if (searchOptions.decorations) {\n this._decorationManager.createHighlightDecorations(results, searchOptions.decorations);\n }\n }\n\n private _findNextAndSelect(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {\n if (!this._terminal || !this._engine) {\n return false;\n }\n if (!this._state.isValidSearchTerm(term)) {\n this._terminal.clearSelection();\n this.clearDecorations();\n return false;\n }\n\n const result = this._engine.findNextWithSelection(term, searchOptions, this._state.cachedSearchTerm);\n return this._selectResult(result, searchOptions?.decorations, internalSearchOptions?.noScroll);\n }\n\n /**\n * Find the previous instance of the term, then scroll to and select it. If it\n * doesn't exist, do nothing.\n * @param term The search term.\n * @param searchOptions Search options.\n * @returns Whether a result was found.\n */\n public findPrevious(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {\n if (!this._terminal || !this._engine) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n\n this._onBeforeSearch.fire();\n\n this._state.lastSearchOptions = searchOptions;\n\n if (this._state.shouldUpdateHighlighting(term, searchOptions)) {\n this._highlightAllMatches(term, searchOptions!);\n }\n\n const found = this._findPreviousAndSelect(term, searchOptions, internalSearchOptions);\n this._fireResults(searchOptions);\n this._state.cachedSearchTerm = term;\n\n this._onAfterSearch.fire();\n\n return found;\n }\n\n private _fireResults(searchOptions?: ISearchOptions): void {\n this._resultTracker.fireResultsChanged(!!searchOptions?.decorations);\n }\n\n private _findPreviousAndSelect(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {\n if (!this._terminal || !this._engine) {\n return false;\n }\n if (!this._state.isValidSearchTerm(term)) {\n this._terminal.clearSelection();\n this.clearDecorations();\n return false;\n }\n\n const result = this._engine.findPreviousWithSelection(term, searchOptions, this._state.cachedSearchTerm);\n return this._selectResult(result, searchOptions?.decorations, internalSearchOptions?.noScroll);\n }\n\n /**\n * Selects and scrolls to a result.\n * @param result The result to select.\n * @returns Whether a result was selected.\n */\n private _selectResult(result: ISearchResult | undefined, options?: ISearchDecorationOptions, noScroll?: boolean): boolean {\n if (!this._terminal || !this._decorationManager) {\n return false;\n }\n\n this._resultTracker.clearSelectedDecoration();\n if (!result) {\n this._terminal.clearSelection();\n return false;\n }\n\n this._terminal.select(result.col, result.row, result.size);\n if (options) {\n const activeDecoration = this._decorationManager.createActiveDecoration(result, options);\n if (activeDecoration) {\n this._resultTracker.selectedDecoration = activeDecoration;\n }\n }\n\n if (!noScroll) {\n // If it is not in the viewport then we scroll else it just gets selected\n if (result.row >= (this._terminal.buffer.active.viewportY + this._terminal.rows) || result.row < this._terminal.buffer.active.viewportY) {\n let scroll = result.row - this._terminal.buffer.active.viewportY;\n scroll -= Math.floor(this._terminal.rows / 2);\n this._terminal.scrollLines(scroll);\n }\n }\n return true;\n }\n}\n"],"names":["root","factory","exports","module","define","amd","globalThis","millis","Promise","resolve","setTimeout","handler","timeout","store","timer","disposable","dispose","Lifecycle_1","toDisposable","clearTimeout","add","__webpack_require__","constructor","this","_token","_isDisposed","cancel","cancelAndSet","runner","Error","setIfNotSet","_isScheduled","set","queueMicrotask","_disposable","undefined","interval","context","handle","setInterval","clearInterval","EventUtils","_listeners","_disposed","event","_event","listener","thisArgs","disposables","entry","fn","slice","push","result","idx","indexOf","splice","Array","isArray","fire","length","call","listeners","i","len","forward","from","to","e","map","any","events","DisposableStore","runAndSubscribe","initial","arg","d","_disposables","Set","isDisposed","o","clear","Disposable","_store","_register","None","Object","freeze","value","_value","DecorationManager","_terminal","super","_highlightDecorations","_highlightedLines","clearHighlightDecorations","createHighlightDecorations","results","options","match","decorations","_createResultDecorations","decoration","_storeDecoration","createActiveDecoration","marker","line","_applyStyles","element","borderColor","isActiveResult","classList","contains","style","outline","decorationRanges","currentCol","col","remainingSize","size","markerOffset","buffer","active","baseY","cursorY","row","amountThisRow","Math","min","cols","range","registerMarker","registerDecoration","x","width","layer","backgroundColor","activeMatchBackground","matchBackground","overviewRulerOptions","has","color","activeMatchColorOverviewRuler","matchOverviewRuler","position","onRender","activeMatchBorder","matchBorder","onDispose","_lineCache","find","term","startRow","startCol","searchOptions","clearSelection","initLinesCache","searchPosition","_findInLine","y","rows","findNextWithSelection","cachedSearchTerm","prevSelectedPos","getSelectionPosition","end","start","findPreviousWithSelection","isReverseSearch","max","_isWholeWord","searchIndex","includes","firstLine","getLine","isWrapped","cache","getLineFromCache","translateBufferLineToStringWithWrap","setLineInCache","stringLine","offsets","offset","_bufferColsToStringOffset","searchTerm","searchStringLine","regex","caseSensitive","toLowerCase","resultIndex","searchRegex","RegExp","foundTerm","exec","lastIndex","lastIndexOf","wholeWord","startRowOffset","endRowOffset","startColOffset","endColOffset","startColIndex","_stringLengthToBufferSize","cell","getCell","char","getChars","nextCell","getWidth","lineIndex","getCode","Async_1","SearchLineCache","_linesCacheTimeout","MutableDisposable","_linesCacheDisposables","_lastAccessTimestamp","_destroyLinesCache","_linesCache","combinedDisposable","onLineFeed","onCursorMove","onResize","Date","now","_scheduleLinesCacheTimeout","delay","disposableTimeout","elapsed","trimRight","strings","lineOffsets","nextLine","lineWrapsToNext","string","translateToString","lastCell","join","Event_1","SearchResultTracker","_searchResults","_onDidChangeResults","Emitter","onDidChangeResults","searchResults","selectedDecoration","_selectedDecoration","updateResults","maxResults","clearResults","clearSelectedDecoration","findResultIndex","fireResultsChanged","hasDecorations","resultCount","reset","_cachedSearchTerm","lastSearchOptions","_lastSearchOptions","isValidSearchTerm","didOptionsChange","newOptions","shouldUpdateHighlighting","clearCachedTerm","__webpack_module_cache__","moduleId","cachedModule","__webpack_modules__","SearchLineCache_1","SearchState_1","SearchEngine_1","DecorationManager_1","SearchResultTracker_1","SearchAddon","_resultTracker","_highlightTimeout","_state","SearchState","_onAfterSearch","onAfterSearch","_onBeforeSearch","onBeforeSearch","_highlightLimit","highlightLimit","activate","terminal","_engine","SearchEngine","_decorationManager","onWriteParsed","_updateMatches","clearDecorations","findPrevious","incremental","noScroll","retainCachedSearchTerm","clearActiveDecoration","findNext","internalSearchOptions","_highlightAllMatches","found","_findNextAndSelect","_fireResults","prevResult","nextCol","nextRow","floor","_selectResult","_findPreviousAndSelect","select","activeDecoration","viewportY","scroll","scrollLines"],"sourceRoot":""} +\ No newline at end of file ++{"version":3,"file":"addon-search.js","mappings":"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","sources":["webpack://SearchAddon/webpack/universalModuleDefinition","webpack://SearchAddon/../src/common/Async.ts","webpack://SearchAddon/../src/common/Event.ts","webpack://SearchAddon/../src/common/Lifecycle.ts","webpack://SearchAddon/./src/DecorationManager.ts","webpack://SearchAddon/./src/SearchEngine.ts","webpack://SearchAddon/./src/SearchLineCache.ts","webpack://SearchAddon/./src/SearchResultTracker.ts","webpack://SearchAddon/./src/SearchState.ts","webpack://SearchAddon/webpack/bootstrap","webpack://SearchAddon/./src/SearchAddon.ts"],"sourcesContent":["(function webpackUniversalModuleDefinition(root, factory) {\n\tif(typeof exports === 'object' && typeof module === 'object')\n\t\tmodule.exports = factory();\n\telse if(typeof define === 'function' && define.amd)\n\t\tdefine([], factory);\n\telse if(typeof exports === 'object')\n\t\texports[\"SearchAddon\"] = factory();\n\telse\n\t\troot[\"SearchAddon\"] = factory();\n})(globalThis, () => {\nreturn ","/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal async helpers for xterm.js core.\n */\n\nimport { DisposableStore, IDisposable, toDisposable } from './Lifecycle';\n\nexport function timeout(millis: number): Promise {\n return new Promise(resolve => setTimeout(resolve, millis));\n}\n\n/**\n * Creates a timeout that can be disposed using its returned value.\n * @param handler The timeout handler.\n * @param timeout An optional timeout in milliseconds.\n * @param store An optional {@link DisposableStore} that will have the timeout disposable managed\n * automatically.\n */\nexport function disposableTimeout(handler: () => void, timeout = 0, store?: DisposableStore): IDisposable {\n const timer = setTimeout(() => {\n handler();\n if (store) {\n disposable.dispose();\n }\n }, timeout);\n const disposable = toDisposable(() => {\n clearTimeout(timer);\n });\n store?.add(disposable);\n return disposable;\n}\n\nexport class TimeoutTimer implements IDisposable {\n private _token: any = -1;\n private _isDisposed = false;\n\n public dispose(): void {\n this.cancel();\n this._isDisposed = true;\n }\n\n public cancel(): void {\n if (this._token !== -1) {\n clearTimeout(this._token);\n this._token = -1;\n }\n }\n\n public cancelAndSet(runner: () => void, timeout: number): void {\n if (this._isDisposed) {\n throw new Error('Calling cancelAndSet on a disposed TimeoutTimer');\n }\n this.cancel();\n this._token = setTimeout(() => {\n this._token = -1;\n runner();\n }, timeout);\n }\n\n public setIfNotSet(runner: () => void, timeout: number): void {\n if (this._isDisposed) {\n throw new Error('Calling setIfNotSet on a disposed TimeoutTimer');\n }\n if (this._token !== -1) {\n return;\n }\n this._token = setTimeout(() => {\n this._token = -1;\n runner();\n }, timeout);\n }\n}\n\n/**\n * Schedules a single runner on the microtask queue. Unlike {@link TimeoutTimer}, a scheduled\n * microtask cannot be unqueued; {@link cancel} prevents the runner from executing if it has not\n * run yet.\n */\nexport class MicrotaskTimer implements IDisposable {\n private _isScheduled = false;\n private _isDisposed = false;\n\n public dispose(): void {\n this.cancel();\n this._isDisposed = true;\n }\n\n public cancel(): void {\n this._isScheduled = false;\n }\n\n public set(runner: () => void): void {\n if (this._isDisposed) {\n throw new Error('Calling set on a disposed MicrotaskTimer');\n }\n if (this._isScheduled) {\n return;\n }\n this._isScheduled = true;\n queueMicrotask(() => {\n if (!this._isScheduled) {\n return;\n }\n this._isScheduled = false;\n runner();\n });\n }\n}\n\nexport class IntervalTimer implements IDisposable {\n private _disposable: IDisposable | undefined;\n private _isDisposed = false;\n\n public cancel(): void {\n this._disposable?.dispose();\n this._disposable = undefined;\n }\n\n public cancelAndSet(runner: () => void, interval: number, context: Window | typeof globalThis = globalThis): void {\n if (this._isDisposed) {\n throw new Error('Calling cancelAndSet on a disposed IntervalTimer');\n }\n this.cancel();\n const handle = context.setInterval(() => {\n runner();\n }, interval);\n this._disposable = {\n dispose: () => {\n context.clearInterval(handle as any);\n this._disposable = undefined;\n }\n };\n }\n\n public dispose(): void {\n this.cancel();\n this._isDisposed = true;\n }\n}\n","/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal event utilities for xterm.js core.\n * Simplified from VS Code's event.ts - no leak detection/profiling.\n */\n\nimport { IDisposable, DisposableStore, toDisposable } from './Lifecycle';\n\nexport interface IEvent {\n (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore): IDisposable;\n}\n\nexport class Emitter {\n private _listeners: { fn: (e: T) => any, thisArgs: any }[] = [];\n private _disposed = false;\n private _event: IEvent | undefined;\n\n public get event(): IEvent {\n if (this._event) {\n return this._event;\n }\n this._event = (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n if (this._disposed) {\n return toDisposable(() => {});\n }\n\n const entry = { fn: listener, thisArgs };\n this._listeners = this._listeners.slice();\n this._listeners.push(entry);\n\n const result = toDisposable(() => {\n const idx = this._listeners.indexOf(entry);\n if (idx !== -1) {\n this._listeners = this._listeners.slice();\n this._listeners.splice(idx, 1);\n }\n });\n\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(result);\n } else {\n disposables.add(result);\n }\n }\n\n return result;\n };\n return this._event;\n }\n\n public fire(event: T): void {\n if (this._disposed || !this._listeners.length) {\n return;\n }\n if (this._listeners.length === 1) {\n this._listeners[0].fn.call(this._listeners[0].thisArgs, event);\n return;\n }\n const listeners = this._listeners;\n for (let i = 0, len = listeners.length; i < len; ++i) {\n listeners[i].fn.call(listeners[i].thisArgs, event);\n }\n }\n\n public dispose(): void {\n if (this._disposed) {\n return;\n }\n this._disposed = true;\n this._listeners.length = 0;\n }\n}\n\nexport namespace EventUtils {\n export function forward(from: IEvent, to: Emitter): IDisposable {\n return from(e => to.fire(e));\n }\n\n export function map(event: IEvent, map: (i: I) => O): IEvent {\n return (listener: (e: O) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n return event(i => listener.call(thisArgs, map(i)), undefined, disposables);\n };\n }\n\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent {\n return (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n const store = new DisposableStore();\n for (const event of events) {\n store.add(event(e => listener.call(thisArgs, e)));\n }\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(store);\n } else {\n disposables.add(store);\n }\n }\n return store;\n };\n }\n\n export function runAndSubscribe(event: IEvent, handler: (e: T) => void, initial: T): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void, initial?: T): IDisposable {\n handler(initial);\n return event(e => handler(e));\n }\n}\n","/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal lifecycle utilities for xterm.js core.\n * Simplified from VS Code's lifecycle.ts - no tracking/leak detection.\n */\n\nexport interface IDisposable {\n dispose(): void;\n}\n\nexport function toDisposable(fn: () => void): IDisposable {\n return { dispose: fn };\n}\n\nexport function dispose(disposable: T): T;\nexport function dispose(disposable: T | undefined): T | undefined;\nexport function dispose(disposables: T[]): T[];\nexport function dispose(arg: T | T[] | undefined): T | T[] | undefined {\n if (!arg) {\n return arg;\n }\n if (Array.isArray(arg)) {\n for (const d of arg) {\n d.dispose();\n }\n return [];\n }\n arg.dispose();\n return arg;\n}\n\nexport function combinedDisposable(...disposables: IDisposable[]): IDisposable {\n return toDisposable(() => dispose(disposables));\n}\n\nexport class DisposableStore implements IDisposable {\n private readonly _disposables = new Set();\n private _isDisposed = false;\n\n public get isDisposed(): boolean {\n return this._isDisposed;\n }\n\n public add(o: T): T {\n if (this._isDisposed) {\n o.dispose();\n } else {\n this._disposables.add(o);\n }\n return o;\n }\n\n public dispose(): void {\n if (this._isDisposed) {\n return;\n }\n this._isDisposed = true;\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n\n public clear(): void {\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n}\n\nexport abstract class Disposable implements IDisposable {\n public static readonly None: IDisposable = Object.freeze({ dispose() { } });\n\n protected readonly _store = new DisposableStore();\n\n public dispose(): void {\n this._store.dispose();\n }\n\n protected _register(o: T): T {\n return this._store.add(o);\n }\n}\n\nexport class MutableDisposable implements IDisposable {\n private _value: T | undefined;\n private _isDisposed = false;\n\n public get value(): T | undefined {\n return this._isDisposed ? undefined : this._value;\n }\n\n public set value(value: T | undefined) {\n if (this._isDisposed || value === this._value) {\n return;\n }\n this._value?.dispose();\n this._value = value;\n }\n\n public clear(): void {\n this.value = undefined;\n }\n\n public dispose(): void {\n this._isDisposed = true;\n this._value?.dispose();\n this._value = undefined;\n }\n}\n","/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { Terminal, IDisposable, IDecoration } from '@xterm/xterm';\nimport type { ISearchDecorationOptions } from '@xterm/addon-search';\nimport { dispose, Disposable, toDisposable } from 'common/Lifecycle';\nimport type { ISearchResult } from './SearchEngine';\n\n/**\n * Interface for managing a highlight decoration.\n */\ninterface IHighlight extends IDisposable {\n decoration: IDecoration;\n match: ISearchResult;\n}\n\n/**\n * Interface for managing multiple decorations for a single match.\n */\ninterface IMultiHighlight extends IDisposable {\n decorations: IDecoration[];\n match: ISearchResult;\n}\n\n/**\n * Manages visual decorations for search results including highlighting and active selection\n * indicators. This class handles the creation, styling, and disposal of search-related decorations.\n */\nexport class DecorationManager extends Disposable {\n private _highlightDecorations: IHighlight[] = [];\n private _highlightedLines: Set = new Set();\n\n constructor(private readonly _terminal: Terminal) {\n super();\n this._register(toDisposable(() => this.clearHighlightDecorations()));\n }\n\n /**\n * Creates decorations for all provided search results.\n * @param results The search results to create decorations for.\n * @param options The decoration options.\n */\n public createHighlightDecorations(results: ISearchResult[], options: ISearchDecorationOptions): void {\n this.clearHighlightDecorations();\n\n for (const match of results) {\n const decorations = this._createResultDecorations(match, options, false);\n if (decorations) {\n for (const decoration of decorations) {\n this._storeDecoration(decoration, match);\n }\n }\n }\n }\n\n /**\n * Creates decorations for the currently active search result.\n * @param result The active search result.\n * @param options The decoration options.\n * @returns The multi-highlight decoration or undefined if creation failed.\n */\n public createActiveDecoration(result: ISearchResult, options: ISearchDecorationOptions): IMultiHighlight | undefined {\n const decorations = this._createResultDecorations(result, options, true);\n if (decorations) {\n return { decorations, match: result, dispose() { dispose(decorations); } };\n }\n return undefined;\n }\n\n /**\n * Clears all highlight decorations.\n */\n public clearHighlightDecorations(): void {\n dispose(this._highlightDecorations);\n this._highlightDecorations = [];\n this._highlightedLines.clear();\n }\n\n /**\n * Stores a decoration and tracks it for management.\n * @param decoration The decoration to store.\n * @param match The search result this decoration represents.\n */\n private _storeDecoration(decoration: IDecoration, match: ISearchResult): void {\n this._highlightedLines.add(decoration.marker.line);\n this._highlightDecorations.push({ decoration, match, dispose() { decoration.dispose(); } });\n }\n\n /**\n * Applies styles to the decoration when it is rendered.\n * @param element The decoration's element.\n * @param borderColor The border color to apply.\n * @param isActiveResult Whether the element is part of the active search result.\n */\n private _applyStyles(element: HTMLElement, borderColor: string | undefined, isActiveResult: boolean): void {\n if (!element.classList.contains('xterm-find-result-decoration')) {\n element.classList.add('xterm-find-result-decoration');\n if (borderColor) {\n element.style.outline = `1px solid ${borderColor}`;\n }\n }\n if (isActiveResult) {\n element.classList.add('xterm-find-active-result-decoration');\n }\n }\n\n /**\n * Creates a decoration for the result and applies styles\n * @param result the search result for which to create the decoration\n * @param options the options for the decoration\n * @param isActiveResult whether this is the currently active result\n * @returns the decorations or undefined if the marker has already been disposed of\n */\n private _createResultDecorations(result: ISearchResult, options: ISearchDecorationOptions, isActiveResult: boolean): IDecoration[] | undefined {\n // Gather decoration ranges for this match as it could wrap\n const decorationRanges: [number, number, number][] = [];\n let currentCol = result.col;\n let remainingSize = result.size;\n let markerOffset = -this._terminal.buffer.active.baseY - this._terminal.buffer.active.cursorY + result.row;\n while (remainingSize > 0) {\n const amountThisRow = Math.min(this._terminal.cols - currentCol, remainingSize);\n decorationRanges.push([markerOffset, currentCol, amountThisRow]);\n currentCol = 0;\n remainingSize -= amountThisRow;\n markerOffset++;\n }\n\n // Create the decorations\n const decorations: IDecoration[] = [];\n for (const range of decorationRanges) {\n const marker = this._terminal.registerMarker(range[0]);\n const decoration = this._terminal.registerDecoration({\n marker,\n x: range[1],\n width: range[2],\n layer: isActiveResult ? 'top' : 'bottom',\n backgroundColor: isActiveResult ? options.activeMatchBackground : options.matchBackground,\n overviewRulerOptions: this._highlightedLines.has(marker.line) ? undefined : {\n color: isActiveResult ? options.activeMatchColorOverviewRuler : options.matchOverviewRuler,\n position: 'center'\n }\n });\n if (decoration) {\n const disposables: IDisposable[] = [];\n disposables.push(marker);\n disposables.push(decoration.onRender((e) => this._applyStyles(e, isActiveResult ? options.activeMatchBorder : options.matchBorder, false)));\n disposables.push(decoration.onDispose(() => dispose(disposables)));\n decorations.push(decoration);\n }\n }\n\n return decorations.length === 0 ? undefined : decorations;\n }\n}\n\n\n","/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { Terminal } from '@xterm/xterm';\nimport type { ISearchOptions } from '@xterm/addon-search';\nimport type { SearchLineCache } from './SearchLineCache';\n\n/**\n * Represents the position to start a search from.\n */\ninterface ISearchPosition {\n startCol: number;\n startRow: number;\n}\n\n/**\n * Represents a search result with its position and content.\n */\nexport interface ISearchResult {\n term: string;\n col: number;\n row: number;\n size: number;\n}\n\n/**\n * Configuration constants for the search engine functionality.\n */\nconst enum Constants {\n /**\n * Characters that are considered non-word characters for search boundary detection. These\n * characters are used to determine word boundaries when performing whole-word searches. Includes\n * common punctuation, symbols, and whitespace characters.\n */\n NON_WORD_CHARACTERS = ' ~!@#$%^&*()+`-=[]{}|\\\\;:\"\\',./<>?'\n}\n\n/**\n * Core search engine that handles finding text within terminal content.\n * This class is responsible for the actual search algorithms and position calculations.\n */\nexport class SearchEngine {\n constructor(\n private readonly _terminal: Terminal,\n private readonly _lineCache: SearchLineCache\n ) {}\n\n /**\n * Find the first occurrence of a term starting from a specific position.\n * @param term The search term.\n * @param startRow The row to start searching from.\n * @param startCol The column to start searching from.\n * @param searchOptions Search options.\n * @returns The search result if found, undefined otherwise.\n */\n public find(term: string, startRow: number, startCol: number, searchOptions?: ISearchOptions): ISearchResult | undefined {\n if (!term || term.length === 0) {\n this._terminal.clearSelection();\n return undefined;\n }\n if (startCol >= this._terminal.cols) {\n throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`);\n }\n\n this._lineCache.initLinesCache();\n\n const searchPosition: ISearchPosition = {\n startRow,\n startCol\n };\n\n // Search startRow\n let result = this._findInLine(term, searchPosition, searchOptions);\n // Search from startRow + 1 to end\n if (!result) {\n for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {\n if (this._isRowCoveredByEarlierSearch(y)) {\n continue;\n }\n searchPosition.startRow = y;\n searchPosition.startCol = 0;\n result = this._findInLine(term, searchPosition, searchOptions);\n if (result) {\n break;\n }\n }\n }\n return result;\n }\n\n /**\n * Find the next occurrence of a term with wrapping and selection management.\n * @param term The search term.\n * @param searchOptions Search options.\n * @param cachedSearchTerm The cached search term to determine incremental behavior.\n * @returns The search result if found, undefined otherwise.\n */\n public findNextWithSelection(term: string, searchOptions?: ISearchOptions, cachedSearchTerm?: string): ISearchResult | undefined {\n if (!term || term.length === 0) {\n this._terminal.clearSelection();\n return undefined;\n }\n\n const prevSelectedPos = this._terminal.getSelectionPosition();\n this._terminal.clearSelection();\n\n let startCol = 0;\n let startRow = 0;\n if (prevSelectedPos) {\n if (cachedSearchTerm === term) {\n startCol = prevSelectedPos.end.x;\n startRow = prevSelectedPos.end.y;\n } else {\n startCol = prevSelectedPos.start.x;\n startRow = prevSelectedPos.start.y;\n }\n }\n\n this._lineCache.initLinesCache();\n\n const searchPosition: ISearchPosition = {\n startRow,\n startCol\n };\n\n // Search startRow\n let result = this._findInLine(term, searchPosition, searchOptions);\n // Search from startRow + 1 to end\n if (!result) {\n for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {\n if (this._isRowCoveredByEarlierSearch(y)) {\n continue;\n }\n searchPosition.startRow = y;\n searchPosition.startCol = 0;\n result = this._findInLine(term, searchPosition, searchOptions);\n if (result) {\n break;\n }\n }\n }\n // If we hit the bottom and didn't search from the very top wrap back up\n if (!result && startRow !== 0) {\n for (let y = 0; y < startRow; y++) {\n // Row 0 is never skipped: it can be a continuation whose line start was trimmed from the\n // scrollback, and nothing earlier in this loop has searched it.\n if (y > 0 && this._isRowCoveredByEarlierSearch(y)) {\n continue;\n }\n searchPosition.startRow = y;\n searchPosition.startCol = 0;\n result = this._findInLine(term, searchPosition, searchOptions);\n if (result) {\n break;\n }\n }\n }\n\n // If there is only one result, wrap back and return selection if it exists.\n if (!result && prevSelectedPos) {\n searchPosition.startRow = prevSelectedPos.start.y;\n searchPosition.startCol = 0;\n result = this._findInLine(term, searchPosition, searchOptions);\n }\n\n return result;\n }\n\n /**\n * Find the previous occurrence of a term with wrapping and selection management.\n * @param term The search term.\n * @param searchOptions Search options.\n * @param cachedSearchTerm The cached search term to determine if expansion should occur.\n * @returns The search result if found, undefined otherwise.\n */\n public findPreviousWithSelection(term: string, searchOptions?: ISearchOptions, cachedSearchTerm?: string): ISearchResult | undefined {\n if (!term || term.length === 0) {\n this._terminal.clearSelection();\n return undefined;\n }\n\n const prevSelectedPos = this._terminal.getSelectionPosition();\n this._terminal.clearSelection();\n\n let startRow = this._terminal.buffer.active.baseY + this._terminal.rows - 1;\n const startCol = this._terminal.cols;\n const isReverseSearch = true;\n\n this._lineCache.initLinesCache();\n const searchPosition: ISearchPosition = {\n startRow,\n startCol\n };\n\n let result: ISearchResult | undefined;\n if (prevSelectedPos) {\n searchPosition.startRow = startRow = prevSelectedPos.start.y;\n searchPosition.startCol = prevSelectedPos.start.x;\n if (cachedSearchTerm !== term) {\n // Try to expand selection to right first.\n result = this._findInLine(term, searchPosition, searchOptions, false);\n if (!result) {\n // If selection was not able to be expanded to the right, then try reverse search\n searchPosition.startRow = startRow = prevSelectedPos.end.y;\n searchPosition.startCol = prevSelectedPos.end.x;\n }\n }\n }\n\n result ??= this._findInLine(term, searchPosition, searchOptions, isReverseSearch);\n\n // Search from startRow - 1 to top\n if (!result) {\n searchPosition.startCol = Math.max(searchPosition.startCol, this._terminal.cols);\n for (let y = startRow - 1; y >= 0; y--) {\n searchPosition.startRow = y;\n result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);\n if (result) {\n break;\n }\n }\n }\n // If we hit the top and didn't search from the very bottom wrap back down\n if (!result && startRow !== (this._terminal.buffer.active.baseY + this._terminal.rows - 1)) {\n for (let y = (this._terminal.buffer.active.baseY + this._terminal.rows - 1); y >= startRow; y--) {\n searchPosition.startRow = y;\n result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);\n if (result) {\n break;\n }\n }\n }\n\n return result;\n }\n\n /**\n * A found substring is a whole word if it doesn't have an alphanumeric character directly\n * adjacent to it.\n * @param searchIndex starting index of the potential whole word substring\n * @param line entire string in which the potential whole word was found\n * @param term the substring that starts at searchIndex\n */\n private _isWholeWord(searchIndex: number, line: string, term: string): boolean {\n return ((searchIndex === 0) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex - 1]))) &&\n (((searchIndex + term.length) === line.length) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex + term.length])));\n }\n\n /** `_isWholeWord` gated on the option, so a rejected hit can be stepped past instead of ending the scan. */\n private _satisfiesWholeWord(searchIndex: number, line: string, term: string, searchOptions: ISearchOptions): boolean {\n return !searchOptions.wholeWord || this._isWholeWord(searchIndex, line, term);\n }\n\n /**\n * Whether an earlier `_findInLine` in this same call already scanned this row's line from an\n * equal or lower offset, which makes rescanning it pure O(rows^2) work on one long line. Sound\n * for every option because `_findInLine` returns the first accepted match at or after its\n * offset, which is monotone in that offset. Only valid once such a search has happened — the\n * wrap-around loop starts at row 0, whose line start may have been trimmed from the scrollback.\n */\n private _isRowCoveredByEarlierSearch(row: number): boolean {\n return this._terminal.buffer.active.getLine(row)?.isWrapped === true;\n }\n\n /**\n * Searches a line for a search term. Takes the provided terminal line and searches the text line,\n * which may contain subsequent terminal lines if the text is wrapped. If the provided line number\n * is part of a wrapped text line that started on an earlier line then it is skipped since it will\n * be properly searched when the terminal line that the text starts on is searched.\n * @param term The search term.\n * @param searchPosition The position to start the search.\n * @param searchOptions Search options.\n * @param isReverseSearch Whether the search should start from the right side of the terminal and\n * search to the left.\n * @returns The search result if it was found.\n */\n private _findInLine(term: string, searchPosition: ISearchPosition, searchOptions: ISearchOptions = {}, isReverseSearch: boolean = false): ISearchResult | undefined {\n // Ignore wrapped lines, only consider on unwrapped line (first row of command string).\n if (isReverseSearch) {\n // Reverse search never rewinds: its caller carries startCol down the rows of the line. Row 0\n // is searched even when wrapped, since its line start may have been trimmed from the scrollback.\n if (searchPosition.startRow > 0 && this._terminal.buffer.active.getLine(searchPosition.startRow)?.isWrapped) {\n searchPosition.startCol += this._terminal.cols;\n return;\n }\n } else {\n // A loop rather than recursion: one frame per wrapped row overflows the stack on a line long\n // enough to fill the scrollback. Bounded at row 0 because after a reflow the buffer's ring\n // holds stale entries at negative indices, so `getLine(-1)` answers with a wrapped line.\n while (searchPosition.startRow > 0 && this._terminal.buffer.active.getLine(searchPosition.startRow)?.isWrapped) {\n searchPosition.startRow--;\n searchPosition.startCol += this._terminal.cols;\n }\n }\n const row = searchPosition.startRow;\n const col = searchPosition.startCol;\n\n let cache = this._lineCache.getLineFromCache(row);\n if (!cache) {\n cache = this._lineCache.translateBufferLineToStringWithWrap(row, true);\n this._lineCache.setLineInCache(row, cache);\n }\n const [stringLine, offsets] = cache;\n\n const offset = this._bufferColsToStringOffset(row, col, offsets);\n let searchTerm = term;\n let searchStringLine = stringLine;\n if (!searchOptions.regex) {\n searchTerm = searchOptions.caseSensitive ? term : term.toLowerCase();\n searchStringLine = searchOptions.caseSensitive ? stringLine : stringLine.toLowerCase();\n }\n\n let resultIndex = -1;\n if (searchOptions.regex) {\n const searchRegex = RegExp(searchTerm, searchOptions.caseSensitive ? 'g' : 'gi');\n let foundTerm: RegExpExecArray | null;\n if (isReverseSearch) {\n // This loop will get the resultIndex of the _last_ regex match in the range 0..offset\n while (foundTerm = searchRegex.exec(searchStringLine.slice(0, offset))) {\n const matchIndex = searchRegex.lastIndex - foundTerm[0].length;\n if (foundTerm[0].length > 0 && this._satisfiesWholeWord(matchIndex, searchStringLine, foundTerm[0], searchOptions)) {\n resultIndex = matchIndex;\n term = foundTerm[0];\n }\n searchRegex.lastIndex = matchIndex + 1;\n }\n } else {\n // Driven over the whole line from `offset` rather than over `slice(offset)`: a slice\n // re-anchors ^ and \\b at whatever column the row happened to wrap at, and only\n // first-accepted-match-at-or-after-offset is monotone in `offset`, which is what lets\n // `_isRowCoveredByEarlierSearch` skip a wrapped row an earlier scan already covered.\n searchRegex.lastIndex = offset;\n while (foundTerm = searchRegex.exec(searchStringLine)) {\n const matchIndex = searchRegex.lastIndex - foundTerm[0].length;\n if (foundTerm[0].length > 0 && this._satisfiesWholeWord(matchIndex, searchStringLine, foundTerm[0], searchOptions)) {\n resultIndex = matchIndex;\n term = foundTerm[0];\n break;\n }\n // A zero-length or rejected match would otherwise repeat forever.\n searchRegex.lastIndex = matchIndex + 1;\n }\n }\n } else if (isReverseSearch) {\n let matchIndex = offset - searchTerm.length >= 0 ? searchStringLine.lastIndexOf(searchTerm, offset - searchTerm.length) : -1;\n // `lastIndexOf` clamps a negative fromIndex to 0, so index 0 has to end the walk.\n while (matchIndex >= 0 && !this._satisfiesWholeWord(matchIndex, searchStringLine, searchTerm, searchOptions)) {\n matchIndex = matchIndex > 0 ? searchStringLine.lastIndexOf(searchTerm, matchIndex - 1) : -1;\n }\n resultIndex = matchIndex;\n } else {\n let matchIndex = searchStringLine.indexOf(searchTerm, offset);\n while (matchIndex >= 0 && !this._satisfiesWholeWord(matchIndex, searchStringLine, searchTerm, searchOptions)) {\n matchIndex = searchStringLine.indexOf(searchTerm, matchIndex + 1);\n }\n resultIndex = matchIndex;\n }\n\n if (resultIndex >= 0) {\n // Adjust the row number and search index if needed since a \"line\" of text can span multiple\n // rows\n let startRowOffset = 0;\n while (startRowOffset < offsets.length - 1 && resultIndex >= offsets[startRowOffset + 1]) {\n startRowOffset++;\n }\n let endRowOffset = startRowOffset;\n while (endRowOffset < offsets.length - 1 && resultIndex + term.length >= offsets[endRowOffset + 1]) {\n endRowOffset++;\n }\n const startColOffset = resultIndex - offsets[startRowOffset];\n const endColOffset = resultIndex + term.length - offsets[endRowOffset];\n const startColIndex = this._stringLengthToBufferSize(row + startRowOffset, startColOffset);\n const endColIndex = this._stringLengthToBufferSize(row + endRowOffset, endColOffset);\n const size = endColIndex - startColIndex + this._terminal.cols * (endRowOffset - startRowOffset);\n\n return {\n term,\n col: startColIndex,\n row: row + startRowOffset,\n size\n };\n }\n }\n\n private _stringLengthToBufferSize(row: number, offset: number): number {\n const line = this._terminal.buffer.active.getLine(row);\n if (!line) {\n return 0;\n }\n for (let i = 0; i < offset; i++) {\n const cell = line.getCell(i);\n if (!cell) {\n break;\n }\n // Adjust the searchIndex to normalize emoji into single chars\n const char = cell.getChars();\n if (char.length > 1) {\n offset -= char.length - 1;\n }\n // Adjust the searchIndex for empty characters following wide unicode\n // chars (eg. CJK)\n const nextCell = line.getCell(i + 1);\n if (nextCell && nextCell.getWidth() === 0) {\n offset++;\n }\n }\n return offset;\n }\n\n /**\n * `cols` counts from the start of the logical line, so summing the cells of every row before the\n * resume point costs O(line) per call and the highlight-all pass makes one call per match.\n * `lineOffsets` already holds the string offset each wrapped row starts at — the same map used\n * above to turn a match index back into a row — so only the last, partial row needs cells. It is\n * also the map the row a match lands on is read from, which the cell sum disagreed with by one\n * for a row whose trailing cell is the null placeholder of a wide character that wrapped.\n */\n private _bufferColsToStringOffset(startRow: number, cols: number, lineOffsets: number[]): number {\n const rowsBack = Math.min(Math.floor(cols / this._terminal.cols), lineOffsets.length - 1);\n let offset = lineOffsets[rowsBack];\n const line = this._terminal.buffer.active.getLine(startRow + rowsBack);\n if (line) {\n const colsInRow = Math.min(cols - rowsBack * this._terminal.cols, this._terminal.cols);\n for (let i = 0; i < colsInRow; i++) {\n const cell = line.getCell(i);\n if (!cell) {\n break;\n }\n if (cell.getWidth()) {\n // Treat null characters as whitespace to align with the translateToString API\n offset += cell.getCode() === 0 ? 1 : cell.getChars().length;\n }\n }\n }\n return offset;\n }\n}\n","/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { Terminal } from '@xterm/xterm';\nimport { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\nimport { disposableTimeout } from 'common/Async';\n\nexport type LineCacheEntry = [\n /**\n * The string representation of a line (as opposed to the buffer cell representation).\n */\n lineAsString: string,\n /**\n * The offsets where each line starts when the entry describes a wrapped line.\n */\n lineOffsets: number[]\n];\n\n/**\n * Configuration constants for the search line cache functionality.\n */\nconst enum Constants {\n /**\n * Time-to-live for cached search results in milliseconds. After this duration, cached search\n * results will be invalidated to ensure they remain consistent with terminal content changes.\n */\n LINES_CACHE_TIME_TO_LIVE = 15000\n}\n\nexport class SearchLineCache extends Disposable {\n /**\n * translateBufferLineToStringWithWrap is a fairly expensive call.\n * We memoize the calls into an array that has a time based ttl.\n * _linesCache is also invalidated when the terminal cursor moves.\n */\n private _linesCache: LineCacheEntry[] | undefined;\n private _linesCacheTimeout = this._register(new MutableDisposable());\n private _linesCacheDisposables = this._register(new MutableDisposable());\n // Track access to avoid recreating a timeout on every init call which occurs once per search\n // result (findNext/findPrevious -> _highlightAllMatches -> find loop).\n private _lastAccessTimestamp = 0;\n\n constructor(private readonly _terminal: Terminal) {\n super();\n this._register(toDisposable(() => this._destroyLinesCache()));\n }\n\n /**\n * Sets up a line cache with a ttl\n */\n public initLinesCache(): void {\n if (!this._linesCache) {\n this._linesCache = new Array(this._terminal.buffer.active.length);\n this._linesCacheDisposables.value = combinedDisposable(\n this._terminal.onLineFeed(() => this._destroyLinesCache()),\n this._terminal.onCursorMove(() => this._destroyLinesCache()),\n this._terminal.onResize(() => this._destroyLinesCache())\n );\n }\n\n this._lastAccessTimestamp = Date.now();\n if (!this._linesCacheTimeout.value) {\n this._scheduleLinesCacheTimeout(Constants.LINES_CACHE_TIME_TO_LIVE);\n }\n }\n\n private _destroyLinesCache(): void {\n this._linesCache = undefined;\n this._lastAccessTimestamp = 0;\n this._linesCacheDisposables.clear();\n this._linesCacheTimeout.clear();\n }\n\n private _scheduleLinesCacheTimeout(delay: number): void {\n this._linesCacheTimeout.value = disposableTimeout(() => {\n if (!this._linesCache) {\n return;\n }\n const now = Date.now();\n const elapsed = now - this._lastAccessTimestamp;\n if (elapsed >= Constants.LINES_CACHE_TIME_TO_LIVE) {\n this._destroyLinesCache();\n return;\n }\n this._scheduleLinesCacheTimeout(Constants.LINES_CACHE_TIME_TO_LIVE - elapsed);\n }, delay);\n }\n\n public getLineFromCache(row: number): LineCacheEntry | undefined {\n return this._linesCache?.[row];\n }\n\n public setLineInCache(row: number, entry: LineCacheEntry): void {\n if (this._linesCache) {\n this._linesCache[row] = entry;\n }\n }\n\n /**\n * Translates a buffer line to a string, including subsequent lines if they are wraps.\n * Wide characters will count as two columns in the resulting string. This\n * function is useful for getting the actual text underneath the raw selection\n * position.\n * @param lineIndex The index of the line being translated.\n * @param trimRight Whether to trim whitespace to the right.\n */\n public translateBufferLineToStringWithWrap(lineIndex: number, trimRight: boolean): LineCacheEntry {\n const strings = [];\n const lineOffsets = [0];\n // A single line longer than the whole scrollback leaves every buffer row wrapped, and the\n // buffer's ring answers an out-of-range row by cycling back to the start, so an unbounded walk\n // never reaches an unwrapped line.\n const bufferLength = this._terminal.buffer.active.length;\n let line = this._terminal.buffer.active.getLine(lineIndex);\n while (line) {\n const nextLine = lineIndex + 1 < bufferLength ? this._terminal.buffer.active.getLine(lineIndex + 1) : undefined;\n const lineWrapsToNext = nextLine ? nextLine.isWrapped : false;\n let string = line.translateToString(!lineWrapsToNext && trimRight);\n if (lineWrapsToNext && nextLine) {\n const lastCell = line.getCell(line.length - 1);\n const lastCellIsNull = lastCell && lastCell.getCode() === 0 && lastCell.getWidth() === 1;\n // a wide character wrapped to the next line\n if (lastCellIsNull && nextLine.getCell(0)?.getWidth() === 2) {\n string = string.slice(0, -1);\n }\n }\n strings.push(string);\n if (lineWrapsToNext) {\n lineOffsets.push(lineOffsets[lineOffsets.length - 1] + string.length);\n } else {\n break;\n }\n lineIndex++;\n line = nextLine;\n }\n return [strings.join(''), lineOffsets];\n }\n}\n","/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ISearchResultChangeEvent } from '@xterm/addon-search';\nimport type { IDisposable } from '@xterm/xterm';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { Disposable } from 'common/Lifecycle';\nimport type { ISearchResult } from './SearchEngine';\n\n/**\n * Interface for managing a currently selected decoration.\n */\ninterface ISelectedDecoration extends IDisposable {\n match: ISearchResult;\n}\n\n/**\n * Tracks search results, manages result indexing, and fires events when results change.\n * This class provides centralized management of search result state and notifications.\n */\nexport class SearchResultTracker extends Disposable {\n private _searchResults: ISearchResult[] = [];\n private _selectedDecoration: ISelectedDecoration | undefined;\n\n private readonly _onDidChangeResults = this._register(new Emitter());\n public get onDidChangeResults(): IEvent { return this._onDidChangeResults.event; }\n\n /**\n * Gets the current search results.\n */\n public get searchResults(): ReadonlyArray {\n return this._searchResults;\n }\n\n /**\n * Gets the currently selected decoration.\n */\n public get selectedDecoration(): ISelectedDecoration | undefined {\n return this._selectedDecoration;\n }\n\n /**\n * Sets the currently selected decoration.\n */\n public set selectedDecoration(decoration: ISelectedDecoration | undefined) {\n this._selectedDecoration = decoration;\n }\n\n /**\n * Updates the search results with a new set of results.\n * @param results The new search results.\n * @param maxResults The maximum number of results to track.\n */\n public updateResults(results: ISearchResult[], maxResults: number): void {\n this._searchResults = results.slice(0, maxResults);\n }\n\n /**\n * Clears all search results.\n */\n public clearResults(): void {\n this._searchResults = [];\n }\n\n /**\n * Clears the selected decoration.\n */\n public clearSelectedDecoration(): void {\n if (this._selectedDecoration) {\n this._selectedDecoration.dispose();\n this._selectedDecoration = undefined;\n }\n }\n\n /**\n * Finds the index of a result in the current results array.\n * @param result The result to find.\n * @returns The index of the result, or -1 if not found.\n */\n public findResultIndex(result: ISearchResult): number {\n for (let i = 0; i < this._searchResults.length; i++) {\n const match = this._searchResults[i];\n if (match.row === result.row && match.col === result.col && match.size === result.size) {\n return i;\n }\n }\n return -1;\n }\n\n /**\n * Fires a result change event with the current state.\n * @param hasDecorations Whether decorations are enabled.\n */\n public fireResultsChanged(hasDecorations: boolean): void {\n if (!hasDecorations) {\n return;\n }\n\n let resultIndex = -1;\n if (this._selectedDecoration) {\n resultIndex = this.findResultIndex(this._selectedDecoration.match);\n }\n\n this._onDidChangeResults.fire({\n resultIndex,\n resultCount: this._searchResults.length\n });\n }\n\n /**\n * Resets all state.\n */\n public reset(): void {\n this.clearSelectedDecoration();\n this.clearResults();\n }\n}\n","/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ISearchOptions } from '@xterm/addon-search';\n\n/**\n * Manages search state including cached search terms, options tracking, and validation.\n * This class provides a centralized way to handle search state consistency and option changes.\n */\nexport class SearchState {\n private _cachedSearchTerm: string | undefined;\n private _lastSearchOptions: ISearchOptions | undefined;\n\n /**\n * Gets the currently cached search term.\n */\n public get cachedSearchTerm(): string | undefined {\n return this._cachedSearchTerm;\n }\n\n /**\n * Sets the cached search term.\n */\n public set cachedSearchTerm(term: string | undefined) {\n this._cachedSearchTerm = term;\n }\n\n /**\n * Gets the last search options used.\n */\n public get lastSearchOptions(): ISearchOptions | undefined {\n return this._lastSearchOptions;\n }\n\n /**\n * Sets the last search options used.\n */\n public set lastSearchOptions(options: ISearchOptions | undefined) {\n this._lastSearchOptions = options;\n }\n\n /**\n * Validates a search term to ensure it's not empty or invalid.\n * @param term The search term to validate.\n * @returns true if the term is valid for searching.\n */\n public isValidSearchTerm(term: string): boolean {\n return !!(term && term.length > 0);\n }\n\n /**\n * Determines if search options have changed compared to the last search.\n * @param newOptions The new search options to compare.\n * @returns true if the options have changed.\n */\n public didOptionsChange(newOptions?: ISearchOptions): boolean {\n if (!this._lastSearchOptions) {\n return true;\n }\n if (!newOptions) {\n return false;\n }\n if (this._lastSearchOptions.caseSensitive !== newOptions.caseSensitive) {\n return true;\n }\n if (this._lastSearchOptions.regex !== newOptions.regex) {\n return true;\n }\n if (this._lastSearchOptions.wholeWord !== newOptions.wholeWord) {\n return true;\n }\n return false;\n }\n\n /**\n * Determines if a new search should trigger highlighting updates.\n * @param term The search term.\n * @param options The search options.\n * @returns true if highlighting should be updated.\n */\n public shouldUpdateHighlighting(term: string, options?: ISearchOptions): boolean {\n if (!options?.decorations) {\n return false;\n }\n return this._cachedSearchTerm === undefined ||\n term !== this._cachedSearchTerm ||\n this.didOptionsChange(options);\n }\n\n /**\n * Clears the cached search term.\n */\n public clearCachedTerm(): void {\n this._cachedSearchTerm = undefined;\n }\n\n /**\n * Resets all state.\n */\n public reset(): void {\n this._cachedSearchTerm = undefined;\n this._lastSearchOptions = undefined;\n }\n}\n","// The module cache\nvar __webpack_module_cache__ = {};\n\n// The require function\nfunction __webpack_require__(moduleId) {\n\t// Check if module is in cache\n\tvar cachedModule = __webpack_module_cache__[moduleId];\n\tif (cachedModule !== undefined) {\n\t\treturn cachedModule.exports;\n\t}\n\t// Create a new module (and put it into the cache)\n\tvar module = __webpack_module_cache__[moduleId] = {\n\t\t// no module.id needed\n\t\t// no module.loaded needed\n\t\texports: {}\n\t};\n\n\t// Execute the module function\n\t__webpack_modules__[moduleId](module, module.exports, __webpack_require__);\n\n\t// Return the exports of the module\n\treturn module.exports;\n}\n\n","/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { Terminal, IDisposable, ITerminalAddon } from '@xterm/xterm';\nimport type { SearchAddon as ISearchApi, ISearchOptions, ISearchAddonOptions, ISearchResultChangeEvent, ISearchDecorationOptions } from '@xterm/addon-search';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\nimport { disposableTimeout } from 'common/Async';\nimport { SearchLineCache } from './SearchLineCache';\nimport { SearchState } from './SearchState';\nimport { SearchEngine, type ISearchResult } from './SearchEngine';\nimport { DecorationManager } from './DecorationManager';\nimport { SearchResultTracker } from './SearchResultTracker';\n\ninterface IInternalSearchOptions {\n noScroll: boolean;\n}\n\n/**\n * Configuration constants for the search addon functionality.\n */\nconst enum Constants {\n /**\n * Default maximum number of search results to highlight simultaneously. This limit prevents\n * performance degradation when searching for very common terms that would result in excessive\n * highlighting decorations.\n */\n DEFAULT_HIGHLIGHT_LIMIT = 1000\n}\n\nexport class SearchAddon extends Disposable implements ITerminalAddon, ISearchApi {\n private _terminal: Terminal | undefined;\n private _highlightLimit: number;\n private _highlightTimeout = this._register(new MutableDisposable());\n private _lineCache = this._register(new MutableDisposable());\n\n // Component instances\n private _state = new SearchState();\n private _engine: SearchEngine | undefined;\n private _decorationManager: DecorationManager | undefined;\n private _resultTracker = this._register(new SearchResultTracker());\n\n private readonly _onAfterSearch = this._register(new Emitter());\n public readonly onAfterSearch = this._onAfterSearch.event;\n private readonly _onBeforeSearch = this._register(new Emitter());\n public readonly onBeforeSearch = this._onBeforeSearch.event;\n\n public get onDidChangeResults(): IEvent {\n return this._resultTracker.onDidChangeResults;\n }\n\n constructor(options?: Partial) {\n super();\n\n this._highlightLimit = options?.highlightLimit ?? Constants.DEFAULT_HIGHLIGHT_LIMIT;\n }\n\n public activate(terminal: Terminal): void {\n this._terminal = terminal;\n this._lineCache.value = new SearchLineCache(terminal);\n this._engine = new SearchEngine(terminal, this._lineCache.value);\n this._decorationManager = new DecorationManager(terminal);\n this._register(this._terminal.onWriteParsed(() => this._updateMatches()));\n this._register(this._terminal.onResize(() => this._updateMatches()));\n this._register(toDisposable(() => this.clearDecorations()));\n }\n\n private _updateMatches(): void {\n this._highlightTimeout.clear();\n if (this._state.cachedSearchTerm && this._state.lastSearchOptions?.decorations) {\n this._highlightTimeout.value = disposableTimeout(() => {\n const term = this._state.cachedSearchTerm;\n this._state.clearCachedTerm();\n this.findPrevious(term!, { ...this._state.lastSearchOptions, incremental: true }, { noScroll: true });\n }, 200);\n }\n }\n\n public clearDecorations(retainCachedSearchTerm?: boolean): void {\n this._resultTracker.clearSelectedDecoration();\n this._decorationManager?.clearHighlightDecorations();\n this._resultTracker.clearResults();\n if (!retainCachedSearchTerm) {\n this._state.clearCachedTerm();\n }\n }\n\n public clearActiveDecoration(): void {\n this._resultTracker.clearSelectedDecoration();\n }\n\n /**\n * Find the next instance of the term, then scroll to and select it. If it\n * doesn't exist, do nothing.\n * @param term The search term.\n * @param searchOptions Search options.\n * @returns Whether a result was found.\n */\n public findNext(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {\n if (!this._terminal || !this._engine) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n\n this._onBeforeSearch.fire();\n\n this._state.lastSearchOptions = searchOptions;\n\n if (this._state.shouldUpdateHighlighting(term, searchOptions)) {\n this._highlightAllMatches(term, searchOptions!);\n }\n\n const found = this._findNextAndSelect(term, searchOptions, internalSearchOptions);\n this._fireResults(searchOptions);\n this._state.cachedSearchTerm = term;\n\n this._onAfterSearch.fire();\n\n return found;\n }\n\n private _highlightAllMatches(term: string, searchOptions: ISearchOptions): void {\n if (!this._terminal || !this._engine || !this._decorationManager) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n if (!this._state.isValidSearchTerm(term)) {\n this.clearDecorations();\n return;\n }\n\n // new search, clear out the old decorations\n this.clearDecorations(true);\n\n const results: ISearchResult[] = [];\n let prevResult: ISearchResult | undefined = undefined;\n let result = this._engine.find(term, 0, 0, searchOptions);\n\n while (result && (prevResult?.row !== result.row || prevResult?.col !== result.col)) {\n if (results.length >= this._highlightLimit) {\n break;\n }\n prevResult = result;\n results.push(prevResult);\n const cols = this._terminal.cols;\n let nextCol = prevResult.col + prevResult.size;\n let nextRow = prevResult.row;\n if (nextCol >= cols) {\n nextRow += Math.floor(nextCol / cols);\n nextCol = nextCol % cols;\n }\n result = this._engine.find(term, nextRow, nextCol, searchOptions);\n }\n\n this._resultTracker.updateResults(results, this._highlightLimit);\n if (searchOptions.decorations) {\n this._decorationManager.createHighlightDecorations(results, searchOptions.decorations);\n }\n }\n\n private _findNextAndSelect(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {\n if (!this._terminal || !this._engine) {\n return false;\n }\n if (!this._state.isValidSearchTerm(term)) {\n this._terminal.clearSelection();\n this.clearDecorations();\n return false;\n }\n\n const result = this._engine.findNextWithSelection(term, searchOptions, this._state.cachedSearchTerm);\n return this._selectResult(result, searchOptions?.decorations, internalSearchOptions?.noScroll);\n }\n\n /**\n * Find the previous instance of the term, then scroll to and select it. If it\n * doesn't exist, do nothing.\n * @param term The search term.\n * @param searchOptions Search options.\n * @returns Whether a result was found.\n */\n public findPrevious(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {\n if (!this._terminal || !this._engine) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n\n this._onBeforeSearch.fire();\n\n this._state.lastSearchOptions = searchOptions;\n\n if (this._state.shouldUpdateHighlighting(term, searchOptions)) {\n this._highlightAllMatches(term, searchOptions!);\n }\n\n const found = this._findPreviousAndSelect(term, searchOptions, internalSearchOptions);\n this._fireResults(searchOptions);\n this._state.cachedSearchTerm = term;\n\n this._onAfterSearch.fire();\n\n return found;\n }\n\n private _fireResults(searchOptions?: ISearchOptions): void {\n this._resultTracker.fireResultsChanged(!!searchOptions?.decorations);\n }\n\n private _findPreviousAndSelect(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {\n if (!this._terminal || !this._engine) {\n return false;\n }\n if (!this._state.isValidSearchTerm(term)) {\n this._terminal.clearSelection();\n this.clearDecorations();\n return false;\n }\n\n const result = this._engine.findPreviousWithSelection(term, searchOptions, this._state.cachedSearchTerm);\n return this._selectResult(result, searchOptions?.decorations, internalSearchOptions?.noScroll);\n }\n\n /**\n * Selects and scrolls to a result.\n * @param result The result to select.\n * @returns Whether a result was selected.\n */\n private _selectResult(result: ISearchResult | undefined, options?: ISearchDecorationOptions, noScroll?: boolean): boolean {\n if (!this._terminal || !this._decorationManager) {\n return false;\n }\n\n this._resultTracker.clearSelectedDecoration();\n if (!result) {\n this._terminal.clearSelection();\n return false;\n }\n\n this._terminal.select(result.col, result.row, result.size);\n if (options) {\n const activeDecoration = this._decorationManager.createActiveDecoration(result, options);\n if (activeDecoration) {\n this._resultTracker.selectedDecoration = activeDecoration;\n }\n }\n\n if (!noScroll) {\n // If it is not in the viewport then we scroll else it just gets selected\n if (result.row >= (this._terminal.buffer.active.viewportY + this._terminal.rows) || result.row < this._terminal.buffer.active.viewportY) {\n let scroll = result.row - this._terminal.buffer.active.viewportY;\n scroll -= Math.floor(this._terminal.rows / 2);\n this._terminal.scrollLines(scroll);\n }\n }\n return true;\n }\n}\n"],"names":["root","factory","exports","module","define","amd","globalThis","millis","Promise","resolve","setTimeout","handler","timeout","store","timer","disposable","dispose","Lifecycle_1","toDisposable","clearTimeout","add","__webpack_require__","constructor","this","_token","_isDisposed","cancel","cancelAndSet","runner","Error","setIfNotSet","_isScheduled","set","queueMicrotask","_disposable","undefined","interval","context","handle","setInterval","clearInterval","EventUtils","_listeners","_disposed","event","_event","listener","thisArgs","disposables","entry","fn","slice","push","result","idx","indexOf","splice","Array","isArray","fire","length","call","listeners","i","len","forward","from","to","e","map","any","events","DisposableStore","runAndSubscribe","initial","arg","d","_disposables","Set","isDisposed","o","clear","Disposable","_store","_register","None","Object","freeze","value","_value","DecorationManager","_terminal","super","_highlightDecorations","_highlightedLines","clearHighlightDecorations","createHighlightDecorations","results","options","match","decorations","_createResultDecorations","decoration","_storeDecoration","createActiveDecoration","marker","line","_applyStyles","element","borderColor","isActiveResult","classList","contains","style","outline","decorationRanges","currentCol","col","remainingSize","size","markerOffset","buffer","active","baseY","cursorY","row","amountThisRow","Math","min","cols","range","registerMarker","registerDecoration","x","width","layer","backgroundColor","activeMatchBackground","matchBackground","overviewRulerOptions","has","color","activeMatchColorOverviewRuler","matchOverviewRuler","position","onRender","activeMatchBorder","matchBorder","onDispose","_lineCache","find","term","startRow","startCol","searchOptions","clearSelection","initLinesCache","searchPosition","_findInLine","y","rows","_isRowCoveredByEarlierSearch","findNextWithSelection","cachedSearchTerm","prevSelectedPos","getSelectionPosition","end","start","findPreviousWithSelection","isReverseSearch","max","_isWholeWord","searchIndex","includes","_satisfiesWholeWord","wholeWord","getLine","isWrapped","cache","getLineFromCache","translateBufferLineToStringWithWrap","setLineInCache","stringLine","offsets","offset","_bufferColsToStringOffset","searchTerm","searchStringLine","regex","caseSensitive","toLowerCase","resultIndex","searchRegex","RegExp","foundTerm","exec","matchIndex","lastIndex","lastIndexOf","startRowOffset","endRowOffset","startColOffset","endColOffset","startColIndex","_stringLengthToBufferSize","cell","getCell","char","getChars","nextCell","getWidth","lineOffsets","rowsBack","floor","colsInRow","getCode","Async_1","SearchLineCache","_linesCacheTimeout","MutableDisposable","_linesCacheDisposables","_lastAccessTimestamp","_destroyLinesCache","_linesCache","combinedDisposable","onLineFeed","onCursorMove","onResize","Date","now","_scheduleLinesCacheTimeout","delay","disposableTimeout","elapsed","lineIndex","trimRight","strings","bufferLength","nextLine","lineWrapsToNext","string","translateToString","lastCell","join","Event_1","SearchResultTracker","_searchResults","_onDidChangeResults","Emitter","onDidChangeResults","searchResults","selectedDecoration","_selectedDecoration","updateResults","maxResults","clearResults","clearSelectedDecoration","findResultIndex","fireResultsChanged","hasDecorations","resultCount","reset","_cachedSearchTerm","lastSearchOptions","_lastSearchOptions","isValidSearchTerm","didOptionsChange","newOptions","shouldUpdateHighlighting","clearCachedTerm","__webpack_module_cache__","moduleId","cachedModule","__webpack_modules__","SearchLineCache_1","SearchState_1","SearchEngine_1","DecorationManager_1","SearchResultTracker_1","SearchAddon","_resultTracker","_highlightTimeout","_state","SearchState","_onAfterSearch","onAfterSearch","_onBeforeSearch","onBeforeSearch","_highlightLimit","highlightLimit","activate","terminal","_engine","SearchEngine","_decorationManager","onWriteParsed","_updateMatches","clearDecorations","findPrevious","incremental","noScroll","retainCachedSearchTerm","clearActiveDecoration","findNext","internalSearchOptions","_highlightAllMatches","found","_findNextAndSelect","_fireResults","prevResult","nextCol","nextRow","_selectResult","_findPreviousAndSelect","select","activeDecoration","viewportY","scroll","scrollLines"],"sourceRoot":""} +\ No newline at end of file +diff --git a/lib/addon-search.mjs b/lib/addon-search.mjs +index 5cf231a96b56284705711faebe6b0c492f133546..f2b8b804ac733d737a9bff22b4e1d24778a807c8 100644 +--- a/lib/addon-search.mjs ++++ b/lib/addon-search.mjs +@@ -14,5 +14,5 @@ + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +-function _(c){return{dispose:c}}function D(c){if(!c)return c;if(Array.isArray(c)){for(let i of c)i.dispose();return[]}return c.dispose(),c}function E(...c){return _(()=>D(c))}var S=class{constructor(){this._disposables=new Set;this._isDisposed=!1}get isDisposed(){return this._isDisposed}add(i){return this._isDisposed?i.dispose():this._disposables.add(i),i}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(let i of this._disposables)i.dispose();this._disposables.clear()}}clear(){for(let i of this._disposables)i.dispose();this._disposables.clear()}},b=class{constructor(){this._store=new S}dispose(){this._store.dispose()}_register(i){return this._store.add(i)}};b.None=Object.freeze({dispose(){}});var v=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(i){this._isDisposed||i===this._value||(this._value?.dispose(),this._value=i)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}};var I=class{constructor(){this._listeners=[];this._disposed=!1}get event(){return this._event?this._event:(this._event=(i,e,t)=>{if(this._disposed)return _(()=>{});let r={fn:i,thisArgs:e};this._listeners=this._listeners.slice(),this._listeners.push(r);let s=_(()=>{let n=this._listeners.indexOf(r);n!==-1&&(this._listeners=this._listeners.slice(),this._listeners.splice(n,1))});return t&&(Array.isArray(t)?t.push(s):t.add(s)),s},this._event)}fire(i){if(this._disposed||!this._listeners.length)return;if(this._listeners.length===1){this._listeners[0].fn.call(this._listeners[0].thisArgs,i);return}let e=this._listeners;for(let t=0,r=e.length;t{function c(s,n){return s(a=>n.fire(a))}r.forward=c;function i(s,n){return(a,o,l)=>s(h=>a.call(o,n(h)),void 0,l)}r.map=i;function e(...s){return(n,a,o)=>{let l=new S;for(let h of s)l.add(h(p=>n.call(a,p)));return o&&(Array.isArray(o)?o.push(l):o.add(l)),l}}r.any=e;function t(s,n,a){return n(a),s(o=>n(o))}r.runAndSubscribe=t})(W||={});function T(c,i=0,e){let t=setTimeout(()=>{c(),e&&r.dispose()},i),r=_(()=>{clearTimeout(t)});return e?.add(r),r}var C=class extends b{constructor(e){super();this._terminal=e;this._linesCacheTimeout=this._register(new v);this._linesCacheDisposables=this._register(new v);this._lastAccessTimestamp=0;this._register(_(()=>this._destroyLinesCache()))}initLinesCache(){this._linesCache||(this._linesCache=new Array(this._terminal.buffer.active.length),this._linesCacheDisposables.value=E(this._terminal.onLineFeed(()=>this._destroyLinesCache()),this._terminal.onCursorMove(()=>this._destroyLinesCache()),this._terminal.onResize(()=>this._destroyLinesCache()))),this._lastAccessTimestamp=Date.now(),this._linesCacheTimeout.value||this._scheduleLinesCacheTimeout(15e3)}_destroyLinesCache(){this._linesCache=void 0,this._lastAccessTimestamp=0,this._linesCacheDisposables.clear(),this._linesCacheTimeout.clear()}_scheduleLinesCacheTimeout(e){this._linesCacheTimeout.value=T(()=>{if(!this._linesCache)return;let r=Date.now()-this._lastAccessTimestamp;if(r>=15e3){this._destroyLinesCache();return}this._scheduleLinesCacheTimeout(15e3-r)},e)}getLineFromCache(e){return this._linesCache?.[e]}setLineInCache(e,t){this._linesCache&&(this._linesCache[e]=t)}translateBufferLineToStringWithWrap(e,t){let r=[],s=[0],n=this._terminal.buffer.active.getLine(e);for(;n;){let a=this._terminal.buffer.active.getLine(e+1),o=a?a.isWrapped:!1,l=n.translateToString(!o&&t);if(o&&a){let h=n.getCell(n.length-1);h&&h.getCode()===0&&h.getWidth()===1&&a.getCell(0)?.getWidth()===2&&(l=l.slice(0,-1))}if(r.push(l),o)s.push(s[s.length-1]+l.length);else break;e++,n=a}return[r.join(""),s]}};var x=class{get cachedSearchTerm(){return this._cachedSearchTerm}set cachedSearchTerm(i){this._cachedSearchTerm=i}get lastSearchOptions(){return this._lastSearchOptions}set lastSearchOptions(i){this._lastSearchOptions=i}isValidSearchTerm(i){return!!(i&&i.length>0)}didOptionsChange(i){return this._lastSearchOptions?i?this._lastSearchOptions.caseSensitive!==i.caseSensitive||this._lastSearchOptions.regex!==i.regex||this._lastSearchOptions.wholeWord!==i.wholeWord:!1:!0}shouldUpdateHighlighting(i,e){return e?.decorations?this._cachedSearchTerm===void 0||i!==this._cachedSearchTerm||this.didOptionsChange(e):!1}clearCachedTerm(){this._cachedSearchTerm=void 0}reset(){this._cachedSearchTerm=void 0,this._lastSearchOptions=void 0}};var R=class{constructor(i,e){this._terminal=i;this._lineCache=e}find(i,e,t,r){if(!i||i.length===0){this._terminal.clearSelection();return}if(t>=this._terminal.cols)throw new Error(`Invalid col: ${t} to search in terminal of ${this._terminal.cols} cols`);this._lineCache.initLinesCache();let s={startRow:e,startCol:t},n=this._findInLine(i,s,r);if(!n)for(let a=e+1;a=0&&(o.startRow=h,l=this._findInLine(i,o,e,a),!l);h--);}if(!l&&s!==this._terminal.buffer.active.baseY+this._terminal.rows-1)for(let h=this._terminal.buffer.active.baseY+this._terminal.rows-1;h>=s&&(o.startRow=h,l=this._findInLine(i,o,e,a),!l);h--);return l}_isWholeWord(i,e,t){return(i===0||" ~!@#$%^&*()+`-=[]{}|\\;:\"',./<>?".includes(e[i-1]))&&(i+t.length===e.length||" ~!@#$%^&*()+`-=[]{}|\\;:\"',./<>?".includes(e[i+t.length]))}_findInLine(i,e,t={},r=!1){let s=e.startRow,n=e.startCol;if(this._terminal.buffer.active.getLine(s)?.isWrapped){if(r){e.startCol+=this._terminal.cols;return}return e.startRow--,e.startCol+=this._terminal.cols,this._findInLine(i,e,t)}let o=this._lineCache.getLineFromCache(s);o||(o=this._lineCache.translateBufferLineToStringWithWrap(s,!0),this._lineCache.setLineInCache(s,o));let[l,h]=o,p=this._bufferColsToStringOffset(s,n),m=i,g=l;t.regex||(m=t.caseSensitive?i:i.toLowerCase(),g=t.caseSensitive?l:l.toLowerCase());let f=-1;if(t.regex){let u=RegExp(m,t.caseSensitive?"g":"gi"),d;if(r)for(;d=u.exec(g.slice(0,p));)f=u.lastIndex-d[0].length,i=d[0],u.lastIndex-=i.length-1;else d=u.exec(g.slice(p)),d&&d[0].length>0&&(f=p+(u.lastIndex-d[0].length),i=d[0])}else r?p-m.length>=0&&(f=g.lastIndexOf(m,p-m.length)):f=g.indexOf(m,p);if(f>=0){if(t.wholeWord&&!this._isWholeWord(f,g,i))return;let u=0;for(;u=h[u+1];)u++;let d=u;for(;d=h[d+1];)d++;let O=f-h[u],k=f+i.length-h[d],y=this._stringLengthToBufferSize(s+u,O),M=this._stringLengthToBufferSize(s+d,k)-y+this._terminal.cols*(d-u);return{term:i,col:y,row:s+u,size:M}}}_stringLengthToBufferSize(i,e){let t=this._terminal.buffer.active.getLine(i);if(!t)return 0;for(let r=0;r1&&(e-=n.length-1);let a=t.getCell(r+1);a&&a.getWidth()===0&&e++}return e}_bufferColsToStringOffset(i,e){let t=i,r=0,s=this._terminal.buffer.active.getLine(t);for(;e>0&&s;){for(let n=0;nthis.clearHighlightDecorations()))}createHighlightDecorations(e,t){this.clearHighlightDecorations();for(let r of e){let s=this._createResultDecorations(r,t,!1);if(s)for(let n of s)this._storeDecoration(n,r)}}createActiveDecoration(e,t){let r=this._createResultDecorations(e,t,!0);if(r)return{decorations:r,match:e,dispose(){D(r)}}}clearHighlightDecorations(){D(this._highlightDecorations),this._highlightDecorations=[],this._highlightedLines.clear()}_storeDecoration(e,t){this._highlightedLines.add(e.marker.line),this._highlightDecorations.push({decoration:e,match:t,dispose(){e.dispose()}})}_applyStyles(e,t,r){e.classList.contains("xterm-find-result-decoration")||(e.classList.add("xterm-find-result-decoration"),t&&(e.style.outline=`1px solid ${t}`)),r&&e.classList.add("xterm-find-active-result-decoration")}_createResultDecorations(e,t,r){let s=[],n=e.col,a=e.size,o=-this._terminal.buffer.active.baseY-this._terminal.buffer.active.cursorY+e.row;for(;a>0;){let h=Math.min(this._terminal.cols-n,a);s.push([o,n,h]),n=0,a-=h,o++}let l=[];for(let h of s){let p=this._terminal.registerMarker(h[0]),m=this._terminal.registerDecoration({marker:p,x:h[1],width:h[2],layer:r?"top":"bottom",backgroundColor:r?t.activeMatchBackground:t.matchBackground,overviewRulerOptions:this._highlightedLines.has(p.line)?void 0:{color:r?t.activeMatchColorOverviewRuler:t.matchOverviewRuler,position:"center"}});if(m){let g=[];g.push(p),g.push(m.onRender(f=>this._applyStyles(f,r?t.activeMatchBorder:t.matchBorder,!1))),g.push(m.onDispose(()=>D(g))),l.push(m)}}return l.length===0?void 0:l}};var L=class extends b{constructor(){super(...arguments);this._searchResults=[];this._onDidChangeResults=this._register(new I)}get onDidChangeResults(){return this._onDidChangeResults.event}get searchResults(){return this._searchResults}get selectedDecoration(){return this._selectedDecoration}set selectedDecoration(e){this._selectedDecoration=e}updateResults(e,t){this._searchResults=e.slice(0,t)}clearResults(){this._searchResults=[]}clearSelectedDecoration(){this._selectedDecoration&&(this._selectedDecoration.dispose(),this._selectedDecoration=void 0)}findResultIndex(e){for(let t=0;tthis._updateMatches())),this._register(this._terminal.onResize(()=>this._updateMatches())),this._register(_(()=>this.clearDecorations()))}_updateMatches(){this._highlightTimeout.clear(),this._state.cachedSearchTerm&&this._state.lastSearchOptions?.decorations&&(this._highlightTimeout.value=T(()=>{let e=this._state.cachedSearchTerm;this._state.clearCachedTerm(),this.findPrevious(e,{...this._state.lastSearchOptions,incremental:!0},{noScroll:!0})},200))}clearDecorations(e){this._resultTracker.clearSelectedDecoration(),this._decorationManager?.clearHighlightDecorations(),this._resultTracker.clearResults(),e||this._state.clearCachedTerm()}clearActiveDecoration(){this._resultTracker.clearSelectedDecoration()}findNext(e,t,r){if(!this._terminal||!this._engine)throw new Error("Cannot use addon until it has been loaded");this._onBeforeSearch.fire(),this._state.lastSearchOptions=t,this._state.shouldUpdateHighlighting(e,t)&&this._highlightAllMatches(e,t);let s=this._findNextAndSelect(e,t,r);return this._fireResults(t),this._state.cachedSearchTerm=e,this._onAfterSearch.fire(),s}_highlightAllMatches(e,t){if(!this._terminal||!this._engine||!this._decorationManager)throw new Error("Cannot use addon until it has been loaded");if(!this._state.isValidSearchTerm(e)){this.clearDecorations();return}this.clearDecorations(!0);let r=[],s,n=this._engine.find(e,0,0,t);for(;n&&(s?.row!==n.row||s?.col!==n.col)&&!(r.length>=this._highlightLimit);){s=n,r.push(s);let a=this._terminal.cols,o=s.col+s.size,l=s.row;o>=a&&(l+=Math.floor(o/a),o=o%a),n=this._engine.find(e,l,o,t)}this._resultTracker.updateResults(r,this._highlightLimit),t.decorations&&this._decorationManager.createHighlightDecorations(r,t.decorations)}_findNextAndSelect(e,t,r){if(!this._terminal||!this._engine)return!1;if(!this._state.isValidSearchTerm(e))return this._terminal.clearSelection(),this.clearDecorations(),!1;let s=this._engine.findNextWithSelection(e,t,this._state.cachedSearchTerm);return this._selectResult(s,t?.decorations,r?.noScroll)}findPrevious(e,t,r){if(!this._terminal||!this._engine)throw new Error("Cannot use addon until it has been loaded");this._onBeforeSearch.fire(),this._state.lastSearchOptions=t,this._state.shouldUpdateHighlighting(e,t)&&this._highlightAllMatches(e,t);let s=this._findPreviousAndSelect(e,t,r);return this._fireResults(t),this._state.cachedSearchTerm=e,this._onAfterSearch.fire(),s}_fireResults(e){this._resultTracker.fireResultsChanged(!!e?.decorations)}_findPreviousAndSelect(e,t,r){if(!this._terminal||!this._engine)return!1;if(!this._state.isValidSearchTerm(e))return this._terminal.clearSelection(),this.clearDecorations(),!1;let s=this._engine.findPreviousWithSelection(e,t,this._state.cachedSearchTerm);return this._selectResult(s,t?.decorations,r?.noScroll)}_selectResult(e,t,r){if(!this._terminal||!this._decorationManager)return!1;if(this._resultTracker.clearSelectedDecoration(),!e)return this._terminal.clearSelection(),!1;if(this._terminal.select(e.col,e.row,e.size),t){let s=this._decorationManager.createActiveDecoration(e,t);s&&(this._resultTracker.selectedDecoration=s)}if(!r&&(e.row>=this._terminal.buffer.active.viewportY+this._terminal.rows||e.rowD(d))}var S=class{constructor(){this._disposables=new Set;this._isDisposed=!1}get isDisposed(){return this._isDisposed}add(i){return this._isDisposed?i.dispose():this._disposables.add(i),i}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(let i of this._disposables)i.dispose();this._disposables.clear()}}clear(){for(let i of this._disposables)i.dispose();this._disposables.clear()}},g=class{constructor(){this._store=new S}dispose(){this._store.dispose()}_register(i){return this._store.add(i)}};g.None=Object.freeze({dispose(){}});var v=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(i){this._isDisposed||i===this._value||(this._value?.dispose(),this._value=i)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}};var I=class{constructor(){this._listeners=[];this._disposed=!1}get event(){return this._event?this._event:(this._event=(i,e,t)=>{if(this._disposed)return m(()=>{});let s={fn:i,thisArgs:e};this._listeners=this._listeners.slice(),this._listeners.push(s);let r=m(()=>{let l=this._listeners.indexOf(s);l!==-1&&(this._listeners=this._listeners.slice(),this._listeners.splice(l,1))});return t&&(Array.isArray(t)?t.push(r):t.add(r)),r},this._event)}fire(i){if(this._disposed||!this._listeners.length)return;if(this._listeners.length===1){this._listeners[0].fn.call(this._listeners[0].thisArgs,i);return}let e=this._listeners;for(let t=0,s=e.length;t{function d(r,l){return r(o=>l.fire(o))}s.forward=d;function i(r,l){return(o,n,a)=>r(c=>o.call(n,l(c)),void 0,a)}s.map=i;function e(...r){return(l,o,n)=>{let a=new S;for(let c of r)a.add(c(u=>l.call(o,u)));return n&&(Array.isArray(n)?n.push(a):n.add(a)),a}}s.any=e;function t(r,l,o){return l(o),r(n=>l(n))}s.runAndSubscribe=t})(k||={});function T(d,i=0,e){let t=setTimeout(()=>{d(),e&&s.dispose()},i),s=m(()=>{clearTimeout(t)});return e?.add(s),s}var C=class extends g{constructor(e){super();this._terminal=e;this._linesCacheTimeout=this._register(new v);this._linesCacheDisposables=this._register(new v);this._lastAccessTimestamp=0;this._register(m(()=>this._destroyLinesCache()))}initLinesCache(){this._linesCache||(this._linesCache=new Array(this._terminal.buffer.active.length),this._linesCacheDisposables.value=E(this._terminal.onLineFeed(()=>this._destroyLinesCache()),this._terminal.onCursorMove(()=>this._destroyLinesCache()),this._terminal.onResize(()=>this._destroyLinesCache()))),this._lastAccessTimestamp=Date.now(),this._linesCacheTimeout.value||this._scheduleLinesCacheTimeout(15e3)}_destroyLinesCache(){this._linesCache=void 0,this._lastAccessTimestamp=0,this._linesCacheDisposables.clear(),this._linesCacheTimeout.clear()}_scheduleLinesCacheTimeout(e){this._linesCacheTimeout.value=T(()=>{if(!this._linesCache)return;let s=Date.now()-this._lastAccessTimestamp;if(s>=15e3){this._destroyLinesCache();return}this._scheduleLinesCacheTimeout(15e3-s)},e)}getLineFromCache(e){return this._linesCache?.[e]}setLineInCache(e,t){this._linesCache&&(this._linesCache[e]=t)}translateBufferLineToStringWithWrap(e,t){let s=[],r=[0],l=this._terminal.buffer.active.length,o=this._terminal.buffer.active.getLine(e);for(;o;){let n=e+10)}didOptionsChange(i){return this._lastSearchOptions?i?this._lastSearchOptions.caseSensitive!==i.caseSensitive||this._lastSearchOptions.regex!==i.regex||this._lastSearchOptions.wholeWord!==i.wholeWord:!1:!0}shouldUpdateHighlighting(i,e){return e?.decorations?this._cachedSearchTerm===void 0||i!==this._cachedSearchTerm||this.didOptionsChange(e):!1}clearCachedTerm(){this._cachedSearchTerm=void 0}reset(){this._cachedSearchTerm=void 0,this._lastSearchOptions=void 0}};var w=class{constructor(i,e){this._terminal=i;this._lineCache=e}find(i,e,t,s){if(!i||i.length===0){this._terminal.clearSelection();return}if(t>=this._terminal.cols)throw new Error(`Invalid col: ${t} to search in terminal of ${this._terminal.cols} cols`);this._lineCache.initLinesCache();let r={startRow:e,startCol:t},l=this._findInLine(i,r,s);if(!l)for(let o=e+1;o0&&this._isRowCoveredByEarlierSearch(a))&&(o.startRow=a,o.startCol=0,n=this._findInLine(i,o,e),n));a++);return!n&&s&&(o.startRow=s.start.y,o.startCol=0,n=this._findInLine(i,o,e)),n}findPreviousWithSelection(i,e,t){if(!i||i.length===0){this._terminal.clearSelection();return}let s=this._terminal.getSelectionPosition();this._terminal.clearSelection();let r=this._terminal.buffer.active.baseY+this._terminal.rows-1,l=this._terminal.cols,o=!0;this._lineCache.initLinesCache();let n={startRow:r,startCol:l},a;if(s&&(n.startRow=r=s.start.y,n.startCol=s.start.x,t!==i&&(a=this._findInLine(i,n,e,!1),a||(n.startRow=r=s.end.y,n.startCol=s.end.x))),a??=this._findInLine(i,n,e,o),!a){n.startCol=Math.max(n.startCol,this._terminal.cols);for(let c=r-1;c>=0&&(n.startRow=c,a=this._findInLine(i,n,e,o),!a);c--);}if(!a&&r!==this._terminal.buffer.active.baseY+this._terminal.rows-1)for(let c=this._terminal.buffer.active.baseY+this._terminal.rows-1;c>=r&&(n.startRow=c,a=this._findInLine(i,n,e,o),!a);c--);return a}_isWholeWord(i,e,t){return(i===0||" ~!@#$%^&*()+`-=[]{}|\\;:\"',./<>?".includes(e[i-1]))&&(i+t.length===e.length||" ~!@#$%^&*()+`-=[]{}|\\;:\"',./<>?".includes(e[i+t.length]))}_satisfiesWholeWord(i,e,t,s){return!s.wholeWord||this._isWholeWord(i,e,t)}_isRowCoveredByEarlierSearch(i){return this._terminal.buffer.active.getLine(i)?.isWrapped===!0}_findInLine(i,e,t={},s=!1){if(s){if(e.startRow>0&&this._terminal.buffer.active.getLine(e.startRow)?.isWrapped){e.startCol+=this._terminal.cols;return}}else for(;e.startRow>0&&this._terminal.buffer.active.getLine(e.startRow)?.isWrapped;)e.startRow--,e.startCol+=this._terminal.cols;let r=e.startRow,l=e.startCol,o=this._lineCache.getLineFromCache(r);o||(o=this._lineCache.translateBufferLineToStringWithWrap(r,!0),this._lineCache.setLineInCache(r,o));let[n,a]=o,c=this._bufferColsToStringOffset(r,l,a),u=i,f=n;t.regex||(u=t.caseSensitive?i:i.toLowerCase(),f=t.caseSensitive?n:n.toLowerCase());let _=-1;if(t.regex){let h=RegExp(u,t.caseSensitive?"g":"gi"),p;if(s)for(;p=h.exec(f.slice(0,c));){let b=h.lastIndex-p[0].length;p[0].length>0&&this._satisfiesWholeWord(b,f,p[0],t)&&(_=b,i=p[0]),h.lastIndex=b+1}else for(h.lastIndex=c;p=h.exec(f);){let b=h.lastIndex-p[0].length;if(p[0].length>0&&this._satisfiesWholeWord(b,f,p[0],t)){_=b,i=p[0];break}h.lastIndex=b+1}}else if(s){let h=c-u.length>=0?f.lastIndexOf(u,c-u.length):-1;for(;h>=0&&!this._satisfiesWholeWord(h,f,u,t);)h=h>0?f.lastIndexOf(u,h-1):-1;_=h}else{let h=f.indexOf(u,c);for(;h>=0&&!this._satisfiesWholeWord(h,f,u,t);)h=f.indexOf(u,h+1);_=h}if(_>=0){let h=0;for(;h=a[h+1];)h++;let p=h;for(;p=a[p+1];)p++;let b=_-a[h],O=_+i.length-a[p],L=this._stringLengthToBufferSize(r+h,b),W=this._stringLengthToBufferSize(r+p,O)-L+this._terminal.cols*(p-h);return{term:i,col:L,row:r+h,size:W}}}_stringLengthToBufferSize(i,e){let t=this._terminal.buffer.active.getLine(i);if(!t)return 0;for(let s=0;s1&&(e-=l.length-1);let o=t.getCell(s+1);o&&o.getWidth()===0&&e++}return e}_bufferColsToStringOffset(i,e,t){let s=Math.min(Math.floor(e/this._terminal.cols),t.length-1),r=t[s],l=this._terminal.buffer.active.getLine(i+s);if(l){let o=Math.min(e-s*this._terminal.cols,this._terminal.cols);for(let n=0;nthis.clearHighlightDecorations()))}createHighlightDecorations(e,t){this.clearHighlightDecorations();for(let s of e){let r=this._createResultDecorations(s,t,!1);if(r)for(let l of r)this._storeDecoration(l,s)}}createActiveDecoration(e,t){let s=this._createResultDecorations(e,t,!0);if(s)return{decorations:s,match:e,dispose(){D(s)}}}clearHighlightDecorations(){D(this._highlightDecorations),this._highlightDecorations=[],this._highlightedLines.clear()}_storeDecoration(e,t){this._highlightedLines.add(e.marker.line),this._highlightDecorations.push({decoration:e,match:t,dispose(){e.dispose()}})}_applyStyles(e,t,s){e.classList.contains("xterm-find-result-decoration")||(e.classList.add("xterm-find-result-decoration"),t&&(e.style.outline=`1px solid ${t}`)),s&&e.classList.add("xterm-find-active-result-decoration")}_createResultDecorations(e,t,s){let r=[],l=e.col,o=e.size,n=-this._terminal.buffer.active.baseY-this._terminal.buffer.active.cursorY+e.row;for(;o>0;){let c=Math.min(this._terminal.cols-l,o);r.push([n,l,c]),l=0,o-=c,n++}let a=[];for(let c of r){let u=this._terminal.registerMarker(c[0]),f=this._terminal.registerDecoration({marker:u,x:c[1],width:c[2],layer:s?"top":"bottom",backgroundColor:s?t.activeMatchBackground:t.matchBackground,overviewRulerOptions:this._highlightedLines.has(u.line)?void 0:{color:s?t.activeMatchColorOverviewRuler:t.matchOverviewRuler,position:"center"}});if(f){let _=[];_.push(u),_.push(f.onRender(h=>this._applyStyles(h,s?t.activeMatchBorder:t.matchBorder,!1))),_.push(f.onDispose(()=>D(_))),a.push(f)}}return a.length===0?void 0:a}};var y=class extends g{constructor(){super(...arguments);this._searchResults=[];this._onDidChangeResults=this._register(new I)}get onDidChangeResults(){return this._onDidChangeResults.event}get searchResults(){return this._searchResults}get selectedDecoration(){return this._selectedDecoration}set selectedDecoration(e){this._selectedDecoration=e}updateResults(e,t){this._searchResults=e.slice(0,t)}clearResults(){this._searchResults=[]}clearSelectedDecoration(){this._selectedDecoration&&(this._selectedDecoration.dispose(),this._selectedDecoration=void 0)}findResultIndex(e){for(let t=0;tthis._updateMatches())),this._register(this._terminal.onResize(()=>this._updateMatches())),this._register(m(()=>this.clearDecorations()))}_updateMatches(){this._highlightTimeout.clear(),this._state.cachedSearchTerm&&this._state.lastSearchOptions?.decorations&&(this._highlightTimeout.value=T(()=>{let e=this._state.cachedSearchTerm;this._state.clearCachedTerm(),this.findPrevious(e,{...this._state.lastSearchOptions,incremental:!0},{noScroll:!0})},200))}clearDecorations(e){this._resultTracker.clearSelectedDecoration(),this._decorationManager?.clearHighlightDecorations(),this._resultTracker.clearResults(),e||this._state.clearCachedTerm()}clearActiveDecoration(){this._resultTracker.clearSelectedDecoration()}findNext(e,t,s){if(!this._terminal||!this._engine)throw new Error("Cannot use addon until it has been loaded");this._onBeforeSearch.fire(),this._state.lastSearchOptions=t,this._state.shouldUpdateHighlighting(e,t)&&this._highlightAllMatches(e,t);let r=this._findNextAndSelect(e,t,s);return this._fireResults(t),this._state.cachedSearchTerm=e,this._onAfterSearch.fire(),r}_highlightAllMatches(e,t){if(!this._terminal||!this._engine||!this._decorationManager)throw new Error("Cannot use addon until it has been loaded");if(!this._state.isValidSearchTerm(e)){this.clearDecorations();return}this.clearDecorations(!0);let s=[],r,l=this._engine.find(e,0,0,t);for(;l&&(r?.row!==l.row||r?.col!==l.col)&&!(s.length>=this._highlightLimit);){r=l,s.push(r);let o=this._terminal.cols,n=r.col+r.size,a=r.row;n>=o&&(a+=Math.floor(n/o),n=n%o),l=this._engine.find(e,a,n,t)}this._resultTracker.updateResults(s,this._highlightLimit),t.decorations&&this._decorationManager.createHighlightDecorations(s,t.decorations)}_findNextAndSelect(e,t,s){if(!this._terminal||!this._engine)return!1;if(!this._state.isValidSearchTerm(e))return this._terminal.clearSelection(),this.clearDecorations(),!1;let r=this._engine.findNextWithSelection(e,t,this._state.cachedSearchTerm);return this._selectResult(r,t?.decorations,s?.noScroll)}findPrevious(e,t,s){if(!this._terminal||!this._engine)throw new Error("Cannot use addon until it has been loaded");this._onBeforeSearch.fire(),this._state.lastSearchOptions=t,this._state.shouldUpdateHighlighting(e,t)&&this._highlightAllMatches(e,t);let r=this._findPreviousAndSelect(e,t,s);return this._fireResults(t),this._state.cachedSearchTerm=e,this._onAfterSearch.fire(),r}_fireResults(e){this._resultTracker.fireResultsChanged(!!e?.decorations)}_findPreviousAndSelect(e,t,s){if(!this._terminal||!this._engine)return!1;if(!this._state.isValidSearchTerm(e))return this._terminal.clearSelection(),this.clearDecorations(),!1;let r=this._engine.findPreviousWithSelection(e,t,this._state.cachedSearchTerm);return this._selectResult(r,t?.decorations,s?.noScroll)}_selectResult(e,t,s){if(!this._terminal||!this._decorationManager)return!1;if(this._resultTracker.clearSelectedDecoration(),!e)return this._terminal.clearSelection(),!1;if(this._terminal.select(e.col,e.row,e.size),t){let r=this._decorationManager.createActiveDecoration(e,t);r&&(this._resultTracker.selectedDecoration=r)}if(!s&&(e.row>=this._terminal.buffer.active.viewportY+this._terminal.rows||e.row void): IDisposable {\n return { dispose: fn };\n}\n\nexport function dispose(disposable: T): T;\nexport function dispose(disposable: T | undefined): T | undefined;\nexport function dispose(disposables: T[]): T[];\nexport function dispose(arg: T | T[] | undefined): T | T[] | undefined {\n if (!arg) {\n return arg;\n }\n if (Array.isArray(arg)) {\n for (const d of arg) {\n d.dispose();\n }\n return [];\n }\n arg.dispose();\n return arg;\n}\n\nexport function combinedDisposable(...disposables: IDisposable[]): IDisposable {\n return toDisposable(() => dispose(disposables));\n}\n\nexport class DisposableStore implements IDisposable {\n private readonly _disposables = new Set();\n private _isDisposed = false;\n\n public get isDisposed(): boolean {\n return this._isDisposed;\n }\n\n public add(o: T): T {\n if (this._isDisposed) {\n o.dispose();\n } else {\n this._disposables.add(o);\n }\n return o;\n }\n\n public dispose(): void {\n if (this._isDisposed) {\n return;\n }\n this._isDisposed = true;\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n\n public clear(): void {\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n}\n\nexport abstract class Disposable implements IDisposable {\n public static readonly None: IDisposable = Object.freeze({ dispose() { } });\n\n protected readonly _store = new DisposableStore();\n\n public dispose(): void {\n this._store.dispose();\n }\n\n protected _register(o: T): T {\n return this._store.add(o);\n }\n}\n\nexport class MutableDisposable implements IDisposable {\n private _value: T | undefined;\n private _isDisposed = false;\n\n public get value(): T | undefined {\n return this._isDisposed ? undefined : this._value;\n }\n\n public set value(value: T | undefined) {\n if (this._isDisposed || value === this._value) {\n return;\n }\n this._value?.dispose();\n this._value = value;\n }\n\n public clear(): void {\n this.value = undefined;\n }\n\n public dispose(): void {\n this._isDisposed = true;\n this._value?.dispose();\n this._value = undefined;\n }\n}\n", "/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal event utilities for xterm.js core.\n * Simplified from VS Code's event.ts - no leak detection/profiling.\n */\n\nimport { IDisposable, DisposableStore, toDisposable } from './Lifecycle';\n\nexport interface IEvent {\n (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore): IDisposable;\n}\n\nexport class Emitter {\n private _listeners: { fn: (e: T) => any, thisArgs: any }[] = [];\n private _disposed = false;\n private _event: IEvent | undefined;\n\n public get event(): IEvent {\n if (this._event) {\n return this._event;\n }\n this._event = (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n if (this._disposed) {\n return toDisposable(() => {});\n }\n\n const entry = { fn: listener, thisArgs };\n this._listeners = this._listeners.slice();\n this._listeners.push(entry);\n\n const result = toDisposable(() => {\n const idx = this._listeners.indexOf(entry);\n if (idx !== -1) {\n this._listeners = this._listeners.slice();\n this._listeners.splice(idx, 1);\n }\n });\n\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(result);\n } else {\n disposables.add(result);\n }\n }\n\n return result;\n };\n return this._event;\n }\n\n public fire(event: T): void {\n if (this._disposed || !this._listeners.length) {\n return;\n }\n if (this._listeners.length === 1) {\n this._listeners[0].fn.call(this._listeners[0].thisArgs, event);\n return;\n }\n const listeners = this._listeners;\n for (let i = 0, len = listeners.length; i < len; ++i) {\n listeners[i].fn.call(listeners[i].thisArgs, event);\n }\n }\n\n public dispose(): void {\n if (this._disposed) {\n return;\n }\n this._disposed = true;\n this._listeners.length = 0;\n }\n}\n\nexport namespace EventUtils {\n export function forward(from: IEvent, to: Emitter): IDisposable {\n return from(e => to.fire(e));\n }\n\n export function map(event: IEvent, map: (i: I) => O): IEvent {\n return (listener: (e: O) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n return event(i => listener.call(thisArgs, map(i)), undefined, disposables);\n };\n }\n\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent {\n return (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n const store = new DisposableStore();\n for (const event of events) {\n store.add(event(e => listener.call(thisArgs, e)));\n }\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(store);\n } else {\n disposables.add(store);\n }\n }\n return store;\n };\n }\n\n export function runAndSubscribe(event: IEvent, handler: (e: T) => void, initial: T): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void, initial?: T): IDisposable {\n handler(initial);\n return event(e => handler(e));\n }\n}\n", "/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal async helpers for xterm.js core.\n */\n\nimport { DisposableStore, IDisposable, toDisposable } from './Lifecycle';\n\nexport function timeout(millis: number): Promise {\n return new Promise(resolve => setTimeout(resolve, millis));\n}\n\n/**\n * Creates a timeout that can be disposed using its returned value.\n * @param handler The timeout handler.\n * @param timeout An optional timeout in milliseconds.\n * @param store An optional {@link DisposableStore} that will have the timeout disposable managed\n * automatically.\n */\nexport function disposableTimeout(handler: () => void, timeout = 0, store?: DisposableStore): IDisposable {\n const timer = setTimeout(() => {\n handler();\n if (store) {\n disposable.dispose();\n }\n }, timeout);\n const disposable = toDisposable(() => {\n clearTimeout(timer);\n });\n store?.add(disposable);\n return disposable;\n}\n\nexport class TimeoutTimer implements IDisposable {\n private _token: any = -1;\n private _isDisposed = false;\n\n public dispose(): void {\n this.cancel();\n this._isDisposed = true;\n }\n\n public cancel(): void {\n if (this._token !== -1) {\n clearTimeout(this._token);\n this._token = -1;\n }\n }\n\n public cancelAndSet(runner: () => void, timeout: number): void {\n if (this._isDisposed) {\n throw new Error('Calling cancelAndSet on a disposed TimeoutTimer');\n }\n this.cancel();\n this._token = setTimeout(() => {\n this._token = -1;\n runner();\n }, timeout);\n }\n\n public setIfNotSet(runner: () => void, timeout: number): void {\n if (this._isDisposed) {\n throw new Error('Calling setIfNotSet on a disposed TimeoutTimer');\n }\n if (this._token !== -1) {\n return;\n }\n this._token = setTimeout(() => {\n this._token = -1;\n runner();\n }, timeout);\n }\n}\n\n/**\n * Schedules a single runner on the microtask queue. Unlike {@link TimeoutTimer}, a scheduled\n * microtask cannot be unqueued; {@link cancel} prevents the runner from executing if it has not\n * run yet.\n */\nexport class MicrotaskTimer implements IDisposable {\n private _isScheduled = false;\n private _isDisposed = false;\n\n public dispose(): void {\n this.cancel();\n this._isDisposed = true;\n }\n\n public cancel(): void {\n this._isScheduled = false;\n }\n\n public set(runner: () => void): void {\n if (this._isDisposed) {\n throw new Error('Calling set on a disposed MicrotaskTimer');\n }\n if (this._isScheduled) {\n return;\n }\n this._isScheduled = true;\n queueMicrotask(() => {\n if (!this._isScheduled) {\n return;\n }\n this._isScheduled = false;\n runner();\n });\n }\n}\n\nexport class IntervalTimer implements IDisposable {\n private _disposable: IDisposable | undefined;\n private _isDisposed = false;\n\n public cancel(): void {\n this._disposable?.dispose();\n this._disposable = undefined;\n }\n\n public cancelAndSet(runner: () => void, interval: number, context: Window | typeof globalThis = globalThis): void {\n if (this._isDisposed) {\n throw new Error('Calling cancelAndSet on a disposed IntervalTimer');\n }\n this.cancel();\n const handle = context.setInterval(() => {\n runner();\n }, interval);\n this._disposable = {\n dispose: () => {\n context.clearInterval(handle as any);\n this._disposable = undefined;\n }\n };\n }\n\n public dispose(): void {\n this.cancel();\n this._isDisposed = true;\n }\n}\n", "/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { Terminal } from '@xterm/xterm';\nimport { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\nimport { disposableTimeout } from 'common/Async';\n\nexport type LineCacheEntry = [\n /**\n * The string representation of a line (as opposed to the buffer cell representation).\n */\n lineAsString: string,\n /**\n * The offsets where each line starts when the entry describes a wrapped line.\n */\n lineOffsets: number[]\n];\n\n/**\n * Configuration constants for the search line cache functionality.\n */\nconst enum Constants {\n /**\n * Time-to-live for cached search results in milliseconds. After this duration, cached search\n * results will be invalidated to ensure they remain consistent with terminal content changes.\n */\n LINES_CACHE_TIME_TO_LIVE = 15000\n}\n\nexport class SearchLineCache extends Disposable {\n /**\n * translateBufferLineToStringWithWrap is a fairly expensive call.\n * We memoize the calls into an array that has a time based ttl.\n * _linesCache is also invalidated when the terminal cursor moves.\n */\n private _linesCache: LineCacheEntry[] | undefined;\n private _linesCacheTimeout = this._register(new MutableDisposable());\n private _linesCacheDisposables = this._register(new MutableDisposable());\n // Track access to avoid recreating a timeout on every init call which occurs once per search\n // result (findNext/findPrevious -> _highlightAllMatches -> find loop).\n private _lastAccessTimestamp = 0;\n\n constructor(private readonly _terminal: Terminal) {\n super();\n this._register(toDisposable(() => this._destroyLinesCache()));\n }\n\n /**\n * Sets up a line cache with a ttl\n */\n public initLinesCache(): void {\n if (!this._linesCache) {\n this._linesCache = new Array(this._terminal.buffer.active.length);\n this._linesCacheDisposables.value = combinedDisposable(\n this._terminal.onLineFeed(() => this._destroyLinesCache()),\n this._terminal.onCursorMove(() => this._destroyLinesCache()),\n this._terminal.onResize(() => this._destroyLinesCache())\n );\n }\n\n this._lastAccessTimestamp = Date.now();\n if (!this._linesCacheTimeout.value) {\n this._scheduleLinesCacheTimeout(Constants.LINES_CACHE_TIME_TO_LIVE);\n }\n }\n\n private _destroyLinesCache(): void {\n this._linesCache = undefined;\n this._lastAccessTimestamp = 0;\n this._linesCacheDisposables.clear();\n this._linesCacheTimeout.clear();\n }\n\n private _scheduleLinesCacheTimeout(delay: number): void {\n this._linesCacheTimeout.value = disposableTimeout(() => {\n if (!this._linesCache) {\n return;\n }\n const now = Date.now();\n const elapsed = now - this._lastAccessTimestamp;\n if (elapsed >= Constants.LINES_CACHE_TIME_TO_LIVE) {\n this._destroyLinesCache();\n return;\n }\n this._scheduleLinesCacheTimeout(Constants.LINES_CACHE_TIME_TO_LIVE - elapsed);\n }, delay);\n }\n\n public getLineFromCache(row: number): LineCacheEntry | undefined {\n return this._linesCache?.[row];\n }\n\n public setLineInCache(row: number, entry: LineCacheEntry): void {\n if (this._linesCache) {\n this._linesCache[row] = entry;\n }\n }\n\n /**\n * Translates a buffer line to a string, including subsequent lines if they are wraps.\n * Wide characters will count as two columns in the resulting string. This\n * function is useful for getting the actual text underneath the raw selection\n * position.\n * @param lineIndex The index of the line being translated.\n * @param trimRight Whether to trim whitespace to the right.\n */\n public translateBufferLineToStringWithWrap(lineIndex: number, trimRight: boolean): LineCacheEntry {\n const strings = [];\n const lineOffsets = [0];\n let line = this._terminal.buffer.active.getLine(lineIndex);\n while (line) {\n const nextLine = this._terminal.buffer.active.getLine(lineIndex + 1);\n const lineWrapsToNext = nextLine ? nextLine.isWrapped : false;\n let string = line.translateToString(!lineWrapsToNext && trimRight);\n if (lineWrapsToNext && nextLine) {\n const lastCell = line.getCell(line.length - 1);\n const lastCellIsNull = lastCell && lastCell.getCode() === 0 && lastCell.getWidth() === 1;\n // a wide character wrapped to the next line\n if (lastCellIsNull && nextLine.getCell(0)?.getWidth() === 2) {\n string = string.slice(0, -1);\n }\n }\n strings.push(string);\n if (lineWrapsToNext) {\n lineOffsets.push(lineOffsets[lineOffsets.length - 1] + string.length);\n } else {\n break;\n }\n lineIndex++;\n line = nextLine;\n }\n return [strings.join(''), lineOffsets];\n }\n}\n", "/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ISearchOptions } from '@xterm/addon-search';\n\n/**\n * Manages search state including cached search terms, options tracking, and validation.\n * This class provides a centralized way to handle search state consistency and option changes.\n */\nexport class SearchState {\n private _cachedSearchTerm: string | undefined;\n private _lastSearchOptions: ISearchOptions | undefined;\n\n /**\n * Gets the currently cached search term.\n */\n public get cachedSearchTerm(): string | undefined {\n return this._cachedSearchTerm;\n }\n\n /**\n * Sets the cached search term.\n */\n public set cachedSearchTerm(term: string | undefined) {\n this._cachedSearchTerm = term;\n }\n\n /**\n * Gets the last search options used.\n */\n public get lastSearchOptions(): ISearchOptions | undefined {\n return this._lastSearchOptions;\n }\n\n /**\n * Sets the last search options used.\n */\n public set lastSearchOptions(options: ISearchOptions | undefined) {\n this._lastSearchOptions = options;\n }\n\n /**\n * Validates a search term to ensure it's not empty or invalid.\n * @param term The search term to validate.\n * @returns true if the term is valid for searching.\n */\n public isValidSearchTerm(term: string): boolean {\n return !!(term && term.length > 0);\n }\n\n /**\n * Determines if search options have changed compared to the last search.\n * @param newOptions The new search options to compare.\n * @returns true if the options have changed.\n */\n public didOptionsChange(newOptions?: ISearchOptions): boolean {\n if (!this._lastSearchOptions) {\n return true;\n }\n if (!newOptions) {\n return false;\n }\n if (this._lastSearchOptions.caseSensitive !== newOptions.caseSensitive) {\n return true;\n }\n if (this._lastSearchOptions.regex !== newOptions.regex) {\n return true;\n }\n if (this._lastSearchOptions.wholeWord !== newOptions.wholeWord) {\n return true;\n }\n return false;\n }\n\n /**\n * Determines if a new search should trigger highlighting updates.\n * @param term The search term.\n * @param options The search options.\n * @returns true if highlighting should be updated.\n */\n public shouldUpdateHighlighting(term: string, options?: ISearchOptions): boolean {\n if (!options?.decorations) {\n return false;\n }\n return this._cachedSearchTerm === undefined ||\n term !== this._cachedSearchTerm ||\n this.didOptionsChange(options);\n }\n\n /**\n * Clears the cached search term.\n */\n public clearCachedTerm(): void {\n this._cachedSearchTerm = undefined;\n }\n\n /**\n * Resets all state.\n */\n public reset(): void {\n this._cachedSearchTerm = undefined;\n this._lastSearchOptions = undefined;\n }\n}\n", "/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { Terminal } from '@xterm/xterm';\nimport type { ISearchOptions } from '@xterm/addon-search';\nimport type { SearchLineCache } from './SearchLineCache';\n\n/**\n * Represents the position to start a search from.\n */\ninterface ISearchPosition {\n startCol: number;\n startRow: number;\n}\n\n/**\n * Represents a search result with its position and content.\n */\nexport interface ISearchResult {\n term: string;\n col: number;\n row: number;\n size: number;\n}\n\n/**\n * Configuration constants for the search engine functionality.\n */\nconst enum Constants {\n /**\n * Characters that are considered non-word characters for search boundary detection. These\n * characters are used to determine word boundaries when performing whole-word searches. Includes\n * common punctuation, symbols, and whitespace characters.\n */\n NON_WORD_CHARACTERS = ' ~!@#$%^&*()+`-=[]{}|\\\\;:\"\\',./<>?'\n}\n\n/**\n * Core search engine that handles finding text within terminal content.\n * This class is responsible for the actual search algorithms and position calculations.\n */\nexport class SearchEngine {\n constructor(\n private readonly _terminal: Terminal,\n private readonly _lineCache: SearchLineCache\n ) {}\n\n /**\n * Find the first occurrence of a term starting from a specific position.\n * @param term The search term.\n * @param startRow The row to start searching from.\n * @param startCol The column to start searching from.\n * @param searchOptions Search options.\n * @returns The search result if found, undefined otherwise.\n */\n public find(term: string, startRow: number, startCol: number, searchOptions?: ISearchOptions): ISearchResult | undefined {\n if (!term || term.length === 0) {\n this._terminal.clearSelection();\n return undefined;\n }\n if (startCol >= this._terminal.cols) {\n throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`);\n }\n\n this._lineCache.initLinesCache();\n\n const searchPosition: ISearchPosition = {\n startRow,\n startCol\n };\n\n // Search startRow\n let result = this._findInLine(term, searchPosition, searchOptions);\n // Search from startRow + 1 to end\n if (!result) {\n for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {\n searchPosition.startRow = y;\n searchPosition.startCol = 0;\n result = this._findInLine(term, searchPosition, searchOptions);\n if (result) {\n break;\n }\n }\n }\n return result;\n }\n\n /**\n * Find the next occurrence of a term with wrapping and selection management.\n * @param term The search term.\n * @param searchOptions Search options.\n * @param cachedSearchTerm The cached search term to determine incremental behavior.\n * @returns The search result if found, undefined otherwise.\n */\n public findNextWithSelection(term: string, searchOptions?: ISearchOptions, cachedSearchTerm?: string): ISearchResult | undefined {\n if (!term || term.length === 0) {\n this._terminal.clearSelection();\n return undefined;\n }\n\n const prevSelectedPos = this._terminal.getSelectionPosition();\n this._terminal.clearSelection();\n\n let startCol = 0;\n let startRow = 0;\n if (prevSelectedPos) {\n if (cachedSearchTerm === term) {\n startCol = prevSelectedPos.end.x;\n startRow = prevSelectedPos.end.y;\n } else {\n startCol = prevSelectedPos.start.x;\n startRow = prevSelectedPos.start.y;\n }\n }\n\n this._lineCache.initLinesCache();\n\n const searchPosition: ISearchPosition = {\n startRow,\n startCol\n };\n\n // Search startRow\n let result = this._findInLine(term, searchPosition, searchOptions);\n // Search from startRow + 1 to end\n if (!result) {\n for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {\n searchPosition.startRow = y;\n searchPosition.startCol = 0;\n result = this._findInLine(term, searchPosition, searchOptions);\n if (result) {\n break;\n }\n }\n }\n // If we hit the bottom and didn't search from the very top wrap back up\n if (!result && startRow !== 0) {\n for (let y = 0; y < startRow; y++) {\n searchPosition.startRow = y;\n searchPosition.startCol = 0;\n result = this._findInLine(term, searchPosition, searchOptions);\n if (result) {\n break;\n }\n }\n }\n\n // If there is only one result, wrap back and return selection if it exists.\n if (!result && prevSelectedPos) {\n searchPosition.startRow = prevSelectedPos.start.y;\n searchPosition.startCol = 0;\n result = this._findInLine(term, searchPosition, searchOptions);\n }\n\n return result;\n }\n\n /**\n * Find the previous occurrence of a term with wrapping and selection management.\n * @param term The search term.\n * @param searchOptions Search options.\n * @param cachedSearchTerm The cached search term to determine if expansion should occur.\n * @returns The search result if found, undefined otherwise.\n */\n public findPreviousWithSelection(term: string, searchOptions?: ISearchOptions, cachedSearchTerm?: string): ISearchResult | undefined {\n if (!term || term.length === 0) {\n this._terminal.clearSelection();\n return undefined;\n }\n\n const prevSelectedPos = this._terminal.getSelectionPosition();\n this._terminal.clearSelection();\n\n let startRow = this._terminal.buffer.active.baseY + this._terminal.rows - 1;\n const startCol = this._terminal.cols;\n const isReverseSearch = true;\n\n this._lineCache.initLinesCache();\n const searchPosition: ISearchPosition = {\n startRow,\n startCol\n };\n\n let result: ISearchResult | undefined;\n if (prevSelectedPos) {\n searchPosition.startRow = startRow = prevSelectedPos.start.y;\n searchPosition.startCol = prevSelectedPos.start.x;\n if (cachedSearchTerm !== term) {\n // Try to expand selection to right first.\n result = this._findInLine(term, searchPosition, searchOptions, false);\n if (!result) {\n // If selection was not able to be expanded to the right, then try reverse search\n searchPosition.startRow = startRow = prevSelectedPos.end.y;\n searchPosition.startCol = prevSelectedPos.end.x;\n }\n }\n }\n\n result ??= this._findInLine(term, searchPosition, searchOptions, isReverseSearch);\n\n // Search from startRow - 1 to top\n if (!result) {\n searchPosition.startCol = Math.max(searchPosition.startCol, this._terminal.cols);\n for (let y = startRow - 1; y >= 0; y--) {\n searchPosition.startRow = y;\n result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);\n if (result) {\n break;\n }\n }\n }\n // If we hit the top and didn't search from the very bottom wrap back down\n if (!result && startRow !== (this._terminal.buffer.active.baseY + this._terminal.rows - 1)) {\n for (let y = (this._terminal.buffer.active.baseY + this._terminal.rows - 1); y >= startRow; y--) {\n searchPosition.startRow = y;\n result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);\n if (result) {\n break;\n }\n }\n }\n\n return result;\n }\n\n /**\n * A found substring is a whole word if it doesn't have an alphanumeric character directly\n * adjacent to it.\n * @param searchIndex starting index of the potential whole word substring\n * @param line entire string in which the potential whole word was found\n * @param term the substring that starts at searchIndex\n */\n private _isWholeWord(searchIndex: number, line: string, term: string): boolean {\n return ((searchIndex === 0) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex - 1]))) &&\n (((searchIndex + term.length) === line.length) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex + term.length])));\n }\n\n /**\n * Searches a line for a search term. Takes the provided terminal line and searches the text line,\n * which may contain subsequent terminal lines if the text is wrapped. If the provided line number\n * is part of a wrapped text line that started on an earlier line then it is skipped since it will\n * be properly searched when the terminal line that the text starts on is searched.\n * @param term The search term.\n * @param searchPosition The position to start the search.\n * @param searchOptions Search options.\n * @param isReverseSearch Whether the search should start from the right side of the terminal and\n * search to the left.\n * @returns The search result if it was found.\n */\n private _findInLine(term: string, searchPosition: ISearchPosition, searchOptions: ISearchOptions = {}, isReverseSearch: boolean = false): ISearchResult | undefined {\n const row = searchPosition.startRow;\n const col = searchPosition.startCol;\n\n // Ignore wrapped lines, only consider on unwrapped line (first row of command string).\n const firstLine = this._terminal.buffer.active.getLine(row);\n if (firstLine?.isWrapped) {\n if (isReverseSearch) {\n searchPosition.startCol += this._terminal.cols;\n return;\n }\n\n // This will iterate until we find the line start.\n // When we find it, we will search using the calculated start column.\n searchPosition.startRow--;\n searchPosition.startCol += this._terminal.cols;\n return this._findInLine(term, searchPosition, searchOptions);\n }\n let cache = this._lineCache.getLineFromCache(row);\n if (!cache) {\n cache = this._lineCache.translateBufferLineToStringWithWrap(row, true);\n this._lineCache.setLineInCache(row, cache);\n }\n const [stringLine, offsets] = cache;\n\n const offset = this._bufferColsToStringOffset(row, col);\n let searchTerm = term;\n let searchStringLine = stringLine;\n if (!searchOptions.regex) {\n searchTerm = searchOptions.caseSensitive ? term : term.toLowerCase();\n searchStringLine = searchOptions.caseSensitive ? stringLine : stringLine.toLowerCase();\n }\n\n let resultIndex = -1;\n if (searchOptions.regex) {\n const searchRegex = RegExp(searchTerm, searchOptions.caseSensitive ? 'g' : 'gi');\n let foundTerm: RegExpExecArray | null;\n if (isReverseSearch) {\n // This loop will get the resultIndex of the _last_ regex match in the range 0..offset\n while (foundTerm = searchRegex.exec(searchStringLine.slice(0, offset))) {\n resultIndex = searchRegex.lastIndex - foundTerm[0].length;\n term = foundTerm[0];\n searchRegex.lastIndex -= (term.length - 1);\n }\n } else {\n foundTerm = searchRegex.exec(searchStringLine.slice(offset));\n if (foundTerm && foundTerm[0].length > 0) {\n resultIndex = offset + (searchRegex.lastIndex - foundTerm[0].length);\n term = foundTerm[0];\n }\n }\n } else {\n if (isReverseSearch) {\n if (offset - searchTerm.length >= 0) {\n resultIndex = searchStringLine.lastIndexOf(searchTerm, offset - searchTerm.length);\n }\n } else {\n resultIndex = searchStringLine.indexOf(searchTerm, offset);\n }\n }\n\n if (resultIndex >= 0) {\n if (searchOptions.wholeWord && !this._isWholeWord(resultIndex, searchStringLine, term)) {\n return;\n }\n\n // Adjust the row number and search index if needed since a \"line\" of text can span multiple\n // rows\n let startRowOffset = 0;\n while (startRowOffset < offsets.length - 1 && resultIndex >= offsets[startRowOffset + 1]) {\n startRowOffset++;\n }\n let endRowOffset = startRowOffset;\n while (endRowOffset < offsets.length - 1 && resultIndex + term.length >= offsets[endRowOffset + 1]) {\n endRowOffset++;\n }\n const startColOffset = resultIndex - offsets[startRowOffset];\n const endColOffset = resultIndex + term.length - offsets[endRowOffset];\n const startColIndex = this._stringLengthToBufferSize(row + startRowOffset, startColOffset);\n const endColIndex = this._stringLengthToBufferSize(row + endRowOffset, endColOffset);\n const size = endColIndex - startColIndex + this._terminal.cols * (endRowOffset - startRowOffset);\n\n return {\n term,\n col: startColIndex,\n row: row + startRowOffset,\n size\n };\n }\n }\n\n private _stringLengthToBufferSize(row: number, offset: number): number {\n const line = this._terminal.buffer.active.getLine(row);\n if (!line) {\n return 0;\n }\n for (let i = 0; i < offset; i++) {\n const cell = line.getCell(i);\n if (!cell) {\n break;\n }\n // Adjust the searchIndex to normalize emoji into single chars\n const char = cell.getChars();\n if (char.length > 1) {\n offset -= char.length - 1;\n }\n // Adjust the searchIndex for empty characters following wide unicode\n // chars (eg. CJK)\n const nextCell = line.getCell(i + 1);\n if (nextCell && nextCell.getWidth() === 0) {\n offset++;\n }\n }\n return offset;\n }\n\n private _bufferColsToStringOffset(startRow: number, cols: number): number {\n let lineIndex = startRow;\n let offset = 0;\n let line = this._terminal.buffer.active.getLine(lineIndex);\n while (cols > 0 && line) {\n for (let i = 0; i < cols && i < this._terminal.cols; i++) {\n const cell = line.getCell(i);\n if (!cell) {\n break;\n }\n if (cell.getWidth()) {\n // Treat null characters as whitespace to align with the translateToString API\n offset += cell.getCode() === 0 ? 1 : cell.getChars().length;\n }\n }\n lineIndex++;\n line = this._terminal.buffer.active.getLine(lineIndex);\n if (line && !line.isWrapped) {\n break;\n }\n cols -= this._terminal.cols;\n }\n return offset;\n }\n}\n", "/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { Terminal, IDisposable, IDecoration } from '@xterm/xterm';\nimport type { ISearchDecorationOptions } from '@xterm/addon-search';\nimport { dispose, Disposable, toDisposable } from 'common/Lifecycle';\nimport type { ISearchResult } from './SearchEngine';\n\n/**\n * Interface for managing a highlight decoration.\n */\ninterface IHighlight extends IDisposable {\n decoration: IDecoration;\n match: ISearchResult;\n}\n\n/**\n * Interface for managing multiple decorations for a single match.\n */\ninterface IMultiHighlight extends IDisposable {\n decorations: IDecoration[];\n match: ISearchResult;\n}\n\n/**\n * Manages visual decorations for search results including highlighting and active selection\n * indicators. This class handles the creation, styling, and disposal of search-related decorations.\n */\nexport class DecorationManager extends Disposable {\n private _highlightDecorations: IHighlight[] = [];\n private _highlightedLines: Set = new Set();\n\n constructor(private readonly _terminal: Terminal) {\n super();\n this._register(toDisposable(() => this.clearHighlightDecorations()));\n }\n\n /**\n * Creates decorations for all provided search results.\n * @param results The search results to create decorations for.\n * @param options The decoration options.\n */\n public createHighlightDecorations(results: ISearchResult[], options: ISearchDecorationOptions): void {\n this.clearHighlightDecorations();\n\n for (const match of results) {\n const decorations = this._createResultDecorations(match, options, false);\n if (decorations) {\n for (const decoration of decorations) {\n this._storeDecoration(decoration, match);\n }\n }\n }\n }\n\n /**\n * Creates decorations for the currently active search result.\n * @param result The active search result.\n * @param options The decoration options.\n * @returns The multi-highlight decoration or undefined if creation failed.\n */\n public createActiveDecoration(result: ISearchResult, options: ISearchDecorationOptions): IMultiHighlight | undefined {\n const decorations = this._createResultDecorations(result, options, true);\n if (decorations) {\n return { decorations, match: result, dispose() { dispose(decorations); } };\n }\n return undefined;\n }\n\n /**\n * Clears all highlight decorations.\n */\n public clearHighlightDecorations(): void {\n dispose(this._highlightDecorations);\n this._highlightDecorations = [];\n this._highlightedLines.clear();\n }\n\n /**\n * Stores a decoration and tracks it for management.\n * @param decoration The decoration to store.\n * @param match The search result this decoration represents.\n */\n private _storeDecoration(decoration: IDecoration, match: ISearchResult): void {\n this._highlightedLines.add(decoration.marker.line);\n this._highlightDecorations.push({ decoration, match, dispose() { decoration.dispose(); } });\n }\n\n /**\n * Applies styles to the decoration when it is rendered.\n * @param element The decoration's element.\n * @param borderColor The border color to apply.\n * @param isActiveResult Whether the element is part of the active search result.\n */\n private _applyStyles(element: HTMLElement, borderColor: string | undefined, isActiveResult: boolean): void {\n if (!element.classList.contains('xterm-find-result-decoration')) {\n element.classList.add('xterm-find-result-decoration');\n if (borderColor) {\n element.style.outline = `1px solid ${borderColor}`;\n }\n }\n if (isActiveResult) {\n element.classList.add('xterm-find-active-result-decoration');\n }\n }\n\n /**\n * Creates a decoration for the result and applies styles\n * @param result the search result for which to create the decoration\n * @param options the options for the decoration\n * @param isActiveResult whether this is the currently active result\n * @returns the decorations or undefined if the marker has already been disposed of\n */\n private _createResultDecorations(result: ISearchResult, options: ISearchDecorationOptions, isActiveResult: boolean): IDecoration[] | undefined {\n // Gather decoration ranges for this match as it could wrap\n const decorationRanges: [number, number, number][] = [];\n let currentCol = result.col;\n let remainingSize = result.size;\n let markerOffset = -this._terminal.buffer.active.baseY - this._terminal.buffer.active.cursorY + result.row;\n while (remainingSize > 0) {\n const amountThisRow = Math.min(this._terminal.cols - currentCol, remainingSize);\n decorationRanges.push([markerOffset, currentCol, amountThisRow]);\n currentCol = 0;\n remainingSize -= amountThisRow;\n markerOffset++;\n }\n\n // Create the decorations\n const decorations: IDecoration[] = [];\n for (const range of decorationRanges) {\n const marker = this._terminal.registerMarker(range[0]);\n const decoration = this._terminal.registerDecoration({\n marker,\n x: range[1],\n width: range[2],\n layer: isActiveResult ? 'top' : 'bottom',\n backgroundColor: isActiveResult ? options.activeMatchBackground : options.matchBackground,\n overviewRulerOptions: this._highlightedLines.has(marker.line) ? undefined : {\n color: isActiveResult ? options.activeMatchColorOverviewRuler : options.matchOverviewRuler,\n position: 'center'\n }\n });\n if (decoration) {\n const disposables: IDisposable[] = [];\n disposables.push(marker);\n disposables.push(decoration.onRender((e) => this._applyStyles(e, isActiveResult ? options.activeMatchBorder : options.matchBorder, false)));\n disposables.push(decoration.onDispose(() => dispose(disposables)));\n decorations.push(decoration);\n }\n }\n\n return decorations.length === 0 ? undefined : decorations;\n }\n}\n\n\n", "/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ISearchResultChangeEvent } from '@xterm/addon-search';\nimport type { IDisposable } from '@xterm/xterm';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { Disposable } from 'common/Lifecycle';\nimport type { ISearchResult } from './SearchEngine';\n\n/**\n * Interface for managing a currently selected decoration.\n */\ninterface ISelectedDecoration extends IDisposable {\n match: ISearchResult;\n}\n\n/**\n * Tracks search results, manages result indexing, and fires events when results change.\n * This class provides centralized management of search result state and notifications.\n */\nexport class SearchResultTracker extends Disposable {\n private _searchResults: ISearchResult[] = [];\n private _selectedDecoration: ISelectedDecoration | undefined;\n\n private readonly _onDidChangeResults = this._register(new Emitter());\n public get onDidChangeResults(): IEvent { return this._onDidChangeResults.event; }\n\n /**\n * Gets the current search results.\n */\n public get searchResults(): ReadonlyArray {\n return this._searchResults;\n }\n\n /**\n * Gets the currently selected decoration.\n */\n public get selectedDecoration(): ISelectedDecoration | undefined {\n return this._selectedDecoration;\n }\n\n /**\n * Sets the currently selected decoration.\n */\n public set selectedDecoration(decoration: ISelectedDecoration | undefined) {\n this._selectedDecoration = decoration;\n }\n\n /**\n * Updates the search results with a new set of results.\n * @param results The new search results.\n * @param maxResults The maximum number of results to track.\n */\n public updateResults(results: ISearchResult[], maxResults: number): void {\n this._searchResults = results.slice(0, maxResults);\n }\n\n /**\n * Clears all search results.\n */\n public clearResults(): void {\n this._searchResults = [];\n }\n\n /**\n * Clears the selected decoration.\n */\n public clearSelectedDecoration(): void {\n if (this._selectedDecoration) {\n this._selectedDecoration.dispose();\n this._selectedDecoration = undefined;\n }\n }\n\n /**\n * Finds the index of a result in the current results array.\n * @param result The result to find.\n * @returns The index of the result, or -1 if not found.\n */\n public findResultIndex(result: ISearchResult): number {\n for (let i = 0; i < this._searchResults.length; i++) {\n const match = this._searchResults[i];\n if (match.row === result.row && match.col === result.col && match.size === result.size) {\n return i;\n }\n }\n return -1;\n }\n\n /**\n * Fires a result change event with the current state.\n * @param hasDecorations Whether decorations are enabled.\n */\n public fireResultsChanged(hasDecorations: boolean): void {\n if (!hasDecorations) {\n return;\n }\n\n let resultIndex = -1;\n if (this._selectedDecoration) {\n resultIndex = this.findResultIndex(this._selectedDecoration.match);\n }\n\n this._onDidChangeResults.fire({\n resultIndex,\n resultCount: this._searchResults.length\n });\n }\n\n /**\n * Resets all state.\n */\n public reset(): void {\n this.clearSelectedDecoration();\n this.clearResults();\n }\n}\n", "/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { Terminal, IDisposable, ITerminalAddon } from '@xterm/xterm';\nimport type { SearchAddon as ISearchApi, ISearchOptions, ISearchAddonOptions, ISearchResultChangeEvent, ISearchDecorationOptions } from '@xterm/addon-search';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\nimport { disposableTimeout } from 'common/Async';\nimport { SearchLineCache } from './SearchLineCache';\nimport { SearchState } from './SearchState';\nimport { SearchEngine, type ISearchResult } from './SearchEngine';\nimport { DecorationManager } from './DecorationManager';\nimport { SearchResultTracker } from './SearchResultTracker';\n\ninterface IInternalSearchOptions {\n noScroll: boolean;\n}\n\n/**\n * Configuration constants for the search addon functionality.\n */\nconst enum Constants {\n /**\n * Default maximum number of search results to highlight simultaneously. This limit prevents\n * performance degradation when searching for very common terms that would result in excessive\n * highlighting decorations.\n */\n DEFAULT_HIGHLIGHT_LIMIT = 1000\n}\n\nexport class SearchAddon extends Disposable implements ITerminalAddon, ISearchApi {\n private _terminal: Terminal | undefined;\n private _highlightLimit: number;\n private _highlightTimeout = this._register(new MutableDisposable());\n private _lineCache = this._register(new MutableDisposable());\n\n // Component instances\n private _state = new SearchState();\n private _engine: SearchEngine | undefined;\n private _decorationManager: DecorationManager | undefined;\n private _resultTracker = this._register(new SearchResultTracker());\n\n private readonly _onAfterSearch = this._register(new Emitter());\n public readonly onAfterSearch = this._onAfterSearch.event;\n private readonly _onBeforeSearch = this._register(new Emitter());\n public readonly onBeforeSearch = this._onBeforeSearch.event;\n\n public get onDidChangeResults(): IEvent {\n return this._resultTracker.onDidChangeResults;\n }\n\n constructor(options?: Partial) {\n super();\n\n this._highlightLimit = options?.highlightLimit ?? Constants.DEFAULT_HIGHLIGHT_LIMIT;\n }\n\n public activate(terminal: Terminal): void {\n this._terminal = terminal;\n this._lineCache.value = new SearchLineCache(terminal);\n this._engine = new SearchEngine(terminal, this._lineCache.value);\n this._decorationManager = new DecorationManager(terminal);\n this._register(this._terminal.onWriteParsed(() => this._updateMatches()));\n this._register(this._terminal.onResize(() => this._updateMatches()));\n this._register(toDisposable(() => this.clearDecorations()));\n }\n\n private _updateMatches(): void {\n this._highlightTimeout.clear();\n if (this._state.cachedSearchTerm && this._state.lastSearchOptions?.decorations) {\n this._highlightTimeout.value = disposableTimeout(() => {\n const term = this._state.cachedSearchTerm;\n this._state.clearCachedTerm();\n this.findPrevious(term!, { ...this._state.lastSearchOptions, incremental: true }, { noScroll: true });\n }, 200);\n }\n }\n\n public clearDecorations(retainCachedSearchTerm?: boolean): void {\n this._resultTracker.clearSelectedDecoration();\n this._decorationManager?.clearHighlightDecorations();\n this._resultTracker.clearResults();\n if (!retainCachedSearchTerm) {\n this._state.clearCachedTerm();\n }\n }\n\n public clearActiveDecoration(): void {\n this._resultTracker.clearSelectedDecoration();\n }\n\n /**\n * Find the next instance of the term, then scroll to and select it. If it\n * doesn't exist, do nothing.\n * @param term The search term.\n * @param searchOptions Search options.\n * @returns Whether a result was found.\n */\n public findNext(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {\n if (!this._terminal || !this._engine) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n\n this._onBeforeSearch.fire();\n\n this._state.lastSearchOptions = searchOptions;\n\n if (this._state.shouldUpdateHighlighting(term, searchOptions)) {\n this._highlightAllMatches(term, searchOptions!);\n }\n\n const found = this._findNextAndSelect(term, searchOptions, internalSearchOptions);\n this._fireResults(searchOptions);\n this._state.cachedSearchTerm = term;\n\n this._onAfterSearch.fire();\n\n return found;\n }\n\n private _highlightAllMatches(term: string, searchOptions: ISearchOptions): void {\n if (!this._terminal || !this._engine || !this._decorationManager) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n if (!this._state.isValidSearchTerm(term)) {\n this.clearDecorations();\n return;\n }\n\n // new search, clear out the old decorations\n this.clearDecorations(true);\n\n const results: ISearchResult[] = [];\n let prevResult: ISearchResult | undefined = undefined;\n let result = this._engine.find(term, 0, 0, searchOptions);\n\n while (result && (prevResult?.row !== result.row || prevResult?.col !== result.col)) {\n if (results.length >= this._highlightLimit) {\n break;\n }\n prevResult = result;\n results.push(prevResult);\n const cols = this._terminal.cols;\n let nextCol = prevResult.col + prevResult.size;\n let nextRow = prevResult.row;\n if (nextCol >= cols) {\n nextRow += Math.floor(nextCol / cols);\n nextCol = nextCol % cols;\n }\n result = this._engine.find(term, nextRow, nextCol, searchOptions);\n }\n\n this._resultTracker.updateResults(results, this._highlightLimit);\n if (searchOptions.decorations) {\n this._decorationManager.createHighlightDecorations(results, searchOptions.decorations);\n }\n }\n\n private _findNextAndSelect(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {\n if (!this._terminal || !this._engine) {\n return false;\n }\n if (!this._state.isValidSearchTerm(term)) {\n this._terminal.clearSelection();\n this.clearDecorations();\n return false;\n }\n\n const result = this._engine.findNextWithSelection(term, searchOptions, this._state.cachedSearchTerm);\n return this._selectResult(result, searchOptions?.decorations, internalSearchOptions?.noScroll);\n }\n\n /**\n * Find the previous instance of the term, then scroll to and select it. If it\n * doesn't exist, do nothing.\n * @param term The search term.\n * @param searchOptions Search options.\n * @returns Whether a result was found.\n */\n public findPrevious(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {\n if (!this._terminal || !this._engine) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n\n this._onBeforeSearch.fire();\n\n this._state.lastSearchOptions = searchOptions;\n\n if (this._state.shouldUpdateHighlighting(term, searchOptions)) {\n this._highlightAllMatches(term, searchOptions!);\n }\n\n const found = this._findPreviousAndSelect(term, searchOptions, internalSearchOptions);\n this._fireResults(searchOptions);\n this._state.cachedSearchTerm = term;\n\n this._onAfterSearch.fire();\n\n return found;\n }\n\n private _fireResults(searchOptions?: ISearchOptions): void {\n this._resultTracker.fireResultsChanged(!!searchOptions?.decorations);\n }\n\n private _findPreviousAndSelect(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {\n if (!this._terminal || !this._engine) {\n return false;\n }\n if (!this._state.isValidSearchTerm(term)) {\n this._terminal.clearSelection();\n this.clearDecorations();\n return false;\n }\n\n const result = this._engine.findPreviousWithSelection(term, searchOptions, this._state.cachedSearchTerm);\n return this._selectResult(result, searchOptions?.decorations, internalSearchOptions?.noScroll);\n }\n\n /**\n * Selects and scrolls to a result.\n * @param result The result to select.\n * @returns Whether a result was selected.\n */\n private _selectResult(result: ISearchResult | undefined, options?: ISearchDecorationOptions, noScroll?: boolean): boolean {\n if (!this._terminal || !this._decorationManager) {\n return false;\n }\n\n this._resultTracker.clearSelectedDecoration();\n if (!result) {\n this._terminal.clearSelection();\n return false;\n }\n\n this._terminal.select(result.col, result.row, result.size);\n if (options) {\n const activeDecoration = this._decorationManager.createActiveDecoration(result, options);\n if (activeDecoration) {\n this._resultTracker.selectedDecoration = activeDecoration;\n }\n }\n\n if (!noScroll) {\n // If it is not in the viewport then we scroll else it just gets selected\n if (result.row >= (this._terminal.buffer.active.viewportY + this._terminal.rows) || result.row < this._terminal.buffer.active.viewportY) {\n let scroll = result.row - this._terminal.buffer.active.viewportY;\n scroll -= Math.floor(this._terminal.rows / 2);\n this._terminal.scrollLines(scroll);\n }\n }\n return true;\n }\n}\n"], +- "mappings": "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", +- "names": ["toDisposable", "fn", "dispose", "arg", "d", "combinedDisposable", "disposables", "DisposableStore", "o", "Disposable", "MutableDisposable", "value", "Emitter", "listener", "thisArgs", "disposables", "toDisposable", "entry", "result", "idx", "event", "listeners", "i", "len", "EventUtils", "forward", "from", "to", "e", "map", "any", "events", "store", "DisposableStore", "runAndSubscribe", "handler", "initial", "disposableTimeout", "handler", "timeout", "store", "timer", "disposable", "toDisposable", "SearchLineCache", "Disposable", "_terminal", "MutableDisposable", "toDisposable", "combinedDisposable", "delay", "disposableTimeout", "elapsed", "row", "entry", "lineIndex", "trimRight", "strings", "lineOffsets", "line", "nextLine", "lineWrapsToNext", "string", "lastCell", "SearchState", "term", "options", "newOptions", "SearchEngine", "_terminal", "_lineCache", "term", "startRow", "startCol", "searchOptions", "searchPosition", "result", "y", "cachedSearchTerm", "prevSelectedPos", "isReverseSearch", "searchIndex", "line", "row", "col", "cache", "stringLine", "offsets", "offset", "searchTerm", "searchStringLine", "resultIndex", "searchRegex", "foundTerm", "startRowOffset", "endRowOffset", "startColOffset", "endColOffset", "startColIndex", "size", "i", "cell", "char", "nextCell", "cols", "lineIndex", "DecorationManager", "Disposable", "_terminal", "toDisposable", "results", "options", "match", "decorations", "decoration", "result", "dispose", "element", "borderColor", "isActiveResult", "decorationRanges", "currentCol", "remainingSize", "markerOffset", "amountThisRow", "range", "marker", "disposables", "e", "SearchResultTracker", "Disposable", "Emitter", "decoration", "results", "maxResults", "result", "i", "match", "hasDecorations", "resultIndex", "SearchAddon", "Disposable", "options", "MutableDisposable", "SearchState", "SearchResultTracker", "Emitter", "terminal", "SearchLineCache", "SearchEngine", "DecorationManager", "toDisposable", "disposableTimeout", "term", "retainCachedSearchTerm", "searchOptions", "internalSearchOptions", "found", "results", "prevResult", "result", "cols", "nextCol", "nextRow", "noScroll", "activeDecoration", "scroll"] ++ "sourcesContent": ["/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal lifecycle utilities for xterm.js core.\n * Simplified from VS Code's lifecycle.ts - no tracking/leak detection.\n */\n\nexport interface IDisposable {\n dispose(): void;\n}\n\nexport function toDisposable(fn: () => void): IDisposable {\n return { dispose: fn };\n}\n\nexport function dispose(disposable: T): T;\nexport function dispose(disposable: T | undefined): T | undefined;\nexport function dispose(disposables: T[]): T[];\nexport function dispose(arg: T | T[] | undefined): T | T[] | undefined {\n if (!arg) {\n return arg;\n }\n if (Array.isArray(arg)) {\n for (const d of arg) {\n d.dispose();\n }\n return [];\n }\n arg.dispose();\n return arg;\n}\n\nexport function combinedDisposable(...disposables: IDisposable[]): IDisposable {\n return toDisposable(() => dispose(disposables));\n}\n\nexport class DisposableStore implements IDisposable {\n private readonly _disposables = new Set();\n private _isDisposed = false;\n\n public get isDisposed(): boolean {\n return this._isDisposed;\n }\n\n public add(o: T): T {\n if (this._isDisposed) {\n o.dispose();\n } else {\n this._disposables.add(o);\n }\n return o;\n }\n\n public dispose(): void {\n if (this._isDisposed) {\n return;\n }\n this._isDisposed = true;\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n\n public clear(): void {\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n}\n\nexport abstract class Disposable implements IDisposable {\n public static readonly None: IDisposable = Object.freeze({ dispose() { } });\n\n protected readonly _store = new DisposableStore();\n\n public dispose(): void {\n this._store.dispose();\n }\n\n protected _register(o: T): T {\n return this._store.add(o);\n }\n}\n\nexport class MutableDisposable implements IDisposable {\n private _value: T | undefined;\n private _isDisposed = false;\n\n public get value(): T | undefined {\n return this._isDisposed ? undefined : this._value;\n }\n\n public set value(value: T | undefined) {\n if (this._isDisposed || value === this._value) {\n return;\n }\n this._value?.dispose();\n this._value = value;\n }\n\n public clear(): void {\n this.value = undefined;\n }\n\n public dispose(): void {\n this._isDisposed = true;\n this._value?.dispose();\n this._value = undefined;\n }\n}\n", "/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal event utilities for xterm.js core.\n * Simplified from VS Code's event.ts - no leak detection/profiling.\n */\n\nimport { IDisposable, DisposableStore, toDisposable } from './Lifecycle';\n\nexport interface IEvent {\n (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore): IDisposable;\n}\n\nexport class Emitter {\n private _listeners: { fn: (e: T) => any, thisArgs: any }[] = [];\n private _disposed = false;\n private _event: IEvent | undefined;\n\n public get event(): IEvent {\n if (this._event) {\n return this._event;\n }\n this._event = (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n if (this._disposed) {\n return toDisposable(() => {});\n }\n\n const entry = { fn: listener, thisArgs };\n this._listeners = this._listeners.slice();\n this._listeners.push(entry);\n\n const result = toDisposable(() => {\n const idx = this._listeners.indexOf(entry);\n if (idx !== -1) {\n this._listeners = this._listeners.slice();\n this._listeners.splice(idx, 1);\n }\n });\n\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(result);\n } else {\n disposables.add(result);\n }\n }\n\n return result;\n };\n return this._event;\n }\n\n public fire(event: T): void {\n if (this._disposed || !this._listeners.length) {\n return;\n }\n if (this._listeners.length === 1) {\n this._listeners[0].fn.call(this._listeners[0].thisArgs, event);\n return;\n }\n const listeners = this._listeners;\n for (let i = 0, len = listeners.length; i < len; ++i) {\n listeners[i].fn.call(listeners[i].thisArgs, event);\n }\n }\n\n public dispose(): void {\n if (this._disposed) {\n return;\n }\n this._disposed = true;\n this._listeners.length = 0;\n }\n}\n\nexport namespace EventUtils {\n export function forward(from: IEvent, to: Emitter): IDisposable {\n return from(e => to.fire(e));\n }\n\n export function map(event: IEvent, map: (i: I) => O): IEvent {\n return (listener: (e: O) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n return event(i => listener.call(thisArgs, map(i)), undefined, disposables);\n };\n }\n\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent {\n return (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n const store = new DisposableStore();\n for (const event of events) {\n store.add(event(e => listener.call(thisArgs, e)));\n }\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(store);\n } else {\n disposables.add(store);\n }\n }\n return store;\n };\n }\n\n export function runAndSubscribe(event: IEvent, handler: (e: T) => void, initial: T): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void, initial?: T): IDisposable {\n handler(initial);\n return event(e => handler(e));\n }\n}\n", "/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal async helpers for xterm.js core.\n */\n\nimport { DisposableStore, IDisposable, toDisposable } from './Lifecycle';\n\nexport function timeout(millis: number): Promise {\n return new Promise(resolve => setTimeout(resolve, millis));\n}\n\n/**\n * Creates a timeout that can be disposed using its returned value.\n * @param handler The timeout handler.\n * @param timeout An optional timeout in milliseconds.\n * @param store An optional {@link DisposableStore} that will have the timeout disposable managed\n * automatically.\n */\nexport function disposableTimeout(handler: () => void, timeout = 0, store?: DisposableStore): IDisposable {\n const timer = setTimeout(() => {\n handler();\n if (store) {\n disposable.dispose();\n }\n }, timeout);\n const disposable = toDisposable(() => {\n clearTimeout(timer);\n });\n store?.add(disposable);\n return disposable;\n}\n\nexport class TimeoutTimer implements IDisposable {\n private _token: any = -1;\n private _isDisposed = false;\n\n public dispose(): void {\n this.cancel();\n this._isDisposed = true;\n }\n\n public cancel(): void {\n if (this._token !== -1) {\n clearTimeout(this._token);\n this._token = -1;\n }\n }\n\n public cancelAndSet(runner: () => void, timeout: number): void {\n if (this._isDisposed) {\n throw new Error('Calling cancelAndSet on a disposed TimeoutTimer');\n }\n this.cancel();\n this._token = setTimeout(() => {\n this._token = -1;\n runner();\n }, timeout);\n }\n\n public setIfNotSet(runner: () => void, timeout: number): void {\n if (this._isDisposed) {\n throw new Error('Calling setIfNotSet on a disposed TimeoutTimer');\n }\n if (this._token !== -1) {\n return;\n }\n this._token = setTimeout(() => {\n this._token = -1;\n runner();\n }, timeout);\n }\n}\n\n/**\n * Schedules a single runner on the microtask queue. Unlike {@link TimeoutTimer}, a scheduled\n * microtask cannot be unqueued; {@link cancel} prevents the runner from executing if it has not\n * run yet.\n */\nexport class MicrotaskTimer implements IDisposable {\n private _isScheduled = false;\n private _isDisposed = false;\n\n public dispose(): void {\n this.cancel();\n this._isDisposed = true;\n }\n\n public cancel(): void {\n this._isScheduled = false;\n }\n\n public set(runner: () => void): void {\n if (this._isDisposed) {\n throw new Error('Calling set on a disposed MicrotaskTimer');\n }\n if (this._isScheduled) {\n return;\n }\n this._isScheduled = true;\n queueMicrotask(() => {\n if (!this._isScheduled) {\n return;\n }\n this._isScheduled = false;\n runner();\n });\n }\n}\n\nexport class IntervalTimer implements IDisposable {\n private _disposable: IDisposable | undefined;\n private _isDisposed = false;\n\n public cancel(): void {\n this._disposable?.dispose();\n this._disposable = undefined;\n }\n\n public cancelAndSet(runner: () => void, interval: number, context: Window | typeof globalThis = globalThis): void {\n if (this._isDisposed) {\n throw new Error('Calling cancelAndSet on a disposed IntervalTimer');\n }\n this.cancel();\n const handle = context.setInterval(() => {\n runner();\n }, interval);\n this._disposable = {\n dispose: () => {\n context.clearInterval(handle as any);\n this._disposable = undefined;\n }\n };\n }\n\n public dispose(): void {\n this.cancel();\n this._isDisposed = true;\n }\n}\n", "/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { Terminal } from '@xterm/xterm';\nimport { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\nimport { disposableTimeout } from 'common/Async';\n\nexport type LineCacheEntry = [\n /**\n * The string representation of a line (as opposed to the buffer cell representation).\n */\n lineAsString: string,\n /**\n * The offsets where each line starts when the entry describes a wrapped line.\n */\n lineOffsets: number[]\n];\n\n/**\n * Configuration constants for the search line cache functionality.\n */\nconst enum Constants {\n /**\n * Time-to-live for cached search results in milliseconds. After this duration, cached search\n * results will be invalidated to ensure they remain consistent with terminal content changes.\n */\n LINES_CACHE_TIME_TO_LIVE = 15000\n}\n\nexport class SearchLineCache extends Disposable {\n /**\n * translateBufferLineToStringWithWrap is a fairly expensive call.\n * We memoize the calls into an array that has a time based ttl.\n * _linesCache is also invalidated when the terminal cursor moves.\n */\n private _linesCache: LineCacheEntry[] | undefined;\n private _linesCacheTimeout = this._register(new MutableDisposable());\n private _linesCacheDisposables = this._register(new MutableDisposable());\n // Track access to avoid recreating a timeout on every init call which occurs once per search\n // result (findNext/findPrevious -> _highlightAllMatches -> find loop).\n private _lastAccessTimestamp = 0;\n\n constructor(private readonly _terminal: Terminal) {\n super();\n this._register(toDisposable(() => this._destroyLinesCache()));\n }\n\n /**\n * Sets up a line cache with a ttl\n */\n public initLinesCache(): void {\n if (!this._linesCache) {\n this._linesCache = new Array(this._terminal.buffer.active.length);\n this._linesCacheDisposables.value = combinedDisposable(\n this._terminal.onLineFeed(() => this._destroyLinesCache()),\n this._terminal.onCursorMove(() => this._destroyLinesCache()),\n this._terminal.onResize(() => this._destroyLinesCache())\n );\n }\n\n this._lastAccessTimestamp = Date.now();\n if (!this._linesCacheTimeout.value) {\n this._scheduleLinesCacheTimeout(Constants.LINES_CACHE_TIME_TO_LIVE);\n }\n }\n\n private _destroyLinesCache(): void {\n this._linesCache = undefined;\n this._lastAccessTimestamp = 0;\n this._linesCacheDisposables.clear();\n this._linesCacheTimeout.clear();\n }\n\n private _scheduleLinesCacheTimeout(delay: number): void {\n this._linesCacheTimeout.value = disposableTimeout(() => {\n if (!this._linesCache) {\n return;\n }\n const now = Date.now();\n const elapsed = now - this._lastAccessTimestamp;\n if (elapsed >= Constants.LINES_CACHE_TIME_TO_LIVE) {\n this._destroyLinesCache();\n return;\n }\n this._scheduleLinesCacheTimeout(Constants.LINES_CACHE_TIME_TO_LIVE - elapsed);\n }, delay);\n }\n\n public getLineFromCache(row: number): LineCacheEntry | undefined {\n return this._linesCache?.[row];\n }\n\n public setLineInCache(row: number, entry: LineCacheEntry): void {\n if (this._linesCache) {\n this._linesCache[row] = entry;\n }\n }\n\n /**\n * Translates a buffer line to a string, including subsequent lines if they are wraps.\n * Wide characters will count as two columns in the resulting string. This\n * function is useful for getting the actual text underneath the raw selection\n * position.\n * @param lineIndex The index of the line being translated.\n * @param trimRight Whether to trim whitespace to the right.\n */\n public translateBufferLineToStringWithWrap(lineIndex: number, trimRight: boolean): LineCacheEntry {\n const strings = [];\n const lineOffsets = [0];\n // A single line longer than the whole scrollback leaves every buffer row wrapped, and the\n // buffer's ring answers an out-of-range row by cycling back to the start, so an unbounded walk\n // never reaches an unwrapped line.\n const bufferLength = this._terminal.buffer.active.length;\n let line = this._terminal.buffer.active.getLine(lineIndex);\n while (line) {\n const nextLine = lineIndex + 1 < bufferLength ? this._terminal.buffer.active.getLine(lineIndex + 1) : undefined;\n const lineWrapsToNext = nextLine ? nextLine.isWrapped : false;\n let string = line.translateToString(!lineWrapsToNext && trimRight);\n if (lineWrapsToNext && nextLine) {\n const lastCell = line.getCell(line.length - 1);\n const lastCellIsNull = lastCell && lastCell.getCode() === 0 && lastCell.getWidth() === 1;\n // a wide character wrapped to the next line\n if (lastCellIsNull && nextLine.getCell(0)?.getWidth() === 2) {\n string = string.slice(0, -1);\n }\n }\n strings.push(string);\n if (lineWrapsToNext) {\n lineOffsets.push(lineOffsets[lineOffsets.length - 1] + string.length);\n } else {\n break;\n }\n lineIndex++;\n line = nextLine;\n }\n return [strings.join(''), lineOffsets];\n }\n}\n", "/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ISearchOptions } from '@xterm/addon-search';\n\n/**\n * Manages search state including cached search terms, options tracking, and validation.\n * This class provides a centralized way to handle search state consistency and option changes.\n */\nexport class SearchState {\n private _cachedSearchTerm: string | undefined;\n private _lastSearchOptions: ISearchOptions | undefined;\n\n /**\n * Gets the currently cached search term.\n */\n public get cachedSearchTerm(): string | undefined {\n return this._cachedSearchTerm;\n }\n\n /**\n * Sets the cached search term.\n */\n public set cachedSearchTerm(term: string | undefined) {\n this._cachedSearchTerm = term;\n }\n\n /**\n * Gets the last search options used.\n */\n public get lastSearchOptions(): ISearchOptions | undefined {\n return this._lastSearchOptions;\n }\n\n /**\n * Sets the last search options used.\n */\n public set lastSearchOptions(options: ISearchOptions | undefined) {\n this._lastSearchOptions = options;\n }\n\n /**\n * Validates a search term to ensure it's not empty or invalid.\n * @param term The search term to validate.\n * @returns true if the term is valid for searching.\n */\n public isValidSearchTerm(term: string): boolean {\n return !!(term && term.length > 0);\n }\n\n /**\n * Determines if search options have changed compared to the last search.\n * @param newOptions The new search options to compare.\n * @returns true if the options have changed.\n */\n public didOptionsChange(newOptions?: ISearchOptions): boolean {\n if (!this._lastSearchOptions) {\n return true;\n }\n if (!newOptions) {\n return false;\n }\n if (this._lastSearchOptions.caseSensitive !== newOptions.caseSensitive) {\n return true;\n }\n if (this._lastSearchOptions.regex !== newOptions.regex) {\n return true;\n }\n if (this._lastSearchOptions.wholeWord !== newOptions.wholeWord) {\n return true;\n }\n return false;\n }\n\n /**\n * Determines if a new search should trigger highlighting updates.\n * @param term The search term.\n * @param options The search options.\n * @returns true if highlighting should be updated.\n */\n public shouldUpdateHighlighting(term: string, options?: ISearchOptions): boolean {\n if (!options?.decorations) {\n return false;\n }\n return this._cachedSearchTerm === undefined ||\n term !== this._cachedSearchTerm ||\n this.didOptionsChange(options);\n }\n\n /**\n * Clears the cached search term.\n */\n public clearCachedTerm(): void {\n this._cachedSearchTerm = undefined;\n }\n\n /**\n * Resets all state.\n */\n public reset(): void {\n this._cachedSearchTerm = undefined;\n this._lastSearchOptions = undefined;\n }\n}\n", "/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { Terminal } from '@xterm/xterm';\nimport type { ISearchOptions } from '@xterm/addon-search';\nimport type { SearchLineCache } from './SearchLineCache';\n\n/**\n * Represents the position to start a search from.\n */\ninterface ISearchPosition {\n startCol: number;\n startRow: number;\n}\n\n/**\n * Represents a search result with its position and content.\n */\nexport interface ISearchResult {\n term: string;\n col: number;\n row: number;\n size: number;\n}\n\n/**\n * Configuration constants for the search engine functionality.\n */\nconst enum Constants {\n /**\n * Characters that are considered non-word characters for search boundary detection. These\n * characters are used to determine word boundaries when performing whole-word searches. Includes\n * common punctuation, symbols, and whitespace characters.\n */\n NON_WORD_CHARACTERS = ' ~!@#$%^&*()+`-=[]{}|\\\\;:\"\\',./<>?'\n}\n\n/**\n * Core search engine that handles finding text within terminal content.\n * This class is responsible for the actual search algorithms and position calculations.\n */\nexport class SearchEngine {\n constructor(\n private readonly _terminal: Terminal,\n private readonly _lineCache: SearchLineCache\n ) {}\n\n /**\n * Find the first occurrence of a term starting from a specific position.\n * @param term The search term.\n * @param startRow The row to start searching from.\n * @param startCol The column to start searching from.\n * @param searchOptions Search options.\n * @returns The search result if found, undefined otherwise.\n */\n public find(term: string, startRow: number, startCol: number, searchOptions?: ISearchOptions): ISearchResult | undefined {\n if (!term || term.length === 0) {\n this._terminal.clearSelection();\n return undefined;\n }\n if (startCol >= this._terminal.cols) {\n throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`);\n }\n\n this._lineCache.initLinesCache();\n\n const searchPosition: ISearchPosition = {\n startRow,\n startCol\n };\n\n // Search startRow\n let result = this._findInLine(term, searchPosition, searchOptions);\n // Search from startRow + 1 to end\n if (!result) {\n for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {\n if (this._isRowCoveredByEarlierSearch(y)) {\n continue;\n }\n searchPosition.startRow = y;\n searchPosition.startCol = 0;\n result = this._findInLine(term, searchPosition, searchOptions);\n if (result) {\n break;\n }\n }\n }\n return result;\n }\n\n /**\n * Find the next occurrence of a term with wrapping and selection management.\n * @param term The search term.\n * @param searchOptions Search options.\n * @param cachedSearchTerm The cached search term to determine incremental behavior.\n * @returns The search result if found, undefined otherwise.\n */\n public findNextWithSelection(term: string, searchOptions?: ISearchOptions, cachedSearchTerm?: string): ISearchResult | undefined {\n if (!term || term.length === 0) {\n this._terminal.clearSelection();\n return undefined;\n }\n\n const prevSelectedPos = this._terminal.getSelectionPosition();\n this._terminal.clearSelection();\n\n let startCol = 0;\n let startRow = 0;\n if (prevSelectedPos) {\n if (cachedSearchTerm === term) {\n startCol = prevSelectedPos.end.x;\n startRow = prevSelectedPos.end.y;\n } else {\n startCol = prevSelectedPos.start.x;\n startRow = prevSelectedPos.start.y;\n }\n }\n\n this._lineCache.initLinesCache();\n\n const searchPosition: ISearchPosition = {\n startRow,\n startCol\n };\n\n // Search startRow\n let result = this._findInLine(term, searchPosition, searchOptions);\n // Search from startRow + 1 to end\n if (!result) {\n for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {\n if (this._isRowCoveredByEarlierSearch(y)) {\n continue;\n }\n searchPosition.startRow = y;\n searchPosition.startCol = 0;\n result = this._findInLine(term, searchPosition, searchOptions);\n if (result) {\n break;\n }\n }\n }\n // If we hit the bottom and didn't search from the very top wrap back up\n if (!result && startRow !== 0) {\n for (let y = 0; y < startRow; y++) {\n // Row 0 is never skipped: it can be a continuation whose line start was trimmed from the\n // scrollback, and nothing earlier in this loop has searched it.\n if (y > 0 && this._isRowCoveredByEarlierSearch(y)) {\n continue;\n }\n searchPosition.startRow = y;\n searchPosition.startCol = 0;\n result = this._findInLine(term, searchPosition, searchOptions);\n if (result) {\n break;\n }\n }\n }\n\n // If there is only one result, wrap back and return selection if it exists.\n if (!result && prevSelectedPos) {\n searchPosition.startRow = prevSelectedPos.start.y;\n searchPosition.startCol = 0;\n result = this._findInLine(term, searchPosition, searchOptions);\n }\n\n return result;\n }\n\n /**\n * Find the previous occurrence of a term with wrapping and selection management.\n * @param term The search term.\n * @param searchOptions Search options.\n * @param cachedSearchTerm The cached search term to determine if expansion should occur.\n * @returns The search result if found, undefined otherwise.\n */\n public findPreviousWithSelection(term: string, searchOptions?: ISearchOptions, cachedSearchTerm?: string): ISearchResult | undefined {\n if (!term || term.length === 0) {\n this._terminal.clearSelection();\n return undefined;\n }\n\n const prevSelectedPos = this._terminal.getSelectionPosition();\n this._terminal.clearSelection();\n\n let startRow = this._terminal.buffer.active.baseY + this._terminal.rows - 1;\n const startCol = this._terminal.cols;\n const isReverseSearch = true;\n\n this._lineCache.initLinesCache();\n const searchPosition: ISearchPosition = {\n startRow,\n startCol\n };\n\n let result: ISearchResult | undefined;\n if (prevSelectedPos) {\n searchPosition.startRow = startRow = prevSelectedPos.start.y;\n searchPosition.startCol = prevSelectedPos.start.x;\n if (cachedSearchTerm !== term) {\n // Try to expand selection to right first.\n result = this._findInLine(term, searchPosition, searchOptions, false);\n if (!result) {\n // If selection was not able to be expanded to the right, then try reverse search\n searchPosition.startRow = startRow = prevSelectedPos.end.y;\n searchPosition.startCol = prevSelectedPos.end.x;\n }\n }\n }\n\n result ??= this._findInLine(term, searchPosition, searchOptions, isReverseSearch);\n\n // Search from startRow - 1 to top\n if (!result) {\n searchPosition.startCol = Math.max(searchPosition.startCol, this._terminal.cols);\n for (let y = startRow - 1; y >= 0; y--) {\n searchPosition.startRow = y;\n result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);\n if (result) {\n break;\n }\n }\n }\n // If we hit the top and didn't search from the very bottom wrap back down\n if (!result && startRow !== (this._terminal.buffer.active.baseY + this._terminal.rows - 1)) {\n for (let y = (this._terminal.buffer.active.baseY + this._terminal.rows - 1); y >= startRow; y--) {\n searchPosition.startRow = y;\n result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);\n if (result) {\n break;\n }\n }\n }\n\n return result;\n }\n\n /**\n * A found substring is a whole word if it doesn't have an alphanumeric character directly\n * adjacent to it.\n * @param searchIndex starting index of the potential whole word substring\n * @param line entire string in which the potential whole word was found\n * @param term the substring that starts at searchIndex\n */\n private _isWholeWord(searchIndex: number, line: string, term: string): boolean {\n return ((searchIndex === 0) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex - 1]))) &&\n (((searchIndex + term.length) === line.length) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex + term.length])));\n }\n\n /** `_isWholeWord` gated on the option, so a rejected hit can be stepped past instead of ending the scan. */\n private _satisfiesWholeWord(searchIndex: number, line: string, term: string, searchOptions: ISearchOptions): boolean {\n return !searchOptions.wholeWord || this._isWholeWord(searchIndex, line, term);\n }\n\n /**\n * Whether an earlier `_findInLine` in this same call already scanned this row's line from an\n * equal or lower offset, which makes rescanning it pure O(rows^2) work on one long line. Sound\n * for every option because `_findInLine` returns the first accepted match at or after its\n * offset, which is monotone in that offset. Only valid once such a search has happened \u2014 the\n * wrap-around loop starts at row 0, whose line start may have been trimmed from the scrollback.\n */\n private _isRowCoveredByEarlierSearch(row: number): boolean {\n return this._terminal.buffer.active.getLine(row)?.isWrapped === true;\n }\n\n /**\n * Searches a line for a search term. Takes the provided terminal line and searches the text line,\n * which may contain subsequent terminal lines if the text is wrapped. If the provided line number\n * is part of a wrapped text line that started on an earlier line then it is skipped since it will\n * be properly searched when the terminal line that the text starts on is searched.\n * @param term The search term.\n * @param searchPosition The position to start the search.\n * @param searchOptions Search options.\n * @param isReverseSearch Whether the search should start from the right side of the terminal and\n * search to the left.\n * @returns The search result if it was found.\n */\n private _findInLine(term: string, searchPosition: ISearchPosition, searchOptions: ISearchOptions = {}, isReverseSearch: boolean = false): ISearchResult | undefined {\n // Ignore wrapped lines, only consider on unwrapped line (first row of command string).\n if (isReverseSearch) {\n // Reverse search never rewinds: its caller carries startCol down the rows of the line. Row 0\n // is searched even when wrapped, since its line start may have been trimmed from the scrollback.\n if (searchPosition.startRow > 0 && this._terminal.buffer.active.getLine(searchPosition.startRow)?.isWrapped) {\n searchPosition.startCol += this._terminal.cols;\n return;\n }\n } else {\n // A loop rather than recursion: one frame per wrapped row overflows the stack on a line long\n // enough to fill the scrollback. Bounded at row 0 because after a reflow the buffer's ring\n // holds stale entries at negative indices, so `getLine(-1)` answers with a wrapped line.\n while (searchPosition.startRow > 0 && this._terminal.buffer.active.getLine(searchPosition.startRow)?.isWrapped) {\n searchPosition.startRow--;\n searchPosition.startCol += this._terminal.cols;\n }\n }\n const row = searchPosition.startRow;\n const col = searchPosition.startCol;\n\n let cache = this._lineCache.getLineFromCache(row);\n if (!cache) {\n cache = this._lineCache.translateBufferLineToStringWithWrap(row, true);\n this._lineCache.setLineInCache(row, cache);\n }\n const [stringLine, offsets] = cache;\n\n const offset = this._bufferColsToStringOffset(row, col, offsets);\n let searchTerm = term;\n let searchStringLine = stringLine;\n if (!searchOptions.regex) {\n searchTerm = searchOptions.caseSensitive ? term : term.toLowerCase();\n searchStringLine = searchOptions.caseSensitive ? stringLine : stringLine.toLowerCase();\n }\n\n let resultIndex = -1;\n if (searchOptions.regex) {\n const searchRegex = RegExp(searchTerm, searchOptions.caseSensitive ? 'g' : 'gi');\n let foundTerm: RegExpExecArray | null;\n if (isReverseSearch) {\n // This loop will get the resultIndex of the _last_ regex match in the range 0..offset\n while (foundTerm = searchRegex.exec(searchStringLine.slice(0, offset))) {\n const matchIndex = searchRegex.lastIndex - foundTerm[0].length;\n if (foundTerm[0].length > 0 && this._satisfiesWholeWord(matchIndex, searchStringLine, foundTerm[0], searchOptions)) {\n resultIndex = matchIndex;\n term = foundTerm[0];\n }\n searchRegex.lastIndex = matchIndex + 1;\n }\n } else {\n // Driven over the whole line from `offset` rather than over `slice(offset)`: a slice\n // re-anchors ^ and \\b at whatever column the row happened to wrap at, and only\n // first-accepted-match-at-or-after-offset is monotone in `offset`, which is what lets\n // `_isRowCoveredByEarlierSearch` skip a wrapped row an earlier scan already covered.\n searchRegex.lastIndex = offset;\n while (foundTerm = searchRegex.exec(searchStringLine)) {\n const matchIndex = searchRegex.lastIndex - foundTerm[0].length;\n if (foundTerm[0].length > 0 && this._satisfiesWholeWord(matchIndex, searchStringLine, foundTerm[0], searchOptions)) {\n resultIndex = matchIndex;\n term = foundTerm[0];\n break;\n }\n // A zero-length or rejected match would otherwise repeat forever.\n searchRegex.lastIndex = matchIndex + 1;\n }\n }\n } else if (isReverseSearch) {\n let matchIndex = offset - searchTerm.length >= 0 ? searchStringLine.lastIndexOf(searchTerm, offset - searchTerm.length) : -1;\n // `lastIndexOf` clamps a negative fromIndex to 0, so index 0 has to end the walk.\n while (matchIndex >= 0 && !this._satisfiesWholeWord(matchIndex, searchStringLine, searchTerm, searchOptions)) {\n matchIndex = matchIndex > 0 ? searchStringLine.lastIndexOf(searchTerm, matchIndex - 1) : -1;\n }\n resultIndex = matchIndex;\n } else {\n let matchIndex = searchStringLine.indexOf(searchTerm, offset);\n while (matchIndex >= 0 && !this._satisfiesWholeWord(matchIndex, searchStringLine, searchTerm, searchOptions)) {\n matchIndex = searchStringLine.indexOf(searchTerm, matchIndex + 1);\n }\n resultIndex = matchIndex;\n }\n\n if (resultIndex >= 0) {\n // Adjust the row number and search index if needed since a \"line\" of text can span multiple\n // rows\n let startRowOffset = 0;\n while (startRowOffset < offsets.length - 1 && resultIndex >= offsets[startRowOffset + 1]) {\n startRowOffset++;\n }\n let endRowOffset = startRowOffset;\n while (endRowOffset < offsets.length - 1 && resultIndex + term.length >= offsets[endRowOffset + 1]) {\n endRowOffset++;\n }\n const startColOffset = resultIndex - offsets[startRowOffset];\n const endColOffset = resultIndex + term.length - offsets[endRowOffset];\n const startColIndex = this._stringLengthToBufferSize(row + startRowOffset, startColOffset);\n const endColIndex = this._stringLengthToBufferSize(row + endRowOffset, endColOffset);\n const size = endColIndex - startColIndex + this._terminal.cols * (endRowOffset - startRowOffset);\n\n return {\n term,\n col: startColIndex,\n row: row + startRowOffset,\n size\n };\n }\n }\n\n private _stringLengthToBufferSize(row: number, offset: number): number {\n const line = this._terminal.buffer.active.getLine(row);\n if (!line) {\n return 0;\n }\n for (let i = 0; i < offset; i++) {\n const cell = line.getCell(i);\n if (!cell) {\n break;\n }\n // Adjust the searchIndex to normalize emoji into single chars\n const char = cell.getChars();\n if (char.length > 1) {\n offset -= char.length - 1;\n }\n // Adjust the searchIndex for empty characters following wide unicode\n // chars (eg. CJK)\n const nextCell = line.getCell(i + 1);\n if (nextCell && nextCell.getWidth() === 0) {\n offset++;\n }\n }\n return offset;\n }\n\n /**\n * `cols` counts from the start of the logical line, so summing the cells of every row before the\n * resume point costs O(line) per call and the highlight-all pass makes one call per match.\n * `lineOffsets` already holds the string offset each wrapped row starts at \u2014 the same map used\n * above to turn a match index back into a row \u2014 so only the last, partial row needs cells. It is\n * also the map the row a match lands on is read from, which the cell sum disagreed with by one\n * for a row whose trailing cell is the null placeholder of a wide character that wrapped.\n */\n private _bufferColsToStringOffset(startRow: number, cols: number, lineOffsets: number[]): number {\n const rowsBack = Math.min(Math.floor(cols / this._terminal.cols), lineOffsets.length - 1);\n let offset = lineOffsets[rowsBack];\n const line = this._terminal.buffer.active.getLine(startRow + rowsBack);\n if (line) {\n const colsInRow = Math.min(cols - rowsBack * this._terminal.cols, this._terminal.cols);\n for (let i = 0; i < colsInRow; i++) {\n const cell = line.getCell(i);\n if (!cell) {\n break;\n }\n if (cell.getWidth()) {\n // Treat null characters as whitespace to align with the translateToString API\n offset += cell.getCode() === 0 ? 1 : cell.getChars().length;\n }\n }\n }\n return offset;\n }\n}\n", "/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { Terminal, IDisposable, IDecoration } from '@xterm/xterm';\nimport type { ISearchDecorationOptions } from '@xterm/addon-search';\nimport { dispose, Disposable, toDisposable } from 'common/Lifecycle';\nimport type { ISearchResult } from './SearchEngine';\n\n/**\n * Interface for managing a highlight decoration.\n */\ninterface IHighlight extends IDisposable {\n decoration: IDecoration;\n match: ISearchResult;\n}\n\n/**\n * Interface for managing multiple decorations for a single match.\n */\ninterface IMultiHighlight extends IDisposable {\n decorations: IDecoration[];\n match: ISearchResult;\n}\n\n/**\n * Manages visual decorations for search results including highlighting and active selection\n * indicators. This class handles the creation, styling, and disposal of search-related decorations.\n */\nexport class DecorationManager extends Disposable {\n private _highlightDecorations: IHighlight[] = [];\n private _highlightedLines: Set = new Set();\n\n constructor(private readonly _terminal: Terminal) {\n super();\n this._register(toDisposable(() => this.clearHighlightDecorations()));\n }\n\n /**\n * Creates decorations for all provided search results.\n * @param results The search results to create decorations for.\n * @param options The decoration options.\n */\n public createHighlightDecorations(results: ISearchResult[], options: ISearchDecorationOptions): void {\n this.clearHighlightDecorations();\n\n for (const match of results) {\n const decorations = this._createResultDecorations(match, options, false);\n if (decorations) {\n for (const decoration of decorations) {\n this._storeDecoration(decoration, match);\n }\n }\n }\n }\n\n /**\n * Creates decorations for the currently active search result.\n * @param result The active search result.\n * @param options The decoration options.\n * @returns The multi-highlight decoration or undefined if creation failed.\n */\n public createActiveDecoration(result: ISearchResult, options: ISearchDecorationOptions): IMultiHighlight | undefined {\n const decorations = this._createResultDecorations(result, options, true);\n if (decorations) {\n return { decorations, match: result, dispose() { dispose(decorations); } };\n }\n return undefined;\n }\n\n /**\n * Clears all highlight decorations.\n */\n public clearHighlightDecorations(): void {\n dispose(this._highlightDecorations);\n this._highlightDecorations = [];\n this._highlightedLines.clear();\n }\n\n /**\n * Stores a decoration and tracks it for management.\n * @param decoration The decoration to store.\n * @param match The search result this decoration represents.\n */\n private _storeDecoration(decoration: IDecoration, match: ISearchResult): void {\n this._highlightedLines.add(decoration.marker.line);\n this._highlightDecorations.push({ decoration, match, dispose() { decoration.dispose(); } });\n }\n\n /**\n * Applies styles to the decoration when it is rendered.\n * @param element The decoration's element.\n * @param borderColor The border color to apply.\n * @param isActiveResult Whether the element is part of the active search result.\n */\n private _applyStyles(element: HTMLElement, borderColor: string | undefined, isActiveResult: boolean): void {\n if (!element.classList.contains('xterm-find-result-decoration')) {\n element.classList.add('xterm-find-result-decoration');\n if (borderColor) {\n element.style.outline = `1px solid ${borderColor}`;\n }\n }\n if (isActiveResult) {\n element.classList.add('xterm-find-active-result-decoration');\n }\n }\n\n /**\n * Creates a decoration for the result and applies styles\n * @param result the search result for which to create the decoration\n * @param options the options for the decoration\n * @param isActiveResult whether this is the currently active result\n * @returns the decorations or undefined if the marker has already been disposed of\n */\n private _createResultDecorations(result: ISearchResult, options: ISearchDecorationOptions, isActiveResult: boolean): IDecoration[] | undefined {\n // Gather decoration ranges for this match as it could wrap\n const decorationRanges: [number, number, number][] = [];\n let currentCol = result.col;\n let remainingSize = result.size;\n let markerOffset = -this._terminal.buffer.active.baseY - this._terminal.buffer.active.cursorY + result.row;\n while (remainingSize > 0) {\n const amountThisRow = Math.min(this._terminal.cols - currentCol, remainingSize);\n decorationRanges.push([markerOffset, currentCol, amountThisRow]);\n currentCol = 0;\n remainingSize -= amountThisRow;\n markerOffset++;\n }\n\n // Create the decorations\n const decorations: IDecoration[] = [];\n for (const range of decorationRanges) {\n const marker = this._terminal.registerMarker(range[0]);\n const decoration = this._terminal.registerDecoration({\n marker,\n x: range[1],\n width: range[2],\n layer: isActiveResult ? 'top' : 'bottom',\n backgroundColor: isActiveResult ? options.activeMatchBackground : options.matchBackground,\n overviewRulerOptions: this._highlightedLines.has(marker.line) ? undefined : {\n color: isActiveResult ? options.activeMatchColorOverviewRuler : options.matchOverviewRuler,\n position: 'center'\n }\n });\n if (decoration) {\n const disposables: IDisposable[] = [];\n disposables.push(marker);\n disposables.push(decoration.onRender((e) => this._applyStyles(e, isActiveResult ? options.activeMatchBorder : options.matchBorder, false)));\n disposables.push(decoration.onDispose(() => dispose(disposables)));\n decorations.push(decoration);\n }\n }\n\n return decorations.length === 0 ? undefined : decorations;\n }\n}\n\n\n", "/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ISearchResultChangeEvent } from '@xterm/addon-search';\nimport type { IDisposable } from '@xterm/xterm';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { Disposable } from 'common/Lifecycle';\nimport type { ISearchResult } from './SearchEngine';\n\n/**\n * Interface for managing a currently selected decoration.\n */\ninterface ISelectedDecoration extends IDisposable {\n match: ISearchResult;\n}\n\n/**\n * Tracks search results, manages result indexing, and fires events when results change.\n * This class provides centralized management of search result state and notifications.\n */\nexport class SearchResultTracker extends Disposable {\n private _searchResults: ISearchResult[] = [];\n private _selectedDecoration: ISelectedDecoration | undefined;\n\n private readonly _onDidChangeResults = this._register(new Emitter());\n public get onDidChangeResults(): IEvent { return this._onDidChangeResults.event; }\n\n /**\n * Gets the current search results.\n */\n public get searchResults(): ReadonlyArray {\n return this._searchResults;\n }\n\n /**\n * Gets the currently selected decoration.\n */\n public get selectedDecoration(): ISelectedDecoration | undefined {\n return this._selectedDecoration;\n }\n\n /**\n * Sets the currently selected decoration.\n */\n public set selectedDecoration(decoration: ISelectedDecoration | undefined) {\n this._selectedDecoration = decoration;\n }\n\n /**\n * Updates the search results with a new set of results.\n * @param results The new search results.\n * @param maxResults The maximum number of results to track.\n */\n public updateResults(results: ISearchResult[], maxResults: number): void {\n this._searchResults = results.slice(0, maxResults);\n }\n\n /**\n * Clears all search results.\n */\n public clearResults(): void {\n this._searchResults = [];\n }\n\n /**\n * Clears the selected decoration.\n */\n public clearSelectedDecoration(): void {\n if (this._selectedDecoration) {\n this._selectedDecoration.dispose();\n this._selectedDecoration = undefined;\n }\n }\n\n /**\n * Finds the index of a result in the current results array.\n * @param result The result to find.\n * @returns The index of the result, or -1 if not found.\n */\n public findResultIndex(result: ISearchResult): number {\n for (let i = 0; i < this._searchResults.length; i++) {\n const match = this._searchResults[i];\n if (match.row === result.row && match.col === result.col && match.size === result.size) {\n return i;\n }\n }\n return -1;\n }\n\n /**\n * Fires a result change event with the current state.\n * @param hasDecorations Whether decorations are enabled.\n */\n public fireResultsChanged(hasDecorations: boolean): void {\n if (!hasDecorations) {\n return;\n }\n\n let resultIndex = -1;\n if (this._selectedDecoration) {\n resultIndex = this.findResultIndex(this._selectedDecoration.match);\n }\n\n this._onDidChangeResults.fire({\n resultIndex,\n resultCount: this._searchResults.length\n });\n }\n\n /**\n * Resets all state.\n */\n public reset(): void {\n this.clearSelectedDecoration();\n this.clearResults();\n }\n}\n", "/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { Terminal, IDisposable, ITerminalAddon } from '@xterm/xterm';\nimport type { SearchAddon as ISearchApi, ISearchOptions, ISearchAddonOptions, ISearchResultChangeEvent, ISearchDecorationOptions } from '@xterm/addon-search';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\nimport { disposableTimeout } from 'common/Async';\nimport { SearchLineCache } from './SearchLineCache';\nimport { SearchState } from './SearchState';\nimport { SearchEngine, type ISearchResult } from './SearchEngine';\nimport { DecorationManager } from './DecorationManager';\nimport { SearchResultTracker } from './SearchResultTracker';\n\ninterface IInternalSearchOptions {\n noScroll: boolean;\n}\n\n/**\n * Configuration constants for the search addon functionality.\n */\nconst enum Constants {\n /**\n * Default maximum number of search results to highlight simultaneously. This limit prevents\n * performance degradation when searching for very common terms that would result in excessive\n * highlighting decorations.\n */\n DEFAULT_HIGHLIGHT_LIMIT = 1000\n}\n\nexport class SearchAddon extends Disposable implements ITerminalAddon, ISearchApi {\n private _terminal: Terminal | undefined;\n private _highlightLimit: number;\n private _highlightTimeout = this._register(new MutableDisposable());\n private _lineCache = this._register(new MutableDisposable());\n\n // Component instances\n private _state = new SearchState();\n private _engine: SearchEngine | undefined;\n private _decorationManager: DecorationManager | undefined;\n private _resultTracker = this._register(new SearchResultTracker());\n\n private readonly _onAfterSearch = this._register(new Emitter());\n public readonly onAfterSearch = this._onAfterSearch.event;\n private readonly _onBeforeSearch = this._register(new Emitter());\n public readonly onBeforeSearch = this._onBeforeSearch.event;\n\n public get onDidChangeResults(): IEvent {\n return this._resultTracker.onDidChangeResults;\n }\n\n constructor(options?: Partial) {\n super();\n\n this._highlightLimit = options?.highlightLimit ?? Constants.DEFAULT_HIGHLIGHT_LIMIT;\n }\n\n public activate(terminal: Terminal): void {\n this._terminal = terminal;\n this._lineCache.value = new SearchLineCache(terminal);\n this._engine = new SearchEngine(terminal, this._lineCache.value);\n this._decorationManager = new DecorationManager(terminal);\n this._register(this._terminal.onWriteParsed(() => this._updateMatches()));\n this._register(this._terminal.onResize(() => this._updateMatches()));\n this._register(toDisposable(() => this.clearDecorations()));\n }\n\n private _updateMatches(): void {\n this._highlightTimeout.clear();\n if (this._state.cachedSearchTerm && this._state.lastSearchOptions?.decorations) {\n this._highlightTimeout.value = disposableTimeout(() => {\n const term = this._state.cachedSearchTerm;\n this._state.clearCachedTerm();\n this.findPrevious(term!, { ...this._state.lastSearchOptions, incremental: true }, { noScroll: true });\n }, 200);\n }\n }\n\n public clearDecorations(retainCachedSearchTerm?: boolean): void {\n this._resultTracker.clearSelectedDecoration();\n this._decorationManager?.clearHighlightDecorations();\n this._resultTracker.clearResults();\n if (!retainCachedSearchTerm) {\n this._state.clearCachedTerm();\n }\n }\n\n public clearActiveDecoration(): void {\n this._resultTracker.clearSelectedDecoration();\n }\n\n /**\n * Find the next instance of the term, then scroll to and select it. If it\n * doesn't exist, do nothing.\n * @param term The search term.\n * @param searchOptions Search options.\n * @returns Whether a result was found.\n */\n public findNext(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {\n if (!this._terminal || !this._engine) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n\n this._onBeforeSearch.fire();\n\n this._state.lastSearchOptions = searchOptions;\n\n if (this._state.shouldUpdateHighlighting(term, searchOptions)) {\n this._highlightAllMatches(term, searchOptions!);\n }\n\n const found = this._findNextAndSelect(term, searchOptions, internalSearchOptions);\n this._fireResults(searchOptions);\n this._state.cachedSearchTerm = term;\n\n this._onAfterSearch.fire();\n\n return found;\n }\n\n private _highlightAllMatches(term: string, searchOptions: ISearchOptions): void {\n if (!this._terminal || !this._engine || !this._decorationManager) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n if (!this._state.isValidSearchTerm(term)) {\n this.clearDecorations();\n return;\n }\n\n // new search, clear out the old decorations\n this.clearDecorations(true);\n\n const results: ISearchResult[] = [];\n let prevResult: ISearchResult | undefined = undefined;\n let result = this._engine.find(term, 0, 0, searchOptions);\n\n while (result && (prevResult?.row !== result.row || prevResult?.col !== result.col)) {\n if (results.length >= this._highlightLimit) {\n break;\n }\n prevResult = result;\n results.push(prevResult);\n const cols = this._terminal.cols;\n let nextCol = prevResult.col + prevResult.size;\n let nextRow = prevResult.row;\n if (nextCol >= cols) {\n nextRow += Math.floor(nextCol / cols);\n nextCol = nextCol % cols;\n }\n result = this._engine.find(term, nextRow, nextCol, searchOptions);\n }\n\n this._resultTracker.updateResults(results, this._highlightLimit);\n if (searchOptions.decorations) {\n this._decorationManager.createHighlightDecorations(results, searchOptions.decorations);\n }\n }\n\n private _findNextAndSelect(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {\n if (!this._terminal || !this._engine) {\n return false;\n }\n if (!this._state.isValidSearchTerm(term)) {\n this._terminal.clearSelection();\n this.clearDecorations();\n return false;\n }\n\n const result = this._engine.findNextWithSelection(term, searchOptions, this._state.cachedSearchTerm);\n return this._selectResult(result, searchOptions?.decorations, internalSearchOptions?.noScroll);\n }\n\n /**\n * Find the previous instance of the term, then scroll to and select it. If it\n * doesn't exist, do nothing.\n * @param term The search term.\n * @param searchOptions Search options.\n * @returns Whether a result was found.\n */\n public findPrevious(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {\n if (!this._terminal || !this._engine) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n\n this._onBeforeSearch.fire();\n\n this._state.lastSearchOptions = searchOptions;\n\n if (this._state.shouldUpdateHighlighting(term, searchOptions)) {\n this._highlightAllMatches(term, searchOptions!);\n }\n\n const found = this._findPreviousAndSelect(term, searchOptions, internalSearchOptions);\n this._fireResults(searchOptions);\n this._state.cachedSearchTerm = term;\n\n this._onAfterSearch.fire();\n\n return found;\n }\n\n private _fireResults(searchOptions?: ISearchOptions): void {\n this._resultTracker.fireResultsChanged(!!searchOptions?.decorations);\n }\n\n private _findPreviousAndSelect(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {\n if (!this._terminal || !this._engine) {\n return false;\n }\n if (!this._state.isValidSearchTerm(term)) {\n this._terminal.clearSelection();\n this.clearDecorations();\n return false;\n }\n\n const result = this._engine.findPreviousWithSelection(term, searchOptions, this._state.cachedSearchTerm);\n return this._selectResult(result, searchOptions?.decorations, internalSearchOptions?.noScroll);\n }\n\n /**\n * Selects and scrolls to a result.\n * @param result The result to select.\n * @returns Whether a result was selected.\n */\n private _selectResult(result: ISearchResult | undefined, options?: ISearchDecorationOptions, noScroll?: boolean): boolean {\n if (!this._terminal || !this._decorationManager) {\n return false;\n }\n\n this._resultTracker.clearSelectedDecoration();\n if (!result) {\n this._terminal.clearSelection();\n return false;\n }\n\n this._terminal.select(result.col, result.row, result.size);\n if (options) {\n const activeDecoration = this._decorationManager.createActiveDecoration(result, options);\n if (activeDecoration) {\n this._resultTracker.selectedDecoration = activeDecoration;\n }\n }\n\n if (!noScroll) {\n // If it is not in the viewport then we scroll else it just gets selected\n if (result.row >= (this._terminal.buffer.active.viewportY + this._terminal.rows) || result.row < this._terminal.buffer.active.viewportY) {\n let scroll = result.row - this._terminal.buffer.active.viewportY;\n scroll -= Math.floor(this._terminal.rows / 2);\n this._terminal.scrollLines(scroll);\n }\n }\n return true;\n }\n}\n"], ++ "mappings": "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", ++ "names": ["toDisposable", "fn", "dispose", "arg", "d", "combinedDisposable", "disposables", "DisposableStore", "o", "Disposable", "MutableDisposable", "value", "Emitter", "listener", "thisArgs", "disposables", "toDisposable", "entry", "result", "idx", "event", "listeners", "i", "len", "EventUtils", "forward", "from", "to", "e", "map", "any", "events", "store", "DisposableStore", "runAndSubscribe", "handler", "initial", "disposableTimeout", "handler", "timeout", "store", "timer", "disposable", "toDisposable", "SearchLineCache", "Disposable", "_terminal", "MutableDisposable", "toDisposable", "combinedDisposable", "delay", "disposableTimeout", "elapsed", "row", "entry", "lineIndex", "trimRight", "strings", "lineOffsets", "bufferLength", "line", "nextLine", "lineWrapsToNext", "string", "lastCell", "SearchState", "term", "options", "newOptions", "SearchEngine", "_terminal", "_lineCache", "term", "startRow", "startCol", "searchOptions", "searchPosition", "result", "y", "cachedSearchTerm", "prevSelectedPos", "isReverseSearch", "searchIndex", "line", "row", "col", "cache", "stringLine", "offsets", "offset", "searchTerm", "searchStringLine", "resultIndex", "searchRegex", "foundTerm", "matchIndex", "startRowOffset", "endRowOffset", "startColOffset", "endColOffset", "startColIndex", "size", "i", "cell", "char", "nextCell", "cols", "lineOffsets", "rowsBack", "colsInRow", "DecorationManager", "Disposable", "_terminal", "toDisposable", "results", "options", "match", "decorations", "decoration", "result", "dispose", "element", "borderColor", "isActiveResult", "decorationRanges", "currentCol", "remainingSize", "markerOffset", "amountThisRow", "range", "marker", "disposables", "e", "SearchResultTracker", "Disposable", "Emitter", "decoration", "results", "maxResults", "result", "i", "match", "hasDecorations", "resultIndex", "SearchAddon", "Disposable", "options", "MutableDisposable", "SearchState", "SearchResultTracker", "Emitter", "terminal", "SearchLineCache", "SearchEngine", "DecorationManager", "toDisposable", "disposableTimeout", "term", "retainCachedSearchTerm", "searchOptions", "internalSearchOptions", "found", "results", "prevResult", "result", "cols", "nextCol", "nextRow", "noScroll", "activeDecoration", "scroll"] + } +diff --git a/src/SearchEngine.ts b/src/SearchEngine.ts +index 1760bc2bd1fd274d23e2032fde631b39c739f0d9..5b3c5cc5e861356b87e8a15c55797f45bac20a5c 100644 +--- a/src/SearchEngine.ts ++++ b/src/SearchEngine.ts +@@ -76,6 +76,9 @@ export class SearchEngine { + // Search from startRow + 1 to end + if (!result) { + for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) { ++ if (this._isRowCoveredByEarlierSearch(y)) { ++ continue; ++ } + searchPosition.startRow = y; + searchPosition.startCol = 0; + result = this._findInLine(term, searchPosition, searchOptions); +@@ -127,6 +130,9 @@ export class SearchEngine { + // Search from startRow + 1 to end + if (!result) { + for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) { ++ if (this._isRowCoveredByEarlierSearch(y)) { ++ continue; ++ } + searchPosition.startRow = y; + searchPosition.startCol = 0; + result = this._findInLine(term, searchPosition, searchOptions); +@@ -138,6 +144,11 @@ export class SearchEngine { + // If we hit the bottom and didn't search from the very top wrap back up + if (!result && startRow !== 0) { + for (let y = 0; y < startRow; y++) { ++ // Row 0 is never skipped: it can be a continuation whose line start was trimmed from the ++ // scrollback, and nothing earlier in this loop has searched it. ++ if (y > 0 && this._isRowCoveredByEarlierSearch(y)) { ++ continue; ++ } + searchPosition.startRow = y; + searchPosition.startCol = 0; + result = this._findInLine(term, searchPosition, searchOptions); +@@ -237,6 +248,22 @@ export class SearchEngine { + (((searchIndex + term.length) === line.length) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex + term.length]))); + } + ++ /** `_isWholeWord` gated on the option, so a rejected hit can be stepped past instead of ending the scan. */ ++ private _satisfiesWholeWord(searchIndex: number, line: string, term: string, searchOptions: ISearchOptions): boolean { ++ return !searchOptions.wholeWord || this._isWholeWord(searchIndex, line, term); ++ } ++ ++ /** ++ * Whether an earlier `_findInLine` in this same call already scanned this row's line from an ++ * equal or lower offset, which makes rescanning it pure O(rows^2) work on one long line. Sound ++ * for every option because `_findInLine` returns the first accepted match at or after its ++ * offset, which is monotone in that offset. Only valid once such a search has happened — the ++ * wrap-around loop starts at row 0, whose line start may have been trimmed from the scrollback. ++ */ ++ private _isRowCoveredByEarlierSearch(row: number): boolean { ++ return this._terminal.buffer.active.getLine(row)?.isWrapped === true; ++ } ++ + /** + * Searches a line for a search term. Takes the provided terminal line and searches the text line, + * which may contain subsequent terminal lines if the text is wrapped. If the provided line number +@@ -250,23 +277,26 @@ export class SearchEngine { + * @returns The search result if it was found. + */ + private _findInLine(term: string, searchPosition: ISearchPosition, searchOptions: ISearchOptions = {}, isReverseSearch: boolean = false): ISearchResult | undefined { +- const row = searchPosition.startRow; +- const col = searchPosition.startCol; +- + // Ignore wrapped lines, only consider on unwrapped line (first row of command string). +- const firstLine = this._terminal.buffer.active.getLine(row); +- if (firstLine?.isWrapped) { +- if (isReverseSearch) { ++ if (isReverseSearch) { ++ // Reverse search never rewinds: its caller carries startCol down the rows of the line. Row 0 ++ // is searched even when wrapped, since its line start may have been trimmed from the scrollback. ++ if (searchPosition.startRow > 0 && this._terminal.buffer.active.getLine(searchPosition.startRow)?.isWrapped) { + searchPosition.startCol += this._terminal.cols; + return; + } +- +- // This will iterate until we find the line start. +- // When we find it, we will search using the calculated start column. +- searchPosition.startRow--; +- searchPosition.startCol += this._terminal.cols; +- return this._findInLine(term, searchPosition, searchOptions); ++ } else { ++ // A loop rather than recursion: one frame per wrapped row overflows the stack on a line long ++ // enough to fill the scrollback. Bounded at row 0 because after a reflow the buffer's ring ++ // holds stale entries at negative indices, so `getLine(-1)` answers with a wrapped line. ++ while (searchPosition.startRow > 0 && this._terminal.buffer.active.getLine(searchPosition.startRow)?.isWrapped) { ++ searchPosition.startRow--; ++ searchPosition.startCol += this._terminal.cols; ++ } + } ++ const row = searchPosition.startRow; ++ const col = searchPosition.startCol; ++ + let cache = this._lineCache.getLineFromCache(row); + if (!cache) { + cache = this._lineCache.translateBufferLineToStringWithWrap(row, true); +@@ -274,7 +304,7 @@ export class SearchEngine { + } + const [stringLine, offsets] = cache; + +- const offset = this._bufferColsToStringOffset(row, col); ++ const offset = this._bufferColsToStringOffset(row, col, offsets); + let searchTerm = term; + let searchStringLine = stringLine; + if (!searchOptions.regex) { +@@ -289,32 +319,46 @@ export class SearchEngine { + if (isReverseSearch) { + // This loop will get the resultIndex of the _last_ regex match in the range 0..offset + while (foundTerm = searchRegex.exec(searchStringLine.slice(0, offset))) { +- resultIndex = searchRegex.lastIndex - foundTerm[0].length; +- term = foundTerm[0]; +- searchRegex.lastIndex -= (term.length - 1); ++ const matchIndex = searchRegex.lastIndex - foundTerm[0].length; ++ if (foundTerm[0].length > 0 && this._satisfiesWholeWord(matchIndex, searchStringLine, foundTerm[0], searchOptions)) { ++ resultIndex = matchIndex; ++ term = foundTerm[0]; ++ } ++ searchRegex.lastIndex = matchIndex + 1; + } + } else { +- foundTerm = searchRegex.exec(searchStringLine.slice(offset)); +- if (foundTerm && foundTerm[0].length > 0) { +- resultIndex = offset + (searchRegex.lastIndex - foundTerm[0].length); +- term = foundTerm[0]; ++ // Driven over the whole line from `offset` rather than over `slice(offset)`: a slice ++ // re-anchors ^ and \b at whatever column the row happened to wrap at, and only ++ // first-accepted-match-at-or-after-offset is monotone in `offset`, which is what lets ++ // `_isRowCoveredByEarlierSearch` skip a wrapped row an earlier scan already covered. ++ searchRegex.lastIndex = offset; ++ while (foundTerm = searchRegex.exec(searchStringLine)) { ++ const matchIndex = searchRegex.lastIndex - foundTerm[0].length; ++ if (foundTerm[0].length > 0 && this._satisfiesWholeWord(matchIndex, searchStringLine, foundTerm[0], searchOptions)) { ++ resultIndex = matchIndex; ++ term = foundTerm[0]; ++ break; ++ } ++ // A zero-length or rejected match would otherwise repeat forever. ++ searchRegex.lastIndex = matchIndex + 1; + } + } ++ } else if (isReverseSearch) { ++ let matchIndex = offset - searchTerm.length >= 0 ? searchStringLine.lastIndexOf(searchTerm, offset - searchTerm.length) : -1; ++ // `lastIndexOf` clamps a negative fromIndex to 0, so index 0 has to end the walk. ++ while (matchIndex >= 0 && !this._satisfiesWholeWord(matchIndex, searchStringLine, searchTerm, searchOptions)) { ++ matchIndex = matchIndex > 0 ? searchStringLine.lastIndexOf(searchTerm, matchIndex - 1) : -1; ++ } ++ resultIndex = matchIndex; + } else { +- if (isReverseSearch) { +- if (offset - searchTerm.length >= 0) { +- resultIndex = searchStringLine.lastIndexOf(searchTerm, offset - searchTerm.length); +- } +- } else { +- resultIndex = searchStringLine.indexOf(searchTerm, offset); ++ let matchIndex = searchStringLine.indexOf(searchTerm, offset); ++ while (matchIndex >= 0 && !this._satisfiesWholeWord(matchIndex, searchStringLine, searchTerm, searchOptions)) { ++ matchIndex = searchStringLine.indexOf(searchTerm, matchIndex + 1); + } ++ resultIndex = matchIndex; + } + + if (resultIndex >= 0) { +- if (searchOptions.wholeWord && !this._isWholeWord(resultIndex, searchStringLine, term)) { +- return; +- } +- + // Adjust the row number and search index if needed since a "line" of text can span multiple + // rows + let startRowOffset = 0; +@@ -365,12 +409,21 @@ export class SearchEngine { + return offset; + } + +- private _bufferColsToStringOffset(startRow: number, cols: number): number { +- let lineIndex = startRow; +- let offset = 0; +- let line = this._terminal.buffer.active.getLine(lineIndex); +- while (cols > 0 && line) { +- for (let i = 0; i < cols && i < this._terminal.cols; i++) { ++ /** ++ * `cols` counts from the start of the logical line, so summing the cells of every row before the ++ * resume point costs O(line) per call and the highlight-all pass makes one call per match. ++ * `lineOffsets` already holds the string offset each wrapped row starts at — the same map used ++ * above to turn a match index back into a row — so only the last, partial row needs cells. It is ++ * also the map the row a match lands on is read from, which the cell sum disagreed with by one ++ * for a row whose trailing cell is the null placeholder of a wide character that wrapped. ++ */ ++ private _bufferColsToStringOffset(startRow: number, cols: number, lineOffsets: number[]): number { ++ const rowsBack = Math.min(Math.floor(cols / this._terminal.cols), lineOffsets.length - 1); ++ let offset = lineOffsets[rowsBack]; ++ const line = this._terminal.buffer.active.getLine(startRow + rowsBack); ++ if (line) { ++ const colsInRow = Math.min(cols - rowsBack * this._terminal.cols, this._terminal.cols); ++ for (let i = 0; i < colsInRow; i++) { + const cell = line.getCell(i); + if (!cell) { + break; +@@ -380,12 +433,6 @@ export class SearchEngine { + offset += cell.getCode() === 0 ? 1 : cell.getChars().length; + } + } +- lineIndex++; +- line = this._terminal.buffer.active.getLine(lineIndex); +- if (line && !line.isWrapped) { +- break; +- } +- cols -= this._terminal.cols; + } + return offset; + } +diff --git a/src/SearchLineCache.ts b/src/SearchLineCache.ts +index 526f4bfcc74a881bb39b400ec79a25d33d602303..19b22f2f70e50a6b01d07966e15727cc5271c776 100644 +--- a/src/SearchLineCache.ts ++++ b/src/SearchLineCache.ts +@@ -109,9 +109,13 @@ export class SearchLineCache extends Disposable { + public translateBufferLineToStringWithWrap(lineIndex: number, trimRight: boolean): LineCacheEntry { + const strings = []; + const lineOffsets = [0]; ++ // A single line longer than the whole scrollback leaves every buffer row wrapped, and the ++ // buffer's ring answers an out-of-range row by cycling back to the start, so an unbounded walk ++ // never reaches an unwrapped line. ++ const bufferLength = this._terminal.buffer.active.length; + let line = this._terminal.buffer.active.getLine(lineIndex); + while (line) { +- const nextLine = this._terminal.buffer.active.getLine(lineIndex + 1); ++ const nextLine = lineIndex + 1 < bufferLength ? this._terminal.buffer.active.getLine(lineIndex + 1) : undefined; + const lineWrapsToNext = nextLine ? nextLine.isWrapped : false; + let string = line.translateToString(!lineWrapsToNext && trimRight); + if (lineWrapsToNext && nextLine) { diff --git a/config/patches/xterm-src/@xterm__addon-search@0.17.0-beta.300.src.patch b/config/patches/xterm-src/@xterm__addon-search@0.17.0-beta.300.src.patch new file mode 100644 index 00000000000..c38f1d1278e --- /dev/null +++ b/config/patches/xterm-src/@xterm__addon-search@0.17.0-beta.300.src.patch @@ -0,0 +1,231 @@ +diff --git a/src/SearchEngine.ts b/src/SearchEngine.ts +index 1760bc2bd1fd274d23e2032fde631b39c739f0d9..5b3c5cc5e861356b87e8a15c55797f45bac20a5c 100644 +--- a/src/SearchEngine.ts ++++ b/src/SearchEngine.ts +@@ -76,6 +76,9 @@ export class SearchEngine { + // Search from startRow + 1 to end + if (!result) { + for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) { ++ if (this._isRowCoveredByEarlierSearch(y)) { ++ continue; ++ } + searchPosition.startRow = y; + searchPosition.startCol = 0; + result = this._findInLine(term, searchPosition, searchOptions); +@@ -127,6 +130,9 @@ export class SearchEngine { + // Search from startRow + 1 to end + if (!result) { + for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) { ++ if (this._isRowCoveredByEarlierSearch(y)) { ++ continue; ++ } + searchPosition.startRow = y; + searchPosition.startCol = 0; + result = this._findInLine(term, searchPosition, searchOptions); +@@ -138,6 +144,11 @@ export class SearchEngine { + // If we hit the bottom and didn't search from the very top wrap back up + if (!result && startRow !== 0) { + for (let y = 0; y < startRow; y++) { ++ // Row 0 is never skipped: it can be a continuation whose line start was trimmed from the ++ // scrollback, and nothing earlier in this loop has searched it. ++ if (y > 0 && this._isRowCoveredByEarlierSearch(y)) { ++ continue; ++ } + searchPosition.startRow = y; + searchPosition.startCol = 0; + result = this._findInLine(term, searchPosition, searchOptions); +@@ -237,6 +248,22 @@ export class SearchEngine { + (((searchIndex + term.length) === line.length) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex + term.length]))); + } + ++ /** `_isWholeWord` gated on the option, so a rejected hit can be stepped past instead of ending the scan. */ ++ private _satisfiesWholeWord(searchIndex: number, line: string, term: string, searchOptions: ISearchOptions): boolean { ++ return !searchOptions.wholeWord || this._isWholeWord(searchIndex, line, term); ++ } ++ ++ /** ++ * Whether an earlier `_findInLine` in this same call already scanned this row's line from an ++ * equal or lower offset, which makes rescanning it pure O(rows^2) work on one long line. Sound ++ * for every option because `_findInLine` returns the first accepted match at or after its ++ * offset, which is monotone in that offset. Only valid once such a search has happened — the ++ * wrap-around loop starts at row 0, whose line start may have been trimmed from the scrollback. ++ */ ++ private _isRowCoveredByEarlierSearch(row: number): boolean { ++ return this._terminal.buffer.active.getLine(row)?.isWrapped === true; ++ } ++ + /** + * Searches a line for a search term. Takes the provided terminal line and searches the text line, + * which may contain subsequent terminal lines if the text is wrapped. If the provided line number +@@ -250,23 +277,26 @@ export class SearchEngine { + * @returns The search result if it was found. + */ + private _findInLine(term: string, searchPosition: ISearchPosition, searchOptions: ISearchOptions = {}, isReverseSearch: boolean = false): ISearchResult | undefined { +- const row = searchPosition.startRow; +- const col = searchPosition.startCol; +- + // Ignore wrapped lines, only consider on unwrapped line (first row of command string). +- const firstLine = this._terminal.buffer.active.getLine(row); +- if (firstLine?.isWrapped) { +- if (isReverseSearch) { ++ if (isReverseSearch) { ++ // Reverse search never rewinds: its caller carries startCol down the rows of the line. Row 0 ++ // is searched even when wrapped, since its line start may have been trimmed from the scrollback. ++ if (searchPosition.startRow > 0 && this._terminal.buffer.active.getLine(searchPosition.startRow)?.isWrapped) { + searchPosition.startCol += this._terminal.cols; + return; + } +- +- // This will iterate until we find the line start. +- // When we find it, we will search using the calculated start column. +- searchPosition.startRow--; +- searchPosition.startCol += this._terminal.cols; +- return this._findInLine(term, searchPosition, searchOptions); ++ } else { ++ // A loop rather than recursion: one frame per wrapped row overflows the stack on a line long ++ // enough to fill the scrollback. Bounded at row 0 because after a reflow the buffer's ring ++ // holds stale entries at negative indices, so `getLine(-1)` answers with a wrapped line. ++ while (searchPosition.startRow > 0 && this._terminal.buffer.active.getLine(searchPosition.startRow)?.isWrapped) { ++ searchPosition.startRow--; ++ searchPosition.startCol += this._terminal.cols; ++ } + } ++ const row = searchPosition.startRow; ++ const col = searchPosition.startCol; ++ + let cache = this._lineCache.getLineFromCache(row); + if (!cache) { + cache = this._lineCache.translateBufferLineToStringWithWrap(row, true); +@@ -274,7 +304,7 @@ export class SearchEngine { + } + const [stringLine, offsets] = cache; + +- const offset = this._bufferColsToStringOffset(row, col); ++ const offset = this._bufferColsToStringOffset(row, col, offsets); + let searchTerm = term; + let searchStringLine = stringLine; + if (!searchOptions.regex) { +@@ -289,32 +319,46 @@ export class SearchEngine { + if (isReverseSearch) { + // This loop will get the resultIndex of the _last_ regex match in the range 0..offset + while (foundTerm = searchRegex.exec(searchStringLine.slice(0, offset))) { +- resultIndex = searchRegex.lastIndex - foundTerm[0].length; +- term = foundTerm[0]; +- searchRegex.lastIndex -= (term.length - 1); ++ const matchIndex = searchRegex.lastIndex - foundTerm[0].length; ++ if (foundTerm[0].length > 0 && this._satisfiesWholeWord(matchIndex, searchStringLine, foundTerm[0], searchOptions)) { ++ resultIndex = matchIndex; ++ term = foundTerm[0]; ++ } ++ searchRegex.lastIndex = matchIndex + 1; + } + } else { +- foundTerm = searchRegex.exec(searchStringLine.slice(offset)); +- if (foundTerm && foundTerm[0].length > 0) { +- resultIndex = offset + (searchRegex.lastIndex - foundTerm[0].length); +- term = foundTerm[0]; ++ // Driven over the whole line from `offset` rather than over `slice(offset)`: a slice ++ // re-anchors ^ and \b at whatever column the row happened to wrap at, and only ++ // first-accepted-match-at-or-after-offset is monotone in `offset`, which is what lets ++ // `_isRowCoveredByEarlierSearch` skip a wrapped row an earlier scan already covered. ++ searchRegex.lastIndex = offset; ++ while (foundTerm = searchRegex.exec(searchStringLine)) { ++ const matchIndex = searchRegex.lastIndex - foundTerm[0].length; ++ if (foundTerm[0].length > 0 && this._satisfiesWholeWord(matchIndex, searchStringLine, foundTerm[0], searchOptions)) { ++ resultIndex = matchIndex; ++ term = foundTerm[0]; ++ break; ++ } ++ // A zero-length or rejected match would otherwise repeat forever. ++ searchRegex.lastIndex = matchIndex + 1; + } + } ++ } else if (isReverseSearch) { ++ let matchIndex = offset - searchTerm.length >= 0 ? searchStringLine.lastIndexOf(searchTerm, offset - searchTerm.length) : -1; ++ // `lastIndexOf` clamps a negative fromIndex to 0, so index 0 has to end the walk. ++ while (matchIndex >= 0 && !this._satisfiesWholeWord(matchIndex, searchStringLine, searchTerm, searchOptions)) { ++ matchIndex = matchIndex > 0 ? searchStringLine.lastIndexOf(searchTerm, matchIndex - 1) : -1; ++ } ++ resultIndex = matchIndex; + } else { +- if (isReverseSearch) { +- if (offset - searchTerm.length >= 0) { +- resultIndex = searchStringLine.lastIndexOf(searchTerm, offset - searchTerm.length); +- } +- } else { +- resultIndex = searchStringLine.indexOf(searchTerm, offset); ++ let matchIndex = searchStringLine.indexOf(searchTerm, offset); ++ while (matchIndex >= 0 && !this._satisfiesWholeWord(matchIndex, searchStringLine, searchTerm, searchOptions)) { ++ matchIndex = searchStringLine.indexOf(searchTerm, matchIndex + 1); + } ++ resultIndex = matchIndex; + } + + if (resultIndex >= 0) { +- if (searchOptions.wholeWord && !this._isWholeWord(resultIndex, searchStringLine, term)) { +- return; +- } +- + // Adjust the row number and search index if needed since a "line" of text can span multiple + // rows + let startRowOffset = 0; +@@ -365,12 +409,21 @@ export class SearchEngine { + return offset; + } + +- private _bufferColsToStringOffset(startRow: number, cols: number): number { +- let lineIndex = startRow; +- let offset = 0; +- let line = this._terminal.buffer.active.getLine(lineIndex); +- while (cols > 0 && line) { +- for (let i = 0; i < cols && i < this._terminal.cols; i++) { ++ /** ++ * `cols` counts from the start of the logical line, so summing the cells of every row before the ++ * resume point costs O(line) per call and the highlight-all pass makes one call per match. ++ * `lineOffsets` already holds the string offset each wrapped row starts at — the same map used ++ * above to turn a match index back into a row — so only the last, partial row needs cells. It is ++ * also the map the row a match lands on is read from, which the cell sum disagreed with by one ++ * for a row whose trailing cell is the null placeholder of a wide character that wrapped. ++ */ ++ private _bufferColsToStringOffset(startRow: number, cols: number, lineOffsets: number[]): number { ++ const rowsBack = Math.min(Math.floor(cols / this._terminal.cols), lineOffsets.length - 1); ++ let offset = lineOffsets[rowsBack]; ++ const line = this._terminal.buffer.active.getLine(startRow + rowsBack); ++ if (line) { ++ const colsInRow = Math.min(cols - rowsBack * this._terminal.cols, this._terminal.cols); ++ for (let i = 0; i < colsInRow; i++) { + const cell = line.getCell(i); + if (!cell) { + break; +@@ -380,12 +433,6 @@ export class SearchEngine { + offset += cell.getCode() === 0 ? 1 : cell.getChars().length; + } + } +- lineIndex++; +- line = this._terminal.buffer.active.getLine(lineIndex); +- if (line && !line.isWrapped) { +- break; +- } +- cols -= this._terminal.cols; + } + return offset; + } +diff --git a/src/SearchLineCache.ts b/src/SearchLineCache.ts +index 526f4bfcc74a881bb39b400ec79a25d33d602303..19b22f2f70e50a6b01d07966e15727cc5271c776 100644 +--- a/src/SearchLineCache.ts ++++ b/src/SearchLineCache.ts +@@ -109,9 +109,13 @@ export class SearchLineCache extends Disposable { + public translateBufferLineToStringWithWrap(lineIndex: number, trimRight: boolean): LineCacheEntry { + const strings = []; + const lineOffsets = [0]; ++ // A single line longer than the whole scrollback leaves every buffer row wrapped, and the ++ // buffer's ring answers an out-of-range row by cycling back to the start, so an unbounded walk ++ // never reaches an unwrapped line. ++ const bufferLength = this._terminal.buffer.active.length; + let line = this._terminal.buffer.active.getLine(lineIndex); + while (line) { +- const nextLine = this._terminal.buffer.active.getLine(lineIndex + 1); ++ const nextLine = lineIndex + 1 < bufferLength ? this._terminal.buffer.active.getLine(lineIndex + 1) : undefined; + const lineWrapsToNext = nextLine ? nextLine.isWrapped : false; + let string = line.translateToString(!lineWrapsToNext && trimRight); + if (lineWrapsToNext && nextLine) { diff --git a/config/patches/xterm-upstream.json b/config/patches/xterm-upstream.json index ec36f65c71d..89afe4fb1fb 100644 --- a/config/patches/xterm-upstream.json +++ b/config/patches/xterm-upstream.json @@ -59,6 +59,33 @@ } ] }, + { + "name": "@xterm/addon-search", + "version": "0.17.0-beta.300", + "packageDir": "addons/addon-search", + "$note": "No versionStampFile: publish.js stamps the addon's package.json, which overlayBuildOutput never patches. The root `build` is required because the addon's own tsgo -p . has empty files/include and only project references, so it emits nothing on its own; `package` is the addon's webpack (CJS half) and the root `esbuild-package` emits the ESM half.", + "$upstream": "Submitted as https://github.com/xtermjs/xterm.js/pull/6149 (issue #6148). Once a release ships it, bump the addon and drop this entry.", + "sourcePatch": "config/patches/xterm-src/@xterm__addon-search@0.17.0-beta.300.src.patch", + "patch": "config/patches/@xterm__addon-search@0.17.0-beta.300.patch", + "generatedPaths": ["lib/"], + "build": [ + { + "cwd": "../..", + "command": "npm", + "args": ["run", "build"] + }, + { + "cwd": ".", + "command": "npm", + "args": ["run", "package"] + }, + { + "cwd": "../..", + "command": "npm", + "args": ["run", "esbuild-package"] + } + ] + }, { "name": "@xterm/addon-serialize", "version": "0.15.0-beta.300", diff --git a/config/relay-assets/node-pty-1.1.0-master-cloexec-patch.cjs b/config/relay-assets/node-pty-1.1.0-master-cloexec-patch.cjs index f4f4f87619a..40bc0350d86 100644 --- a/config/relay-assets/node-pty-1.1.0-master-cloexec-patch.cjs +++ b/config/relay-assets/node-pty-1.1.0-master-cloexec-patch.cjs @@ -1,16 +1,34 @@ /** - * Relay-side pty-master close-on-exec patch for node-pty 1.1.0 (#17915). + * Relay-side pty fd-leak patch for node-pty 1.1.0 (#17915). * * The app gets this through pnpm `patchedDependencies`; the relay installs stock - * node-pty from npm onto the host, where no pnpm patch reaches. Without it every - * later child of the relay -- pty children, git helpers, probes, agent CLIs -- - * inherits each live master fd and keeps its /dev/pts device alive for the life - * of the relay (#8362). + * node-pty from npm onto the host, where no pnpm patch reaches. Stock 1.1.0 leaks + * a pty fd on both Unix relay platforms, by two unrelated bugs on two code paths. * - * Linux only, deliberately: it is the only relay platform that takes forkpty()'s - * no-atomic-O_CLOEXEC path, and the only one that already compiles node-pty at - * install time, so the rebuild costs a second compile rather than a first one. - * macOS re-opens the tty through uv_tty_init's cloexec dup and Windows has no fds. + * Linux takes forkpty(), which has no atomic O_CLOEXEC, so every later child of + * the relay -- pty children, git helpers, probes, agent CLIs -- inherits each live + * master and keeps its /dev/pts device alive for the life of the relay (#8362). + * + * macOS takes pty_posix_spawn(), which opens up to three throwaway ptys to push + * the real master off fds 0-2 and then never closes them: the cleanup loop is + * `for (; count > 0; count--)`, but in any running process the first posix_openpt() + * already returns >= 2, so the loop breaks with count == 0 and its body never runs + * -- and where it does run it closes low_fds[count], never low_fds[0]. Measured on + * darwin-arm64: one orphaned /dev/ptmx fd per terminal, never returned. + * + * macOS does not inherit the master into spawned children today, but not because it + * is marked: FD_CLOEXEC is not set on it (`lsof +fg` shows R,W,NB, no CX). What + * closes it is POSIX_SPAWN_CLOEXEC_DEFAULT in pty_posix_spawn's spawn flags, an + * Apple-only flag that closes every fd in the child. That is one option away from + * gone -- setting uid/gid drops libuv back to fork()/exec(), which honors nothing + * but FD_CLOEXEC -- so the master is marked on the Apple path too, exactly as the + * app's pnpm patch marks it. Windows has no fds and is excluded. + * + * The compile it buys differs by platform. Linux relays already run node-gyp at + * install time (1.1.0 ships no linux prebuild), so this is a second compile on a + * path that already compiles. macOS runs the shipped darwin prebuild and has no + * build/ at all, so this is its first compile -- the price of the only fix there + * is, since the bug is in the source that prebuild was built from. * * Non-fatal by construction: the working build is moved aside before anything is * touched and moved back on any failure, and a failed attempt drops a skip marker @@ -31,7 +49,7 @@ const { dirname, join, resolve } = require('node:path') const EXPECTED_NODE_PTY_VERSION = '1.1.0' const ORIGINAL_SOURCE_SHA256 = '5e1005d6bdcfbe97b486ee415419fe7adae99035047f07340fbad36419e0bae6' -const PATCHED_SOURCE_SHA256 = '97dea52199216c01b62070758f0f38621ae53adc16c221271dd35ae2d8ee3482' +const PATCHED_SOURCE_SHA256 = '3e6bc1a688aae187d231687130cfc0a11781c672f5f616d73183d471ee8ee65c' const STATUS_PREFIX = 'ORCA-NPTY-CLOEXEC:' const SKIP_MARKER_FILENAME = '.node-pty-cloexec-skip' @@ -97,7 +115,56 @@ const FORKPTY_CALL_SITE = [ ` ] -const REPLACEMENTS = [FORWARD_DECLARATION, DEFINITION, FORKPTY_CALL_SITE] +// Apple never reaches FORKPTY_CALL_SITE: `default:` sits in the `#else` arm of PtyFork's +// `#if defined(__APPLE__)`, so before this pair the asset patched nothing macOS executes. +const POSIX_SPAWN_CALL_SITE = [ + ` if (pty_nonblock(master) == -1) { + throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); + } +#else +`, + ` if (pty_nonblock(master) == -1) { + throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); + } + if (pty_cloexec(master) == -1) { + throw Napi::Error::New(napiEnv, "Could not set master fd to close-on-exec."); + } +#else +` +] + +// The throwaway ptys pty_posix_spawn opens to keep the real master off fds 0-2. Byte-identical to +// the app's pnpm patch, so both trees compile the same cleanup. +const LOW_FDS_DECLARATION = [ + ` int low_fds[3]; + size_t count = 0; +`, + ` int low_fds[3] = {-1, -1, -1}; + size_t count = 0; +` +] + +const LOW_FDS_CLEANUP = [ + ` for (; count > 0; count--) { + close(low_fds[count]); + } +`, + ` for (size_t i = 0; i <= count && i < 3; i++) { + if (low_fds[i] != -1) { + close(low_fds[i]); + } + } +` +] + +const REPLACEMENTS = [ + FORWARD_DECLARATION, + DEFINITION, + POSIX_SPAWN_CALL_SITE, + FORKPTY_CALL_SITE, + LOW_FDS_DECLARATION, + LOW_FDS_CLEANUP +] function sourceSha256(source) { return createHash('sha256').update(source).digest('hex') @@ -188,10 +255,12 @@ function rebuildNodePty(relayDir) { } } -// Why a child: a bad build can abort the process on require, which would strand the -// moved-aside working build. Why the reachability check: a host without /proc cannot -// show inheritance, and an unobservable flag is not evidence the rebuild was wrong. -const VERIFY_SCRIPT = ` +// Why a child, for both scripts below: a bad build can abort the process on require, which would +// strand the moved-aside working build. Why each ends in a reachability check: a host that cannot +// show its fds says nothing, and an unobservable flag is not evidence the rebuild was wrong. +// +// Linux's leak is inheritance, so the observation is a later plain child's /proc/self/fd. +const VERIFY_INHERITANCE_SCRIPT = ` const pty = require(process.argv[1]); const term = pty.spawn('/bin/sh', ['-c', 'exit 0'], { name: 'xterm-256color', cols: 80, rows: 24, cwd: process.cwd(), env: process.env @@ -200,13 +269,39 @@ const probe = require('node:child_process').spawnSync('/bin/sh', ['-c', 'ls -l / try { term.kill() } catch {} const listing = probe.stdout || ''; if (probe.status !== 0 || !listing.includes('->')) { console.log('UNVERIFIED'); process.exit(0) } -console.log(listing.includes('ptmx') ? 'INHERITED' : 'ISOLATED'); +console.log(listing.includes('ptmx') ? 'LEAKED' : 'ISOLATED'); process.exit(0); ` -/** 'isolated' when a later plain child no longer inherits the master, 'unverified' when /proc cannot say. */ -function verifyMasterNotInheritedByLaterChild(relayDir) { - const result = spawnSync(process.execPath, ['-e', VERIFY_SCRIPT, nodePtyDir(relayDir)], { +// Apple's leak is self-held, not inherited, so the observation is this process's own fd table: +// N live ptys must account for exactly N /dev/ptmx rows. A stock build shows 2N -- the master plus +// the throwaway pty_posix_spawn opened and never closed. lsof, not /proc, because macOS has no +// /proc; a host without lsof cannot say, which is 'unverified', not a failed patch. +const VERIFY_SELF_FDS_SCRIPT = ` +const pty = require(process.argv[1]); +const terms = []; +for (let i = 0; i < 3; i++) { + terms.push(pty.spawn('/bin/sh', ['-c', 'sleep 30'], { + name: 'xterm-256color', cols: 80, rows: 24, cwd: process.cwd(), env: process.env + })); +} +const probe = require('node:child_process').spawnSync('/bin/sh', ['-c', 'lsof -p ' + process.pid], { encoding: 'utf8', maxBuffer: 1 << 24 }); +for (const term of terms) { try { term.kill() } catch {} } +const rows = (probe.stdout || '').split('\\n').filter((line) => line.includes('/dev/ptmx')); +if (probe.status !== 0 || rows.length < terms.length) { console.log('UNVERIFIED'); process.exit(0) } +console.log(rows.length > terms.length ? 'LEAKED' : 'ISOLATED'); +process.exit(0); +` + +const LEAK_MESSAGE = { + darwin: 'rebuilt node-pty still leaks a throwaway pty fd per spawn', + linux: 'rebuilt node-pty still leaks the pty master into later children' +} + +/** 'isolated' when the platform's leak is gone, 'unverified' when the host cannot show it. */ +function verifyNoPtyFdLeak(relayDir, platform) { + const script = platform === 'darwin' ? VERIFY_SELF_FDS_SCRIPT : VERIFY_INHERITANCE_SCRIPT + const result = spawnSync(process.execPath, ['-e', script, nodePtyDir(relayDir)], { cwd: relayDir, encoding: 'utf8', timeout: VERIFY_TIMEOUT_MS, @@ -219,22 +314,53 @@ function verifyMasterNotInheritedByLaterChild(relayDir) { `rebuilt node-pty did not load: ${tail || result.error?.message || result.signal}` ) } - if (output.includes('INHERITED')) { - throw new Error('rebuilt node-pty still leaks the pty master into later children') + if (output.includes('LEAKED')) { + throw new Error(LEAK_MESSAGE[platform] || LEAK_MESSAGE.linux) } return output.includes('ISOLATED') ? 'isolated' : 'unverified' } -function rollback(relayDir, releaseDir, backupDir) { - rmSync(releaseDir, { recursive: true, force: true }) +/** + * What gets moved aside before the compile, and where the compile writes. + * + * Linux ships no prebuild, so `build/Release` is both the working build and the compile's output, + * and moving it aside only arms the rollback. macOS runs `prebuilds/darwin-` and has no + * `build/` at all, so the compile writes a new `build/Release` -- which node-pty's loader checks + * ahead of `prebuilds`. Moving `prebuilds` aside does double duty there: it arms the rollback and + * it is what makes node-pty's install script fall through from "prebuild found" to `node-gyp + * rebuild`. Deliberately not `npm_config_build_from_source`, which deletes the prebuilds outright + * and would leave nothing to roll back to. + */ +function buildLayout(relayDir, platform, arch) { + const ptyDir = nodePtyDir(relayDir) + const compiledDir = join(ptyDir, 'build', 'Release') + if (platform === 'darwin') { + const prebuildsDir = join(ptyDir, 'prebuilds') + return { + compiledDir, + movedDir: prebuildsDir, + workingBuildPath: join(prebuildsDir, `darwin-${arch}`, 'pty.node'), + missingStatus: 'skipped:no-prebuild' + } + } + return { + compiledDir, + movedDir: compiledDir, + workingBuildPath: join(compiledDir, 'pty.node'), + missingStatus: 'skipped:no-compiled-build' + } +} + +function rollback(relayDir, layout, backupDir) { + rmSync(layout.compiledDir, { recursive: true, force: true }) try { revertNodePtyMasterCloexecSource(relayDir) } catch { // The build that is about to be restored predates the patch either way. } if (existsSync(backupDir)) { - mkdirSync(dirname(releaseDir), { recursive: true }) - renameSync(backupDir, releaseDir) + mkdirSync(dirname(layout.movedDir), { recursive: true }) + renameSync(backupDir, layout.movedDir) } } @@ -244,16 +370,17 @@ function rollback(relayDir, releaseDir, backupDir) { */ function applyNodePtyMasterCloexecPatch(relayDir = process.cwd(), options = {}) { const platform = options.platform || process.platform + const arch = options.arch || process.arch const rebuild = options.rebuild || rebuildNodePty - const verify = options.verify || verifyMasterNotInheritedByLaterChild - if (platform !== 'linux') { - return 'skipped:not-linux' + const verify = options.verify || verifyNoPtyFdLeak + if (platform !== 'linux' && platform !== 'darwin') { + return 'skipped:unsupported-platform' } const skipMarkerPath = join(relayDir, SKIP_MARKER_FILENAME) if (existsSync(skipMarkerPath)) { return 'skipped:earlier-attempt-failed' } - const releaseDir = join(nodePtyDir(relayDir), 'build', 'Release') + const layout = buildLayout(relayDir, platform, arch) const backupDir = join(nodePtyDir(relayDir), BACKUP_DIRNAME) // A backup stranded by a connection that died mid-rebuild is stale by definition: // whatever repaired node-pty since built from the source now on disk. @@ -272,25 +399,28 @@ function applyNodePtyMasterCloexecPatch(relayDir = process.cwd(), options = {}) if (hash !== ORIGINAL_SOURCE_SHA256) { return 'skipped:unexpected-source' } - // No compiled build means the host runs a prebuild or nothing at all; rebuilding - // could only take away the artifact the probe just proved loadable. - if (!existsSync(join(releaseDir, 'pty.node'))) { - return 'skipped:no-compiled-build' + // Nothing to fall back on means the host runs neither a compile nor the prebuild + // this platform expects; rebuilding could only take away the artifact the probe + // just proved loadable. + if (!existsSync(layout.workingBuildPath)) { + return layout.missingStatus } try { - renameSync(releaseDir, backupDir) + renameSync(layout.movedDir, backupDir) } catch (err) { return `skipped:${err.message}` } try { patchNodePtyMasterCloexecSource(relayDir) rebuild(relayDir) - const verdict = verify(relayDir) + const verdict = verify(relayDir, platform) + // Discarded, not restored: a tree that gets published must hold no unpatched binary the + // loader could still fall back to. A later repair recompiles from the patched source. rmSync(backupDir, { recursive: true, force: true }) return verdict === 'isolated' ? 'patched' : 'patched-unverified' } catch (err) { - rollback(relayDir, releaseDir, backupDir) + rollback(relayDir, layout, backupDir) // Bounded on purpose: one compile attempt per relay directory, never a retry loop. try { writeFileSync(skipMarkerPath, `${new Date().toISOString()} ${err.message}\n`) diff --git a/config/scripts/bootstrap-locale-catalog.mjs b/config/scripts/bootstrap-locale-catalog.mjs index 05739b4da9c..e5c5fb69a81 100644 --- a/config/scripts/bootstrap-locale-catalog.mjs +++ b/config/scripts/bootstrap-locale-catalog.mjs @@ -34,6 +34,11 @@ const LOCALE_CONFIG = { targetLanguage: 'es', displayName: 'Spanish', cacheFile: '.es-catalog-cache.json' + }, + fr: { + targetLanguage: 'fr', + displayName: 'French', + cacheFile: '.fr-catalog-cache.json' } } diff --git a/config/scripts/locale-key-overrides.mjs b/config/scripts/locale-key-overrides.mjs index ec2a0f7d7c2..5519f34f1b5 100644 --- a/config/scripts/locale-key-overrides.mjs +++ b/config/scripts/locale-key-overrides.mjs @@ -12,6 +12,9 @@ const BASE_LOCALE_KEY_OVERRIDES = { // Bare "Cursor" terminal/theme settings = on-screen カーソル, not the Cursor product. 'auto.components.settings.TerminalWindowSection.c9e1fdf42f': { ja: 'カーソル' }, 'auto.components.onboarding.ThemeStep.ab2a583a97': { ja: 'カーソル' }, + // File-row "Duplicate" is the action, and it sits beside "Copy" (复制) in the same menu; keyed + // because the skills-dialog chip shares the English string but reads as a noun. + 'auto.components.right.sidebar.FileExplorerRow.0fec99bfd7': { zh: '创建副本' }, 'menu.reportCrash': { ko: '크래시 신고...', zh: '报告崩溃...', ja: 'クラッシュを報告...' }, 'menu.showMobileButton': { ko: 'Orca 모바일 버튼 표시', diff --git a/config/scripts/locale-translation-policy.mjs b/config/scripts/locale-translation-policy.mjs index 9fd4350ee45..cec2ebf63ad 100644 --- a/config/scripts/locale-translation-policy.mjs +++ b/config/scripts/locale-translation-policy.mjs @@ -217,10 +217,41 @@ export const NEVER_TRANSLATE_VALUES = new Set([ ]) export const NATIVE_PICKER_LABELS = { - zh: { chinese: '中文(简体)', korean: '한국어', japanese: '日本語', spanish: 'Español' }, - ko: { chinese: '中文(简体)', korean: '한국어', japanese: '日本語', spanish: 'Español' }, - ja: { chinese: '中文(简体)', korean: '한국어', japanese: '日本語', spanish: 'Español' }, - es: { chinese: '中文(简体)', korean: '한국어', japanese: '日本語', spanish: 'Español' } + zh: { + chinese: '中文(简体)', + korean: '한국어', + japanese: '日本語', + spanish: 'Español', + french: 'Français' + }, + ko: { + chinese: '中文(简体)', + korean: '한국어', + japanese: '日本語', + spanish: 'Español', + french: 'Français' + }, + ja: { + chinese: '中文(简体)', + korean: '한국어', + japanese: '日本語', + spanish: 'Español', + french: 'Français' + }, + es: { + chinese: '中文(简体)', + korean: '한국어', + japanese: '日本語', + spanish: 'Español', + french: 'Français' + }, + fr: { + chinese: '中文(简体)', + korean: '한국어', + japanese: '日本語', + spanish: 'Español', + french: 'Français' + } } const CJK_LATIN_SPACED_TERM_PATTERN = CJK_LATIN_SPACED_TERMS.join('|') diff --git a/config/scripts/locale-zh-value-overrides.mjs b/config/scripts/locale-zh-value-overrides.mjs index b53fb1d2248..5055ba00341 100644 --- a/config/scripts/locale-zh-value-overrides.mjs +++ b/config/scripts/locale-zh-value-overrides.mjs @@ -44,6 +44,8 @@ export const ZH_VALUE_OVERRIDES = { 'Loading labels': '加载标签', // Why: MT rendered the "Pin Tab" action as "引脚标签" (noun reading of "pin"); pair it with 取消固定标签. 'Pin Tab': '固定标签', + // Why: MT read "Duplicate" as the adjective (重复); it is the action, and the menu is already on 选项卡. + 'Duplicate Tab': '复制选项卡', Approved: '已批准', Strike: '删除线', Bold: '粗体', diff --git a/config/scripts/node-pty-master-cloexec-patch.test.mjs b/config/scripts/node-pty-master-cloexec-patch.test.mjs index 16013cbf0a3..e323c5d61c8 100644 --- a/config/scripts/node-pty-master-cloexec-patch.test.mjs +++ b/config/scripts/node-pty-master-cloexec-patch.test.mjs @@ -27,7 +27,7 @@ afterEach(() => { } }) -describe('SSH relay node-pty pty-master close-on-exec patch', () => { +describe('SSH relay node-pty pty fd-leak patch', () => { it('adds the forkpty close-on-exec call and reverts to the published bytes', () => { const fixture = writeRelayFixture() @@ -44,6 +44,27 @@ describe('SSH relay node-pty pty-master close-on-exec patch', () => { expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) }) + it('rewrites the Apple branch, which is the only one macOS executes', () => { + const fixture = writeRelayFixture() + patchNodePtyMasterCloexecSource(fixture.root) + const patched = readFileSync(fixture.sourcePath, 'utf8') + + // Stock's cleanup never runs: the first posix_openpt() already returns >= 2, so the loop + // breaks with count == 0 -- and where it does run it closes low_fds[count], never low_fds[0]. + expect(STOCK_SOURCE).toContain('for (; count > 0; count--) {') + expect(patched).not.toContain('for (; count > 0; count--) {') + expect(patched).toContain('int low_fds[3] = {-1, -1, -1};') + expect(patched).toContain('for (size_t i = 0; i <= count && i < 3; i++) {') + + // `default:` sits in the `#else` arm of PtyFork's `#if defined(__APPLE__)`, so marking only + // the forkpty call site left the master macOS actually opens unmarked. + expect(patched).toContain( + ' if (pty_cloexec(master) == -1) {\n' + + ' throw Napi::Error::New(napiEnv, "Could not set master fd to close-on-exec.");\n' + + ' }\n#else\n' + ) + }) + it('refuses a different node-pty version or an unrecognized source', () => { const wrongVersion = writeRelayFixture({ version: '1.2.0-beta.4' }) expect(() => patchNodePtyMasterCloexecSource(wrongVersion.root)).toThrow('expected 1.1.0') @@ -139,19 +160,81 @@ describe('SSH relay node-pty pty-master close-on-exec patch', () => { expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) }) - it('never compiles on a platform that does not leak', () => { - for (const platform of ['darwin', 'win32']) { - const fixture = writeRelayFixture() - const calls = [] - const status = applyNodePtyMasterCloexecPatch(fixture.root, { - platform, - rebuild: () => calls.push('rebuild'), - verify: () => 'isolated' - }) - expect(status).toBe('skipped:not-linux') - expect(calls).toEqual([]) - expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) - } + it('never compiles on a platform with no pty fds to leak', () => { + const fixture = writeRelayFixture() + const calls = [] + const status = applyNodePtyMasterCloexecPatch(fixture.root, { + platform: 'win32', + rebuild: () => calls.push('rebuild'), + verify: () => 'isolated' + }) + expect(status).toBe('skipped:unsupported-platform') + expect(calls).toEqual([]) + expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) + }) + + it('compiles a macOS install out from under its shipped prebuild', () => { + // macOS has no build/ at all: node-pty runs `prebuilds/darwin-`, built from the leaky + // source. Moving `prebuilds` aside is what both arms the rollback and makes node-pty's own + // install script fall through from "prebuild found" to node-gyp. + const fixture = writeRelayFixture({ platform: 'darwin' }) + const prebuildsPresentDuringRebuild = [] + + const status = applyNodePtyMasterCloexecPatch(fixture.root, { + platform: 'darwin', + arch: fixture.arch, + rebuild: () => { + prebuildsPresentDuringRebuild.push(existsSync(fixture.prebuildsDir)) + writeCompiledBuild(fixture, 'patched-build') + }, + verify: () => 'isolated' + }) + + expect(status).toBe('patched') + expect(prebuildsPresentDuringRebuild).toEqual([false]) + expect(readFileSync(fixture.compiledPath, 'utf8')).toBe('patched-build') + // The published tree must hold no unpatched binary: node-pty's loader checks build/Release + // first, but falls back to a prebuild if that ever fails to load. + expect(existsSync(fixture.prebuildsDir)).toBe(false) + expect(existsSync(fixture.backupDir)).toBe(false) + }) + + it('restores the macOS prebuild when the first compile fails', () => { + // A macOS host has no toolchain guarantee at all, so this is the common failure, not the rare + // one -- and the relay has to come back on the prebuild exactly as it was installed. + const fixture = writeRelayFixture({ platform: 'darwin' }) + + const status = applyNodePtyMasterCloexecPatch(fixture.root, { + platform: 'darwin', + arch: fixture.arch, + rebuild: () => { + writeCompiledBuild(fixture, 'half-built') + throw new Error('npm rebuild node-pty exited 1: no C++ toolchain') + }, + verify: () => 'isolated' + }) + + expect(status).toContain('failed:') + expect(readFileSync(fixture.buildPath, 'utf8')).toBe('stock-build') + expect(existsSync(fixture.compiledPath)).toBe(false) + expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) + expect(existsSync(fixture.skipMarkerPath)).toBe(true) + }) + + it('will not rebuild a macOS install that has no prebuild to fall back on', () => { + const fixture = writeRelayFixture({ platform: 'darwin', build: false }) + const calls = [] + + const status = applyNodePtyMasterCloexecPatch(fixture.root, { + platform: 'darwin', + arch: fixture.arch, + rebuild: () => calls.push('rebuild'), + verify: () => 'isolated' + }) + + expect(status).toBe('skipped:no-prebuild') + expect(calls).toEqual([]) + expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) }) it('leaves an already patched install alone', () => { @@ -201,19 +284,35 @@ describe('SSH relay node-pty pty-master close-on-exec patch', () => { }) }) -function writeRelayFixture({ version = '1.1.0', source = STOCK_SOURCE, build = true } = {}) { +/** + * `buildPath` is the working build the patch has to be able to fall back on, which differs by + * platform: Linux compiles into build/Release at install time, macOS runs a shipped prebuild and + * has no build/ at all. `compiledPath` is where the rebuild writes on either. + */ +function writeRelayFixture({ + version = '1.1.0', + source = STOCK_SOURCE, + build = true, + platform = 'linux', + arch = 'arm64' +} = {}) { const root = mkdtempSync(join(projectDir, '.node-pty-cloexec-patch-test-')) cleanupDirs.push(root) const nodePtyDir = join(root, 'node_modules', 'node-pty') const sourcePath = join(nodePtyDir, 'src', 'unix', 'pty.cc') - const buildPath = join(nodePtyDir, 'build', 'Release', 'pty.node') + const compiledPath = join(nodePtyDir, 'build', 'Release', 'pty.node') + const prebuildsDir = join(nodePtyDir, 'prebuilds') mkdirSync(join(nodePtyDir, 'src', 'unix'), { recursive: true }) writeFileSync(join(nodePtyDir, 'package.json'), JSON.stringify({ version })) writeFileSync(sourcePath, source) const fixture = { root, + arch, sourcePath, - buildPath, + compiledPath, + prebuildsDir, + buildPath: + platform === 'darwin' ? join(prebuildsDir, `darwin-${arch}`, 'pty.node') : compiledPath, backupDir: join(nodePtyDir, '.orca-cloexec-prepatch-release'), skipMarkerPath: join(root, SKIP_MARKER_FILENAME) } @@ -227,3 +326,8 @@ function writeBuild(fixture, contents) { mkdirSync(resolve(fixture.buildPath, '..'), { recursive: true }) writeFileSync(fixture.buildPath, contents) } + +function writeCompiledBuild(fixture, contents) { + mkdirSync(resolve(fixture.compiledPath, '..'), { recursive: true }) + writeFileSync(fixture.compiledPath, contents) +} diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 8bc10fc5b72..7111531c35c 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -263,6 +263,14 @@ export function shouldRunPrChecks(changedFiles) { return changedFiles.some((file) => !isDocsOnlyPath(file) && !isDesktopIrrelevantPath(file)) } +export function needsMobileDependencies(changedFiles) { + // Why: static analysis lints CHANGED files, mobile ones included, and its + // type-aware pass resolves types from mobile/node_modules. Mobile is a + // separate pnpm project, so without this the root-only install leaves every + // mobile type an `error` type and the gate reports phantom findings. + return changedFiles.length === 0 || changedFiles.some((file) => file.startsWith('mobile/')) +} + export function classifyPrJobs(changedFiles) { const emptyDiff = changedFiles.length === 0 const shouldRun = shouldRunPrChecks(changedFiles) @@ -276,6 +284,7 @@ export function classifyPrJobs(changedFiles) { return { should_run: shouldRun, native_cache_changed: shouldRun && (emptyDiff || changedFiles.some(isNativeCacheInputPath)), + mobile_dependencies: shouldRun && needsMobileDependencies(changedFiles), ...jobs } } diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 1fe296af265..4642372135c 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -316,6 +316,24 @@ describe('per-job path classification', () => { } }) + // Why: static analysis lints changed mobile files with a type-aware pass, and + // mobile is a separate pnpm project. Without its node_modules every mobile type + // resolves to an `error` type and the changed-code gate fails on phantom + // findings, which is exactly how a react-test-renderer union broke a PR. + it('installs mobile dependencies exactly when mobile files change', () => { + expect(classifyPrJobs([]).mobile_dependencies).toBe(true) + expect(classifyPrJobs(['README.md']).mobile_dependencies).toBe(false) + expect(classifyPrJobs(['src/main/index.ts']).mobile_dependencies).toBe(false) + expect( + classifyPrJobs(['src/main/index.ts', 'mobile/src/session/a.test.ts']).mobile_dependencies + ).toBe(true) + // Why false: a mobile-only diff skips every desktop job, so the install step's own + // job never runs and claiming the install is needed contradicts should_run. + expect(classifyPrJobs(['mobile/package.json']).mobile_dependencies).toBe(false) + expect(classifyPrJobs(['mobile/package.json']).should_run).toBe(false) + expect(classifyPrJobs(['README.md', 'mobile/src/a.ts']).mobile_dependencies).toBe(false) + }) + it('keeps unit-test-only diffs out of packaging', () => { expectClassification(['src/main/git/git-status.test.ts'], { git_compatibility: true @@ -354,6 +372,20 @@ describe('PR Checks skip wiring', () => { } }) + it('gives static analysis the mobile types its type-aware pass resolves', () => { + expect(prWorkflow.jobs.code_paths.outputs.mobile_dependencies).toBe( + '${{ steps.filter.outputs.mobile_dependencies }}' + ) + const steps = prWorkflow.jobs.static_analysis.steps + const install = steps.findIndex((step) => step.name === 'Install mobile dependencies') + const gate = steps.findIndex((step) => step.name === 'Enforce changed-code quality') + expect(install).toBeGreaterThan(-1) + expect(install).toBeLessThan(gate) + expect(steps[install].if).toBe("needs.code_paths.outputs.mobile_dependencies == 'true'") + expect(steps[install]['working-directory']).toBe('mobile') + expect(steps[install].run).toContain('--frozen-lockfile') + }) + it('keeps the cheap root-directory guard on docs-only PRs', () => { expect(prWorkflow.jobs.root_directory_guard.if).toBeUndefined() expect(prWorkflow.jobs.root_directory_guard.needs).toBeUndefined() diff --git a/config/scripts/release-blocker-fixes.test.mjs b/config/scripts/release-blocker-fixes.test.mjs new file mode 100644 index 00000000000..bccd631a266 --- /dev/null +++ b/config/scripts/release-blocker-fixes.test.mjs @@ -0,0 +1,35 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' + +const projectDir = resolve(import.meta.dirname, '../..') + +describe('release blocker safeguards', () => { + it('keeps the root package version on the current stable release line', () => { + const packageJson = JSON.parse(readFileSync(resolve(projectDir, 'package.json'), 'utf8')) + const match = /^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(packageJson.version) + expect(match).not.toBeNull() + const version = match.slice(1, 4).map(Number) + const isAtLeastStable = + version[0] > 1 || + (version[0] === 1 && (version[1] > 4 || (version[1] === 4 && version[2] >= 196))) + expect(isAtLeastStable).toBe(true) + }) + + it('passes the staging confirmation through the step environment', () => { + const workflow = parse( + readFileSync( + resolve(projectDir, '.github/workflows/cloud-prove-relay-asia-staging.yml'), + 'utf8' + ) + ) + const step = workflow.jobs.prove.steps.find( + ({ name }) => name === 'Validate the exact staging proof request' + ) + + expect(step.env.CONFIRMATION).toBe('${{ inputs.confirmation }}') + expect(step.run).toContain('test "${CONFIRMATION}" = PROVE_ASIA_STAGING') + expect(step.run).not.toContain('${{ inputs.confirmation }}') + }) +}) diff --git a/config/scripts/verify-localization-catalog.mjs b/config/scripts/verify-localization-catalog.mjs index cb58372fab1..a73e9d5e3cc 100644 --- a/config/scripts/verify-localization-catalog.mjs +++ b/config/scripts/verify-localization-catalog.mjs @@ -11,7 +11,12 @@ import { repairTranslatedValue } from './locale-translation-policy.mjs' const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts']) const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets']) -const LOCALIZATION_FUNCTION_NAMES = new Set(['t', 'translate', 'translateMain']) +const LOCALIZATION_FUNCTION_NAMES = new Set([ + 't', + 'translate', + 'translateMain', + 'translateSearchKeyword' +]) const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g const LOCALES_RELATIVE_DIR = path.join('src', 'renderer', 'src', 'i18n', 'locales') export const LOCALIZATION_SOURCE_ROOTS = [ diff --git a/config/scripts/verify-localization-catalog.test.mjs b/config/scripts/verify-localization-catalog.test.mjs index 4bf3d7d7eba..4623f345b79 100644 --- a/config/scripts/verify-localization-catalog.test.mjs +++ b/config/scripts/verify-localization-catalog.test.mjs @@ -51,6 +51,20 @@ describe('verify-localization-catalog', () => { expect(readJson(path.join(localesDir, 'es.json'))).toEqual({}) }) + it('bootstraps keys referenced only through translateSearchKeyword', async () => { + const { root, localesDir } = makeProject({ + sourceText: + "import { translateSearchKeyword } from '@/components/settings/settings-search-keywords'\nexport const keywords = translateSearchKeyword('auto.components.settings.example.search.scroll', 'scroll')\n" + }) + + await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(1) + await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(0) + + expect(readJson(path.join(localesDir, 'en.json'))).toEqual({ + auto: { components: { settings: { example: { search: { scroll: 'scroll' } } } } } + }) + }) + it('never overwrites mismatched translations or removes target-only entries', async () => { const { root, localesDir } = makeProject({ sourceText: diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 39fbcf45af1..ef8ebb61bb4 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 37m + + downloads: 38m @@ -15,7 +15,7 @@ downloads downloads - 37m - 37m + 38m + 38m diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index f2247e0900d..c8333ec31c9 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -12,7 +12,7 @@

- English · 中文 · 日本語 · 한국어 · Français · Português + English · 中文 · 日本語 · 한국어 · Français · Português · Українська

diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md index 97c78d4e713..7a69e1acffe 100644 --- a/docs/readme/README.fr.md +++ b/docs/readme/README.fr.md @@ -12,7 +12,7 @@

- English · 中文 · 日本語 · 한국어 · Español · Português + English · 中文 · 日本語 · 한국어 · Español · Português · Українська

diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index cce2032a67c..a256f5239c4 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -12,7 +12,7 @@

- English · 中文 · 한국어 · Español · Français · Português + English · 中文 · 한국어 · Español · Français · Português · Українська

diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index 4a75722ff8c..d2dd2a62747 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -12,7 +12,7 @@

- English · 中文 · 日本語 · Español · Français · Português + English · 中文 · 日本語 · Español · Français · Português · Українська

diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md index 86d998a4e5f..667cebb685f 100644 --- a/docs/readme/README.pt.md +++ b/docs/readme/README.pt.md @@ -12,7 +12,7 @@

- English · 中文 · 日本語 · 한국어 · Español · Français + English · 中文 · 日本語 · 한국어 · Español · Français · Українська

diff --git a/docs/readme/README.uk.md b/docs/readme/README.uk.md new file mode 100644 index 00000000000..b3e2a42b868 --- /dev/null +++ b/docs/readme/README.uk.md @@ -0,0 +1,269 @@ +

+ Orca Orca +

+ +

+ Зірки на GitHub + Загальна кількість завантажень усіх релізів + Ліцензія: MIT + Приєднатися до Discord Orca + Стежити за Orca в X + Підтримувані платформи: macOS, Windows і Linux +

+ +

+ English · 中文 · 日本語 · 한국어 · Español · Français · Português +

+ +

+ AI-оркестратор для розробників рівня 100x.
+ Запускайте Codex, Claude Code, OpenCode або Pi паралельно — кожен у власному worktree, усі під контролем в одному місці. +

+ +

Завантажити Orca

+ +

+ Десктопний застосунок Orca запускає агентів у паралельних worktree, у кутку — супутній мобільний застосунок Orca +

+ +## Можливості + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +### Супутній мобільний застосунок + +Стежте за агентами та керуйте ними з телефону — отримуйте сповіщення про завершення роботи агента та надсилайте подальші вказівки, де б ви не були. + +[App Store для iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Документація →](https://www.onorca.dev/docs/mobile) + + + Десктоп Orca із супутнім мобільним застосунком +
+ +### Паралельні worktree + +Надішліть один промпт одразу п’ятьом агентам, кожен із яких працюватиме у власному ізольованому git worktree, — порівняйте результати та виконайте злиття найкращого з них. + +[Документація →](https://www.onorca.dev/docs/model/worktrees) + + + Оркестрація паралельних worktree +
+ +### Розділені термінали + +Термінали рівня Ghostty з рендерингом на WebGL, необмеженою кількістю розділень і буфером прокручування, який зберігається після перезапуску. + +[Документація →](https://www.onorca.dev/docs/terminal) + + + Розділені термінали +
+ +### Режим дизайну + +Клацніть на будь-якому елементі інтерфейсу у справжньому вікні Chromium, щоб надіслати його HTML, CSS і обрізаний скриншот прямо в промпт агента. + +[Документація →](https://www.onorca.dev/docs/browser/design-mode) + + + Вбудований браузер і режим дизайну +
+ +### GitHub і Linear, нативно + +Переглядайте PR, issue та дошки проєктів прямо в застосунку — відкривайте worktree з будь-якої задачі та рев'юйте без перемикання контексту. + +[Документація →](https://www.onorca.dev/docs/review/linear) + + + Робочі процеси GitHub і Linear в Orca +
+ +### SSH worktree + +Запускайте агентів на потужній віддаленій машині з повноцінним редагуванням файлів, git і терміналами — з автоперепідключенням і прокиданням портів. + +[Документація →](https://www.onorca.dev/docs/ssh) + + + Віддалені worktree через SSH +
+ +### Анотуйте diff-и агентів + +Залишайте коментарі на будь-якому рядку diff-у й надсилайте їх агенту — рев'юйте, редагуйте та комітьте, не виходячи з Orca. + +[Документація →](https://www.onorca.dev/docs/review/annotate-ai-diff) + + + Анотування diff-ів, згенерованих AI +
+ +### Перетягуйте файли агентам + +Редактор на базі VS Code з автозбереженням усюди — перетягуйте файли чи зображення прямо в промпт агента. + +[Документація →](https://www.onorca.dev/docs/editing/file-explorer) + + + Перетягування файлів і зображень у промпт агента +
+ +### Orca CLI + +Агенти теж керують Orca — автоматизуйте будь-який робочий процес командами `orca worktree create`, `snapshot`, `click` і `fill`. + +[Документація →](https://www.onorca.dev/docs/cli/overview) + + + Керування Orca з CLI +
+ +**Також у комплекті:** + +- **[Швидкий пошук](https://www.onorca.dev/docs/model/quick-open)** — Шукайте серед worktree, файлів, агентів, команд і контексту репозиторію, не відриваючись від роботи. +- **[Перемикач акаунтів і відстеження використання](https://www.onorca.dev/docs/agents/usage-tracking)** — Стежте за використанням Claude і Codex та скиданням лімітів, перемикайте акаунти на льоту без повторного входу. +- **[Розширені перегляди репозиторію](https://www.onorca.dev/docs/editing/markdown)** — Переглядайте Markdown, зображення, PDF та документацію репозиторію прямо в робочому просторі. +- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — Дозвольте агентам керувати десктопними застосунками та видимим інтерфейсом, коли робочий процес потребує реальної взаємодії. +- **[Сповіщення та статус непрочитаного](https://www.onorca.dev/docs/notifications)** — Дізнавайтеся, коли агент завершив роботу або потребує уваги, і позначайте треди як непрочитані, щоб повернутися пізніше. +- **І багато іншого** — ми випускаємо оновлення щодня, тож цей список завжди відстає. Справжній перелік можливостей — це [changelog](https://github.com/stablyai/orca/releases). + +--- + +## Підтримувані агенти + +Працює з **будь-яким CLI-агентом** — якщо він запускається в терміналі, він запуститься і в Orca. + +

+ Claude Code logo Claude Code   + Codex logo Codex   + Grok logo Grok   + Cursor logo Cursor   + GitHub Copilot logo GitHub Copilot   + OpenCode logo OpenCode   + MiMo Code logo MiMo Code   + Amp logo Amp   + OpenClaude logo OpenClaude   + Antigravity logo Antigravity   + Pi logo Pi   + oh-my-pi logo oh-my-pi   + Hermes Agent logo Hermes Agent   + Devin logo Devin   + Goose logo Goose   + Auggie logo Auggie   + Autohand Code logo Autohand Code   + Charm logo Charm   + Cline logo Cline   + Codebuff logo Codebuff   + Command Code logo Command Code   + Continue logo Continue   + Droid logo Droid   + Kilocode logo Kilocode   + Kimi logo Kimi   + Kiro logo Kiro   + Mistral Vibe logo Mistral Vibe   + Qwen Code logo Qwen Code   + Rovo Dev logo Rovo Dev   + + будь-який CLI-агент +

+ +--- + +## Встановлення + +### Десктоп — macOS, Windows, Linux + +- **[Завантажити з onOrca.dev](https://onorca.dev/download)** +- Або завантажте білд напряму: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [Усі білди](https://github.com/stablyai/orca/releases/latest) +- Запускаєте `orca serve` на headless Linux-сервері? Дивіться [посібник із headless Linux-сервера](../reference/headless-linux-server.md). + +_Або через пакетний менеджер:_ + +```bash +# macOS (Homebrew) +brew install --cask stablyai/orca/orca + +# Arch Linux (AUR) — або stably-orca-git для збірки з джерела +yay -S stably-orca-bin +``` + +### Супутній мобільний застосунок — iOS, Android + +Під’єднайте мобільний застосунок до десктопного, щоб стежити за агентами та керувати ними з телефону. + +- **iOS:** [Завантажити з App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) або [приєднатися до TestFlight](https://testflight.apple.com/join/YjeGMQBA) +- **Android:** [Завантажити APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Інструкція зі встановлення](https://www.onorca.dev/docs/android-apk) + +--- + +## Спільнота та підтримка + +- **Discord:** Приєднуйтеся до спільноти в **[Discord](https://discord.gg/fzjDKHxv8Q)**. +- **Twitter / X:** Стежте за **[@orca_build](https://x.com/orca_build)**, щоб бути в курсі оновлень і анонсів. +- **WeChat:** Відскануйте QR-код, щоб приєднатися до групи № 7 спільноти Orca у WeChat. Якщо вона заповнена, приєднайтеся до групи № 8. + + QR-код групи WeChat 7 спільноти Orca   + QR-код групи WeChat 8 спільноти Orca + +- **Зворотний зв'язок та ідеї:** Ми випускаємо оновлення швидко. Чогось бракує? [Запропонуйте нову функцію](https://github.com/stablyai/orca/issues). +- **Конфіденційність:** Перегляньте [документацію про конфіденційність і телеметрію](https://www.onorca.dev/docs/telemetry), щоб дізнатися, які анонімні дані про використання збирає Orca і як від цього відмовитися. +- **Підтримайте нас:** Поставте [зірку](https://github.com/stablyai/orca) цьому репозиторію, щоб стежити за нашими щоденними релізами. + +--- + +## Розробка + +Хочете зробити внесок або запустити проєкт локально? Перегляньте наш посібник [CONTRIBUTING.md](../../.github/CONTRIBUTING.md). + + + Контриб'ютори Orca + + +

+ Графік історії зірок на GitHub для stablyai/orca +

+ +## Підписані білди +Підписання коду для Windows надано за підтримки [SignPath.io](https://signpath.io), сертифікат надано [SignPath Foundation](https://signpath.org). + +## Ліцензія + +Orca — безкоштовний проєкт із відкритим кодом за ліцензією [MIT](../../LICENSE). diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index d7bae3fba9e..160f5b05611 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -12,7 +12,7 @@

- English · 日本語 · 한국어 · Español · Français · Português + English · 日本語 · 한국어 · Español · Français · Português · Українська

diff --git a/docs/reference/ssh-execution-boundary.md b/docs/reference/ssh-execution-boundary.md index cc88cf39a17..88a4a3c0a0e 100644 --- a/docs/reference/ssh-execution-boundary.md +++ b/docs/reference/ssh-execution-boundary.md @@ -66,10 +66,27 @@ A verdict needs evidence from the host that owns the process. Apply these tests **Does the termination event match the current identity?** A host-delivered exit for the live PTY incarnation and provider generation, while its siblings still report, establishes `exited`. A stale event, an event for a superseded incarnation, or one quiet terminal with no host evidence does not. +**Did the answer carry its evidence, or only the same wording?** `pty.attach` refuses with `PTY "" not found` both for a pid the relay probed and found gone and for an id its session map never had — which is every id minted before a relay restart, since ids carry a per-start mint epoch. Only the probed refusal carries `PTY_ATTACH_PROVEN_EXITED_MARKER` (`src/shared/pty-attach-absence-evidence.ts`) and reaches the client as `SshPtyProvenExitedOnRelayError`; the unmarked union arrives as `SshPtyAbsentFromRelayError`, which licenses retiring the client's own route to the PTY and nothing more. A missing marker is never evidence — an older relay omits it too. + **Is a returned status actually a claim of success?** An operation that reports failure may have succeeded, and one that reports success may not have run — check the durable state it should have changed rather than trusting the return. Anything short of positive host evidence is `unverifiable`. Reporting it as `exited` is the error this document exists to prevent: it orphans live work and can cold-start a duplicate over the same worktree. +## Deciding a remote pane is idle + +The orphan-PTY sweep is the one flow that turns an observation into a SIGKILL, so its idleness evidence has to be measured against the same thing the signal reaches. It is not the terminal. + +`forceKillPosixPtyProcessGroups` (`src/main/pty/posix-pty-process-groups.ts`) collects every process group on the pane's tty and `killpg`s each one. The blast radius is therefore _(process groups on the tty) × (members of those groups, wherever they are)_, and the second factor is not bounded by the terminal at all. Two facts make that gap reachable: + +- **Job control can be off.** With `set +m` a background job does not get its own process group — it keeps the shell's. `ps` then shows one process group on the tty, running a build. Nothing in a tty-shaped predicate can see it. +- **A group member can leave the terminal.** `ioctl(TIOCNOTTY)` without `setsid` drops the controlling terminal but keeps the pgid, so the process reports `tpgid == -1`, never appears in `ps -t `, and is still killed by `killpg(shellPgid)`. A double-forked grandchild similarly keeps the pgid while reparenting to pid 1, so no walk by `ppid` from the PTY root can name it either. + +So `shellOwnsEveryTtyProcessGroup` (`src/main/providers/agent-foreground-process-batch.ts`) requires both measurements: every process group on the tty is the shell's own with none stopped, **and** the shell's own process group has no other member anywhere in the host's process table. The name is tty-shaped for wire-compatibility reasons only. + +Two residuals remain, and neither is removable here. The capture is a snapshot, so work started between the `ps` and the signal is invisible — bounded by `RELAY_PTY_SWEEP_MAX_EVIDENCE_AGE_MS` on the reading side, not eliminated. And a process the host's own `ps` cannot enumerate (another PID namespace, `hidepid=2`, a table truncated by a permission boundary) is unobservable while `killpg` still reaches it. + +The general rule this instantiates: **evidence must be measured in the unit the destructive action operates on.** Evidence in a different unit is `unverifiable` no matter how precise it looks. + ## Reading artifacts instead of process state Artifacts are stronger evidence than liveness signals, but they answer a narrower question than they appear to. diff --git a/docs/reference/xterm-patch-regeneration.md b/docs/reference/xterm-patch-regeneration.md index bd49dd8ca3d..ecb30913d28 100644 --- a/docs/reference/xterm-patch-regeneration.md +++ b/docs/reference/xterm-patch-regeneration.md @@ -24,11 +24,11 @@ truth. Everything else is derived from it by `config/scripts/regenerate-xterm-patches.mjs`, which is pinned to the exact upstream commit the published tarball was built from. -`@xterm/addon-webgl` and `@xterm/addon-serialize` are generated the same way, -from their own source patches under `config/patches/xterm-src/`. Their entries -differ only in `packageDir` and build steps; everything below applies to all -three. `@xterm/addon-ligatures` is the one patch still written by hand — see -[Known Gaps](#known-gaps). +`@xterm/addon-webgl`, `@xterm/addon-search` and `@xterm/addon-serialize` are +generated the same way, from their own source patches under +`config/patches/xterm-src/`. Their entries differ only in `packageDir` and build +steps; everything below applies to all four. `@xterm/addon-ligatures` is the one +patch still written by hand — see [Known Gaps](#known-gaps). ## Rules diff --git a/mobile/src/session/MobileNativeChatQuestion.tsx b/mobile/src/session/MobileNativeChatQuestion.tsx index f470214dbed..f4a34494328 100644 --- a/mobile/src/session/MobileNativeChatQuestion.tsx +++ b/mobile/src/session/MobileNativeChatQuestion.tsx @@ -2,7 +2,11 @@ import { useMemo, useRef, useState } from 'react' import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native' import { ArrowUp, Check, CircleHelp } from 'lucide-react-native' import { colors, radii, spacing, typography } from '../theme/mobile-theme' -import { formatQuestionAnswer, type MobileChatQuestion } from './mobile-native-chat-question' +import { + formatQuestionAnswer, + formatQuestionFreeTextAnswer, + type MobileChatQuestion +} from './mobile-native-chat-question' type Props = { question: MobileChatQuestion @@ -18,6 +22,7 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J const [freeText, setFreeText] = useState('') const [sending, setSending] = useState(false) const sendingRef = useRef(false) + const allowOther = question.allowOther !== false const hasOptions = question.options.length > 0 const trimmedFreeText = freeText.trim() @@ -42,8 +47,9 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J } } - const answerSingle = async (option: string): Promise => { - await sendAnswer(formatQuestionAnswer(question, [option])) + const answerSingle = async (option: string, optionIndex: number): Promise => { + const token = question.optionTokens[optionIndex] + await sendAnswer(token && token.length > 0 ? token : formatQuestionAnswer(question, [option])) } const submitMulti = async (): Promise => { @@ -57,14 +63,13 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J if (trimmedFreeText.length === 0) { return } - // Free text is an unknown entry; formatQuestionAnswer passes it through. - if (await sendAnswer(formatQuestionAnswer(question, [trimmedFreeText]))) { + if (await sendAnswer(formatQuestionFreeTextAnswer(question, trimmedFreeText))) { setFreeText('') } } const canSubmitMulti = selected.length > 0 && !sending - const canSendFreeText = trimmedFreeText.length > 0 && !sending + const canSendFreeText = allowOther && trimmedFreeText.length > 0 && !sending // Stable keys for option rows even if an agent repeats a label. const optionRows = useMemo( @@ -81,7 +86,7 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J {hasOptions ? ( - {optionRows.map(({ label, key }) => { + {optionRows.map(({ label, key }, optIndex) => { const isSelected = selected.includes(label) return ( (question.multiSelect ? toggle(label) : answerSingle(label))} + onPress={() => + question.multiSelect ? toggle(label) : answerSingle(label, optIndex) + } > {question.multiSelect ? ( @@ -124,35 +131,37 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J ) : null} - - - [ - styles.freeSend, - !canSendFreeText && styles.freeSendDisabled, - pressed && canSendFreeText && styles.pressed - ]} - onPress={submitFreeText} - disabled={!canSendFreeText} - > - + - - + [ + styles.freeSend, + !canSendFreeText && styles.freeSendDisabled, + pressed && canSendFreeText && styles.pressed + ]} + onPress={submitFreeText} + disabled={!canSendFreeText} + > + + + + ) : null} ) } diff --git a/mobile/src/session/MobileSessionActiveContent.tsx b/mobile/src/session/MobileSessionActiveContent.tsx index 7dd09977c45..019e83c6a99 100644 --- a/mobile/src/session/MobileSessionActiveContent.tsx +++ b/mobile/src/session/MobileSessionActiveContent.tsx @@ -38,7 +38,7 @@ export function MobileSessionActiveContent({ browserScreencastSupported, showToast, nativeChatSendError, - nativeChatInputLockReason, + nativeChatOverlayInputLockReason, nativeChatController, dictation, handleDictationToggle, @@ -240,7 +240,7 @@ export function MobileSessionActiveContent({ dictationMode={dictationMode} onMicPressIn={handleDictationPressIn} onMicPressOut={handleDictationPressOut} - inputLockReason={nativeChatInputLockReason} + inputLockReason={nativeChatOverlayInputLockReason} sendErrorMessage={nativeChatSendError.message} onClearSendError={nativeChatSendError.clear} sendSurfaceId={controller.nativeChatScopeKey ?? ''} diff --git a/mobile/src/session/MobileSessionHeader.tsx b/mobile/src/session/MobileSessionHeader.tsx index 1ddc7cb1d83..552f507a787 100644 --- a/mobile/src/session/MobileSessionHeader.tsx +++ b/mobile/src/session/MobileSessionHeader.tsx @@ -168,6 +168,7 @@ export function MobileSessionHeader({ controller }: { controller: MobileSessionC {t.type === 'file' && ( )} + {t.type === 'agent-session' && } {t.type === 'terminal' && (() => { const agentId = resolveMobileTerminalTabAgentId(t) diff --git a/mobile/src/session/MobileSessionSheets.tsx b/mobile/src/session/MobileSessionSheets.tsx index 48dcabed23c..0aac2bb9d42 100644 --- a/mobile/src/session/MobileSessionSheets.tsx +++ b/mobile/src/session/MobileSessionSheets.tsx @@ -43,6 +43,8 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC setFileActionTarget, browserActionTarget, setBrowserActionTarget, + agentSessionActionTarget, + setAgentSessionActionTarget, discardMarkdownTarget, setDiscardMarkdownTarget, leaveDrafts, @@ -261,6 +263,14 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC onCloseTab={handleCloseSessionTab} bulkCloseActions={bulkCloseActions} /> + + setAgentSessionActionTarget(null) + )} + onClose={() => setAgentSessionActionTarget(null)} + /> = { activated: boolean activationSeq: number latestActivationSeq: number - sourceTerminalHandle: string + sourceTerminalHandle: string | null activeTerminalHandle: string | null + sourceSessionTabId?: string | null + activeSessionTabId?: string | null activeTabType: string | null } switchSessionTab: (tab: T) => void diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index 2283256da05..890a3a1562e 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -58,7 +58,12 @@ export type MobileNativeChatController = { handleNativeChatSendWithOutcome: ( text: string, images?: string[], - deadline?: number + deadline?: number, + attachments?: readonly { + id?: string + path: string + previewUri: string + }[] ) => Promise /** Launch-context text still parked on the agent's TUI input line, or null. * Image sends read it to size their leading clear (one Ctrl+U per line). */ diff --git a/mobile/src/session/mobile-native-chat-eligibility.test.ts b/mobile/src/session/mobile-native-chat-eligibility.test.ts index 7daa4babea6..e1bd97cad8f 100644 --- a/mobile/src/session/mobile-native-chat-eligibility.test.ts +++ b/mobile/src/session/mobile-native-chat-eligibility.test.ts @@ -123,6 +123,30 @@ describe('resolveMobileNativeChat', () => { expect(resolveMobileNativeChat({ type: 'browser', launchAgent: 'claude' })).toBeNull() }) + it('resolves Codex structured agent-session tabs directly', () => { + expect( + resolveMobileNativeChat({ + type: 'agent-session', + sessionId: 'structured-1', + agent: 'codex' + }) + ).toEqual({ + agent: 'codex', + sessionId: 'structured-1', + transcriptPath: null + }) + }) + + it('rejects non-Codex structured agent-session tabs', () => { + expect( + resolveMobileNativeChat({ + type: 'agent-session', + sessionId: 'structured-1', + agent: 'claude' + } as never) + ).toBeNull() + }) + it('canShowMobileNativeChat mirrors resolution', () => { expect(canShowMobileNativeChat({ type: 'terminal', launchAgent: 'claude' })).toBe(true) expect(canShowMobileNativeChat(null)).toBe(false) diff --git a/mobile/src/session/mobile-native-chat-eligibility.ts b/mobile/src/session/mobile-native-chat-eligibility.ts index abda64b04ab..a3f66eb14aa 100644 --- a/mobile/src/session/mobile-native-chat-eligibility.ts +++ b/mobile/src/session/mobile-native-chat-eligibility.ts @@ -32,6 +32,8 @@ export type MobileNativeChatTab = { /** Host-provided launch context still parked as an unsent TUI-input draft. */ launchDraft?: string launchDraftCreatedAt?: number + sessionId?: string | null + agent?: string | null } /** Resolve a session tab to the transcript identity native chat needs, or @@ -42,7 +44,15 @@ export function resolveMobileNativeChat( tab: MobileNativeChatTab | null, nativeChatTranscriptIsLocalReadable = false ): MobileNativeChatResolution | null { - if (!tab || tab.type !== 'terminal') { + if (!tab) { + return null + } + if (tab.type === 'agent-session') { + return tab.sessionId && tab.agent === 'codex' + ? { agent: tab.agent, sessionId: tab.sessionId, transcriptPath: null } + : null + } + if (tab.type !== 'terminal') { return null } const liveAgent = tab.agentStatus?.agentType ?? null @@ -71,3 +81,15 @@ export function canShowMobileNativeChat( ): boolean { return resolveMobileNativeChat(tab, nativeChatTranscriptIsLocalReadable) !== null } + +export function resolveMobileNativeChatFileSessionId( + tab: MobileNativeChatTab | null +): string | null { + if (tab?.type === 'agent-session') { + return tab.sessionId ?? null + } + if (tab?.type === 'terminal') { + return tab.agentStatus?.providerSession?.id ?? null + } + return null +} diff --git a/mobile/src/session/mobile-native-chat-image-scope-state.ts b/mobile/src/session/mobile-native-chat-image-scope-state.ts new file mode 100644 index 00000000000..8d7de510e3a --- /dev/null +++ b/mobile/src/session/mobile-native-chat-image-scope-state.ts @@ -0,0 +1,18 @@ +import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' + +export const NO_NATIVE_CHAT_IMAGE_ATTACHMENTS: PendingNativeChatImage[] = [] + +export type MobileNativeChatImagesByScope = Record + +export function withScopeAttachments( + byScope: MobileNativeChatImagesByScope, + scope: string, + next: PendingNativeChatImage[] +): MobileNativeChatImagesByScope { + if (next.length > 0) { + return { ...byScope, [scope]: next } + } + const remaining = { ...byScope } + delete remaining[scope] + return remaining +} diff --git a/mobile/src/session/mobile-native-chat-question.test.ts b/mobile/src/session/mobile-native-chat-question.test.ts index 94fbcf055a9..079e661e545 100644 --- a/mobile/src/session/mobile-native-chat-question.test.ts +++ b/mobile/src/session/mobile-native-chat-question.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { formatQuestionAnswer, + formatQuestionFreeTextAnswer, mobileChatQuestionKey, parseAgentQuestion, type MobileChatQuestion @@ -141,6 +142,12 @@ describe('formatQuestionAnswer', () => { expect(formatQuestionAnswer(numbered, [])).toBe('') expect(formatQuestionAnswer(numbered, [' '])).toBe('') }) + + it('prefixes free-text answers with an opaque prompt token when provided', () => { + expect( + formatQuestionFreeTextAnswer({ ...numbered, freeTextToken: 'target' }, ' hi there ') + ).toBe(`target:${encodeURIComponent('hi there')}`) + }) }) describe('mobileChatQuestionKey', () => { @@ -154,5 +161,8 @@ describe('mobileChatQuestionKey', () => { expect(mobileChatQuestionKey({ ...first, options: ['A', 'C'] })).not.toBe( mobileChatQuestionKey(first) ) + expect(mobileChatQuestionKey({ ...first, freeTextToken: 'target-2' })).not.toBe( + mobileChatQuestionKey(first) + ) }) }) diff --git a/mobile/src/session/mobile-native-chat-question.ts b/mobile/src/session/mobile-native-chat-question.ts index 8a1f06dfbf7..5d4e65a46ff 100644 --- a/mobile/src/session/mobile-native-chat-question.ts +++ b/mobile/src/session/mobile-native-chat-question.ts @@ -7,10 +7,14 @@ export type MobileChatQuestion = { question: string options: string[] multiSelect: boolean + /** Structured questions hide the free-text row when the provider does not accept it. */ + allowOther?: boolean /** Per-option leading marker ("1", "b", …) when the source line carried one, * parallel to `options`. Null where the option was a plain bullet. Used to * echo the exact choice the agent listed back to the terminal. */ optionTokens: (string | null)[] + /** Opaque prefix used when free-text answers must target a specific prompt. */ + freeTextToken?: string } export function mobileChatQuestionKey(question: MobileChatQuestion): string { @@ -152,3 +156,13 @@ export function formatQuestionAnswer(question: MobileChatQuestion, selected: str return parts.join(question.multiSelect ? ', ' : ' ') } + +export function formatQuestionFreeTextAnswer(question: MobileChatQuestion, text: string): string { + const trimmed = text.trim() + if (trimmed.length === 0) { + return '' + } + return question.freeTextToken + ? `${question.freeTextToken}:${encodeURIComponent(trimmed)}` + : formatQuestionAnswer(question, [trimmed]) +} diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts index 5134cd373d4..c6e5f434326 100644 --- a/mobile/src/session/mobile-session-route-parity.test.ts +++ b/mobile/src/session/mobile-session-route-parity.test.ts @@ -62,15 +62,15 @@ const HOST_COMPONENT_NAMES = new Set([ 'View' ]) -const HEAD_MAIN_HOOK_SHA256 = '5c475b904928f418c76a7885afdbed7adbfea3fe3ea05e85d956dc22f958a302' -const HEAD_HOOK_BINDING_SHA256 = '028f99dd14fea2110cff446418ee71513aeed38484c2dcea68bf0da8eff377c0' +const HEAD_MAIN_HOOK_SHA256 = '10071240ef9edafc2b9c8bed73be83dceaf7828e3b29f17dab55da020a7697a6' +const HEAD_HOOK_BINDING_SHA256 = '1dadb8c3dc0573ea20659ce7251629669e618dd0effaeac3a4536b29c2e865a1' const HEAD_CALLBACK_IDENTITY_SHA256 = - 'd60ffe53f8d77f2dd3ebd14a5de162bb399113c170b59bdc917de6318ec433ec' -const HEAD_CALLBACK_BODY_SHA256 = '69dfda53fd700f4395a18a37ffdaa530e187bc24b4986d8fdc0184127c00b52d' -const HEAD_EFFECT_SHA256 = '346d384ea0bf2f8f926c5092c5bf57bc2a03494f49f9639e9d6b8a2c51c9f882' + '2a9e4825df007f6ef53b81aa5004991d6318eee7507b44d625c07e630be432eb' +const HEAD_CALLBACK_BODY_SHA256 = '22103ba85a86e3a3fcb80a7509c7a455d79863010cde3af02db6565b55e3ebe9' +const HEAD_EFFECT_SHA256 = 'd9ebfaabc1e79773cdada7ab370b20459ed972f1f8edce1652199f4d0391cd13' const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fef5cc45fde6f8189581' const HEAD_NESTED_FUNCTION_SHA256 = - 'b562c117eb1e4532dd656d8bdd3ca3bc58ce65d78a7ed740dbd866a48d4d8dbe' + '6a13919ede2a8033436fb03e0ff7c426fbed97f470875a7b21b00aaada17fb73' const HEAD_NATIVE_REGISTRATION_SHA256 = 'cab85e4e4a3f43289ba93ddea9ccce57aea83e0bf14fd1620a965aad0c1cb49e' const HEAD_NATIVE_REMOVAL_SHA256 = @@ -79,13 +79,13 @@ const HEAD_TIMER_CREATION_SHA256 = '1a31b625e2174c3db77272249843196d2b6b06ab1e654a96d8f7858e3082e66b' const HEAD_TIMER_CLEANUP_SHA256 = 'c73f1d1c2cc89642f3d727d6f3b6b81860a9d6f34234541a2065ec3d1a8cd116' const HEAD_RUNTIME_STRING_SHA256 = - 'ad0def23206f08d0523c155fe730e86824876e67cf1db6b597541b9c35b54447' + 'ba52a3ede721bd29acbe8593161e90b216b7f361ff896e085927d4d73fa83b2f' const HEAD_HOST_JSX_SHA256 = '390405926b1695fa3a33686f0bc192b432f5468d8576499d7cafbb4922defbb5' -const HEAD_LEAF_JSX_SHA256 = 'b070e25c47b3e298be02a4ffe1572b36e204446fc161bad894690e9939403f54' +const HEAD_LEAF_JSX_SHA256 = '21dba981875e173f692590bf910d60964660c5f4cbb79f3a377c7e54f6a1f016' const HEAD_STYLE_REFERENCE_SHA256 = '295a3501c2c6d7bea7c8bbf38b3f3534f01344cd7e1b91bb8e07c040821d596a' const HEAD_IDENTITY_FIELD_SHA256 = - '6b37a0351795a387a358df76a5ab919a7098ddb76bf25a936c8902c062c8951c' + '91146853930a34dd1f3d80e5c97fbacd7cf19fb93dd26fe8fc6f29169622f9d6' const HEAD_NAVIGATION_SHA256 = '9d96f5dad7de555d6553eac39c0fab00efad507470fd562cb9beaa32db16f512' const HEAD_CAPABILITY_SHA256 = 'ca219f7909a091717110b823d5b94a20770ad3ae51894e0fa765e8628309392d' @@ -472,10 +472,10 @@ describe('mobile session route extraction parity', () => { const contentBindings = CONTENT_COMPONENT_NAMES.flatMap( (name) => readHookFacts(name, definitions).bindings ) - expect(main.hooks).toHaveLength(269) + expect(main.hooks).toHaveLength(266) expect(hash(main.hooks)).toBe(HEAD_MAIN_HOOK_SHA256) expect(hash(main.bindings)).toBe(HEAD_HOOK_BINDING_SHA256) - expect(main.callbacks).toHaveLength(78) + expect(main.callbacks).toHaveLength(77) expect(hash(main.callbacks)).toBe(HEAD_CALLBACK_IDENTITY_SHA256) expect(hash(main.callbackBodies)).toBe(HEAD_CALLBACK_BODY_SHA256) expect(main.effects).toHaveLength(24) @@ -517,12 +517,12 @@ describe('mobile session route extraction parity', () => { it('preserves runtime strings, styles, and the expanded JSX tree', () => { const strings = readRuntimeStrings() - expect(strings).toHaveLength(537) + expect(strings).toHaveLength(546) expect(hash(strings)).toBe(HEAD_RUNTIME_STRING_SHA256) const jsx = readJsxFacts(readDefinitions()) expect(jsx.host).toHaveLength(124) expect(hash(jsx.host)).toBe(HEAD_HOST_JSX_SHA256) - expect(jsx.leaf).toHaveLength(59) + expect(jsx.leaf).toHaveLength(61) expect(hash(jsx.leaf)).toBe(HEAD_LEAF_JSX_SHA256) expect(jsx.styleReferences).toHaveLength(172) expect(hash(jsx.styleReferences)).toBe(HEAD_STYLE_REFERENCE_SHA256) diff --git a/mobile/src/session/mobile-session-route-types.ts b/mobile/src/session/mobile-session-route-types.ts index a61b653a0e3..36c90b0a29d 100644 --- a/mobile/src/session/mobile-session-route-types.ts +++ b/mobile/src/session/mobile-session-route-types.ts @@ -9,7 +9,7 @@ import type { TerminalRecord } from './mobile-terminal-records' export type Terminal = TerminalRecord -export type MobileSessionTabType = 'terminal' | 'markdown' | 'file' | 'browser' +export type MobileSessionTabType = 'terminal' | 'markdown' | 'file' | 'browser' | 'agent-session' export type MobileSessionTab = | { @@ -30,6 +30,14 @@ export type MobileSessionTab = terminalTheme?: MobileTerminalTheme isActive: boolean } + | { + type: 'agent-session' + id: string + title: string + sessionId: string + agent: 'codex' + isActive: boolean + } | { type: 'markdown' id: string diff --git a/mobile/src/session/mobile-session-startup-source.test.ts b/mobile/src/session/mobile-session-startup-source.test.ts index acab1d5e7c6..83b2021b95e 100644 --- a/mobile/src/session/mobile-session-startup-source.test.ts +++ b/mobile/src/session/mobile-session-startup-source.test.ts @@ -29,6 +29,14 @@ const tabReconciliationOwnerSource = readMobileSessionRouteSource( const autoCreateHookSource = readMobileSessionRouteSource( './use-initial-session-terminal-autocreate.ts' ) +const foundationSource = readMobileSessionRouteSource('./use-mobile-session-foundation.ts') +const terminalRuntimeSource = readMobileSessionRouteSource( + './use-mobile-session-terminal-runtime.ts' +) +const terminalSubscriptionSourceForIdentity = readMobileSessionRouteSource( + './use-mobile-session-terminal-subscription.ts' +) +const lifecycleSource = readMobileSessionRouteSource('./use-mobile-session-lifecycle.ts') function sliceBetween(startPattern: string, endPattern: string, targetSource = source): string { const start = targetSource.indexOf(startPattern) @@ -106,6 +114,19 @@ describe('mobile session startup', () => { expect(reconciliationHookSource).toContain('appStateSubscription.remove()') }) + it('binds terminal identity to the shared client before subscription effects run', () => { + expect(foundationSource).toContain('const { client, clientId, state: connState }') + expect(foundationSource).toContain(' clientId,') + expect(terminalRuntimeSource).toContain('useRef(clientId)') + expect(terminalRuntimeSource).toContain('deviceTokenRef.current = clientId') + expect(terminalRuntimeSource).toContain('inputGate.canSend && clientId !== null') + expect(terminalSubscriptionSourceForIdentity).toContain('if (clientId === null)') + expect(terminalSubscriptionSourceForIdentity).toContain( + "client: { id: clientId, type: 'mobile' as const }" + ) + expect(lifecycleSource).not.toContain('deviceTokenRef.current = host.deviceToken') + }) + it('confirms terminal stream teardown with a committed inventory-recovery bridge', () => { expect(terminalSubscriptionSource).toContain( "if (data.type === 'end' || data.type === 'error')" diff --git a/mobile/src/session/mobile-structured-agent-prompts.ts b/mobile/src/session/mobile-structured-agent-prompts.ts new file mode 100644 index 00000000000..84cb7033d30 --- /dev/null +++ b/mobile/src/session/mobile-structured-agent-prompts.ts @@ -0,0 +1,251 @@ +import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' +import type { MobileChatPermission } from './mobile-native-chat-permission' +import type { MobileChatQuestion } from './mobile-native-chat-question' + +export type StructuredApprovalItem = AgentJournalRenderItem & { + body: Extract +} + +export type StructuredQuestionItem = AgentJournalRenderItem & { + body: Extract +} + +export type StructuredPromptResponseTarget = { + itemId: string + expectedRevision: number + optionId: string +} + +type PromptTokenPayload = + | { + kind: 'approval' + itemId: string + revision: number + optionId: string + } + | { + kind: 'question-option' + itemId: string + revision: number + optionId: string + } + | { + kind: 'question-free-text' + itemId: string + revision: number + questionId: string + } + +const STRUCTURED_PROMPT_TOKEN_PREFIX = 'structured-agent-prompt:' + +export function pendingStructuredApproval( + item: AgentJournalRenderItem +): item is StructuredApprovalItem { + return item.body.kind === 'approval' && item.body.resolution.state === 'pending' +} + +export function pendingStructuredQuestion( + item: AgentJournalRenderItem +): item is StructuredQuestionItem { + return item.body.kind === 'question' && item.body.resolution.state === 'pending' +} + +function encodeQuestionAnswer(questionId: string, answer: string): string { + return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}` +} + +function encodePromptToken(payload: PromptTokenPayload): string { + return `${STRUCTURED_PROMPT_TOKEN_PREFIX}${encodeURIComponent(JSON.stringify(payload))}` +} + +function decodePromptToken(value: string): PromptTokenPayload | null { + if (!value.startsWith(STRUCTURED_PROMPT_TOKEN_PREFIX)) { + return null + } + try { + const decoded = JSON.parse( + decodeURIComponent(value.slice(STRUCTURED_PROMPT_TOKEN_PREFIX.length)) + ) as Record + if ( + typeof decoded.itemId !== 'string' || + typeof decoded.revision !== 'number' || + !Number.isFinite(decoded.revision) + ) { + return null + } + if (decoded.kind === 'approval' && typeof decoded.optionId === 'string') { + return { + kind: decoded.kind, + itemId: decoded.itemId, + revision: decoded.revision, + optionId: decoded.optionId + } + } + if (decoded.kind === 'question-option' && typeof decoded.optionId === 'string') { + return { + kind: decoded.kind, + itemId: decoded.itemId, + revision: decoded.revision, + optionId: decoded.optionId + } + } + if (decoded.kind === 'question-free-text' && typeof decoded.questionId === 'string') { + return { + kind: decoded.kind, + itemId: decoded.itemId, + revision: decoded.revision, + questionId: decoded.questionId + } + } + } catch { + return null + } + return null +} + +function decodeQuestionFreeTextAnswer(value: string): { + payload: Extract + answer: string +} | null { + if (!value.startsWith(STRUCTURED_PROMPT_TOKEN_PREFIX)) { + return null + } + const separator = value.indexOf(':', STRUCTURED_PROMPT_TOKEN_PREFIX.length) + if (separator === -1) { + return null + } + const payload = decodePromptToken(value.slice(0, separator)) + if (payload?.kind !== 'question-free-text') { + return null + } + return { payload, answer: decodeURIComponent(value.slice(separator + 1)) } +} + +export function projectStructuredPermission( + prompt: StructuredApprovalItem | null +): MobileChatPermission | null { + if (prompt?.body.kind !== 'approval') { + return null + } + return { + title: prompt.body.title, + ...(prompt.body.detail ? { detail: prompt.body.detail } : {}), + options: prompt.body.options.map((option) => ({ + label: option.label, + send: encodePromptToken({ + kind: 'approval', + itemId: prompt.itemId, + revision: prompt.revision, + optionId: option.id + }) + })) + } +} + +export function projectStructuredQuestion( + prompt: StructuredQuestionItem | null +): MobileChatQuestion | null { + if (prompt?.body.kind !== 'question') { + return null + } + return { + question: prompt.body.question, + options: prompt.body.options.map((option) => option.label), + multiSelect: false, + allowOther: Boolean(prompt.body.freeTextQuestionId), + optionTokens: prompt.body.options.map((option) => + encodePromptToken({ + kind: 'question-option', + itemId: prompt.itemId, + revision: prompt.revision, + optionId: option.id + }) + ), + ...(prompt.body.freeTextQuestionId + ? { + freeTextToken: encodePromptToken({ + kind: 'question-free-text', + itemId: prompt.itemId, + revision: prompt.revision, + questionId: prompt.body.freeTextQuestionId + }) + } + : {}) + } +} + +export function structuredApprovalResponseTarget( + response: string, + currentPrompt: StructuredApprovalItem | null +): StructuredPromptResponseTarget | null { + const token = decodePromptToken(response) + if (token?.kind === 'approval') { + return { + itemId: token.itemId, + expectedRevision: token.revision, + optionId: token.optionId + } + } + if (token) { + return null + } + const option = currentPrompt?.body.options.find( + (candidate) => candidate.id === response || candidate.label === response + ) + return currentPrompt && option + ? { + itemId: currentPrompt.itemId, + expectedRevision: currentPrompt.revision, + optionId: option.id + } + : null +} + +export function structuredQuestionResponseTarget( + response: string, + currentPrompt: StructuredQuestionItem | null +): StructuredPromptResponseTarget | null { + const token = decodePromptToken(response) + if (token?.kind === 'question-option') { + return { + itemId: token.itemId, + expectedRevision: token.revision, + optionId: token.optionId + } + } + if (token) { + return null + } + const freeText = decodeQuestionFreeTextAnswer(response) + if (freeText) { + const answer = freeText.answer.trim() + return answer.length > 0 + ? { + itemId: freeText.payload.itemId, + expectedRevision: freeText.payload.revision, + optionId: encodeQuestionAnswer(freeText.payload.questionId, answer) + } + : null + } + if (!currentPrompt) { + return null + } + const trimmed = response.trim() + const option = currentPrompt.body.options.find( + (candidate) => candidate.id === response || candidate.label === trimmed + ) + if (option) { + return { + itemId: currentPrompt.itemId, + expectedRevision: currentPrompt.revision, + optionId: option.id + } + } + return currentPrompt.body.freeTextQuestionId && trimmed + ? { + itemId: currentPrompt.itemId, + expectedRevision: currentPrompt.revision, + optionId: encodeQuestionAnswer(currentPrompt.body.freeTextQuestionId, trimmed) + } + : null +} diff --git a/mobile/src/session/mobile-structured-agent-session-launch.test.ts b/mobile/src/session/mobile-structured-agent-session-launch.test.ts new file mode 100644 index 00000000000..54f9b5cbe88 --- /dev/null +++ b/mobile/src/session/mobile-structured-agent-session-launch.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' +import { createMobileStructuredCodexSession } from './mobile-structured-agent-session-launch' + +function clientReturning( + ...responses: unknown[] +): RpcClient & { sendRequest: ReturnType } { + let responseIndex = 0 + const sendRequest = vi.fn(async () => responses[responseIndex++]) + return { sendRequest } as unknown as RpcClient & { sendRequest: ReturnType } +} + +const acceptedCreateResult = { + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 0 }, + value: { + sessionId: 'codex_session_1', + fence: 1, + page: { + sessionId: 'codex_session_1', + epoch: 'epoch-1', + direction: 'tail', + items: [], + removedItemIds: [], + submissions: [], + window: { oldest: null, newest: null, nextCursor: { epoch: 'epoch-1', sequence: 0 } }, + liveCursor: { epoch: 'epoch-1', sequence: 0 }, + hasOlder: false, + hasNewer: false + }, + unconfirmedClientMessageIds: [] + } +} +const acceptedCreate = { ok: true, result: acceptedCreateResult } + +describe('mobile structured Codex launch', () => { + it('creates through the structured agent-session intent after support is confirmed', async () => { + const client = clientReturning({ ok: true, result: { supported: true } }, acceptedCreate) + + await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toMatchObject({ + kind: 'created', + sessionId: expect.stringMatching(/^codex_[A-Za-z0-9_]{8,128}$/) + }) + expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'agentSession.createSupport', { + worktree: 'id:workspace-1', + agent: 'codex' + }) + expect(client.sendRequest).toHaveBeenNthCalledWith( + 2, + 'agentSession.create', + expect.objectContaining({ + worktree: 'id:workspace-1', + agent: 'codex', + envelope: expect.objectContaining({ expectedRuntimeFence: null }) + }), + expect.objectContaining({ budgetSpansConnect: true }) + ) + const params = client.sendRequest.mock.calls[1]?.[1] as { + envelope: { sessionId: string; payloadFingerprint: string } + worktree: string + agent: 'codex' + } + expect(params.envelope.payloadFingerprint).toMatch(/^[0-9a-f]{64}$/) + expect(params.envelope.sessionId).toMatch(/^codex_[A-Za-z0-9_]{8,128}$/) + }) + + it('reports unsupported without creating a terminal when the structured path is unavailable', async () => { + const client = clientReturning({ ok: true, result: { supported: false, reason: 'remote' } }) + + await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toEqual({ + kind: 'unsupported', + reason: 'remote' + }) + expect(client.sendRequest).toHaveBeenCalledTimes(1) + }) + + it('keeps an unknown create outcome distinct so callers do not create a duplicate terminal', async () => { + const client = clientReturning({ ok: true, result: { supported: true } }) + client.sendRequest.mockImplementationOnce(async () => ({ + ok: true, + result: { supported: true } + })) + client.sendRequest.mockRejectedValue(markRpcDeliveryUnknown(new Error('response lost'))) + + await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toMatchObject({ + kind: 'unknown' + }) + expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'agentSession.createSupport', + 'agentSession.create', + 'agentSession.create' + ]) + expect(client.sendRequest.mock.calls[1]?.[1]).toBe(client.sendRequest.mock.calls[2]?.[1]) + }) + + it('keeps the outcome unknown when the idempotent retry cannot be sent', async () => { + const client = clientReturning({ ok: true, result: { supported: true } }) + client.sendRequest.mockImplementationOnce(async () => ({ + ok: true, + result: { supported: true } + })) + client.sendRequest.mockRejectedValueOnce(markRpcDeliveryUnknown(new Error('response lost'))) + client.sendRequest.mockRejectedValueOnce(new Error('connection interrupted')) + + await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toMatchObject({ + kind: 'unknown' + }) + }) + + it('never creates a legacy sibling after an unclassified create exception', async () => { + const client = clientReturning({ ok: true, result: { supported: true } }) + client.sendRequest.mockImplementationOnce(async () => ({ + ok: true, + result: { supported: true } + })) + client.sendRequest.mockRejectedValue(new Error('internal error after commit')) + + await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toMatchObject({ + kind: 'unknown' + }) + expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'agentSession.createSupport', + 'agentSession.create', + 'agentSession.create' + ]) + expect(client.sendRequest.mock.calls[1]?.[1]).toBe(client.sendRequest.mock.calls[2]?.[1]) + }) + + it('treats malformed structured responses as unknown', async () => { + const client = clientReturning( + { ok: true, result: { supported: true } }, + { ok: true, result: { ok: true, value: { sessionId: '' } } } + ) + + await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toMatchObject({ + kind: 'unknown' + }) + }) +}) diff --git a/mobile/src/session/mobile-structured-agent-session-launch.ts b/mobile/src/session/mobile-structured-agent-session-launch.ts new file mode 100644 index 00000000000..ecad0410dfd --- /dev/null +++ b/mobile/src/session/mobile-structured-agent-session-launch.ts @@ -0,0 +1,158 @@ +import type { + AgentSessionAttachResult, + AgentSessionMutationResult +} from '../../../src/shared/agent-session-wire' +import { structuredAgentSessionPayloadFingerprint } from '../../../src/shared/structured-agent-session-mutation' +import type { RpcClient } from '../transport/rpc-client' +import { structuredSessionOperationId } from './mobile-structured-agent-session-rpc' + +type StructuredCreateSupport = { + supported?: boolean + reason?: 'agent' | 'remote' | 'wsl' +} + +export type MobileStructuredCodexLaunchResult = + | { kind: 'created'; sessionId: string } + | { kind: 'unsupported'; reason?: StructuredCreateSupport['reason'] } + | { kind: 'failed'; message: string } + | { kind: 'unknown'; message: string } + +type StructuredCreateParams = { + envelope: { + sessionId: string + clientOperationId: string + expectedRuntimeFence: null + payloadFingerprint: string + } + worktree: string + agent: 'codex' +} + +function createStructuredCodexSessionId(): string { + return `codex_${createRandomUuid().replaceAll('-', '_')}` +} + +function createRandomUuid(): string { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID() + } + return Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join('') +} + +function createStructuredCodexSessionParams(worktreeId: string): StructuredCreateParams { + const sessionId = createStructuredCodexSessionId() + const worktree = `id:${worktreeId}` + const fields = { worktree, agent: 'codex' as const } + return { + envelope: { + sessionId, + clientOperationId: structuredSessionOperationId(), + expectedRuntimeFence: null, + payloadFingerprint: structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId, + fields + }) + }, + ...fields + } +} + +function unknownCreateResult(error: unknown): MobileStructuredCodexLaunchResult { + const message = error instanceof Error ? error.message.trim() : '' + return { + kind: 'unknown', + message: message || 'The Codex chat result could not be confirmed.' + } +} + +export async function createMobileStructuredCodexSession( + client: RpcClient, + worktreeId: string +): Promise { + const worktree = `id:${worktreeId}` + let supportResponse + try { + supportResponse = await client.sendRequest('agentSession.createSupport', { + worktree, + agent: 'codex' + }) + } catch { + // A support probe has no side effect; an unavailable probe safely degrades to terminal chat. + return { kind: 'unsupported' } + } + if ( + !supportResponse || + typeof supportResponse !== 'object' || + typeof supportResponse.ok !== 'boolean' || + !supportResponse.ok + ) { + return { kind: 'unsupported' } + } + const support = supportResponse.result as StructuredCreateSupport | null + if (!support || typeof support !== 'object' || support.supported !== true) { + return { kind: 'unsupported', reason: support?.reason } + } + + const params = createStructuredCodexSessionParams(worktreeId) + let response + try { + response = await client.sendRequest('agentSession.create', params, { + timeoutMs: 15_000, + budgetSpansConnect: true + }) + } catch { + // Replay the durable envelope once so a lost acknowledgement cannot create a sibling. + try { + response = await client.sendRequest('agentSession.create', params, { + timeoutMs: 15_000, + budgetSpansConnect: true + }) + } catch (retryError) { + // A second transport error cannot disprove the first attempt committed. + return unknownCreateResult(retryError) + } + } + + if (!response || typeof response !== 'object' || typeof response.ok !== 'boolean') { + return unknownCreateResult(new Error('The Codex chat result could not be confirmed.')) + } + if (!response.ok) { + if ( + !response.error || + typeof response.error !== 'object' || + typeof response.error.code !== 'string' + ) { + return unknownCreateResult(new Error('The Codex chat result could not be confirmed.')) + } + if (response.error.code === 'agent_session_operation_unknown') { + return unknownCreateResult(new Error(response.error.message)) + } + return { kind: 'failed', message: response.error.message || 'Could not open Codex chat.' } + } + const result = response.result as AgentSessionMutationResult + if (!result || typeof result !== 'object' || typeof result.ok !== 'boolean') { + return unknownCreateResult(new Error('The Codex chat result could not be confirmed.')) + } + if (!result.ok) { + if ( + !result.refusal || + typeof result.refusal !== 'object' || + typeof result.refusal.code !== 'string' + ) { + return unknownCreateResult(new Error('The Codex chat result could not be confirmed.')) + } + if (result.refusal.code === 'agent_session_operation_unknown') { + return unknownCreateResult(new Error(result.refusal.message)) + } + return { kind: 'failed', message: result.refusal.message || 'Could not open Codex chat.' } + } + if ( + !result.value || + typeof result.value.sessionId !== 'string' || + !result.value.sessionId.trim() + ) { + return unknownCreateResult(new Error('The Codex chat result could not be confirmed.')) + } + return { kind: 'created', sessionId: result.value.sessionId } +} diff --git a/mobile/src/session/mobile-structured-agent-session-rpc.ts b/mobile/src/session/mobile-structured-agent-session-rpc.ts new file mode 100644 index 00000000000..a602122978e --- /dev/null +++ b/mobile/src/session/mobile-structured-agent-session-rpc.ts @@ -0,0 +1,152 @@ +import { + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, + parseAgentSessionOperationTimestamp +} from '../../../src/shared/agent-session-host-authority' +import type { AgentSessionMutationResult } from '../../../src/shared/agent-session-wire' +import { + createStructuredAgentSessionOperationId, + structuredAgentSessionPayloadFingerprint +} from '../../../src/shared/structured-agent-session-mutation' +import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' +import type { RpcClient } from '../transport/rpc-client' +import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' +import { MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS } from './mobile-native-chat-send' + +export const STRUCTURED_SEND_TIMEOUT_MS = 15_000 + +export type StructuredAgentSessionMutationCallResult = + | { status: 'accepted'; value: TValue } + | { status: 'refused'; message: string } + | { status: 'failed'; message: string } + | { status: 'unknown' } + +export type StructuredAgentSessionMutationResult = + | { status: 'accepted'; value: TValue; sameFence: boolean } + | { status: 'rejected' } + | { status: 'unknown' } + +export type StructuredAgentSessionMutate = ( + method: string, + fingerprintMethod: string, + fields: Record +) => Promise> + +export async function callAgentSession( + client: RpcClient, + method: string, + params: unknown, + timeoutMs = STRUCTURED_SEND_TIMEOUT_MS, + options?: { failWhenDisconnected?: boolean } +): Promise { + const response = await client.sendRequest(method, params, { + timeoutMs, + budgetSpansConnect: true, + ...(options?.failWhenDisconnected ? { failWhenDisconnected: true } : {}) + }) + if (!response.ok) { + throw new Error(response.error.message) + } + return response.result as TResult +} + +export function structuredSessionOperationId(): string { + const randomUuid = + typeof globalThis.crypto?.randomUUID === 'function' + ? () => globalThis.crypto.randomUUID() + : () => { + return Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join( + '' + ) + } + return createStructuredAgentSessionOperationId(randomUuid) +} + +/** + * Bounded by expiry, never by count: every retained id belongs to a send whose outcome is still + * unknown, so dropping one turns the user's retry into a second message on the host. Only an id + * the host would already refuse — unparseable, or past the window in which it can be admitted — + * is safe to release, which matches the host's own tombstone retention. + */ +export function retainStructuredSessionOperationId( + operationIds: Map, + key: string, + operationId = structuredSessionOperationId(), + now: number = Date.now() +): string { + operationIds.delete(key) + operationIds.set(key, operationId) + for (const [retainedKey, retainedId] of operationIds) { + if (retainedKey === key) { + continue + } + const timestamp = parseAgentSessionOperationTimestamp(retainedId) + if (timestamp === null || now - timestamp > AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS) { + operationIds.delete(retainedKey) + } + } + return operationId +} + +export function timeoutForDeadline(deadline: number | undefined): number | null { + if (deadline === undefined) { + return STRUCTURED_SEND_TIMEOUT_MS + } + const timeoutMs = deadline - Date.now() + return timeoutMs >= MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS ? timeoutMs : null +} + +export async function requestStructuredAgentSessionMutation(args: { + client: RpcClient + method: string + fingerprintMethod: string + sessionId: string + expectedRuntimeFence: number + fields: Record + clientOperationId?: string + retryUnknown?: boolean + timeoutMs?: number +}): Promise> { + const { + client, + method, + fingerprintMethod, + sessionId, + expectedRuntimeFence, + fields, + clientOperationId, + retryUnknown, + timeoutMs + } = args + try { + const result = await callAgentSession>( + client, + method, + { + envelope: { + sessionId, + clientOperationId: clientOperationId ?? structuredSessionOperationId(), + expectedRuntimeFence, + payloadFingerprint: structuredAgentSessionPayloadFingerprint({ + method: fingerprintMethod, + sessionId, + fields + }) + }, + ...(retryUnknown ? { retryUnknown: true } : {}), + ...fields + }, + timeoutMs + ) + return result.ok + ? { status: 'accepted', value: result.value } + : { status: 'refused', message: result.refusal.message } + } catch (error) { + if (isRpcDeliveryUnknown(error) || isLogicalClientCutoverError(error)) { + return { status: 'unknown' } + } + return { + status: 'failed', + message: error instanceof Error ? error.message : 'Request not sent' + } + } +} diff --git a/mobile/src/session/mobile-structured-session-operation-retention.test.ts b/mobile/src/session/mobile-structured-session-operation-retention.test.ts new file mode 100644 index 00000000000..209f28f65c9 --- /dev/null +++ b/mobile/src/session/mobile-structured-session-operation-retention.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../src/shared/agent-session-host-authority' +import { retainStructuredSessionOperationId } from './mobile-structured-agent-session-rpc' + +const NOW = 1_900_000_000_000 + +function operationIdAt(timestamp: number, entropy: string): string { + return `${timestamp}-${entropy.repeat(32).slice(0, 32)}` +} + +describe('structured session operation retention', () => { + it('keeps every unconfirmed operation id past the old 128-entry cap', () => { + const operationIds = new Map() + for (let index = 0; index < 400; index += 1) { + retainStructuredSessionOperationId( + operationIds, + `request-${index}`, + operationIdAt(NOW, 'a'), + NOW + ) + } + + expect(operationIds.size).toBe(400) + // Why: the first send is exactly the one a retry would duplicate if it were evicted. + expect(operationIds.get('request-0')).toBe(operationIdAt(NOW, 'a')) + }) + + it('releases only ids the host would already refuse as expired', () => { + const operationIds = new Map() + const expired = operationIdAt(NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS - 1, 'b') + const admissible = operationIdAt(NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, 'c') + retainStructuredSessionOperationId(operationIds, 'stale', expired, NOW) + retainStructuredSessionOperationId(operationIds, 'live', admissible, NOW) + + retainStructuredSessionOperationId(operationIds, 'fresh', operationIdAt(NOW, 'd'), NOW) + + expect(operationIds.has('stale')).toBe(false) + expect(operationIds.get('live')).toBe(admissible) + expect(operationIds.get('fresh')).toBe(operationIdAt(NOW, 'd')) + }) + + it('drops ids the host could never admit and re-keys a repeated send', () => { + const operationIds = new Map() + retainStructuredSessionOperationId(operationIds, 'unparseable', 'not-an-operation-id', NOW) + const reused = retainStructuredSessionOperationId( + operationIds, + 'send', + operationIdAt(NOW, 'e'), + NOW + ) + + // A retry of the same send reuses the retained id rather than minting a duplicate. + expect( + retainStructuredSessionOperationId(operationIds, 'send', operationIds.get('send'), NOW) + ).toBe(reused) + expect(operationIds.has('unparseable')).toBe(false) + }) +}) diff --git a/mobile/src/session/mobile-terminal-records.test.ts b/mobile/src/session/mobile-terminal-records.test.ts index e4ce55a84aa..bcc9510d0e8 100644 --- a/mobile/src/session/mobile-terminal-records.test.ts +++ b/mobile/src/session/mobile-terminal-records.test.ts @@ -182,6 +182,20 @@ describe('mobile terminal records', () => { ).toBe(false) }) + it('treats structured agent-session identity changes as session-tab changes', () => { + const base = { + type: 'agent-session' as const, + id: 'agent-tab-1', + title: 'Codex', + sessionId: 'session-1', + agent: 'codex', + isActive: true + } + + expect(mobileSessionTabsEqual([base], [{ ...base }])).toBe(true) + expect(mobileSessionTabsEqual([base], [{ ...base, sessionId: 'session-2' }])).toBe(false) + }) + const record = (over: Partial & { handle: string }): TerminalRecord => ({ title: 'Terminal', terminalTheme: undefined, diff --git a/mobile/src/session/mobile-terminal-records.ts b/mobile/src/session/mobile-terminal-records.ts index 09426863b99..f03a31acf41 100644 --- a/mobile/src/session/mobile-terminal-records.ts +++ b/mobile/src/session/mobile-terminal-records.ts @@ -62,6 +62,14 @@ type MobileSessionTabLike = canGoForward?: boolean isActive?: boolean } + | { + type: 'agent-session' + id: string + title?: string + sessionId?: string + agent?: string + isActive?: boolean + } export function mobileTerminalThemesEqual( left: MobileTerminalTheme | null | undefined, @@ -152,6 +160,8 @@ function mobileSessionTabEqual( a.canGoBack === b.canGoBack && a.canGoForward === b.canGoForward ) + case 'agent-session': + return b.type === 'agent-session' && a.sessionId === b.sessionId && a.agent === b.agent } } diff --git a/mobile/src/session/mobile-terminal-tab-agent.test.ts b/mobile/src/session/mobile-terminal-tab-agent.test.ts index 5177981f0e6..6ad034335ad 100644 --- a/mobile/src/session/mobile-terminal-tab-agent.test.ts +++ b/mobile/src/session/mobile-terminal-tab-agent.test.ts @@ -120,4 +120,17 @@ describe('getMobileSessionTabTitle', () => { expect(getMobileSessionTabTitle(blankBrowserTab)).toBe('New Browser') }) + + it('labels structured agent-session tabs without terminal decoration rules', () => { + expect( + getMobileSessionTabTitle({ + type: 'agent-session', + id: 'agent-tab-1', + title: 'Codex Chat', + sessionId: 'session-1', + agent: 'codex', + isActive: true + }) + ).toBe('Codex Chat') + }) }) diff --git a/mobile/src/session/mobile-terminal-tab-agent.ts b/mobile/src/session/mobile-terminal-tab-agent.ts index 20326d41c98..dfc7be812e8 100644 --- a/mobile/src/session/mobile-terminal-tab-agent.ts +++ b/mobile/src/session/mobile-terminal-tab-agent.ts @@ -62,6 +62,9 @@ export function getMobileSessionTabTitle(tab: MobileSessionTab): string { if (tab.type === 'file') { return tab.title || 'File' } + if (tab.type === 'agent-session') { + return tab.title || 'Chat' + } // Why: strip the leading agent status glyph (✳ etc.) once the tab shows the // provider icon. Mobile falls back for glyph-only titles because iOS can // render the bare status glyph as a stray colored box beside the icon. diff --git a/mobile/src/session/opened-mobile-session-tab.test.ts b/mobile/src/session/opened-mobile-session-tab.test.ts index 988a3ea313f..dbeed5ed830 100644 --- a/mobile/src/session/opened-mobile-session-tab.test.ts +++ b/mobile/src/session/opened-mobile-session-tab.test.ts @@ -378,4 +378,19 @@ describe('shouldActivateOpenedMobileSessionTab', () => { }) ).toBe(false) }) + + it('allows a structured agent-session tab to anchor chat file activation', () => { + expect( + shouldActivateOpenedMobileSessionTab({ + activated: false, + activationSeq: 2, + latestActivationSeq: 2, + sourceTerminalHandle: null, + activeTerminalHandle: null, + sourceSessionTabId: 'agent-tab-1', + activeSessionTabId: 'agent-tab-1', + activeTabType: 'agent-session' + }) + ).toBe(true) + }) }) diff --git a/mobile/src/session/opened-mobile-session-tab.ts b/mobile/src/session/opened-mobile-session-tab.ts index 17c8cff9848..f76571eceef 100644 --- a/mobile/src/session/opened-mobile-session-tab.ts +++ b/mobile/src/session/opened-mobile-session-tab.ts @@ -9,8 +9,10 @@ export type OpenedMobileSessionTabActivationState = { activated: boolean activationSeq: number latestActivationSeq: number - sourceTerminalHandle: string + sourceTerminalHandle: string | null activeTerminalHandle: string | null + sourceSessionTabId?: string | null + activeSessionTabId?: string | null activeTabType: string | null } @@ -114,12 +116,15 @@ export async function activateOpenedSourceControlDiffTab( diff --git a/mobile/src/session/use-mobile-file-tap-handlers.test.ts b/mobile/src/session/use-mobile-file-tap-handlers.test.ts index 21f07562459..6d0adbbf8df 100644 --- a/mobile/src/session/use-mobile-file-tap-handlers.test.ts +++ b/mobile/src/session/use-mobile-file-tap-handlers.test.ts @@ -142,4 +142,31 @@ describe('useMobileFileTapHandlers', () => { ) expect(options.reportChatTapFailure).toHaveBeenCalledWith("Couldn't open mobile/src/x.ts:12") }) + + it('lets structured chat file taps resolve without a backing terminal handle', async () => { + const sendRequest = vi.fn(async () => ok({ exists: false, isDirectory: false })) + const options = { + ...createOptions(sendRequest), + activeHandleRef: { current: null as string | null }, + getActiveSessionTabId: () => 'agent-tab-1', + getActiveSessionTabType: () => 'agent-session' + } + act(() => { + renderer = create(createElement(Harness, { options })) + }) + + handlers!.handleNativeChatFileTap('src/app.ts') + await act(async () => {}) + + expect(sendRequest).toHaveBeenCalledWith( + 'files.resolveTerminalPath', + { + worktree: 'id:wt-1', + pathText: 'src/app.ts', + crossWorkspace: true, + nativeChatContext: { tabId: 'agent-tab-1', sessionId: 'session-1' } + }, + { timeoutMs: 10_000 } + ) + }) }) diff --git a/mobile/src/session/use-mobile-file-tap-handlers.ts b/mobile/src/session/use-mobile-file-tap-handlers.ts index 67995115f45..5bfe71f839b 100644 --- a/mobile/src/session/use-mobile-file-tap-handlers.ts +++ b/mobile/src/session/use-mobile-file-tap-handlers.ts @@ -141,15 +141,13 @@ export function useMobileFileTapHandlers( const handleNativeChatFileTap = useCallback((pathText: string) => { const current = optionsRef.current - // The chat overlay rides on its backing terminal tab; that handle anchors - // the activation gate even though resolution ignores the terminal's cwd. const sourceTerminalHandle = current.activeHandleRef.current - if (!current.client || !sourceTerminalHandle) { + const nativeChatSessionId = current.nativeChatSessionId + const nativeChatTabId = current.getActiveSessionTabId() + if (!current.client || (!sourceTerminalHandle && !(nativeChatSessionId && nativeChatTabId))) { return } const activationSeq = ++activationSeqRef.current - const nativeChatSessionId = current.nativeChatSessionId - const nativeChatTabId = current.getActiveSessionTabId() openMobileNativeChatFileTap({ client: current.client, hostId: current.hostId, @@ -172,6 +170,8 @@ export function useMobileFileTapHandlers( latestActivationSeq: activationSeqRef.current, sourceTerminalHandle, activeTerminalHandle: current.activeHandleRef.current, + sourceSessionTabId: nativeChatTabId, + activeSessionTabId: current.getActiveSessionTabId(), activeTabType: current.getActiveSessionTabType() }), switchSessionTab: current.switchSessionTab, diff --git a/mobile/src/session/use-mobile-native-chat-active-resolution.ts b/mobile/src/session/use-mobile-native-chat-active-resolution.ts new file mode 100644 index 00000000000..ea7f923de47 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-active-resolution.ts @@ -0,0 +1,83 @@ +import { useLayoutEffect, useRef, type MutableRefObject } from 'react' +import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' +import { resolveMobileNativeChat, type MobileNativeChatTab } from './mobile-native-chat-eligibility' +import { useMobileSessionViewMode } from './use-mobile-session-view-mode' + +export function useMobileNativeChatActiveResolution(args: { + hostId: string + worktreeId: string + activeSessionTab: MobileNativeChatTab | null + activeSessionTabId: string | null + activeHandleRef: MutableRefObject + nativeChatTranscriptIsLocalReadable: boolean +}): { + isTabChatView: (tabId: string) => boolean + toggleTabChatView: (tabId: string) => void + showNativeChat: boolean + showNativeChatRef: MutableRefObject + activeChatAgent: string | null + activeChatAgentRef: MutableRefObject + activeChatSessionId: string | null + activeChatStructured: boolean + activeChatResolution: ReturnType + activeTabAgentWorking: boolean + nativeChatStatus: MobileNativeChatTab['agentStatus'] | null + sourceIdentity: string + streamIdentity: string + streamScopeKey: string +} { + const { + activeHandleRef, + activeSessionTab, + activeSessionTabId, + hostId, + nativeChatTranscriptIsLocalReadable, + worktreeId + } = args + const { isTabChatView, toggleTabChatView } = useMobileSessionViewMode({ hostId, worktreeId }) + const tabWantsChat = + activeSessionTab?.type === 'agent-session' || + (activeSessionTabId ? isTabChatView(activeSessionTabId) : false) + const activeChatResolution = + activeSessionTab && activeSessionTabId && tabWantsChat + ? resolveMobileNativeChat(activeSessionTab, nativeChatTranscriptIsLocalReadable) + : null + const showNativeChat = activeChatResolution != null + const showNativeChatRef = useRef(showNativeChat) + const activeChatAgent = activeChatResolution?.agent ?? null + const activeChatAgentRef = useRef(activeChatAgent) + + useLayoutEffect(() => { + showNativeChatRef.current = showNativeChat + activeChatAgentRef.current = activeChatAgent + }, [activeChatAgent, showNativeChat]) + + const activeChatSessionId = activeChatResolution?.sessionId ?? null + const activeChatStructured = + activeChatResolution != null && activeSessionTab?.type === 'agent-session' + const activeTabStatus = activeSessionTab?.agentStatus + const activeTabAgentWorking = + activeTabStatus?.state === 'working' && activeTabStatus.workingMode !== 'monitoring' + const nativeChatStatus = activeChatResolution && !activeChatStructured ? activeTabStatus : null + const routeKey = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}` + const streamIdentity = `${routeKey}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}` + const providerSessionId = activeSessionTab?.agentStatus?.providerSession?.id ?? '' + const streamScopeKey = `${routeKey}\0${activeChatSessionId ?? providerSessionId}\0${activeHandleRef.current ?? ''}` + + return { + isTabChatView, + toggleTabChatView, + showNativeChat, + showNativeChatRef, + activeChatAgent, + activeChatAgentRef, + activeChatSessionId, + activeChatStructured, + activeChatResolution, + activeTabAgentWorking, + nativeChatStatus, + sourceIdentity: encodeNativeChatTranscriptIdentity([hostId, worktreeId]), + streamIdentity, + streamScopeKey + } +} diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index 40937adc0e0..83f8075d914 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -1,6 +1,7 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SessionOptionDescriptor } from '../../../src/shared/native-chat-session-options' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' @@ -14,6 +15,55 @@ const holdUnconfirmedSend = vi.fn() // and transcript state; defaults keep the send-seam tests unchanged. const viewMode = { isTabChatView: (_tabId: string) => true } const sessionState = { messages: [] as unknown[], status: 'ready', transcriptLoading: false } +const structuredSendWithOutcome = vi.fn() +const structuredCancel = vi.fn() +const structuredRespondPermission = vi.fn(async () => true) +const structuredRespondQuestion = vi.fn(async () => true) +const structuredSetOption = vi.fn(async () => true) +const structuredInvokeOption = vi.fn(async () => true) +const structuredOptionSnapshot: SessionOptionDescriptor[] = [ + { + id: 'model', + label: 'Model', + category: 'model', + kind: { + type: 'select', + currentValue: 'gpt-fast', + choices: [{ value: 'gpt-fast', label: 'GPT Fast' }] + }, + valueSource: 'reported', + settable: true + } +] +const structuredOptionSurface = { + getSnapshot: () => structuredOptionSnapshot, + setOption: async () => ({ snapshot: structuredOptionSnapshot }), + invokeAction: async () => ({ snapshot: structuredOptionSnapshot }), + subscribe: () => () => {} +} +const structuredPermission = { + title: 'Allow Bash?', + detail: 'rm -rf build', + options: [ + { label: 'Allow once', send: 'allow-once' }, + { label: 'Deny', send: 'deny' } + ] +} +const structuredQuestion = { + question: 'Pick destination', + options: ['Choice A', 'Choice B'], + allowOther: true, + optionTokens: ['choice-a', 'choice-b'] +} +const structuredSessionState = { + messages: [] as unknown[], + status: 'ready', + transcriptLoading: false, + error: undefined, + hasMore: false, + loadingEarlier: false, + loadEarlier: vi.fn() +} const draftsArgs: Record[] = [] const promptsState = { permission: null as unknown, @@ -33,6 +83,24 @@ vi.mock('./use-mobile-session-view-mode', () => ({ vi.mock('./use-mobile-native-chat-session', () => ({ useMobileNativeChatSession: () => sessionState })) +vi.mock('./use-mobile-structured-agent-session', () => ({ + useMobileStructuredAgentSession: () => ({ + session: structuredSessionState, + isWorking: false, + turnId: null, + sendWithOutcome: structuredSendWithOutcome, + cancel: structuredCancel, + permission: structuredPermission, + question: structuredQuestion, + optionSnapshot: structuredOptionSnapshot, + optionSurface: structuredOptionSurface, + pendingOptionId: 'model', + respondPermission: structuredRespondPermission, + respondQuestion: structuredRespondQuestion, + setStructuredOption: structuredSetOption, + invokeStructuredOption: structuredInvokeOption + }) +})) vi.mock('./use-mobile-native-chat-drafts', () => ({ useMobileNativeChatDrafts: (args: Record) => { draftsArgs.push(args) @@ -110,18 +178,28 @@ describe('useMobileNativeChatController handleNativeChatSend', () => { // itself is mocked above). const clientStub = { sendRequest: vi.fn() } - function Harness({ connState = 'connected' }: { connState?: ConnectionState }): null { + function Harness({ + connState = 'connected', + tab = null, + activeHandle = 'term-1', + inputLeaseReady = true + }: { + connState?: ConnectionState + tab?: unknown + activeHandle?: string | null + inputLeaseReady?: boolean + }): null { controller = useMobileNativeChatController({ client: clientStub as unknown as RpcClient, connState, hostId: 'h', worktreeId: 'w', - activeSessionTab: null, - activeSessionTabId: 'tab-1', - activeHandleRef: { current: 'term-1' }, + activeSessionTab: tab as never, + activeSessionTabId: (tab as { id?: string } | null)?.id ?? 'tab-1', + activeHandleRef: { current: activeHandle }, deviceTokenRef: { current: null }, nativeChatTranscriptIsLocalReadable: true, - nativeChatInputLeaseReady: true, + nativeChatInputLeaseReady: inputLeaseReady, onSendError, onSendResolved }) @@ -138,6 +216,7 @@ describe('useMobileNativeChatController handleNativeChatSend', () => { }) resetMobileNativeChatStaleInputForTests() captureSendOrigin.mockReturnValue(ORIGIN) + structuredSendWithOutcome.mockResolvedValue('accepted') act(() => { renderer = create(createElement(Harness)) }) @@ -233,6 +312,80 @@ describe('useMobileNativeChatController handleNativeChatSend', () => { expect(restoreRejectedDraft).not.toHaveBeenCalled() }) + it('routes structured agent-session sends away from terminal/nativeChat transports', async () => { + await act(async () => { + renderer?.update( + createElement(Harness, { + tab: { + type: 'agent-session', + id: 'agent-tab-1', + title: 'Codex Chat', + sessionId: 'session-structured', + agent: 'codex', + isActive: true + }, + activeHandle: null, + inputLeaseReady: false + }) + ) + }) + + let accepted = false + await act(async () => { + accepted = await controller!.handleNativeChatSend('look') + }) + + expect(accepted).toBe(true) + expect(structuredSendWithOutcome).toHaveBeenCalledWith('look') + expect(sendWithOutcome).not.toHaveBeenCalled() + expect(clientStub.sendRequest).not.toHaveBeenCalled() + }) + + it('exposes structured prompt cards and session options on structured tabs', async () => { + await act(async () => { + renderer?.update( + createElement(Harness, { + tab: { + type: 'agent-session', + id: 'agent-tab-1', + title: 'Codex Chat', + sessionId: 'session-structured', + agent: 'codex', + isActive: true + }, + activeHandle: null, + inputLeaseReady: false + }) + ) + }) + + expect(controller!.nativeChatPermission).toEqual(structuredPermission) + expect(controller!.nativeChatQuestion).toEqual(structuredQuestion) + expect(controller!.nativeChatSessionOptions).not.toBeNull() + expect(controller!.nativeChatSessionOptions?.controller.snapshot).toEqual( + structuredOptionSnapshot + ) + + await act(async () => { + expect(await controller!.handleNativeChatRespondPermission('allow-once')).toBe(true) + }) + expect(structuredRespondPermission).toHaveBeenCalledWith('allow-once') + expect(sendWithOutcome).not.toHaveBeenCalled() + + await act(async () => { + expect(await controller!.handleNativeChatQuestionAnswer('choice-a')).toBe(true) + }) + expect(structuredRespondQuestion).toHaveBeenCalledWith('choice-a') + expect(clientStub.sendRequest).not.toHaveBeenCalled() + + await act(async () => { + expect( + await controller!.nativeChatSessionOptions!.controller.setOption('model', 'gpt-fast') + ).toBe(true) + }) + expect(structuredSetOption).toHaveBeenCalledWith('model', 'gpt-fast') + }) + it('pre-clears separately for a text-only send but never for an image send', async () => { // The image path pastes the image behind its OWN leading Ctrl+U and then calls // this send; a second clear here wipes the image off the input line and the diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 109dea93ec3..bf944398e87 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -1,9 +1,7 @@ -import { useCallback, useLayoutEffect, useRef, type MutableRefObject } from 'react' -import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' -import { useMobileSessionViewMode } from './use-mobile-session-view-mode' +import { useLayoutEffect, useRef, type MutableRefObject } from 'react' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' -import { type MobileNativeChatTab, resolveMobileNativeChat } from './mobile-native-chat-eligibility' +import type { MobileNativeChatTab } from './mobile-native-chat-eligibility' import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send' import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send' import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss' @@ -11,15 +9,17 @@ import { useMobileNativeChatCancelAsk } from './use-mobile-native-chat-cancel-as import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search' import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send' -import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key' import { mobileNativeChatStreamPreview } from './mobile-native-chat-streaming-gate' import { useMobileNativeChatSession } from './use-mobile-native-chat-session' -import { useMobileNativeChatSessionOptions } from './use-mobile-native-chat-session-options' +import { useMobileNativeChatSessionOptionController } from './use-mobile-native-chat-session-option-controller' +import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' +import { useMobileStructuredNativeChatSendBridge } from './use-mobile-structured-native-chat-send-bridge' import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts' import { useMobileNativeChatStop } from './use-mobile-native-chat-stop' import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes' import { useThrottledLatestValue } from './use-throttled-latest-value' import type { MobileNativeChatController } from './mobile-native-chat-controller-contract' +import { useMobileNativeChatActiveResolution } from './use-mobile-native-chat-active-resolution' export type { MobileNativeChatController } from './mobile-native-chat-controller-contract' @@ -58,36 +58,51 @@ export function useMobileNativeChatController(args: { onSendError, onSendResolved } = args - const { isTabChatView, toggleTabChatView } = useMobileSessionViewMode({ hostId, worktreeId }) - - const activeChatResolution = - activeSessionTab && activeSessionTabId && isTabChatView(activeSessionTabId) - ? resolveMobileNativeChat(activeSessionTab, nativeChatTranscriptIsLocalReadable) - : null - const showNativeChat = activeChatResolution != null - const showNativeChatRef = useRef(showNativeChat) - const activeChatAgent = activeChatResolution?.agent ?? null - const activeChatAgentRef = useRef(activeChatAgent) - useLayoutEffect(() => { - showNativeChatRef.current = showNativeChat - activeChatAgentRef.current = activeChatAgent - }, [activeChatAgent, showNativeChat]) - - const activeChatSessionId = activeChatResolution?.sessionId ?? null - const routeKey = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}` - const streamIdentity = `${routeKey}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}` - // Same chat, but keyed off the tab rather than the view-gated resolution: - // `streamIdentity` goes session-less the moment the user peeks at the terminal, - // and a scope that flips on a view toggle throws the gate's baseline away. - const streamScopeKey = `${routeKey}\0${activeSessionTab?.agentStatus?.providerSession?.id ?? ''}\0${activeHandleRef.current ?? ''}` - - const nativeChatSession = useMobileNativeChatSession({ - client, - sourceIdentity: encodeNativeChatTranscriptIdentity([hostId, worktreeId]), - agent: activeChatResolution?.agent ?? null, - sessionId: activeChatSessionId, - transcriptPath: activeChatResolution?.transcriptPath ?? null + const { + activeChatAgent, + activeChatAgentRef, + activeChatResolution, + activeChatSessionId, + activeChatStructured, + activeTabAgentWorking, + isTabChatView, + nativeChatStatus, + showNativeChat, + showNativeChatRef, + sourceIdentity, + streamIdentity, + streamScopeKey, + toggleTabChatView + } = useMobileNativeChatActiveResolution({ + hostId, + worktreeId, + activeSessionTab, + activeSessionTabId, + activeHandleRef, + nativeChatTranscriptIsLocalReadable }) + + const legacyNativeChatSession = useMobileNativeChatSession({ + client, + sourceIdentity, + agent: activeChatStructured ? null : (activeChatResolution?.agent ?? null), + sessionId: activeChatStructured ? null : activeChatSessionId, + transcriptPath: activeChatStructured ? null : (activeChatResolution?.transcriptPath ?? null) + }) + const structuredNativeChat = useMobileStructuredAgentSession({ + client, + sessionId: activeChatStructured ? activeChatSessionId : null, + sourceIdentity, + enabled: showNativeChat, + // Holds are connection-scoped; dropping this on transport loss lets the hook + // reacquire the provider without clearing the cached transcript. + connected: connState === 'connected', + agent: activeChatStructured ? activeChatAgent : null, + onSendError + }) + const nativeChatSession = activeChatStructured + ? structuredNativeChat.session + : legacyNativeChatSession const { composerText: chatComposerText, setComposerText: setChatComposerText, @@ -117,27 +132,29 @@ export function useMobileNativeChatController(args: { transcriptSettled: nativeChatSession.status === 'ready' }) - const activeTabStatus = activeSessionTab?.agentStatus - const activeTabAgentWorking = - activeTabStatus?.state === 'working' && activeTabStatus.workingMode !== 'monitoring' - const nativeChatStatus = activeChatResolution ? activeTabStatus : null - const nativeChatAgentWorking = activeChatResolution != null && activeTabAgentWorking + const nativeChatAgentWorking = activeChatStructured + ? structuredNativeChat.isWorking + : activeChatResolution != null && activeTabAgentWorking // Deliberately not gated on the chat view being visible: the streaming gate // has to tell "hidden mid-turn" from "the turn ended". - const nativeChatStreamLive = activeTabAgentWorking + const nativeChatStreamLive = activeChatStructured + ? structuredNativeChat.isWorking + : activeTabAgentWorking // Throttle the streaming bubble: OpenCode emits a status frame per streamed // part, and each one re-renders and re-parses the whole accumulated markdown. const nativeChatStreamingText = useThrottledLatestValue( - mobileNativeChatStreamPreview(nativeChatStatus, nativeChatAgentWorking), + activeChatStructured + ? undefined + : mobileNativeChatStreamPreview(nativeChatStatus, nativeChatAgentWorking), NATIVE_CHAT_STREAM_THROTTLE_MS ) const { - permission: nativeChatPermission, - question: nativeChatQuestion, + permission: legacyNativeChatPermission, + question: legacyNativeChatQuestion, detectedAsk: nativeChatDetectedAsk, ask: nativeChatAskPrompt } = useMobileNativeChatPrompts({ - enabled: activeChatResolution != null, + enabled: activeChatResolution != null && !activeChatStructured, status: nativeChatStatus, messages: nativeChatSession.messages, transcriptLoading: nativeChatSession.transcriptLoading @@ -146,8 +163,6 @@ export function useMobileNativeChatController(args: { const nativeChatTranscriptSettled = nativeChatSession.status === 'ready' || (nativeChatSession.status === 'error' && nativeChatSession.messages.length > 0) - const nativeChatAskObservable = - showNativeChat && (nativeChatDetectedAsk != null || nativeChatTranscriptSettled) const { askKey: nativeChatAskKey, showAsk: showNativeChatAsk, @@ -157,17 +172,19 @@ export function useMobileNativeChatController(args: { detectedAsk: nativeChatDetectedAsk, scopeKey: activeSessionTabId, sessionKey: activeChatSessionId, - observing: nativeChatAskObservable + observing: showNativeChat && (nativeChatDetectedAsk != null || nativeChatTranscriptSettled) }) // Every chat write gates on both: the lease proves the input floor is ours, and // `connState` collapses a render before the lease does on disconnect. - const inputSendable = nativeChatInputLeaseReady && connState === 'connected' + const inputSendable = activeChatStructured + ? client != null && activeChatSessionId != null && connState === 'connected' + : nativeChatInputLeaseReady && connState === 'connected' const { answerAsk: handleNativeChatAnswerAsk, cancelPending: cancelNativeChatAnswer } = useMobileNativeChatAnswerSend({ client, - enabled: inputSendable, + enabled: inputSendable && !activeChatStructured, handleRef: activeHandleRef, deviceTokenRef, agentRef: activeChatAgentRef, @@ -178,16 +195,16 @@ export function useMobileNativeChatController(args: { const handleNativeChatCancelAsk = useMobileNativeChatCancelAsk({ client, - enabled: inputSendable, + enabled: inputSendable && !activeChatStructured, handleRef: activeHandleRef, deviceTokenRef, cancelPending: cancelNativeChatAnswer, onSendError }) - const handleNativeChatRespondPermission = useMobileNativeChatPermissionSend({ + const legacyHandleNativeChatRespondPermission = useMobileNativeChatPermissionSend({ client, - enabled: inputSendable, + enabled: inputSendable && !activeChatStructured, handleRef: activeHandleRef, deviceTokenRef, onSendError @@ -195,7 +212,7 @@ export function useMobileNativeChatController(args: { const handleNativeChatStop = useMobileNativeChatStop({ client, - enabled: inputSendable, + enabled: inputSendable && !activeChatStructured, handleRef: activeHandleRef, deviceTokenRef, streamIdentity, @@ -216,11 +233,11 @@ export function useMobileNativeChatController(args: { const { send: handleNativeChatSend, sendWithOutcome: handleNativeChatSendWithOutcome, - answerQuestion: handleNativeChatQuestionAnswer, + answerQuestion: legacyHandleNativeChatQuestionAnswer, dispatchCommand: handleNativeChatDispatchCommand } = useMobileNativeChatMessageSend({ client, - enabled: inputSendable, + enabled: inputSendable && !activeChatStructured, handleRef: activeHandleRef, deviceTokenRef, agentRef: activeChatAgentRef, @@ -234,26 +251,44 @@ export function useMobileNativeChatController(args: { onSendError }) - // Bring the terminal view forward when an agent-owned picker command is used. - const handleAgentPicker = useCallback(() => { - if (activeSessionTabId && isTabChatView(activeSessionTabId)) { - toggleTabChatView(activeSessionTabId) - } - }, [activeSessionTabId, isTabChatView, toggleTabChatView]) - - const sessionOptions = useMobileNativeChatSessionOptions({ - agent: activeChatResolution?.agent ?? null, - scopeKey: mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId), - reportedModel: activeSessionTab?.agentStatus?.model ?? null, - dispatchCommand: handleNativeChatDispatchCommand, - onAgentPicker: handleAgentPicker + const structuredNativeChatSend = useMobileStructuredNativeChatSendBridge({ + sendStructured: structuredNativeChat.sendWithOutcome, + captureSendOrigin, + clearDraftForSend, + acceptSend, + holdUnconfirmedSend, + restoreRejectedDraft, + onSendError }) + + const { nativeChatSessionOptions, recordCommand: recordNativeChatSessionOptionCommand } = + useMobileNativeChatSessionOptionController({ + activeChatStructured, + activeSessionTabId, + agent: activeChatResolution?.agent ?? null, + dispatchCommand: handleNativeChatDispatchCommand, + hostId, + isTabChatView, + isWorking: nativeChatAgentWorking, + reportedModel: activeSessionTab?.agentStatus?.model ?? null, + structured: { + snapshot: structuredNativeChat.optionSnapshot, + pendingId: structuredNativeChat.pendingOptionId, + setOption: structuredNativeChat.setStructuredOption, + invokeAction: structuredNativeChat.invokeStructuredOption + }, + toggleTabChatView, + worktreeId + }) useLayoutEffect(() => { - recordSessionOptionCommandRef.current = sessionOptions.recordCommand - }, [sessionOptions.recordCommand]) + recordSessionOptionCommandRef.current = recordNativeChatSessionOptionCommand + }, [recordNativeChatSessionOptionCommand]) // Card actions retire the route's held failure banner too, not just sends. const answerAsk = useNativeChatAcceptedAction(handleNativeChatAnswerAsk, onSendResolved) const cancelAsk = useNativeChatAcceptedAction(handleNativeChatCancelAsk, onSendResolved) + const handleNativeChatRespondPermission = activeChatStructured + ? structuredNativeChat.respondPermission + : legacyHandleNativeChatRespondPermission const respond = useNativeChatAcceptedAction(handleNativeChatRespondPermission, onSendResolved) return { @@ -272,24 +307,31 @@ export function useMobileNativeChatController(args: { nativeChatStreamingText, nativeChatStreamLive, nativeChatStreamScopeKey: streamScopeKey, - nativeChatPermission, - nativeChatQuestion, - nativeChatAsk: showNativeChatAsk ? nativeChatAskPrompt : null, + nativeChatPermission: activeChatStructured + ? structuredNativeChat.permission + : legacyNativeChatPermission, + nativeChatQuestion: activeChatStructured + ? structuredNativeChat.question + : legacyNativeChatQuestion, + nativeChatAsk: !activeChatStructured && showNativeChatAsk ? nativeChatAskPrompt : null, nativeChatAskKey, dismissNativeChatAsk, handleNativeChatAnswerAsk: answerAsk, handleNativeChatCancelAsk: cancelAsk, handleNativeChatRespondPermission: respond, - handleNativeChatStop, + handleNativeChatStop: activeChatStructured ? structuredNativeChat.cancel : handleNativeChatStop, nativeChatFilePaths, loadNativeChatFiles, - handleNativeChatQuestionAnswer, - handleNativeChatSend, - handleNativeChatSendWithOutcome, + handleNativeChatQuestionAnswer: activeChatStructured + ? structuredNativeChat.respondQuestion + : legacyHandleNativeChatQuestionAnswer, + handleNativeChatSend: activeChatStructured + ? structuredNativeChatSend.send + : handleNativeChatSend, + handleNativeChatSendWithOutcome: activeChatStructured + ? structuredNativeChatSend.sendWithOutcome + : handleNativeChatSendWithOutcome, readSeededLaunchDraft, - nativeChatSessionOptions: - sessionOptions.snapshot.length > 0 - ? { controller: sessionOptions, isWorking: nativeChatAgentWorking } - : null + nativeChatSessionOptions } } diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.ts index dbcb97df527..77d839adf34 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.ts @@ -1,18 +1,17 @@ import { useCallback, useRef, useState } from 'react' -import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../src/shared/clipboard-image' import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' -import { - ImageLibraryPermissionError, - pickMobileImages, - type MobileImageSource -} from './mobile-image-source-picker' +import type { MobileImageSource } from './mobile-image-source-picker' import { appendPendingNativeChatImages, - uploadMobileNativeChatImages, type PendingNativeChatImage } from './mobile-native-chat-image-attachment' +import { + NO_NATIVE_CHAT_IMAGE_ATTACHMENTS, + withScopeAttachments, + type MobileNativeChatImagesByScope +} from './mobile-native-chat-image-scope-state' import { MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS, pasteMobileNativeChatImagePaths @@ -31,6 +30,7 @@ import { acquireMobileNativeChatTerminalWrite, releaseMobileNativeChatTerminalWrite } from './mobile-native-chat-terminal-write-lock' +import { useMobileNativeChatImageUpload } from './use-mobile-native-chat-image-upload' type CurrentRef = { readonly current: T } type ShowToast = (message: string, durationMs?: number) => void @@ -60,8 +60,11 @@ type Args = { readonly baseSend: ( text: string, imagePreviewUris?: string[], - deadline?: number + deadline?: number, + attachments?: readonly PendingNativeChatImage[] ) => Promise + /** Structured sessions send attachments without the terminal paste path. */ + readonly structuredNativeChat: boolean /** Launch-context text parked on the agent's TUI input line, or null. The * paste's leading clear must cover every line of it, or the draft's earlier * lines survive and ride along with the image. */ @@ -83,21 +86,6 @@ export type MobileNativeChatImageAttachments = { readonly sendNativeChat: (text: string) => Promise } -const NO_ATTACHMENTS: PendingNativeChatImage[] = [] - -function withScopeAttachments( - byScope: Record, - scope: string, - next: PendingNativeChatImage[] -): Record { - if (next.length > 0) { - return { ...byScope, [scope]: next } - } - const remaining = { ...byScope } - delete remaining[scope] - return remaining -} - const defaultSleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) @@ -112,98 +100,40 @@ export function useMobileNativeChatImageAttachments({ showToast, onSendError, baseSend, + structuredNativeChat, readSeededLaunchDraft, onAttachSuccess, onError, sleep = defaultSleep }: Args): MobileNativeChatImageAttachments { - const [attachmentsByScope, setAttachmentsByScope] = useState< - Record - >({}) - const [isAttaching, setIsAttaching] = useState(false) + const [attachmentsByScope, setAttachmentsByScope] = useState({}) const idCounter = useRef(0) - // Count in-flight uploads so an overlapping attach can't clear the flag early. - const attachingCount = useRef(0) - // Live connState for attachImage's catch: the closure's value was already - // checked 'connected' at entry, so only a ref can see a mid-upload disconnect. - const connStateRef = useRef(connState) - connStateRef.current = connState + const attachments = + (scopeKey ? attachmentsByScope[scopeKey] : undefined) ?? NO_NATIVE_CHAT_IMAGE_ATTACHMENTS - const attachments = (scopeKey ? attachmentsByScope[scopeKey] : undefined) ?? NO_ATTACHMENTS - - const attachImage = useCallback( - async (source: MobileImageSource): Promise => { - // The chip lands in the scope that initiated the pick, even if the user - // switches tabs while the upload is in flight. - const scope = scopeKey - if (!client || !scope || !activeHandleRef.current || connState !== 'connected') { - return - } - // Only this call's own increment may be undone in `finally`; a cancelled - // pick or pre-upload error never ran `onUploadStart`, so decrementing the - // shared counter would clear a concurrent upload's in-flight flag early. - let started = false - const uploadedImages: Omit[] = [] - let uploadError: unknown = null - try { - await uploadMobileNativeChatImages(source, { - client, - getConnectionId: getActiveWorktreeConnectionId, - pickImages: pickMobileImages, - onImageUploaded: (image) => uploadedImages.push(image), - onUploadStart: () => { - started = true - attachingCount.current += 1 - setIsAttaching(true) - } - }) - } catch (error) { - uploadError = error - } finally { - if (started) { - attachingCount.current -= 1 - if (attachingCount.current === 0) { - setIsAttaching(false) - } - } - } - if (uploadedImages.length > 0) { - setAttachmentsByScope((prev) => ({ - ...prev, - [scope]: appendPendingNativeChatImages(prev[scope] ?? [], uploadedImages, idCounter) - })) - onAttachSuccess?.() - } - if (uploadError !== null) { - const message = uploadError instanceof Error ? uploadError.message : String(uploadError) - onError?.() - if (connStateRef.current !== 'connected') { - showToast('Attach failed (disconnected)', 1500) - return - } - if (uploadError instanceof ImageLibraryPermissionError) { - showToast('Photo permission denied', 1500) - return - } - if (message === CLIPBOARD_IMAGE_TOO_LARGE_ERROR) { - showToast('Image too large to attach', 1500) - return - } - showToast('Attach failed', 1500) - } + const addUploadedImages = useCallback( + (scope: string, uploadedImages: Omit[]) => { + setAttachmentsByScope((prev) => ({ + ...prev, + [scope]: appendPendingNativeChatImages(prev[scope] ?? [], uploadedImages, idCounter) + })) }, - [ - activeHandleRef, - client, - connState, - getActiveWorktreeConnectionId, - onAttachSuccess, - onError, - scopeKey, - showToast - ] + [] ) + const { attachImage, isAttaching } = useMobileNativeChatImageUpload({ + client, + activeHandleRef, + getActiveWorktreeConnectionId, + connState, + scopeKey, + structuredNativeChat, + showToast, + onImagesUploaded: addUploadedImages, + onAttachSuccess, + onError + }) + const removeAttachment = useCallback( (id: string): void => { const scope = scopeKey @@ -238,7 +168,32 @@ export function useMobileNativeChatImageAttachments({ const deadline = openMobileNativeChatSendBudget() try { const scope = scopeKey - const pendingImages = (scope ? attachmentsByScope[scope] : undefined) ?? NO_ATTACHMENTS + const pendingImages = + (scope ? attachmentsByScope[scope] : undefined) ?? NO_NATIVE_CHAT_IMAGE_ATTACHMENTS + if (structuredNativeChat && pendingImages.length > 0 && scope) { + if (!client || !enabled || connState !== 'connected') { + onError?.() + onSendError('Message not sent (disconnected)') + return false + } + const outcome = await baseSend( + text, + pendingImages.map((attachment) => attachment.previewUri), + deadline, + pendingImages + ) + if (outcome !== 'rejected') { + const sentIds = new Set(pendingImages.map((attachment) => attachment.id)) + setAttachmentsByScope((prev) => + withScopeAttachments( + prev, + scope, + (prev[scope] ?? []).filter((attachment) => !sentIds.has(attachment.id)) + ) + ) + } + return outcome !== 'rejected' + } if (pendingImages.length === 0 || !scope) { // Heal a previously failed paste: a text-only send to that terminal would // otherwise glue the stale image paste onto this message. Best-effort — diff --git a/mobile/src/session/use-mobile-native-chat-image-upload.ts b/mobile/src/session/use-mobile-native-chat-image-upload.ts new file mode 100644 index 00000000000..01567b5c727 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-image-upload.ts @@ -0,0 +1,126 @@ +import { useCallback, useLayoutEffect, useRef, useState } from 'react' +import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../src/shared/clipboard-image' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' +import { + ImageLibraryPermissionError, + pickMobileImages, + type MobileImageSource +} from './mobile-image-source-picker' +import { + uploadMobileNativeChatImages, + type PendingNativeChatImage +} from './mobile-native-chat-image-attachment' + +type CurrentRef = { readonly current: T } +type UploadedNativeChatImage = Omit +type ShowToast = (message: string, durationMs?: number) => void + +export function useMobileNativeChatImageUpload(args: { + client: RpcClient | null + activeHandleRef: CurrentRef + getActiveWorktreeConnectionId: () => Promise + connState: ConnectionState + scopeKey: string | null + structuredNativeChat: boolean + showToast: ShowToast + onImagesUploaded: (scope: string, images: UploadedNativeChatImage[]) => void + onAttachSuccess?: () => void + onError?: () => void +}): { + attachImage: (source: MobileImageSource) => Promise + isAttaching: boolean +} { + const { + activeHandleRef, + client, + connState, + getActiveWorktreeConnectionId, + onAttachSuccess, + onError, + onImagesUploaded, + scopeKey, + showToast, + structuredNativeChat + } = args + const [isAttaching, setIsAttaching] = useState(false) + const attachingCount = useRef(0) + const connStateRef = useRef(connState) + useLayoutEffect(() => { + connStateRef.current = connState + }, [connState]) + + const attachImage = useCallback( + async (source: MobileImageSource): Promise => { + const scope = scopeKey + if ( + !client || + !scope || + connState !== 'connected' || + (!activeHandleRef.current && !structuredNativeChat) + ) { + return + } + let started = false + const uploadedImages: UploadedNativeChatImage[] = [] + let uploadError: unknown = null + try { + await uploadMobileNativeChatImages(source, { + client, + getConnectionId: getActiveWorktreeConnectionId, + pickImages: pickMobileImages, + onImageUploaded: (image) => uploadedImages.push(image), + onUploadStart: () => { + started = true + attachingCount.current += 1 + setIsAttaching(true) + } + }) + } catch (error) { + uploadError = error + } finally { + if (started) { + attachingCount.current -= 1 + if (attachingCount.current === 0) { + setIsAttaching(false) + } + } + } + if (uploadedImages.length > 0) { + onImagesUploaded(scope, uploadedImages) + onAttachSuccess?.() + } + if (uploadError !== null) { + const message = uploadError instanceof Error ? uploadError.message : String(uploadError) + onError?.() + if (connStateRef.current !== 'connected') { + showToast('Attach failed (disconnected)', 1500) + return + } + if (uploadError instanceof ImageLibraryPermissionError) { + showToast('Photo permission denied', 1500) + return + } + if (message === CLIPBOARD_IMAGE_TOO_LARGE_ERROR) { + showToast('Image too large to attach', 1500) + return + } + showToast('Attach failed', 1500) + } + }, + [ + activeHandleRef, + client, + connState, + getActiveWorktreeConnectionId, + onAttachSuccess, + onError, + onImagesUploaded, + scopeKey, + showToast, + structuredNativeChat + ] + ) + + return { attachImage, isAttaching } +} diff --git a/mobile/src/session/use-mobile-native-chat-session-option-controller.ts b/mobile/src/session/use-mobile-native-chat-session-option-controller.ts new file mode 100644 index 00000000000..aa61bdd85ff --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-session-option-controller.ts @@ -0,0 +1,100 @@ +import { useCallback, useMemo } from 'react' +import type { + SessionOptionDescriptor, + SessionOptionValue +} from '../../../src/shared/native-chat-session-options' +import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key' +import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers' +import { + useMobileNativeChatSessionOptions, + type MobileNativeChatSessionOptionsController +} from './use-mobile-native-chat-session-options' + +export function useMobileNativeChatSessionOptionController(args: { + activeChatStructured: boolean + activeSessionTabId: string | null + agent: string | null + dispatchCommand: (text: string) => Promise + hostId: string + isTabChatView: (tabId: string) => boolean + isWorking: boolean + reportedModel: string | null + structured: { + snapshot: SessionOptionDescriptor[] + pendingId: string | null + setOption: (id: string, value: SessionOptionValue) => Promise + invokeAction: (id: string) => Promise + } + toggleTabChatView: (tabId: string) => void + worktreeId: string +}): { + nativeChatSessionOptions: MobileNativeChatSessionOptionPickersProps | null + recordCommand: (command: string) => void +} { + const { + activeChatStructured, + activeSessionTabId, + agent, + dispatchCommand, + hostId, + isTabChatView, + isWorking, + reportedModel, + structured, + toggleTabChatView, + worktreeId + } = args + const { + invokeAction: invokeStructuredAction, + pendingId: structuredPendingId, + setOption: setStructuredOption, + snapshot: structuredSnapshot + } = structured + + const handleAgentPicker = useCallback(() => { + if (activeSessionTabId && isTabChatView(activeSessionTabId)) { + toggleTabChatView(activeSessionTabId) + } + }, [activeSessionTabId, isTabChatView, toggleTabChatView]) + + const sessionOptions = useMobileNativeChatSessionOptions({ + agent: activeChatStructured ? null : agent, + scopeKey: mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId), + reportedModel, + dispatchCommand, + onAgentPicker: handleAgentPicker + }) + const structuredController = useMemo( + () => + activeChatStructured && structuredSnapshot.length > 0 + ? { + snapshot: structuredSnapshot, + pendingId: structuredPendingId, + setOption: setStructuredOption, + invokeAction: invokeStructuredAction, + recordCommand: () => {} + } + : null, + [ + activeChatStructured, + invokeStructuredAction, + setStructuredOption, + structuredPendingId, + structuredSnapshot + ] + ) + const nativeChatSessionOptions = useMemo( + () => + activeChatStructured + ? structuredController + ? { controller: structuredController, isWorking } + : null + : sessionOptions.snapshot.length > 0 + ? { controller: sessionOptions, isWorking } + : null, + [activeChatStructured, isWorking, sessionOptions, structuredController] + ) + + return { nativeChatSessionOptions, recordCommand: sessionOptions.recordCommand } +} diff --git a/mobile/src/session/use-mobile-session-attachments.ts b/mobile/src/session/use-mobile-session-attachments.ts index 66691545118..841d3984b8f 100644 --- a/mobile/src/session/use-mobile-session-attachments.ts +++ b/mobile/src/session/use-mobile-session-attachments.ts @@ -36,7 +36,8 @@ export function useMobileSessionAttachments(scope: MobileSessionAccessorySelecti nativeChatInputLeaseReady, nativeChatController, getActiveWorktreeConnectionId, - refreshCanPaste + refreshCanPaste, + activeSessionTab } = scope const handlePaste = useMobileTerminalPaste({ client, @@ -80,6 +81,7 @@ export function useMobileSessionAttachments(scope: MobileSessionAccessorySelecti getActiveWorktreeConnectionId, beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend, nativeChatBaseSend: nativeChatController.handleNativeChatSendWithOutcome, + structuredNativeChat: activeSessionTab?.type === 'agent-session', readSeededLaunchDraft: nativeChatController.readSeededLaunchDraft, showToast, onNativeChatSendError: nativeChatSendError.show, diff --git a/mobile/src/session/use-mobile-session-file-actions.ts b/mobile/src/session/use-mobile-session-file-actions.ts index 7ba21770a21..56aa2b049d8 100644 --- a/mobile/src/session/use-mobile-session-file-actions.ts +++ b/mobile/src/session/use-mobile-session-file-actions.ts @@ -1,6 +1,7 @@ import { useRef, useCallback } from 'react' import { Linking } from 'react-native' import { useMobileFileTapHandlers } from './use-mobile-file-tap-handlers' +import { resolveMobileNativeChatFileSessionId } from './mobile-native-chat-eligibility' import { activateOpenedSourceControlDiffTab } from './opened-mobile-session-tab' import type { MobileSessionTab } from './mobile-session-route-types' import type { MobileSessionTerminalSendActionsModel } from './use-mobile-session-terminal-send-actions' @@ -31,10 +32,7 @@ export function useMobileSessionFileActions(scope: MobileSessionTerminalSendActi hostId, worktreeId, worktreeName: routeWorktreeName, - nativeChatSessionId: - activeSessionTab?.type === 'terminal' - ? (activeSessionTab.agentStatus?.providerSession?.id ?? null) - : null, + nativeChatSessionId: resolveMobileNativeChatFileSessionId(activeSessionTab), activeHandleRef, terminalCwdRef, openBrowser: (url) => void handleCreateBrowserRef.current?.(url), diff --git a/mobile/src/session/use-mobile-session-foundation.ts b/mobile/src/session/use-mobile-session-foundation.ts index 9a7889e10cb..fa2f9607bbc 100644 --- a/mobile/src/session/use-mobile-session-foundation.ts +++ b/mobile/src/session/use-mobile-session-foundation.ts @@ -35,7 +35,7 @@ export function useMobileSessionFoundation() { const router = useRouter() const insets = useSafeAreaInsets() // Why: shared client per host owned by RpcClientProvider (docs/mobile-shared-client-per-host.md). - const { client, state: connState } = useHostClient(hostId) + const { client, clientId, state: connState } = useHostClient(hostId) const reconnectAttempts = useReconnectAttempt(hostId) const lastConnectedAt = useLastConnectedAt(hostId) const forceReconnectHost = useForceReconnect() @@ -96,6 +96,7 @@ export function useMobileSessionFoundation() { router, insets, client, + clientId, connState, reconnectAttempts, lastConnectedAt, diff --git a/mobile/src/session/use-mobile-session-image-attachments.test.tsx b/mobile/src/session/use-mobile-session-image-attachments.test.tsx new file mode 100644 index 00000000000..68aeafaea6f --- /dev/null +++ b/mobile/src/session/use-mobile-session-image-attachments.test.tsx @@ -0,0 +1,123 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { useMobileSessionImageAttachments } from './use-mobile-session-image-attachments' + +const mocks = vi.hoisted(() => ({ + useMobileImageAttachment: vi.fn(), + useMobileNativeChatImageAttachments: vi.fn() +})) + +vi.mock('./use-mobile-image-attachment', () => ({ + useMobileImageAttachment: mocks.useMobileImageAttachment +})) + +vi.mock('./use-mobile-native-chat-image-attachments', () => ({ + useMobileNativeChatImageAttachments: mocks.useMobileNativeChatImageAttachments +})) + +type HookArgs = Parameters[0] + +function baseArgs(overrides: Partial = {}): HookArgs { + return { + client: {} as RpcClient, + activeHandle: 'term-1', + activeHandleRef: { current: null }, + canSend: true, + connState: 'connected', + deviceTokenRef: { current: null }, + nativeChatScopeKey: 'scope-1', + nativeChatInputLeaseReady: false, + getActiveWorktreeConnectionId: async () => 'conn-1', + beforeTerminalSend: async () => true, + nativeChatBaseSend: vi.fn().mockResolvedValue('accepted'), + structuredNativeChat: true, + readSeededLaunchDraft: () => null, + showToast: vi.fn(), + onNativeChatSendError: vi.fn(), + onSuccess: vi.fn(), + onError: vi.fn(), + ...overrides + } +} + +describe('useMobileSessionImageAttachments', () => { + let renderer: ReactTestRenderer | null = null + + function Harness({ args }: { args: HookArgs }): null { + useMobileSessionImageAttachments(args) + return null + } + + beforeEach(() => { + mocks.useMobileImageAttachment.mockReturnValue({ + attachImage: vi.fn(), + isAttaching: false + }) + mocks.useMobileNativeChatImageAttachments.mockReturnValue({ + attachments: [], + isAttaching: false, + attachImage: vi.fn(), + removeAttachment: vi.fn(), + sendNativeChat: vi.fn() + }) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.clearAllMocks() + }) + + function render(args: HookArgs): void { + act(() => { + renderer = create(createElement(Harness, { args })) + }) + } + + it('enables native-chat image sends for connected structured sessions without a terminal lease', () => { + render(baseArgs()) + + expect(mocks.useMobileNativeChatImageAttachments).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: true, + structuredNativeChat: true + }) + ) + }) + + it('keeps terminal-backed native-chat image sends gated on the input lease', () => { + render( + baseArgs({ + activeHandleRef: { current: 'term-1' }, + nativeChatInputLeaseReady: false, + structuredNativeChat: false + }) + ) + + expect(mocks.useMobileNativeChatImageAttachments).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: false, + structuredNativeChat: false + }) + ) + }) + + it('disables structured native-chat image sends while disconnected', () => { + render( + baseArgs({ + connState: 'connecting', + nativeChatInputLeaseReady: true, + structuredNativeChat: true + }) + ) + + expect(mocks.useMobileNativeChatImageAttachments).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: false, + structuredNativeChat: true + }) + ) + }) +}) diff --git a/mobile/src/session/use-mobile-session-image-attachments.ts b/mobile/src/session/use-mobile-session-image-attachments.ts index 9b5a010df9f..07edac51f39 100644 --- a/mobile/src/session/use-mobile-session-image-attachments.ts +++ b/mobile/src/session/use-mobile-session-image-attachments.ts @@ -29,8 +29,15 @@ type Args = { readonly nativeChatBaseSend: ( text: string, images?: string[], - deadline?: number + deadline?: number, + attachments?: readonly { + id: string + path: string + previewUri: string + }[] ) => Promise + /** Structured agent sessions do not have a terminal paste path. */ + readonly structuredNativeChat: boolean /** Launch-context text parked on the agent's TUI input line, or null — sizes * the image paste's leading clear so a multi-line draft cannot ride along. */ readonly readSeededLaunchDraft: () => string | null @@ -57,6 +64,7 @@ export function useMobileSessionImageAttachments({ getActiveWorktreeConnectionId, beforeTerminalSend, nativeChatBaseSend, + structuredNativeChat, readSeededLaunchDraft, showToast, onNativeChatSendError, @@ -86,7 +94,8 @@ export function useMobileSessionImageAttachments({ getActiveWorktreeConnectionId, connState, scopeKey: nativeChatScopeKey, - enabled: nativeChatInputLeaseReady, + enabled: structuredNativeChat ? connState === 'connected' : nativeChatInputLeaseReady, + structuredNativeChat, showToast, onSendError: onNativeChatSendError, baseSend: nativeChatBaseSend, diff --git a/mobile/src/session/use-mobile-session-lifecycle.ts b/mobile/src/session/use-mobile-session-lifecycle.ts index 912ebdb4921..7c58e84a3e5 100644 --- a/mobile/src/session/use-mobile-session-lifecycle.ts +++ b/mobile/src/session/use-mobile-session-lifecycle.ts @@ -16,7 +16,6 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM connState, setCustomKeys, setVisibleBuiltInIds, - deviceTokenRef, setHostEndpoint, connStateRef, terminalRefs, @@ -26,7 +25,7 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM unsubscribeTerminal, subscribeToTerminal } = scope - // Why: read deviceToken from host record so code can pass client.id on subscribe/send for driver-state-machine identity. + // Why: the shared client owns authenticated identity; this host read only supplies connection-hint metadata. useEffect(() => { if (!hostId) { return @@ -38,7 +37,6 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM } const host = hosts.find((h) => h.id === hostId) if (host) { - deviceTokenRef.current = host.deviceToken setHostEndpoint(host.endpoint) } }) diff --git a/mobile/src/session/use-mobile-session-native-chat-dictation.ts b/mobile/src/session/use-mobile-session-native-chat-dictation.ts index 7942233dbfd..6046cba1059 100644 --- a/mobile/src/session/use-mobile-session-native-chat-dictation.ts +++ b/mobile/src/session/use-mobile-session-native-chat-dictation.ts @@ -77,6 +77,12 @@ export function useMobileSessionNativeChatDictation( }) const { toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController nativeChatSendError.bannerMountedRef.current = showNativeChat + const nativeChatOverlayInputLockReason = + activeSessionTab?.type === 'agent-session' + ? connState === 'connected' + ? null + : 'disconnected' + : nativeChatInputLockReason const routeKey = nativeChatScopeKey ?? `${hostId}\0${worktreeId}` const getSendCompletionGeneration = useMobileSendCompletionGeneration({ onBlur: resetLiveInputFocus, @@ -211,6 +217,7 @@ export function useMobileSessionNativeChatDictation( nativeChatInputLeaseReady, nativeChatInputLeaseReadyRef, nativeChatInputLockReason, + nativeChatOverlayInputLockReason, markNativeChatInputLeaseReady, clearNativeChatInputLease, nativeChatController, diff --git a/mobile/src/session/use-mobile-session-screen-state.ts b/mobile/src/session/use-mobile-session-screen-state.ts index 107ac3181de..6e2f82124b3 100644 --- a/mobile/src/session/use-mobile-session-screen-state.ts +++ b/mobile/src/session/use-mobile-session-screen-state.ts @@ -23,6 +23,7 @@ import type { MobileSessionTab, Terminal } from './mobile-session-route-types' +import { useMobileSessionTabActionTargets } from './use-mobile-session-tab-action-targets' import type { MobileSessionFoundationModel } from './use-mobile-session-foundation' export function useMobileSessionScreenState(scope: MobileSessionFoundationModel) { @@ -90,19 +91,7 @@ export function useMobileSessionScreenState(scope: MobileSessionFoundationModel) const [createTabAgentOptions, setCreateTabAgentOptions] = useState([]) const [showCreateBrowserModal, setShowCreateBrowserModal] = useState(false) const [showHeaderMoreActions, setShowHeaderMoreActions] = useState(false) - const [actionTarget, setActionTarget] = useState(null) - const [markdownActionTarget, setMarkdownActionTarget] = useState | null>(null) - const [fileActionTarget, setFileActionTarget] = useState | null>(null) - const [browserActionTarget, setBrowserActionTarget] = useState | null>(null) + const sessionTabActionTargets = useMobileSessionTabActionTargets() const [discardMarkdownTarget, setDiscardMarkdownTarget] = useState +type FileTab = Extract +type BrowserTab = Extract +type AgentSessionTab = Extract +type SetActionTarget = Dispatch> + +export function useMobileSessionTabActionTargets() { + const [actionTarget, setActionTarget] = useState(null) + const [markdownActionTarget, setMarkdownActionTarget] = useState(null) + const [fileActionTarget, setFileActionTarget] = useState(null) + const [browserActionTarget, setBrowserActionTarget] = useState(null) + const [agentSessionActionTarget, setAgentSessionActionTarget] = useState( + null + ) + + return { + actionTarget, + agentSessionActionTarget, + browserActionTarget, + fileActionTarget, + markdownActionTarget, + setActionTarget, + setAgentSessionActionTarget, + setBrowserActionTarget, + setFileActionTarget, + setMarkdownActionTarget + } +} + +export function useMobileSessionTabActionSheetOpener(args: { + activeHandleRef: MutableRefObject + setActionTarget: SetActionTarget + setMarkdownActionTarget: SetActionTarget + setFileActionTarget: SetActionTarget + setBrowserActionTarget: SetActionTarget + setAgentSessionActionTarget: SetActionTarget +}): (tab: MobileSessionTab) => void { + const { + activeHandleRef, + setActionTarget, + setAgentSessionActionTarget, + setBrowserActionTarget, + setFileActionTarget, + setMarkdownActionTarget + } = args + return useCallback( + (tab: MobileSessionTab) => { + if (tab.type === 'terminal') { + if (typeof tab.terminal !== 'string') { + return + } + setActionTarget({ + handle: tab.terminal, + title: tab.title, + isActive: tab.terminal === activeHandleRef.current + }) + } else if (tab.type === 'markdown') { + setMarkdownActionTarget(tab) + } else if (tab.type === 'file') { + setFileActionTarget(tab) + } else if (tab.type === 'agent-session') { + setAgentSessionActionTarget(tab) + } else { + setBrowserActionTarget(tab) + } + }, + [ + activeHandleRef, + setActionTarget, + setAgentSessionActionTarget, + setBrowserActionTarget, + setFileActionTarget, + setMarkdownActionTarget + ] + ) +} diff --git a/mobile/src/session/use-mobile-session-tab-switching.ts b/mobile/src/session/use-mobile-session-tab-switching.ts index 21095b613dd..48c48c2f994 100644 --- a/mobile/src/session/use-mobile-session-tab-switching.ts +++ b/mobile/src/session/use-mobile-session-tab-switching.ts @@ -136,6 +136,9 @@ export function useMobileSessionTabSwitching(scope: MobileSessionKeyboardStateMo void readFileTab(tab) return } + if (tab.type === 'agent-session') { + return + } const cached = markdownDocs.get(tab.id) if (cached?.status === 'ready' && cached.isDirty) { return diff --git a/mobile/src/session/use-mobile-session-terminal-create-actions.test.ts b/mobile/src/session/use-mobile-session-terminal-create-actions.test.ts new file mode 100644 index 00000000000..c0a4e8368c5 --- /dev/null +++ b/mobile/src/session/use-mobile-session-terminal-create-actions.test.ts @@ -0,0 +1,231 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' +import { useMobileSessionTerminalCreateActions } from './use-mobile-session-terminal-create-actions' + +vi.mock('../platform/haptics', () => ({ + triggerSuccess: vi.fn(), + triggerError: vi.fn() +})) + +function clientReturning(...responses: unknown[]): RpcClient { + let responseIndex = 0 + return { + sendRequest: vi.fn(async () => responses[responseIndex++]) + } as unknown as RpcClient +} + +function terminalCreateResponse() { + return { + ok: true, + result: { + tab: { + type: 'terminal', + id: 'terminal-tab-1', + title: 'Codex', + terminal: 'terminal-1', + isActive: true + } + } + } +} + +function createScope(client: RpcClient) { + return { + worktreeId: 'workspace-1', + client, + connState: 'connected', + setTerminals: vi.fn(), + terminalsRef: { current: [] }, + setSessionTabs: vi.fn(), + defaultTerminalHandlesToLiveInput: vi.fn(), + setActiveHandle: vi.fn(), + activeSessionTabId: 'existing-tab', + activeSessionTabIdRef: { current: 'existing-tab' }, + setActiveSessionTabId: vi.fn(), + setCreating: vi.fn(), + creatingTerminalRef: { current: false }, + creatingBrowser: false, + creatingMarkdown: false, + setCreateError: vi.fn(), + deviceTokenRef: { current: null }, + initializedHandlesRef: { current: new Set() }, + activeHandleRef: { current: 'existing-terminal' }, + activeSessionTabTypeRef: { current: 'terminal' }, + pendingActiveSessionTabIdRef: { current: null }, + pendingActiveTerminalHandleRef: { current: null }, + scheduleDelayedAction: vi.fn(), + showToast: vi.fn(), + unsubscribeTerminal: vi.fn(), + subscribeToTerminal: vi.fn(), + fetchSessionTabs: vi.fn(async () => {}) + } +} + +describe('mobile + Codex tab creation routing', () => { + let renderer: ReactTestRenderer | undefined + afterEach(() => renderer?.unmount()) + + it('uses the structured agent-session path for a bare Codex launch', async () => { + const client = clientReturning( + { ok: true, result: { supported: true } }, + { + ok: true, + result: { + ok: true, + value: { sessionId: 'codex_session_1' } + } + } + ) + const scope = createScope(client) + let actions: ReturnType | undefined + function Harness() { + actions = useMobileSessionTerminalCreateActions(scope as never) + return null + } + await act(async () => { + renderer = create(createElement(Harness)) + }) + await act(async () => { + await actions?.handleCreateTerminal('codex') + }) + + expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'agentSession.createSupport', { + worktree: 'id:workspace-1', + agent: 'codex' + }) + expect(client.sendRequest).toHaveBeenNthCalledWith( + 2, + 'agentSession.create', + expect.objectContaining({ worktree: 'id:workspace-1', agent: 'codex' }), + expect.anything() + ) + expect(client.sendRequest).not.toHaveBeenCalledWith( + 'session.tabs.createTerminal', + expect.anything() + ) + expect(scope.setActiveSessionTabId).toHaveBeenCalledWith('agent-session:codex_session_1') + expect(scope.setActiveHandle).toHaveBeenCalledWith(null) + expect(scope.unsubscribeTerminal).toHaveBeenCalledWith('existing-terminal') + }) + + it('keeps the legacy terminal path when structured support is disabled', async () => { + const client = clientReturning( + { ok: false, error: { code: 'structured_agent_session_unsupported', message: 'off' } }, + terminalCreateResponse() + ) + const scope = createScope(client) + let actions: ReturnType | undefined + function Harness() { + actions = useMobileSessionTerminalCreateActions(scope as never) + return null + } + await act(async () => { + renderer = create(createElement(Harness)) + }) + await act(async () => { + await actions?.handleCreateTerminal('codex') + }) + + expect(client.sendRequest).toHaveBeenNthCalledWith( + 2, + 'session.tabs.createTerminal', + expect.objectContaining({ worktree: 'id:workspace-1', agent: 'codex' }) + ) + expect(scope.setActiveSessionTabId).toHaveBeenCalledWith('terminal-tab-1') + }) + + it('falls back to a terminal when structured creation is refused', async () => { + const client = clientReturning( + { ok: true, result: { supported: true } }, + { + ok: true, + result: { + ok: false, + refusal: { code: 'agent_session_ownership_unknown', message: 'provider unavailable' } + } + }, + terminalCreateResponse() + ) + const scope = createScope(client) + let actions: ReturnType | undefined + function Harness() { + actions = useMobileSessionTerminalCreateActions(scope as never) + return null + } + await act(async () => { + renderer = create(createElement(Harness)) + }) + await act(async () => { + await actions?.handleCreateTerminal('codex') + }) + + expect(client.sendRequest).toHaveBeenNthCalledWith( + 3, + 'session.tabs.createTerminal', + expect.objectContaining({ worktree: 'id:workspace-1', agent: 'codex' }) + ) + expect(scope.setActiveSessionTabId).toHaveBeenCalledWith('terminal-tab-1') + }) + + it('keeps prompted Codex launches on the legacy terminal path', async () => { + const client = clientReturning(terminalCreateResponse(), { + ok: true, + result: { send: { accepted: true } } + }) + const scope = createScope(client) + let actions: ReturnType | undefined + function Harness() { + actions = useMobileSessionTerminalCreateActions(scope as never) + return null + } + await act(async () => { + renderer = create(createElement(Harness)) + }) + await act(async () => { + await actions?.handleCreateTerminal('codex', { initialPrompt: 'Inspect this diff' }) + }) + + expect(client.sendRequest).toHaveBeenCalledWith( + 'session.tabs.createTerminal', + expect.objectContaining({ agent: 'codex' }) + ) + expect(client.sendRequest).not.toHaveBeenCalledWith( + 'agentSession.createSupport', + expect.anything() + ) + }) + + it('does not create a legacy sibling after an unknown structured outcome', async () => { + const client = clientReturning({ ok: true, result: { supported: true } }) + const sendRequest = client.sendRequest as unknown as ReturnType + sendRequest.mockImplementationOnce(async () => ({ + ok: true, + result: { supported: true } + })) + sendRequest.mockRejectedValueOnce(markRpcDeliveryUnknown(new Error('response lost'))) + sendRequest.mockRejectedValueOnce(markRpcDeliveryUnknown(new Error('still unknown'))) + const scope = createScope(client) + let actions: ReturnType | undefined + function Harness() { + actions = useMobileSessionTerminalCreateActions(scope as never) + return null + } + await act(async () => { + renderer = create(createElement(Harness)) + }) + await act(async () => { + await actions?.handleCreateTerminal('codex') + }) + + expect(sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'agentSession.createSupport', + 'agentSession.create', + 'agentSession.create' + ]) + expect(scope.setCreateError).toHaveBeenCalledWith('still unknown') + expect(scope.showToast).toHaveBeenCalledWith('still unknown', 1800) + }) +}) diff --git a/mobile/src/session/use-mobile-session-terminal-create-actions.ts b/mobile/src/session/use-mobile-session-terminal-create-actions.ts index 895b9a5a256..0ccd3591011 100644 --- a/mobile/src/session/use-mobile-session-terminal-create-actions.ts +++ b/mobile/src/session/use-mobile-session-terminal-create-actions.ts @@ -10,6 +10,7 @@ import type { MobileNewTabAgentOption } from './mobile-new-tab-agent-options' import type { TerminalQuickCommand } from '../../../src/shared/terminal-quick-command-types' import type { Terminal, TerminalCreateResult } from './mobile-session-route-types' import type { MobileSessionAttachmentsModel } from './use-mobile-session-attachments' +import { createMobileStructuredCodexSession } from './mobile-structured-agent-session-launch' export function useMobileSessionTerminalCreateActions(scope: MobileSessionAttachmentsModel) { const { @@ -22,6 +23,7 @@ export function useMobileSessionTerminalCreateActions(scope: MobileSessionAttach defaultTerminalHandlesToLiveInput, setActiveHandle, activeSessionTabId, + activeSessionTabIdRef, setActiveSessionTabId, setCreating, creatingTerminalRef, @@ -61,6 +63,35 @@ export function useMobileSessionTerminalCreateActions(scope: MobileSessionAttach .slice(2, 10)}` try { + // Bare Codex launches follow structured support; prompted launches keep their startup semantics. + if (agent === 'codex' && options === undefined) { + const structured = await createMobileStructuredCodexSession(client, worktreeId) + if (structured.kind === 'created') { + const previous = activeHandleRef.current + if (previous) { + unsubscribeTerminal(previous) + initializedHandlesRef.current.delete(previous) + } + const tabId = `agent-session:${structured.sessionId}` + pendingActiveSessionTabIdRef.current = tabId + pendingActiveTerminalHandleRef.current = null + activeSessionTabTypeRef.current = 'agent-session' + activeSessionTabIdRef.current = tabId + setActiveSessionTabId(tabId) + activeHandleRef.current = null + setActiveHandle(null) + // Refresh if the create response beats its published tab frame. + scheduleDelayedAction(() => void fetchSessionTabs(), 500) + return + } + if (structured.kind === 'unknown') { + // Never create a legacy sibling when the host may already have committed. + setCreateError(structured.message) + triggerError() + showToast(structured.message, 1800) + return + } + } const response = await client.sendRequest('session.tabs.createTerminal', { worktree: `id:${worktreeId}`, afterTabId: activeSessionTabId ?? undefined, diff --git a/mobile/src/session/use-mobile-session-terminal-runtime.ts b/mobile/src/session/use-mobile-session-terminal-runtime.ts index 8ef472fb845..5086efe1ba1 100644 --- a/mobile/src/session/use-mobile-session-terminal-runtime.ts +++ b/mobile/src/session/use-mobile-session-terminal-runtime.ts @@ -27,6 +27,7 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM worktreeId, connState, client, + clientId, sessionTabs, setLiveInputCapture, liveInputTerminalHandles, @@ -42,7 +43,9 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM const terminalGestureInputInFlightRef = useRef>(new Set()) const terminalCwdRef = useRef>(new Map()) const initialModesSeenRef = useRef>(new Set()) - const deviceTokenRef = useRef(null) + const deviceTokenRef = useRef(clientId) + // Keep the authenticated identity synchronous with the client exposed to downstream hooks. + deviceTokenRef.current = clientId // Why: state (not a ref) so the connection verdict re-renders when the endpoint loads and the Tailscale hint can appear. const [hostEndpoint, setHostEndpoint] = useState(null) const clientRef = useRef(null) @@ -123,11 +126,13 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM sendLiveTerminalInputRef, setLiveInputCapture }) - const { canCompose, canSend } = resolveMobileTerminalInputGate({ + const inputGate = resolveMobileTerminalInputGate({ connState, activeHandle, activeSessionTabType: activeSessionTab?.type }) + const canCompose = inputGate.canCompose + const canSend = inputGate.canSend && clientId !== null const liveInputEnabled = activeHandle ? liveInputTerminalHandles.has(activeHandle) : false const { focusLiveInput, handleTerminalTap, resetLiveInputFocus } = useTerminalLiveInputFocus({ activeHandleRef, diff --git a/mobile/src/session/use-mobile-session-terminal-send-actions.ts b/mobile/src/session/use-mobile-session-terminal-send-actions.ts index c80edee9271..6909f71ca63 100644 --- a/mobile/src/session/use-mobile-session-terminal-send-actions.ts +++ b/mobile/src/session/use-mobile-session-terminal-send-actions.ts @@ -16,6 +16,7 @@ import { import { normalizeTerminalTextInput } from '../terminal/terminal-text-input-normalization' import { useAgentSendKeyboardDismissal } from './use-agent-send-keyboard-dismissal' import type { MobileSessionTab } from './mobile-session-route-types' +import { useMobileSessionTabActionSheetOpener } from './use-mobile-session-tab-action-targets' import type { MobileSessionTerminalWebviewModel } from './use-mobile-session-terminal-webview' export function useMobileSessionTerminalSendActions(scope: MobileSessionTerminalWebviewModel) { @@ -27,6 +28,7 @@ export function useMobileSessionTerminalSendActions(scope: MobileSessionTerminal setMarkdownActionTarget, setFileActionTarget, setBrowserActionTarget, + setAgentSessionActionTarget, keyboardHeight, deviceTokenRef, clientRef, @@ -175,24 +177,14 @@ export function useMobileSessionTerminalSendActions(scope: MobileSessionTerminal sessionTabActionSheetKeyboardHideSubRef.current = null }, []) - const openSessionTabActionSheet = useCallback((tab: MobileSessionTab) => { - if (tab.type === 'terminal') { - if (typeof tab.terminal !== 'string') { - return - } - setActionTarget({ - handle: tab.terminal, - title: tab.title, - isActive: tab.terminal === activeHandleRef.current - }) - } else if (tab.type === 'markdown') { - setMarkdownActionTarget(tab) - } else if (tab.type === 'file') { - setFileActionTarget(tab) - } else { - setBrowserActionTarget(tab) - } - }, []) + const openSessionTabActionSheet = useMobileSessionTabActionSheetOpener({ + activeHandleRef, + setActionTarget, + setMarkdownActionTarget, + setFileActionTarget, + setBrowserActionTarget, + setAgentSessionActionTarget + }) const openSessionTabActionSheetAfterKeyboardDismiss = useCallback( (tab: MobileSessionTab) => { diff --git a/mobile/src/session/use-mobile-session-terminal-subscription.ts b/mobile/src/session/use-mobile-session-terminal-subscription.ts index 8ad0bb7383a..333d472a30e 100644 --- a/mobile/src/session/use-mobile-session-terminal-subscription.ts +++ b/mobile/src/session/use-mobile-session-terminal-subscription.ts @@ -15,9 +15,9 @@ export function useMobileSessionTerminalSubscription( ) { const { client, + clientId, setTerminalModes, terminalCwdRef, - deviceTokenRef, viewportRef, viewportMeasuredRef, terminalUnsubsRef, @@ -49,6 +49,10 @@ export function useMobileSessionTerminalSubscription( logSkippedGate('no-client') return } + if (clientId === null) { + logSkippedGate('no-client-identity') + return + } if (terminalUnsubsRef.current.has(handle)) { logSkippedGate('already-subscribed') return @@ -89,7 +93,7 @@ export function useMobileSessionTerminalSubscription( client, { terminal: handle, - client: { id: deviceTokenRef.current!, type: 'mobile' as const }, + client: { id: clientId, type: 'mobile' as const }, viewport: nativeChatTerminalStream.mobileNativeChatSubscribeViewport( covered, viewportRef.current @@ -263,6 +267,7 @@ export function useMobileSessionTerminalSubscription( }, [ client, + clientId, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction, diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts new file mode 100644 index 00000000000..108275223be --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -0,0 +1,161 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { getAgentSessionOptionCatalog } from '../../../src/shared/agent-session-option-catalog' +import type { + AgentSessionOptionResult, + AgentSessionOptionsResult +} from '../../../src/shared/agent-session-wire' +import type { + SessionOptionDescriptor, + SessionOptionsSurface, + SessionOptionValue +} from '../../../src/shared/native-chat-session-options' +import { + applyStructuredAgentSessionOptions, + canSetStructuredAgentSessionOption, + commitStructuredAgentSessionOption, + commitStructuredAgentSessionOptionValues, + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot +} from '../../../src/shared/structured-agent-session-options' +import type { RpcClient } from '../transport/rpc-client' +import { + callAgentSession, + type StructuredAgentSessionMutate +} from './mobile-structured-agent-session-rpc' + +type StructuredOptionsController = { + optionSnapshot: SessionOptionDescriptor[] + optionSurface: SessionOptionsSurface + pendingOptionId: string | null + setStructuredOption: (id: string, value: SessionOptionValue) => Promise + invokeStructuredOption: (id: string) => Promise +} + +export function useMobileStructuredAgentOptions(args: { + agent: string | null + client: RpcClient | null + sessionId: string | null + enabled: boolean + fence: number | null + mutate: StructuredAgentSessionMutate +}): StructuredOptionsController { + const { agent, client, enabled, fence, mutate, sessionId } = args + const [optionState, setOptionState] = useState(() => + createStructuredAgentSessionOptionState(agent ?? 'codex') + ) + const activeOptionRecordRef = useRef(optionState.record) + const optionCatalog = useMemo( + () => (agent === 'claude' || agent === 'codex' ? getAgentSessionOptionCatalog(agent) : null), + [agent] + ) + + useEffect(() => { + const next = createStructuredAgentSessionOptionState(agent ?? 'codex') + activeOptionRecordRef.current = next.record + setOptionState(next) + }, [agent, enabled, fence, sessionId]) + + useEffect(() => { + if (!client || !sessionId || !enabled || !optionCatalog) { + return + } + let stale = false + void callAgentSession(client, 'agentSession.options', { sessionId }) + .then((result) => { + if (!stale) { + setOptionState((current) => + current.record === activeOptionRecordRef.current + ? applyStructuredAgentSessionOptions(current, optionCatalog, result) + : current + ) + } + }) + .catch(() => undefined) + return () => { + stale = true + } + }, [client, enabled, optionCatalog, sessionId, fence]) + + const optionSnapshot = useMemo( + () => structuredAgentSessionOptionSnapshot(optionState), + [optionState] + ) + + const setStructuredOption = useCallback( + async (id: string, value: SessionOptionValue): Promise => { + if ( + !canSetStructuredAgentSessionOption(optionState, id, value) || + typeof value !== 'string' + ) { + return false + } + const targetRecord = optionState.record + setOptionState((current) => ({ ...current, pendingId: id })) + try { + const result = await mutate( + 'agentSession.setOption', + 'agentSession.setOption', + { key: id, value } + ) + if (activeOptionRecordRef.current !== targetRecord) { + return result.status !== 'rejected' + } + if (result.status === 'accepted') { + setOptionState((current) => + current.record === targetRecord && result.sameFence + ? commitStructuredAgentSessionOptionValues( + current, + result.value.options ?? { [id]: value } + ) + : current + ) + return true + } + if (result.status === 'unknown') { + setOptionState((current) => + current.record === targetRecord + ? commitStructuredAgentSessionOption(current, id, value) + : current + ) + return true + } + return false + } finally { + setOptionState((current) => + current.record === targetRecord && current.pendingId === id + ? { ...current, pendingId: null } + : current + ) + } + }, + [mutate, optionState] + ) + + const invokeStructuredOption = useCallback(async () => false, []) + + const setOption = useCallback( + async (id: string, value: SessionOptionValue) => { + await setStructuredOption(id, value) + return { snapshot: optionSnapshot } + }, + [optionSnapshot, setStructuredOption] + ) + + const optionSurface = useMemo( + () => ({ + getSnapshot: () => optionSnapshot, + setOption, + invokeAction: async () => ({ snapshot: optionSnapshot }), + subscribe: () => () => {} + }), + [optionSnapshot, setOption] + ) + + return { + optionSnapshot, + optionSurface, + pendingOptionId: optionState.pendingId, + setStructuredOption, + invokeStructuredOption + } +} diff --git a/mobile/src/session/use-mobile-structured-agent-session.test.tsx b/mobile/src/session/use-mobile-structured-agent-session.test.tsx new file mode 100644 index 00000000000..83562839363 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-session.test.tsx @@ -0,0 +1,849 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalRenderItem, + AgentJournalResolution +} from '../../../src/shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' +import type { RpcClient } from '../transport/rpc-client' +import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' +import { formatQuestionFreeTextAnswer } from './mobile-native-chat-question' +import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' + +function ok(result: unknown) { + return { ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +function snapshotEvent(fence = 3): AgentSessionSubscribeEvent { + return { + type: 'snapshot', + sessionId: 'session-1', + fence, + page: { + sessionId: 'session-1', + epoch: 'epoch-1', + fence, + direction: 'tail', + items: [], + removedItemIds: [], + submissions: [], + window: { + oldest: null, + newest: null, + nextCursor: { epoch: 'epoch-1', sequence: 0 } + }, + liveCursor: { epoch: 'epoch-1', sequence: 0 }, + hasOlder: false, + hasNewer: false + } + } +} + +function snapshotWithMessage(): AgentSessionSubscribeEvent { + const event = snapshotEvent() + return { + ...event, + page: { + ...event.page, + items: [ + { + itemId: 'msg-1', + revision: 1, + sequence: 1, + observedAt: 10, + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'sent before the blip' }] + } + } + ], + window: { + oldest: { epoch: 'epoch-1', sequence: 1 }, + newest: { epoch: 'epoch-1', sequence: 1 }, + nextCursor: { epoch: 'epoch-1', sequence: 2 } + }, + liveCursor: { epoch: 'epoch-1', sequence: 1 } + } + } as AgentSessionSubscribeEvent +} + +function pendingResolution(): AgentJournalResolution { + return { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } +} + +function approvalItem(): AgentJournalRenderItem { + return { + itemId: 'approval-1', + revision: 2, + sequence: 1, + observedAt: 10, + body: { + kind: 'approval', + title: 'Allow Bash?', + detail: 'rm -rf build', + options: [ + { id: 'allow-once', label: 'Allow once' }, + { id: 'deny', label: 'Deny' } + ], + resolution: pendingResolution() + } + } +} + +function approvalItemWithIdentity(itemId: string, revision: number): AgentJournalRenderItem { + return { ...approvalItem(), itemId, revision } +} + +function questionItem(): AgentJournalRenderItem { + return { + itemId: 'question-1', + revision: 7, + sequence: 2, + observedAt: 12, + body: { + kind: 'question', + question: 'Pick destination', + freeTextQuestionId: 'free-q', + options: [ + { id: 'choice-a', label: 'Choice A' }, + { id: 'choice-b', label: 'Choice B' } + ], + resolution: pendingResolution() + } + } +} + +function questionItemWithIdentity(itemId: string, revision: number): AgentJournalRenderItem { + return { ...questionItem(), itemId, revision } +} + +function runningStatusItem(): AgentJournalRenderItem { + return { + itemId: 'status-1', + revision: 1, + sequence: 3, + observedAt: 14, + body: { + kind: 'status', + text: 'Working', + turnLifecycle: { turnId: 'turn-1', state: 'running' } + } + } +} + +function defaultSendRequest(method: string, params?: Record) { + if (method === 'agentSession.send') { + return ok({ + ok: true, + replayed: false, + fence: 3, + cursor: { epoch: 'epoch-1', sequence: 1 }, + value: { turnId: 'turn-1' } + }) + } + if (method === 'agentSession.options') { + return ok({ + models: [ + { + id: 'gpt-fast', + label: 'GPT Fast', + isDefault: true, + defaultEffort: 'low', + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + }, + { + id: 'gpt-slow', + label: 'GPT Slow', + isDefault: false, + defaultEffort: 'high', + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + } + ], + current: { + model: 'gpt-fast', + effort: 'low' + } + }) + } + if (method === 'agentSession.setOption') { + return ok({ + ok: true, + replayed: false, + fence: 3, + cursor: { epoch: 'epoch-1', sequence: 2 }, + value: { + key: 'model', + value: 'gpt-fast', + options: { model: 'gpt-fast' } + } + }) + } + if (method === 'agentSession.respondToApproval' || method === 'agentSession.respondToQuestion') { + return ok({ + ok: true, + replayed: false, + fence: 3, + cursor: { epoch: 'epoch-1', sequence: 3 }, + value: { + itemId: String(params?.itemId ?? ''), + revision: 2, + resolution: { + state: 'resolved', + selectedOptionId: String(params?.optionId ?? ''), + resolvedBy: 'mobile', + resolvedAt: 123 + } + } + }) + } + return ok({}) +} + +describe('useMobileStructuredAgentSession', () => { + let renderer: ReactTestRenderer | null = null + let hook: ReturnType | null = null + let listener: ((value: unknown) => void) | null = null + const onSendError = vi.fn() + const unsubscribe = vi.fn() + const sendRequest = vi.fn(defaultSendRequest) + const subscribe = vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => { + listener = onData + return unsubscribe + }) + const client = { + sendRequest, + subscribe + } as unknown as RpcClient + + function Harness({ + sessionId = 'session-1', + agent = 'codex', + connected = true, + sourceIdentity = 'host-a\0workspace-a' + }: { + sessionId?: string | null + agent?: string | null + connected?: boolean + sourceIdentity?: string + }): null { + hook = useMobileStructuredAgentSession({ + client, + sessionId, + sourceIdentity, + enabled: true, + connected, + agent, + onSendError + } as never) + return null + } + + beforeEach(() => { + vi.clearAllMocks() + sendRequest.mockImplementation(defaultSendRequest) + listener = null + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + hook = null + }) + + it('subscribes and holds structured sessions without nativeChat or terminal RPCs', async () => { + act(() => { + renderer = create(createElement(Harness)) + }) + + await vi.waitFor(() => + expect(subscribe).toHaveBeenCalledWith( + 'agentSession.subscribe', + { sessionId: 'session-1' }, + expect.any(Function) + ) + ) + await vi.waitFor(() => + expect(sendRequest).toHaveBeenCalledWith( + 'agentSession.hold', + expect.objectContaining({ sessionId: 'session-1', holderId: expect.any(String) }), + expect.any(Object) + ) + ) + expect(sendRequest).not.toHaveBeenCalledWith( + expect.stringMatching(/^(nativeChat|terminal)\./), + expect.anything(), + expect.anything() + ) + }) + + it('re-holds after a reconnect that outlives the host release grace', async () => { + act(() => { + renderer = create(createElement(Harness, { connected: true })) + }) + await vi.waitFor(() => + expect( + sendRequest.mock.calls.filter(([method]) => method === 'agentSession.hold') + ).toHaveLength(1) + ) + await vi.waitFor(() => expect(subscribe).toHaveBeenCalledTimes(1)) + + // A transport loss retires the connection-scoped hold; after the host's 15s grace + // it may evict the provider child. Reconnect must acquire before replaying the stream. + await act(async () => { + renderer?.update(createElement(Harness, { connected: false })) + }) + expect(unsubscribe).toHaveBeenCalledTimes(1) + await act(async () => { + renderer?.update(createElement(Harness, { connected: true })) + }) + + await vi.waitFor(() => + expect( + sendRequest.mock.calls.filter(([method]) => method === 'agentSession.hold') + ).toHaveLength(2) + ) + await vi.waitFor(() => expect(subscribe).toHaveBeenCalledTimes(2)) + const holdOrders = sendRequest.mock.calls + .map((call, index) => + call[0] === 'agentSession.hold' ? sendRequest.mock.invocationCallOrder[index] : null + ) + .filter((order): order is number => order !== null) + const subscribeOrders = subscribe.mock.invocationCallOrder + const secondHoldOrder = holdOrders[1] + const secondSubscribeOrder = subscribeOrders[1] + if (secondHoldOrder === undefined || secondSubscribeOrder === undefined) { + throw new Error('reconnect calls were not recorded') + } + expect(secondHoldOrder).toBeLessThan(secondSubscribeOrder) + }) + + it('sends with the shared structured mutation envelope after the stream fence lands', async () => { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => listener?.(snapshotEvent())) + + let outcome: 'accepted' | 'unknown' | 'rejected' = 'rejected' + await act(async () => { + outcome = await hook!.sendWithOutcome('hello') + }) + + expect(outcome).toBe('accepted') + expect(sendRequest).toHaveBeenCalledWith( + 'agentSession.send', + expect.objectContaining({ + envelope: expect.objectContaining({ + sessionId: 'session-1', + expectedRuntimeFence: 3, + clientOperationId: expect.stringMatching(/^\d{13}-[0-9a-f]{32}$/), + payloadFingerprint: expect.any(String) + }), + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'hello' }] + } + }), + expect.any(Object) + ) + }) + + it('surfaces structured prompt cards and option snapshots', async () => { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => listener?.(snapshotEvent(3))) + act(() => listener?.(snapshotEvent(3))) + act(() => + listener?.({ + ...snapshotEvent(3), + page: { + ...snapshotEvent(3).page, + items: [approvalItem(), questionItem()] + } + }) + ) + + if (!hook) { + throw new Error('hook not ready') + } + + await vi.waitFor(() => expect(hook.permission).not.toBeNull()) + await vi.waitFor(() => expect(hook.question).not.toBeNull()) + await vi.waitFor(() => expect(hook.optionSnapshot.length).toBeGreaterThan(0)) + + expect(hook.permission).toMatchObject({ + title: 'Allow Bash?', + detail: 'rm -rf build', + options: [ + { label: 'Allow once', send: expect.any(String) }, + { label: 'Deny', send: expect.any(String) } + ] + }) + expect(hook.question).toMatchObject({ + question: 'Pick destination', + allowOther: true, + optionTokens: [expect.any(String), expect.any(String)], + freeTextToken: expect.any(String) + }) + expect(hook.optionSurface.getSnapshot()).toEqual(hook.optionSnapshot) + + await act(async () => { + expect(await hook.setStructuredOption('model', 'gpt-fast')).toBe(true) + }) + expect(sendRequest).toHaveBeenCalledWith( + 'agentSession.setOption', + expect.objectContaining({ + envelope: expect.objectContaining({ + sessionId: 'session-1', + expectedRuntimeFence: 3, + clientOperationId: expect.any(String), + payloadFingerprint: expect.any(String) + }), + key: 'model', + value: 'gpt-fast' + }), + expect.any(Object) + ) + + await act(async () => { + expect(await hook.respondPermission(hook.permission!.options[0]!.send)).toBe(true) + }) + expect(sendRequest).toHaveBeenCalledWith( + 'agentSession.respondToApproval', + expect.objectContaining({ + envelope: expect.objectContaining({ + sessionId: 'session-1', + expectedRuntimeFence: 3 + }), + itemId: 'approval-1', + optionId: 'allow-once' + }), + expect.any(Object) + ) + + await act(async () => { + expect( + await hook.respondQuestion(formatQuestionFreeTextAnswer(hook.question!, 'custom answer')) + ).toBe(true) + }) + expect(sendRequest).toHaveBeenCalledWith( + 'agentSession.respondToQuestion', + expect.objectContaining({ + envelope: expect.objectContaining({ + sessionId: 'session-1', + expectedRuntimeFence: 3 + }), + itemId: 'question-1', + optionId: `${encodeURIComponent('free-q')}:${encodeURIComponent('custom answer')}` + }), + expect.any(Object) + ) + }) + + it('sends structured image attachments in the message body', async () => { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => listener?.(snapshotEvent(3))) + + let outcome: 'accepted' | 'unknown' | 'rejected' = 'rejected' + await act(async () => { + outcome = await hook.sendWithOutcome('look at this', undefined, undefined, [ + { path: '/tmp/a.png', previewUri: 'file:///a.jpg' } + ]) + }) + + expect(outcome).toBe('accepted') + expect(sendRequest).toHaveBeenCalledWith( + 'agentSession.send', + expect.objectContaining({ + envelope: expect.objectContaining({ + sessionId: 'session-1', + expectedRuntimeFence: 3, + clientOperationId: expect.any(String), + payloadFingerprint: expect.any(String) + }), + body: { + kind: 'message', + role: 'user', + blocks: [ + { type: 'text', text: 'look at this' }, + { type: 'image-ref', path: '/tmp/a.png' } + ] + } + }), + expect.any(Object) + ) + }) + + it('rejects preview-only structured image URIs instead of sending them as host paths', async () => { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => listener?.(snapshotEvent(3))) + sendRequest.mockClear() + + let outcome: 'accepted' | 'unknown' | 'rejected' = 'accepted' + await act(async () => { + outcome = await hook!.sendWithOutcome('look at this', ['file:///a.jpg']) + }) + + expect(outcome).toBe('rejected') + expect(onSendError).toHaveBeenCalledWith('Message not sent') + expect(sendRequest).not.toHaveBeenCalledWith( + 'agentSession.send', + expect.objectContaining({ + body: expect.objectContaining({ + blocks: expect.arrayContaining([{ type: 'image-ref', path: 'file:///a.jpg' }]) + }) + }), + expect.any(Object) + ) + }) + + it('answers the prompt captured by a structured card after a newer prompt lands', async () => { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => + listener?.({ + ...snapshotEvent(3), + page: { + ...snapshotEvent(3).page, + items: [ + approvalItemWithIdentity('approval-old', 4), + questionItemWithIdentity('question-old', 8) + ] + } + }) + ) + const approvalToken = hook!.permission!.options[0]!.send + const questionToken = hook!.question!.optionTokens[0]! + const freeText = formatQuestionFreeTextAnswer(hook!.question!, 'old answer') + + act(() => + listener?.({ + ...snapshotEvent(3), + page: { + ...snapshotEvent(3).page, + items: [ + approvalItemWithIdentity('approval-new', 9), + questionItemWithIdentity('question-new', 10) + ] + } + }) + ) + sendRequest.mockClear() + + await act(async () => { + expect(await hook!.respondPermission(approvalToken)).toBe(true) + expect(await hook!.respondQuestion(questionToken)).toBe(true) + expect(await hook!.respondQuestion(freeText)).toBe(true) + }) + + expect(sendRequest).toHaveBeenCalledWith( + 'agentSession.respondToApproval', + expect.objectContaining({ + itemId: 'approval-old', + expectedRevision: 4, + optionId: 'allow-once' + }), + expect.any(Object) + ) + expect(sendRequest).toHaveBeenCalledWith( + 'agentSession.respondToQuestion', + expect.objectContaining({ + itemId: 'question-old', + expectedRevision: 8, + optionId: 'choice-a' + }), + expect.any(Object) + ) + expect(sendRequest).toHaveBeenCalledWith( + 'agentSession.respondToQuestion', + expect.objectContaining({ + itemId: 'question-old', + expectedRevision: 8, + optionId: `${encodeURIComponent('free-q')}:${encodeURIComponent('old answer')}` + }), + expect.any(Object) + ) + }) + + it('surfaces unknown structured prompt responses as unconfirmed', async () => { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => + listener?.({ + ...snapshotEvent(3), + page: { + ...snapshotEvent(3).page, + items: [approvalItem(), questionItem()] + } + }) + ) + sendRequest.mockImplementation(async (method, params) => { + if (method === 'agentSession.respondToApproval') { + throw markRpcDeliveryUnknown(new Error('Connection closed')) + } + return defaultSendRequest(method, params) + }) + onSendError.mockClear() + + await act(async () => { + expect(await hook!.respondPermission(hook!.permission!.options[0]!.send)).toBe(false) + }) + expect(onSendError).toHaveBeenCalledWith('Response unconfirmed — check chat before retrying') + + sendRequest.mockImplementation(async (method, params) => { + if (method === 'agentSession.respondToQuestion') { + throw markRpcDeliveryUnknown(new Error('Connection closed')) + } + return defaultSendRequest(method, params) + }) + onSendError.mockClear() + + await act(async () => { + expect(await hook!.respondQuestion(hook!.question!.optionTokens[0]!)).toBe(false) + }) + expect(onSendError).toHaveBeenCalledWith('Answer unconfirmed — check chat before retrying') + }) + + it('uses a fresh operation id when a prompt response delivery is unknown', async () => { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => + listener?.({ + ...snapshotEvent(3), + page: { ...snapshotEvent(3).page, items: [approvalItem()] } + }) + ) + let attempts = 0 + sendRequest.mockImplementation(async (method, params) => { + if (method === 'agentSession.respondToApproval' && attempts++ === 0) { + throw markRpcDeliveryUnknown(new Error('Connection closed')) + } + return defaultSendRequest(method, params) + }) + + const token = hook!.permission!.options[0]!.send + await act(async () => { + expect(await hook!.respondPermission(token)).toBe(false) + expect(await hook!.respondPermission(token)).toBe(true) + }) + + const calls = sendRequest.mock.calls.filter( + ([method]) => method === 'agentSession.respondToApproval' + ) + expect(calls).toHaveLength(2) + const firstId = (calls[0]![1] as { envelope: { clientOperationId: string } }).envelope + .clientOperationId + const retryId = (calls[1]![1] as { envelope: { clientOperationId: string } }).envelope + .clientOperationId + expect(firstId).toMatch(/^\d{13}-[0-9a-f]{32}$/) + expect(retryId).toMatch(/^\d{13}-[0-9a-f]{32}$/) + expect(retryId).not.toBe(firstId) + }) + + it('marks a retried send as retryUnknown after ambiguous delivery', async () => { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => listener?.(snapshotEvent(3))) + let attempts = 0 + sendRequest.mockImplementation(async (method, params) => { + if (method === 'agentSession.send' && attempts++ === 0) { + throw markRpcDeliveryUnknown(new Error('Connection closed')) + } + return defaultSendRequest(method, params) + }) + + await act(async () => { + expect(await hook!.sendWithOutcome('retry me')).toBe('unknown') + expect(await hook!.sendWithOutcome('retry me')).toBe('accepted') + }) + + const calls = sendRequest.mock.calls.filter(([method]) => method === 'agentSession.send') + expect(calls).toHaveLength(2) + expect(calls[0]![1]).not.toHaveProperty('retryUnknown') + expect(calls[1]![1]).toMatchObject({ retryUnknown: true }) + const firstId = (calls[0]![1] as { envelope: { clientOperationId: string } }).envelope + .clientOperationId + const retryId = (calls[1]![1] as { envelope: { clientOperationId: string } }).envelope + .clientOperationId + expect(retryId).toBe(firstId) + }) + + it('keeps structured option changes dispatched after unknown delivery', async () => { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => listener?.(snapshotEvent(3))) + await vi.waitFor(() => expect(hook!.optionSnapshot.length).toBeGreaterThan(0)) + sendRequest.mockImplementation(async (method, params) => { + if (method === 'agentSession.setOption') { + throw markRpcDeliveryUnknown(new Error('Connection closed')) + } + return defaultSendRequest(method, params) + }) + onSendError.mockClear() + + await act(async () => { + expect(await hook!.setStructuredOption('model', 'gpt-slow')).toBe(true) + }) + + const model = hook!.optionSnapshot.find((descriptor) => descriptor.id === 'model') + expect(model).toMatchObject({ + valueSource: 'dispatched', + kind: expect.objectContaining({ currentValue: 'gpt-slow' }) + }) + expect(onSendError).not.toHaveBeenCalled() + }) + + it('reports structured Stop as unconfirmed after unknown delivery', async () => { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => + listener?.({ + ...snapshotEvent(3), + page: { + ...snapshotEvent(3).page, + items: [runningStatusItem()] + } + }) + ) + sendRequest.mockImplementation(async (method, params) => { + if (method === 'agentSession.cancel') { + throw markRpcDeliveryUnknown(new Error('Connection closed')) + } + return defaultSendRequest(method, params) + }) + onSendError.mockClear() + + await act(async () => { + hook!.cancel() + await Promise.resolve() + }) + + expect(onSendError).toHaveBeenCalledWith('Stop unconfirmed — check chat before retrying') + }) + + it('releases a landed hold when the structured tab unmounts', async () => { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => + expect(sendRequest).toHaveBeenCalledWith( + 'agentSession.hold', + expect.objectContaining({ sessionId: 'session-1' }), + expect.any(Object) + ) + ) + const held = sendRequest.mock.calls.find((call) => call[0] === 'agentSession.hold')?.[1] as { + holderId: string + } + + act(() => renderer?.unmount()) + + await vi.waitFor(() => + expect(sendRequest).toHaveBeenCalledWith( + 'agentSession.release', + { sessionId: 'session-1', holderId: held.holderId }, + expect.any(Object) + ) + ) + }) + + it('keeps the transcript visible while reconnecting', async () => { + await act(async () => { + renderer = create(createElement(Harness, { connected: true })) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => listener?.(snapshotWithMessage())) + expect(hook?.session.messages).toHaveLength(1) + + await act(async () => { + renderer?.update(createElement(Harness, { connected: false })) + }) + expect(hook?.session.messages).toHaveLength(1) + expect(hook?.session.status).toBe('ready') + + await act(async () => { + renderer?.update(createElement(Harness, { connected: true })) + }) + expect(hook?.session.messages).toHaveLength(1) + }) + + it('restores the correct cached transcript when switching tabs offline', async () => { + await act(async () => { + renderer = create(createElement(Harness, { connected: true, sessionId: 'session-1' })) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => listener?.(snapshotWithMessage())) + expect(hook?.session.messages).toHaveLength(1) + + await act(async () => { + renderer?.update(createElement(Harness, { connected: false, sessionId: 'session-2' })) + }) + expect(hook?.session.messages).toEqual([]) + expect(hook?.session.status).toBe('idle') + + await act(async () => { + renderer?.update(createElement(Harness, { connected: false, sessionId: 'session-1' })) + }) + expect(hook?.session.messages).toHaveLength(1) + }) + + it('isolates matching provider session ids across host and workspace sources', async () => { + await act(async () => { + renderer = create( + createElement(Harness, { + connected: true, + sessionId: 'session-1', + sourceIdentity: 'host-a\0workspace-a' + }) + ) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => listener?.(snapshotWithMessage())) + expect(hook?.session.messages).toHaveLength(1) + + await act(async () => { + renderer?.update( + createElement(Harness, { + connected: false, + sessionId: 'session-1', + sourceIdentity: 'host-b\0workspace-b' + }) + ) + }) + expect(hook?.session.messages).toEqual([]) + }) +}) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts new file mode 100644 index 00000000000..d9cabf1f2d0 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -0,0 +1,315 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react' +import type { + AgentSessionCancelResult, + AgentSessionPromptResult, + AgentSessionSendResult +} from '../../../src/shared/agent-session-wire' +import type { + SessionOptionDescriptor, + SessionOptionsSurface, + SessionOptionValue +} from '../../../src/shared/native-chat-session-options' +import { + structuredAgentSessionSendBody, + type StructuredAgentSessionAttachment +} from '../../../src/shared/structured-agent-session-outbox' +import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' +import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection' +import { activeStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-projection' +import { + pendingStructuredApproval, + pendingStructuredQuestion, + projectStructuredPermission, + projectStructuredQuestion, + structuredApprovalResponseTarget, + structuredQuestionResponseTarget +} from './mobile-structured-agent-prompts' +import { + requestStructuredAgentSessionMutation, + retainStructuredSessionOperationId as retainStructuredOpId, + timeoutForDeadline, + type StructuredAgentSessionMutationResult +} from './mobile-structured-agent-session-rpc' +import type { RpcClient } from '../transport/rpc-client' +import type { MobileChatPermission } from './mobile-native-chat-permission' +import type { MobileChatQuestion } from './mobile-native-chat-question' +import type { MobileNativeChatSession } from './use-mobile-native-chat-session' +import { useMobileStructuredAgentState } from './use-mobile-structured-agent-state' +import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options' + +type StructuredMobileAttachment = StructuredAgentSessionAttachment & { id?: string } + +type StructuredMobileSession = { + session: MobileNativeChatSession + isWorking: boolean + turnId: string | null + sendWithOutcome: ( + text: string, + images?: string[], + deadline?: number, + attachments?: readonly StructuredMobileAttachment[] + ) => Promise + cancel: () => void + permission: MobileChatPermission | null + question: MobileChatQuestion | null + optionSnapshot: SessionOptionDescriptor[] + optionSurface: SessionOptionsSurface + pendingOptionId: string | null + respondPermission: (optionId: string) => Promise + respondQuestion: (answer: string) => Promise + setStructuredOption: (id: string, value: SessionOptionValue) => Promise + invokeStructuredOption: (id: string) => Promise +} + +export function useMobileStructuredAgentSession(args: { + client: RpcClient | null + sessionId: string | null + /** Host/workspace scope used to keep same provider ids isolated. */ + sourceIdentity?: string + enabled: boolean + /** Live transport only; gates the connection-scoped hold, nothing else. */ + connected: boolean + agent: string | null + onSendError: (message: string) => void +}): StructuredMobileSession { + const { agent, client, connected, sessionId, sourceIdentity = '', enabled, onSendError } = args + const sessionKey = encodeNativeChatTranscriptIdentity([sourceIdentity, agent, sessionId]) + const operationIdsRef = useRef(new Map()) + useEffect(() => () => operationIdsRef.current.clear(), []) + const retainOperationId = (key: string, operationId?: string): string => + retainStructuredOpId(operationIdsRef.current, key, operationId) + const stateArgs = { client, sessionId, sessionKey, enabled, connected } + const { state, stateRef, loadingOlder, loadEarlier } = useMobileStructuredAgentState(stateArgs) + + const mutate = useCallback( + async ( + method: string, + fingerprintMethod: string, + fields: Record + ): Promise> => { + const current = stateRef.current + if (!client || !sessionId || !enabled || current.fence === null) { + return { status: 'rejected' } + } + const targetFence = current.fence + const key = `${sessionKey}:${fingerprintMethod}:${JSON.stringify(fields)}` + const clientOperationId = retainOperationId(key, operationIdsRef.current.get(key)) + const result = await requestStructuredAgentSessionMutation({ + client, + method, + fingerprintMethod, + sessionId, + expectedRuntimeFence: targetFence, + fields, + clientOperationId + }) + if (result.status === 'accepted') { + operationIdsRef.current.delete(key) + return { + status: 'accepted', + value: result.value, + sameFence: stateRef.current.fence === targetFence + } + } + if (result.status === 'unknown') { + // Prompt/option/cancel plans cannot redispatch an unknown ledger row; + // issue a fresh id so a retry can be admitted after the user checks the + // stream. Sends opt into explicit retryUnknown below. + operationIdsRef.current.delete(key) + return result + } + operationIdsRef.current.delete(key) + onSendError(result.message) + return { status: 'rejected' } + }, + [client, enabled, onSendError, sessionId, sessionKey] + ) + + const { + invokeStructuredOption, + optionSnapshot, + optionSurface, + pendingOptionId, + setStructuredOption + } = useMobileStructuredAgentOptions({ + agent, + client, + sessionId, + enabled, + fence: state.fence, + mutate + }) + + const sendWithOutcome = useCallback( + async ( + text: string, + images?: string[], + deadline?: number, + attachments?: readonly StructuredMobileAttachment[] + ): Promise => { + const currentFence = stateRef.current.fence + if (!client || !sessionId || !enabled || currentFence === null) { + onSendError('Message not sent (disconnected)') + return 'rejected' + } + const timeoutMs = timeoutForDeadline(deadline) + if (timeoutMs === null) { + onSendError('Message not sent') + return 'rejected' + } + if (attachments === undefined && images !== undefined && images.length > 0) { + onSendError('Message not sent') + return 'rejected' + } + const sendAttachments = attachments ?? [] + const body = structuredAgentSessionSendBody(text, sendAttachments) + if (body.blocks.length === 0) { + return 'rejected' + } + const fields = { body } + const key = `${sessionKey}:agentSession.send:${JSON.stringify(fields)}` + const priorOperationId = operationIdsRef.current.get(key) + const clientOperationId = retainOperationId(key, priorOperationId) + const result = await requestStructuredAgentSessionMutation({ + client, + method: 'agentSession.send', + fingerprintMethod: 'agentSession.send', + sessionId, + expectedRuntimeFence: currentFence, + fields, + clientOperationId, + ...(priorOperationId ? { retryUnknown: true } : {}), + timeoutMs + }) + if (result.status === 'accepted') { + operationIdsRef.current.delete(key) + return 'accepted' + } + if (result.status === 'unknown') { + return 'unknown' + } + operationIdsRef.current.delete(key) + onSendError(result.message === 'Request not sent' ? 'Message not sent' : result.message) + return 'rejected' + }, + [client, enabled, onSendError, sessionId, sessionKey] + ) + + const respondPermission = useCallback( + async (optionId: string): Promise => { + const target = structuredApprovalResponseTarget( + optionId, + stateRef.current.items.find(pendingStructuredApproval) ?? null + ) + if (!target) { + return false + } + const result = await mutate( + 'agentSession.respondToApproval', + 'agentSession.respondTo:approval', + target + ) + if (result.status === 'unknown') { + onSendError('Response unconfirmed — check chat before retrying') + return false + } + return result.status === 'accepted' + }, + [mutate, onSendError] + ) + + const respondQuestion = useCallback( + async (answer: string): Promise => { + const target = structuredQuestionResponseTarget( + answer, + stateRef.current.items.find(pendingStructuredQuestion) ?? null + ) + if (!target) { + return false + } + const result = await mutate( + 'agentSession.respondToQuestion', + 'agentSession.respondTo:question', + target + ) + if (result.status === 'unknown') { + onSendError('Answer unconfirmed — check chat before retrying') + return false + } + return result.status === 'accepted' + }, + [mutate, onSendError] + ) + + const cancel = useCallback(() => { + const current = stateRef.current + const turnId = activeStructuredAgentSessionTurnId(current.items) + if (!client || !sessionId || !enabled || current.fence === null || !turnId) { + onSendError('Stop not sent') + return + } + const fields = { turnId } + const key = `${sessionKey}:agentSession.cancel:${JSON.stringify(fields)}` + const clientOperationId = retainOperationId(key, operationIdsRef.current.get(key)) + void requestStructuredAgentSessionMutation({ + client, + method: 'agentSession.cancel', + fingerprintMethod: 'agentSession.cancel', + sessionId, + expectedRuntimeFence: current.fence, + fields, + clientOperationId + }).then((result) => { + if (result.status !== 'unknown') { + operationIdsRef.current.delete(key) + } + if (result.status === 'unknown') { + onSendError('Stop unconfirmed — check chat before retrying') + } else if (result.status === 'refused') { + onSendError(result.message) + } else if (result.status === 'failed') { + onSendError(result.message === 'Request not sent' ? 'Stop not sent' : result.message) + } + }) + }, [client, enabled, onSendError, sessionId, sessionKey]) + + const messages = useMemo( + () => projectStructuredAgentSessionMessages(state.items, [], state.submissions), + [state.items, state.submissions] + ) + const status = state.status === 'idle' ? 'idle' : state.status + const approvalPrompt = useMemo( + () => state.items.find(pendingStructuredApproval) ?? null, + [state.items] + ) + const questionPrompt = useMemo( + () => state.items.find(pendingStructuredQuestion) ?? null, + [state.items] + ) + + return { + session: { + messages, + status, + transcriptLoading: status === 'loading', + error: state.error, + hasMore: state.hasOlder, + loadingEarlier: loadingOlder, + loadEarlier + }, + isWorking: activeStructuredAgentSessionTurnId(state.items) !== null, + turnId: activeStructuredAgentSessionTurnId(state.items), + sendWithOutcome, + cancel, + permission: projectStructuredPermission(approvalPrompt), + question: projectStructuredQuestion(questionPrompt), + optionSnapshot, + optionSurface, + pendingOptionId, + respondPermission, + respondQuestion, + setStructuredOption, + invokeStructuredOption + } +} diff --git a/mobile/src/session/use-mobile-structured-agent-state.ts b/mobile/src/session/use-mobile-structured-agent-state.ts new file mode 100644 index 00000000000..52aefab24aa --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-state.ts @@ -0,0 +1,198 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { + AgentSessionHistoryResult, + AgentSessionSubscribeEvent +} from '../../../src/shared/agent-session-wire' +import { AGENT_SESSION_HISTORY_MAX_LIMIT } from '../../../src/shared/agent-session-wire' +import { structuredAgentSessionHolderId } from '../../../src/shared/structured-agent-session-holder' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + oldestStructuredAgentSessionCursor, + reduceStructuredAgentSession, + type StructuredAgentSessionAction, + type StructuredAgentSessionState +} from '../../../src/shared/structured-agent-session-reducer' +import type { RpcClient } from '../transport/rpc-client' +import { callAgentSession } from './mobile-structured-agent-session-rpc' + +const MAX_RETAINED_SESSION_STATES = 32 + +function isSubscribeEvent(value: unknown): value is AgentSessionSubscribeEvent { + if (typeof value !== 'object' || value === null) { + return false + } + const type = (value as { type?: unknown }).type + return type === 'snapshot' || type === 'batch' || type === 'reset' || type === 'end' +} + +export function useMobileStructuredAgentState(args: { + client: RpcClient | null + sessionId: string | null + sessionKey: string | null + enabled: boolean + /** Live transport only. The hold dies with the connection and has to be retaken, + * but the transcript must survive the outage rather than blank out with it. */ + connected: boolean +}): { + state: StructuredAgentSessionState + stateRef: { readonly current: StructuredAgentSessionState } + loadingOlder: boolean + loadEarlier: () => void +} { + const { client, connected, enabled, sessionId, sessionKey } = args + // Keep a bounded cache so offline tab switches select the right transcript + // synchronously without growing for the lifetime of the app. + const [sessionStates, setSessionStates] = useState>( + () => new Map() + ) + const state = + enabled && sessionKey + ? (sessionStates.get(sessionKey) ?? EMPTY_STRUCTURED_AGENT_SESSION) + : EMPTY_STRUCTURED_AGENT_SESSION + const [loadingOlder, setLoadingOlder] = useState(false) + const stateRef = useRef(state) + const sessionKeyRef = useRef(sessionKey) + const streamGenerationRef = useRef(0) + useLayoutEffect(() => { + stateRef.current = state + sessionKeyRef.current = sessionKey + }, [sessionKey, state]) + + const apply = useCallback( + (action: StructuredAgentSessionAction) => { + if (!sessionKey) { + return + } + setSessionStates((current) => { + const previous = current.get(sessionKey) ?? EMPTY_STRUCTURED_AGENT_SESSION + const next = reduceStructuredAgentSession(previous, action) + if (next === previous) { + return current + } + const updated = new Map(current) + updated.delete(sessionKey) + updated.set(sessionKey, next) + while (updated.size > MAX_RETAINED_SESSION_STATES) { + const oldest = updated.keys().next().value + if (oldest === undefined) { + break + } + updated.delete(oldest) + } + return updated + }) + }, + [sessionKey] + ) + + useEffect(() => { + streamGenerationRef.current += 1 + sessionKeyRef.current = sessionKey + setLoadingOlder(false) + if (!client || !sessionId || !enabled) { + return + } + if (!connected) { + // The cleanup above drops the dead hold and stream; keyed state keeps this + // session's transcript visible while another tab can be selected. + return + } + apply({ type: 'loading' }) + const holderId = structuredAgentSessionHolderId('mobile-chat') + let cancelled = false + let unsubscribe = (): void => {} + const held = callAgentSession(client, 'agentSession.hold', { + sessionId, + holderId + }) + void held + .then(() => { + if (cancelled) { + return + } + unsubscribe = client.subscribe('agentSession.subscribe', { sessionId }, (raw) => { + if ( + typeof raw === 'object' && + raw !== null && + (raw as { type?: unknown }).type === 'error' + ) { + apply({ type: 'error', message: String((raw as { message?: unknown }).message ?? '') }) + return + } + if (isSubscribeEvent(raw)) { + apply({ type: 'event', event: raw }) + } + }) + }) + .catch((error: unknown) => { + if (!cancelled) { + apply({ type: 'error', message: error instanceof Error ? error.message : String(error) }) + } + }) + return () => { + cancelled = true + unsubscribe() + void held + .then(() => + callAgentSession( + client, + 'agentSession.release', + { + sessionId, + holderId + }, + undefined, + { failWhenDisconnected: true } + ).catch(() => undefined) + ) + .catch(() => undefined) + } + }, [apply, client, connected, enabled, sessionId, sessionKey]) + + const loadEarlier = useCallback(() => { + const current = stateRef.current + if (!client || !sessionId || !sessionKey || loadingOlder || !current.hasOlder) { + return + } + const cursor = oldestStructuredAgentSessionCursor(current) + if (!cursor) { + return + } + const requestSessionKey = sessionKey + const requestGeneration = streamGenerationRef.current + setLoadingOlder(true) + void callAgentSession(client, 'agentSession.history', { + sessionId, + direction: 'before', + cursor, + limit: AGENT_SESSION_HISTORY_MAX_LIMIT + }) + .then((result) => { + if ( + result.ok && + sessionKeyRef.current === requestSessionKey && + streamGenerationRef.current === requestGeneration + ) { + apply({ type: 'older-page', requestedEpoch: cursor.epoch, page: result.page }) + } + }) + .catch((error: unknown) => { + if ( + sessionKeyRef.current === requestSessionKey && + streamGenerationRef.current === requestGeneration + ) { + apply({ type: 'error', message: error instanceof Error ? error.message : String(error) }) + } + }) + .finally(() => { + if ( + sessionKeyRef.current === requestSessionKey && + streamGenerationRef.current === requestGeneration + ) { + setLoadingOlder(false) + } + }) + }, [apply, client, loadingOlder, sessionId, sessionKey]) + + return { state, stateRef, loadingOlder, loadEarlier } +} diff --git a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts new file mode 100644 index 00000000000..fa786867fc7 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts @@ -0,0 +1,100 @@ +import { useCallback } from 'react' +import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import type { MobileNativeChatSendOrigin } from './use-mobile-native-chat-drafts' + +type StructuredNativeChatAttachment = { + id?: string + path: string + previewUri: string +} + +export function useMobileStructuredNativeChatSendBridge(args: { + sendStructured: ( + text: string, + images?: string[], + deadline?: number, + attachments?: readonly StructuredNativeChatAttachment[] + ) => Promise + captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null + clearDraftForSend: (origin: MobileNativeChatSendOrigin, text: string) => void + acceptSend: (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => void + holdUnconfirmedSend: ( + origin: MobileNativeChatSendOrigin, + text: string, + onUnconfirmed: () => void + ) => void + restoreRejectedDraft: (origin: MobileNativeChatSendOrigin, text: string) => void + onSendError: (message: string) => void +}): { + send: (text: string, images?: string[]) => Promise + sendWithOutcome: ( + text: string, + images?: string[], + deadline?: number, + attachments?: readonly StructuredNativeChatAttachment[] + ) => Promise +} { + const { + acceptSend, + captureSendOrigin, + clearDraftForSend, + holdUnconfirmedSend, + onSendError, + restoreRejectedDraft, + sendStructured + } = args + const sendWithOutcome = useCallback( + async ( + text: string, + images?: string[], + deadline?: number, + attachments?: readonly StructuredNativeChatAttachment[] + ): Promise => { + const origin = captureSendOrigin(text.trimEnd()) + if (!origin) { + onSendError('Message not sent (disconnected)') + return 'rejected' + } + clearDraftForSend(origin, text) + const outcome = + attachments !== undefined + ? await sendStructured(text, images, deadline, attachments) + : deadline !== undefined + ? await sendStructured(text, images, deadline) + : images !== undefined + ? await sendStructured(text, images) + : await sendStructured(text) + if (outcome === 'accepted') { + acceptSend(origin, text.trimEnd(), images) + return 'accepted' + } + if (outcome === 'unknown') { + holdUnconfirmedSend(origin, text.trimEnd(), () => + onSendError('Delivery unconfirmed — check chat before retrying') + ) + return 'unknown' + } + restoreRejectedDraft(origin, text) + return 'rejected' + }, + [ + acceptSend, + captureSendOrigin, + clearDraftForSend, + holdUnconfirmedSend, + onSendError, + restoreRejectedDraft, + sendStructured + ] + ) + const send = useCallback( + async ( + text: string, + images?: string[], + deadline?: number, + attachments?: readonly StructuredNativeChatAttachment[] + ) => (await sendWithOutcome(text, images, deadline, attachments)) !== 'rejected', + [sendWithOutcome] + ) + return { send, sendWithOutcome } +} diff --git a/mobile/src/transport/cellular-connecting-label-stall.test.ts b/mobile/src/transport/cellular-connecting-label-stall.test.ts index e9a46d3b406..358b0abab41 100644 --- a/mobile/src/transport/cellular-connecting-label-stall.test.ts +++ b/mobile/src/transport/cellular-connecting-label-stall.test.ts @@ -19,6 +19,10 @@ vi.mock('./e2ee', () => ({ decryptBytes: (bytes: Uint8Array) => bytes })) +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + type CarrierBehavior = // Carrier silently drops the SYN to a LAN/CGNAT destination: the socket sits // CONNECTING until the client's 12s connect timeout fires. diff --git a/mobile/src/transport/client-context.test.ts b/mobile/src/transport/client-context.test.ts index a311f362f85..4a7d5d7b0e8 100644 --- a/mobile/src/transport/client-context.test.ts +++ b/mobile/src/transport/client-context.test.ts @@ -146,8 +146,8 @@ beforeEach(() => { }) describe('useHostClient', () => { - it('rebinds when Expo reuses a screen between two connected cached hosts', async () => { - const host2 = { ...HOST, id: 'host-2', name: 'Host 2' } + it('rebinds the client and its authenticated identity together across cached hosts', async () => { + const host2 = { ...HOST, id: 'host-2', name: 'Host 2', deviceToken: 'token-2' } const client1 = makeFakeClient('connected') const client2 = makeFakeClient('connected') connectMock.mockReturnValueOnce(client1).mockReturnValueOnce(client2) @@ -155,11 +155,13 @@ describe('useHostClient', () => { let selectedHostId = HOST.id let selectedClient: RpcClient | null = null + let selectedClientId: string | null = null let selectedState: ConnectionState = 'disconnected' let renderer: ReactTestRenderer | null = null function Probe(): null { const selected = useHostClient(selectedHostId) selectedClient = selected.client + selectedClientId = selected.clientId selectedState = selected.state useHostClient(host2.id) return null @@ -171,16 +173,18 @@ describe('useHostClient', () => { await Promise.resolve() }) expect(selectedClient).toBe(client1) + expect(selectedClientId).toBe(HOST.deviceToken) expect(selectedState).toBe('connected') selectedHostId = host2.id - client2.emitState('disconnected') await act(async () => { + client2.emitState('disconnected') renderer?.update(createElement(RpcClientProvider, null, createElement(Probe))) await Promise.resolve() }) expect(selectedClient).toBe(client2) + expect(selectedClientId).toBe(host2.deviceToken) expect(selectedState).toBe('disconnected') expect(connectMock).toHaveBeenCalledTimes(2) } finally { diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx index f83519940c1..76d26c1bab5 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -7,7 +7,6 @@ import { useEffect, useMemo, useRef, - useState, type ReactNode } from 'react' import type { RpcClient } from './rpc-client' @@ -35,6 +34,15 @@ import { import type { ConnectionState, HostProfile } from './types' import type { RpcClientContextValue } from './rpc-client-context-contract' +export { + useDisconnectHostClient, + useForceReconnect, + useForgetHostClient, + useHostClient, + usePrimeHosts, + useRefreshHostClient +} from './host-client-hooks' + type StoreEntry = HostClientStoreEntry const Ctx = createContext(null) @@ -364,99 +372,3 @@ export function useRpcClientContext(): RpcClientContextValue { } return ctx } - -// Primary hook for screens: acquires the shared client on mount, releases on unmount, re-renders on state change. -export function useHostClient(hostId: string | undefined): { - client: RpcClient | null - state: ConnectionState -} { - const ctx = useRpcClientContext() - const [, force] = useState(0) - // Why: an absent entry at mount is almost always the open racing the render, not a - // dead host — seed amber; a failed open notifies 'disconnected' moments later. - const [state, setState] = useState(() => - hostId ? (ctx.getKnownState(hostId) ?? 'connecting') : 'disconnected' - ) - const clientRef = useRef(null) - const clientHostIdRef = useRef(hostId) - const acquisitionRef = useRef({}) - - useEffect(() => { - if (!hostId) { - clientRef.current = null - clientHostIdRef.current = undefined - setState('disconnected') - return - } - clientHostIdRef.current = hostId - let cancelled = false - // Subscribe before acquire so any state change during open is captured. - const unsub = ctx.subscribeHostState(hostId, (next) => { - if (cancelled) { - return - } - setState(next) - // Why: async open and forceReconnect swap the client object; re-read each state change so screens never drive a stale one. - const found = ctx.getAllClients().find((entry) => entry.hostId === hostId) - if (found && found.client !== clientRef.current) { - clientRef.current = found.client - force((n) => n + 1) - } else if (!found && clientRef.current) { - // Why: disconnect/forget deletes the entry; never retain a dead client (STA-1511). - clientRef.current = null - force((n) => n + 1) - } - }) - const initial = ctx.acquire(hostId, acquisitionRef.current) - clientRef.current = initial - setState(ctx.getKnownState(hostId) ?? 'connecting') - if (initial) { - // Why: two cached hosts can both be connected, so equal state values cannot reveal the replacement client. - force((n) => n + 1) - } - return () => { - cancelled = true - unsub() - ctx.release(hostId, acquisitionRef.current) - clientRef.current = null - clientHostIdRef.current = undefined - } - }, [ctx, hostId]) - - // Why: Expo can reuse the screen before effects bind the next host; never expose the prior host's client or state in that render. - const bound = clientHostIdRef.current === hostId - const boundState = bound - ? state - : hostId - ? (ctx.getKnownState(hostId) ?? 'connecting') - : 'disconnected' - return { client: bound ? clientRef.current : null, state: boundState } -} - -// Why: host-store's removeHost() must close the live client but has no React-side handle; this hook bridges to it. -export function useRefreshHostClient(): (hostId: string) => void { - const ctx = useRpcClientContext() - return ctx.refreshHostClient -} - -export function useForgetHostClient(): (hostId: string) => void { - const ctx = useRpcClientContext() - return ctx.forgetHostClient -} - -export function useDisconnectHostClient(): (hostId: string) => void { - const ctx = useRpcClientContext() - return ctx.disconnectHostClient -} - -// Why: future-proof "Connection issues — try again" affordance. -export function useForceReconnect(): (hostId: string) => Promise { - const ctx = useRpcClientContext() - return ctx.forceReconnect -} - -// Why: primes already-loaded HostProfiles so the provider can skip a second loadHosts()/Keychain pass on cold start. -export function usePrimeHosts(): (hosts: HostProfile[]) => void { - const ctx = useRpcClientContext() - return ctx.primeHosts -} diff --git a/mobile/src/transport/direct-connection-log.ts b/mobile/src/transport/direct-connection-log.ts index ef805e643c5..2630b008459 100644 --- a/mobile/src/transport/direct-connection-log.ts +++ b/mobile/src/transport/direct-connection-log.ts @@ -43,4 +43,8 @@ export class DirectConnectionLog { { code: 'liveness-timeout' } ) } + + connected = (): void => { + this.emit('success', 'Authenticated', 'Channel ready for RPC', { code: 'direct-connected' }) + } } diff --git a/mobile/src/transport/direct-rpc-client.ts b/mobile/src/transport/direct-rpc-client.ts index 36ed573aa5b..16306f95cdd 100644 --- a/mobile/src/transport/direct-rpc-client.ts +++ b/mobile/src/transport/direct-rpc-client.ts @@ -18,6 +18,7 @@ import { import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog' import { isStaleForegroundDial } from './rpc-stale-dial' import type { ConnectionState, ForegroundNudgeReason, RpcResponse } from './types' +import { negotiateMobileRuntimeCapabilities } from './mobile-runtime-capability-negotiation' const LIVENESS_REQUEST_ID_PREFIX = 'mobile-liveness-' @@ -226,17 +227,22 @@ export class DirectRpcClient implements RpcClient { } private handleAuthenticated(session: RpcClientSocketSession): void { - console.log('[net] e2ee_authenticated — connected', { streamCount: this.streams.size() }) this.livenessSession = session this.liveness.start(session) - this.authenticationGeneration++ - this.reconnect.authenticated() - this.authenticationRetry.accepted() - this.connectionState.publish('connected') - this.connectionLog.emit('success', 'Authenticated', 'Channel ready for RPC', { - code: 'direct-connected' + const generation = ++this.authenticationGeneration + negotiateMobileRuntimeCapabilities({ + sendRequest: (method, params) => + this.requests.sendAuthenticatedRequest(method, params, 5_000), + current: () => this.socketSession === session && this.authenticationGeneration === generation, + onReady: () => { + this.reconnect.authenticated() + this.authenticationRetry.accepted() + this.connectionState.publish('connected') + this.connectionLog.connected() + this.streams.replayAfterAuthentication() + }, + onFailure: () => this.socketClose.forceClose(session) }) - this.streams.replayAfterAuthentication() } private handleRpcResponse(response: RpcResponse): void { diff --git a/mobile/src/transport/foreground-stale-dial-restart.test.ts b/mobile/src/transport/foreground-stale-dial-restart.test.ts index 8c9ebd94d7e..39410e8fc3f 100644 --- a/mobile/src/transport/foreground-stale-dial-restart.test.ts +++ b/mobile/src/transport/foreground-stale-dial-restart.test.ts @@ -25,6 +25,10 @@ vi.mock('./e2ee', () => ({ decryptBytes: (bytes: Uint8Array) => bytes })) +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + // Mirrors React Native's WebSocket: readyState lives in JS and only advances on // a delivered event, so a socket the OS killed while the app was suspended stays // CONNECTING forever from the client's point of view. diff --git a/mobile/src/transport/host-client-context-state.ts b/mobile/src/transport/host-client-context-state.ts index 972a810af21..859e5d847f9 100644 --- a/mobile/src/transport/host-client-context-state.ts +++ b/mobile/src/transport/host-client-context-state.ts @@ -79,6 +79,7 @@ export function createHostClientSelectors( return { getKnownState, getState: (hostId: string): ConnectionState => getKnownState(hostId) ?? 'disconnected', + getClientId: (hostId: string): string | null => entries.get(hostId)?.clientId ?? null, getReconnectAttempt: (hostId: string): number => entries.get(hostId)?.client.getReconnectAttempt() ?? 0, getLastConnectedAt: (hostId: string): number | null => diff --git a/mobile/src/transport/host-client-hooks.ts b/mobile/src/transport/host-client-hooks.ts new file mode 100644 index 00000000000..c7f885034c2 --- /dev/null +++ b/mobile/src/transport/host-client-hooks.ts @@ -0,0 +1,92 @@ +import { useEffect, useRef, useState } from 'react' +import type { RpcClient } from './rpc-client' +import type { ConnectionState, HostProfile } from './types' +import type { HostClientAcquisition } from './host-client-acquisition-registry' +import { useRpcClientContext } from './client-context' + +// Primary hook for screens: acquires the shared client on mount, releases on unmount, re-renders on state change. +export function useHostClient(hostId: string | undefined): { + client: RpcClient | null + clientId: string | null + state: ConnectionState +} { + const ctx = useRpcClientContext() + const [, force] = useState(0) + const [state, setState] = useState(() => + hostId ? (ctx.getKnownState(hostId) ?? 'connecting') : 'disconnected' + ) + const clientRef = useRef(null) + const clientHostIdRef = useRef(hostId) + const acquisitionRef = useRef({}) + + useEffect(() => { + if (!hostId) { + clientRef.current = null + clientHostIdRef.current = undefined + setState('disconnected') + return + } + clientHostIdRef.current = hostId + let cancelled = false + const unsub = ctx.subscribeHostState(hostId, (next) => { + if (cancelled) { + return + } + setState(next) + const found = ctx.getAllClients().find((entry) => entry.hostId === hostId) + if (found && found.client !== clientRef.current) { + clientRef.current = found.client + force((n) => n + 1) + } else if (!found && clientRef.current) { + clientRef.current = null + force((n) => n + 1) + } + }) + const initial = ctx.acquire(hostId, acquisitionRef.current) + clientRef.current = initial + setState(ctx.getKnownState(hostId) ?? 'connecting') + if (initial) { + force((n) => n + 1) + } + return () => { + cancelled = true + unsub() + ctx.release(hostId, acquisitionRef.current) + clientRef.current = null + clientHostIdRef.current = undefined + } + }, [ctx, hostId]) + + const bound = clientHostIdRef.current === hostId + const boundClient = bound ? clientRef.current : null + const boundState = bound + ? state + : hostId + ? (ctx.getKnownState(hostId) ?? 'connecting') + : 'disconnected' + return { + client: boundClient, + clientId: boundClient && hostId ? ctx.getClientId(hostId) : null, + state: boundState + } +} + +export function useRefreshHostClient(): (hostId: string) => void { + return useRpcClientContext().refreshHostClient +} + +export function useForgetHostClient(): (hostId: string) => void { + return useRpcClientContext().forgetHostClient +} + +export function useDisconnectHostClient(): (hostId: string) => void { + return useRpcClientContext().disconnectHostClient +} + +export function useForceReconnect(): (hostId: string) => Promise { + return useRpcClientContext().forceReconnect +} + +export function usePrimeHosts(): (hosts: HostProfile[]) => void { + return useRpcClientContext().primeHosts +} diff --git a/mobile/src/transport/host-entry-opener.ts b/mobile/src/transport/host-entry-opener.ts index 6e29f55d985..03d6363c7c7 100644 --- a/mobile/src/transport/host-entry-opener.ts +++ b/mobile/src/transport/host-entry-opener.ts @@ -12,6 +12,7 @@ import type { ConnectionState, HostProfile } from './types' export type HostClientStoreEntry = { client: RpcClient + clientId: string state: ConnectionState refCount: number unsubState: () => void @@ -130,6 +131,7 @@ export async function openHostClientEntry( }) ?? (() => {}) const entry: HostClientStoreEntry = { client, + clientId: host.deviceToken, state: client.getState(), refCount: state.pendingAcquisitions.get(hostId) ?? 0, unsubState, diff --git a/mobile/src/transport/mobile-endpoint-supervisor-stalled-cell.test.ts b/mobile/src/transport/mobile-endpoint-supervisor-stalled-cell.test.ts new file mode 100644 index 00000000000..be4050cee5b --- /dev/null +++ b/mobile/src/transport/mobile-endpoint-supervisor-stalled-cell.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor' +import { + dependencies, + FakeLogicalClient, + FakeRelaySession, + host, + relay +} from './mobile-endpoint-supervisor-test-fakes' +import { ReplacementAuthenticationTimeoutError } from './replacement-session-authentication' +import type { RpcClient } from './rpc-client' + +vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) +vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' })) +vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) })) + +// The 2026-09-03 incident: five consecutive "authentication timed out" dials against a +// live desktop while the cell's assignment tables were lock-contended. Each logged +// failure was two dials — the timeout counted as a director-class failure, so the phone +// re-resolved the same cell and waited the full bound again. +describe('relay dial against a cell that took the dial and stalled', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + function timingOut(logical: FakeLogicalClient, error: Error): void { + logical.migrateTo.mockImplementation(async (session: RpcClient) => { + session.close() + throw error + }) + } + + it('does not re-resolve the director and names the stalled stage', async () => { + const logical = new FakeLogicalClient('disconnected', 'lan') + timingOut(logical, new ReplacementAuthenticationTimeoutError('awaiting-hello', 30_000)) + const openRelay = vi.fn(() => new FakeRelaySession('connecting')) + const resolveRelay = vi.fn(async () => relay) + const onLog = vi.fn() + const supervisor = new MobileEndpointSupervisor( + logical, + host, + dependencies({ openRelay, resolveRelay, onLog }) + ) + + await supervisor.start() + + expect(openRelay).toHaveBeenCalledOnce() + expect(resolveRelay).not.toHaveBeenCalled() + expect(onLog).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'relay-dial-failed', + detail: expect.stringContaining('timed out (awaiting-hello, 30s)') + }) + ) + supervisor.stop() + }) + + it('still re-resolves the director when the cell socket never opened', async () => { + const logical = new FakeLogicalClient('disconnected', 'lan') + timingOut(logical, new ReplacementAuthenticationTimeoutError('opening', 12_000)) + const openRelay = vi.fn(() => new FakeRelaySession('connecting')) + const resolveRelay = vi.fn(async () => relay) + const supervisor = new MobileEndpointSupervisor( + logical, + host, + dependencies({ openRelay, resolveRelay }) + ) + + await supervisor.start() + + expect(resolveRelay).toHaveBeenCalledOnce() + expect(openRelay).toHaveBeenCalledTimes(2) + supervisor.stop() + }) +}) diff --git a/mobile/src/transport/mobile-endpoint-supervisor-support.ts b/mobile/src/transport/mobile-endpoint-supervisor-support.ts index 6ee0a6b42cb..1a2c00f12de 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor-support.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor-support.ts @@ -1,5 +1,6 @@ import { RelayOuterError } from './mobile-relay-e2ee-link' import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel' +import { ReplacementAuthenticationTimeoutError } from './replacement-session-authentication' import type { RelayReconnectController } from './mobile-relay-reconnect-controller' import type { StableLogicalRpcClient } from './stable-logical-rpc-client' import type { HostProfile } from './types' @@ -68,6 +69,11 @@ export async function dialRelayThroughDirectorFallback(args: { } export function isDirectorResolutionFailure(error: Error): boolean { + // Why: a cell that took relay-auth and went quiet is the right cell working slowly; + // re-resolving it just doubles the wait against the same contended window. + if (error instanceof ReplacementAuthenticationTimeoutError) { + return error.stage === null || error.stage === 'opening' + } return ( !(error instanceof MobileE2EEAuthenticationError) && (!(error instanceof RelayOuterError) || [4409, 4503, 1006].includes(error.code)) diff --git a/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts index 4023a1a8e39..1dc1473d9db 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts @@ -1,6 +1,7 @@ import { vi } from 'vitest' import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle' import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' +import { RelayDialStageTracker, type RelayDialStage } from './relay-dial-stage' import type { MobileEndpointSupervisorDependencies } from './mobile-endpoint-supervisor' import type { RpcClient } from './rpc-client' import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client' @@ -51,6 +52,10 @@ export class FakeRelaySession extends FakeSession implements MobileRelayRpcSessi // Why: production-realistic defaults — fictional fake values hid three // live defects in this subsystem (latch, churn, int32 timer overflow). getAttachDeadlineAt = () => Date.now() + 10_000 + readonly dialStage = new RelayDialStageTracker() + getDialStage = () => this.dialStage.getDialStage() + onDialStageChange = (listener: (stage: RelayDialStage) => void) => + this.dialStage.onDialStageChange(listener) getResumeExpiresAt = () => this.resumeExpiry getResumeConfirmation = () => ({ v: 1 as const, diff --git a/mobile/src/transport/mobile-relay-e2ee-link.test.ts b/mobile/src/transport/mobile-relay-e2ee-link.test.ts index aa2d42b13f0..965135511eb 100644 --- a/mobile/src/transport/mobile-relay-e2ee-link.test.ts +++ b/mobile/src/transport/mobile-relay-e2ee-link.test.ts @@ -60,6 +60,61 @@ describe('MobileRelayE2eeLink', () => { expect(socket.close).toHaveBeenCalledOnce() }) + it('reports open only once relay-auth is on the wire', () => { + const socket = new ThrowingSocket() + const onOpen = vi.fn() + const sent: string[] = [] + socket.send.mockImplementation((frame: string) => { + sent.push(frame) + }) + new MobileRelayE2eeLink({ + endpoint: { + cellUrl: 'https://relay-c1.onorca.dev', + relayHostId: 'AbCdEf0123_-xyZ9' + }, + credential: 'credential', + expectedCredentialKind: 'resume', + deviceToken: 'device-token', + desktopPublicKeyB64: 'desktop-key', + onAuthenticated: vi.fn(), + onText: vi.fn(), + onBinary: vi.fn(), + onOpen, + onError: vi.fn(), + createSocket: () => socket as unknown as WebSocket + }) + + expect(onOpen).not.toHaveBeenCalled() + socket.onopen?.() + expect(sent).toHaveLength(1) + expect(JSON.parse(sent[0]!)).toMatchObject({ type: 'relay-auth', mode: 'connect' }) + expect(onOpen).toHaveBeenCalledOnce() + }) + + it('does not report open when the relay-auth write fails', () => { + const socket = new ThrowingSocket() + const onOpen = vi.fn() + new MobileRelayE2eeLink({ + endpoint: { + cellUrl: 'https://relay-c1.onorca.dev', + relayHostId: 'AbCdEf0123_-xyZ9' + }, + credential: 'credential', + expectedCredentialKind: 'resume', + deviceToken: 'device-token', + desktopPublicKeyB64: 'desktop-key', + onAuthenticated: vi.fn(), + onText: vi.fn(), + onBinary: vi.fn(), + onOpen, + onError: vi.fn(), + createSocket: () => socket as unknown as WebSocket + }) + + socket.onopen?.() + expect(onOpen).not.toHaveBeenCalled() + }) + it('keeps a typed close code when transport error precedes close', () => { const socket = new ThrowingSocket() const onError = vi.fn() diff --git a/mobile/src/transport/mobile-relay-e2ee-link.ts b/mobile/src/transport/mobile-relay-e2ee-link.ts index f19417a60f1..7743deb23dd 100644 --- a/mobile/src/transport/mobile-relay-e2ee-link.ts +++ b/mobile/src/transport/mobile-relay-e2ee-link.ts @@ -26,6 +26,8 @@ type MobileRelayE2eeLinkOptions = { onText: (plaintext: string) => void onBinary: (plaintext: Uint8Array) => void onHello?: (hello: Extract) => void + // Fired once relay-auth is on the wire: from here the cell owns the wait. + onOpen?: () => void onError: (error: Error) => void createSocket?: (url: string) => WebSocket } @@ -96,7 +98,9 @@ export class MobileRelayE2eeLink { ) } catch (error) { this.fail(asError(error)) + return } + this.options.onOpen?.() } this.socket.onmessage = (event) => { this.inboundChain = this.inboundChain diff --git a/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts b/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts index a3885a226c0..b811721e562 100644 --- a/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts +++ b/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts @@ -74,6 +74,16 @@ async function authenticateSession(onLog?: ConnectionLogSink) { _meta: { runtimeId: 'runtime-1' } }) ) + await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledTimes(2)) + const capabilities = sentRequests()[1]! + fakes.linkOptions!.onText( + JSON.stringify({ + id: capabilities.id, + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-1' } + }) + ) await vi.waitFor(() => expect(session.getState()).toBe('connected')) fakes.sendText.mockClear() return session diff --git a/mobile/src/transport/mobile-relay-rpc-session.test.ts b/mobile/src/transport/mobile-relay-rpc-session.test.ts index d5b547885cf..5887dffc73d 100644 --- a/mobile/src/transport/mobile-relay-rpc-session.test.ts +++ b/mobile/src/transport/mobile-relay-rpc-session.test.ts @@ -11,6 +11,7 @@ const fakes = vi.hoisted(() => ({ endpoint: { cellUrl: string; relayHostId: string } credential: string expectedCredentialKind: string + onOpen(): void onHello(value: unknown): void onAuthenticated(): void onText(value: string): void @@ -54,7 +55,7 @@ function openSession() { }) } -async function authenticateSession() { +async function confirmResume() { const session = openSession() fakes.linkOptions!.onHello({ type: 'relay-hello', @@ -92,9 +93,39 @@ async function authenticateSession() { _meta: { runtimeId: 'runtime-1' } }) ) + await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledTimes(2)) + const capabilityRequest = JSON.parse(fakes.sendText.mock.calls[1]![0] as string) as { + id: string + method: string + deviceToken: string + params: { clientCapabilities?: string[] } + } + return { session, confirmationRequest: request, capabilityRequest } +} + +async function authenticateSession(capabilitySupported = true) { + const { session, confirmationRequest, capabilityRequest } = await confirmResume() + expect(session.getState()).toBe('handshaking') + fakes.linkOptions!.onText( + JSON.stringify( + capabilitySupported + ? { + id: capabilityRequest.id, + ok: true, + result: capabilityRequest.params, + _meta: { runtimeId: 'runtime-1' } + } + : { + id: capabilityRequest.id, + ok: false, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId: 'runtime-1' } + } + ) + ) await vi.waitFor(() => expect(session.getState()).toBe('connected')) fakes.sendText.mockClear() - return { session, confirmationRequest: request } + return { session, confirmationRequest, capabilityRequest } } describe('mobile relay RPC session', () => { @@ -106,7 +137,7 @@ describe('mobile relay RPC session', () => { afterEach(() => vi.useRealTimers()) it('requires exact resume observations and confirms by request ID before becoming connected', async () => { - const { session, confirmationRequest } = await authenticateSession() + const { session, confirmationRequest, capabilityRequest } = await authenticateSession() expect(fakes.linkOptions).toMatchObject({ endpoint: relay, @@ -120,9 +151,61 @@ describe('mobile relay RPC session', () => { }) expect(confirmationRequest.params).not.toHaveProperty('relayDeviceId') expect(confirmationRequest.params).not.toHaveProperty('acceptedCredentialVersion') + expect(capabilityRequest).toMatchObject({ + method: 'runtime.clientCapabilities.update', + params: { + clientCapabilities: expect.arrayContaining(['agent-session.structured.v1']) + }, + deviceToken: 'device-token' + }) expect(session.getAttachDeadlineAt()).toEqual(expect.any(Number)) }) + it('connects when an older runtime rejects capability negotiation', async () => { + const { session } = await authenticateSession(false) + + expect(session.getState()).toBe('connected') + expect(session.getFailure()).toBeNull() + }) + + it('connects when the relay never answers capability negotiation', async () => { + const { session } = await confirmResume() + + // Why: the advisory's own deadline used to fail confirmResume, so a link too slow to + // answer within the request timeout never published 'connected' — it just redialled. + await vi.waitFor(() => expect(session.getState()).toBe('connected'), { timeout: 5_000 }) + expect(session.getFailure()).toBeNull() + }) + + // Why: ConnectionState stays 'connecting' until relay-hello, so the migration bound + // needs a separate signal to tell "cell never answered the upgrade" from "cell took + // relay-auth and is still resolving the assignment". + it('reports the dial stage as the link opens, receives hello, and authenticates', async () => { + const session = openSession() + const stages: string[] = [] + session.onDialStageChange((stage) => stages.push(stage)) + expect(session.getDialStage()).toBe('opening') + + fakes.linkOptions!.onOpen() + expect(session.getDialStage()).toBe('awaiting-hello') + expect(session.getState()).toBe('connecting') + fakes.linkOptions!.onHello({ + type: 'relay-hello', + ok: true, + credentialKind: 'resume', + leaseExpiresAt: Date.now() + 10_000, + acceptedCredentialVersion: 3, + acceptedAs: 'current', + resumeExpiresAt: Date.now() + 300_000 + }) + expect(session.getDialStage()).toBe('handshaking') + fakes.linkOptions!.onAuthenticated() + expect(session.getDialStage()).toBe('confirming') + await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledOnce()) + expect(stages).toEqual(['awaiting-hello', 'handshaking', 'confirming']) + session.close() + }) + it('rejects a mismatched outer credential version and closes the physical link', () => { const session = openSession() fakes.linkOptions!.onHello({ diff --git a/mobile/src/transport/mobile-relay-rpc-session.ts b/mobile/src/transport/mobile-relay-rpc-session.ts index f242fce07ba..947a1d23ce8 100644 --- a/mobile/src/transport/mobile-relay-rpc-session.ts +++ b/mobile/src/transport/mobile-relay-rpc-session.ts @@ -9,7 +9,10 @@ import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' import { openRpcRequestBudget, resolvePostConnectRequestTimeout } from './rpc-request-budget' import { isRpcResponse } from './rpc-response-shape' +import { RelayDialStageTracker, type RelayDialStageSource } from './relay-dial-stage' +import { RelayPendingRequests } from './relay-pending-requests' import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog' +import { settleMobileRuntimeCapabilities } from './mobile-runtime-capability-negotiation' import type { RpcClient } from './rpc-client' import type { ConnectionLogSink, ConnectionState, RpcResponse } from './types' @@ -18,20 +21,15 @@ const RELAY_MISSED_PROBE_LIMIT = 2 const RELAY_FOREGROUND_PROBE_MIN_INTERVAL_MS = 10_000 let relayRpcSessionSequence = 0 -type PendingRequest = { - resolve: (response: RpcResponse) => void - reject: (error: Error) => void - timer: ReturnType -} - -export type MobileRelayRpcSession = RpcClient & { - // The cell's attach-reservation deadline (~10s). Diagnostics only — never - // schedule anything from it; rotation keys off getResumeExpiresAt(). - getAttachDeadlineAt(): number | null - getResumeExpiresAt(): number | null - getResumeConfirmation(): DeviceResumeConfirmed | null - getFailure(): Error | null -} +export type MobileRelayRpcSession = RpcClient & + RelayDialStageSource & { + // The cell's attach-reservation deadline (~10s). Diagnostics only — never + // schedule anything from it; rotation keys off getResumeExpiresAt(). + getAttachDeadlineAt(): number | null + getResumeExpiresAt(): number | null + getResumeConfirmation(): DeviceResumeConfirmed | null + getFailure(): Error | null + } export function connectMobileRelayRpcSession(args: { relay: MobileRelayEndpoint @@ -45,10 +43,9 @@ export function connectMobileRelayRpcSession(args: { onLog?: ConnectionLogSink }): MobileRelayRpcSession { const requestTimeoutMs = args.requestTimeoutMs ?? 30_000 - const pending = new Map() + const pending = new RelayPendingRequests() const stateListeners = new Set<(state: ConnectionState) => void>() let state: ConnectionState = 'connecting' - let requestCounter = 0 let lastConnectedAt: number | null = null let attachDeadlineAt: number | null = null let resumeExpiresAt: number | null = null @@ -58,8 +55,9 @@ export function connectMobileRelayRpcSession(args: { let logSequence = 0 const logSessionId = `${Date.now().toString(36)}-${(++relayRpcSessionSequence).toString(36)}` const livenessIdentity = {} + const dialStage = new RelayDialStageTracker() const streams = new MobileRelayRpcStreams({ - nextId, + nextId: () => pending.nextId(), sendFrame, waitForConnected: () => waitForConnected() }) @@ -71,6 +69,7 @@ export function connectMobileRelayRpcSession(args: { deviceToken: args.deviceToken, desktopPublicKeyB64: args.desktopPublicKeyB64, createSocket: args.createSocket, + onOpen: () => dialStage.advance('awaiting-hello'), onHello: (hello) => { if ( hello.credentialKind !== 'resume' || @@ -81,6 +80,7 @@ export function connectMobileRelayRpcSession(args: { } attachDeadlineAt = hello.leaseExpiresAt resumeExpiresAt = hello.resumeExpiresAt + dialStage.advance('handshaking') publishState('handshaking') }, onAuthenticated: () => void confirmResume(), @@ -132,10 +132,12 @@ export function connectMobileRelayRpcSession(args: { closed = true livenessWatchdog.stop(livenessIdentity) link.close() - rejectPending(new Error('Client closed')) + pending.rejectAll(new Error('Client closed')) streams.clear() publishState('disconnected') }, + getDialStage: () => dialStage.getDialStage(), + onDialStageChange: (listener) => dialStage.onDialStageChange(listener), getAttachDeadlineAt: () => attachDeadlineAt, getResumeExpiresAt: () => resumeExpiresAt, getResumeConfirmation: () => resumeConfirmation, @@ -148,7 +150,8 @@ export function connectMobileRelayRpcSession(args: { missedProbeLimit: RELAY_MISSED_PROBE_LIMIT, voluntaryProbeMinIntervalMs: RELAY_FOREGROUND_PROBE_MIN_INTERVAL_MS, sendProbe: () => - state === 'connected' && sendFrame({ id: nextId(), method: 'status.get', params: undefined }), + state === 'connected' && + sendFrame({ id: pending.nextId(), method: 'status.get', params: undefined }), onTimeout: (evidence) => { args.onLog?.({ id: `relay-liveness-${logSessionId}-${++logSequence}`, @@ -165,6 +168,7 @@ export function connectMobileRelayRpcSession(args: { return client async function confirmResume(): Promise { + dialStage.advance('confirming') try { const response = await sendRpc( 'pairing.getEndpoints', @@ -182,6 +186,10 @@ export function connectMobileRelayRpcSession(args: { resumeConfirmation = result.resumeConfirmation resumeExpiresAt = result.resumeConfirmation.resumeExpiresAt lastConnectedAt = Date.now() + // Why: an unanswered advisory must not keep a slow relay from ever reaching connected. + await settleMobileRuntimeCapabilities((method, params) => + sendRpc(method, params, requestTimeoutMs, true) + ) livenessWatchdog.start(livenessIdentity) publishState('connected') } catch (error) { @@ -198,17 +206,17 @@ export function connectMobileRelayRpcSession(args: { if (closed || (!beforeConnected && state !== 'connected')) { return Promise.reject(new Error('relay session not connected')) } - const id = nextId() + const id = pending.nextId() return new Promise((resolve, reject) => { const timer = setTimeout(() => { - pending.delete(id) + pending.drop(id) // Why: the frame was written long ago — the desktop may have processed it. reject(markRpcDeliveryUnknown(new Error(`relay RPC timed out: ${method}`))) }, timeoutMs) - pending.set(id, { resolve, reject, timer }) + pending.track(id, { resolve, reject, timer }) if (!sendFrame({ id, method, params })) { clearTimeout(timer) - pending.delete(id) + pending.drop(id) reject(new Error('relay E2EE channel not ready')) } }) @@ -228,11 +236,7 @@ export function connectMobileRelayRpcSession(args: { if (!isRpcResponse(value)) { return } - const request = pending.get(value.id) - if (request) { - clearTimeout(request.timer) - pending.delete(value.id) - request.resolve(value) + if (pending.settle(value)) { return } streams.handleResponse(value) @@ -288,28 +292,9 @@ export function connectMobileRelayRpcSession(args: { failure = error livenessWatchdog.stop(livenessIdentity) link.close() - rejectPending(error) + pending.rejectAll(error) publishState(error instanceof MobileE2EEAuthenticationError ? 'auth-failed' : 'disconnected') } - - function rejectPending(error: Error): void { - if (pending.size === 0) { - return - } - // Why: pending entries only exist after their frame reached the authenticated - // link (sendFrame failures delete them synchronously), so the desktop may - // have processed them — mark the ambiguity for callers. - markRpcDeliveryUnknown(error) - for (const request of pending.values()) { - clearTimeout(request.timer) - request.reject(error) - } - pending.clear() - } - - function nextId(): string { - return `relay-rpc-${++requestCounter}-${Date.now()}` - } } function asError(error: unknown): Error { diff --git a/mobile/src/transport/mobile-relay-runtime-failover.test.ts b/mobile/src/transport/mobile-relay-runtime-failover.test.ts index 795f2618dfb..01f4d45feb0 100644 --- a/mobile/src/transport/mobile-relay-runtime-failover.test.ts +++ b/mobile/src/transport/mobile-relay-runtime-failover.test.ts @@ -9,6 +9,7 @@ import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel import { RelayOuterError } from './mobile-relay-e2ee-link' import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle' import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' +import { RelayDialStageTracker, type RelayDialStage } from './relay-dial-stage' import { MobileEndpointSupervisor, type MobileEndpointSupervisorDependencies @@ -81,6 +82,10 @@ class FakeRelaySession extends FakeSession implements MobileRelayRpcSession { // Why: production-realistic constants — fictional fake values hid three // live defects in this subsystem (latch, churn, int32 timer overflow). getAttachDeadlineAt = () => Date.now() + 10_000 + readonly dialStage = new RelayDialStageTracker() + getDialStage = () => this.dialStage.getDialStage() + onDialStageChange = (listener: (stage: RelayDialStage) => void) => + this.dialStage.onDialStageChange(listener) getResumeExpiresAt = () => Date.now() + 30 * 24 * 3_600_000 getResumeConfirmation = () => null getFailure = () => this.failure diff --git a/mobile/src/transport/mobile-runtime-capability-negotiation.test.ts b/mobile/src/transport/mobile-runtime-capability-negotiation.test.ts new file mode 100644 index 00000000000..6eb659d14a6 --- /dev/null +++ b/mobile/src/transport/mobile-runtime-capability-negotiation.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from 'vitest' +import { negotiateMobileRuntimeCapabilities } from './mobile-runtime-capability-negotiation' +import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' +import type { RpcResponse } from './types' + +function negotiate(args: { reject: unknown; current?: boolean }): { + onReady: ReturnType + onFailure: ReturnType +} { + const onReady = vi.fn() + const onFailure = vi.fn() + negotiateMobileRuntimeCapabilities({ + sendRequest: () => Promise.reject(args.reject), + current: () => args.current ?? true, + onReady, + onFailure + }) + return { onReady, onFailure } +} + +describe('mobile runtime capability negotiation', () => { + it('proceeds when the host never answers, so a slow link still reaches connected', async () => { + const timedOut = markRpcDeliveryUnknown( + new Error('Request timed out: runtime.clientCapabilities.update') + ) + const { onReady, onFailure } = negotiate({ reject: timedOut }) + + await vi.waitFor(() => expect(onReady).toHaveBeenCalledTimes(1)) + expect(onFailure).not.toHaveBeenCalled() + }) + + it('proceeds when the socket drops the request mid-flight', async () => { + const interrupted = markRpcDeliveryUnknown(new Error('Connection interrupted')) + const { onReady, onFailure } = negotiate({ reject: interrupted }) + + await vi.waitFor(() => expect(onReady).toHaveBeenCalledTimes(1)) + expect(onFailure).not.toHaveBeenCalled() + }) + + it('fails a socket that could not put the advisory on the wire', async () => { + const { onReady, onFailure } = negotiate({ reject: new Error('Connection interrupted') }) + + await vi.waitFor(() => expect(onFailure).toHaveBeenCalledTimes(1)) + expect(onReady).not.toHaveBeenCalled() + }) + + it('leaves a replaced session alone on an unanswered request', async () => { + const timedOut = markRpcDeliveryUnknown(new Error('Request timed out')) + const { onReady, onFailure } = negotiate({ reject: timedOut, current: false }) + + await vi.waitFor(() => expect(onReady).not.toHaveBeenCalled()) + expect(onFailure).not.toHaveBeenCalled() + }) + + it('leaves a replaced session alone on a successful response', async () => { + const onReady = vi.fn() + const onFailure = vi.fn() + negotiateMobileRuntimeCapabilities({ + sendRequest: () => + Promise.resolve({ id: 'capability-1', ok: true, result: {} } as RpcResponse), + current: () => false, + onReady, + onFailure + }) + + await vi.waitFor(() => expect(onReady).not.toHaveBeenCalled()) + expect(onFailure).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/transport/mobile-runtime-capability-negotiation.ts b/mobile/src/transport/mobile-runtime-capability-negotiation.ts new file mode 100644 index 00000000000..7221f8e085a --- /dev/null +++ b/mobile/src/transport/mobile-runtime-capability-negotiation.ts @@ -0,0 +1,57 @@ +import { + MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD, + mobileRuntimeClientCapabilityUpdateParams +} from './mobile-runtime-client-capabilities' +import { isRpcDeliveryUnknown } from './rpc-delivery-ambiguity' +import type { RpcResponse } from './types' + +type CapabilityRequest = (method: string, params: unknown) => Promise + +/** + * The advisory is one-way and its result is discarded, so an unanswered request says nothing about + * the link — only a frame that never reached the wire proves the socket cannot carry traffic. + * Everything else (timeout, mid-flight drop) settles like an explicit rejection: capabilities + * unavailable, proceed. Rejects for the unsent case alone. + */ +export async function settleMobileRuntimeCapabilities( + sendRequest: CapabilityRequest +): Promise { + let response: RpcResponse + try { + response = await sendRequest( + MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD, + mobileRuntimeClientCapabilityUpdateParams() + ) + } catch (error) { + if (!isRpcDeliveryUnknown(error)) { + throw error + } + console.warn('[net] mobile capability negotiation unanswered — proceeding', error) + return + } + if (!response.ok) { + console.warn('[net] mobile capability negotiation unavailable', response.error.code) + } +} + +export function negotiateMobileRuntimeCapabilities(args: { + sendRequest: CapabilityRequest + current: () => boolean + onReady: () => void + onFailure: () => void +}): void { + void settleMobileRuntimeCapabilities(args.sendRequest) + .then(() => { + if (args.current()) { + args.onReady() + } + }) + .catch((error: unknown) => { + if (!args.current()) { + return + } + // Why: nothing else force-closes a socket that cannot send before `connected` is published. + console.warn('[net] mobile capability negotiation could not be sent', error) + args.onFailure() + }) +} diff --git a/mobile/src/transport/mobile-runtime-client-capabilities.ts b/mobile/src/transport/mobile-runtime-client-capabilities.ts new file mode 100644 index 00000000000..5b3dc977240 --- /dev/null +++ b/mobile/src/transport/mobile-runtime-client-capabilities.ts @@ -0,0 +1,44 @@ +import { + STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../src/shared/protocol-version' +import { remoteRuntimeClientCapabilities } from '../../../src/shared/remote-runtime-client-capabilities' + +export const MOBILE_RUNTIME_CLIENT_CAPABILITIES = remoteRuntimeClientCapabilities([ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY +]) + +export const MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD = + 'runtime.clientCapabilities.update' as const + +export function mobileRuntimeClientCapabilityUpdateParams(): { + clientCapabilities: string[] +} { + return { clientCapabilities: [...MOBILE_RUNTIME_CLIENT_CAPABILITIES] } +} + +export function mobileRuntimeClientCapabilityUpdateRequest(args: { + id: string + deviceToken: string +}): { + id: string + deviceToken: string + method: typeof MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD + params: { clientCapabilities: string[] } +} { + return { + id: args.id, + deviceToken: args.deviceToken, + method: MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD, + params: mobileRuntimeClientCapabilityUpdateParams() + } +} + +export function advertiseMobileRuntimeClientCapabilities( + send: (request: unknown) => boolean | void, + id: string, + deviceToken: string +): void { + send(mobileRuntimeClientCapabilityUpdateRequest({ id, deviceToken })) +} diff --git a/mobile/src/transport/relay-dial-stage.ts b/mobile/src/transport/relay-dial-stage.ts new file mode 100644 index 00000000000..c4a743f84f4 --- /dev/null +++ b/mobile/src/transport/relay-dial-stage.ts @@ -0,0 +1,64 @@ +// Where a relay dial is waiting, so a bound can tell "the cell never answered the +// upgrade" from "the cell took the dial and is slow" — the two look identical from +// ConnectionState, which stays 'connecting' until relay-hello arrives. +export type RelayDialStage = + // WebSocket upgrade not yet open. + | 'opening' + // Socket open and relay-auth sent; the cell is resolving/reserving and asking the + // desktop to attach before it can answer with relay-hello. + | 'awaiting-hello' + // relay-hello accepted; E2EE handshake with the desktop in flight. + | 'handshaking' + // E2EE authenticated; waiting on the desktop's resume confirmation. + | 'confirming' + +export type RelayDialStageSource = { + getDialStage(): RelayDialStage + onDialStageChange(listener: (stage: RelayDialStage) => void): () => void +} + +export function relayDialStageSource(session: object): RelayDialStageSource | null { + const candidate = session as Partial + return typeof candidate.getDialStage === 'function' && + typeof candidate.onDialStageChange === 'function' + ? (candidate as RelayDialStageSource) + : null +} + +export class RelayDialStageTracker implements RelayDialStageSource { + private stage: RelayDialStage = 'opening' + private readonly listeners = new Set<(stage: RelayDialStage) => void>() + + getDialStage(): RelayDialStage { + return this.stage + } + + onDialStageChange(listener: (stage: RelayDialStage) => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + advance(stage: RelayDialStage): void { + if (this.stage === stage) { + return + } + this.stage = stage + for (const listener of this.listeners) { + listener(stage) + } + } +} + +// Budget per stage once the cell holds the dial. awaiting-hello covers the cell's +// assignment/reservation transactions (observed 14–16s under lock contention) plus its +// 10s host-attach deadline; handshaking is two E2EE round trips; confirming is bounded +// by the session's own 30s resume-confirmation request, with slack so that error wins. +const RELAY_DIAL_STAGE_BUDGET_MS: Record, number> = { + 'awaiting-hello': 30_000, + handshaking: 12_000, + confirming: 35_000 +} + +export function relayDialStageBudgetMs(stage: Exclude): number { + return RELAY_DIAL_STAGE_BUDGET_MS[stage] +} diff --git a/mobile/src/transport/relay-pending-requests.ts b/mobile/src/transport/relay-pending-requests.ts new file mode 100644 index 00000000000..8260869d73c --- /dev/null +++ b/mobile/src/transport/relay-pending-requests.ts @@ -0,0 +1,53 @@ +import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' +import type { RpcResponse } from './types' + +type PendingRequest = { + resolve: (response: RpcResponse) => void + reject: (error: Error) => void + timer: ReturnType +} + +/** In-flight relay RPC requests awaiting their response frame, keyed by request id. */ +export class RelayPendingRequests { + private readonly pending = new Map() + private requestCounter = 0 + + nextId(): string { + return `relay-rpc-${++this.requestCounter}-${Date.now()}` + } + + track(id: string, request: PendingRequest): void { + this.pending.set(id, request) + } + + drop(id: string): void { + this.pending.delete(id) + } + + /** Settle the waiter for this response; false when no request owns it. */ + settle(response: RpcResponse): boolean { + const request = this.pending.get(response.id) + if (!request) { + return false + } + clearTimeout(request.timer) + this.pending.delete(response.id) + request.resolve(response) + return true + } + + rejectAll(error: Error): void { + if (this.pending.size === 0) { + return + } + // Why: pending entries only exist after their frame reached the authenticated + // link (sendFrame failures delete them synchronously), so the desktop may + // have processed them — mark the ambiguity for callers. + markRpcDeliveryUnknown(error) + for (const request of this.pending.values()) { + clearTimeout(request.timer) + request.reject(error) + } + this.pending.clear() + } +} diff --git a/mobile/src/transport/replacement-session-authentication.test.ts b/mobile/src/transport/replacement-session-authentication.test.ts new file mode 100644 index 00000000000..096721fa02d --- /dev/null +++ b/mobile/src/transport/replacement-session-authentication.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RelayDialStageTracker } from './relay-dial-stage' +import { + ReplacementAuthenticationTimeoutError, + waitForAuthenticated +} from './replacement-session-authentication' +import type { RpcClient } from './rpc-client' +import type { ConnectionState } from './types' + +class FakeSession implements RpcClient { + readonly sendRequest = vi.fn() + readonly subscribe = vi.fn(() => () => {}) + readonly updateTerminalSubscriptionViewport = vi.fn() + readonly notifyForeground = vi.fn() + readonly close = vi.fn() + private readonly listeners = new Set<(state: ConnectionState) => void>() + constructor(private state: ConnectionState = 'connecting') {} + getState = () => this.state + getReconnectAttempt = () => 0 + getLastConnectedAt = () => null + onStateChange = (listener: (state: ConnectionState) => void) => { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + setState(state: ConnectionState): void { + this.state = state + for (const listener of this.listeners) { + listener(state) + } + } +} + +class FakeRelaySession extends FakeSession { + readonly dialStage = new RelayDialStageTracker() + getDialStage = () => this.dialStage.getDialStage() + onDialStageChange = this.dialStage.onDialStageChange.bind(this.dialStage) +} + +// Why: fake timers are active, so "still pending" is decided on the microtask queue. +async function settle( + promise: Promise +): Promise<{ status: 'pending' | 'settled'; error?: Error }> { + let outcome: { status: 'pending' | 'settled'; error?: Error } = { status: 'pending' } + void promise.then( + () => (outcome = { status: 'settled' }), + (error: Error) => (outcome = { status: 'settled', error }) + ) + await Promise.resolve() + await Promise.resolve() + return outcome +} + +describe('waitForAuthenticated', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('keeps the flat bound for a session that reports no dial stages', async () => { + const session = new FakeSession() + const waiting = waitForAuthenticated(session, 12_000) + waiting.catch(() => {}) + await vi.advanceTimersByTimeAsync(11_999) + expect((await settle(waiting)).status).toBe('pending') + await vi.advanceTimersByTimeAsync(1) + const outcome = await settle(waiting) + expect(outcome.error).toBeInstanceOf(ReplacementAuthenticationTimeoutError) + expect(outcome.error?.message).toBe('replacement session authentication timed out') + }) + + // The 2026-09-03 incident: the cell accepted relay-auth and spent 14–16s in its + // lock-contended assignment transactions. The flat 12s bound hung up 2–4s before + // the cell finished, five dials in a row, while the desktop was live the whole time. + it('re-arms the bound per stage once the cell holds the dial', async () => { + const session = new FakeRelaySession() + const waiting = waitForAuthenticated(session, 12_000) + waiting.catch(() => {}) + await vi.advanceTimersByTimeAsync(11_000) + session.dialStage.advance('awaiting-hello') + await vi.advanceTimersByTimeAsync(5_000) + expect((await settle(waiting)).status).toBe('pending') + session.dialStage.advance('handshaking') + session.setState('handshaking') + await vi.advanceTimersByTimeAsync(11_000) + expect((await settle(waiting)).status).toBe('pending') + session.dialStage.advance('confirming') + await vi.advanceTimersByTimeAsync(20_000) + session.setState('connected') + await expect(waiting).resolves.toBeUndefined() + }) + + it('bounds a cell that took the dial and never answers, naming the stage', async () => { + const session = new FakeRelaySession() + const waiting = waitForAuthenticated(session, 12_000) + waiting.catch(() => {}) + await vi.advanceTimersByTimeAsync(2_000) + session.dialStage.advance('awaiting-hello') + await vi.advanceTimersByTimeAsync(29_999) + expect((await settle(waiting)).status).toBe('pending') + await vi.advanceTimersByTimeAsync(1) + const outcome = await settle(waiting) + expect(outcome.error).toBeInstanceOf(ReplacementAuthenticationTimeoutError) + expect((outcome.error as ReplacementAuthenticationTimeoutError).stage).toBe('awaiting-hello') + expect(outcome.error?.message).toBe( + 'replacement session authentication timed out (awaiting-hello, 30s)' + ) + }) + + it('keeps the caller bound while the socket never opens', async () => { + const session = new FakeRelaySession() + const waiting = waitForAuthenticated(session, 12_000) + waiting.catch(() => {}) + await vi.advanceTimersByTimeAsync(12_000) + const outcome = await settle(waiting) + expect((outcome.error as ReplacementAuthenticationTimeoutError).stage).toBe('opening') + expect(outcome.error?.message).toBe( + 'replacement session authentication timed out (opening, 12s)' + ) + }) + + it('ignores stage advances after the wait has settled', async () => { + const session = new FakeRelaySession() + const waiting = waitForAuthenticated(session, 12_000) + session.setState('disconnected') + await expect(waiting).rejects.toThrow('replacement session disconnected') + session.dialStage.advance('awaiting-hello') + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/mobile/src/transport/replacement-session-authentication.ts b/mobile/src/transport/replacement-session-authentication.ts index 0ba54a9d611..5ef9a5f1f46 100644 --- a/mobile/src/transport/replacement-session-authentication.ts +++ b/mobile/src/transport/replacement-session-authentication.ts @@ -1,21 +1,43 @@ import type { RpcClient } from './rpc-client' +import { + relayDialStageBudgetMs, + relayDialStageSource, + type RelayDialStage +} from './relay-dial-stage' + +export class ReplacementAuthenticationTimeoutError extends Error { + constructor( + readonly stage: RelayDialStage | null, + budgetMs: number + ) { + super( + stage + ? `replacement session authentication timed out (${stage}, ${Math.round(budgetMs / 1000)}s)` + : 'replacement session authentication timed out' + ) + this.name = 'ReplacementAuthenticationTimeoutError' + } +} // Why: a migration must not cut over to a session that has only opened a socket — the -// replacement has to reach 'connected' (E2EE authenticated) first, and a relay dial can -// sit in handshaking for seconds, so the wait is bounded by the caller's timeout. +// replacement has to reach 'connected' (E2EE authenticated) first. The caller's bound +// covers reaching an open socket; a relay session that reports dial stages re-arms a +// per-stage budget on every advance, so a cell that accepted the dial and is working +// slowly (lock-contended assignment tables) is not hung up on like a black hole — the +// retry would land in the same window and burn a director round on the way. export function waitForAuthenticated(session: RpcClient, timeoutMs: number): Promise { if (session.getState() === 'connected') { return Promise.resolve() } + const stages = relayDialStageSource(session) return new Promise((resolve, reject) => { let settled = false let unsubscribe: (() => void) | null = null + let unsubscribeStage: (() => void) | null = null + let timer: ReturnType | null = null // Why: armed before subscribing — a synchronous notification during registration // must find a timer to clear, or a settled wait leaves it running for 12s. - const timer = setTimeout(() => { - finish() - reject(new Error('replacement session authentication timed out')) - }, timeoutMs) + arm(stages?.getDialStage() ?? null) unsubscribe = session.onStateChange((state) => { if (state === 'connected') { finish() @@ -29,6 +51,23 @@ export function waitForAuthenticated(session: RpcClient, timeoutMs: number): Pro // Why: the notification fired inside onStateChange, before we held the handle. unsubscribe() unsubscribe = null + } else if (stages) { + unsubscribeStage = stages.onDialStageChange((stage) => arm(stage)) + } + + function arm(stage: RelayDialStage | null): void { + if (settled) { + return + } + if (timer) { + clearTimeout(timer) + } + const budgetMs = + stage === null || stage === 'opening' ? timeoutMs : relayDialStageBudgetMs(stage) + timer = setTimeout(() => { + finish() + reject(new ReplacementAuthenticationTimeoutError(stage, budgetMs)) + }, budgetMs) } function finish(): void { @@ -36,9 +75,14 @@ export function waitForAuthenticated(session: RpcClient, timeoutMs: number): Pro return } settled = true - clearTimeout(timer) + if (timer) { + clearTimeout(timer) + timer = null + } unsubscribe?.() unsubscribe = null + unsubscribeStage?.() + unsubscribeStage = null } }) } diff --git a/mobile/src/transport/rpc-client-capabilities.test.ts b/mobile/src/transport/rpc-client-capabilities.test.ts new file mode 100644 index 00000000000..7107ae6717e --- /dev/null +++ b/mobile/src/transport/rpc-client-capabilities.test.ts @@ -0,0 +1,161 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { connect } from './rpc-client' + +vi.mock('./e2ee', () => ({ + generateKeyPair: () => ({ + publicKey: new Uint8Array(32), + secretKey: new Uint8Array(32) + }), + deriveSharedKey: () => new Uint8Array(32), + publicKeyFromBase64: () => new Uint8Array(32), + publicKeyToBase64: () => 'client-public-key', + encrypt: (plaintext: string) => `encrypted:${plaintext}`, + decrypt: (raw: string) => raw.replace(/^encrypted:/, ''), + decryptBytes: (bytes: Uint8Array) => bytes +})) + +class MockWebSocket { + static CONNECTING = 0 + static OPEN = 1 + static CLOSING = 2 + static CLOSED = 3 + + readonly CONNECTING = MockWebSocket.CONNECTING + readonly OPEN = MockWebSocket.OPEN + readonly CLOSING = MockWebSocket.CLOSING + readonly CLOSED = MockWebSocket.CLOSED + + readyState = MockWebSocket.CONNECTING + onopen: (() => void) | null = null + onmessage: ((event: { data: unknown }) => void) | null = null + onclose: (() => void) | null = null + sent: string[] = [] + + constructor(readonly endpoint: string) { + mockSockets.push(this) + } + + send(payload: string): void { + this.sent.push(payload) + } + + close(): void { + this.readyState = MockWebSocket.CLOSED + this.onclose?.() + } + + open(): void { + this.readyState = MockWebSocket.OPEN + this.onopen?.() + } + + receive(payload: unknown): void { + this.onmessage?.({ data: payload }) + } +} + +type SentRpcRequest = { id: string; method: string; params?: unknown } + +const mockSockets: MockWebSocket[] = [] +const originalWebSocket = globalThis.WebSocket + +function sentRequest(socket: MockWebSocket, method: string): SentRpcRequest { + const request = socket.sent + .map((payload) => JSON.parse(payload.replace(/^encrypted:/, '')) as SentRpcRequest) + .find((candidate) => candidate.method === method) + if (!request) { + throw new Error(`Request not sent: ${method}`) + } + return request +} + +describe('mobile rpc-client capabilities', () => { + beforeEach(() => { + mockSockets.length = 0 + globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket + }) + + afterEach(() => { + globalThis.WebSocket = originalWebSocket + }) + + it('waits for mobile capability acknowledgement before replaying streams', async () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const socket = mockSockets[0]! + client.subscribe('session.tabs.subscribe', { worktree: 'id:wt-1' }, () => {}) + + socket.open() + socket.receive(JSON.stringify({ type: 'e2ee_ready' })) + socket.receive('encrypted:{"type":"e2ee_authenticated"}') + + const capabilityRequest = sentRequest(socket, 'runtime.clientCapabilities.update') + expect(capabilityRequest.params).toMatchObject({ + clientCapabilities: expect.arrayContaining(['agent-session.structured.v1']) + }) + expect(socket.sent.some((payload) => payload.includes('session.tabs.subscribe'))).toBe(false) + + socket.receive( + `encrypted:${JSON.stringify({ + id: capabilityRequest.id, + ok: true, + result: capabilityRequest.params, + _meta: { runtimeId: 'runtime-1' } + })}` + ) + + await vi.waitFor(() => expect(sentRequest(socket, 'session.tabs.subscribe')).toBeDefined()) + + client.close() + }) + + it('replays streams when an older runtime rejects capability negotiation', async () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const socket = mockSockets[0]! + client.subscribe('session.tabs.subscribe', { worktree: 'id:wt-1' }, () => {}) + + socket.open() + socket.receive(JSON.stringify({ type: 'e2ee_ready' })) + socket.receive('encrypted:{"type":"e2ee_authenticated"}') + + const capabilityRequest = sentRequest(socket, 'runtime.clientCapabilities.update') + socket.receive( + `encrypted:${JSON.stringify({ + id: capabilityRequest.id, + ok: false, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId: 'runtime-1' } + })}` + ) + + await vi.waitFor(() => expect(sentRequest(socket, 'session.tabs.subscribe')).toBeDefined()) + expect(client.getState()).toBe('connected') + + client.close() + }) + + it('reaches connected when a slow host never answers capability negotiation', async () => { + vi.useFakeTimers() + try { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const socket = mockSockets[0]! + client.subscribe('session.tabs.subscribe', { worktree: 'id:wt-1' }, () => {}) + + socket.open() + socket.receive(JSON.stringify({ type: 'e2ee_ready' })) + socket.receive('encrypted:{"type":"e2ee_authenticated"}') + sentRequest(socket, 'runtime.clientCapabilities.update') + + // Why: the 5s capability deadline used to force-close the socket, so a link + // this slow never left 'connecting' — it just redialled forever. + await vi.advanceTimersByTimeAsync(5_001) + + expect(client.getState()).toBe('connected') + expect(sentRequest(socket, 'session.tabs.subscribe')).toBeDefined() + expect(socket.readyState).toBe(MockWebSocket.OPEN) + + client.close() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/mobile/src/transport/rpc-client-connect-wait-replay.test.ts b/mobile/src/transport/rpc-client-connect-wait-replay.test.ts index 24015ce829e..55a9dc60311 100644 --- a/mobile/src/transport/rpc-client-connect-wait-replay.test.ts +++ b/mobile/src/transport/rpc-client-connect-wait-replay.test.ts @@ -14,6 +14,10 @@ vi.mock('./e2ee', () => ({ decryptBytes: (bytes: Uint8Array) => bytes })) +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + class MockWebSocket { static CONNECTING = 0 static OPEN = 1 diff --git a/mobile/src/transport/rpc-client-context-contract.ts b/mobile/src/transport/rpc-client-context-contract.ts index 9eb9efd4444..54e25973c7f 100644 --- a/mobile/src/transport/rpc-client-context-contract.ts +++ b/mobile/src/transport/rpc-client-context-contract.ts @@ -18,6 +18,7 @@ export type RpcClientContextValue = { disconnectHostClient: (hostId: string) => void getState: (hostId: string) => ConnectionState getKnownState: (hostId: string) => ConnectionState | null + getClientId: (hostId: string) => string | null getReconnectAttempt: (hostId: string) => number getLastConnectedAt: (hostId: string) => number | null getActivePath: (hostId: string) => MobileConnectionPath diff --git a/mobile/src/transport/rpc-client-delivery-ambiguity.test.ts b/mobile/src/transport/rpc-client-delivery-ambiguity.test.ts index 6fb0f7d3610..eca1eca03d1 100644 --- a/mobile/src/transport/rpc-client-delivery-ambiguity.test.ts +++ b/mobile/src/transport/rpc-client-delivery-ambiguity.test.ts @@ -15,6 +15,10 @@ vi.mock('./e2ee', () => ({ decryptBytes: (bytes: Uint8Array) => bytes })) +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + class MockWebSocket { static CONNECTING = 0 static OPEN = 1 diff --git a/mobile/src/transport/rpc-client-request-deadline.test.ts b/mobile/src/transport/rpc-client-request-deadline.test.ts index 2ed349f375a..05fe81d7944 100644 --- a/mobile/src/transport/rpc-client-request-deadline.test.ts +++ b/mobile/src/transport/rpc-client-request-deadline.test.ts @@ -14,6 +14,10 @@ vi.mock('./e2ee', () => ({ decryptBytes: (bytes: Uint8Array) => bytes })) +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + class MockWebSocket { static CONNECTING = 0 static OPEN = 1 diff --git a/mobile/src/transport/rpc-client-request-tracker.ts b/mobile/src/transport/rpc-client-request-tracker.ts index 34edd2631e2..d96d1e97609 100644 --- a/mobile/src/transport/rpc-client-request-tracker.ts +++ b/mobile/src/transport/rpc-client-request-tracker.ts @@ -42,9 +42,28 @@ export class RpcClientRequestTracker { }) } + return this.sendConnectedRequest( + method, + params, + resolvePostConnectRequestTimeout(budget, REQUEST_TIMEOUT_MS) + ) + } + + sendAuthenticatedRequest( + method: string, + params: unknown, + timeoutMs = REQUEST_TIMEOUT_MS + ): Promise { + return this.sendConnectedRequest(method, params, timeoutMs) + } + + private sendConnectedRequest( + method: string, + params: unknown, + timeoutMs: number + ): Promise { return new Promise((resolve, reject) => { const id = this.options.nextId() - const timeoutMs = resolvePostConnectRequestTimeout(budget, REQUEST_TIMEOUT_MS) const timeout = setTimeout(() => { this.pending.delete(id) console.log('[net] sendRequest TIMEOUT', { diff --git a/mobile/src/transport/rpc-client-runtime-events.test.ts b/mobile/src/transport/rpc-client-runtime-events.test.ts index d18739423f7..04b2a90039e 100644 --- a/mobile/src/transport/rpc-client-runtime-events.test.ts +++ b/mobile/src/transport/rpc-client-runtime-events.test.ts @@ -14,6 +14,10 @@ vi.mock('./e2ee', () => ({ decryptBytes: (bytes: Uint8Array) => bytes })) +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + class RuntimeEventTestSocket { static CONNECTING = 0 static OPEN = 1 diff --git a/mobile/src/transport/rpc-client-synthesized-close-diagnostics.test.ts b/mobile/src/transport/rpc-client-synthesized-close-diagnostics.test.ts index 832180ad6c1..5898685bb70 100644 --- a/mobile/src/transport/rpc-client-synthesized-close-diagnostics.test.ts +++ b/mobile/src/transport/rpc-client-synthesized-close-diagnostics.test.ts @@ -17,6 +17,10 @@ vi.mock('./e2ee', () => ({ decryptBytes: (bytes: Uint8Array) => bytes })) +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + // Why: close() deliberately never fires onclose — that is the wedged-transport bug being modelled. class WedgedWebSocket { static CONNECTING = 0 diff --git a/mobile/src/transport/rpc-client-terminal-reconnect.test.ts b/mobile/src/transport/rpc-client-terminal-reconnect.test.ts index f382068b735..83f40113cae 100644 --- a/mobile/src/transport/rpc-client-terminal-reconnect.test.ts +++ b/mobile/src/transport/rpc-client-terminal-reconnect.test.ts @@ -15,6 +15,10 @@ vi.mock('./e2ee', () => ({ decryptBytes: (bytes: Uint8Array) => bytes })) +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + class MockWebSocket { static CONNECTING = 0 static OPEN = 1 diff --git a/mobile/src/transport/rpc-client-unauthorized-close.test.ts b/mobile/src/transport/rpc-client-unauthorized-close.test.ts index 464d0b95808..1ba79015331 100644 --- a/mobile/src/transport/rpc-client-unauthorized-close.test.ts +++ b/mobile/src/transport/rpc-client-unauthorized-close.test.ts @@ -17,6 +17,10 @@ vi.mock('./e2ee', () => ({ decryptBytes: (bytes: Uint8Array) => bytes })) +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + class MockWebSocket { static CONNECTING = 0 static OPEN = 1 diff --git a/mobile/src/transport/rpc-client.test.ts b/mobile/src/transport/rpc-client.test.ts index a7f3bb9d82a..e88e6c220e6 100644 --- a/mobile/src/transport/rpc-client.test.ts +++ b/mobile/src/transport/rpc-client.test.ts @@ -15,6 +15,11 @@ vi.mock('./e2ee', () => ({ decryptBytes: (bytes: Uint8Array) => bytes })) +// Capability ordering has dedicated coverage; keep connection tests focused on socket behavior. +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + class MockWebSocket { static CONNECTING = 0 static OPEN = 1 @@ -64,36 +69,20 @@ class MockWebSocket { const mockSockets: MockWebSocket[] = [] const originalWebSocket = globalThis.WebSocket -function sentRequest(socket: MockWebSocket, method: string): { id: string; params?: unknown } { - for (const payload of socket.sent) { - const decoded = JSON.parse(payload.replace(/^encrypted:/, '')) as { - id: string - method: string - params?: unknown - } - if (decoded.method === method) { - return { id: decoded.id, params: decoded.params } - } +type SentRpcRequest = { id: string; method: string; params?: unknown } + +function sentRequest(socket: MockWebSocket, method: string): SentRpcRequest { + const request = sentRequests(socket, method)[0] + if (request) { + return request } throw new Error(`Request not sent: ${method}`) } -function sentRequests( - socket: MockWebSocket, - method: string -): Array<{ id: string; params?: unknown }> { - const requests: Array<{ id: string; params?: unknown }> = [] - for (const payload of socket.sent) { - const decoded = JSON.parse(payload.replace(/^encrypted:/, '')) as { - id: string - method: string - params?: unknown - } - if (decoded.method === method) { - requests.push({ id: decoded.id, params: decoded.params }) - } - } - return requests +function sentRequests(socket: MockWebSocket, method: string): SentRpcRequest[] { + return socket.sent + .map((payload) => JSON.parse(payload.replace(/^encrypted:/, '')) as SentRpcRequest) + .filter((request) => request.method === method) } function encodeBrowserFrame(): Uint8Array { diff --git a/mobile/src/transport/rpc-session-liveness-integration.test.ts b/mobile/src/transport/rpc-session-liveness-integration.test.ts index c446477c365..88e71a0862c 100644 --- a/mobile/src/transport/rpc-session-liveness-integration.test.ts +++ b/mobile/src/transport/rpc-session-liveness-integration.test.ts @@ -15,6 +15,10 @@ vi.mock('./e2ee', () => ({ decryptBytes: (bytes: Uint8Array) => bytes })) +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + class MockWebSocket { static readonly CONNECTING = 0 static readonly OPEN = 1 diff --git a/mobile/src/transport/stable-logical-rpc-client.test.ts b/mobile/src/transport/stable-logical-rpc-client.test.ts index 0ba7a1f2ea7..faa236a88ca 100644 --- a/mobile/src/transport/stable-logical-rpc-client.test.ts +++ b/mobile/src/transport/stable-logical-rpc-client.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { RelayDialStageTracker, type RelayDialStage } from './relay-dial-stage' import type { ConnectionState, RpcResponse } from './types' import type { RpcClient } from './rpc-client' import { isRpcDeliveryUnknown, markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' @@ -399,6 +400,34 @@ describe('stable logical RPC client', () => { expect(client.getPendingPath()).toBeNull() }) + // Pins the shipping wiring: migrateTo's bound honors the replacement's dial stages. + it('outlives the flat bound when the relay cell holds the dial', async () => { + vi.useFakeTimers() + try { + const oldSession = new FakeSession('connected') + const replacement = Object.assign(new FakeSession('connecting'), { + dialStage: new RelayDialStageTracker(), + getDialStage(): RelayDialStage { + return this.dialStage.getDialStage() + }, + onDialStageChange(listener: (stage: RelayDialStage) => void) { + return this.dialStage.onDialStageChange(listener) + } + }) + const client = createStableLogicalRpcClient(oldSession, 'lan') + const migrating = client.migrateTo(replacement, 'relay', 12_000) + await vi.advanceTimersByTimeAsync(1_000) + replacement.dialStage.advance('awaiting-hello') + await vi.advanceTimersByTimeAsync(20_000) + expect(replacement.close).not.toHaveBeenCalled() + replacement.setState('connected') + await migrating + expect(client.getActivePath()).toBe('relay') + } finally { + vi.useRealTimers() + } + }) + it('closes a replacement that fails authentication and preserves the active session', async () => { const oldSession = new FakeSession('connected') const replacement = new FakeSession('connecting') diff --git a/package.json b/package.json index 333936dbcf9..c519d7ea6b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.178-rc.2", + "version": "1.4.197", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0de7027688c..0b72bb101ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,6 +111,7 @@ overrides: patchedDependencies: '@vscode/windows-process-tree@0.8.0': 9217ef36c01ed74127fef5512b0c92089cdbf820fd6c109dd671137eebdc7585 '@xterm/addon-ligatures@0.11.0-beta.300': 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920 + '@xterm/addon-search@0.17.0-beta.300': eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0 '@xterm/addon-serialize@0.15.0-beta.300': 851eac3d75e6d8c013b9f4c053e61d824b23965cb19ecc28e335e05059f3a294 '@xterm/addon-webgl@0.20.0-beta.299': 94687e89a0115e6e6aa102837f986debdc029c091527ee5eb4a4e17ceaf9473e '@xterm/xterm@6.1.0-beta.303': 98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d @@ -322,7 +323,7 @@ importers: version: 0.11.0-beta.300(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)) '@xterm/addon-search': specifier: 0.17.0-beta.300 - version: 0.17.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)) + version: 0.17.0-beta.300(patch_hash=eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)) '@xterm/addon-unicode11': specifier: 0.10.0-beta.300 version: 0.10.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)) @@ -9837,7 +9838,7 @@ snapshots: lru-cache: 11.5.1 opentype.js: 2.0.0 - '@xterm/addon-search@0.17.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))': + '@xterm/addon-search@0.17.0-beta.300(patch_hash=eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))': dependencies: '@xterm/xterm': 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e355fabf244..97920f087c5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -58,6 +58,7 @@ patchedDependencies: node-pty@1.1.0: config/patches/node-pty@1.1.0.patch '@xterm/addon-ligatures@0.11.0-beta.300': config/patches/@xterm__addon-ligatures@0.11.0-beta.300.patch '@xterm/addon-webgl@0.20.0-beta.299': config/patches/@xterm__addon-webgl@0.20.0-beta.299.patch + '@xterm/addon-search@0.17.0-beta.300': config/patches/@xterm__addon-search@0.17.0-beta.300.patch '@xterm/addon-serialize@0.15.0-beta.300': config/patches/@xterm__addon-serialize@0.15.0-beta.300.patch '@xterm/xterm@6.1.0-beta.303': config/patches/@xterm__xterm@6.1.0-beta.303.patch lint-staged@16.4.0: config/patches/lint-staged@16.4.0.patch diff --git a/src/cli/handlers/account.ts b/src/cli/handlers/account.ts index a6ee5d73246..5a8bd4d3c6c 100644 --- a/src/cli/handlers/account.ts +++ b/src/cli/handlers/account.ts @@ -7,6 +7,7 @@ import type { CommandHandler, HandlerContext } from '../dispatch' import { printResult } from '../format' import { RuntimeClientError } from '../runtime-client' import { stripElectronRunAsNode } from '../runtime/launch' +import { rejectRemoteSelectionFlags } from '../remote-selection-flag-rejection' import { deleteActiveClaudeKeychainCredentialsStrict, readActiveClaudeKeychainCredentialsStrict, @@ -276,15 +277,11 @@ async function addCodexAccount({ client, json }: HandlerContext): Promise * mistake this feature exists to avoid. A `--help` note does not reach someone who * already typed the flag. */ -function rejectRemoteSelectionFlags(ctx: HandlerContext, command: string): void { - for (const flag of ['environment', 'pairing-code']) { - if (ctx.flags.has(flag)) { - throw new RuntimeClientError( - 'invalid_argument', - `\`--${flag}\` does not retarget \`${command}\`. Run it on the host whose accounts you want to manage.` - ) - } - } +function rejectAccountRemoteSelectionFlags(ctx: HandlerContext, command: string): void { + rejectRemoteSelectionFlags( + ctx.flags, + `\`${command}\`. Run it on the host whose accounts you want to manage.` + ) } async function assertAccountImportSupported({ client }: HandlerContext): Promise { @@ -316,14 +313,14 @@ export const ACCOUNT_HANDLERS: Record = { `Unsupported --agent "${agent}". Use "claude" or "codex".` ) } - rejectRemoteSelectionFlags(ctx, 'orca account add') + rejectAccountRemoteSelectionFlags(ctx, 'orca account add') // Why: fail on runtime version skew before burning a full OAuth round trip. await assertAccountImportSupported(ctx) await ctx.client.call('accounts.list', { refreshUsage: false }) await (agent === 'claude' ? addClaudeAccount(ctx) : addCodexAccount(ctx)) }, 'account list': async (ctx) => { - rejectRemoteSelectionFlags(ctx, 'orca account list') + rejectAccountRemoteSelectionFlags(ctx, 'orca account list') const { client, json } = ctx // Why: this command renders no usage numbers, so skip the forced provider // refresh — it is one serial network round-trip per managed account. diff --git a/src/cli/handlers/artifacts.ts b/src/cli/handlers/artifacts.ts index 2306dcc406a..8546d7997f3 100644 --- a/src/cli/handlers/artifacts.ts +++ b/src/cli/handlers/artifacts.ts @@ -18,6 +18,7 @@ import { ARTIFACT_SHARING_DISABLED_NEXT_STEPS } from '../../shared/artifact-sharing-gate' import type { CommandHandler, HandlerContext } from '../dispatch' +import { rejectRemoteSelectionFlags } from '../remote-selection-flag-rejection' import { RuntimeClientError } from '../runtime-client' import { formatArtifactListPage, formatArtifactShared } from '../artifact-format' import { printResult } from '../format' @@ -44,15 +45,11 @@ function cloudOptions(ctx: HandlerContext): ArtifactCloudOptions { } } -function rejectRemoteSelectionFlags(ctx: HandlerContext): void { - for (const flag of ['environment', 'pairing-code']) { - if (ctx.flags.has(flag)) { - throw new RuntimeClientError( - 'invalid_argument', - `\`--${flag}\` does not retarget artifact commands; artifacts use the signed-in desktop account.` - ) - } - } +function rejectArtifactRemoteSelectionFlags(ctx: HandlerContext): void { + rejectRemoteSelectionFlags( + ctx.flags, + 'artifact commands; artifacts use the signed-in desktop account.' + ) } function artifactContentType(path: string): ArtifactWriteRequest['contentType'] | null { @@ -165,7 +162,7 @@ function requireOperation(operation: ArtifactCloudOperation): T { export const ARTIFACT_HANDLERS: Record = { 'artifacts list': async (ctx) => { - rejectRemoteSelectionFlags(ctx) + rejectArtifactRemoteSelectionFlags(ctx) const cursor = stringFlag(ctx, 'cursor') const response = await ctx.client.call>( 'artifacts.list', @@ -178,7 +175,7 @@ export const ARTIFACT_HANDLERS: Record = { printResult({ ...response, result: value }, ctx.json, formatArtifactListPage) }, 'artifacts share': async (ctx) => { - rejectRemoteSelectionFlags(ctx) + rejectArtifactRemoteSelectionFlags(ctx) const response = await ctx.client.call>( 'artifacts.share', await readArtifactRequest(ctx) @@ -187,7 +184,7 @@ export const ARTIFACT_HANDLERS: Record = { printResult({ ...response, result: value }, ctx.json, formatArtifactShared) }, 'artifacts update': async (ctx) => { - rejectRemoteSelectionFlags(ctx) + rejectArtifactRemoteSelectionFlags(ctx) const response = await ctx.client.call>( 'artifacts.update', await readArtifactRequest(ctx) @@ -196,7 +193,7 @@ export const ARTIFACT_HANDLERS: Record = { printResult({ ...response, result: value }, ctx.json, formatArtifactShared) }, 'artifacts unshare': async (ctx) => { - rejectRemoteSelectionFlags(ctx) + rejectArtifactRemoteSelectionFlags(ctx) const remoteInput = parseRemoteArtifactInput(process.env[REMOTE_ARTIFACT_INPUT_ENV]) const sourceKey = remoteInput?.sourceKey ?? resolve(ctx.cwd, requireStringFlag(ctx, 'file')) const response = await ctx.client.call>('artifacts.unshare', { @@ -207,7 +204,7 @@ export const ARTIFACT_HANDLERS: Record = { printResult({ ...response, result: { deleted: true } }, ctx.json, () => 'Artifact deleted.') }, 'artifacts delete': async (ctx) => { - rejectRemoteSelectionFlags(ctx) + rejectArtifactRemoteSelectionFlags(ctx) const response = await ctx.client.call>('artifacts.delete', { id: requireStringFlag(ctx, 'id'), ...cloudOptions(ctx) diff --git a/src/cli/handlers/environment.ts b/src/cli/handlers/environment.ts index 2a433021769..37b437af2c3 100644 --- a/src/cli/handlers/environment.ts +++ b/src/cli/handlers/environment.ts @@ -3,6 +3,7 @@ import { formatEnvironment, formatEnvironmentList, formatHostList, printResult } import { listSshTargets } from '../host-selector-alternatives' import { getDefaultUserDataPath, RuntimeClientError } from '../runtime-client' import type { RuntimeRpcSuccess } from '../runtime-client' +import { rejectRemoteSelectionFlags } from '../remote-selection-flag-rejection' import { redactRuntimeEnvironment } from '../../shared/runtime-environments' import { addEnvironmentFromPairingCode, @@ -33,7 +34,12 @@ export const ENVIRONMENT_HANDLERS: Record = { // Why: an agent told "run it on " had nowhere to look. `orca environment list` showed // paired servers only, and nothing in the CLI listed SSH targets at all, so the wrong-axis // guess was the only move available. This is the one place that answers both. - 'host list': async ({ client, json }) => { + 'host list': async ({ client, flags, json }) => { + rejectLocalPairingStoreRetargeting( + flags, + '`orca host list`. It answers from this machine\u2019s own pairing store, so a routed answer would name servers paired with a different machine.', + 'Run `orca host list` on that machine to see the SSH targets registered there.' + ) const environments = listEnvironments(getDefaultUserDataPath()).map((environment) => ({ kind: 'environment' as const, name: environment.name, @@ -53,7 +59,12 @@ export const ENVIRONMENT_HANDLERS: Record = { ] printResult(localSuccess({ hosts }), json, formatHostList) }, - 'environment list': async ({ json }) => { + 'environment list': async ({ flags, json }) => { + rejectLocalPairingStoreRetargeting( + flags, + '`orca environment list`. Paired servers are stored on this machine, so there is no other host to ask.', + 'Run `orca environment list` on that machine to see the servers paired with it.' + ) const environments = listEnvironments(getDefaultUserDataPath()).map(redactRuntimeEnvironment) printResult(localSuccess({ environments }), json, formatEnvironmentList) }, @@ -78,6 +89,23 @@ export const ENVIRONMENT_HANDLERS: Record = { } } +/** + * These two listings are pinned local by `shouldIgnoreRemoteSelection`, so a runtime selector is + * dropped for routing. It used to still reach the SSH half of `host list` through the routed + * client, producing a listing whose SSH rows came from the named server and whose paired-server + * rows came from this machine — one answer describing two hosts, stamped `runtimeId: local`. + * Failing is the only answer that is true of a single machine. + */ +function rejectLocalPairingStoreRetargeting( + flags: Map, + suffix: string, + crossHostNextStep: string +): void { + rejectRemoteSelectionFlags(flags, suffix, { + nextSteps: [crossHostNextStep, 'Drop the flag to answer for this machine.'] + }) +} + function getRequiredStringFlag(flags: Map, name: string): string { const value = flags.get(name) if (typeof value !== 'string' || value.length === 0) { diff --git a/src/cli/handlers/terminal.ts b/src/cli/handlers/terminal.ts index 72e59b86655..3ff6142275a 100644 --- a/src/cli/handlers/terminal.ts +++ b/src/cli/handlers/terminal.ts @@ -32,6 +32,10 @@ import { getOptionalStringFlag, getRequiredStringFlag } from '../flags' +import { + annotateOmittedHostScope, + type WithAnnotatedHostScope +} from '../omitted-host-scope-selectors' import { RuntimeClientError } from '../runtime-client' import { getBrowserWorktreeSelector, @@ -90,12 +94,16 @@ const terminalFocusHandler: CommandHandler = async ({ flags, client, cwd, json } export const TERMINAL_HANDLERS: Record = { 'terminal list': async ({ flags, client, cwd, json }) => { - const result = await client.call('terminal.list', { - worktree: await getOptionalWorktreeSelector(flags, 'worktree', cwd, client), - limit: getOptionalPositiveIntegerFlag(flags, 'limit'), - // Why: agent JSON calls dominate; topology stays available through an explicit opt-in. - includeVisualLayouts: !json || flags.has('include-visual-layouts') - }) + const result = await client.call>( + 'terminal.list', + { + worktree: await getOptionalWorktreeSelector(flags, 'worktree', cwd, client), + limit: getOptionalPositiveIntegerFlag(flags, 'limit'), + // Why: agent JSON calls dominate; topology stays available through an explicit opt-in. + includeVisualLayouts: !json || flags.has('include-visual-layouts') + } + ) + await annotateOmittedHostScope(client, result.result) printResult(result, json, formatTerminalList) }, 'terminal show': async ({ flags, client, cwd, json }) => { diff --git a/src/cli/handlers/worktree.ts b/src/cli/handlers/worktree.ts index 484bcf9ea6d..262599234f0 100644 --- a/src/cli/handlers/worktree.ts +++ b/src/cli/handlers/worktree.ts @@ -7,6 +7,10 @@ import type { } from '../../shared/runtime-types' import type { CommandHandler } from '../dispatch' import { formatWorktreeList, formatWorktreePs, formatWorktreeShow, printResult } from '../format' +import { + annotateOmittedHostScope, + type WithAnnotatedHostScope +} from '../omitted-host-scope-selectors' import { RuntimeClientError } from '../runtime-client' import { getOptionalNullableNumberFlag, @@ -171,16 +175,22 @@ async function getCreateRepoSelector( export const WORKTREE_HANDLERS: Record = { 'worktree ps': async ({ flags, client, json }) => { - const result = await client.call('worktree.ps', { - limit: getOptionalPositiveIntegerFlag(flags, 'limit') - }) + const result = await client.call>( + 'worktree.ps', + { limit: getOptionalPositiveIntegerFlag(flags, 'limit') } + ) + await annotateOmittedHostScope(client, result.result) printResult(result, json, formatWorktreePs) }, 'worktree list': async ({ flags, client, json }) => { - const result = await client.call('worktree.list', { - repo: getOptionalStringFlag(flags, 'repo'), - limit: getOptionalPositiveIntegerFlag(flags, 'limit') - }) + const result = await client.call>( + 'worktree.list', + { + repo: getOptionalStringFlag(flags, 'repo'), + limit: getOptionalPositiveIntegerFlag(flags, 'limit') + } + ) + await annotateOmittedHostScope(client, result.result) printResult(result, json, formatWorktreeList) }, 'worktree show': async ({ flags, client, cwd, json }) => { diff --git a/src/cli/index-local-command-routing-flags.test.ts b/src/cli/index-local-command-routing-flags.test.ts new file mode 100644 index 00000000000..b8db44915d2 --- /dev/null +++ b/src/cli/index-local-command-routing-flags.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it, vi } from 'vitest' + +const { + callMock, + runtimeClientConstructorMock, + serveOrcaAppMock, + getDefaultUserDataPathMock, + addEnvironmentFromPairingCodeMock, + listEnvironmentsMock, + removeEnvironmentMock, + resolveEnvironmentMock, + spawnMock +} = vi.hoisted(() => ({ + callMock: vi.fn(), + runtimeClientConstructorMock: vi.fn(), + serveOrcaAppMock: vi.fn(), + getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'), + addEnvironmentFromPairingCodeMock: vi.fn(), + listEnvironmentsMock: vi.fn(), + removeEnvironmentMock: vi.fn(), + resolveEnvironmentMock: vi.fn(), + spawnMock: vi.fn() +})) + +vi.mock('./runtime-client', async () => { + const { createRuntimeClientModuleMock } = await import('./index-test-harness.js') + return createRuntimeClientModuleMock({ + callMock, + runtimeClientConstructorMock, + serveOrcaAppMock, + getDefaultUserDataPathMock + }) +}) + +vi.mock('./runtime/environments', () => ({ + addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock, + listEnvironments: listEnvironmentsMock, + removeEnvironment: removeEnvironmentMock, + resolveEnvironment: resolveEnvironmentMock +})) + +vi.mock('child_process', async () => { + const { createChildProcessModuleMock } = await import('./index-test-harness.js') + return createChildProcessModuleMock(spawnMock) +}) + +import { main } from './index' +import { okFixture, queueFixtures } from './test-fixtures' +import { pairRuntimeEnvironment, useWorktreeAwarenessEnvironment } from './index-test-harness' + +const SSH_TARGET = { id: 'ssh-1777360569033-yvz2mp', label: 'openclaw' } + +/** Every SSH-target lookup answers with the one target only this machine's runtime knows about. */ +function queueSshTargetLookups(count: number): void { + queueFixtures( + callMock, + ...Array.from({ length: count }, () => okFixture('req_ssh_targets', { targets: [SSH_TARGET] })) + ) +} + +describe('runtime-selector flags on locally pinned CLI commands', () => { + useWorktreeAwarenessEnvironment({ + callMock, + serveOrcaAppMock, + getDefaultUserDataPathMock, + addEnvironmentFromPairingCodeMock, + listEnvironmentsMock, + spawnMock + }) + + it('answers `host list` from this machine and stamps the runtime that actually answered', async () => { + pairRuntimeEnvironment(listEnvironmentsMock, 'env-m4air', 'm4air') + queueSshTargetLookups(1) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['host', 'list', '--json'], '/tmp/repo') + + const printed = JSON.parse(String(logSpy.mock.calls[0]?.[0])) + expect(printed._meta.runtimeId).toBe('local') + expect(printed.result.hosts.map((host: { id: string }) => host.id)).toEqual([ + 'local', + SSH_TARGET.id, + 'env-m4air' + ]) + // The tell: `runtimeId: local` is only honest if no routed client was ever built. + expect(runtimeClientConstructorMock).toHaveBeenCalledWith(null, null) + }) + + it('rejects `host list --environment` instead of answering with a half-routed listing', async () => { + // Why: pre-fix this routed the SSH lookup to m4air while reading paired servers from this + // machine, dropped the openclaw row, and still stamped `_meta.runtimeId: "local"` — one + // listing describing two hosts, which reads as "m4air has no SSH targets". + pairRuntimeEnvironment(listEnvironmentsMock, 'env-m4air', 'm4air') + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['host', 'list', '--environment', 'm4air', '--json'], '/tmp/repo') + + const printed = JSON.parse(String(logSpy.mock.calls[0]?.[0])) + expect(printed.ok).toBe(false) + expect(printed.error.code).toBe('invalid_argument') + expect(printed.error.message).toContain('`--environment` does not retarget `orca host list`') + expect(process.exitCode).toBe(1) + expect(callMock).not.toHaveBeenCalled() + expect(runtimeClientConstructorMock).not.toHaveBeenCalledWith(null, 'm4air') + process.exitCode = 0 + }) + + it('rejects `environment list --environment` rather than repeating the local answer', async () => { + pairRuntimeEnvironment(listEnvironmentsMock, 'env-m4air', 'm4air') + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['environment', 'list', '--environment', 'm4air', '--json'], '/tmp/repo') + + const printed = JSON.parse(String(logSpy.mock.calls[0]?.[0])) + expect(printed.ok).toBe(false) + expect(printed.error.code).toBe('invalid_argument') + expect(printed.error.message).toContain( + '`--environment` does not retarget `orca environment list`' + ) + process.exitCode = 0 + }) + + it('rejects `--pairing-code` on both listings for the same reason', async () => { + pairRuntimeEnvironment(listEnvironmentsMock, 'env-m4air', 'm4air') + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['host', 'list', '--pairing-code', 'orca://pair?code=x', '--json'], '/tmp/repo') + await main( + ['environment', 'list', '--pairing-code', 'orca://pair?code=x', '--json'], + '/tmp/repo' + ) + + for (const call of logSpy.mock.calls) { + const printed = JSON.parse(String(call[0])) + expect(printed.ok).toBe(false) + expect(printed.error.message).toContain('`--pairing-code` does not retarget') + } + expect(callMock).not.toHaveBeenCalled() + process.exitCode = 0 + }) + + it('keeps `host list` local when ORCA_ENVIRONMENT is set ambiently', async () => { + // Why: the ambient variable produced the same two-machine listing as the explicit flag, with + // no flag to reject. Pinning the family is what makes `runtimeId: local` true in both cases. + process.env.ORCA_ENVIRONMENT = 'm4air' + pairRuntimeEnvironment(listEnvironmentsMock, 'env-m4air', 'm4air') + queueSshTargetLookups(1) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['host', 'list', '--json'], '/tmp/repo') + + const printed = JSON.parse(String(logSpy.mock.calls[0]?.[0])) + expect(printed.ok).toBe(true) + expect(printed.result.hosts.some((host: { id: string }) => host.id === SSH_TARGET.id)).toBe( + true + ) + expect(runtimeClientConstructorMock).toHaveBeenCalledWith(null, null) + expect(runtimeClientConstructorMock).not.toHaveBeenCalledWith(undefined, undefined) + }) + + it('still treats --environment as the selector argument on `environment show` and `rm`', async () => { + // Why: the guard must not fire where the flag names the row to act on rather than a route. + const environment = { + id: 'env-m4air', + name: 'm4air', + createdAt: 1, + updatedAt: 1, + lastUsedAt: null, + runtimeId: null, + endpoints: [], + preferredEndpointId: null + } + resolveEnvironmentMock.mockReturnValue(environment) + removeEnvironmentMock.mockReturnValue(environment) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['environment', 'show', '--environment', 'm4air', '--json'], '/tmp/repo') + await main(['environment', 'rm', '--environment', 'm4air', '--json'], '/tmp/repo') + + for (const call of logSpy.mock.calls) { + expect(JSON.parse(String(call[0])).ok).toBe(true) + } + }) +}) diff --git a/src/cli/index-omitted-host-scope-selectors.test.ts b/src/cli/index-omitted-host-scope-selectors.test.ts new file mode 100644 index 00000000000..1fbd77289f5 --- /dev/null +++ b/src/cli/index-omitted-host-scope-selectors.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it, vi } from 'vitest' + +const { + callMock, + runtimeClientConstructorMock, + serveOrcaAppMock, + getDefaultUserDataPathMock, + addEnvironmentFromPairingCodeMock, + listEnvironmentsMock, + spawnMock +} = vi.hoisted(() => ({ + callMock: vi.fn(), + runtimeClientConstructorMock: vi.fn(), + serveOrcaAppMock: vi.fn(), + getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'), + addEnvironmentFromPairingCodeMock: vi.fn(), + listEnvironmentsMock: vi.fn(), + spawnMock: vi.fn() +})) + +vi.mock('./runtime-client', async () => { + const { createRuntimeClientModuleMock } = await import('./index-test-harness.js') + return createRuntimeClientModuleMock({ + callMock, + runtimeClientConstructorMock, + serveOrcaAppMock, + getDefaultUserDataPathMock + }) +}) + +vi.mock('./runtime/environments', () => ({ + addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock, + listEnvironments: listEnvironmentsMock, + removeEnvironment: vi.fn(), + resolveEnvironment: vi.fn() +})) + +vi.mock('child_process', async () => { + const { createChildProcessModuleMock } = await import('./index-test-harness.js') + return createChildProcessModuleMock(spawnMock) +}) + +import { main } from './index' +import { okFixture, queueFixtures } from './test-fixtures' +import { pairRuntimeEnvironment, useWorktreeAwarenessEnvironment } from './index-test-harness' + +const TERMINAL_ROW = { + handle: 'term_1', + ptyId: 'pty-1', + worktreeId: 'repo::/wt', + worktreePath: '/wt', + branch: 'main', + tabId: 'tab-1', + leafId: 'leaf-1', + title: 'worker', + connected: true, + writable: true, + lastOutputAt: null, + preview: '', + executionHostId: 'local' +} + +describe('omittedHostIds selector annotation', () => { + useWorktreeAwarenessEnvironment({ + callMock, + serveOrcaAppMock, + getDefaultUserDataPathMock, + addEnvironmentFromPairingCodeMock, + listEnvironmentsMock, + spawnMock + }) + + it('marks a stale runtime host that no caller can select', async () => { + // Why: `omittedHostIds` is built from the runtime's own bookkeeping, so it names `runtime:` + // ids for servers that are no longer paired. An agent looping over the list to complete a + // partial listing hard-errors on those — 6 of 9 in the recorded QA run. + pairRuntimeEnvironment(listEnvironmentsMock, 'env-paired', 'm4air') + queueFixtures( + callMock, + okFixture('req_terminal_list', { + terminals: [TERMINAL_ROW], + totalCount: 1, + truncated: false, + hostScope: { + hostIds: ['local'], + omittedHostIds: ['runtime:env-paired', 'runtime:env-retired'] + } + }) + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['terminal', 'list', '--json'], '/tmp/repo') + + const printed = JSON.parse(String(logSpy.mock.calls[0]?.[0])) + expect(printed.result.hostScope.omittedHostIds).toEqual([ + 'runtime:env-paired', + 'runtime:env-retired' + ]) + expect(printed.result.hostScope.omittedHostSelectors).toEqual([ + { hostId: 'runtime:env-paired', selector: '--environment m4air' }, + { hostId: 'runtime:env-retired', selector: null } + ]) + }) + + it('says which omitted hosts are not selectable in the human listing', async () => { + pairRuntimeEnvironment(listEnvironmentsMock, 'env-paired', 'm4air') + queueFixtures( + callMock, + okFixture('req_terminal_list', { + terminals: [TERMINAL_ROW], + totalCount: 1, + truncated: false, + hostScope: { + hostIds: ['local'], + omittedHostIds: ['runtime:env-paired', 'runtime:env-retired'] + } + }) + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['terminal', 'list'], '/tmp/repo') + + const printed = String(logSpy.mock.calls[0]?.[0]) + expect(printed).toContain('runtime:env-paired (--environment m4air)') + expect(printed).toContain('runtime:env-retired (not selectable from this machine)') + }) + + it('resolves an omitted SSH host against the targets the runtime actually knows', async () => { + listEnvironmentsMock.mockReturnValue([]) + queueFixtures( + callMock, + okFixture('req_terminal_list', { + terminals: [TERMINAL_ROW], + totalCount: 1, + truncated: false, + hostScope: { hostIds: ['local'], omittedHostIds: ['ssh:box-1', 'ssh:box-gone'] } + }), + okFixture('req_ssh_targets', { targets: [{ id: 'box-1', label: 'openclaw' }] }) + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['terminal', 'list', '--json'], '/tmp/repo') + + const printed = JSON.parse(String(logSpy.mock.calls[0]?.[0])) + expect(printed.result.hostScope.omittedHostSelectors).toEqual([ + { hostId: 'ssh:box-1', selector: '--host ssh:box-1' }, + { hostId: 'ssh:box-gone', selector: null } + ]) + }) + + it('never keeps a host id out of omittedHostIds', async () => { + // Why: filtering the unreachable ones would shrink what the listing admits it did not cover. + // The gap is real whether or not this machine can name the host that owns it. + listEnvironmentsMock.mockReturnValue([]) + queueFixtures( + callMock, + okFixture('req_terminal_list', { + terminals: [], + totalCount: 0, + truncated: false, + hostScope: { hostIds: [], omittedHostIds: ['runtime:env-retired'] } + }) + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['terminal', 'list', '--json'], '/tmp/repo') + + const printed = JSON.parse(String(logSpy.mock.calls[0]?.[0])) + expect(printed.result.hostScope.omittedHostIds).toEqual(['runtime:env-retired']) + }) + + it('costs no extra round trip when nothing was omitted', async () => { + queueFixtures( + callMock, + okFixture('req_terminal_list', { + terminals: [TERMINAL_ROW], + totalCount: 1, + truncated: false, + hostScope: { hostIds: ['local'], omittedHostIds: [] } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['terminal', 'list', '--json'], '/tmp/repo') + + expect(callMock).toHaveBeenCalledTimes(1) + }) +}) + +describe('worktree listings report their host coverage', () => { + useWorktreeAwarenessEnvironment({ + callMock, + serveOrcaAppMock, + getDefaultUserDataPathMock, + addEnvironmentFromPairingCodeMock, + listEnvironmentsMock, + spawnMock + }) + + it('prints a host column and the scope line for `worktree list`', async () => { + listEnvironmentsMock.mockReturnValue([]) + queueFixtures( + callMock, + okFixture('req_worktree_list', { + worktrees: [ + { + id: 'repo-ssh::/remote/wt', + branch: 'main', + path: '/remote/wt', + hostId: 'ssh:box-1', + displayName: 'remote', + parentWorktreeId: null, + childWorktreeIds: [], + linkedIssue: null, + comment: '' + } + ], + totalCount: 521, + truncated: true, + hostScope: { hostIds: ['ssh:box-1'], omittedHostIds: ['runtime:env-retired'] } + }) + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['worktree', 'list'], '/tmp/repo') + + const printed = String(logSpy.mock.calls[0]?.[0]) + expect(printed).toContain('host=ssh:box-1') + expect(printed).toContain('scope: ssh:box-1') + expect(printed).toContain('runtime:env-retired (not selectable from this machine)') + expect(printed).toContain('truncated: showing 1 of 521') + }) + + it('does not claim a scope for `worktree ps` when the host reported none', async () => { + queueFixtures( + callMock, + okFixture('req_worktree_ps', { worktrees: [], totalCount: 0, truncated: false }) + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['worktree', 'ps'], '/tmp/repo') + + const printed = String(logSpy.mock.calls[0]?.[0]) + expect(printed).toContain('scope: unverifiable') + }) +}) diff --git a/src/cli/index-terminal-list-host-scope.test.ts b/src/cli/index-terminal-list-host-scope.test.ts index b38cd71451f..04b486df671 100644 --- a/src/cli/index-terminal-list-host-scope.test.ts +++ b/src/cli/index-terminal-list-host-scope.test.ts @@ -88,7 +88,10 @@ describe('orca terminal list host scope', () => { expect(printed.result.terminals[0].executionHostId).toBe('ssh:box-1') expect(printed.result.hostScope).toEqual({ hostIds: ['ssh:box-1'], - omittedHostIds: ['local'] + omittedHostIds: ['local'], + // The CLI annotates each omitted host with the flag that reaches it; see + // index-omitted-host-scope-selectors.test.ts. + omittedHostSelectors: [{ hostId: 'local', selector: '--host local' }] }) }) diff --git a/src/cli/index.ts b/src/cli/index.ts index c29bfff7060..9389113b195 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -31,6 +31,10 @@ function shouldIgnoreRemoteSelection(commandPath: string[]): boolean { commandPath[0] === 'account' || commandPath[0] === 'artifacts' || commandPath[0] === 'environment' || + // Why: `host list` answers "what can this machine target, and with what flag". Half of that + // answer (paired servers) is read from this machine's own pairing store and cannot be routed, + // so routing the other half produced one listing describing two machines at once. + commandPath[0] === 'host' || commandPath[0] === 'serve' || commandPath[0] === 'agent' || commandPath[0] === 'vm' || diff --git a/src/cli/omitted-host-scope-selectors.ts b/src/cli/omitted-host-scope-selectors.ts new file mode 100644 index 00000000000..2666b116375 --- /dev/null +++ b/src/cli/omitted-host-scope-selectors.ts @@ -0,0 +1,126 @@ +import { + parseExecutionHostId, + type ExecutionHostId, + type ParsedExecutionHost +} from '../shared/execution-host' +import type { RuntimeListingHostScope } from '../shared/runtime-listing-host-scope' +import { + findEnvironmentByName, + findSshTargetByName, + listSshTargets, + type SshTargetSummary +} from './host-selector-alternatives' +import type { RuntimeClient } from './runtime-client' + +export type OmittedHostScopeSelector = { + hostId: ExecutionHostId + /** The flag that routes a follow-up query to this host, or null when it names nothing here. */ + selector: string | null +} + +/** A host scope annotated on this machine. The runtime never sends `omittedHostSelectors`. */ +export type ListingHostScopeWithSelectors = RuntimeListingHostScope & { + omittedHostSelectors?: OmittedHostScopeSelector[] +} + +export type WithAnnotatedHostScope = Omit & { + hostScope?: ListingHostScopeWithSelectors +} + +/** + * Resolves how to reach each host a listing did not cover. + * + * `hostScope` is the documented way to complete a partial listing, but `omittedHostIds` is built + * from the runtime's own bookkeeping — repos, folder workspaces, and workspace sessions — so it + * names `runtime:` ids for servers that are no longer paired. An agent looping over the list to + * finish the job hard-errors on those. + * + * The ids are kept rather than filtered: dropping one would shrink what the listing admits it did + * not cover, and `docs/reference/ssh-execution-boundary.md` requires a listing to name its gaps. + * A `null` selector marks the ones this machine cannot name, which is the part a caller needs. + * Only the local pairing store and SSH-target registry are consulted, so this answers "can I + * select it", never "is it up" — no host is claimed live or exited on this path. + */ +export async function resolveOmittedHostScopeSelectors( + client: RuntimeClient, + omittedHostIds: readonly ExecutionHostId[] +): Promise { + const parsed = omittedHostIds.map((hostId) => ({ + hostId, + host: parseExecutionHostId(hostId) + })) + const environments = parsed.some((entry) => entry.host?.kind === 'runtime') + ? await listPairedEnvironments() + : [] + // Why: SSH targets need a round trip, so only pay for it when an ssh host was actually omitted. + const sshTargets = parsed.some((entry) => entry.host?.kind === 'ssh') + ? await listSshTargets(client) + : [] + return parsed.map(({ hostId, host }) => ({ + hostId, + selector: resolveSelector(host, environments, sshTargets) + })) +} + +async function listPairedEnvironments(): Promise<{ id: string; name: string }[]> { + const [{ listEnvironments }, { getDefaultUserDataPath }] = await Promise.all([ + import('./runtime/environments.js'), + import('./runtime-client.js') + ]) + return listEnvironments(getDefaultUserDataPath()).map((environment) => ({ + id: environment.id, + name: environment.name + })) +} + +function resolveSelector( + host: ParsedExecutionHost | null, + environments: readonly { id: string; name: string }[], + sshTargets: readonly SshTargetSummary[] +): string | null { + if (host?.kind === 'local') { + return '--host local' + } + if (host?.kind === 'ssh') { + return findSshTargetByName(sshTargets, host.targetId) ? `--host ssh:${host.targetId}` : null + } + if (host?.kind === 'runtime') { + const environment = findEnvironmentByName(environments, host.environmentId) + return environment ? `--environment ${environment.name}` : null + } + return null +} + +/** Renders a scope line; an absent scope means the host never reported one, not full coverage. */ +export function formatListingHostScope(scope: ListingHostScopeWithSelectors | undefined): string { + if (!scope) { + return 'scope: unverifiable — this host does not report which hosts it lists' + } + const covered = scope.hostIds.length > 0 ? scope.hostIds.join(', ') : 'none' + if (scope.omittedHostIds.length === 0) { + return `scope: ${covered}` + } + const selectorByHostId = new Map( + (scope.omittedHostSelectors ?? []).map((entry) => [entry.hostId, entry.selector]) + ) + const omitted = scope.omittedHostIds.map((hostId) => { + if (!selectorByHostId.has(hostId)) { + return hostId + } + const selector = selectorByHostId.get(hostId) + return selector ? `${hostId} (${selector})` : `${hostId} (not selectable from this machine)` + }) + return `scope: ${covered} — not covered: ${omitted.join(', ')}` +} + +/** Attaches the resolved selectors in place; a listing with no omitted hosts pays nothing. */ +export async function annotateOmittedHostScope( + client: RuntimeClient, + result: { hostScope?: ListingHostScopeWithSelectors } +): Promise { + const scope = result.hostScope + if (!scope || scope.omittedHostIds.length === 0) { + return + } + scope.omittedHostSelectors = await resolveOmittedHostScopeSelectors(client, scope.omittedHostIds) +} diff --git a/src/cli/remote-selection-flag-rejection.ts b/src/cli/remote-selection-flag-rejection.ts new file mode 100644 index 00000000000..e2609fa604a --- /dev/null +++ b/src/cli/remote-selection-flag-rejection.ts @@ -0,0 +1,29 @@ +import { RuntimeClientError } from './runtime/types' + +/** + * The flags that pick which runtime answers a command. `shouldIgnoreRemoteSelection` + * in `src/cli/index.ts` pins some command families to the local runtime, which drops + * these silently — so every pinned family pairs the pin with this rejection instead. + */ +export const REMOTE_SELECTION_FLAGS = ['environment', 'pairing-code'] as const + +/** + * Fails a pinned command that was given a runtime selector, rather than answering + * for a machine the caller did not name. `suffix` completes "`--` does not + * retarget …" and should say what the command answers for and where to run it. + */ +export function rejectRemoteSelectionFlags( + flags: ReadonlyMap, + suffix: string, + data?: Record +): void { + for (const flag of REMOTE_SELECTION_FLAGS) { + if (flags.has(flag)) { + throw new RuntimeClientError( + 'invalid_argument', + `\`--${flag}\` does not retarget ${suffix}`, + data + ) + } + } +} diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index 112d9528d2e..f2236ef86e9 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -1,5 +1,6 @@ import type { CommandSpec } from '../args' import { GLOBAL_FLAGS } from '../args' +import { WORKTREE_LISTING_SCOPE_NOTES } from './worktree-listing-scope-notes' import { SERVE_COMMAND_SPECS } from './serve' import { TERMINAL_CLOSE_COMMAND_SPEC } from './terminal-close' @@ -65,7 +66,8 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ path: ['worktree', 'list'], summary: 'List Orca-managed worktrees', usage: 'orca worktree list [--repo ] [--limit ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'repo', 'limit'] + allowedFlags: [...GLOBAL_FLAGS, 'repo', 'limit'], + notes: [...WORKTREE_LISTING_SCOPE_NOTES] }, { path: ['worktree', 'show'], @@ -180,7 +182,8 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ path: ['worktree', 'ps'], summary: 'Show a compact orchestration summary across worktrees', usage: 'orca worktree ps [--limit ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'limit'] + allowedFlags: [...GLOBAL_FLAGS, 'limit'], + notes: [...WORKTREE_LISTING_SCOPE_NOTES] }, { path: ['terminal', 'list'], diff --git a/src/cli/specs/environment.ts b/src/cli/specs/environment.ts index d6ceb795028..7bf90615270 100644 --- a/src/cli/specs/environment.ts +++ b/src/cli/specs/environment.ts @@ -10,7 +10,8 @@ export const ENVIRONMENT_COMMAND_SPECS: CommandSpec[] = [ notes: [ 'Answers "what can I target and what do I pass" in one place: this machine, the SSH targets registered on it, and the Orca servers paired with it.', 'The three kinds are reached differently. A paired Orca server is a connection, selected with --environment . An SSH target is a machine the connected Orca host reaches, selected with --host ssh:. Passing one where the other belongs is the most common way to get an empty or missing-host answer.', - "SSH targets are read from the Orca host you are currently connected to, so this lists that host's targets and not another server's." + "SSH targets are read from this machine's own Orca runtime, so this lists that machine's targets and not another server's. Run `orca host list` on the other machine to see the targets registered there.", + '--environment and --pairing-code are rejected rather than ignored: paired servers come from this machine\u2019s pairing store, so a routed answer would describe two machines at once.' ], examples: ['orca host list', 'orca host list --json'] }, @@ -25,7 +26,10 @@ export const ENVIRONMENT_COMMAND_SPECS: CommandSpec[] = [ path: ['environment', 'list'], summary: 'List saved Orca runtime environments', usage: 'orca environment list [--json]', - allowedFlags: [...GLOBAL_FLAGS] + allowedFlags: [...GLOBAL_FLAGS], + notes: [ + 'Answers from this machine\u2019s pairing store. --environment and --pairing-code are rejected rather than ignored, because there is no other host that could answer.' + ] }, { path: ['environment', 'show'], diff --git a/src/cli/specs/worktree-listing-scope-notes.ts b/src/cli/specs/worktree-listing-scope-notes.ts new file mode 100644 index 00000000000..91449cc6584 --- /dev/null +++ b/src/cli/specs/worktree-listing-scope-notes.ts @@ -0,0 +1,6 @@ +/** Shared by `worktree list` and `worktree ps`, which report host coverage the same way. */ +export const WORKTREE_LISTING_SCOPE_NOTES: readonly string[] = [ + 'Each row carries the execution host that owns it (`host=`), and the trailing `scope:` line names every host the page covers plus the ones it does not.', + 'A host named under `not covered` may still have workspaces; an empty answer for it is not evidence that it has none. Each is annotated with the flag that reaches it, or marked not selectable from this machine.', + 'The row cap is shared across hosts, so a host whose rows sort last is not starved out of the page.' +] diff --git a/src/cli/terminal-format.ts b/src/cli/terminal-format.ts index edf4cbaa22c..e61a2e48b76 100644 --- a/src/cli/terminal-format.ts +++ b/src/cli/terminal-format.ts @@ -1,10 +1,10 @@ import { PTY_LIVE_NOTE, describeUnconfirmedStop } from '../shared/pty-liveness-verdict' import { structuredChatPtyWriteRefusalCopy } from '../shared/agent-session-pty-write-refusal-copy' +import { formatListingHostScope, type WithAnnotatedHostScope } from './omitted-host-scope-selectors' import type { RuntimeTerminalClose, RuntimeTerminalCreate, RuntimeTerminalFocus, - RuntimeTerminalListHostScope, RuntimeTerminalListResult, RuntimeTerminalVisualLayout, RuntimeTerminalVisualLayoutNode, @@ -18,8 +18,10 @@ import type { RuntimeTerminalWait } from '../shared/runtime-types' -export function formatTerminalList(result: RuntimeTerminalListResult): string { - const scope = formatTerminalListHostScope(result.hostScope) +export function formatTerminalList( + result: WithAnnotatedHostScope +): string { + const scope = formatListingHostScope(result.hostScope) if (result.terminals.length === 0) { return `No terminals listed.\n${scope}` } @@ -37,18 +39,6 @@ export function formatTerminalList(result: RuntimeTerminalListResult): string { : bodyWithScope } -// Why: a listing that does not say what it covers reads as absolute, and an -// absent scope means the host is too old to know — not that it covered everything. -function formatTerminalListHostScope(scope: RuntimeTerminalListHostScope | undefined): string { - if (!scope) { - return 'scope: unverifiable — this host does not report which hosts it lists' - } - const covered = scope.hostIds.length > 0 ? scope.hostIds.join(', ') : 'none' - const omitted = - scope.omittedHostIds.length > 0 ? ` — not covered: ${scope.omittedHostIds.join(', ')}` : '' - return `scope: ${covered}${omitted}` -} - function formatTerminalVisualLayouts( layouts: readonly RuntimeTerminalVisualLayout[] | undefined ): string | null { diff --git a/src/cli/workspace-format.ts b/src/cli/workspace-format.ts index 8cdfce86b74..51a0369978b 100644 --- a/src/cli/workspace-format.ts +++ b/src/cli/workspace-format.ts @@ -7,6 +7,7 @@ import type { RuntimeWorktreeRecord } from '../shared/runtime-types' import type { MemorySnapshot, WorktreeMemory } from '../shared/process-stats-types' +import { formatListingHostScope, type WithAnnotatedHostScope } from './omitted-host-scope-selectors' export function formatMemorySnapshot(snapshot: MemorySnapshot): string { const topWorktrees = [...snapshot.worktrees].sort((a, b) => b.memory - a.memory).slice(0, 10) @@ -130,19 +131,21 @@ export function formatEnvironment(environment: PublicKnownRuntimeEnvironment): s ].join('\n') } -export function formatWorktreePs(result: RuntimeWorktreePsResult): string { +export function formatWorktreePs(result: WithAnnotatedHostScope): string { + const scope = formatListingHostScope(result.hostScope) if (result.worktrees.length === 0) { - return 'No worktrees found.' + return `No worktrees found.\n${scope}` } const body = result.worktrees .map( (worktree) => - `${worktree.repo} ${worktree.branch} live:${worktree.liveTerminalCount} pty:${worktree.hasAttachedPty ? 'yes' : 'no'} unread:${worktree.unread ? 'yes' : 'no'}\n${worktree.path}${worktree.preview ? `\npreview: ${worktree.preview}` : ''}` + `${worktree.repo} ${worktree.branch} host=${worktree.hostId ?? 'unverifiable'} live:${worktree.liveTerminalCount} pty:${worktree.hasAttachedPty ? 'yes' : 'no'} unread:${worktree.unread ? 'yes' : 'no'}\n${worktree.path}${worktree.preview ? `\npreview: ${worktree.preview}` : ''}` ) .join('\n\n') + const bodyWithScope = `${body}\n\n${scope}` return result.truncated - ? `${body}\n\ntruncated: showing ${result.worktrees.length} of ${result.totalCount}` - : body + ? `${bodyWithScope}\ntruncated: showing ${result.worktrees.length} of ${result.totalCount}` + : bodyWithScope } export function formatRepoList(result: RuntimeRepoList): string { @@ -168,19 +171,23 @@ export function formatRepoRefs(result: RuntimeRepoSearchRefs): string { return result.truncated ? `${result.refs.join('\n')}\n\ntruncated: yes` : result.refs.join('\n') } -export function formatWorktreeList(result: RuntimeWorktreeListResult): string { +export function formatWorktreeList( + result: WithAnnotatedHostScope +): string { + const scope = formatListingHostScope(result.hostScope) if (result.worktrees.length === 0) { - return 'No worktrees found.' + return `No worktrees found.\n${scope}` } const body = result.worktrees .map((worktree) => { const childCount = worktree.childWorktreeIds?.length ?? 0 - return `${String(worktree.id)} ${String(worktree.branch)} ${String(worktree.path)}\ndisplayName: ${String(worktree.displayName ?? '')}\nparentWorktreeId: ${String(worktree.parentWorktreeId ?? 'null')}\nchildWorktreeIds: ${childCount > 0 ? worktree.childWorktreeIds.join(',') : '[]'}\nlinkedIssue: ${String(worktree.linkedIssue ?? 'null')}\ncomment: ${String(worktree.comment ?? '')}` + return `${String(worktree.id)} ${String(worktree.branch)} host=${String(worktree.hostId ?? 'unverifiable')} ${String(worktree.path)}\ndisplayName: ${String(worktree.displayName ?? '')}\nparentWorktreeId: ${String(worktree.parentWorktreeId ?? 'null')}\nchildWorktreeIds: ${childCount > 0 ? worktree.childWorktreeIds.join(',') : '[]'}\nlinkedIssue: ${String(worktree.linkedIssue ?? 'null')}\ncomment: ${String(worktree.comment ?? '')}` }) .join('\n\n') + const bodyWithScope = `${body}\n\n${scope}` return result.truncated - ? `${body}\n\ntruncated: showing ${result.worktrees.length} of ${result.totalCount}` - : body + ? `${bodyWithScope}\ntruncated: showing ${result.worktrees.length} of ${result.totalCount}` + : bodyWithScope } export function formatWorktreeShow(result: { worktree: RuntimeWorktreeRecord }): string { diff --git a/src/main/git/remote.ts b/src/main/git/remote.ts index 20cf8415d04..aa7932687ca 100644 --- a/src/main/git/remote.ts +++ b/src/main/git/remote.ts @@ -3,8 +3,7 @@ import { runPullWithDivergenceFallback } from '../../shared/git-remote-error' import { resolveEffectiveGitUpstream } from '../../shared/git-effective-upstream' -import { gitRefTargetsBranchOnRemote } from '../../shared/git-remote-branch-name' -import { findGitRemoteNameByFetchUrl } from '../../shared/git-remote-url-index' +import { resolveConfiguredGitPushTarget } from '../../shared/git-push-target-resolution' import type { GitPushTarget } from '../../shared/worktree/types' import type { GitRuntimeOptions } from './git-runtime-options' import { gitOptionsForWorktree } from './git-runtime-options' @@ -20,157 +19,6 @@ import { runWithGitWorktreeOperationLock } from '../../shared/git-worktree-opera export { gitPullRebaseFromBase } from './remote-rebase' -async function getConfiguredPushTarget( - worktreePath: string, - options: GitRuntimeOptions = {} -): Promise<{ remote: string; refspec: string } | null> { - try { - const { stdout: branchStdout } = await gitExecFileAsync( - ['symbolic-ref', '--quiet', '--short', 'HEAD'], - gitOptionsForWorktree(worktreePath, options) - ) - const branch = branchStdout.trim() - if (!branch) { - return null - } - - const [pushRemote, { stdout: mergeStdout }] = await Promise.all([ - getConfiguredPushRemote(worktreePath, branch, options), - gitExecFileAsync( - ['config', '--get', `branch.${branch}.merge`], - gitOptionsForWorktree(worktreePath, options) - ) - ]) - const remote = pushRemote?.remote - const mergeRef = mergeStdout.trim() - const branchRef = mergeRef.replace(/^refs\/heads\//, '') - if (!remote || !branchRef || remote === '.' || branchRef === mergeRef) { - return null - } - if (await branchMergeTargetsConfiguredBase(worktreePath, branch, remote, branchRef, options)) { - return null - } - if (!canPushConfiguredMergeBranch(pushRemote, branch, branchRef)) { - return null - } - return { remote, refspec: `HEAD:${branchRef}` } - } catch { - return null - } -} - -async function getConfigValue( - worktreePath: string, - key: string, - options: GitRuntimeOptions = {} -): Promise { - try { - const { stdout } = await gitExecFileAsync( - ['config', '--get', key], - gitOptionsForWorktree(worktreePath, options) - ) - const value = stdout.trim() - return value || null - } catch { - return null - } -} - -function isUrlValuedRemote(remote: string): boolean { - return /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(remote) || /^[^@/:]+@[^:]+:.+/.test(remote) -} - -type ConfiguredPushRemote = { - remote: string - branchRemote: string | null -} - -// One `git remote -v` instead of `git remote` plus a serial `git remote get-url` -// per remote; both print the same insteadOf-expanded fetch URL. -async function findRemoteNameForUrl( - worktreePath: string, - remoteUrl: string, - options: GitRuntimeOptions = {} -): Promise { - try { - const { stdout } = await gitExecFileAsync( - ['remote', '-v'], - gitOptionsForWorktree(worktreePath, options) - ) - return findGitRemoteNameByFetchUrl(stdout, (candidateUrl) => candidateUrl === remoteUrl) - } catch { - return null - } -} - -async function normalizePushRemote( - worktreePath: string, - remote: string, - options: GitRuntimeOptions = {} -): Promise { - if (!isUrlValuedRemote(remote)) { - return remote - } - return (await findRemoteNameForUrl(worktreePath, remote, options)) ?? remote -} - -async function getConfiguredPushRemote( - worktreePath: string, - branch: string, - options: GitRuntimeOptions = {} -): Promise { - const branchRemote = await getConfigValue(worktreePath, `branch.${branch}.remote`, options) - const remote = - (await getConfigValue(worktreePath, `branch.${branch}.pushRemote`, options)) ?? - (await getConfigValue(worktreePath, 'remote.pushDefault', options)) ?? - branchRemote - if (!remote) { - return null - } - const normalizedRemote = await normalizePushRemote(worktreePath, remote, options) - // The two usually name the same URL; resolving it twice reads the remote table twice. - if (!branchRemote) { - return { remote: normalizedRemote, branchRemote: null } - } - return { - remote: normalizedRemote, - branchRemote: - branchRemote === remote - ? normalizedRemote - : await normalizePushRemote(worktreePath, branchRemote, options) - } -} - -async function branchMergeTargetsConfiguredBase( - worktreePath: string, - branch: string, - remote: string, - branchRef: string, - options: GitRuntimeOptions = {} -): Promise { - return gitRefTargetsBranchOnRemote( - await getConfigValue(worktreePath, `branch.${branch}.base`, options), - remote, - branchRef - ) -} - -function canPushConfiguredMergeBranch( - pushRemote: ConfiguredPushRemote | null, - branch: string, - branchRef: string -): boolean { - if (!pushRemote) { - return false - } - if (branchRef === branch) { - return true - } - // Why: branch.merge belongs to branch.remote. A pushDefault fork must not - // inherit origin/main as its destination branch. - return pushRemote.remote !== 'origin' && pushRemote.branchRemote === pushRemote.remote -} - function explicitPushTarget(target: GitPushTarget): { remote: string; refspec: string } { return { remote: target.remoteName, refspec: `HEAD:${target.branchName}` } } @@ -197,7 +45,9 @@ export async function gitPush( // from worktree config, not the upstream relationship. const target = pushTarget ? explicitPushTarget(pushTarget) - : await getConfiguredPushTarget(worktreePath, options) + : await resolveConfiguredGitPushTarget((args) => + gitExecFileAsync(args, gitOptionsForWorktree(worktreePath, options)) + ) const args = [ 'push', ...(options.forceWithLease ? ['--force-with-lease'] : []), diff --git a/src/main/git/worktree-branch-removal.ts b/src/main/git/worktree-branch-removal.ts index 35385254bca..dc09311596c 100644 --- a/src/main/git/worktree-branch-removal.ts +++ b/src/main/git/worktree-branch-removal.ts @@ -7,12 +7,9 @@ import { withLocalGitCapabilityCacheForExecution } from './git-capability-state' import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance' import { gitExecFileAsync } from './runner' import { parseWorktreeList } from '../../shared/git-worktree-porcelain-parser' +import { isBranchCheckedOutInWorktreeError } from '../../shared/git-branch-delete-refusal' import type { GitWorktreeExecOptions, RemoveWorktreeOptions } from './worktree-operation-options' -import { - gitExecOptions, - isBranchCheckedOutInWorktreeError, - normalizeLocalBranchRef -} from './worktree-operation-options' +import { gitExecOptions, normalizeLocalBranchRef } from './worktree-operation-options' export async function deleteBranchAfterWorktreeRemoval( repoPath: string, diff --git a/src/main/git/worktree-operation-options.ts b/src/main/git/worktree-operation-options.ts index 9376fe63f85..6f956c6c422 100644 --- a/src/main/git/worktree-operation-options.ts +++ b/src/main/git/worktree-operation-options.ts @@ -2,6 +2,7 @@ import type { LocalBaseRefRefreshResult, LocalBaseRefUpdateSuggestion } from '../../shared/worktree/base-ref-drift-types' +import { readGitCommandFailureText } from '../../shared/git-command-failure-text' import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' import type { GitWorktreeInfo } from '../../shared/worktree/types' @@ -95,28 +96,8 @@ export function getErrorCode(error: unknown): string | undefined { : undefined } -function getErrorText(error: unknown): string { - if (typeof error === 'object' && error !== null) { - const parts: string[] = [] - if ('message' in error && typeof error.message === 'string') { - parts.push(error.message) - } - if ('stderr' in error && typeof error.stderr === 'string') { - parts.push(error.stderr) - } - return parts.join('\n') - } - return String(error) -} - export function isNotGitRepositoryError(error: unknown): boolean { - return /not a git repository/i.test(getErrorText(error)) -} - -export function isBranchCheckedOutInWorktreeError(error: unknown): boolean { - return /cannot delete branch .*(?:used by worktree|checked out)|branch .*is checked out/i.test( - getErrorText(error) - ) + return /not a git repository/i.test(readGitCommandFailureText(error)) } export function normalizeLocalBranchRef(branch: string): string { diff --git a/src/main/i18n/main-i18n.ts b/src/main/i18n/main-i18n.ts index 8ebe21e0545..4fb548986b4 100644 --- a/src/main/i18n/main-i18n.ts +++ b/src/main/i18n/main-i18n.ts @@ -25,6 +25,7 @@ const LAZY_LOCALE_LOADERS: Record< () => Promise<{ default: Record }> > = { es: () => import('../../renderer/src/i18n/locales/es.json'), + fr: () => import('../../renderer/src/i18n/locales/fr.json'), ja: () => import('../../renderer/src/i18n/locales/ja.json'), ko: () => import('../../renderer/src/i18n/locales/ko.json'), zh: () => import('../../renderer/src/i18n/locales/zh.json') diff --git a/src/main/ipc/pty-controller-ownership-routing.test.ts b/src/main/ipc/pty-controller-ownership-routing.test.ts index 0d236e63cb2..2101d17f26b 100644 --- a/src/main/ipc/pty-controller-ownership-routing.test.ts +++ b/src/main/ipc/pty-controller-ownership-routing.test.ts @@ -324,6 +324,7 @@ describe('registerPtyHandlers', () => { } const store = { upsertSshRemotePtyLease: vi.fn(), + supersedeSshRemotePtyLeasesForBoundPane: vi.fn(), persistPtyBinding: vi.fn(), removeSshRemotePtyLease: vi.fn(), markSshRemotePtyLease: vi.fn(), diff --git a/src/main/ipc/pty-daemon-spawn-session-identity.test.ts b/src/main/ipc/pty-daemon-spawn-session-identity.test.ts index 90ea56049fa..b1ead181530 100644 --- a/src/main/ipc/pty-daemon-spawn-session-identity.test.ts +++ b/src/main/ipc/pty-daemon-spawn-session-identity.test.ts @@ -345,6 +345,7 @@ describe('registerPtyHandlers', () => { ) const store = { upsertSshRemotePtyLease: vi.fn(), + supersedeSshRemotePtyLeasesForBoundPane: vi.fn(), persistPtyBinding: vi.fn() } registerSshPtyProvider('ssh-1', { diff --git a/src/main/ipc/pty-dead-owner-respawn.test.ts b/src/main/ipc/pty-dead-owner-respawn.test.ts index 8f47fa6fcdf..4669d7e5528 100644 --- a/src/main/ipc/pty-dead-owner-respawn.test.ts +++ b/src/main/ipc/pty-dead-owner-respawn.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { SessionNotFoundError } from '../daemon/daemon-errors' import { makePaneKey } from '../../shared/stable-pane-id' import { registerPtyHandlers, setLocalPtyProvider } from './pty' @@ -59,7 +60,7 @@ describe('registerPtyHandlers', () => { const providerSpawn = vi.fn( async (options: { attachOnly?: boolean; command?: string; sessionId?: string }) => { if (options.attachOnly) { - throw new Error('Session not found: pty-proven-absent-owner') + throw new SessionNotFoundError('pty-proven-absent-owner') } return { id: 'pty-fresh-proven', incarnationId: 'inc-fresh-proven' } } @@ -161,10 +162,13 @@ describe('registerPtyHandlers', () => { expect(providerSpawn.mock.calls[1]?.[0]).toMatchObject({ command: 'codex resume proven-absent-session' }) + // The registry that owns the PTY answered, so this exit is confirmed — but the code stays the + // -1 sentinel; a synthesized zero would be indistinguishable from a clean shell exit. expect(runtime.onPtyExit).toHaveBeenCalledWith( 'pty-proven-absent-owner', - 0, - 'inc-proven-absent-owner' + -1, + 'inc-proven-absent-owner', + { hostExitConfirmed: true } ) expect(store.setWorkspaceSession).toHaveBeenCalledOnce() expect(store.flushOrThrow).toHaveBeenCalledOnce() @@ -178,7 +182,7 @@ describe('registerPtyHandlers', () => { const providerSpawn = vi.fn( async (options: { attachOnly?: boolean; command?: string; sessionId?: string }) => { if (options.attachOnly) { - throw new Error('Session not found: pty-probe-blip-owner') + throw new SessionNotFoundError('pty-probe-blip-owner') } return { id: 'pty-fresh-probe-blip', incarnationId: 'inc-fresh-probe-blip' } } @@ -282,8 +286,9 @@ describe('registerPtyHandlers', () => { expect(providerSpawn).toHaveBeenCalledTimes(2) expect(runtime.onPtyExit).toHaveBeenCalledWith( 'pty-probe-blip-owner', - 0, - 'inc-probe-blip-owner' + -1, + 'inc-probe-blip-owner', + { hostExitConfirmed: true } ) }) // Why: a parked pane (stopped with keepHistory) leaves the runtime holding the binding while @@ -299,7 +304,7 @@ describe('registerPtyHandlers', () => { const providerSpawn = vi.fn( async (options: { attachOnly?: boolean; command?: string; sessionId?: string }) => { if (options.attachOnly) { - throw new Error('Session not found: pty-already-retired-owner') + throw new SessionNotFoundError('pty-already-retired-owner') } return { id: 'pty-fresh-already-retired', incarnationId: 'inc-fresh-already-retired' } } @@ -420,6 +425,8 @@ describe('registerPtyHandlers', () => { expect(providerSpawn.mock.calls[1]?.[0]).toMatchObject({ command: 'codex resume already-retired-session' }) - expect(runtime.onPtyExit).toHaveBeenCalledWith('pty-already-retired-owner', 0, undefined) + expect(runtime.onPtyExit).toHaveBeenCalledWith('pty-already-retired-owner', -1, undefined, { + hostExitConfirmed: true + }) }) }) diff --git a/src/main/ipc/pty-pane-reservation-settlement.test.ts b/src/main/ipc/pty-pane-reservation-settlement.test.ts index c894eeaeddd..06e8679ace4 100644 --- a/src/main/ipc/pty-pane-reservation-settlement.test.ts +++ b/src/main/ipc/pty-pane-reservation-settlement.test.ts @@ -2,6 +2,10 @@ import { describe, expect, it, vi } from 'vitest' import { spawnMock, registerPtyMock } from './pty-ipc-mock-registry' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { makePaneKey } from '../../shared/stable-pane-id' +import { + SSH_SESSION_EXPIRED_ERROR, + SshPtyProvenExitedOnRelayError +} from '../providers/ssh-pty-errors' import { registerPtyHandlers, registerSshPtyProvider, @@ -67,7 +71,9 @@ describe('registerPtyHandlers', () => { const freshPtyId = `ssh:${connectionId}@@fresh-relay-pty` const remoteSpawn = vi.fn(async (options: { attachOnly?: boolean; command?: string }) => { if (options.attachOnly) { - throw new Error('PTY "dead-relay-pty" not found') + // The relay's raw wire text never reaches a pane untyped; the SSH reattach path mints the + // proven-exit class for the one refusal the relay backed with a pid probe. + throw new SshPtyProvenExitedOnRelayError(`${SSH_SESSION_EXPIRED_ERROR}: dead-relay-pty`) } return { id: freshPtyId, incarnationId: 'inc-fresh-ssh-owner' } }) @@ -118,6 +124,7 @@ describe('registerPtyHandlers', () => { flushOrThrow: vi.fn(), persistPtyBinding: vi.fn(), upsertSshRemotePtyLease: vi.fn(), + supersedeSshRemotePtyLeasesForBoundPane: vi.fn(), removeSshRemotePtyLease: vi.fn(), markSshRemotePtyLease: vi.fn(), clearSshRemotePtyKillIntent: vi.fn() @@ -476,6 +483,7 @@ describe('registerPtyHandlers', () => { } as never) const store = { upsertSshRemotePtyLease: vi.fn(), + supersedeSshRemotePtyLeasesForBoundPane: vi.fn(), persistPtyBinding: vi.fn(), removeSshRemotePtyLease: vi.fn(), markSshRemotePtyLease: vi.fn(), diff --git a/src/main/ipc/pty-persisted-incarnation-repair.test.ts b/src/main/ipc/pty-persisted-incarnation-repair.test.ts index 8f565f1c08e..743963d0189 100644 --- a/src/main/ipc/pty-persisted-incarnation-repair.test.ts +++ b/src/main/ipc/pty-persisted-incarnation-repair.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { statSyncMock } from './pty-ipc-mock-registry' import { setupPtyIpcSuite } from './pty-ipc-test-harness' -import { TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors' +import { SessionNotFoundError, TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors' import { makePaneKey } from '../../shared/stable-pane-id' import { registerPtyHandlers, clearProviderPtyState, setLocalPtyProvider } from './pty' @@ -230,7 +230,7 @@ describe('registerPtyHandlers', () => { const providerSpawn = vi.fn( async (options: { attachOnly?: boolean; command?: string; sessionId?: string }) => { if (options.attachOnly) { - throw new Error('Session not found: pty-dead-persisted-owner') + throw new SessionNotFoundError('pty-dead-persisted-owner') } return { id: 'pty-fresh-recovery', incarnationId: 'inc-fresh-recovery' } } @@ -352,8 +352,9 @@ describe('registerPtyHandlers', () => { expect(store.setWorkspaceSession).toHaveBeenCalledOnce() expect(runtime.onPtyExit).toHaveBeenCalledWith( 'pty-dead-persisted-owner', - 0, - 'inc-dead-persisted-owner' + -1, + 'inc-dead-persisted-owner', + { hostExitConfirmed: true } ) return } @@ -376,8 +377,9 @@ describe('registerPtyHandlers', () => { expect(store.flushOrThrow).toHaveBeenCalledOnce() expect(runtime.onPtyExit).toHaveBeenCalledWith( 'pty-dead-persisted-owner', - 0, - 'inc-dead-persisted-owner' + -1, + 'inc-dead-persisted-owner', + { hostExitConfirmed: true } ) } ) diff --git a/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts b/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts index 3fd1f89b11d..d1078477d12 100644 --- a/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts +++ b/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts @@ -154,6 +154,7 @@ describe('registerPtyHandlers', () => { } as never) const store = { upsertSshRemotePtyLease: vi.fn(), + supersedeSshRemotePtyLeasesForBoundPane: vi.fn(), persistPtyBinding: vi.fn(), removeSshRemotePtyLease: vi.fn(), markSshRemotePtyLease: vi.fn(), @@ -260,6 +261,7 @@ describe('registerPtyHandlers', () => { } as never) const store = { upsertSshRemotePtyLease: vi.fn(), + supersedeSshRemotePtyLeasesForBoundPane: vi.fn(), persistPtyBinding: vi.fn() } let controller: RuntimeSpawnController | null = null @@ -370,6 +372,7 @@ describe('registerPtyHandlers', () => { } as never) const store = { upsertSshRemotePtyLease: vi.fn(), + supersedeSshRemotePtyLeasesForBoundPane: vi.fn(), persistPtyBinding: vi.fn(() => { throw new Error('disk full') }), @@ -471,6 +474,7 @@ describe('registerPtyHandlers', () => { } as never) const store = { upsertSshRemotePtyLease: vi.fn(), + supersedeSshRemotePtyLeasesForBoundPane: vi.fn(), persistPtyBinding: vi.fn(), removeSshRemotePtyLease: vi.fn(), markSshRemotePtyLease: vi.fn(), @@ -577,6 +581,7 @@ describe('registerPtyHandlers', () => { } as never) const store = { upsertSshRemotePtyLease: vi.fn(), + supersedeSshRemotePtyLeasesForBoundPane: vi.fn(), persistPtyBinding: vi.fn(), removeSshRemotePtyLease: vi.fn(), markSshRemotePtyLease: vi.fn(), diff --git a/src/main/ipc/pty-serializer-settlement-mapping.test.ts b/src/main/ipc/pty-serializer-settlement-mapping.test.ts index 4af42f1dbd5..53755214238 100644 --- a/src/main/ipc/pty-serializer-settlement-mapping.test.ts +++ b/src/main/ipc/pty-serializer-settlement-mapping.test.ts @@ -108,6 +108,7 @@ describe('registerPtyHandlers', () => { } as never) const store = { upsertSshRemotePtyLease: vi.fn(), + supersedeSshRemotePtyLeasesForBoundPane: vi.fn(), persistPtyBinding: vi.fn(), removeSshRemotePtyLease: vi.fn(), markSshRemotePtyLease: vi.fn(), @@ -212,6 +213,7 @@ describe('registerPtyHandlers', () => { } as never) const store = { upsertSshRemotePtyLease: vi.fn(), + supersedeSshRemotePtyLeasesForBoundPane: vi.fn(), persistPtyBinding: vi.fn(() => { throw new Error('disk full') }), diff --git a/src/main/ipc/pty-session-liveness-and-ownership.test.ts b/src/main/ipc/pty-session-liveness-and-ownership.test.ts index fb88fc4b6cb..574e83c1220 100644 --- a/src/main/ipc/pty-session-liveness-and-ownership.test.ts +++ b/src/main/ipc/pty-session-liveness-and-ownership.test.ts @@ -386,6 +386,7 @@ describe('registerPtyHandlers', () => { it('ignores fire-and-forget IPC for detached SSH PTYs without a provider', async () => { const store = { upsertSshRemotePtyLease: vi.fn(), + supersedeSshRemotePtyLeasesForBoundPane: vi.fn(), persistPtyBinding: vi.fn(), markSshRemotePtyLease: vi.fn(), clearSshRemotePtyKillIntent: vi.fn() diff --git a/src/main/ipc/pty/ipc/spawn-commit-persist.ts b/src/main/ipc/pty/ipc/spawn-commit-persist.ts index be9cb31394a..540ada9397d 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-persist.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-persist.ts @@ -134,6 +134,13 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{ }) } } + // Why here and not at the upsert: this path leases before it binds, so supersession fenced on the + // pane's binding still named the predecessor and bailed on every reconnect — one more reattachable + // lease, and one more `pty.attach`, per reconnect forever. Runs after whichever binding write this + // commit made, so the lease/binding order no longer decides. + if (ctx.deps.store && args.connectionId && ctx.validatedLeafId !== null) { + ctx.deps.store.supersedeSshRemotePtyLeasesForBoundPane(args.connectionId, ctx.validatedLeafId) + } // Why: when the renderer has declared it will own the serializer for this paneKey, suppress the daemon-snapshot seed so its hydration path is sole authority (keyed on paneKey since the ptyId isn't known yet). See docs/mobile-prefer-renderer-scrollback.md. const rendererPreSignaled = ctx.validatedPaneKey ? pendingByPaneKey.has(ctx.validatedPaneKey) diff --git a/src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts b/src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts new file mode 100644 index 00000000000..6266faf0c02 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts @@ -0,0 +1,289 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { rmSync, mkdtempSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { testState, createStore } from '../../../persistence-test-harness' +import { TEST_LEAF_1, TEST_LEAF_2 } from '../../../persistence-session-fixtures' +import { sshRemotePtyLeaseAllowsReattach } from '../../../../shared/ssh-types' +import { toAppSshPtyId } from '../../../providers/ssh-pty-id' +import { toSshExecutionHostId } from '../../../../shared/execution-host' +import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' +import { createPtyIpcSpawnState } from './spawn-state' +import { persistPtyIpcSpawnCommit } from './spawn-commit-persist' + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) + +const TARGET = 'ssh-1' +const WORKTREE = 'repo1::/worktree' +const TAB = 'tab-1' + +/** + * Drives the shipped IPC spawn commit rather than the store primitives it calls. + * + * The store-level suite could not catch this: it exercised bind-then-upsert, and this path does the + * opposite — it writes the lease row first so a force-quit in the renderer's debounce window cannot + * strand a running remote shell without one, then binds the pane. Supersession is fenced on the + * pane's binding, so under this real order it bailed on the predecessor every time and never re-ran, + * and each reconnect left one more reattachable lease for `reattachKnownPtys` to `pty.attach`. + */ +async function commitSshSpawn( + store: ReturnType, + args: { relayPtyId: string; leafId: string } +): Promise { + const deps = { store } as unknown as PtySpawnIpcDeps + const spawnArgs = { + cols: 80, + rows: 24, + worktreeId: WORKTREE, + tabId: TAB, + leafId: args.leafId, + connectionId: TARGET + } as unknown as PtySpawnIpcArgs + const ctx = createPtyIpcSpawnState(deps, spawnArgs) + ctx.result = { id: toAppSshPtyId(TARGET, args.relayPtyId) } + ctx.validatedLeafId = args.leafId + await persistPtyIpcSpawnCommit(ctx) +} + +/** One pane's layout, so the two host partitions can be given different bindings for one leaf. */ +function sessionBinding(ptyId: string) { + return { + activeRepoId: 'repo1', + activeWorktreeId: WORKTREE, + activeTabId: TAB, + tabsByWorktree: {}, + terminalLayoutsByTabId: { + [TAB]: { + root: { type: 'leaf' as const, leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: ptyId } + } + } + } +} + +function bulkReattachPtyIds(store: ReturnType): string[] { + return store + .getSshRemotePtyLeases(TARGET) + .filter(sshRemotePtyLeaseAllowsReattach) + .map((lease) => lease.ptyId) + .sort() +} + +describe('the IPC spawn commit keeps one reattachable lease per SSH pane', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) + }) + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + // QA's measurement, driven through the real path: five relay restarts, one pane, N+1 leases. + it('holds the reattach set flat across five reconnects of one pane', async () => { + const store = await createStore() + + for (let reconnect = 0; reconnect < 5; reconnect++) { + // A relay renumbers from `pty-1` on every start; a reconnect therefore re-leases the same + // pane under an id it has never used before. + await commitSshSpawn(store, { relayPtyId: `pty-${reconnect}`, leafId: TEST_LEAF_1 }) + } + + expect(bulkReattachPtyIds(store)).toEqual(['pty-4']) + }) + + it('retires each predecessor as `expired` with the winner recorded, never `terminated`', async () => { + const store = await createStore() + + await commitSshSpawn(store, { relayPtyId: 'pty-0', leafId: TEST_LEAF_1 }) + await commitSshSpawn(store, { relayPtyId: 'pty-1', leafId: TEST_LEAF_1 }) + + const predecessor = store.getSshRemotePtyLeases(TARGET).find((entry) => entry.ptyId === 'pty-0') + // `expired`, not `terminated`: losing the lease is not evidence the remote shell died, and the + // process is deliberately left running (docs/reference/ssh-execution-boundary.md). + expect(predecessor).toMatchObject({ state: 'expired', supersededBy: 'pty-1' }) + }) + + // The failure that would be worse than the fan-out: over-superseding strands a live remote + // process behind a pane that can no longer find it. + it('leaves a genuine orphan reattachable while superseding the pane that re-leased', async () => { + const store = await createStore() + + await commitSshSpawn(store, { relayPtyId: 'orphan-pty', leafId: TEST_LEAF_2 }) + // The orphan's client lost its route; nothing observed the shell, so it stays askable. + store.markSshRemotePtyLease(TARGET, 'orphan-pty', 'expired') + + await commitSshSpawn(store, { relayPtyId: 'pty-0', leafId: TEST_LEAF_1 }) + await commitSshSpawn(store, { relayPtyId: 'pty-1', leafId: TEST_LEAF_1 }) + + const orphan = store.getSshRemotePtyLeases(TARGET).find((entry) => entry.ptyId === 'orphan-pty') + expect(orphan?.supersededBy).toBeUndefined() + expect(bulkReattachPtyIds(store)).toEqual(['orphan-pty', 'pty-1']) + }) + + /** + * The shape the Docker lane exposed, and the reason a spawn-time trigger is not enough on its + * own. When the spawn commit writes no binding, the renderer's debounced layout publish does it + * later — so at commit time the pane still names the predecessor and supersession correctly + * declines. Nothing revisited it afterwards, and the predecessor stayed reattachable forever. + * + * Measured rows agreed on target, worktree, tab and leaf and still carried no `supersededBy`. + */ + it('retires a predecessor whose successor bound the pane after the spawn commit', async () => { + const store = await createStore() + + await commitSshSpawn(store, { relayPtyId: 'pty2:aaa:1', leafId: TEST_LEAF_1 }) + // What `handlePtyReattachFailure` writes when a restarted relay disowns the id. + store.markSshRemotePtyLease(TARGET, 'pty2:aaa:1', 'expired') + + // The successor leases without binding the pane; the binding catches up afterwards, exactly as + // the renderer's debounced publish does. + store.upsertSshRemotePtyLease({ + targetId: TARGET, + ptyId: 'pty2:bbb:1', + worktreeId: WORKTREE, + tabId: TAB, + leafId: TEST_LEAF_1, + state: 'attached' + }) + expect(bulkReattachPtyIds(store)).toEqual(['pty2:aaa:1', 'pty2:bbb:1']) + store.persistPtyBinding({ + worktreeId: WORKTREE, + tabId: TAB, + leafId: TEST_LEAF_1, + ptyId: toAppSshPtyId(TARGET, 'pty2:bbb:1') + }) + + // What the connect path does before reading the set it feeds to `pty.attach`. + store.reconcileSshRemotePtyLeasesForTarget(TARGET) + + expect(bulkReattachPtyIds(store)).toEqual(['pty2:bbb:1']) + }) + + // Reconciliation must not invent evidence: with no binding naming the pane, nothing says which + // shell owns it, so every lease stays askable. + it('leaves leases reattachable when no binding names the pane', async () => { + const store = await createStore() + + store.upsertSshRemotePtyLease({ + targetId: TARGET, + ptyId: 'unbound-a', + worktreeId: WORKTREE, + tabId: TAB, + leafId: TEST_LEAF_1, + state: 'expired' + }) + store.upsertSshRemotePtyLease({ + targetId: TARGET, + ptyId: 'unbound-b', + worktreeId: WORKTREE, + tabId: TAB, + leafId: TEST_LEAF_1, + state: 'expired' + }) + + store.reconcileSshRemotePtyLeasesForTarget(TARGET) + + expect(bulkReattachPtyIds(store)).toEqual(['unbound-a', 'unbound-b']) + }) + + /** + * The measured defect, reduced to its cause. + * + * Main writes an SSH pane's binding to the `ssh:` partition, but a stale copy of the same + * leaf survives in `local`. Reading `local` first named the PREDECESSOR as the pane's current + * PTY, so supersession took an already-expired lease as its winner and returned having marked + * nothing — once per relay restart, forever. Both partitions name the same PTY again once the + * renderer republishes, which is why the finished store looks consistent and hides this. + */ + it('supersedes when the local partition still names the predecessor', async () => { + const store = await createStore() + const hostId = toSshExecutionHostId(TARGET) + const predecessor = toAppSshPtyId(TARGET, 'pty2:old:1') + const successor = toAppSshPtyId(TARGET, 'pty2:new:1') + + store.upsertSshRemotePtyLease({ + targetId: TARGET, + ptyId: 'pty2:old:1', + worktreeId: WORKTREE, + tabId: TAB, + leafId: TEST_LEAF_1, + state: 'expired' + }) + // Both partitions start on the predecessor, as they do before a relay restart. + store.setWorkspaceSession(sessionBinding(predecessor)) + store.setWorkspaceSession(sessionBinding(predecessor), hostId) + store.upsertSshRemotePtyLease({ + targetId: TARGET, + ptyId: 'pty2:new:1', + worktreeId: WORKTREE, + tabId: TAB, + leafId: TEST_LEAF_1, + state: 'attached' + }) + // Production's writer for an SSH pane binding, and the whole point: it updates ONLY the host + // partition, so `local` is left naming the predecessor until the renderer republishes. + store.persistPtyBinding( + { worktreeId: WORKTREE, tabId: TAB, leafId: TEST_LEAF_1, ptyId: successor }, + hostId + ) + expect( + store.getWorkspaceSession().terminalLayoutsByTabId?.[TAB]?.ptyIdsByLeafId?.[TEST_LEAF_1] + ).toBe(predecessor) + + store.supersedeSshRemotePtyLeasesForBoundPane(TARGET, TEST_LEAF_1) + + const retired = store.getSshRemotePtyLeases(TARGET).find((l) => l.ptyId === 'pty2:old:1') + expect(retired).toMatchObject({ state: 'expired', supersededBy: 'pty2:new:1' }) + expect(bulkReattachPtyIds(store)).toEqual(['pty2:new:1']) + }) + + // The mirror: a live shell the pane is still bound to must never be retired, whichever partition + // names it. Over-superseding strands a running remote process. + it('never retires a live lease the pane is still bound to', async () => { + const store = await createStore() + const live = toAppSshPtyId(TARGET, 'pty2:live:1') + + store.upsertSshRemotePtyLease({ + targetId: TARGET, + ptyId: 'pty2:live:1', + worktreeId: WORKTREE, + tabId: TAB, + leafId: TEST_LEAF_1, + state: 'attached' + }) + // Bound BEFORE the stray lease arrives, which is the order that makes the binding meaningful: + // with no binding at all, an arriving lease is the only evidence there is and does win. + store.setWorkspaceSession(sessionBinding(live)) + store.setWorkspaceSession(sessionBinding(live), toSshExecutionHostId(TARGET)) + store.upsertSshRemotePtyLease({ + targetId: TARGET, + ptyId: 'pty2:other:1', + worktreeId: WORKTREE, + tabId: TAB, + leafId: TEST_LEAF_1, + state: 'attached' + }) + + store.supersedeSshRemotePtyLeasesForBoundPane(TARGET, TEST_LEAF_1) + + const stillLive = store.getSshRemotePtyLeases(TARGET).find((l) => l.ptyId === 'pty2:live:1') + expect(stillLive).toMatchObject({ state: 'attached' }) + expect(stillLive?.supersededBy).toBeUndefined() + }) + + // Panes are independent, and supersession keys on the leaf: a second live pane on the same + // target must survive its neighbour reconnecting. + it('does not touch a sibling pane on the same target', async () => { + const store = await createStore() + + await commitSshSpawn(store, { relayPtyId: 'sibling-pty', leafId: TEST_LEAF_2 }) + await commitSshSpawn(store, { relayPtyId: 'pty-0', leafId: TEST_LEAF_1 }) + await commitSshSpawn(store, { relayPtyId: 'pty-1', leafId: TEST_LEAF_1 }) + + expect(bulkReattachPtyIds(store)).toEqual(['pty-1', 'sibling-pty']) + }) +}) diff --git a/src/main/ipc/pty/pane/ssh-pane-lease-claim.ts b/src/main/ipc/pty/pane/ssh-pane-lease-claim.ts new file mode 100644 index 00000000000..5746814a5f7 --- /dev/null +++ b/src/main/ipc/pty/pane/ssh-pane-lease-claim.ts @@ -0,0 +1,44 @@ +import { isTerminalLeafId } from '../../../../shared/stable-pane-id' +import { getRelayPtyId } from '../provider/registry' +import type { Store } from '../../../persistence' + +/** + * Claim a remote PTY for a pane: record the lease, then retire the pane's predecessors. + * + * The lease keeps the RELAY id, because reconnect calls `pty.attach` with target-local ids, while + * the pane binding keeps the app-facing id used for hydration. + * + * Supersession is a second step rather than something `upsertSshRemotePtyLease` finishes on its own + * because it is fenced on the pane's durable binding — it refuses to retire a predecessor the pane + * is still bound to, which would detach a live pane. A caller that leases BEFORE it binds therefore + * trips that fence on every reconnect and, with the upsert as the only trigger, never re-runs: one + * more reattachable lease, and one more `pty.attach` round trip on every later connect, forever. + * Re-running it here from the binding side is what makes the two writes commute. + */ +export function claimSshPaneLease(args: { + store: Store | undefined + connectionId: string | null | undefined + ptyId: string + worktreeId: string | undefined + tabId: string | undefined + leafId: string | undefined +}): void { + const { store, connectionId } = args + if (!store || !connectionId) { + return + } + const leafId = + typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null + store.upsertSshRemotePtyLease({ + targetId: connectionId, + ptyId: getRelayPtyId(connectionId, args.ptyId), + ...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}), + ...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}), + ...(leafId ? { leafId } : {}), + state: 'attached', + lastAttachedAt: Date.now() + }) + if (leafId) { + store.supersedeSshRemotePtyLeasesForBoundPane(connectionId, leafId) + } +} diff --git a/src/main/ipc/pty/pane/stable-owner.ts b/src/main/ipc/pty/pane/stable-owner.ts index 9442e914e1f..731065e59ab 100644 --- a/src/main/ipc/pty/pane/stable-owner.ts +++ b/src/main/ipc/pty/pane/stable-owner.ts @@ -1,5 +1,6 @@ import { toSshExecutionHostId } from '../../../../shared/execution-host' import { makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' +import { UNVERIFIED_PROCESS_EXIT_CODE } from '../../../../shared/terminal-exit-cause' import type { Store } from '../../../persistence' import { retireTerminalSurfaceFromPersistence } from '../../../runtime/mobile-session-terminal-persistence-retirement' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' @@ -11,7 +12,7 @@ import { TerminalSessionOwnerUnverifiedError } from '../../../daemon/daemon-errors' import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' -import { isPtyAlreadyGoneError } from '../provider/liveness' +import { isHostReportedPtyAbsenceError, isObservedPtyExitEvidence } from '../provider/liveness' import { clearProviderPtyState } from '../provider/state-cleanup' export type StablePaneOwner = { @@ -239,7 +240,7 @@ export async function attachStablePaneOwner( if (isDaemonEndpointGoneError(error)) { throw new TerminalHostGoneError() } - if (!isPtyAlreadyGoneError(error)) { + if (!isHostReportedPtyAbsenceError(error)) { throw error } const ownerBeforeRetire = args.resolveOwner?.() @@ -252,7 +253,17 @@ export async function attachStablePaneOwner( ) { throw new Error('terminal_pane_owner_changed') } - runtime?.onPtyExit(owner.ptyId, 0, owner.incarnationId) + // `pty.attach` answers absent both for a pid the relay probed and found gone and for an id its + // session map never had — every id minted before a relay restart, checked against nothing. Only + // the marked half observed the process, so only it may certify a death; the rest publishes the + // stop sentinel its sibling handlePtyReattachFailure publishes, which every reader resolves to + // `stop_unverified` (docs/reference/ssh-execution-boundary.md). + runtime?.onPtyExit( + owner.ptyId, + UNVERIFIED_PROCESS_EXIT_CODE, + owner.incarnationId, + isObservedPtyExitEvidence(error) ? { hostExitConfirmed: true } : {} + ) clearProviderPtyState(owner.ptyId) ptyOwnership.delete(owner.ptyId) if ( diff --git a/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts b/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts new file mode 100644 index 00000000000..972f479b492 --- /dev/null +++ b/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts @@ -0,0 +1,185 @@ +// `attachStablePaneOwner` is the last reader that synthesised a runtime exit from a reattach +// refusal, and it published code 0 — which `orca-runtime-on-pty-exit` records as a death +// certificate. The refusal it acts on is a union: `pty.attach` answers absent both for a pid the +// relay probed and found gone, and for an id its session map never had, which is every id minted +// before a relay restart. Certifying the union orphans a live remote shell and cold-starts a second +// agent onto its transcript (docs/reference/ssh-execution-boundary.md). +// +// The sibling handlePtyReattachFailure has always refused to certify from that union. These pin the +// same rule here, and pin that the marked half — the one refusal the relay backed with a pid probe +// — still earns the certificate, so a genuinely dead PTY is not left `unverifiable` forever. +import { describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../../shared/constants' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { SSH_EXIT_UNCONFIRMED_REASON } from '../../../../shared/pty-liveness-verdict' +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import { SessionNotFoundError } from '../../../daemon/daemon-errors' +import type { Store } from '../../../persistence' +import { + SSH_SESSION_EXPIRED_ERROR, + SshPtyAbsentFromRelayError, + SshPtyProvenExitedOnRelayError +} from '../../../providers/ssh-pty-errors' +import type { IPtyProvider } from '../../../providers/types' +import { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import { resolvePersistedStablePaneOwner, spawnForStablePane } from './stable-owner' + +const CONNECTION = 'conn-1' +const WORKTREE = 'repo-1::/tmp/pane-absence' +const TAB = 'tab-1' +const LEAF = '1b3f2c4d-5e6a-4b7c-8d9e-0f1a2b3c4d5e' +const SIBLING_LEAF = '2c4d3e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f' +// Ids carry the relay's per-start mint epoch, so this one names a PTY the CURRENT relay never minted. +const PTY_ID = 'ssh:conn-1@@pty2:epoch-a:1' +const OWNER = { tabId: TAB, leafId: LEAF, ptyId: PTY_ID, hasPersistedBinding: true as const } + +function paneStore(): { store: Store; read: () => WorkspaceSessionState } { + let session = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WORKTREE]: [{ id: TAB, type: 'terminal', worktreeId: WORKTREE, ptyId: PTY_ID }] + }, + terminalLayoutsByTabId: { + [TAB]: { + root: { + type: 'split', + direction: 'row', + first: { type: 'leaf', leafId: LEAF }, + second: { type: 'leaf', leafId: SIBLING_LEAF } + }, + activeLeafId: LEAF, + ptyIdsByLeafId: { [LEAF]: PTY_ID, [SIBLING_LEAF]: 'ssh:conn-1@@pty2:epoch-a:2' } + } + } + } as unknown as WorkspaceSessionState + return { + read: () => session, + store: { + getWorkspaceSession: () => session, + setWorkspaceSession: (next: WorkspaceSessionState) => { + session = next + }, + flushOrThrow: () => {}, + getRepos: () => [ + { + id: 'repo-1', + path: '/tmp/pane-absence', + displayName: 'pane-absence', + badgeColor: '#000000', + addedAt: 0 + } + ], + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + setWorktreeMeta: () => {}, + removeWorktreeMeta: () => {}, + getSettings: () => ({ workspaceDir: '/tmp/workspaces' }), + getProjects: () => [] + } as unknown as Store + } +} + +function runtimeOwning(store: Store): OrcaRuntimeService { + const runtime = new OrcaRuntimeService(store as never) + runtime.setPtyController({ + write: () => true, + kill: () => true, + hasPty: () => null, + listProcesses: async () => [], + getForegroundProcess: async () => null + } as never) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + runtime.registerPty(PTY_ID, WORKTREE, CONNECTION) + return runtime +} + +async function adoptAfterAttachRefusal(error: unknown): Promise<{ + runtime: OrcaRuntimeService + store: Store + read: () => WorkspaceSessionState + spawn: ReturnType +}> { + const { store, read } = paneStore() + const runtime = runtimeOwning(store) + const spawn = vi + .fn() + .mockRejectedValueOnce(error) + .mockResolvedValueOnce({ id: 'ssh:conn-1@@pty2:epoch-b:1', isReattach: false }) + await spawnForStablePane({ + runtime, + store, + provider: { spawn } as unknown as IPtyProvider, + spawnOptions: { cols: 80, rows: 24 }, + owner: OWNER, + worktreeId: WORKTREE, + connectionId: CONNECTION, + resolveOwner: () => null + }) + return { runtime, store, read, spawn } +} + +describe('a stable pane whose reattach was refused', () => { + it('records no death certificate when the relay merely does not know the id', async () => { + const { runtime, spawn } = await adoptAfterAttachRefusal( + new SshPtyAbsentFromRelayError(`${SSH_SESSION_EXPIRED_ERROR}: pty2:epoch-a:1`) + ) + + expect(spawn).toHaveBeenCalledTimes(2) + // The shell may well still be running under the previous daemon's orphaned process tree, so the + // register must keep saying "we could not observe it" — not "it ended". + expect(runtime.getPtyLivenessVerdict(PTY_ID)).toEqual({ + status: 'unverifiable', + reason: SSH_EXIT_UNCONFIRMED_REASON + }) + }) + + it('still certifies the death the relay proved with a pid probe', async () => { + const { runtime, store, spawn } = await adoptAfterAttachRefusal( + new SshPtyProvenExitedOnRelayError(`${SSH_SESSION_EXPIRED_ERROR}: pty2:epoch-a:1`) + ) + + expect(spawn).toHaveBeenCalledTimes(2) + expect(runtime.getPtyLivenessVerdict(PTY_ID)).toEqual({ status: 'exited' }) + // If nothing ever retired, a proven-dead pane would reattach to a corpse on every adoption. + expect( + resolvePersistedStablePaneOwner(store, makePaneKey(TAB, LEAF), WORKTREE, CONNECTION) + ).toBeNull() + }) + + it('certifies an absence reported by the process registry that owns the PTY', async () => { + // The daemon (or the in-process map) answering here is the owner of the process, and an + // endpoint that had gone raises TerminalHostGoneError above, so this absence is an observation + // rather than a lost route. + const { runtime } = await adoptAfterAttachRefusal(new SessionNotFoundError(PTY_ID)) + + expect(runtime.getPtyLivenessVerdict(PTY_ID)).toEqual({ status: 'exited' }) + }) + + it('refuses to abandon the binding on an untyped "not found" string', async () => { + // The relay's raw wire wording. The SSH reattach path types it before any pane sees it, so an + // untyped one reached this gate having lost every distinction the type carries — including + // whether the answer came from the host that owns the process at all. + const { store, read } = paneStore() + const before = JSON.stringify(read()) + const runtime = runtimeOwning(store) + const spawn = vi.fn().mockRejectedValue(new Error(`PTY "pty2:epoch-a:1" not found`)) + + await expect( + spawnForStablePane({ + runtime, + store, + provider: { spawn } as unknown as IPtyProvider, + spawnOptions: { cols: 80, rows: 24 }, + owner: OWNER, + worktreeId: WORKTREE, + connectionId: CONNECTION, + resolveOwner: () => null + }) + ).rejects.toThrow('not found') + + expect(spawn).toHaveBeenCalledTimes(1) + expect(runtime.getPtyLivenessVerdict(PTY_ID)).toBeNull() + expect(JSON.stringify(read())).toBe(before) + }) +}) diff --git a/src/main/ipc/pty/provider/liveness.ts b/src/main/ipc/pty/provider/liveness.ts index cc764143384..8a362b74aaf 100644 --- a/src/main/ipc/pty/provider/liveness.ts +++ b/src/main/ipc/pty/provider/liveness.ts @@ -2,10 +2,12 @@ import { isRemoteAgentHooksEnabled } from '../../../../shared/agent-hook-relay' import type { AgentSessionOwnerBinding } from '../../../../shared/agent-session-host-authority' import { agentSessionOwnerBindingsEqual } from '../../../../shared/claimed-agent-pty-owner' import { addNodePtyRecoveryHint } from '../../../daemon/node-pty-error-hints' +import { SessionNotFoundError } from '../../../daemon/daemon-errors' import type { Store } from '../../../persistence' import { isSshPtyAbsentFromRelayError, - isSshPtyNotFoundError + isSshPtyNotFoundError, + isSshPtyProvenExitedOnRelayError } from '../../../providers/ssh-pty-errors' import type { IPtyProvider } from '../../../providers/types' import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' @@ -66,6 +68,33 @@ export function isPtyAlreadyGoneError(err: unknown): boolean { ) } +/** + * Narrower than {@link isPtyAlreadyGoneError}, for the one caller that retires a durable pane + * binding rather than just releasing in-memory state: only a typed answer from the host that owns + * the process may authorise that. The bare `PTY ".+" not found` text is the relay's raw wire + * wording, which the SSH reattach path always types before it reaches a pane; matching the text + * instead would let any untyped string carrying that phrase unbind a live pane + * (docs/reference/ssh-execution-boundary.md). + */ +export function isHostReportedPtyAbsenceError(err: unknown): boolean { + return isSshPtyAbsentFromRelayError(err) || err instanceof SessionNotFoundError +} + +/** + * The half of {@link isHostReportedPtyAbsenceError} that actually observed the process, and so the + * only half that may certify an exit. + * + * The relay's plain absence answer is excluded because `pty.attach` gives it for an id its session + * map never had as readily as for a pid it probed — after a relay restart, every id the previous + * one minted. `SessionNotFoundError` is included because the process answering is the one that owns + * the PTY: the in-process registry itself, or a daemon whose endpoint is live (a gone endpoint + * raises `isDaemonEndpointGoneError` instead), so its absence is an observation rather than a lost + * route (docs/reference/ssh-execution-boundary.md). + */ +export function isObservedPtyExitEvidence(err: unknown): boolean { + return isSshPtyProvenExitedOnRelayError(err) || err instanceof SessionNotFoundError +} + export function delay(ms: number): Promise { return new Promise((resolve) => { const timer = setTimeout(resolve, ms) diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index 0b4e8804ac0..23592604bea 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -1,8 +1,6 @@ import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' -import { isTerminalLeafId } from '../../../../shared/stable-pane-id' import { ptyOwnership, ptyIncarnationById, deletePtyOwnership } from '../provider/ownership-state' import { ptySizes } from '../delivery/visibility-state' -import { getRelayPtyId } from '../provider/registry' import { shouldSkipCodexHomeEnvForWindowsShell, recordCodexPaneAccountForSpawn, @@ -25,6 +23,7 @@ import { requestKindSchema } from '../../../../shared/telemetry-events' import { persistAdmittedStablePaneBinding } from '../pane/stable-owner' +import { claimSshPaneLease } from '../pane/ssh-pane-lease-claim' import { isNativeWindowsLocalPtySpawn, markNativeWindowsConptyPty @@ -114,23 +113,15 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { ) { markNativeWindowsConptyPty(ctx.result.id) } - const persistSshLease = (): void => { - if (!ctx.deps.store || !args.connectionId) { - return - } - // Why: SSH leases keep relay ids for remote reconciliation, while session bindings keep app-facing ids for hydration. - ctx.deps.store.upsertSshRemotePtyLease({ - targetId: args.connectionId, - ptyId: getRelayPtyId(args.connectionId, ctx.result.id), - ...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}), - ...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}), - ...(typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) - ? { leafId: args.leafId } - : {}), - state: 'attached', - lastAttachedAt: Date.now() + const persistSshLease = (): void => + claimSshPaneLease({ + store: ctx.deps.store, + connectionId: args.connectionId, + ptyId: ctx.result.id, + worktreeId: args.worktreeId, + tabId: args.tabId, + leafId: args.leafId }) - } if (!ctx.hostSessionBinding) { persistSshLease() } diff --git a/src/main/ipc/ssh-ipc-test-harness.ts b/src/main/ipc/ssh-ipc-test-harness.ts index 84802fe83a0..581fcc916ca 100644 --- a/src/main/ipc/ssh-ipc-test-harness.ts +++ b/src/main/ipc/ssh-ipc-test-harness.ts @@ -25,6 +25,7 @@ export type SshLeaseStoreMock = { upsertSshPtyConsumerRecovery: Mock removeSshPtyConsumerRecovery: Mock getSshRemotePtyLeases: Mock + reconcileSshRemotePtyLeasesForTarget: Mock markSshRemotePtyLease: Mock markSshRemotePtyLeases: Mock markSshRemotePtyLeasesAsync: Mock @@ -102,6 +103,7 @@ export function createSshIpcHarness(mocks: SshIpcMocks): SshIpcHarness { upsertSshPtyConsumerRecovery: vi.fn(), removeSshPtyConsumerRecovery: vi.fn(), getSshRemotePtyLeases: vi.fn().mockReturnValue([]), + reconcileSshRemotePtyLeasesForTarget: vi.fn(), markSshRemotePtyLease: vi.fn(), markSshRemotePtyLeases: vi.fn(), markSshRemotePtyLeasesAsync: vi.fn(), diff --git a/src/main/ipc/worktrees-create-execution-host-routing.test.ts b/src/main/ipc/worktrees-create-execution-host-routing.test.ts new file mode 100644 index 00000000000..36f1e816ea6 --- /dev/null +++ b/src/main/ipc/worktrees-create-execution-host-routing.test.ts @@ -0,0 +1,229 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + addWorktreeMock, + getActiveMultiplexerMock, + getSshGitProviderMock, + listWorktreesMock +} from './worktrees-test-module-mocks' +import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' + +vi.mock('electron', async () => + (await import('./worktrees-test-module-mocks')).electronModuleMock() +) +vi.mock('../git/worktree', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() +) +vi.mock('../git/runner', async () => + (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() +) +vi.mock('../git/repo', async () => + (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +) +vi.mock('../git/git-username', async (importOriginal) => ({ + ...(await importOriginal>()), + resolveLocalGitUsername: (await import('./worktrees-test-module-mocks')) + .resolveLocalGitUsernameMock +})) +vi.mock('../github/client', async () => + (await import('./worktrees-test-module-mocks')).githubClientModuleMock() +) +vi.mock('../source-control/hosted-review', async () => + (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() +) +vi.mock('../providers/ssh-git-dispatch', async () => + (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() +) +vi.mock('../providers/ssh-filesystem-dispatch', async () => + (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() +) +vi.mock('./worktree-symlinks', async () => + (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() +) +vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) +vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) +vi.mock('../setup-runner-script-text', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('../worktree-runner-script', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('../effective-hook-config', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('../setup-hook-env-vars', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('./worktree-logic', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('../terminal-history-deletion', async () => + (await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock() +) +vi.mock('../ports/advertised-url-watcher', async () => + (await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock() +) +vi.mock('../workspace-cleanup-scan-snapshot', async () => + (await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock() +) +vi.mock('../workspace-space-analysis-snapshot', async () => + (await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock() +) +vi.mock('../workspace-cleanup-removal-snapshot-prune', async () => + (await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock() +) +vi.mock('../runtime/worktree-teardown', async () => + (await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock() +) +vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) + +const REMOTE_REPO_PATH = '/remote/repo' + +function makeRepo(fields: Record) { + return { + id: 'repo-1', + path: REMOTE_REPO_PATH, + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: 'origin/main', + ...fields + } +} + +function makeProvider(worktreePath: string) { + return { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'show-ref') { + // A hit here reads as "branch already exists"; the create loop would then rename. + throw Object.assign(new Error('missing exact ref'), { code: 1 }) + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: worktreePath, + head: 'abc123', + branch: 'refs/heads/wt', + isBare: false, + isMainWorktree: false + } + ]) + } +} + +function useRepo(repo: ReturnType): void { + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + store.setWorktreeMeta.mockImplementation((_worktreeId: string, meta: unknown) => meta) + getActiveMultiplexerMock.mockReturnValue({ + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + }) +} + +describe('worktrees:create execution host routing', () => { + beforeEach(() => { + setupWorktreeHandlers() + }) + + it('creates on the SSH host for a row that names it only as executionHostId', async () => { + // No `connectionId`: the raw read answered "local" and ran `git worktree add` on the client + // against `/remote/repo`. The runtime sibling already resolved this row remotely. + useRepo(makeRepo({ executionHostId: 'ssh:target-a' })) + const provider = makeProvider('/remote/repo-wt') + getSshGitProviderMock.mockImplementation((connectionId: string) => + connectionId === 'target-a' ? provider : undefined + ) + + await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + + expect(provider.addWorktree).toHaveBeenCalledTimes(1) + expect(addWorktreeMock).not.toHaveBeenCalled() + }) + + it('keeps two simultaneously registered SSH hosts apart', async () => { + useRepo(makeRepo({ executionHostId: 'ssh:target-b' })) + const providerA = makeProvider('/remote/repo-wt-a') + const providerB = makeProvider('/remote/repo-wt-b') + getSshGitProviderMock.mockImplementation((connectionId: string) => + connectionId === 'target-a' ? providerA : connectionId === 'target-b' ? providerB : undefined + ) + + await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + + expect(providerB.addWorktree).toHaveBeenCalledTimes(1) + expect(providerA.addWorktree).not.toHaveBeenCalled() + expect(addWorktreeMock).not.toHaveBeenCalled() + }) + + it('refuses a runtime row with no nested SSH target instead of creating locally', async () => { + useRepo(makeRepo({ executionHostId: 'runtime:env-1' })) + + await expect( + handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + ).rejects.toThrow('not dispatched by this process') + + expect(addWorktreeMock).not.toHaveBeenCalled() + }) + + it('refuses a runtime row whose nested SSH target is dialable in this namespace', async () => { + // `target-a` names a target inside env-1. A same-named one registered here is another machine, + // so creating through it lands the checkout on the wrong host. + useRepo(makeRepo({ executionHostId: 'runtime:env-1', connectionId: 'target-a' })) + const provider = makeProvider('/remote/repo-wt') + getSshGitProviderMock.mockImplementation((connectionId: string) => + connectionId === 'target-a' ? provider : undefined + ) + + await expect( + handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + ).rejects.toThrow('not dispatched by this process') + + expect(provider.addWorktree).not.toHaveBeenCalled() + expect(addWorktreeMock).not.toHaveBeenCalled() + }) + + it('answers local for a row that declares itself local while carrying a connection', async () => { + // A contradictory row: `getRepoSshConnectionId` lets `local` win, and the runtime sibling has + // always read it that way. The raw field sent it remote, so the two entry points disagreed. + useRepo( + makeRepo({ path: '/workspace/repo', executionHostId: 'local', connectionId: 'target-a' }) + ) + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/wt', + head: 'abc123', + branch: 'wt', + isBare: false, + isMainWorktree: false + } + ]) + const provider = makeProvider('/remote/repo-wt') + getSshGitProviderMock.mockImplementation((connectionId: string) => + connectionId === 'target-a' ? provider : undefined + ) + + await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + + expect(addWorktreeMock).toHaveBeenCalledTimes(1) + expect(provider.addWorktree).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts b/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts index f371529963c..1f94598208b 100644 --- a/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts +++ b/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts @@ -29,6 +29,7 @@ import { normalizeLinkedWorkItemFields } from '../ipc-context-schemas' import type { CreateWorktreeArgsWithSystemProvenance } from '../ipc-context-schemas' import { createFolderWorkspace } from './folder-workspace-creation' import { findExactRepoOwner, isCapturedRepoCurrent } from '../listing/worktree-host-ownership' +import { requireWorktreeCreateRoute } from '../../../worktree-create-execution-host-route' import type { WorktreeIpcContext } from '../worktree-ipc-context' export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): void { @@ -62,11 +63,19 @@ export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): voi let result: CreateWorktreeResult try { // Why: wrap only the helpers; the pre-validation throws above are IPC-shape bugs, not the git/filesystem failures the funnel tracks. - result = isFolderRepo(repo) - ? createFolderWorkspace(createArgs, repo, store) - : repo.connectionId - ? await createRemoteWorktree(createArgs, repo, store, mainWindow) - : await createLocalWorktree(createArgs, repo, store, mainWindow, runtime) + if (isFolderRepo(repo)) { + // A folder workspace is a registration, not a filesystem create, so it is host-agnostic. + result = createFolderWorkspace(createArgs, repo, store) + } else { + // Resolve the host rather than reading the raw field: an `executionHostId: 'ssh:*'`-only + // row read as local here and ran `git worktree add` on the client against a remote path, + // while the runtime sibling on the same repo already resolved. + const createRoute = requireWorktreeCreateRoute(repo) + result = + createRoute.kind === 'ssh' + ? await createRemoteWorktree(createArgs, createRoute.repo, store, mainWindow) + : await createLocalWorktree(createArgs, repo, store, mainWindow, runtime) + } } catch (error) { releaseAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) track('workspace_create_failed', { diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts index 46db8c4f0c7..f0628cb25e5 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts @@ -1,9 +1,8 @@ -import { toSshExecutionHostId } from '../../../shared/execution-host' import type { PersistedState } from '../../../shared/persisted-state-types' import type { SshRemotePtyLease } from '../../../shared/ssh-types' import { isTerminalLeafId } from '../../../shared/stable-pane-id' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { invalidateLocalWorktreeMetadataPruneInputs } from '../../local-worktree-metadata-prune-gate' +import { supersedeSiblingLeasesForPane } from './ssh-pty-pane-supersession' export type SshPtyLeaseOperations = { state: PersistedState @@ -15,91 +14,6 @@ export type SshPtyLeaseOperations = { flushDurableStateOrThrowAsync: () => Promise } -/** - * The PTY a pane is durably bound to, keyed on the leaf alone — the only remint-stable half of a - * pane key, since `detachTerminalPaneToTab` moves a live pane and leaves its lease naming the tab - * it left. - * - * Reads both partitions deliberately. Main writes some SSH pane bindings to `ssh:` and - * some to `local`, so a reader that consulted one would see "unbound" for a live pane and expire - * its lease. Reading both makes this fence correct whichever partition the binding landed in. - */ -function durablyBoundPtyIdForPane( - operations: SshPtyLeaseOperations, - targetId: string, - leafId: string -): string | undefined { - const findLeafBinding = (session: WorkspaceSessionState | undefined): string | undefined => - Object.values(session?.terminalLayoutsByTabId ?? {}).find( - (layout) => layout?.ptyIdsByLeafId?.[leafId] - )?.ptyIdsByLeafId?.[leafId] - const boundPtyId = - findLeafBinding(operations.state.workspaceSession) ?? - findLeafBinding(operations.state.workspaceSessionsByHostId?.[toSshExecutionHostId(targetId)]) - return boundPtyId ? operations.toComparablePtyId(targetId, boundPtyId) : undefined -} - -/** - * One pane owns at most one live remote PTY. Lease identity is `(targetId, ptyId)` alone, so a - * pane re-leasing under a new relay id leaves its predecessor live with nothing to retire it and - * the next reattach fans out over both — the reported 2 -> 19 -> 20 across three reconnects. - * - * Superseded leases are marked `expired`, never `terminated`: losing a lease is not evidence the - * shell died, so the remote process is deliberately left running. They also carry `supersededBy`, - * which is what keeps them out of the bulk reattach set now that plain `expired` no longer does — - * the winner's ptyId is already in hand here, so recording it needs no relay-start identity. - */ -function supersedeSiblingLeasesForPane( - operations: SshPtyLeaseOperations, - winner: SshRemotePtyLease, - now: number -): void { - if (!winner.worktreeId || !winner.leafId) { - return - } - if (winner.state === 'terminated' || winner.state === 'expired') { - return - } - // At upsert time the arriving lease may not be the one the pane is bound to yet. Expiring the - // bound predecessor would detach a live pane, so leave both live and let reattach arbitrate - // with the binding in hand. - const boundPtyId = durablyBoundPtyIdForPane(operations, winner.targetId, winner.leafId) - if (boundPtyId && boundPtyId !== winner.ptyId) { - return - } - const superseded: SshRemotePtyLease[] = [] - for (const lease of operations.state.sshRemotePtyLeases ?? []) { - if ( - lease.ptyId === winner.ptyId || - lease.targetId !== winner.targetId || - lease.worktreeId !== winner.worktreeId || - // Leaf only: a lease freezes its tabId, so a pane broken out into a new tab would otherwise - // never compete with its own predecessor — which is the reported cardinality growth. - lease.leafId !== winner.leafId || - lease.state === 'terminated' - ) { - continue - } - if (lease.state === 'expired') { - // An already-expired predecessor is superseded by the same evidence, and marking it is what - // bounds the reattach set: without this, every past orphan for this pane stays reattachable - // forever. `updatedAt` stays put — bumping it would make a stale lease look recent to - // `getRecentExpiredSshLease`. - lease.supersededBy = winner.ptyId - continue - } - lease.state = 'expired' - lease.supersededBy = winner.ptyId - lease.updatedAt = now - superseded.push(lease) - } - if (superseded.length > 0) { - // Why: matching on lease ptyId first means this scrubs only the predecessor's stale binding — - // the winner's own binding cannot match and is left intact. - operations.clearBindingsForLeases(winner.targetId, superseded) - } -} - /** * Only `terminated` unbinds a pane. It is the operator-close state and the one written after a * host-acknowledged stop; `expired` records that the CLIENT lost its route and says nothing about diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-pane-supersession.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-pane-supersession.ts new file mode 100644 index 00000000000..61d6b934db5 --- /dev/null +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-pane-supersession.ts @@ -0,0 +1,201 @@ +import { toSshExecutionHostId } from '../../../shared/execution-host' +import type { SshRemotePtyLease } from '../../../shared/ssh-types' +import { isTerminalLeafId } from '../../../shared/stable-pane-id' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { SshPtyLeaseOperations } from './ssh-pty-lease-operations' + +/** + * Every PTY id any partition binds to this pane, most authoritative first. + * + * Keyed on the leaf alone — the only remint-stable half of a pane key, since + * `detachTerminalPaneToTab` moves a live pane and leaves its lease naming the tab it left. + * + * Returns a LIST, and reads the target's own partition first, because the two partitions disagree + * for the length of a reconnect and this resolved that disagreement backwards. Main writes an SSH + * pane's binding to `ssh:`, while a stale copy of the same leaf survives in `local`; + * consulting `local` first therefore named the PREDECESSOR as the pane's current PTY on every relay + * restart. Supersession then took that expired predecessor as its winner and returned without + * marking anything — the per-reconnect lease growth. Both partitions are still read, because a + * reader that consulted only one would see "unbound" for a live pane and expire its lease. + */ +function durablyBoundPtyIdsForPane( + operations: SshPtyLeaseOperations, + targetId: string, + leafId: string +): string[] { + const findLeafBindings = (session: WorkspaceSessionState | undefined): string[] => + Object.values(session?.terminalLayoutsByTabId ?? {}) + .map((layout) => layout?.ptyIdsByLeafId?.[leafId]) + .filter((ptyId): ptyId is string => Boolean(ptyId)) + const ordered = [ + ...findLeafBindings( + operations.state.workspaceSessionsByHostId?.[toSshExecutionHostId(targetId)] + ), + ...findLeafBindings(operations.state.workspaceSession) + ] + return [...new Set(ordered.map((ptyId) => operations.toComparablePtyId(targetId, ptyId)))] +} + +/** A lease this client still holds a route to, as opposed to one it has already lost. */ +function isLiveLeaseState(state: SshRemotePtyLease['state']): boolean { + return state === 'attached' || state === 'detached' +} + +/** + * One pane owns at most one live remote PTY. Lease identity is `(targetId, ptyId)` alone, so a + * pane re-leasing under a new relay id leaves its predecessor live with nothing to retire it and + * the next reattach fans out over both — the reported 2 -> 19 -> 20 across three reconnects. + * + * Superseded leases are marked `expired`, never `terminated`: losing a lease is not evidence the + * shell died, so the remote process is deliberately left running. They also carry `supersededBy`, + * which is what keeps them out of the bulk reattach set now that plain `expired` no longer does — + * the winner's ptyId is already in hand here, so recording it needs no relay-start identity. + */ +export function supersedeSiblingLeasesForPane( + operations: SshPtyLeaseOperations, + winner: SshRemotePtyLease, + now: number +): boolean { + if (!winner.worktreeId || !winner.leafId) { + return false + } + if (winner.state === 'terminated' || winner.state === 'expired') { + return false + } + // At upsert time the arriving lease may not be the one the pane is bound to yet. Expiring the + // bound predecessor would detach a live pane, so leave both live and let reattach arbitrate + // with the binding in hand. `supersedeSshRemotePtyLeasesForBoundPane` re-runs this once the + // binding write lands, so a caller that upserts before it binds is not left bailed forever. + // Membership rather than equality: during a reconnect the two partitions name different PTYs for + // the same leaf, and requiring the winner to match the FIRST one read is what made this bail. + const boundPtyIds = durablyBoundPtyIdsForPane(operations, winner.targetId, winner.leafId) + if (boundPtyIds.length > 0 && !boundPtyIds.includes(winner.ptyId)) { + return false + } + let marked = false + const superseded: SshRemotePtyLease[] = [] + for (const lease of operations.state.sshRemotePtyLeases ?? []) { + if ( + lease.ptyId === winner.ptyId || + lease.targetId !== winner.targetId || + lease.worktreeId !== winner.worktreeId || + // Leaf only: a lease freezes its tabId, so a pane broken out into a new tab would otherwise + // never compete with its own predecessor — which is the reported cardinality growth. + lease.leafId !== winner.leafId || + lease.state === 'terminated' || + // Never retire a shell the pane is BOTH still bound to and still routable to. The stale + // partition can name a predecessor, and retiring that is the point; retiring a live one + // would strand a running remote process behind a pane that can no longer reach it. + (boundPtyIds.includes(lease.ptyId) && isLiveLeaseState(lease.state)) + ) { + continue + } + if (lease.state === 'expired') { + // An already-expired predecessor is superseded by the same evidence, and marking it is what + // bounds the reattach set: without this, every past orphan for this pane stays reattachable + // forever. `updatedAt` stays put — bumping it would make a stale lease look recent to + // `getRecentExpiredSshLease`. + marked ||= lease.supersededBy !== winner.ptyId + lease.supersededBy = winner.ptyId + continue + } + lease.state = 'expired' + lease.supersededBy = winner.ptyId + lease.updatedAt = now + marked = true + superseded.push(lease) + } + if (superseded.length > 0) { + // Why: matching on lease ptyId first means this scrubs only the predecessor's stale binding — + // the winner's own binding cannot match and is left intact. + operations.clearBindingsForLeases(winner.targetId, superseded) + } + return marked +} + +/** + * Supersede from the lease the pane's binding names — preferring a LIVE one when the partitions + * disagree, since a reconnect leaves the stale partition naming an already-expired predecessor and + * an expired winner supersedes nothing. + */ +function supersedeFromBoundPane( + operations: SshPtyLeaseOperations, + targetId: string, + leafId: string, + now: number +): boolean { + if (!isTerminalLeafId(leafId)) { + return false + } + const boundPtyIds = durablyBoundPtyIdsForPane(operations, targetId, leafId) + if (boundPtyIds.length === 0) { + // No binding names this pane, so nothing here is evidence about which shell owns it. Leaving + // every lease reattachable is the deliberate direction: an orphan must stay askable. + return false + } + const candidates = (operations.state.sshRemotePtyLeases ?? []).filter( + (lease) => + lease.targetId === targetId && lease.leafId === leafId && boundPtyIds.includes(lease.ptyId) + ) + const winner = candidates.find((lease) => isLiveLeaseState(lease.state)) + const marked = winner ? supersedeSiblingLeasesForPane(operations, winner, now) : false + return marked +} + +/** + * The binding-side trigger for supersession, and the reason the two writes that together claim a + * pane are commutative. + * + * `upsertSshRemotePtyLease` is the only other trigger, and it bails whenever the pane's durable + * binding still names the predecessor. A spawn path that upserts its lease BEFORE it writes the + * binding therefore bails and never re-runs on its own. Re-resolving the winner from the binding + * is safe in the other direction too: it supersedes only from the lease the pane is actually bound + * to, so it can never strand a live orphan. + */ +export function supersedeSshRemotePtyLeasesForBoundPane( + operations: SshPtyLeaseOperations, + targetId: string, + leafId: string +): void { + if (supersedeFromBoundPane(operations, targetId, leafId, Date.now())) { + operations.flush() + } +} + +/** + * Bound the reattach set to one lease per pane, re-derived from each pane's CURRENT binding. + * + * The spawn-side trigger cannot be sufficient alone, and measuring the shipped path is what showed + * it: a pane's binding has several writers — the spawn commit, the relay's reattach bind, and the + * renderer's debounced layout publish — and the last of those lands well after the spawn commit + * that leased the pty. A predecessor that was still bound when its successor was claimed therefore + * keeps its reattachability forever, because nothing revisits it once the binding catches up. The + * observed rows agreed on target, worktree, tab and leaf and still carried no mark. + * + * Running this immediately before the reattach set is read makes the answer independent of which + * writer bound the pane and when. It also repairs stores written by earlier builds, where these + * rows have already accumulated and no spawn-time trigger would ever revisit them. + * + * Panes with no binding are skipped rather than pruned: absence of a binding is not evidence about + * which shell owns the pane, and a genuine orphan has to stay askable + * (docs/reference/ssh-execution-boundary.md). + */ +export function reconcileSshRemotePtyLeasesForTarget( + operations: SshPtyLeaseOperations, + targetId: string +): void { + const leafIds = new Set() + for (const lease of operations.state.sshRemotePtyLeases ?? []) { + if (lease.targetId === targetId && lease.leafId) { + leafIds.add(lease.leafId) + } + } + const now = Date.now() + let changed = false + for (const leafId of leafIds) { + changed = supersedeFromBoundPane(operations, targetId, leafId, now) || changed + } + if (changed) { + operations.flush() + } +} diff --git a/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts b/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts index 8768a47e50a..75aa19ad24b 100644 --- a/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts +++ b/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts @@ -23,6 +23,10 @@ import { type SshPtyLeaseOperations, upsertSshRemotePtyLease as upsertSshRemotePtyLeaseOperation } from '../leasing-ssh-ptys/ssh-pty-lease-operations' +import { + reconcileSshRemotePtyLeasesForTarget as reconcileSshRemotePtyLeasesForTargetOperation, + supersedeSshRemotePtyLeasesForBoundPane as supersedeSshRemotePtyLeasesForBoundPaneOperation +} from '../leasing-ssh-ptys/ssh-pty-pane-supersession' import { getSshPtyConsumerRecovery as getSshPtyConsumerRecoveryOperation, removeSshPtyConsumerRecovery as removeSshPtyConsumerRecoveryOperation, @@ -95,6 +99,28 @@ export class SshLeaseRecoveryOperations { upsertSshRemotePtyLeaseOperation(getSshPtyLeaseOperations(this), lease) } + /** + * Re-run pane supersession from the binding rather than from an arriving lease. Spawn commits + * call this after their binding write so it does not matter whether the lease or the binding + * landed first; see `supersedeSshRemotePtyLeasesForBoundPane`. + */ + supersedeSshRemotePtyLeasesForBoundPane(targetId: string, leafId: string): void { + supersedeSshRemotePtyLeasesForBoundPaneOperation( + getSshPtyLeaseOperations(this), + targetId, + leafId + ) + } + + /** + * Re-derive one reattachable lease per pane from each pane's current binding. Called on the + * connect path immediately before the reattach set is read; see + * `reconcileSshRemotePtyLeasesForTarget`. + */ + reconcileSshRemotePtyLeasesForTarget(targetId: string): void { + reconcileSshRemotePtyLeasesForTargetOperation(getSshPtyLeaseOperations(this), targetId) + } + markSshRemotePtyLeases(targetId: string, state: SshRemotePtyLease['state']): void { markSshRemotePtyLeasesOperation(getSshPtyLeaseOperations(this), targetId, state) } diff --git a/src/main/providers/agent-foreground-process-batch.ts b/src/main/providers/agent-foreground-process-batch.ts index 12b60164446..414e57afcb3 100644 --- a/src/main/providers/agent-foreground-process-batch.ts +++ b/src/main/providers/agent-foreground-process-batch.ts @@ -29,7 +29,10 @@ export type BatchedForegroundProcessResult = { processName: string | null reason?: string /** Set only when the table was readable: every process group attached to this PTY's terminal is - * the shell's own, and none of them is stopped. Left absent when we could not observe it. */ + * the shell's own, none of them is stopped, AND that group's only member is the shell itself. + * Left absent when we could not observe it. Keeps the tty-shaped name because it is on the wire + * (`ForegroundProcessEvidence`); the value only ever got stricter, so an old client reading it + * skips more, never less. */ shellOwnsEveryTtyProcessGroup?: boolean } @@ -62,30 +65,45 @@ export type BatchedForegroundProcessOptions = { stats?: ProcessTableIndexStats } -/** Which process groups occupy each controlling terminal, and which terminals hold a stopped - * process. */ -type TtyOccupancy = { +/** The two units a forced stop can reach, indexed from one capture: which process groups occupy + * each controlling terminal (which terminals hold a stopped process), and how many rows belong to + * each process group anywhere on the host. */ +type PaneOccupancy = { processGroupsByTty: ReadonlyMap> stoppedTtys: ReadonlySet + /** Rows per `pgid`, counted over the WHOLE table with no tty filter — that is the point of it. + * A member that shares the shell's group but has no controlling terminal is reachable by + * `killpg` and invisible to every tty-shaped index. */ + rowsByProcessGroup: ReadonlyMap + /** True when some row carried no `pgid`, so the group counts are incomplete and cannot support + * an idleness claim. */ + processGroupsIncomplete: boolean } -const ttyOccupancyByCapture = new WeakMap() +const paneOccupancyByCapture = new WeakMap() -/** Index the capture by controlling terminal. +/** Index the capture by controlling terminal and by process group. * - * Keyed on `tpgid` because the snapshot carries no tty column and does not need one: a process - * group belongs to exactly one session, a session to at most one controlling terminal, so two - * rows reporting the same live `tpgid` are on the same tty. Memoized per capture, since the - * per-pane cadence poll and `pty.listProcesses` share one TTL-cached table. */ -function getTtyOccupancy(rows: readonly ProcessTableRow[]): TtyOccupancy { - const cached = ttyOccupancyByCapture.get(rows) + * The tty half is keyed on `tpgid` because the snapshot carries no tty column and does not need + * one: a process group belongs to exactly one session, a session to at most one controlling + * terminal, so two rows reporting the same live `tpgid` are on the same tty. Memoized per capture, + * since the per-pane cadence poll and `pty.listProcesses` share one TTL-cached table. */ +function getPaneOccupancy(rows: readonly ProcessTableRow[]): PaneOccupancy { + const cached = paneOccupancyByCapture.get(rows) if (cached) { return cached } const processGroupsByTty = new Map>() const stoppedTtys = new Set() + const rowsByProcessGroup = new Map() + let processGroupsIncomplete = false for (const row of rows) { - if (row.pgid === undefined || row.tpgid === undefined || row.tpgid <= 0) { + if (row.pgid === undefined) { + processGroupsIncomplete = true + continue + } + rowsByProcessGroup.set(row.pgid, (rowsByProcessGroup.get(row.pgid) ?? 0) + 1) + if (row.tpgid === undefined || row.tpgid <= 0) { continue } let groups = processGroupsByTty.get(row.tpgid) @@ -99,8 +117,13 @@ function getTtyOccupancy(rows: readonly ProcessTableRow[]): TtyOccupancy { stoppedTtys.add(row.tpgid) } } - const occupancy: TtyOccupancy = { processGroupsByTty, stoppedTtys } - ttyOccupancyByCapture.set(rows, occupancy) + const occupancy: PaneOccupancy = { + processGroupsByTty, + stoppedTtys, + rowsByProcessGroup, + processGroupsIncomplete + } + paneOccupancyByCapture.set(rows, occupancy) return occupancy } @@ -161,7 +184,7 @@ export function resolveAgentForegroundProcessesFromIndex( } } - const occupancy = getTtyOccupancy(index.rows) + const occupancy = getPaneOccupancy(index.rows) return requests.map((request) => { const root = lookupProcessTableIndex(index, (value) => value.byPid.get(request.rootPid)) if (!root) { @@ -185,20 +208,40 @@ export function resolveAgentForegroundProcessesFromIndex( reason: 'no_controlling_tty' } } - // The only host-observable "nothing is running here" signal, and it has to be read off the - // whole tty rather than off `tpgid === pgid`. A backgrounded `pnpm build &` and a Ctrl-Z'd - // editor both leave the shell owning the foreground group, byte-identical to an idle prompt; - // what separates them is a second process group attached to the pane's terminal. That is also - // exactly the blast radius of the stop this attests to — `forceKillPosixPtyProcessGroups` - // SIGKILLs every process group on the tty — so the evidence and the kill now measure the same - // thing. A reader may treat `false` as "busy" and must never treat absence as "idle". + // The only host-observable "nothing is running here" signal, and it takes TWO measurements + // because the stop it authorizes has two units. `forceKillPosixPtyProcessGroups` collects every + // process group on the pane's tty and then `killpg`s each one, so the blast radius is + // (groups on the tty) x (members of those groups, wherever they are). Neither half implies the + // other, so both are required: + // + // tty: a backgrounded `pnpm build &` and a Ctrl-Z'd editor both hand the terminal back, so + // the shell's row is byte-identical to an idle prompt. What separates them is a second + // process group attached to the pane's terminal. + // group: with job control off (`set +m`, common in non-interactive and dumb-terminal shells, + // and settable by the user at the prompt) a background job KEEPS the shell's pgid, so + // the tty shows one group and that group is running a build. Same for a child that + // drops the controlling terminal without `setsid` (`tpgid == -1`, absent from every + // tty index, still reachable by `killpg`) and for a double-forked grandchild that + // reparents to pid 1 and so never appears in the ppid walk below. + // + // Residual after both, written down because the predicate cannot see it: the capture is a + // snapshot, so work started between the `ps` and the signal is invisible — bounded, not + // removed, by RELAY_PTY_SWEEP_MAX_EVIDENCE_AGE_MS on the reading side; and a process the host's + // own `ps` cannot enumerate (another PID namespace, `hidepid=2`, a table truncated by a + // permission boundary) is unobservable here while `killpg` still reaches it. + // + // A reader may treat `false` as "busy" and must never treat absence as "idle". const ttyProcessGroups = occupancy.processGroupsByTty.get(root.tpgid) const shellOwnsEveryTtyProcessGroup = root.tpgid === root.pgid && ttyProcessGroups !== undefined && ttyProcessGroups.size === 1 && ttyProcessGroups.has(root.pgid) && - !occupancy.stoppedTtys.has(root.tpgid) + !occupancy.stoppedTtys.has(root.tpgid) && + !occupancy.processGroupsIncomplete && + // The root always counts itself, so exactly one row in its group means the group IS the + // shell — no separate leader check, and no set of pids retained per capture. + occupancy.rowsByProcessGroup.get(root.pgid) === 1 const allCandidates = rowsByOwner.get(root.pid) ?? [] const foregroundCandidates = allCandidates.filter((row) => row.pgid === root.tpgid) const fallbackProcess = request.fallbackProcess diff --git a/src/main/providers/ssh-pty-errors.ts b/src/main/providers/ssh-pty-errors.ts index 92ee84941ad..4d312caa8b1 100644 --- a/src/main/providers/ssh-pty-errors.ts +++ b/src/main/providers/ssh-pty-errors.ts @@ -20,12 +20,16 @@ export function isSshPtyIdentityMismatchError(error: unknown): boolean { } /** - * A reachable relay answered for this exact PTY id and reported it absent — positive evidence of - * absence from the execution host, so `exited` rather than `unverifiable` - * (docs/reference/ssh-execution-boundary.md). Deliberately NOT raised for a transport failure, a - * request timeout, a disposed multiplexer, an identity mismatch (the id names a live PTY belonging - * to another pane), or `restoreRequired` (the PTY is live, only its source stream is not) — none of - * those observe the process, and treating them as absence orphans live remote work. + * A reachable relay answered for this exact PTY id and reported it absent, so the client may retire + * its own route to it. Deliberately NOT raised for a transport failure, a request timeout, a + * disposed multiplexer, an identity mismatch (the id names a live PTY belonging to another pane), + * or `restoreRequired` (the PTY is live, only its source stream is not) — none of those observe the + * process, and treating them as absence orphans live remote work. + * + * This is NOT itself a death certificate. `pty.attach` answers absent for an id its session map + * never had as readily as for a pid it probed — and after a relay restart that is every id the + * previous one minted. Certifying `exited` needs {@link SshPtyProvenExitedOnRelayError} + * (docs/reference/ssh-execution-boundary.md). * * Carries the same `SSH_SESSION_EXPIRED` message so message-based consumers are unaffected; only * callers that can act on the stronger verdict test the class. @@ -40,3 +44,24 @@ export class SshPtyAbsentFromRelayError extends Error { export function isSshPtyAbsentFromRelayError(error: unknown): boolean { return error instanceof SshPtyAbsentFromRelayError } + +/** + * The narrow half of {@link SshPtyAbsentFromRelayError}: the relay probed the pid and found it gone + * before answering absent, so this is the one attach refusal that observed the process and the only + * one that may certify a death. + * + * The parent class is raised for the whole union, which also contains "this session map has no such + * id" — every id minted before a relay restart, checked against nothing. Callers that only release + * client-side bookkeeping keep testing the parent; a caller about to record `exited` must test this + * (docs/reference/ssh-execution-boundary.md). + */ +export class SshPtyProvenExitedOnRelayError extends SshPtyAbsentFromRelayError { + constructor(message: string) { + super(message) + this.name = 'SshPtyProvenExitedOnRelayError' + } +} + +export function isSshPtyProvenExitedOnRelayError(error: unknown): boolean { + return error instanceof SshPtyProvenExitedOnRelayError +} diff --git a/src/main/providers/ssh-pty-reattach-absence-discrimination.test.ts b/src/main/providers/ssh-pty-reattach-absence-discrimination.test.ts index 79ed97a8f19..bfcc174a4b6 100644 --- a/src/main/providers/ssh-pty-reattach-absence-discrimination.test.ts +++ b/src/main/providers/ssh-pty-reattach-absence-discrimination.test.ts @@ -14,9 +14,11 @@ import { describe, expect, it, vi } from 'vitest' import { isSshPtyAbsentFromRelayError, + isSshPtyProvenExitedOnRelayError, SSH_PTY_SOURCE_RESTORE_REQUIRED_ERROR, SSH_SESSION_EXPIRED_ERROR } from './ssh-pty-errors' +import { PTY_ATTACH_PROVEN_EXITED_MARKER } from '../../shared/pty-attach-absence-evidence' import { reattachSshPtySessionForSpawn } from './ssh-pty-session-reattach' import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' @@ -55,6 +57,20 @@ describe('an SSH reattach refusal says whether the host observed the PTY', () => expect(error.message).toContain(SSH_SESSION_EXPIRED_ERROR) expect(isSshPtyAbsentFromRelayError(error)).toBe(true) + // ...but absence from the relay is a union. An unmarked answer is also what a restarted relay + // gives for every id the previous one minted, checked against nothing, so it may not certify a + // death (docs/reference/ssh-execution-boundary.md). + expect(isSshPtyProvenExitedOnRelayError(error)).toBe(false) + }) + + it('separates the refusal the relay backed with a pid probe', async () => { + const error = await refusalFrom(async () => { + throw new Error(`PTY "${SESSION}" not found (${PTY_ATTACH_PROVEN_EXITED_MARKER})`) + }) + + expect(error.message).toContain(SSH_SESSION_EXPIRED_ERROR) + expect(isSshPtyAbsentFromRelayError(error)).toBe(true) + expect(isSshPtyProvenExitedOnRelayError(error)).toBe(true) }) it('gives a restoreRequired refusal its own token instead of the expiry text', async () => { @@ -79,5 +95,6 @@ describe('an SSH reattach refusal says whether the host observed the PTY', () => expect(error.message).toContain(SSH_SESSION_EXPIRED_ERROR) expect(isSshPtyAbsentFromRelayError(error)).toBe(false) + expect(isSshPtyProvenExitedOnRelayError(error)).toBe(false) }) }) diff --git a/src/main/providers/ssh-pty-session-reattach.ts b/src/main/providers/ssh-pty-session-reattach.ts index 530135ad310..f05373a03dc 100644 --- a/src/main/providers/ssh-pty-session-reattach.ts +++ b/src/main/providers/ssh-pty-session-reattach.ts @@ -5,9 +5,11 @@ import { SSH_PTY_SOURCE_RESTORE_REQUIRED_ERROR, SSH_SESSION_EXPIRED_ERROR, SshPtyAbsentFromRelayError, + SshPtyProvenExitedOnRelayError, isSshPtyIdentityMismatchError, isSshPtyNotFoundError } from './ssh-pty-errors' +import { isProvenExitedPtyAttachRefusal } from '../../shared/pty-attach-absence-evidence' import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id' import type { PtySpawnOptions, PtySpawnResult } from './types' import type { SshPtySpawnExitRaceTracker } from './ssh-pty-spawn-exit-race' @@ -238,6 +240,13 @@ export async function reattachSshPtySession(args: { // Why the class: the relay answered for this exact id, so callers holding a pane binding may // retire it and spawn fresh. Plain `SSH_SESSION_EXPIRED` cannot say that — a restarted relay // renumbers from pty-1, so the message alone is indistinguishable from a lost link. + // + // Why the subclass: the relay marks the one refusal it backed with a pid probe. Without the + // marker the answer is the "no such id" union, which is not evidence the shell ended, so the + // narrow class is minted only when the relay said so (docs/reference/ssh-execution-boundary.md). + if (isProvenExitedPtyAttachRefusal(error)) { + throw new SshPtyProvenExitedOnRelayError(`${SSH_SESSION_EXPIRED_ERROR}: ${relaySessionId}`) + } throw new SshPtyAbsentFromRelayError(`${SSH_SESSION_EXPIRED_ERROR}: ${relaySessionId}`) } throw error diff --git a/src/main/runtime/mobile-rpc-allowlist.test.ts b/src/main/runtime/mobile-rpc-allowlist.test.ts index adf9223b39a..684d6699fc1 100644 --- a/src/main/runtime/mobile-rpc-allowlist.test.ts +++ b/src/main/runtime/mobile-rpc-allowlist.test.ts @@ -36,7 +36,13 @@ const MOBILE_DYNAMIC_RPC_METHODS = [ 'github.resolveReviewThread', 'github.project.updateIssueCommentBySlug', 'github.project.deleteIssueCommentBySlug', - 'hostedReview.forBranch' + 'hostedReview.forBranch', + 'runtime.clientCapabilities.update', + 'agentSession.send', + 'agentSession.cancel', + 'agentSession.history', + 'agentSession.hold', + 'agentSession.release' ] const MOBILE_STREAMING_CLEANUP_RPC_METHODS = [ @@ -145,9 +151,28 @@ describe('mobile RPC allowlist', () => { ).toEqual([]) }) - it('does not expose structured agent sessions to mobile credentials', () => { + it('exposes only the mobile structured agent-session surface', () => { expect( [...mobileRpcAllowlist()].filter((method) => method.startsWith('agentSession.')) - ).toEqual([]) + ).toEqual([ + 'agentSession.createSupport', + 'agentSession.create', + 'agentSession.ensure', + 'agentSession.send', + 'agentSession.cancel', + 'agentSession.close', + 'agentSession.respondToApproval', + 'agentSession.respondToQuestion', + 'agentSession.setOption', + 'agentSession.handoffStatus', + 'agentSession.options', + 'agentSession.history', + 'agentSession.subscribe', + 'agentSession.unsubscribe', + 'agentSession.hold', + 'agentSession.release' + ]) + expect(mobileRpcAllowlist().has('agentSession.attach')).toBe(false) + expect(mobileRpcAllowlist().has('agentSession.requestHandoff')).toBe(false) }) }) diff --git a/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts b/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts index 9c6e5cda532..bd282b6575d 100644 --- a/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts +++ b/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts @@ -21,8 +21,10 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi tab: RuntimeMobileSessionAgentTab ): Promise { const host = getStructuredAgentSessionHost() - if (typeof host?.setSessionTabVisibility === 'function') { - await host.setSessionTabVisibility(tab.sessionId, false) + if (host) { + if (typeof host.setSessionTabVisibility === 'function') { + await host.setSessionTabVisibility(tab.sessionId, false) + } } const nextTabs = snapshot.tabs.filter((candidate) => candidate.id !== tab.id) const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null @@ -41,6 +43,10 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi } this.storeMobileSessionSnapshot(worktreeId, nextSnapshot) this.emitMobileSessionTabsSnapshot(nextSnapshot) + // Retire durable visibility and the runtime snapshot before stopping the provider. + if (typeof host?.close === 'function') { + await host.close(tab.sessionId) + } } // Why: a refused echoed close means the echoing client already pruned its diff --git a/src/main/runtime/orca-runtime-create-managed-worktree.ts b/src/main/runtime/orca-runtime-create-managed-worktree.ts index f9116f73405..f17a2285b83 100644 --- a/src/main/runtime/orca-runtime-create-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-worktree.ts @@ -4,7 +4,8 @@ import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktre import type { CreateWorktreeResult } from '../../shared/worktree/create-types' import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' import { isFolderRepo } from '../../shared/repo-kind' -import { getRepoSshConnectionId } from '../../shared/execution-host' +import { resolveWorktreeCreateRoute } from '../worktree-create-execution-host-route' +import { ExecutionHostNotDispatchableError } from '../providers/execution-host-provider-dispatch' import { createRuntimeFolderWorktree } from './runtime-folder-worktree-create' import { createRuntimeLocalManagedWorktree } from './runtime-local-worktree-create' import { prepareRuntimeLocalWorktreeSetup } from './runtime-local-worktree-setup' @@ -57,10 +58,14 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork draftStartup?.agent ?? (requestedAgentEnabled ? requestedAgent : undefined)) const effectiveDraftPaste = args.startupDraftPaste ?? draftStartup?.draftPaste - // Resolve the execution host once: SSH ownership has two spellings, and reading the raw - // `connectionId` field routes an `executionHostId: 'ssh:*'`-only repo down the local path, - // which runs `git worktree add` on the client against a remote path. - const sshConnectionId = getRepoSshConnectionId(repo) + // Resolve the execution host once, shared with the `worktrees:create` IPC entry point so the + // two cannot answer differently for the same repo. Reading the raw `connectionId` field routes + // an `executionHostId: 'ssh:*'`-only repo down the local path, which runs `git worktree add` on + // the client against a remote path. + const createRoute = resolveWorktreeCreateRoute(repo) + // `null` on a `runtime:` host is deliberate: its nested target is addressable only inside that + // environment, so the trust write must not go to a same-named target in this client's table. + const sshConnectionId = createRoute.kind === 'ssh' ? createRoute.connectionId : null if (isFolderRepo(repo)) { // A folder workspace is a registration, not a filesystem create, so it is host-agnostic — // except for the agent trust write, which must land on the host that will run the agent. @@ -97,20 +102,21 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork const lineageInput = args.lineage || args.comment ? { ...args.lineage, comment: args.comment } : undefined const lineageResolution = await this.resolveLineageForWorktreeCreate(lineageInput) - if (sshConnectionId) { - // Why normalize the row: the remote-create pipeline reads `repo.connectionId!` at every - // depth, so hand it the connection the resolved host actually names. - const result = await this.createManagedRemoteWorktree( - { ...repo, connectionId: sshConnectionId }, - { - ...args, - activate: args.activate, - ...(effectiveStartup ? { startup: effectiveStartup } : {}), - ...(effectiveStartupFollowup ? { startupFollowup: effectiveStartupFollowup } : {}), - ...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}), - ...(effectiveDraftPaste ? { startupDraftPaste: effectiveDraftPaste } : {}) - } - ) + if (createRoute.kind === 'runtime') { + throw new ExecutionHostNotDispatchableError(createRoute.hostId) + } + if (createRoute.kind === 'ssh') { + // `createRoute.repo` carries the resolved connection in `connectionId`, because the + // remote-create pipeline still reads `repo.connectionId!` at every depth. See the workaround + // note in worktree-create-execution-host-route.ts. + const result = await this.createManagedRemoteWorktree(createRoute.repo, { + ...args, + activate: args.activate, + ...(effectiveStartup ? { startup: effectiveStartup } : {}), + ...(effectiveStartupFollowup ? { startupFollowup: effectiveStartupFollowup } : {}), + ...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}), + ...(effectiveDraftPaste ? { startupDraftPaste: effectiveDraftPaste } : {}) + }) const recordedLineage = this.recordCreatedWorktreeLineage(result.worktree, lineageResolution) this.emitWorktreeLifecycle({ kind: 'created', diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 5328173adcd..06391e2ae83 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -1,7 +1,7 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithStructuredAgentSessionRecoverTuiOwner } from './orca-runtime-structured-agent-session-recover-tui-owner' import { DEFAULT_WORKTREE_PS_LIMIT } from './orca-runtime-postlude' -import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types' +import type { RuntimeWorktreePsResult } from '../../shared/runtime-types' import { buildRuntimeWorktreePsSummaries } from './runtime-worktree-ps-summaries' import { buildRuntimeWorktreeSummaryPathIndex } from './runtime-worktree-summary-paths' import { @@ -16,6 +16,7 @@ import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identit import { ensureStructuredAgentSessionHost as installStructuredAgentSessionHost } from './structured-agent-session-runtime' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { buildWorktreeListingPage } from './worktree-listing-host-scope' import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv @@ -33,11 +34,7 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent async getWorktreePs( limit = DEFAULT_WORKTREE_PS_LIMIT, sourceDefaultsSupported = true - ): Promise<{ - worktrees: RuntimeWorktreePsSummary[] - totalCount: number - truncated: boolean - }> { + ): Promise { if (!Number.isInteger(limit) || limit <= 0) { throw new Error('invalid_limit') } @@ -114,11 +111,9 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent }) const sorted = [...summaries.values()].sort(compareWorktreePs) - return { - worktrees: sorted.slice(0, limit), - totalCount: sorted.length, - truncated: sorted.length > limit - } + // Why: the same cap starvation as worktree.list — a host whose rows all sort last gets no + // page at all, which is indistinguishable from it having no workspaces (#18104). + return buildWorktreeListingPage(sorted, limit, this.listKnownExecutionHostIds()) } listRepos(): Repo[] { diff --git a/src/main/runtime/orca-runtime-remove-managed-worktree.ts b/src/main/runtime/orca-runtime-remove-managed-worktree.ts index 5b45abd13fa..25a294c2a90 100644 --- a/src/main/runtime/orca-runtime-remove-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-remove-managed-worktree.ts @@ -10,8 +10,7 @@ import { preservedBranchCleanupScopeKey } from '../../shared/preserved-branch-cl import { getRuntimeWorktreeRemovalOptionsKey } from './runtime-worktree-selection' import { withWorktreeSpan } from '../observability/instrumentation' import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' -import { requireSshGitProvider } from '../providers/ssh-git-dispatch' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { resolveWorktreeRemovalRoute } from '../worktree-removal-execution-host-route' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import { listWorktreesStrict } from '../git/worktree' import { findRegisteredDeletableWorktree } from '../worktree-removal-safety' @@ -79,22 +78,24 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM if (orphanOrFolderResult) { return orphanOrFolderResult } - const provider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null - const fsProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : null - const localWorktreeGitOptions = repo.connectionId - ? {} - : getLocalProjectWorktreeGitOptions(this.requireStore(), repo) + // One host for the whole removal. Listing on a different host from the one the prune and + // the delete use is how an `executionHostId: 'ssh:*'`-only row got listed remotely and + // deleted here; the route refuses rather than falling back to this machine. + const route = resolveWorktreeRemovalRoute(removalHostId) + const localWorktreeGitOptions = + route.kind === 'ssh' ? {} : getLocalProjectWorktreeGitOptions(this.requireStore(), repo) const hasLocalWorktreeGitOptions = Object.keys(localWorktreeGitOptions).length > 0 - const registeredWorktrees = repo.connectionId - ? await provider!.listWorktrees(repo.path) - : hasLocalWorktreeGitOptions - ? await listWorktreesStrict(repo.path, localWorktreeGitOptions) - : await listWorktreesStrict(repo.path) + const registeredWorktrees = + route.kind === 'ssh' + ? await route.provider.listWorktrees(repo.path) + : hasLocalWorktreeGitOptions + ? await listWorktreesStrict(repo.path, localWorktreeGitOptions) + : await listWorktreesStrict(repo.path) const removedMeta = resolveWorktreeRemovalMetadata( store, removalTarget.repoId, removalTarget.id, - cleanupHostId ?? getRepoExecutionHostId(repo) + removalHostId ) const removedPushTarget = removedMeta?.pushTarget ?? removalTarget.pushTarget const registeredWorktree = findRegisteredDeletableWorktree( @@ -111,8 +112,7 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM removedPushTarget, force, allowUnverifiedPtyStop, - provider, - fsProvider: fsProvider ?? null, + route, localOptions: localWorktreeGitOptions, store, acquireWatcherRemoval: this.acquireFileWatcherRemoval, @@ -123,7 +123,7 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM }), deleteHistory: () => deleteRemoteWorktreeHistory( - repo.connectionId ? this.getSshProviderFn?.(repo.connectionId) : undefined, + route.kind === 'ssh' ? this.getSshProviderFn?.(route.connectionId) : undefined, removalTarget.id ), finishRemoval: () => { @@ -145,13 +145,17 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force)) } if ( - !repo.connectionId && + route.kind === 'local' && force === true && process.platform === 'win32' && (isWindowsAbsolutePathLike(canonicalWorktreePath) || !!localWorktreeGitOptions.wslDistro) && removedMeta && - (await isRuntimeWorktreePathMissing(repo, canonicalWorktreePath, localWorktreeGitOptions)) + (await isRuntimeWorktreePathMissing( + route.hostId, + canonicalWorktreePath, + localWorktreeGitOptions + )) ) { const removalResult = await removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval({ canonicalWorktreePath, @@ -182,26 +186,27 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM this.notifyWorktreesChanged(repo.id) return removalResult ?? {} } - if (repo.connectionId) { + if (route.kind === 'ssh') { return removeRuntimeRegisteredRemoteWorktree({ repo, target: removalTarget, registeredWorktree, removedPushTarget, store, - provider: provider!, + provider: route.provider, + connectionId: route.connectionId, force, allowUnverifiedPtyStop, deleteBranch, acquireWatcherRemoval: this.acquireFileWatcherRemoval, stopPtys: () => this.stopPtysForDestructiveWorktreeRemoval(removalTarget.id, { - connectionId: repo.connectionId!, + connectionId: route.connectionId, allowUnverifiedStop: allowUnverifiedPtyStop }), deleteHistory: () => deleteRemoteWorktreeHistory( - this.getSshProviderFn?.(repo.connectionId!), + this.getSshProviderFn?.(route.connectionId), removalTarget.id ), preserveBranchHead: (result, fallbackHead) => diff --git a/src/main/runtime/orca-runtime-remove-orphan-or-folder-worktree.ts b/src/main/runtime/orca-runtime-remove-orphan-or-folder-worktree.ts index d49dc410cd5..01f4f305803 100644 --- a/src/main/runtime/orca-runtime-remove-orphan-or-folder-worktree.ts +++ b/src/main/runtime/orca-runtime-remove-orphan-or-folder-worktree.ts @@ -91,7 +91,11 @@ export async function removeOrphanOrFolderWorktree({ if (removalTarget.id === getRuntimeFolderWorkspaceRootId(repo)) { throw new Error('Cannot delete the project root workspace. Remove the folder project instead.') } - const folderConnectionId = repo.connectionId?.trim() || null + // Resolved, not raw: a folder repo naming its owner only as `executionHostId: 'ssh:*'` used to + // tear down its PTYs and history on the client. A `runtime:` host answers null — its nested + // target is addressable only inside that environment, never from this client's SSH table. + const folderHost = parseExecutionHostId(removalHostId) + const folderConnectionId = folderHost?.kind === 'ssh' ? folderHost.targetId : null const folderSshPtyProvider = folderConnectionId ? runtime.getSshProviderFn?.(folderConnectionId) : undefined diff --git a/src/main/runtime/orca-runtime-state-fields.ts b/src/main/runtime/orca-runtime-state-fields.ts index 770ce0517a8..2f95dfeada1 100644 --- a/src/main/runtime/orca-runtime-state-fields.ts +++ b/src/main/runtime/orca-runtime-state-fields.ts @@ -87,6 +87,11 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands { ) { super() this.store = store + store?.onSettingsChanged?.((updates) => { + if ('experimentalStructuredNativeChat' in updates) { + this.notifyMobileSessionTabsChanged() + } + }) const runtime = this as RuntimeCommandSurfaceHost installRuntimeFileCommandSurface(runtime, this.fileCommands) installRuntimeGitCommandSurface(runtime, this.gitCommands) diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index 278ccd28a74..346006cd5fd 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -136,7 +136,8 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId listResolved: () => this.listResolvedWorktrees(), resolveRepo: (selector) => this.resolveRepoSelector(selector), selectRepos: (selector) => this.selectReposBySelector(selector), - scanRepo: (repo) => this.listRepoWorktreesForResolution(repo) + scanRepo: (repo) => this.listRepoWorktreesForResolution(repo), + listKnownHostIds: () => this.listKnownExecutionHostIds() }) protected readonly ptyForegroundAgent = new RuntimePtyForegroundAgent({ diff --git a/src/main/runtime/orca-runtime-structured-native-chat-settings.test.ts b/src/main/runtime/orca-runtime-structured-native-chat-settings.test.ts new file mode 100644 index 00000000000..90be5c61ba2 --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-native-chat-settings.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' + +describe('structured native chat settings', () => { + it('republishes mobile session tabs when the host visibility setting changes', () => { + const settingsListeners: ((updates: Record) => void)[] = [] + const runtime = new OrcaRuntimeService({ + onSettingsChanged: vi.fn((listener) => { + settingsListeners.push(listener as (updates: Record) => void) + return vi.fn() + }) + } as never) + const notify = vi.spyOn(runtime, 'notifyMobileSessionTabsChanged').mockImplementation(() => {}) + + settingsListeners[0]?.({ compactWorktreeCards: true }) + expect(notify).not.toHaveBeenCalled() + + settingsListeners[0]?.({ experimentalStructuredNativeChat: true }) + expect(notify).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index 977606e6366..d6ec1e22782 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -205,6 +205,11 @@ describe('structured session cold restoration', () => { it('normalizes a restored tab id and removes it when closed', async () => { const runtime = new OrcaRuntimeService() const closeSessionTab = vi.fn(async () => undefined) + const closeStructuredSession = vi.fn(async () => { + const snapshot = await runtime.listMobileSessionTabs('id:workspace-1') + expect(snapshot.tabs.some((tab) => tab.type === 'agent-session')).toBe(false) + }) + const setSessionTabVisibility = vi.fn(async () => undefined) runtime.setNotifier({ closeSessionTab } as never) const internal = runtime as unknown as { hasPersistedStructuredAgentSessionStore(): boolean @@ -221,6 +226,8 @@ describe('structured session cold restoration', () => { setStructuredAgentSessionHost({ reconcileRestartLeases: async () => undefined, restoreReadableSessions: async () => undefined, + close: closeStructuredSession, + setSessionTabVisibility, listSessionTabs: () => [ { sessionId: 'agent-session:agent-session:restored-session', @@ -304,6 +311,11 @@ describe('structured session cold restoration', () => { 'structured-agent-session-restored-session', 'workspace-1' ) + expect(closeStructuredSession).toHaveBeenCalledWith('restored-session') + expect(setSessionTabVisibility).toHaveBeenCalledWith('restored-session', false) + expect(setSessionTabVisibility.mock.invocationCallOrder[0]).toBeLessThan( + closeStructuredSession.mock.invocationCallOrder[0]! + ) const closed = await runtime.listMobileSessionTabs('id:workspace-1') expect(closed.tabs.map((tab) => tab.id)).toEqual([ diff --git a/src/main/runtime/orca-runtime-tests/mobile-creation-and-orchestration-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-creation-and-orchestration-part-04.spec.ts index 2d6042c280e..965a1a1bc3f 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-creation-and-orchestration-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-creation-and-orchestration-part-04.spec.ts @@ -168,6 +168,8 @@ describe('OrcaRuntimeService', () => { agents: [] } ], + // Why: the summary now names the hosts it covered; an absent scope would read as absolute. + hostScope: { hostIds: ['local'], omittedHostIds: [] }, totalCount: 1, truncated: false }) diff --git a/src/main/runtime/orca-runtime-tests/worktree-create-execution-host.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-create-execution-host.spec.ts new file mode 100644 index 00000000000..981e9860463 --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/worktree-create-execution-host.spec.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + OrcaRuntimeService, + addWorktree, + registerSshGitProvider, + unregisterSshGitProvider +} from '../orca-runtime-test-mocks.spec' +import { store } from '../orca-runtime-test-fixtures.spec' + +const RUNTIME_REPO_PATH = '/remote/repo' + +function makeRuntimeHostedStore(extraRepoFields: Record = {}) { + const repo = { + ...store.getRepos()[0]!, + path: RUNTIME_REPO_PATH, + executionHostId: 'runtime:env-1', + ...extraRepoFields + } + return { + ...store, + getRepos: () => [repo], + getRepo: (id: string) => (id === repo.id ? repo : undefined) + } +} + +describe('OrcaRuntimeService worktree create execution host', () => { + beforeEach(() => { + vi.mocked(addWorktree).mockClear() + }) + + it('refuses to create for a runtime-hosted repo with no nested SSH target', async () => { + const runtime = new OrcaRuntimeService(makeRuntimeHostedStore() as never) + + await expect( + runtime.createManagedWorktree({ repoSelector: 'id:repo-1', name: 'wt' }) + ).rejects.toThrow('not dispatched by this process') + + expect(addWorktree).not.toHaveBeenCalled() + }) + + it('refuses a runtime-hosted repo whose nested SSH target is dialable in this namespace', async () => { + // `target-a` names a target inside env-1. The same-named one registered here is another + // machine, so creating through it would put the checkout on the wrong host. + const provider = { exec: vi.fn(), addWorktree: vi.fn(), listWorktrees: vi.fn() } + registerSshGitProvider('target-a', provider as never) + const runtime = new OrcaRuntimeService( + makeRuntimeHostedStore({ connectionId: 'target-a' }) as never + ) + + try { + await expect( + runtime.createManagedWorktree({ repoSelector: 'id:repo-1', name: 'wt' }) + ).rejects.toThrow('not dispatched by this process') + + expect(provider.addWorktree).not.toHaveBeenCalled() + expect(addWorktree).not.toHaveBeenCalled() + } finally { + unregisterSshGitProvider('target-a') + } + }) +}) diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts index beaa91036d0..06982cf179e 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts @@ -193,41 +193,69 @@ describe('OrcaRuntimeService', () => { }) it('does not coalesce concurrent same-id removals on different hosts', async () => { + const baseRepo = store.getRepos()[0]! + // The second owner names its host only in the migrated spelling, so its removal must reach the + // SSH host rather than joining the local one and running `git worktree remove` here. + const remoteRepo = { ...baseRepo, path: '/remote/repo', executionHostId: 'ssh:host-b' } const runtimeStore = { ...store, - getRepos: () => [ - { ...store.getRepos()[0], executionHostId: 'local' }, - { ...store.getRepos()[0], executionHostId: 'runtime:env-1' } - ] + getRepos: () => [{ ...baseRepo, executionHostId: 'local' }, remoteRepo] } const runtime = createWorktreeRemovalRuntime(runtimeStore) vi.spyOn(runtime, 'acquireFileWatcherRemoval').mockResolvedValue({ finish: vi.fn() }) const bothStarted = deferred() const finishRemovals = deferred() let startedCount = 0 - vi.mocked(removeWorktree).mockImplementation(async () => { + const startRemoval = async (): Promise> => { startedCount += 1 if (startedCount === 2) { bothStarted.resolve() } await finishRemovals.promise return {} - }) + } + vi.mocked(removeWorktree).mockImplementation(startRemoval) + const provider = { + exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: remoteRepo.path, + head: 'main', + branch: 'main', + isBare: false, + isMainWorktree: true + }, + { + path: TEST_WORKTREE_PATH, + head: 'def456', + branch: 'feature/test', + isBare: false, + isMainWorktree: false + } + ]), + removeWorktree: vi.fn().mockImplementation(startRemoval) + } + registerSshGitProvider('host-b', provider as never) - const local = runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'local') - const paired = runtime.removeManagedWorktree( - TEST_WORKTREE_ID, - true, - false, - false, - 'runtime:env-1' - ) + try { + const local = runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'local') + const remote = runtime.removeManagedWorktree( + TEST_WORKTREE_ID, + true, + false, + false, + 'ssh:host-b' + ) - await bothStarted.promise - expect(removeWorktree).toHaveBeenCalledTimes(2) + await bothStarted.promise + expect(removeWorktree).toHaveBeenCalledTimes(1) + expect(provider.removeWorktree).toHaveBeenCalledTimes(1) - finishRemovals.resolve() - await expect(Promise.all([local, paired])).resolves.toEqual([{}, {}]) + finishRemovals.resolve() + await expect(Promise.all([local, remote])).resolves.toEqual([{}, {}]) + } finally { + unregisterSshGitProvider('host-b') + } }) it('rejects concurrent runtime worktree removals for the same id with different options', async () => { diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-execution-host.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-execution-host.spec.ts new file mode 100644 index 00000000000..8ec38e0e5e8 --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-execution-host.spec.ts @@ -0,0 +1,216 @@ +import { + listWorktrees, + listWorktreesStrict, + registerSshFilesystemProvider, + registerSshGitProvider, + removeWorktree, + unregisterSshFilesystemProvider, + unregisterSshGitProvider +} from '../orca-runtime-test-mocks.spec' +import type { WorktreeMeta } from '../orca-runtime-test-mocks.spec' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + TEST_WORKTREE_ID, + TEST_WORKTREE_PATH, + makeWorktreeMeta, + store +} from '../orca-runtime-test-fixtures.spec' +import { createWorktreeRemovalRuntime } from '../orca-runtime-test-scenario-builders.spec' +import type { ExecutionHostId } from '../../../shared/execution-host' + +const REMOTE_REPO_PATH = '/remote/repo' + +function missingPath(): never { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) +} + +function makeGitProvider(worktrees: readonly unknown[]) { + return { + exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), + listWorktrees: vi.fn().mockResolvedValue(worktrees), + removeWorktree: vi.fn().mockResolvedValue({}) + } +} + +function makeRemoteRepoStore( + executionHostId: ExecutionHostId, + extraRepoFields: Record = {}, + metaOverrides: Partial = {} +) { + const repo = { + ...store.getRepos()[0]!, + path: REMOTE_REPO_PATH, + executionHostId, + ...extraRepoFields + } + const metaById: Record = { + [TEST_WORKTREE_ID]: makeWorktreeMeta({ hostId: executionHostId, ...metaOverrides }) + } + const removeWorktreeMeta = vi.fn((worktreeId: string, hostId?: string) => { + if (!hostId || metaById[worktreeId]?.hostId === hostId) { + delete metaById[worktreeId] + } + }) + return { + repo, + metaById, + removeWorktreeMeta, + runtimeStore: { + ...store, + getRepos: () => [repo], + getRepo: (id: string) => (id === repo.id ? repo : undefined), + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } + return metaById[worktreeId] + }, + removeWorktreeMeta + } + } +} + +const REPO_ROOT_ENTRY = { + path: REMOTE_REPO_PATH, + head: 'main', + branch: 'main', + isBare: false, + isMainWorktree: true +} + +const REGISTERED_ENTRY = { + path: TEST_WORKTREE_PATH, + head: 'def456', + branch: 'feature/test', + isBare: false, + isMainWorktree: false +} + +describe('OrcaRuntimeService worktree removal execution host', () => { + beforeEach(() => { + vi.mocked(listWorktrees).mockClear() + vi.mocked(listWorktreesStrict).mockClear() + vi.mocked(removeWorktree).mockClear() + }) + + it('removes a migrated-spelling SSH row on its own host, never this machine', async () => { + // No `connectionId` at all: the row names its owner only as `executionHostId: 'ssh:target-a'`. + const { runtimeStore, removeWorktreeMeta, metaById } = makeRemoteRepoStore('ssh:target-a') + const provider = makeGitProvider([REPO_ROOT_ENTRY, REGISTERED_ENTRY]) + registerSshGitProvider('target-a', provider as never) + const runtime = createWorktreeRemovalRuntime(runtimeStore) + vi.spyOn(runtime, 'acquireFileWatcherRemoval').mockResolvedValue({ finish: vi.fn() }) + + try { + await runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'ssh:target-a') + + expect(provider.listWorktrees).toHaveBeenCalledWith(REMOTE_REPO_PATH) + expect(provider.removeWorktree).toHaveBeenCalledWith(TEST_WORKTREE_PATH, true) + expect(listWorktreesStrict).not.toHaveBeenCalled() + expect(removeWorktree).not.toHaveBeenCalled() + expect(removeWorktreeMeta).toHaveBeenCalledWith(TEST_WORKTREE_ID, 'ssh:target-a') + expect(metaById[TEST_WORKTREE_ID]).toBeUndefined() + } finally { + unregisterSshGitProvider('target-a') + } + }) + + it('runs the cleanup path for a migrated-spelling row entirely on its host', async () => { + // #18358 made an `executionHostId`-only row a removable cleanup candidate. Every step of the + // removal it starts — list, existence probe, delete, prune — must name the same host. + const { runtimeStore, removeWorktreeMeta } = makeRemoteRepoStore('ssh:target-a') + const provider = makeGitProvider([REPO_ROOT_ENTRY]) + const fsProvider = { stat: vi.fn(missingPath), deletePath: vi.fn() } + registerSshGitProvider('target-a', provider as never) + registerSshFilesystemProvider('target-a', fsProvider as never) + const runtime = createWorktreeRemovalRuntime(runtimeStore) + + try { + await expect( + runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'ssh:target-a') + ).resolves.toEqual({}) + + expect(provider.listWorktrees).toHaveBeenCalledWith(REMOTE_REPO_PATH) + expect(fsProvider.stat).toHaveBeenCalledWith(TEST_WORKTREE_PATH) + expect(listWorktreesStrict).not.toHaveBeenCalled() + expect(removeWorktree).not.toHaveBeenCalled() + expect(fsProvider.deletePath).not.toHaveBeenCalled() + expect(removeWorktreeMeta).toHaveBeenCalledWith(TEST_WORKTREE_ID, 'ssh:target-a') + } finally { + unregisterSshFilesystemProvider('target-a') + unregisterSshGitProvider('target-a') + } + }) + + it('keeps two simultaneously registered SSH hosts off each other paths', async () => { + const { runtimeStore } = makeRemoteRepoStore('ssh:target-b') + const providerA = makeGitProvider([REPO_ROOT_ENTRY, REGISTERED_ENTRY]) + const providerB = makeGitProvider([REPO_ROOT_ENTRY, REGISTERED_ENTRY]) + registerSshGitProvider('target-a', providerA as never) + registerSshGitProvider('target-b', providerB as never) + const runtime = createWorktreeRemovalRuntime(runtimeStore) + vi.spyOn(runtime, 'acquireFileWatcherRemoval').mockResolvedValue({ finish: vi.fn() }) + + try { + await runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'ssh:target-b') + + expect(providerB.removeWorktree).toHaveBeenCalledWith(TEST_WORKTREE_PATH, true) + expect(providerA.listWorktrees).not.toHaveBeenCalled() + expect(providerA.removeWorktree).not.toHaveBeenCalled() + } finally { + unregisterSshGitProvider('target-b') + unregisterSshGitProvider('target-a') + } + }) + + it('refuses an SSH row whose host is unreachable instead of deleting here', async () => { + const { runtimeStore, metaById } = makeRemoteRepoStore('ssh:target-a') + const runtime = createWorktreeRemovalRuntime(runtimeStore) + + await expect( + runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'ssh:target-a') + ).rejects.toThrow('Remote connection dropped') + + expect(listWorktreesStrict).not.toHaveBeenCalled() + expect(removeWorktree).not.toHaveBeenCalled() + expect(metaById[TEST_WORKTREE_ID]).toBeDefined() + }) + + it('refuses a runtime row with no nested SSH target rather than deleting locally', async () => { + const { runtimeStore, metaById } = makeRemoteRepoStore('runtime:env-1') + const runtime = createWorktreeRemovalRuntime(runtimeStore) + + await expect( + runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'runtime:env-1') + ).rejects.toThrow('not dispatched by this process') + + expect(listWorktreesStrict).not.toHaveBeenCalled() + expect(removeWorktree).not.toHaveBeenCalled() + expect(metaById[TEST_WORKTREE_ID]).toBeDefined() + }) + + it('refuses a runtime row whose nested SSH target is dialable in this namespace', async () => { + // `connectionId: 'target-a'` names a target inside env-1, not the one registered here. The raw + // read dialled this client's same-named host and removed a worktree on the wrong machine. + const { runtimeStore, metaById } = makeRemoteRepoStore('runtime:env-1', { + connectionId: 'target-a' + }) + const provider = makeGitProvider([REPO_ROOT_ENTRY, REGISTERED_ENTRY]) + registerSshGitProvider('target-a', provider as never) + const runtime = createWorktreeRemovalRuntime(runtimeStore) + + try { + await expect( + runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'runtime:env-1') + ).rejects.toThrow('not dispatched by this process') + + // Selector resolution still lists through the raw field before removal begins — a read on + // the wrong namespace, tracked separately. Nothing destructive reaches it. + expect(provider.removeWorktree).not.toHaveBeenCalled() + expect(removeWorktree).not.toHaveBeenCalled() + expect(metaById[TEST_WORKTREE_ID]).toBeDefined() + } finally { + unregisterSshGitProvider('target-a') + } + }) +}) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index aabfeb899c8..9d223233ef2 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -18,6 +18,7 @@ await import('./orca-runtime-tests/terminal-listing.spec') await import('./orca-runtime-tests/worktree-selector-resolution.spec') await import('./orca-runtime-tests/local-worktree-creation.spec') await import('./orca-runtime-tests/local-worktree-creation-part-02.spec') +await import('./orca-runtime-tests/worktree-create-execution-host.spec') await import('./orca-runtime-tests/ssh-worktree-lifecycle.spec') await import('./orca-runtime-tests/ssh-worktree-lifecycle-part-02.spec') await import('./orca-runtime-tests/ssh-worktree-lifecycle-part-03.spec') @@ -104,5 +105,6 @@ await import('./orca-runtime-tests/worktree-removal-and-reconciliation.spec') await import('./orca-runtime-tests/worktree-removal-and-reconciliation-part-02.spec') await import('./orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec') await import('./orca-runtime-tests/worktree-removal-and-reconciliation-part-04.spec') +await import('./orca-runtime-tests/worktree-removal-execution-host.spec') await import('./orca-runtime-tests/targeting-and-resilience.spec') await import('./orca-runtime-tests/worktree-scan-cache-ttl.spec') diff --git a/src/main/runtime/relay/relay-control-client.test.ts b/src/main/runtime/relay/relay-control-client.test.ts index 2975b67e631..d235f0ebacd 100644 --- a/src/main/runtime/relay/relay-control-client.test.ts +++ b/src/main/runtime/relay/relay-control-client.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import nacl from 'tweetnacl' import { WebSocketServer, type WebSocket } from 'ws' import type { E2EEKeypair } from '../e2ee-keypair' +import { MOBILE_RELAY_CLOSE_CODE } from '../../../shared/mobile-relay-close-codes' import { RelayControlClient } from './relay-control-client' const encoder = new TextEncoder() @@ -550,4 +551,48 @@ describe('RelayControlClient scripted-socket lifecycle', () => { vi.advanceTimersByTime(91_000) expect(client.isLive()).toBe(false) }) + + it('ignores an unrecognized control message without closing the active control', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { client, socket, onClose } = scriptedControl() + await client.connect() + expect(client.isLive()).toBe(true) + + // A newer relay opcode the desktop schema does not know. Rule 2 of + // remote-wire-compatibility: an unknown-but-well-formed frame is dropped, + // never fatal to a live control. + socket.deliver({ type: 'relay-hint', v: 2, hint: 'future-feature' }) + + expect(client.isLive()).toBe(true) + expect(socket.readyState).toBe(1) + expect(onClose).not.toHaveBeenCalled() + warn.mockRestore() + }) + + it('ignores a reply whose request already timed out instead of self-closing', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { client, socket, onClose } = scriptedControl() + await client.connect() + + // A relay control-error carrying a reqId with no live waiter — e.g. a late + // reply that arrived after the desktop's request deadline deleted it, or the + // relay's no-op error for a command it could not route. Must not be fatal. + socket.deliver({ type: 'control-error', reqId: 'expired-req', code: 'unknown_control_message' }) + + expect(client.isLive()).toBe(true) + expect(socket.readyState).toBe(1) + expect(onClose).not.toHaveBeenCalled() + warn.mockRestore() + }) + + it('still tears down a malformed (non-JSON) control frame', async () => { + const { client, socket, onClose } = scriptedControl() + await client.connect() + + socket.emit('message', 'not-json{', false) + + expect(client.isLive()).toBe(false) + expect(socket.readyState).toBe(3) + expect(onClose).toHaveBeenCalledWith(MOBILE_RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL) + }) }) diff --git a/src/main/runtime/relay/relay-control-client.ts b/src/main/runtime/relay/relay-control-client.ts index 76816c81a3a..7e742173f72 100644 --- a/src/main/runtime/relay/relay-control-client.ts +++ b/src/main/runtime/relay/relay-control-client.ts @@ -234,7 +234,18 @@ export class RelayControlClient { if (this.requests.resolveMessage(message)) { return } - this.failProtocol('unknown control message') + // Drop a well-formed control message we do not recognize, matching how every + // other Orca decoder treats an unknown frame (see the silent-drop convention + // in docs/reference/remote-wire-compatibility.md). The control channel has no + // opcode negotiation step, so this reaches either a newer relay's message + // this build predates, or a reply whose request already timed out and has no + // waiter (relay control ops run DB transactions that can exceed the request + // deadline under load). Self-closing here was strictly worse than ignoring: + // it orphaned the relay session, which answered the phone with HOST_OFFLINE + // for the orphan-grace window plus the director's reconnect throttle — minutes + // of outage from a single stray frame. + const messageType = typeof message.type === 'string' ? message.type : 'unknown' + console.warn(`[relay] ignoring unrecognized control message type=${messageType}`) } private handleProofMessage(message: Record): void { diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index 975988d203c..5e669ab702e 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -77,6 +77,8 @@ export type RpcContext = { clientKind?: 'mobile' | 'runtime' // Why: negotiation is bound to the authenticated socket, never asserted by a destructive request. clientCapabilities?: readonly RuntimeCapability[] + // Why: mobile v2 auth is exact-key validated; capability upgrades must mutate only the authenticated socket after auth. + updateClientCapabilities?: (capabilities: readonly RuntimeCapability[]) => void // Why: Dispatch authority rides in the authenticated RPC envelope, never in user payload fields. orchestrationCapability?: string // Why: long-lived mutations such as ask can durably expose acceptance before their waiter settles. diff --git a/src/main/runtime/rpc/dispatcher-stream-options.ts b/src/main/runtime/rpc/dispatcher-stream-options.ts index cc8322373b1..e3151c66b0e 100644 --- a/src/main/runtime/rpc/dispatcher-stream-options.ts +++ b/src/main/runtime/rpc/dispatcher-stream-options.ts @@ -10,6 +10,7 @@ export type RpcDispatchStreamingOptions = { pairedDeviceId?: string clientKind?: 'mobile' | 'runtime' clientCapabilities?: readonly RuntimeCapability[] + updateClientCapabilities?: (capabilities: readonly RuntimeCapability[]) => void pairing?: PairingRpcContext sendBinary?: (bytes: Uint8Array) => boolean | void registerBinaryStreamHandler?: ( diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index 122e18f078a..c3febab1d62 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -29,8 +29,7 @@ import { RpcStreamingDispatcher } from './rpc-streaming-dispatcher' export type DispatcherOptions = { runtime: OrcaRuntimeService; methods?: readonly RpcAnyMethod[] } -// oxfmt-ignore -type DispatchCallOptions = Pick +type DispatchCallOptions = RpcDispatchStreamingOptions export class RpcDispatcher { private readonly runtime: OrcaRuntimeService @@ -131,6 +130,7 @@ export class RpcDispatcher { clientId: options?.clientId, clientKind: options?.clientKind, clientCapabilities: options?.clientCapabilities, + updateClientCapabilities: options?.updateClientCapabilities, orchestrationCapability: request.orchestrationCapability, authenticatedCallerFingerprint: mutation?.identity.callerFingerprint ?? diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 3bfb7303bd0..34596835294 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -31,6 +31,7 @@ describe('client UI RPC methods', () => { visibleTaskProviders: ['github', 'gitlab'], defaultRepoSelection: ['repo-1'], defaultLinearTeamSelection: ['team-1'], + experimentalStructuredNativeChat: true, compactWorktreeCards: true, minimaxGroupId: 'group-42', minimaxUsageModels: 'general,abab6.5', @@ -60,6 +61,24 @@ describe('client UI RPC methods', () => { expect(response).toMatchObject({ ok: true, result: { settings } }) }) + it('rejects paired attempts to mutate the host-owned structured chat setting', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateClientSettings: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('settings.update', { experimentalStructuredNativeChat: true }) + ) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'invalid_argument' } + }) + expect(runtime.updateClientSettings).not.toHaveBeenCalled() + }) + it('persists the runtime host task source settings for mobile Tasks', async () => { const settings = { defaultTuiAgent: null, diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 1bdaf224397..ba77b94803e 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -38,6 +38,7 @@ import { PLUGIN_METHODS } from './plugins' import { SKILL_METHODS } from './skills' import { CLIPBOARD_METHODS } from './clipboard' import { HOST_CAPABILITY_METHODS } from './host-capabilities' +import { RUNTIME_CLIENT_CAPABILITY_METHODS } from './runtime-client-capabilities' import { EMULATOR_METHODS } from './emulator' import { PAIRING_METHODS } from './pairing' import { UPDATER_METHODS } from './updater' @@ -91,6 +92,7 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...SKILL_METHODS, ...CLIPBOARD_METHODS, ...HOST_CAPABILITY_METHODS, + ...RUNTIME_CLIENT_CAPABILITY_METHODS, ...CLIENT_EVENT_METHODS, ...CLIENT_UI_METHODS, ...EMULATOR_METHODS, diff --git a/src/main/runtime/rpc/methods/runtime-client-capabilities.test.ts b/src/main/runtime/rpc/methods/runtime-client-capabilities.test.ts new file mode 100644 index 00000000000..c0fb2f250bd --- /dev/null +++ b/src/main/runtime/rpc/methods/runtime-client-capabilities.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcRequest } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { RUNTIME_CLIENT_CAPABILITY_METHODS } from './runtime-client-capabilities' + +function makeRequest(params: unknown): RpcRequest { + return { + id: 'req-1', + authToken: 'tok', + method: 'runtime.clientCapabilities.update', + params + } +} + +function dispatcher(): RpcDispatcher { + return new RpcDispatcher({ + runtime: { getRuntimeId: () => 'runtime-1' } as unknown as OrcaRuntimeService, + methods: RUNTIME_CLIENT_CAPABILITY_METHODS + }) +} + +describe('runtime.clientCapabilities.update', () => { + it('updates the authenticated socket capability set after auth', async () => { + const updateClientCapabilities = vi.fn() + + const response = await dispatcher().dispatch( + makeRequest({ + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + }), + { clientKind: 'mobile', updateClientCapabilities } + ) + + expect(response).toMatchObject({ + ok: true, + result: { clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] } + }) + expect(updateClientCapabilities).toHaveBeenCalledWith([ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ]) + }) + + it('rejects malformed upgrades without mutating authenticated state', async () => { + const updateClientCapabilities = vi.fn() + + const response = await dispatcher().dispatch( + makeRequest({ + clientCapabilities: [42] + }), + { clientKind: 'mobile', updateClientCapabilities } + ) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'invalid_argument' } + }) + expect(updateClientCapabilities).not.toHaveBeenCalled() + }) + + it('fails closed when a transport has no post-auth updater', async () => { + const response = await dispatcher().dispatch( + makeRequest({ + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + }), + { clientKind: 'runtime' } + ) + + expect(response).toMatchObject({ + ok: false, + error: { message: 'client_capabilities_update_unsupported' } + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/runtime-client-capabilities.ts b/src/main/runtime/rpc/methods/runtime-client-capabilities.ts new file mode 100644 index 00000000000..a1ab53267b3 --- /dev/null +++ b/src/main/runtime/rpc/methods/runtime-client-capabilities.ts @@ -0,0 +1,24 @@ +import { z } from 'zod' +import type { RuntimeCapability } from '../../../../shared/protocol-version' +import { defineMethod, type RpcAnyMethod } from '../core' + +const ClientCapabilitiesUpdate = z + .object({ + clientCapabilities: z.array(z.string().min(1).max(128)).max(64) + }) + .strict() + +export const RUNTIME_CLIENT_CAPABILITY_METHODS: RpcAnyMethod[] = [ + defineMethod({ + name: 'runtime.clientCapabilities.update', + params: ClientCapabilitiesUpdate, + handler: (params, { updateClientCapabilities }) => { + if (!updateClientCapabilities) { + throw new Error('client_capabilities_update_unsupported') + } + const clientCapabilities = params.clientCapabilities as RuntimeCapability[] + updateClientCapabilities(clientCapabilities) + return { clientCapabilities } + } + }) +] diff --git a/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts b/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts index 03f7ddb50b4..1021bf3a48e 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts @@ -75,6 +75,48 @@ describe('session tab structured capability mutations', () => { expect(fixture.calls[method.runtimeMethod]).toHaveBeenCalledOnce() }) } + + it.each(['session.tabs.close', 'session.tabs.closeLifecycle'] as const)( + 'allows capable mobile clients to close structured tabs when the experiment is enabled (%s)', + async (method) => { + const snapshot = agentSnapshot() + const closeMobileSessionTab = vi.fn().mockResolvedValue({ closed: true }) + const runtime = { + getRuntimeId: () => 'test-runtime', + getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: true })), + listMobileSessionTabs: vi.fn().mockResolvedValue(snapshot), + closeMobileSessionTab + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const replies: string[] = [] + await dispatcher.dispatchStreaming( + { + id: 'request-1', + authToken: 'token', + method, + params: + method === 'session.tabs.close' + ? { worktree: 'id:wt-1', tabId: 'codex-session', reason: 'user' } + : { + worktree: 'id:wt-1', + tabId: 'codex-session', + reason: 'cleanup', + publicationEpoch: 'epoch-1', + terminal: 'pty-1' + } + }, + (response) => replies.push(response), + { + clientKind: 'mobile', + pairedDeviceId: 'paired-mobile', + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + } + ) + + expect(JSON.parse(replies[0]!).ok).toBe(true) + expect(closeMobileSessionTab).toHaveBeenCalledOnce() + } + ) }) function createFixture(capabilities: RuntimeCapability[]) { diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts index bc3d96da79b..f0a6ac4c5b0 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts @@ -96,6 +96,22 @@ describe('projectSessionTabAgentStatus', () => { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY ]) ).toEqual(oldClient) + expect( + projectSessionTabAgentStatus( + snapshot, + 'mobile', + [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + false + ) + ).toEqual(oldClient) + + const capableMobile = projectSessionTabAgentStatus( + snapshot, + 'mobile', + [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + true + ) + expect(capableMobile).toBe(snapshot) const capable = projectSessionTabAgentStatus(snapshot, 'runtime', [ STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY @@ -133,6 +149,14 @@ describe('projectSessionTabAgentStatus', () => { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY ]).tabs.map((tab) => tab.id) ).toEqual(['agent-session:codex', 'agent-session:claude']) + expect( + projectSessionTabAgentStatus( + snapshot, + 'mobile', + [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + true + ).tabs.map((tab) => tab.id) + ).toEqual(['agent-session:codex', 'agent-session:claude']) }) it('withholds session boundaries from legacy paired clients', () => { diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts index f786f817ada..93d93ceba41 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts @@ -1,6 +1,5 @@ import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../../../shared/protocol-version' import type { @@ -9,18 +8,21 @@ import type { RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-types' import type { TabGroupLayoutNode } from '../../../../shared/tab-types' +import { structuredNativeChatProjectionEnabled } from './structured-agent-session-policy' type SessionTabsPayload = RuntimeMobileSessionTabsResult | RuntimeMobileSessionTabsSnapshot export function projectSessionTabAgentStatus( payload: TPayload, clientKind: 'mobile' | 'runtime' | undefined, - clientCapabilities: readonly RuntimeCapability[] | undefined + clientCapabilities: readonly RuntimeCapability[] | undefined, + structuredNativeChatEnabled?: boolean ): TPayload { - const structuredVisible = - clientKind !== 'mobile' && - (clientKind === undefined || - (clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) ?? false)) + const structuredVisible = structuredNativeChatProjectionEnabled({ + clientKind, + clientCapabilities, + structuredNativeChatEnabled + }) let projected = structuredVisible ? payload : projectAgentSessionTabsOut(payload, () => true) // Why: only paired runtimes have legacy `done` completion side effects; mobile must keep its row without changing the exact v2 auth shape. if ( diff --git a/src/main/runtime/rpc/methods/session-tab-close-methods.ts b/src/main/runtime/rpc/methods/session-tab-close-methods.ts index 50e56144f29..bd60ecd6ddf 100644 --- a/src/main/runtime/rpc/methods/session-tab-close-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-close-methods.ts @@ -4,6 +4,7 @@ import { defineMethod, type RpcAnyMethod } from '../core' import { CloseLifecycleTab, CloseTab } from './session-tabs-schemas' import { assertProjectedSessionTabVisible } from './session-tab-browser-placement-projection' import { projectSessionTabsForClient } from './session-tabs-inventory' +import { isStructuredNativeChatEnabled } from './structured-agent-session-policy' export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [ defineMethod({ @@ -14,7 +15,10 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [ const visible = projectSessionTabsForClient( await context.runtime.listMobileSessionTabs(params.worktree, context.pairedDeviceId), context.clientKind, - context.clientCapabilities + context.clientCapabilities, + context.clientKind === 'mobile' + ? isStructuredNativeChatEnabled(context.runtime) + : undefined ) assertProjectedSessionTabVisible(visible, params.tabId) } @@ -80,7 +84,10 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [ const visible = projectSessionTabsForClient( await context.runtime.listMobileSessionTabs(params.worktree, context.pairedDeviceId), context.clientKind, - context.clientCapabilities + context.clientCapabilities, + context.clientKind === 'mobile' + ? isStructuredNativeChatEnabled(context.runtime) + : undefined ) assertProjectedSessionTabVisible(visible, params.tabId) } diff --git a/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts b/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts index 6b9e953e4e3..ba7c41000d0 100644 --- a/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts @@ -6,6 +6,7 @@ import { translateProjectedSessionTabMove } from './session-tab-browser-placement-projection' import { projectSessionTabsForClient } from './session-tabs-inventory' +import { isStructuredNativeChatEnabled } from './structured-agent-session-policy' import { ActivateTab, MoveTab, SetTabProps, UpdatePaneLayout } from './session-tabs-schemas' export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [ @@ -17,7 +18,8 @@ export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [ const visible = projectSessionTabsForClient( await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId), clientKind, - clientCapabilities + clientCapabilities, + clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined ) assertProjectedSessionTabVisible(visible, params.tabId) } @@ -36,7 +38,12 @@ export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [ }) } ) - return projectSessionTabsForMutationClient(result, clientKind, clientCapabilities) + return projectSessionTabsForMutationClient( + result, + clientKind, + clientCapabilities, + clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined + ) } }), defineMethod({ @@ -46,7 +53,12 @@ export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [ let translated: Parameters[2] = params if (clientKind) { const raw = await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId) - const projected = projectSessionTabsForClient(raw, clientKind, clientCapabilities) + const projected = projectSessionTabsForClient( + raw, + clientKind, + clientCapabilities, + clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined + ) translated = translateProjectedSessionTabMove(raw, projected, params) } const base = { tabId: translated.tabId, targetGroupId: translated.targetGroupId } @@ -129,7 +141,8 @@ async function assertVisibleMutationTab( const visible = projectSessionTabsForClient( await runtime.listMobileSessionTabs(worktree, pairedDeviceId), clientKind, - clientCapabilities + clientCapabilities, + clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined ) assertProjectedSessionTabVisible(visible, tabId) } diff --git a/src/main/runtime/rpc/methods/session-tabs-inventory.ts b/src/main/runtime/rpc/methods/session-tabs-inventory.ts index fba9a460e86..5ab29ae51b5 100644 --- a/src/main/runtime/rpc/methods/session-tabs-inventory.ts +++ b/src/main/runtime/rpc/methods/session-tabs-inventory.ts @@ -4,6 +4,7 @@ import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime- import type { RpcContext } from '../core' import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection' import { projectSessionTabBrowserPlacements } from './session-tab-browser-placement-projection' +import { isStructuredNativeChatEnabled } from './structured-agent-session-policy' type SessionTabsInventory = { snapshots: RuntimeMobileSessionTabsResult[] @@ -26,21 +27,38 @@ function clientUnderstandsAuthoritativeInventory(context: RpcContext): boolean { export function projectSessionTabsForClient( snapshot: RuntimeMobileSessionTabsResult, clientKind: 'mobile' | 'runtime' | undefined, - clientCapabilities: Parameters[2] + clientCapabilities: Parameters[2], + structuredNativeChatEnabled?: boolean ): RuntimeMobileSessionTabsResult { return projectSessionTabBrowserPlacements( - projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities), + projectSessionTabAgentStatus( + snapshot, + clientKind, + clientCapabilities, + structuredNativeChatEnabled + ), clientCapabilities ) } +function structuredNativeChatEnabledForContext(context: RpcContext): boolean | undefined { + return context.clientKind === 'mobile' + ? isStructuredNativeChatEnabled(context.runtime) + : undefined +} + function projectInventory( inventory: SessionTabsInventory, context: RpcContext ): SessionTabsInventory { return { snapshots: inventory.snapshots.map((snapshot) => - projectSessionTabsForClient(snapshot, context.clientKind, context.clientCapabilities) + projectSessionTabsForClient( + snapshot, + context.clientKind, + context.clientCapabilities, + structuredNativeChatEnabledForContext(context) + ) ), ...(inventory.authoritative && clientUnderstandsAuthoritativeInventory(context) ? { authoritative: true as const } @@ -109,7 +127,8 @@ export async function subscribeSessionTabsInventory( projectSessionTabsForClient( snapshot, context.clientKind, - context.clientCapabilities + context.clientCapabilities, + structuredNativeChatEnabledForContext(context) ) as SessionTabsChange const withoutNavigationIntent = (snapshot: SessionTabsChange): SessionTabsChange => { if (snapshot.navigationIntent === undefined) { diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index be61fc55edf..29131869fa0 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' import type { OrcaRuntimeService } from '../../orca-runtime' -import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' import { SESSION_TAB_METHODS } from './session-tabs' function makeRequest(method: string, params?: unknown): RpcRequest { @@ -10,6 +13,48 @@ function makeRequest(method: string, params?: unknown): RpcRequest { } describe('session tab RPC methods', () => { + it('does not restore structured tabs for mobile while the host setting is off', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: false })), + restoreStructuredAgentSessionTabs: vi.fn(), + listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot()) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('session.tabs.list', { worktree: 'id:wt-1' }), + { + clientKind: 'mobile', + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + } + ) + + expect(response.ok).toBe(true) + expect(runtime.restoreStructuredAgentSessionTabs).not.toHaveBeenCalled() + }) + + it('restores structured tabs for mobile only after capability and setting are present', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: true })), + restoreStructuredAgentSessionTabs: vi.fn(), + listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot()) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('session.tabs.list', { worktree: 'id:wt-1' }), + { + clientKind: 'mobile', + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + } + ) + + expect(response.ok).toBe(true) + expect(runtime.restoreStructuredAgentSessionTabs).toHaveBeenCalledTimes(1) + }) + it('routes mobile-only activation without notifying desktop clients', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 3a322e33ed7..34d50a2a76b 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -15,6 +15,7 @@ import { import { SESSION_TAB_MARKDOWN_METHODS } from './session-tab-markdown-methods' import { SESSION_TAB_MUTATION_METHODS } from './session-tab-mutation-methods' import { restoreStructuredTabsIfSupported } from './structured-session-tab-restore' +import { isStructuredNativeChatEnabled } from './structured-agent-session-policy' import { assertLegacyAiVaultResumeCommandAllowed } from '../../../ai-vault/structured-session-ownership' export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ @@ -22,11 +23,12 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ name: 'session.tabs.list', params: WorktreeTabSelector, handler: async (params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => { - await restoreStructuredTabsIfSupported(runtime, clientCapabilities) + await restoreStructuredTabsIfSupported({ runtime, clientKind, clientCapabilities }) return projectSessionTabsForClient( await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId), clientKind, - clientCapabilities + clientCapabilities, + clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined ) } }), @@ -34,7 +36,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ name: 'session.tabs.listAll', params: null, handler: async (_params, context) => { - await restoreStructuredTabsIfSupported(context.runtime, context.clientCapabilities) + await restoreStructuredTabsIfSupported(context) return listSessionTabsInventory(context) } }), @@ -89,7 +91,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ let unsubscribe = (): void => {} let closed = false let initialized = false - await restoreStructuredTabsIfSupported(runtime, clientCapabilities) + await restoreStructuredTabsIfSupported({ runtime, clientKind, clientCapabilities }) const initial = await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId) if (closed) { return @@ -115,7 +117,12 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ } emit({ type: 'snapshot', - ...projectSessionTabsForClient(initial, clientKind, clientCapabilities) + ...projectSessionTabsForClient( + initial, + clientKind, + clientCapabilities, + clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined + ) }) initialized = true if (closed) { @@ -126,7 +133,12 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ if (snapshot.worktree === subscribedWorktree) { emit({ type: 'updated', - ...projectSessionTabsForClient(snapshot, clientKind, clientCapabilities) + ...projectSessionTabsForClient( + snapshot, + clientKind, + clientCapabilities, + clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined + ) }) } }, pairedDeviceId) @@ -157,7 +169,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ name: 'session.tabs.subscribeAll', params: null, handler: async (_params, context, emit) => { - await restoreStructuredTabsIfSupported(context.runtime, context.clientCapabilities) + await restoreStructuredTabsIfSupported(context) return subscribeSessionTabsInventory(context, emit) } }), diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts index 2dd317e08b0..33018c21f4d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts @@ -5,21 +5,18 @@ // handed a session it cannot render or drive — and, just as importantly, cannot make the host EXIST // by calling into it, which is an observable side effect. -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' import type { RpcContext } from '../core' +import { supportsStructuredAgentSessions } from './structured-agent-session-policy' /** * In-process callers are the same build as the host, so they carry no negotiated * capability list; every remote client must say it can read structured sessions. */ export function supportsStructuredSessions(ctx: RpcContext): boolean { - return ( - ctx.clientKind === undefined || - (ctx.clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) ?? false) - ) + return supportsStructuredAgentSessions(ctx) } export function requireStructuredCapability(ctx: RpcContext): void { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts new file mode 100644 index 00000000000..4fe38474ec6 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts @@ -0,0 +1,47 @@ +import { + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + type RuntimeCapability +} from '../../../../shared/protocol-version' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcContext } from '../core' + +type StructuredPolicyContext = Pick & { + runtime?: Pick + structuredNativeChatEnabled?: boolean +} + +export function isStructuredNativeChatEnabled( + runtime: Pick +): boolean { + try { + return runtime.getClientSettings().experimentalStructuredNativeChat === true + } catch { + return false + } +} + +export function supportsStructuredAgentSessions(context: StructuredPolicyContext): boolean { + if (context.clientKind === undefined) { + return true + } + const hasCapability = + context.clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) === true + if (!hasCapability) { + return false + } + if (context.clientKind !== 'mobile') { + return true + } + return ( + context.structuredNativeChatEnabled === true || + (context.runtime ? isStructuredNativeChatEnabled(context.runtime) : false) + ) +} + +export function structuredNativeChatProjectionEnabled(args: { + clientKind: 'mobile' | 'runtime' | undefined + clientCapabilities: readonly RuntimeCapability[] | undefined + structuredNativeChatEnabled?: boolean +}): boolean { + return supportsStructuredAgentSessions(args) +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index bc6f7473a43..155d0aa6768 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -127,7 +127,7 @@ function hostStub(): StructuredAgentSessionHost { return hostCalls as unknown as StructuredAgentSessionHost } -function dispatcher(): RpcDispatcher { +function dispatcher(runtimeOverrides: Record = {}): RpcDispatcher { runtimeCalls = { getStructuredAgentSessionCreateSupport: vi.fn(async () => ({ supported: true })), resolveStructuredAgentSessionCreateIntent: vi.fn(async (params) => ({ @@ -153,7 +153,8 @@ function dispatcher(): RpcDispatcher { registerSubscriptionCleanup: vi.fn(), cleanupSubscription: vi.fn(), cleanupSubscriptionsByPrefix: vi.fn(), - ...runtimeCalls + ...runtimeCalls, + ...runtimeOverrides } return new RpcDispatcher({ runtime: runtime as unknown as OrcaRuntimeService, @@ -170,10 +171,11 @@ async function call( clientId?: string clientKind?: 'mobile' | 'runtime' clientCapabilities?: string[] - } + }, + runtimeOverrides: Record = {} ): Promise { const replies: RpcResponse[] = [] - await dispatcher().dispatchStreaming( + await dispatcher(runtimeOverrides).dispatchStreaming( request(method, params), (raw) => replies.push(JSON.parse(raw) as RpcResponse), client @@ -189,6 +191,10 @@ const STRUCTURED_CLIENT = { clientKind: 'runtime' as const, clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] } +const STRUCTURED_MOBILE_CLIENT = { + clientKind: 'mobile' as const, + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] +} beforeEach(() => { setStructuredAgentSessionHost(hostStub()) @@ -205,6 +211,18 @@ describe('capability gating', () => { expect(response).toMatchObject({ ok: true, result: { ok: true } }) expect(hostCalls.close).toHaveBeenCalledWith(SESSION) expect(hostCalls.setSessionTabVisibility).toHaveBeenCalledWith(SESSION, false) + expect(hostCalls.setSessionTabVisibility.mock.invocationCallOrder[0]).toBeLessThan( + hostCalls.close.mock.invocationCallOrder[0]! + ) + }) + + it('does not stop the provider when durable tab retirement fails', async () => { + hostCalls.setSessionTabVisibility.mockRejectedValueOnce(new Error('visibility write failed')) + + const response = await call('agentSession.close', { sessionId: SESSION }, STRUCTURED_CLIENT) + + expect(response).toMatchObject({ ok: false }) + expect(hostCalls.close).not.toHaveBeenCalled() }) it('advertises the capability without bumping the protocol version', () => { @@ -269,6 +287,25 @@ describe('capability gating', () => { expect(hostCalls.send).toHaveBeenCalledTimes(1) }) + it('requires the host structured-chat setting for mobile clients', async () => { + const response = await call('agentSession.send', sendParams(), STRUCTURED_MOBILE_CLIENT, { + getClientSettings: () => ({ experimentalStructuredNativeChat: false }) + }) + expect(response).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('structured_agent_session_unsupported') } + }) + expect(hostCalls.send).not.toHaveBeenCalled() + }) + + it('serves mobile clients only after capability and setting negotiation', async () => { + const response = await call('agentSession.send', sendParams(), STRUCTURED_MOBILE_CLIENT, { + getClientSettings: () => ({ experimentalStructuredNativeChat: true }) + }) + expect(response).toMatchObject({ ok: true }) + expect(hostCalls.send).toHaveBeenCalledTimes(1) + }) + it('serves an in-process caller, which negotiates no capabilities at all', async () => { const response = await call('agentSession.send', sendParams()) expect(response).toMatchObject({ ok: true }) @@ -354,6 +391,37 @@ describe('method routing', () => { ) }) + it('reports an unknown create outcome when attach commits before tab publication fails', async () => { + const worktree = 'id:workspace-1' + const params = { + envelope: envelope({ + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId: SESSION, + fields: { worktree, agent: 'codex' } + }) + }), + worktree, + agent: 'codex' + } + + const response = await call('agentSession.create', params, STRUCTURED_CLIENT, { + publishStructuredAgentSessionTab: vi.fn(async () => { + throw new Error('publish failed') + }) + }) + + expect(hostCalls.attach).toHaveBeenCalledOnce() + expect(response).toMatchObject({ + ok: true, + result: { + ok: false, + refusal: { code: 'agent_session_operation_unknown' } + } + }) + }) + it('separates create from ensure by the fence the client may declare', async () => { const created = await call('agentSession.create', attachParams()) expect(created).toMatchObject({ ok: true }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index d4f71eab1ee..3b18f6b0ef1 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -121,12 +121,23 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ } const result = await requireHost(ctx).attach(callerFor(ctx), attachParams) if (result.ok) { - await ctx.runtime.publishStructuredAgentSessionTab({ - workspaceId: resolved.location.workspaceId, - sessionId: result.value.sessionId, - agent: resolved.agent as 'claude' | 'codex', - activate: true - }) + try { + await ctx.runtime.publishStructuredAgentSessionTab({ + workspaceId: resolved.location.workspaceId, + sessionId: result.value.sessionId, + agent: resolved.agent as 'claude' | 'codex', + activate: true + }) + } catch (error) { + console.warn('[agent-session] create committed before tab publication failed', error) + return { + ok: false, + refusal: { + code: 'agent_session_operation_unknown', + message: 'The chat may have been created, but its tab could not be confirmed.' + } + } + } } return result } @@ -155,11 +166,11 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ params: OptionsParams, handler: async (params, ctx) => { const host = requireHost(ctx) - await host.close(params.sessionId) // Terminal-disposal closes use this RPC without the session-tabs retirement RPC. if (typeof host.setSessionTabVisibility === 'function') { await host.setSessionTabVisibility(params.sessionId, false) } + await host.close(params.sessionId) return { ok: true as const } } }), diff --git a/src/main/runtime/rpc/methods/structured-session-tab-restore.ts b/src/main/runtime/rpc/methods/structured-session-tab-restore.ts index c1f265cc4c8..4333713a445 100644 --- a/src/main/runtime/rpc/methods/structured-session-tab-restore.ts +++ b/src/main/runtime/rpc/methods/structured-session-tab-restore.ts @@ -1,11 +1,13 @@ -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { RpcContext } from '../core' +import { supportsStructuredAgentSessions } from './structured-agent-session-policy' export async function restoreStructuredTabsIfSupported( - runtime: RpcContext['runtime'], - capabilities: readonly string[] | undefined + context: Pick ): Promise { - if (capabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)) { - await runtime.restoreStructuredAgentSessionTabs() + if ( + supportsStructuredAgentSessions(context) && + typeof context.runtime.restoreStructuredAgentSessionTabs === 'function' + ) { + await context.runtime.restoreStructuredAgentSessionTabs() } } diff --git a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts index 75938d6573c..6eddcb748be 100644 --- a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts +++ b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts @@ -113,6 +113,7 @@ export class RpcStreamingDispatcher { pairedDeviceId: options?.pairedDeviceId, clientKind: options?.clientKind, clientCapabilities: options?.clientCapabilities, + updateClientCapabilities: options?.updateClientCapabilities, orchestrationCapability: request.orchestrationCapability, authenticatedCallerFingerprint: mutation?.identity.callerFingerprint ?? @@ -165,6 +166,7 @@ export class RpcStreamingDispatcher { pairedDeviceId: options?.pairedDeviceId, clientKind: options?.clientKind, clientCapabilities: options?.clientCapabilities, + updateClientCapabilities: options?.updateClientCapabilities, orchestrationCapability: request.orchestrationCapability, pairing: options?.pairing, sendBinary: options?.sendBinary, diff --git a/src/main/runtime/runtime-client-settings.ts b/src/main/runtime/runtime-client-settings.ts index 41a251ce649..b9e6959c8da 100644 --- a/src/main/runtime/runtime-client-settings.ts +++ b/src/main/runtime/runtime-client-settings.ts @@ -32,6 +32,7 @@ export type RuntimeClientSettings = Pick< | 'defaultLinearTeamSelection' | 'githubProjects' | 'experimentalNewWorktreeCardStyle' + | 'experimentalStructuredNativeChat' | 'compactWorktreeCards' | 'minimaxGroupId' | 'minimaxUsageModels' @@ -97,6 +98,7 @@ export class RuntimeClientSettingsController { defaultLinearTeamSelection: settings.defaultLinearTeamSelection ?? null, githubProjects: settings.githubProjects, experimentalNewWorktreeCardStyle: settings.experimentalNewWorktreeCardStyle === true, + experimentalStructuredNativeChat: settings.experimentalStructuredNativeChat === true, compactWorktreeCards: settings.compactWorktreeCards === true, minimaxGroupId: settings.minimaxGroupId ?? '', minimaxUsageModels: settings.minimaxUsageModels ?? 'general', diff --git a/src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts b/src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts index 6df19517d64..4913b31bd7d 100644 --- a/src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts +++ b/src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts @@ -44,7 +44,8 @@ function queries( listResolved: async () => [], resolveRepo: async () => repo, selectRepos: () => [repo], - scanRepo: async () => ({ ok, worktrees: [...worktrees] }) + scanRepo: async () => ({ ok, worktrees: [...worktrees] }), + listKnownHostIds: () => [] }) } diff --git a/src/main/runtime/runtime-managed-worktree-queries.test.ts b/src/main/runtime/runtime-managed-worktree-queries.test.ts index 354b6653324..01df53cbd1d 100644 --- a/src/main/runtime/runtime-managed-worktree-queries.test.ts +++ b/src/main/runtime/runtime-managed-worktree-queries.test.ts @@ -46,7 +46,8 @@ function queries(store: RuntimeStore): RuntimeManagedWorktreeQueries { listResolved: async () => [], resolveRepo: async () => store.getRepos()[0]!, selectRepos: () => store.getRepos(), - scanRepo: async () => ({ ok: true, worktrees: [] }) + scanRepo: async () => ({ ok: true, worktrees: [] }), + listKnownHostIds: () => [] }) } diff --git a/src/main/runtime/runtime-managed-worktree-queries.ts b/src/main/runtime/runtime-managed-worktree-queries.ts index b0ed2bc4a3b..5a5812236af 100644 --- a/src/main/runtime/runtime-managed-worktree-queries.ts +++ b/src/main/runtime/runtime-managed-worktree-queries.ts @@ -1,7 +1,8 @@ import type { DetectedWorktreeListResult, Worktree } from '../../shared/worktree/types' import type { Repo } from '../../shared/repo-types' import type { RuntimeWorktreeListResult } from '../../shared/runtime-types' -import { getRepoExecutionHostId } from '../../shared/execution-host' +import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' +import { buildWorktreeListingPage } from './worktree-listing-host-scope' import { readWorktreeMetaForHost } from '../persistence/host-qualified-worktree-meta' import { getRepoOwnedWorktreeMeta } from '../worktree-metadata-ownership' import type { WorktreeMeta } from '../../shared/worktree/meta-types' @@ -38,6 +39,8 @@ type Dependencies = { resolveRepo(selector: string): Promise selectRepos(selector: string): Repo[] scanRepo(repo: Repo): Promise + /** Hosts this runtime has repos or workspaces on, so a host with no rows is still named. */ + listKnownHostIds(): Iterable } /** @@ -100,11 +103,9 @@ export class RuntimeManagedWorktreeQueries { (!repoId || worktree.repoId === repoId) && this.isVisible(worktree, matchers.get(worktree.repoId), sourceDefaultsSupported) ) - return { - worktrees: worktrees.slice(0, limit), - totalCount: worktrees.length, - truncated: worktrees.length > limit - } + // Why: a `--repo` listing was scoped by the caller, so naming every configured host as + // omitted would report a gap the caller deliberately excluded. + return buildWorktreeListingPage(worktrees, limit, repoId ? [] : this.deps.listKnownHostIds()) } resolveRepoForConnection(selector: string, connectionId?: string | null): Promise { diff --git a/src/main/runtime/runtime-registered-remote-worktree-removal.ts b/src/main/runtime/runtime-registered-remote-worktree-removal.ts index 09c7ac362f4..6eec18d52c8 100644 --- a/src/main/runtime/runtime-registered-remote-worktree-removal.ts +++ b/src/main/runtime/runtime-registered-remote-worktree-removal.ts @@ -13,6 +13,8 @@ export async function removeRuntimeRegisteredRemoteWorktree(args: { removedPushTarget: GitPushTarget | undefined store: RuntimeStore provider: SshGitProvider + /** From the resolved removal route; `repo.connectionId!` answered null for an `ssh:`-only row. */ + connectionId: string force: boolean allowUnverifiedPtyStop: boolean deleteBranch: boolean @@ -28,8 +30,7 @@ export async function removeRuntimeRegisteredRemoteWorktree(args: { ) => RemoveWorktreeResult finishRemoval: (result: RemoveWorktreeResult) => void }): Promise { - const { repo, target, registeredWorktree, provider } = args - const connectionId = repo.connectionId! + const { repo, target, registeredWorktree, provider, connectionId } = args const removeOptions = !args.deleteBranch ? { deleteBranch: args.deleteBranch } : {} const gate = await args.acquireWatcherRemoval(registeredWorktree.path, connectionId) let rawResult: RemoveWorktreeResult | undefined diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts index 57f5a61af2f..767ca885234 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts @@ -188,6 +188,7 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'repo.searchRefs', 'repo.sparsePresets', 'repo.update', + 'runtime.clientCapabilities.update', 'runtime.clientEvents.subscribe', 'runtime.clientEvents.unsubscribe', 'session.tabs.activate', @@ -201,6 +202,22 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'session.tabs.subscribeAll', 'session.tabs.unsubscribe', 'session.tabs.unsubscribeAll', + 'agentSession.createSupport', + 'agentSession.create', + 'agentSession.ensure', + 'agentSession.send', + 'agentSession.cancel', + 'agentSession.close', + 'agentSession.respondToApproval', + 'agentSession.respondToQuestion', + 'agentSession.setOption', + 'agentSession.handoffStatus', + 'agentSession.options', + 'agentSession.history', + 'agentSession.subscribe', + 'agentSession.unsubscribe', + 'agentSession.hold', + 'agentSession.release', 'nativeChat.readSession', 'nativeChat.subscribe', 'nativeChat.unsubscribe', diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts index 86732e7430c..dd714b77528 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts @@ -141,6 +141,12 @@ export class RuntimeRpcWebSocketDispatch extends RuntimeRpcRequestAdmission { // Why: gates the mobile-only payload diet so full-screen web/desktop clients aren't truncated. clientKind: device.scope, clientCapabilities: authenticatedSocket?.clientCapabilities, + updateClientCapabilities: + authenticatedSocket && device.scope === 'mobile' + ? (clientCapabilities) => { + authenticatedSocket.clientCapabilities = clientCapabilities + } + : undefined, pairing: pairingContext, signal: abortRegistration?.signal, sendBinary, diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 649a8ac49b7..6b9858bda0c 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -87,6 +87,7 @@ export type RuntimeStore = { terminalWindowsShell?: GlobalSettings['terminalWindowsShell'] floatingTerminalEnabled?: GlobalSettings['floatingTerminalEnabled'] agentStatusHooksEnabled?: GlobalSettings['agentStatusHooksEnabled'] + experimentalStructuredNativeChat?: GlobalSettings['experimentalStructuredNativeChat'] defaultTaskSource?: GlobalSettings['defaultTaskSource'] defaultTaskViewPreset?: GlobalSettings['defaultTaskViewPreset'] visibleTaskProviders?: GlobalSettings['visibleTaskProviders'] @@ -122,4 +123,5 @@ export type RuntimeStore = { updates: Partial, options?: { notifyListeners?: boolean; originWebContentsId?: number } ) => unknown + onSettingsChanged?: Store['onSettingsChanged'] } diff --git a/src/main/runtime/runtime-unregistered-worktree-removal.ts b/src/main/runtime/runtime-unregistered-worktree-removal.ts index 3d21998ef55..01de8820c1c 100644 --- a/src/main/runtime/runtime-unregistered-worktree-removal.ts +++ b/src/main/runtime/runtime-unregistered-worktree-removal.ts @@ -2,8 +2,10 @@ import type { GitPushTarget, GitWorktreeInfo } from '../../shared/worktree/types import type { Repo } from '../../shared/repo-types' import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { LocalProjectWorktreeGitOptions } from '../project-runtime-git-options' -import type { IFilesystemProvider } from '../providers/types' -import type { SshGitProvider } from '../providers/ssh-git-provider' +import { + getWorktreeRemovalConnectionId, + type WorktreeRemovalRoute +} from '../worktree-removal-execution-host-route' import { getLocalWorktreePathAccess, removeLocalWorktreePath, @@ -39,8 +41,8 @@ export async function removeRuntimeUnregisteredWorktree(args: { removedPushTarget: GitPushTarget | undefined force: boolean allowUnverifiedPtyStop: boolean - provider: SshGitProvider | null - fsProvider: IFilesystemProvider | null + /** One resolved host for the whole removal, replacing the `provider` whose `null` also meant local. */ + route: WorktreeRemovalRoute localOptions: LocalProjectWorktreeGitOptions store: RuntimeStore acquireWatcherRemoval: (path: string, connectionId?: string) => Promise @@ -48,21 +50,23 @@ export async function removeRuntimeUnregisteredWorktree(args: { deleteHistory: () => Promise finishRemoval: () => void }): Promise<{}> { - const { repo, target, registeredWorktrees, removedMeta } = args + const { repo, target, registeredWorktrees, removedMeta, route } = args let canCleanOrphanedDirectory = false if (canCleanupUnregisteredOrcaWorktreeDirectory({ meta: removedMeta })) { - if (repo.connectionId) { - if (!args.fsProvider) { + if (route.kind === 'ssh') { + const fsProvider = route.fsProvider + if (!fsProvider) { throw new Error('SSH filesystem provider unavailable') } - if (!args.fsProvider.lstat) { + const lstat = fsProvider.lstat + if (!lstat) { throw new Error('SSH filesystem provider lstat unavailable') } canCleanOrphanedDirectory = await canSafelyRemoveOrphanedWorktreeDirectory( target.path, repo.path, - (path) => args.fsProvider!.lstat!(path), - (path) => args.fsProvider!.readFile(path) + (path) => lstat(path), + (path) => fsProvider.readFile(path) ) } else { const access = getLocalWorktreePathAccess(args.localOptions) @@ -85,7 +89,7 @@ export async function removeRuntimeUnregisteredWorktree(args: { args.finishRemoval() return {} } - if (!repo.connectionId) { + if (route.kind === 'local') { const access = getLocalWorktreePathAccess(args.localOptions) const runtimeWorktreePath = toLocalWorktreeRuntimePath(target.path, args.localOptions) if ( @@ -108,7 +112,7 @@ export async function removeRuntimeUnregisteredWorktree(args: { return {} } } - if (await isRuntimeWorktreePathMissing(repo, target.path, args.localOptions)) { + if (await isRuntimeWorktreePathMissing(route.hostId, target.path, args.localOptions)) { if (!args.force && !removedMeta) { throw new Error(UNREGISTERED_MISSING_WORKTREE_MESSAGE) } @@ -123,14 +127,19 @@ export async function removeRuntimeUnregisteredWorktree(args: { async function deleteUnregisteredDirectory( args: Parameters[0] ): Promise { - const connectionId = args.repo.connectionId?.trim() || undefined + const route = args.route + const connectionId = getWorktreeRemovalConnectionId(route) const gate = await args.acquireWatcherRemoval(args.target.path, connectionId) let completed = false try { await args.stopPtys(args.target.id, connectionId, args.allowUnverifiedPtyStop) - await (connectionId - ? args.fsProvider!.deletePath(args.target.path, true) - : removeLocalWorktreePath(args.target.path, args.localOptions)) + if (route.kind === 'local') { + await removeLocalWorktreePath(args.target.path, args.localOptions) + } else if (route.fsProvider) { + await route.fsProvider.deletePath(args.target.path, true) + } else { + throw new Error('SSH filesystem provider unavailable') + } completed = true } finally { await gate.finish(completed) @@ -142,9 +151,9 @@ async function deleteUnregisteredDirectory( async function cleanupPushTarget( args: Parameters[0] ): Promise { - await (args.repo.connectionId + await (args.route.kind === 'ssh' ? cleanupUnusedWorktreePushTargetRemoteSsh( - args.provider!, + args.route.provider, args.repo.path, args.target.id, args.removedPushTarget, diff --git a/src/main/runtime/runtime-worktree-filesystem.ts b/src/main/runtime/runtime-worktree-filesystem.ts index c2c5ac711c5..4f29919ca95 100644 --- a/src/main/runtime/runtime-worktree-filesystem.ts +++ b/src/main/runtime/runtime-worktree-filesystem.ts @@ -9,9 +9,12 @@ import { getLocalWorktreePathAccess, toLocalWorktreeRuntimePath } from '../local-worktree-filesystem' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { + ExecutionHostNotDispatchableError, + resolveFilesystemRouteForHost +} from '../providers/execution-host-provider-dispatch' import { isWorktreePathMissing } from '../worktree-removal-safety' -import { getRepoExecutionHostId } from '../../shared/execution-host' +import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' import { getRepoOwnedWorktreeMeta } from '../worktree-metadata-ownership' import type { WorktreeMeta } from '../../shared/worktree/meta-types' import { @@ -22,19 +25,26 @@ import { import type { RuntimeStore } from './runtime-store-contract' import { gitStatusErrorMeansNotRepository } from './runtime-worktree-selection' +// Takes the resolved host rather than the repo: reading `repo.connectionId` answered "stat this on +// the client" for a row that names its owner only as `executionHostId: 'ssh:'`, which is +// the evidence a forced removal prunes registrations on. export async function isRuntimeWorktreePathMissing( - repo: Repo, + hostId: ExecutionHostId, worktreePath: string, localWorktreeGitOptions: { wslDistro?: string } = {} ): Promise { - if (!repo.connectionId) { + const route = resolveFilesystemRouteForHost(hostId) + if (route.kind === 'runtime') { + throw new ExecutionHostNotDispatchableError(route.hostId) + } + if (route.kind === 'local') { const access = getLocalWorktreePathAccess(localWorktreeGitOptions) return isWorktreePathMissing( toLocalWorktreeRuntimePath(worktreePath, localWorktreeGitOptions), access.statPath ) } - const fsProvider = getSshFilesystemProvider(repo.connectionId) + const fsProvider = route.provider return fsProvider ? isWorktreePathMissing(worktreePath, (path) => fsProvider.stat(path)) : false } diff --git a/src/main/runtime/worktree-list-host-scope.test.ts b/src/main/runtime/worktree-list-host-scope.test.ts new file mode 100644 index 00000000000..e497028f4c4 --- /dev/null +++ b/src/main/runtime/worktree-list-host-scope.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { Repo } from '../../shared/repo-types' +import { selectHostBalancedPage } from '../../shared/host-balanced-listing-page' +import { RuntimeManagedWorktreeQueries } from './runtime-managed-worktree-queries' +import type { ResolvedWorktree } from './runtime-worktree-path-identity' +import type { RuntimeStore } from './runtime-store-contract' + +const LOCAL_REPO: Repo = { + id: 'repo-local', + path: '/workspace/app', + displayName: 'app', + badgeColor: '#000000', + addedAt: 1 +} + +const SSH_REPO: Repo = { + ...LOCAL_REPO, + id: 'repo-ssh', + connectionId: 'box-1', + displayName: 'app (remote)' +} + +const settings = { + workspaceDir: '/worktrees', + nestWorkspaces: true, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' +} + +function worktree(repoId: string, path: string, hostId: string): ResolvedWorktree { + return { + id: `${repoId}::${path}`, + repoId, + path, + branch: 'main', + hostId, + displayName: path, + comment: '', + linkedIssue: null, + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null, + git: { path, head: 'abc', branch: 'main', isBare: false, isMainWorktree: false } + } as unknown as ResolvedWorktree +} + +/** The reproduced shape from #18104: every remote row lands contiguously at the end. */ +function fleet(localCount: number, sshCount: number): ResolvedWorktree[] { + return [ + ...Array.from({ length: localCount }, (_, index) => + worktree(LOCAL_REPO.id, `/worktrees/local-${index}`, 'local') + ), + ...Array.from({ length: sshCount }, (_, index) => + worktree(SSH_REPO.id, `/remote/wt-${index}`, 'ssh:box-1') + ) + ] +} + +function queries( + resolved: ResolvedWorktree[], + knownHostIds: ExecutionHostId[] = ['local', 'ssh:box-1'] +): RuntimeManagedWorktreeQueries { + const store = { + getRepos: () => [LOCAL_REPO, SSH_REPO], + getRepo: () => LOCAL_REPO, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + setWorktreeMeta: vi.fn(), + getAllWorktreeLineage: () => ({}), + getSettings: () => settings + } as unknown as RuntimeStore + return new RuntimeManagedWorktreeQueries({ + getStore: () => store, + listResolved: async () => resolved, + resolveRepo: async () => SSH_REPO, + selectRepos: () => [SSH_REPO], + scanRepo: async () => ({ ok: true, worktrees: [] }), + listKnownHostIds: () => knownHostIds + }) +} + +describe('worktree.list host coverage under the row cap', () => { + it('returns remote rows that sit entirely past the cap', async () => { + // Why #18104: 497 local + 24 SSH rows, SSH at indices 496-520, and a 200-row cap returned + // `{local: 200}` — zero of 24 remote worktrees, with nothing saying the gap was a whole host. + const result = await queries(fleet(497, 24)).list(undefined, 200) + + expect(result.totalCount).toBe(521) + expect(result.truncated).toBe(true) + expect(result.worktrees).toHaveLength(200) + const remote = result.worktrees.filter((row) => row.hostId === 'ssh:box-1') + expect(remote).toHaveLength(24) + expect(result.hostScope).toEqual({ hostIds: ['local', 'ssh:box-1'], omittedHostIds: [] }) + }) + + it('keeps the page a subsequence of the unbounded listing', async () => { + // Why: balancing decides which rows survive the cap, never how the survivors are ordered. + const resolved = fleet(497, 24) + const result = await queries(resolved).list(undefined, 200) + + const positions = result.worktrees.map((row) => resolved.findIndex((it) => it.id === row.id)) + expect(positions).toEqual([...positions].sort((left, right) => left - right)) + }) + + it('names a configured host that contributed no rows at all', async () => { + // Why: a repo whose scan failed contributes zero rows exactly like a host with no worktrees. + // docs/reference/ssh-execution-boundary.md forbids the listing from reading as absolute there. + const result = await queries(fleet(3, 0), ['local', 'ssh:box-1', 'runtime:paired']).list( + undefined, + 200 + ) + + expect(result.hostScope).toEqual({ + hostIds: ['local'], + omittedHostIds: ['runtime:paired', 'ssh:box-1'] + }) + }) + + it('does not report configured hosts as omitted from a --repo listing', async () => { + // Why: the caller scoped this themselves, so naming the hosts they excluded is noise. + const result = await queries(fleet(0, 5)).list('id:repo-ssh', 200) + + expect(result.hostScope).toEqual({ hostIds: ['ssh:box-1'], omittedHostIds: [] }) + }) + + it('leaves an uncapped listing byte-identical', async () => { + const resolved = fleet(4, 2) + const result = await queries(resolved).list(undefined, 200) + + expect(result.worktrees.map((row) => row.id)).toEqual(resolved.map((row) => row.id)) + expect(result.truncated).toBe(false) + }) +}) + +describe('selectHostBalancedPage', () => { + it('gives every host a share of the cap rather than filling it from the first', () => { + const rows = [ + ...Array.from({ length: 10 }, (_, index) => ({ host: 'local', index })), + ...Array.from({ length: 10 }, (_, index) => ({ host: 'ssh:box-1', index: index + 10 })) + ] + + const page = selectHostBalancedPage(rows, 4, (row) => row.host) + + expect(page.map((row) => row.host)).toEqual(['local', 'local', 'ssh:box-1', 'ssh:box-1']) + }) + + it('fills the cap from the remaining hosts when one runs out of rows', () => { + const rows = [ + { host: 'local', id: 'a' }, + { host: 'local', id: 'b' }, + { host: 'local', id: 'c' }, + { host: 'ssh:box-1', id: 'd' } + ] + + const page = selectHostBalancedPage(rows, 3, (row) => row.host) + + expect(page.map((row) => row.id)).toEqual(['a', 'b', 'd']) + }) + + it('buckets rows with no host together instead of dropping them', () => { + const rows = [{ id: 'a' }, { id: 'b' }, { id: 'c' }] + + expect(selectHostBalancedPage(rows, 2, () => undefined).map((row) => row.id)).toEqual([ + 'a', + 'b' + ]) + }) +}) diff --git a/src/main/runtime/worktree-listing-host-scope.ts b/src/main/runtime/worktree-listing-host-scope.ts new file mode 100644 index 00000000000..99650882670 --- /dev/null +++ b/src/main/runtime/worktree-listing-host-scope.ts @@ -0,0 +1,64 @@ +import type { ExecutionHostId } from '../../shared/execution-host' +import { selectHostBalancedPage } from '../../shared/host-balanced-listing-page' +import type { RuntimeListingHostScope } from '../../shared/runtime-listing-host-scope' + +/** + * Applies a worktree listing's row cap and reports which hosts the resulting page covers. + * + * Rows are resolved repo by repo, so every SSH repo's rows land contiguously at the end of the + * fleet order: 24 remote worktrees sat at indices 496-520 of 521 and a 200-row cap returned zero + * of them (#18104). Balancing the page across hosts fixes the starvation; the scope is what makes + * the remaining gap legible, because a host with no rows in the page is otherwise indistinguishable + * from a host with no worktrees — which `docs/reference/ssh-execution-boundary.md` forbids a + * listing from implying. + */ +export function buildWorktreeListingPage( + rows: readonly TRow[], + limit: number, + knownHostIds: Iterable +): { + worktrees: TRow[] + hostScope: RuntimeListingHostScope + totalCount: number + truncated: boolean +} { + const page = selectHostBalancedPage(rows, limit, (row) => row.hostId) + return { + worktrees: page, + hostScope: buildWorktreeListingHostScope({ + pageHostIds: page.map((row) => row.hostId), + matchedHostIds: rows.map((row) => row.hostId), + knownHostIds + }), + totalCount: rows.length, + truncated: rows.length > limit + } +} + +/** + * The worktree-listing counterpart of `buildTerminalListHostScope`: names the hosts the returned + * page covers, and every host it does not — including a configured repo whose scan failed, which + * contributes zero rows exactly like a host with no worktrees. + */ +export function buildWorktreeListingHostScope(args: { + /** Hosts of the rows actually returned. */ + pageHostIds: Iterable + /** Hosts of every row that matched, including those the cap dropped. */ + matchedHostIds: Iterable + /** Hosts this runtime has configured repos or workspaces on, even if they contributed no rows. */ + knownHostIds: Iterable +}): RuntimeListingHostScope { + const covered = new Set() + for (const hostId of args.pageHostIds) { + if (hostId) { + covered.add(hostId) + } + } + const omitted = new Set() + for (const hostId of [...args.matchedHostIds, ...args.knownHostIds]) { + if (hostId && !covered.has(hostId)) { + omitted.add(hostId) + } + } + return { hostIds: [...covered].sort(), omittedHostIds: [...omitted].sort() } +} diff --git a/src/main/runtime/worktree-ps-host-scope.test.ts b/src/main/runtime/worktree-ps-host-scope.test.ts new file mode 100644 index 00000000000..cf718cd267f --- /dev/null +++ b/src/main/runtime/worktree-ps-host-scope.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const electronMocks = vi.hoisted(() => { + const ipcMain = { + on: vi.fn(() => ipcMain), + removeListener: vi.fn(() => ipcMain), + emit: vi.fn(() => true) + } + return { + BrowserWindow: { fromId: vi.fn((): unknown => null) }, + webContents: { fromId: vi.fn((): unknown => null) }, + ipcMain, + app: { getPath: vi.fn(() => '/tmp'), isPackaged: false } + } +}) +vi.mock('electron', () => electronMocks) + +const getSshGitProviderMock = vi.hoisted(() => vi.fn()) +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock, + getSshGitProviderGeneration: vi.fn(() => 0), + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable', + requireSshGitProvider: (connectionId: string) => getSshGitProviderMock(connectionId) +})) + +const listWorktreesStrictMock = vi.hoisted(() => vi.fn()) +vi.mock('../git/worktree', async (importOriginal) => ({ + ...(await importOriginal>()), + listWorktreesStrict: listWorktreesStrictMock +})) + +import { OrcaRuntimeService } from './orca-runtime' + +const LOCAL_REPO_ID = 'repo-local' +const LOCAL_REPO_PATH = '/Users/me/dev/app' +const SSH_REPO_ID = 'repo-ssh' +const SSH_REPO_PATH = '/home/user/app' +const SSH_CONNECTION_ID = 'box-1' + +function gitWorktree(path: string, isMain = false) { + return { path, head: 'abc', branch: 'main', isBare: false, isMainWorktree: isMain } +} + +/** Local rows sort ahead of the remote ones, mirroring the fleet order that starves the cap. */ +function makeStore() { + const metaById: Record = {} + return { + getRepo: (id: string) => + makeStore() + .getRepos() + .find((repo) => repo.id === id), + getRepos: () => [ + { + id: LOCAL_REPO_ID, + path: LOCAL_REPO_PATH, + displayName: 'app', + badgeColor: 'blue', + addedAt: 1 + }, + { + id: SSH_REPO_ID, + path: SSH_REPO_PATH, + displayName: 'app remote', + badgeColor: 'blue', + addedAt: 2, + connectionId: SSH_CONNECTION_ID + } + ], + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (id: string) => metaById[id], + setWorktreeMeta: (id: string, meta: Record) => { + metaById[id] = { ...(metaById[id] as object), ...meta } + return metaById[id] + }, + removeWorktreeMeta: () => {}, + getAllWorktreeLineage: () => ({}), + getAllWorkspaceLineage: () => ({}), + removeWorktreeLineage: vi.fn(), + removeWorkspaceLineage: vi.fn(), + getGitHubCache: () => undefined as never, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' + }), + getProjects: () => [] + } +} + +describe('worktree.ps host coverage', () => { + beforeEach(() => { + getSshGitProviderMock.mockReset() + listWorktreesStrictMock.mockReset() + listWorktreesStrictMock.mockResolvedValue([ + gitWorktree(LOCAL_REPO_PATH, true), + gitWorktree(`${LOCAL_REPO_PATH}-a`), + gitWorktree(`${LOCAL_REPO_PATH}-b`), + gitWorktree(`${LOCAL_REPO_PATH}-c`) + ]) + getSshGitProviderMock.mockReturnValue({ + listWorktrees: vi.fn(async () => [ + gitWorktree(SSH_REPO_PATH, true), + gitWorktree(`${SSH_REPO_PATH}-a`) + ]) + }) + }) + + it('names every host the page covers', async () => { + const runtime = new OrcaRuntimeService(makeStore() as never) + + const result = await runtime.getWorktreePs(10_000) + + expect(result.hostScope?.hostIds).toEqual(['local', `ssh:${SSH_CONNECTION_ID}`]) + expect(result.hostScope?.omittedHostIds).toEqual([]) + }) + + it('keeps a remote row in the page when the cap cannot hold every local row', async () => { + const runtime = new OrcaRuntimeService(makeStore() as never) + + const result = await runtime.getWorktreePs(2) + + expect(result.truncated).toBe(true) + expect(result.worktrees).toHaveLength(2) + expect(result.worktrees.map((worktree) => worktree.hostId)).toContain( + `ssh:${SSH_CONNECTION_ID}` + ) + expect(result.hostScope?.hostIds).toEqual(['local', `ssh:${SSH_CONNECTION_ID}`]) + }) +}) diff --git a/src/main/ssh-expired-lease-pane-readoption.test.ts b/src/main/ssh-expired-lease-pane-readoption.test.ts index 4c2c34b5f62..11bf5fe2083 100644 --- a/src/main/ssh-expired-lease-pane-readoption.test.ts +++ b/src/main/ssh-expired-lease-pane-readoption.test.ts @@ -7,6 +7,7 @@ import { resolvePersistedStablePaneOwner } from './ipc/pty/pane/stable-owner' import { adoptStablePane } from './ipc/pty/pane/adopt-stable' import { sshProviders } from './ipc/pty/provider/registry' import type { IPtyProvider } from './providers/types' +import { SSH_SESSION_EXPIRED_ERROR, SshPtyAbsentFromRelayError } from './providers/ssh-pty-errors' import { testState, createStore, makeTerminalTab } from './persistence-test-harness' import { TEST_LEAF_1 } from './persistence-session-fixtures' @@ -141,11 +142,13 @@ describe('recovery through createTerminal reattaches before it respawns', () => expect(adopted?.owner).toMatchObject({ ptyId: APP_PTY_ID, hasPersistedBinding: true }) }) + // The typed refusal is what the SSH reattach path raises; the raw `PTY "…" not found` wire text + // never reaches a pane untyped, and an untyped one no longer authorises abandoning the binding. it('falls through to a fresh spawn once the host answers that the PTY is absent', async () => { const store = storeWithBoundRemotePane() store.markSshRemotePtyLease(TARGET, APP_PTY_ID, 'expired') const spawn = vi.fn(async () => { - throw new Error('PTY "remote-pty" not found') + throw new SshPtyAbsentFromRelayError(`${SSH_SESSION_EXPIRED_ERROR}: remote-pty`) }) sshProviders.set(TARGET, { spawn } as unknown as IPtyProvider) diff --git a/src/main/ssh-reattach-pane-cardinality.test.ts b/src/main/ssh-reattach-pane-cardinality.test.ts index 5827ec1ee68..d7179703360 100644 --- a/src/main/ssh-reattach-pane-cardinality.test.ts +++ b/src/main/ssh-reattach-pane-cardinality.test.ts @@ -82,24 +82,38 @@ function relayReattachBinds( } /** - * Both binding writers land before the lease upsert — spawn asserts that ordering directly, and - * the relay's reattach binds the pane before `markSshRemotePtyLeasesAttachedAsync`. Supersession - * therefore sees a session already naming the arriving shell. + * One reconnect's worth of writes, in the order the spawn commits actually issue them: the lease + * row first — so a force-quit in the renderer's debounce window cannot leave a running remote shell + * with no lease to reattach it — then the binding, then the binding-side supersession trigger. + * + * This suite used to bind BEFORE upserting, an order no caller uses. Under that order supersession + * always saw a session already naming the arriving shell and passed; under production's order it + * bailed on the predecessor's binding every time and never re-ran, so the guard could not catch the + * per-reconnect lease growth it exists to pin. * * Goes through `persistPtyBinding` rather than `setWorkspaceSession` because that is the writer * production uses; a raw session write is reconciled back to the attached lease's PTY by binding * recovery, which would make the fixture disagree with the real flow. */ -function paneBindsTo( +function paneSpawnCommits( store: ReturnType, - args: { tabId: string; leafId: string; ptyId: string } + args: { tabId: string; leafId: string; ptyId: string; leaseTabId?: string } ): void { + store.upsertSshRemotePtyLease({ + targetId: TARGET, + ptyId: args.ptyId, + worktreeId: WORKTREE, + tabId: args.leaseTabId ?? args.tabId, + leafId: args.leafId, + state: 'attached' + }) store.persistPtyBinding({ worktreeId: WORKTREE, tabId: args.tabId, leafId: args.leafId, ptyId: args.ptyId }) + store.supersedeSshRemotePtyLeasesForBoundPane(TARGET, args.leafId) } function liveLeasePtyIds(store: ReturnType): string[] { @@ -300,8 +314,7 @@ describe('STA-3077: one pane keeps at most one live remote lease', () => { const lease = { targetId: TARGET, worktreeId: WORKTREE, tabId: TAB, leafId: TEST_LEAF_1 } store.upsertSshRemotePtyLease({ ...lease, ptyId: 'pty-1', state: 'attached' }) - paneBindsTo(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) - store.upsertSshRemotePtyLease({ ...lease, ptyId: 'pty-2', state: 'attached' }) + paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) expect(liveLeasePtyIds(store)).toEqual(['pty-2']) }) @@ -314,8 +327,7 @@ describe('STA-3077: one pane keeps at most one live remote lease', () => { const lease = { targetId: TARGET, worktreeId: WORKTREE, tabId: TAB, leafId: TEST_LEAF_1 } store.upsertSshRemotePtyLease({ ...lease, ptyId: 'pty-1', state: 'attached' }) - paneBindsTo(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) - store.upsertSshRemotePtyLease({ ...lease, ptyId: 'pty-2', state: 'attached' }) + paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) const predecessor = store.getSshRemotePtyLeases(TARGET).find((entry) => entry.ptyId === 'pty-1') expect(predecessor?.state).toBe('expired') @@ -325,11 +337,9 @@ describe('STA-3077: one pane keeps at most one live remote lease', () => { it('holds the live lease count flat across ten reconnects of one pane', async () => { const store = await createStore() store.setWorkspaceSession(sessionWithPane({ tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-0' })) - const lease = { targetId: TARGET, worktreeId: WORKTREE, tabId: TAB, leafId: TEST_LEAF_1 } for (let reconnect = 0; reconnect < 10; reconnect++) { - paneBindsTo(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: `pty-${reconnect}` }) - store.upsertSshRemotePtyLease({ ...lease, ptyId: `pty-${reconnect}`, state: 'attached' }) + paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: `pty-${reconnect}` }) } expect(liveLeasePtyIds(store)).toEqual(['pty-9']) @@ -349,17 +359,14 @@ describe('STA-3077: one pane keeps at most one live remote lease', () => { state: 'attached' }) - paneBindsTo(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) // The successor's lease names the tab the pane sits in NOW; the predecessor's still names the // one it was written in. Only the leaf is common, so keying on the tab would stop the two // competing and leave both live — the cardinality growth. - store.upsertSshRemotePtyLease({ - targetId: TARGET, - ptyId: 'pty-2', - worktreeId: WORKTREE, - tabId: OTHER_TAB, + paneSpawnCommits(store, { + tabId: TAB, leafId: TEST_LEAF_1, - state: 'attached' + ptyId: 'pty-2', + leaseTabId: OTHER_TAB }) expect(liveLeasePtyIds(store)).toEqual(['pty-2']) @@ -394,8 +401,7 @@ describe('STA-3077: one pane keeps at most one live remote lease', () => { const lease = { targetId: TARGET, worktreeId: WORKTREE, tabId: TAB, leafId: TEST_LEAF_1 } store.upsertSshRemotePtyLease({ ...lease, ptyId: 'pty-1', state: 'attached' }) - paneBindsTo(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) - store.upsertSshRemotePtyLease({ ...lease, ptyId: 'pty-2', state: 'attached' }) + paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) expect(liveLeasePtyIds(store).sort()).toEqual(['pty-2', 'sibling-pty']) }) @@ -455,8 +461,7 @@ describe('STA-3077: `expired` separates a superseded sibling from an orphan', () it('never bulk-reattaches a superseded sibling', async () => { const store = await storeWithPane('pty-1') - paneBindsTo(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) - store.upsertSshRemotePtyLease({ ...paneLease, ptyId: 'pty-2', state: 'attached' }) + paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) const predecessor = store.getSshRemotePtyLeases(TARGET).find((entry) => entry.ptyId === 'pty-1') expect(predecessor).toMatchObject({ state: 'expired', supersededBy: 'pty-2' }) @@ -469,8 +474,7 @@ describe('STA-3077: `expired` separates a superseded sibling from an orphan', () store.setWorkspaceSession(sessionWithPane({ tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-0' })) for (let reconnect = 0; reconnect < 10; reconnect++) { - paneBindsTo(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: `pty-${reconnect}` }) - store.upsertSshRemotePtyLease({ ...paneLease, ptyId: `pty-${reconnect}`, state: 'attached' }) + paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: `pty-${reconnect}` }) } expect(bulkReattachPtyIds(store)).toEqual(['pty-9']) @@ -496,8 +500,7 @@ describe('STA-3077: `expired` separates a superseded sibling from an orphan', () store.markSshRemotePtyLease(TARGET, 'pty-1', 'expired') const orphanUpdatedAt = store.getSshRemotePtyLeases(TARGET)[0].updatedAt - paneBindsTo(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) - store.upsertSshRemotePtyLease({ ...paneLease, ptyId: 'pty-2', state: 'attached' }) + paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) const predecessor = store.getSshRemotePtyLeases(TARGET).find((entry) => entry.ptyId === 'pty-1') expect(predecessor).toMatchObject({ state: 'expired', supersededBy: 'pty-2' }) @@ -510,8 +513,7 @@ describe('STA-3077: `expired` separates a superseded sibling from an orphan', () // belongs to the lease that lost, never to whatever claims the id next. it('clears the supersession mark when the id is re-upserted as a live lease', async () => { const store = await storeWithPane('pty-1') - paneBindsTo(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) - store.upsertSshRemotePtyLease({ ...paneLease, ptyId: 'pty-2', state: 'attached' }) + paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) // A restarted relay hands `pty-1` to a new shell for a different pane. store.upsertSshRemotePtyLease({ diff --git a/src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts b/src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts index 3201f8709fe..afb365c075c 100644 --- a/src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts +++ b/src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts @@ -53,7 +53,8 @@ function createHarness( shutdown } as unknown as IPtyProvider const store = { - getSshRemotePtyLeases: vi.fn().mockReturnValue(leases) + getSshRemotePtyLeases: vi.fn().mockReturnValue(leases), + reconcileSshRemotePtyLeasesForTarget: vi.fn() } as unknown as Store return { provider, store, shutdown } } diff --git a/src/main/ssh/ssh-orphan-sweep-pane-state-verdicts.test.ts b/src/main/ssh/ssh-orphan-sweep-pane-state-verdicts.test.ts index ab069240681..560d18b8b62 100644 --- a/src/main/ssh/ssh-orphan-sweep-pane-state-verdicts.test.ts +++ b/src/main/ssh/ssh-orphan-sweep-pane-state-verdicts.test.ts @@ -68,6 +68,44 @@ const CAPTURES = { ' 3159 3158 3159 3158 T sleep 300', ' 3160 1 1 -1 R ps -axo pid=,ppid=,pgid=,tpgid=,stat=,command=' ] + }, + /** `set +m; sleep 300 &`. With job control OFF the job does not get its own process group — it + * keeps the SHELL's pgid. So the tty carries exactly one process group, and that group is + * running a build. Reproduced independently on a real Ubuntu host through an Orca pane. */ + setMinusMBackground: { + rootPid: 12, + table: [ + ' 1 0 1 -1 Ss /bin/bash /work/run.sh', + ' 11 1 1 -1 S python3 /work/pty-scenario.py setm_background', + ' 12 11 12 12 Ss+ bash -i', + ' 13 12 12 12 S+ sleep 300', + ' 14 11 1 -1 R ps -axo pid=,ppid=,pgid=,tpgid=,stat=,command=' + ] + }, + /** A `set +m` job that drops its controlling terminal (`ioctl(TIOCNOTTY)` with no `setsid`). It + * keeps the shell's pgid, reports `tpgid == -1`, and is absent from `ps -t ` and from every + * tty-keyed index — while `killpg(shellPgid)` still reaches it. */ + nottyGroupMember: { + rootPid: 16, + table: [ + ' 1 0 1 -1 Ss /bin/bash /work/run.sh', + ' 15 1 1 -1 S python3 /work/pty-scenario.py notty_member', + ' 16 15 16 16 Ss+ bash -i', + ' 17 16 16 -1 S python3 -c import fcntl,os,time;fd=os.open("/dev/tty",os.O_RDWR);fcntl.ioctl(fd,0x5422);os.close(fd);time.sleep(300)', + ' 18 15 1 -1 R ps -axo pid=,ppid=,pgid=,tpgid=,stat=,command=' + ] + }, + /** A `set +m` job that double-forks. pid 22 keeps the shell's pgid and tty but reparented to pid + * 1, so the ppid walk from `rootPid` never reaches it and it can never be named. */ + doubleForkedGroupMember: { + rootPid: 20, + table: [ + ' 1 0 1 -1 Ss /bin/bash /work/run.sh', + ' 19 1 1 -1 S python3 /work/pty-scenario.py double_fork', + ' 20 19 20 20 Ss+ bash -i', + ' 22 1 20 20 S+ python3 -c import os,sys,time;p=os.fork() if p: print("GRANDCHILD:%d"%p);sys.stdout.flush();os._exit(0) time.sleep(300)', + ' 23 19 1 -1 R ps -axo pid=,ppid=,pgid=,tpgid=,stat=,command=' + ] } } as const @@ -147,6 +185,15 @@ describe('what the host publishes about a pane, read by the sweep', () => { expect(shellShape(CAPTURES.background)).toBe(shellShape(CAPTURES.idle)) expect(shellShape(CAPTURES.ctrlz)).toBe(shellShape(CAPTURES.idle)) expect(shellShape(CAPTURES.foreground)).not.toBe(shellShape(CAPTURES.idle)) + + // Same premise for the `set +m` captures, minus `ppid`: their harness keeps its parent alive + // rather than reparenting the shell to init, and the ppid is the one field of the shape the + // predicate never reads. + const paneShape = (capture: { rootPid: number; table: readonly string[] }): string => + shellShape(capture).split(' ').slice(1).join(' ') + expect(paneShape(CAPTURES.setMinusMBackground)).toBe(paneShape(CAPTURES.idle)) + expect(paneShape(CAPTURES.nottyGroupMember)).toBe(paneShape(CAPTURES.idle)) + expect(paneShape(CAPTURES.doubleForkedGroupMember)).toBe(paneShape(CAPTURES.idle)) }) it('sweeps an idle shell', async () => { @@ -191,6 +238,58 @@ describe('what the host publishes about a pane, read by the sweep', () => { expect(skipReason(plan)).toBe('host does not attest an idle shell') }) + // The tty is not the unit the stop operates on. `forceKillPosixPtyProcessGroups` collects the + // groups on the tty and then `killpg`s each one, so anything sharing the shell's pgid dies with + // it — including members the tty index cannot see at all. All three captures below reproduce on + // real Linux: before the group-membership half of the predicate they published + // `shellOwnsEveryTtyProcessGroup: true`, planned a SWEEP, and the planted pid was GONE after the + // real `forceKillPosixPtyProcessGroups` call. + it('never sweeps a pane whose background job shares the shell pgid under `set +m`', async () => { + // pid 13 is `sleep 300` — stand in `pnpm build`. Its pgid IS the shell's, so the tty carries + // exactly one process group and the tty half of the predicate reads the pane as idle. + const rows = parseStrictProcessTableRows(CAPTURES.setMinusMBackground.table.join('\n')) + const tty = rows.filter((row) => row.tpgid === CAPTURES.setMinusMBackground.rootPid) + expect(new Set(tty.map((row) => row.pgid))).toEqual(new Set([12])) + expect(tty.map((row) => row.pid)).toEqual([12, 13]) + + const evidence = await publish(CAPTURES.setMinusMBackground) + expect(evidence).toMatchObject({ shellOwnsEveryTtyProcessGroup: false }) + + const plan = await planFor(CAPTURES.setMinusMBackground) + expect(plan.sweep).toEqual([]) + expect(skipReason(plan)).toBe('host does not attest an idle shell') + }) + + it('never sweeps a pane whose group member dropped the controlling terminal', async () => { + // pid 17 kept the shell's pgid and called `ioctl(TIOCNOTTY)`, so it reports `tpgid == -1`, + // never appears in `ps -t `, and no tty-shaped index — not process groups, not pids — + // can observe it. `killpg(16)` reaches it regardless. + const rows = parseStrictProcessTableRows(CAPTURES.nottyGroupMember.table.join('\n')) + expect(rows.filter((row) => row.tpgid === 16).map((row) => row.pid)).toEqual([16]) + expect(rows.filter((row) => row.pgid === 16).map((row) => row.pid)).toEqual([16, 17]) + + const evidence = await publish(CAPTURES.nottyGroupMember) + expect(evidence).toMatchObject({ shellOwnsEveryTtyProcessGroup: false }) + + const plan = await planFor(CAPTURES.nottyGroupMember) + expect(plan.sweep).toEqual([]) + expect(skipReason(plan)).toBe('host does not attest an idle shell') + }) + + it('never sweeps a pane whose group member double-forked away from the shell', async () => { + // pid 22 reparented to pid 1, so the ppid walk from rootPid cannot reach it and the named- + // process backstop can never fire. It still holds the shell's pgid. + const rows = parseStrictProcessTableRows(CAPTURES.doubleForkedGroupMember.table.join('\n')) + expect(rows.find((row) => row.pid === 22)).toMatchObject({ ppid: 1, pgid: 20, tpgid: 20 }) + + const evidence = await publish(CAPTURES.doubleForkedGroupMember) + expect(evidence).toMatchObject({ processName: null, shellOwnsEveryTtyProcessGroup: false }) + + const plan = await planFor(CAPTURES.doubleForkedGroupMember) + expect(plan.sweep).toEqual([]) + expect(skipReason(plan)).toBe('host does not attest an idle shell') + }) + it('refuses an observation older than the pass it would authorize', async () => { // Same idle capture that sweeps above; only its age differs. Staleness degrades to "leave it // running", never to "stop it". diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index c5cb2dd552c..257eb984caa 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -52,6 +52,7 @@ import { RELAY_DEPLOY_TIMEOUT_MS } from './ssh-relay-deploy-timing' import { createSshOperationAbortError, shellEscape } from './ssh-connection-utils' +import { isWindowsRelayPlatform } from '../../shared/relay-artifacts' import { probeBuildToolchain, formatMissingToolchainError, @@ -745,27 +746,29 @@ const NODE_PTY_CONSOLE_LIST_PATCH_FILENAME = 'node-pty-1.1.0-console-list-agent- const NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME = 'node-pty-1.1.0-master-cloexec-patch.cjs' const NODE_PTY_CLOEXEC_STATUS_PREFIX = 'ORCA-NPTY-CLOEXEC:' /** - * Whether the tree the patch left behind still leaks the pty master into every later child. - * `fixed` is the only outcome a shared cache entry may be published from. + * Whether the tree the patch left behind still leaks a pty fd -- the master into every later child + * on Linux, a throwaway /dev/ptmx per spawn on macOS. `fixed` is the only outcome a shared cache + * entry may be published from. */ type NodePtyMasterCloexecOutcome = 'fixed' | 'unfixed' /** * The statuses that leave a non-leaking tree. Deliberately an allowlist, not a `failed:` denylist: - * the script's `skipped:` family is mixed. `skipped:not-linux` is a platform that never leaks, but - * `skipped:earlier-attempt-failed`, `skipped:no-compiled-build`, `skipped:unexpected-source` and - * the two `skipped:` forms all mean the patch was refused and the leaky build is still on - * disk -- indistinguishable from `failed:` as far as what gets published. + * the script's `skipped:` family is mixed. `skipped:unsupported-platform` is a platform that never + * leaks, but `skipped:earlier-attempt-failed`, `skipped:no-compiled-build`, `skipped:no-prebuild`, + * `skipped:unexpected-source` and the two `skipped:` forms all mean the patch was refused + * and the leaky build is still on disk -- indistinguishable from `failed:` as far as what gets + * published. */ const NODE_PTY_CLOEXEC_FIXED_STATUSES: ReadonlySet = new Set([ 'patched', - // The rebuild ran from patched source; only the isolation check could not observe the result. - // An unobservable check is not a failed patch, and treating it as one would disable the shared - // cache on every host without `lsof`. + // The rebuild ran from patched source; only the leak check could not observe the result. An + // unobservable check is not a failed patch, and treating it as one would disable the shared + // cache on every host without `/proc` or `lsof`. 'patched-unverified', 'already-patched', - // Unreachable while the platform gate below short-circuits first, but it is the one `skipped:` - // that means "nothing to fix" rather than "would not fix it". - 'skipped:not-linux' + // Unreachable while the platform gate below short-circuits Windows first, but it is the one + // `skipped:` that means "nothing to fix" rather than "would not fix it". + 'skipped:unsupported-platform' ]) // Exported for the relay-native-dependency-coverage test, which asserts every // native addon the relay bundle imports is either installed here or explicitly @@ -1300,7 +1303,7 @@ async function installNativeDeps( } /** - * Re-apply the pty-master FD_CLOEXEC patch the app gets from pnpm to the host's npm copy (#17915). + * Re-apply the pty fd-leak patch the app gets from pnpm to the host's npm copy (#17915). * * Why it is safe to rebuild under a live relay: this only runs from installNativeDeps, so only on a * freshly created directory or a locked repair, and a relay already serving PTYs has pty.node mapped @@ -1324,9 +1327,11 @@ async function applyNodePtyMasterCloexecPatch( nodePath: string, signal?: AbortSignal ): Promise { - // Linux is the only relay platform that takes forkpty()'s no-O_CLOEXEC path; macOS and Windows - // ship prebuilds, so forcing a rebuild there would add a first compile to fix nothing. - if (isWindowsRemoteHost(hostPlatform) || !platform.startsWith('linux')) { + // Both Unix relay platforms leak, by different bugs: Linux inherits the master through forkpty()'s + // no-O_CLOEXEC path, macOS orphans one throwaway /dev/ptmx fd per spawn in pty_posix_spawn. Only + // Windows, which has no fds, is short-circuited -- and answering 'fixed' from a gate that ran + // nothing is exactly how a leaking darwin tree got published to the shared cache. + if (isWindowsRemoteHost(hostPlatform) || isWindowsRelayPlatform(platform)) { return 'fixed' } try { diff --git a/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts b/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts index 66af01fa43c..b0ca39ec2b4 100644 --- a/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts +++ b/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts @@ -88,11 +88,12 @@ import { const PATCH_ASSET = 'node-pty-1.1.0-master-cloexec-patch.cjs' /** - * The relay installs stock node-pty from npm, so the app's pnpm patch never reaches it and every - * later child of the relay inherits a live pty master (#17915). The compile that closes it sits on + * The relay installs stock node-pty from npm, so the app's pnpm patch never reaches it and the + * relay leaks a pty fd per terminal (#17915) -- the master into every later child on Linux, an + * orphaned /dev/ptmx throwaway in `pty_posix_spawn` on macOS. The compile that closes both sits on * the connect path, so what these specs pin is the blast radius, not the patch itself. */ -describe('relay pty-master close-on-exec patch on the install path', () => { +describe('relay pty fd-leak patch on the install path', () => { const sftpCapture: SftpWriteCapture = { paths: [], contents: {}, @@ -211,9 +212,9 @@ describe('relay pty-master close-on-exec patch on the install path', () => { }) it('does not publish a tree the patch refused to touch', async () => { - // `skipped:` is not one verdict. Every form except `skipped:not-linux` means the patch was - // declined and the leaky build is still on disk, which is indistinguishable from `failed:` - // as far as what would get published. + // `skipped:` is not one verdict. Every form except `skipped:unsupported-platform` means the + // patch was declined and the leaky build is still on disk, which is indistinguishable from + // `failed:` as far as what would get published. const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { const conn = makeMockConnection(sftpCapture) @@ -281,25 +282,39 @@ describe('relay pty-master close-on-exec patch on the install path', () => { expect(patchCommands()).toEqual([]) }) - it('never adds a compile to a macOS relay, which does not leak the master', async () => { + it('runs the patch on a macOS relay, which orphans a /dev/ptmx fd per spawn', async () => { + // macOS takes `pty_posix_spawn`, not forkpty, so the asset's original replacements rewrote + // nothing macOS executes -- and this gate answered 'fixed' without running anything, which is + // exactly what publishes to the shared cache. Every later host on the machine then linked a + // tree that leaks one /dev/ptmx fd per terminal, measured +1 per open/close cycle on + // darwin-arm64. macOS pays a first compile here, unlike Linux's second, and that is the price. vi.mocked(parseUnameToRelayPlatform).mockReturnValue('darwin-arm64') const conn = makeMockConnection(sftpCapture) - feed([ - ...makeStagedFirstInstallExecPrefix(), - '', // npm install native deps - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - '', // promote into the shared native-deps cache - '', // clean stage root - 'DEAD', - '', // publish the per-launch credential - 'READY' - ]) + feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' })) await deployAndLaunchRelay(conn) - expect(patchCommands()).toEqual([]) + expect(patchCommands()).toHaveLength(1) + expect(promoted()).toBe(true) + }) + + it('does not publish a macOS tree whose compile failed', async () => { + // The darwin rollback restores the shipped prebuild, so the relay still works -- and that is + // precisely why the status, not the exit code, has to decide publishability: a rolled-back + // macOS tree probes loadable and still leaks. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + vi.mocked(parseUnameToRelayPlatform).mockReturnValue('darwin-arm64') + const conn = makeMockConnection(sftpCapture) + feed(firstInstallReporting('failed:npm rebuild node-pty exited 1: gyp ERR! not ok')) + + await deployAndLaunchRelay(conn) + + expect(patchCommands()).toHaveLength(1) + expect(promoted()).toBe(false) + } finally { + warn.mockRestore() + } }) it('connects anyway when the patch command fails outright', async () => { diff --git a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts index 49fc1e98ccd..76d92e77e9a 100644 --- a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts +++ b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts @@ -157,6 +157,7 @@ function createSession(targetId: string): InstanceType { upsertSshPtyConsumerRecovery: vi.fn(), removeSshPtyConsumerRecovery: vi.fn(), getSshRemotePtyLeases: vi.fn().mockReturnValue([]), + reconcileSshRemotePtyLeasesForTarget: vi.fn(), markSshRemotePtyLease: vi.fn(), markSshRemotePtyLeases: vi.fn(), markSshRemotePtyLeasesAsync: vi.fn(), diff --git a/src/main/ssh/ssh-relay-session-terminal-error.test.ts b/src/main/ssh/ssh-relay-session-terminal-error.test.ts index 9bb14618319..4cfa657e401 100644 --- a/src/main/ssh/ssh-relay-session-terminal-error.test.ts +++ b/src/main/ssh/ssh-relay-session-terminal-error.test.ts @@ -104,6 +104,7 @@ function createMockDeps(): { upsertSshPtyConsumerRecovery: vi.fn(), removeSshPtyConsumerRecovery: vi.fn(), getSshRemotePtyLeases: vi.fn().mockReturnValue([]), + reconcileSshRemotePtyLeasesForTarget: vi.fn(), markSshRemotePtyLease: vi.fn(), markSshRemotePtyLeases: vi.fn(), markSshRemotePtyLeasesAsync: vi.fn(), diff --git a/src/main/ssh/ssh-relay-session-test-fixtures.ts b/src/main/ssh/ssh-relay-session-test-fixtures.ts index efdee31c406..ba0782b3a20 100644 --- a/src/main/ssh/ssh-relay-session-test-fixtures.ts +++ b/src/main/ssh/ssh-relay-session-test-fixtures.ts @@ -21,6 +21,7 @@ export function createMockDeps(): SshRelaySessionTestDeps { upsertSshPtyConsumerRecovery: vi.fn(), removeSshPtyConsumerRecovery: vi.fn(), getSshRemotePtyLeases: vi.fn().mockReturnValue([]), + reconcileSshRemotePtyLeasesForTarget: vi.fn(), getWorkspaceSession: vi.fn(), markSshRemotePtyLease: vi.fn(), markSshRemotePtyLeases: vi.fn(), diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index bca3632b83b..99a2ce9fbf9 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -2322,6 +2322,12 @@ export class SshRelaySession { if (!shouldContinue()) { return } + // Why immediately before the read: a pane's binding is written by several writers, and the + // renderer's debounced layout publish lands long after the spawn commit that leased the pty — + // so a predecessor that was still bound at spawn time never gets marked by a spawn-side + // trigger. Re-deriving from each pane's CURRENT binding here is what actually bounds this set, + // and it repairs stores that already accumulated these rows. + this.store.reconcileSshRemotePtyLeasesForTarget(this.targetId) // Why not `state !== 'expired'`: that state covers both a superseded sibling (re-adopting it is // the 2 -> 19 -> 20 fan-out) and an orphan whose reattach merely lost contact. Only the first // carries a retirement mark, and only it has to be skipped. diff --git a/src/main/worktree-create-execution-host-route.test.ts b/src/main/worktree-create-execution-host-route.test.ts new file mode 100644 index 00000000000..ec4ecd2899e --- /dev/null +++ b/src/main/worktree-create-execution-host-route.test.ts @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { registerSshGitProvider, unregisterSshGitProvider } from './providers/ssh-git-dispatch' +import { ExecutionHostNotDispatchableError } from './providers/execution-host-provider-dispatch' +import type { Repo } from '../shared/repo-types' +import { + requireWorktreeCreateRoute, + resolveWorktreeCreateRoute +} from './worktree-create-execution-host-route' + +const HOST_A = 'target-a' +const HOST_B = 'target-b' + +function repoRow(fields: Partial): Repo { + return { + id: 'repo-1', + path: '/remote/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + ...fields + } as Repo +} + +afterEach(() => { + unregisterSshGitProvider(HOST_A) + unregisterSshGitProvider(HOST_B) +}) + +describe('resolveWorktreeCreateRoute', () => { + it('routes a row that names its host only as executionHostId to that SSH target', () => { + registerSshGitProvider(HOST_A, { name: 'git-a' } as never) + + expect(resolveWorktreeCreateRoute(repoRow({ executionHostId: 'ssh:target-a' }))).toMatchObject({ + kind: 'ssh', + hostId: 'ssh:target-a', + connectionId: HOST_A, + repo: { connectionId: HOST_A } + }) + }) + + it('normalizes the row for a legacy connectionId-only repo without changing its answer', () => { + expect(resolveWorktreeCreateRoute(repoRow({ connectionId: HOST_A }))).toMatchObject({ + kind: 'ssh', + connectionId: HOST_A, + repo: { connectionId: HOST_A } + }) + }) + + it('keeps two simultaneously registered SSH hosts on their own connections', () => { + registerSshGitProvider(HOST_A, { name: 'git-a' } as never) + registerSshGitProvider(HOST_B, { name: 'git-b' } as never) + + expect(resolveWorktreeCreateRoute(repoRow({ executionHostId: 'ssh:target-a' }))).toMatchObject({ + connectionId: HOST_A, + repo: { connectionId: HOST_A } + }) + expect(resolveWorktreeCreateRoute(repoRow({ executionHostId: 'ssh:target-b' }))).toMatchObject({ + connectionId: HOST_B, + repo: { connectionId: HOST_B } + }) + }) + + it('lets an explicit local host win over a surviving connectionId', () => { + // A contradictory row. `getRepoExecutionHostId` answers `local`, which is what the runtime + // create sibling has always done; the raw read sent it remote. + expect( + resolveWorktreeCreateRoute(repoRow({ executionHostId: 'local', connectionId: HOST_A })) + ).toEqual({ kind: 'local', hostId: 'local' }) + }) + + it('answers runtime for a runtime row with no nested SSH target', () => { + expect(resolveWorktreeCreateRoute(repoRow({ executionHostId: 'runtime:env-1' }))).toEqual({ + kind: 'runtime', + hostId: 'runtime:env-1', + environmentId: 'env-1' + }) + }) + + it('answers runtime for a runtime row whose nested target is dialable here', () => { + registerSshGitProvider(HOST_A, { name: 'git-a' } as never) + + expect( + resolveWorktreeCreateRoute( + repoRow({ executionHostId: 'runtime:env-1', connectionId: HOST_A }) + ) + ).toMatchObject({ kind: 'runtime', environmentId: 'env-1' }) + }) +}) + +describe('requireWorktreeCreateRoute', () => { + it('refuses a runtime host rather than creating through this client', () => { + expect(() => requireWorktreeCreateRoute(repoRow({ executionHostId: 'runtime:env-1' }))).toThrow( + ExecutionHostNotDispatchableError + ) + }) + + it('passes local and SSH hosts through unchanged', () => { + registerSshGitProvider(HOST_A, { name: 'git-a' } as never) + + expect(requireWorktreeCreateRoute(repoRow({}))).toEqual({ kind: 'local', hostId: 'local' }) + expect(requireWorktreeCreateRoute(repoRow({ executionHostId: 'ssh:target-a' }))).toMatchObject({ + kind: 'ssh', + connectionId: HOST_A + }) + }) +}) diff --git a/src/main/worktree-create-execution-host-route.ts b/src/main/worktree-create-execution-host-route.ts new file mode 100644 index 00000000000..2831a2fcbeb --- /dev/null +++ b/src/main/worktree-create-execution-host-route.ts @@ -0,0 +1,69 @@ +/** + * Which execution host a worktree create runs on. + * + * Two entry points create the same workspace and disagreed about how to read its host. The runtime + * path resolved (`orca-runtime-create-managed-worktree.ts`) and then normalized the row; the IPC + * handler branched on raw `repo.connectionId`, so a row naming its owner only as + * `executionHostId: 'ssh:'` ran `git worktree add` on the client against a remote path + * (#11163). Same repo, two entry points, two answers. + * + * Both now take this one route. + * + * The `repo` on the `ssh` variant is a normalization, and it is a workaround rather than the + * pattern: `createRemoteWorktree` and its callees re-read `repo.connectionId!` at five depths + * (`ipc/worktree-remote.ts`), so the resolved connection has to be handed to them through the field + * they already read. It travels only as far as this object does — anything downstream that re-reads + * the row from the store still sees the unnormalized one. The real fix is to give that pipeline an + * explicit connection parameter and delete `repo.connectionId!` from it, which is a separate change. + */ + +import { getRepoExecutionHostId, type LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host' +import type { Repo } from '../shared/repo-types' +import { + ExecutionHostNotDispatchableError, + resolveGitRouteForHost +} from './providers/execution-host-provider-dispatch' + +export type WorktreeCreateRoute = + | { kind: 'local'; hostId: typeof LOCAL_EXECUTION_HOST_ID } + | { + kind: 'ssh' + hostId: `ssh:${string}` + connectionId: string + /** The row with `connectionId` set to the resolved target; see the workaround note above. */ + repo: Repo + } + | { kind: 'runtime'; hostId: `runtime:${string}`; environmentId: string } + +export function resolveWorktreeCreateRoute(repo: Repo): WorktreeCreateRoute { + const route = resolveGitRouteForHost(getRepoExecutionHostId(repo)) + switch (route.kind) { + case 'local': + return { kind: 'local', hostId: route.hostId } + case 'ssh': + return { + kind: 'ssh', + hostId: route.hostId, + connectionId: route.connectionId, + repo: { ...repo, connectionId: route.connectionId } + } + case 'runtime': + return { kind: 'runtime', hostId: route.hostId, environmentId: route.environmentId } + } +} + +/** + * For the two create forks that put files on a host. `runtime:` is not one of them: the + * environment's own server creates the worktree, and the SSH target on its repo row is that + * server's nested one, addressable only as (environmentId, targetId). Creating through this + * client's SSH table would `git worktree add` on a same-named target on the wrong machine. + */ +export function requireWorktreeCreateRoute( + repo: Repo +): Exclude { + const route = resolveWorktreeCreateRoute(repo) + if (route.kind === 'runtime') { + throw new ExecutionHostNotDispatchableError(route.hostId) + } + return route +} diff --git a/src/main/worktree-removal-execution-host-route.test.ts b/src/main/worktree-removal-execution-host-route.test.ts new file mode 100644 index 00000000000..5fbbfbf9540 --- /dev/null +++ b/src/main/worktree-removal-execution-host-route.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + registerSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE, + unregisterSshGitProvider +} from './providers/ssh-git-dispatch' +import { + registerSshFilesystemProvider, + unregisterSshFilesystemProvider +} from './providers/ssh-filesystem-dispatch' +import { ExecutionHostNotDispatchableError } from './providers/execution-host-provider-dispatch' +import { + getWorktreeRemovalConnectionId, + resolveWorktreeRemovalRoute +} from './worktree-removal-execution-host-route' + +const HOST_A = 'target-a' +const HOST_B = 'target-b' + +function gitProvider(name: string): never { + return { name } as never +} + +function fsProvider(name: string): never { + return { name } as never +} + +afterEach(() => { + unregisterSshGitProvider(HOST_A) + unregisterSshGitProvider(HOST_B) + unregisterSshFilesystemProvider(HOST_A) + unregisterSshFilesystemProvider(HOST_B) +}) + +describe('resolveWorktreeRemovalRoute', () => { + it('routes a local host to this machine with no connection', () => { + const route = resolveWorktreeRemovalRoute('local') + + expect(route).toEqual({ kind: 'local', hostId: 'local' }) + expect(getWorktreeRemovalConnectionId(route)).toBeUndefined() + }) + + it('keeps two simultaneously registered SSH hosts on their own providers', () => { + registerSshGitProvider(HOST_A, gitProvider('git-a')) + registerSshGitProvider(HOST_B, gitProvider('git-b')) + registerSshFilesystemProvider(HOST_A, fsProvider('fs-a')) + registerSshFilesystemProvider(HOST_B, fsProvider('fs-b')) + + const routeA = resolveWorktreeRemovalRoute('ssh:target-a') + const routeB = resolveWorktreeRemovalRoute('ssh:target-b') + + expect(routeA).toMatchObject({ + kind: 'ssh', + hostId: 'ssh:target-a', + connectionId: HOST_A, + provider: { name: 'git-a' }, + fsProvider: { name: 'fs-a' } + }) + expect(routeB).toMatchObject({ + kind: 'ssh', + hostId: 'ssh:target-b', + connectionId: HOST_B, + provider: { name: 'git-b' }, + fsProvider: { name: 'fs-b' } + }) + expect(getWorktreeRemovalConnectionId(routeA)).toBe(HOST_A) + expect(getWorktreeRemovalConnectionId(routeB)).toBe(HOST_B) + }) + + it('carries a null filesystem provider without falling back to the local one', () => { + registerSshGitProvider(HOST_A, gitProvider('git-a')) + + expect(resolveWorktreeRemovalRoute('ssh:target-a')).toMatchObject({ + kind: 'ssh', + fsProvider: null + }) + }) + + it('refuses an unreachable SSH host instead of answering local', () => { + expect(() => resolveWorktreeRemovalRoute('ssh:target-a')).toThrow( + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE + ) + }) + + it('refuses a runtime host with no nested SSH target', () => { + expect(() => resolveWorktreeRemovalRoute('runtime:env-1')).toThrow( + ExecutionHostNotDispatchableError + ) + }) + + it('refuses a runtime host even when a same-named target is dialable here', () => { + // The nested target lives in the environment's namespace; a same-named local one is a + // different machine, and removing a worktree through it deletes the wrong checkout. + registerSshGitProvider(HOST_A, gitProvider('git-a')) + + expect(() => resolveWorktreeRemovalRoute('runtime:target-a')).toThrow( + ExecutionHostNotDispatchableError + ) + }) +}) diff --git a/src/main/worktree-removal-execution-host-route.ts b/src/main/worktree-removal-execution-host-route.ts new file mode 100644 index 00000000000..529eed71af5 --- /dev/null +++ b/src/main/worktree-removal-execution-host-route.ts @@ -0,0 +1,81 @@ +/** + * Which execution host a destructive worktree removal runs against. + * + * `removeManagedWorktree` resolved its host once, for metadata pruning + * (`cleanupHostId ?? getRepoExecutionHostId(repo)`), and then read raw `repo.connectionId` for + * every step that actually touches the filesystem: the `git worktree list` that decides whether the + * path is registered, the provider handed to the unregistered-removal branch, the + * registered-remote-vs-local fork, and the PTY/history teardown. One function, two spellings — + * so a row naming its owner only as `executionHostId: 'ssh:'` listed a *remote* checkout on + * this client, entered the unregistered branch with `provider: null`, and deleted a same-named + * local directory while metadata was pruned under `ssh:` (#11163). #18358 made that + * reachable by migrating the cleanup scan, so those rows now surface as removable candidates. + * + * Routing is now one answer for the whole removal, taken from the host the prune already used, so + * list, remove and prune cannot disagree. The ambiguous `provider: SshGitProvider | null` carrier + * is deleted from the callees rather than supplemented, which makes every remaining reader a + * compile error in the typed modules that do the destructive work. + * + * `runtime:` is not a variant. Its files live on that environment's own server and the SSH + * target on its repo row is that server's nested one, addressable only as the pair + * (environmentId, targetId); handing it to this client's SSH table would `git worktree remove` a + * same-named path on the wrong machine. It throws, matching `workspace-cleanup-git-route` and + * `runtime-git-command-target`. + * + * An `ssh:` host with no registered provider also throws. Loss of contact is never evidence that + * the checkout is local (docs/reference/ssh-execution-boundary.md); refusing leaves a remote + * worktree in place, while the incumbent fallback deleted a client-side path. + */ + +import type { ExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host' +import { + ExecutionHostNotDispatchableError, + resolveFilesystemRouteForHost, + resolveGitRouteForHost +} from './providers/execution-host-provider-dispatch' +import { SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } from './providers/ssh-git-dispatch' +import type { SshGitProvider } from './providers/ssh-git-provider' +import type { IFilesystemProvider } from './providers/types' + +export type WorktreeRemovalRoute = + | { kind: 'local'; hostId: typeof LOCAL_EXECUTION_HOST_ID } + | { + kind: 'ssh' + hostId: `ssh:${string}` + connectionId: string + provider: SshGitProvider + /** + * Still nullable: the incumbent read `getSshFilesystemProvider` (not `require…`) and the + * directory branches raise their own message when they need it. Narrowing it here would + * refuse removals that never touch the filesystem provider. + */ + fsProvider: IFilesystemProvider | null + } + +export function resolveWorktreeRemovalRoute(hostId: ExecutionHostId): WorktreeRemovalRoute { + const route = resolveGitRouteForHost(hostId) + switch (route.kind) { + case 'local': + return { kind: 'local', hostId: route.hostId } + case 'runtime': + throw new ExecutionHostNotDispatchableError(route.hostId) + case 'ssh': { + if (!route.provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + const fsRoute = resolveFilesystemRouteForHost(hostId) + return { + kind: 'ssh', + hostId: route.hostId, + connectionId: route.connectionId, + provider: route.provider, + fsProvider: fsRoute.kind === 'ssh' ? fsRoute.provider : null + } + } + } +} + +/** The connection to teardown PTYs, watchers and history against — `undefined` on a local host. */ +export function getWorktreeRemovalConnectionId(route: WorktreeRemovalRoute): string | undefined { + return route.kind === 'ssh' ? route.connectionId : undefined +} diff --git a/src/relay/git-branch-delete-refusal-parity.test.ts b/src/relay/git-branch-delete-refusal-parity.test.ts new file mode 100644 index 00000000000..71344bca940 --- /dev/null +++ b/src/relay/git-branch-delete-refusal-parity.test.ts @@ -0,0 +1,205 @@ +/** + * The relay and the desktop each carried their own `getErrorText`, and they had + * drifted: the relay read `message` + `stderr` + `stdout`, the desktop only + * `message` + `stderr`. So a `git branch -d` refusal that arrived on `stdout` + * routed the SSH removal through prune-and-retry while the local removal gave up + * and preserved the branch. + * + * These tests push the same failure through both published removal entry points — + * `removeWorktreeOp` (what `git.removeWorktree` runs on the host) and `removeWorktree` + * (the local runner) — and require the same branch-deletion commands and the same + * `RemoveWorktreeResult`. A second error-text reader on either side fails here. + */ +import type * as FsPromises from 'node:fs/promises' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock, resolveGitDirMock, moveWorktreeDirectoryToTrashMock } = vi.hoisted( + () => ({ + gitExecFileAsyncMock: vi.fn(), + resolveGitDirMock: vi.fn(), + moveWorktreeDirectoryToTrashMock: vi.fn() + }) +) + +vi.mock('../main/worktree-trash', () => ({ + moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock, + restoreWorktreeDirectoryFromTrash: vi.fn(async () => true), + scheduleWorktreeTrashDeletion: vi.fn() +})) + +vi.mock('../main/git/runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock, + gitExecFileSync: vi.fn(), + translateWslOutputPaths: (output: string) => output +})) + +vi.mock('../main/git/status', () => ({ + resolveGitDir: resolveGitDirMock, + runWithGitReadCacheInvalidation: (run: () => Promise) => run() +})) + +vi.mock('fs/promises', async () => { + const actual = await vi.importActual('fs/promises') + return { + ...actual, + stat: vi.fn(async () => { + throw enoent() + }), + readFile: vi.fn() + } +}) + +import { GitCapabilityCache } from '../shared/git-capability-cache' +import type { RemoveWorktreeResult } from '../shared/worktree/create-types' +import { clearGitCapabilityStateForTests } from '../main/git/git-capability-state' +import { _resetWorktreeScanCacheForTests, removeWorktree } from '../main/git/worktree' +import { __resetSparseCheckoutStateCacheForTests } from '../main/git/worktree-sparse-checkout-cache' +import type { GitExec } from './git-handler-ops' +import { removeWorktreeOp } from './git-handler-worktree-ops' + +const REPO_PATH = '/repo' +const WORKTREE_PATH = '/repo-feature' +const BRANCH = 'feature/test' + +function enoent(): Error { + return Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) +} + +/** Only the branch-deletion phase; the two entry points legitimately reach it by different routes. */ +function branchDeletionCalls(calls: string[][]): string[] { + return calls + .map((args) => args.join(' ')) + .filter((call) => call.startsWith('branch ') || call === 'worktree prune') +} + +function worktreeListPorcelain(withFeature: boolean): string { + const blocks = [[`worktree ${REPO_PATH}`, 'HEAD abc123', 'branch refs/heads/main']] + if (withFeature) { + blocks.push([`worktree ${WORKTREE_PATH}`, 'HEAD def456', `branch refs/heads/${BRANCH}`]) + } + return `${blocks.map((block) => block.join('\n')).join('\n\n')}\n` +} + +type RefusalStream = 'stdout' | 'stderr' + +const REFUSAL_TEXT = `error: cannot delete branch '${BRANCH}' used by worktree at '/repo-stale'` + +/** + * A `branch -d` rejection carrying the refusal on exactly one stream. `message` stays + * generic so the assertion is about the stream, not about Node's stderr echo. + */ +function branchDeleteRefusal(stream: RefusalStream): Error { + return Object.assign(new Error('Command failed: git branch -d'), { + code: 1, + stdout: stream === 'stdout' ? REFUSAL_TEXT : '', + stderr: stream === 'stderr' ? REFUSAL_TEXT : '' + }) +} + +/** Refuses the first `branch -d`, accepts the retry that follows `worktree prune`. */ +function scriptRelayGit(stream: RefusalStream): { + git: GitExec + calls: string[][] +} { + const calls: string[][] = [] + let branchDeleteCount = 0 + const git = vi.fn(async (args) => { + calls.push(args) + if (args[0] === 'rev-parse') { + return { stdout: `${REPO_PATH}/.git\n`, stderr: '' } + } + if (args[0] === 'worktree' && args[1] === 'list') { + return { stdout: worktreeListPorcelain(true), stderr: '' } + } + if (args[0] === 'branch' && args[1] === '-d') { + branchDeleteCount += 1 + if (branchDeleteCount === 1) { + throw branchDeleteRefusal(stream) + } + return { stdout: '', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + return { git, calls } +} + +function scriptDesktopGit(stream: RefusalStream): string[][] { + const calls: string[][] = [] + let branchDeleteCount = 0 + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + calls.push(args) + if (args[0] === 'worktree' && args[1] === 'list') { + return { stdout: worktreeListPorcelain(branchDeleteCount === 0), stderr: '' } + } + if (args[0] === 'branch' && args[1] === '-d') { + branchDeleteCount += 1 + if (branchDeleteCount === 1) { + throw branchDeleteRefusal(stream) + } + return { stdout: '', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + return calls +} + +async function removeOverRelay( + stream: RefusalStream +): Promise<{ result: RemoveWorktreeResult; branchCalls: string[] }> { + const { git, calls } = scriptRelayGit(stream) + const result = await removeWorktreeOp( + git, + { worktreePath: WORKTREE_PATH }, + new GitCapabilityCache() + ) + return { result, branchCalls: branchDeletionCalls(calls) } +} + +async function removeLocally( + stream: RefusalStream +): Promise<{ result: RemoveWorktreeResult; branchCalls: string[] }> { + const calls = scriptDesktopGit(stream) + const result = await removeWorktree(REPO_PATH, WORKTREE_PATH) + return { result, branchCalls: branchDeletionCalls(calls) } +} + +beforeEach(() => { + clearGitCapabilityStateForTests() + _resetWorktreeScanCacheForTests() + __resetSparseCheckoutStateCacheForTests() + gitExecFileAsyncMock.mockReset() + resolveGitDirMock.mockReset() + resolveGitDirMock.mockImplementation(async (worktreePath: string) => `${worktreePath}/.git`) + moveWorktreeDirectoryToTrashMock.mockReset() + // Default: the checkout cannot be renamed aside, so removal runs `worktree remove` in place. + moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined) +}) + +describe('relay/desktop branch-delete refusal parity', () => { + it('prunes and retries on both paths when the refusal arrives on stdout', async () => { + const relay = await removeOverRelay('stdout') + const local = await removeLocally('stdout') + + expect(relay.branchCalls).toEqual(local.branchCalls) + expect(relay.result).toEqual(local.result) + expect(local.branchCalls).toEqual([ + `branch -d -- ${BRANCH}`, + 'worktree prune', + `branch -d -- ${BRANCH}` + ]) + expect(local.result).toEqual({}) + }) + + it('prunes and retries on both paths when the refusal arrives on stderr, as real Git sends it', async () => { + const relay = await removeOverRelay('stderr') + const local = await removeLocally('stderr') + + expect(relay.branchCalls).toEqual(local.branchCalls) + expect(relay.result).toEqual(local.result) + expect(local.branchCalls).toEqual([ + `branch -d -- ${BRANCH}`, + 'worktree prune', + `branch -d -- ${BRANCH}` + ]) + }) +}) diff --git a/src/relay/git-handler-push-target.ts b/src/relay/git-handler-push-target.ts index d81111d45d3..6663b5b3ad3 100644 --- a/src/relay/git-handler-push-target.ts +++ b/src/relay/git-handler-push-target.ts @@ -1,168 +1,24 @@ import { assertGitPushTargetShape } from '../shared/git-push-target-validation' -import { gitRefTargetsBranchOnRemote } from '../shared/git-remote-branch-name' -import { findGitRemoteNameByFetchUrl } from '../shared/git-remote-url-index' +import { + resolveConfiguredGitPushTarget, + type ResolvedGitPushTarget +} from '../shared/git-push-target-resolution' import type { GitPushTarget } from '../shared/worktree/types' type RelayGit = (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string }> -export type ResolvedPushTarget = { - remote: string - refspec: string -} - -async function getConfiguredPushTarget( - git: RelayGit, - worktreePath: string -): Promise { - try { - const { stdout: branchStdout } = await git( - ['symbolic-ref', '--quiet', '--short', 'HEAD'], - worktreePath - ) - const branch = branchStdout.trim() - if (!branch) { - return null - } - const [pushRemote, { stdout: mergeStdout }] = await Promise.all([ - getConfiguredPushRemote(git, worktreePath, branch), - git(['config', '--get', `branch.${branch}.merge`], worktreePath) - ]) - const remote = pushRemote?.remote - const mergeRef = mergeStdout.trim() - const branchRef = mergeRef.replace(/^refs\/heads\//, '') - if (!remote || !branchRef || remote === '.' || branchRef === mergeRef) { - return null - } - if (await branchMergeTargetsConfiguredBase(git, worktreePath, branch, remote, branchRef)) { - return null - } - if (!canPushConfiguredMergeBranch(pushRemote, branch, branchRef)) { - return null - } - return { remote, refspec: `HEAD:${branchRef}` } - } catch { - return null - } -} - -async function getConfigValue( - git: RelayGit, - worktreePath: string, - key: string -): Promise { - try { - const { stdout } = await git(['config', '--get', key], worktreePath) - const value = stdout.trim() - return value || null - } catch { - return null - } -} - -function isUrlValuedRemote(remote: string): boolean { - return /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(remote) || /^[^@/:]+@[^:]+:.+/.test(remote) -} - -type ConfiguredPushRemote = { - remote: string - branchRemote: string | null -} - -// Host-side twin of `src/main/git/remote.ts`: one `git remote -v` instead of -// `git remote` plus a serial `git remote get-url` per remote. -async function findRemoteNameForUrl( - git: RelayGit, - worktreePath: string, - remoteUrl: string -): Promise { - try { - const { stdout } = await git(['remote', '-v'], worktreePath) - return findGitRemoteNameByFetchUrl(stdout, (candidateUrl) => candidateUrl === remoteUrl) - } catch { - return null - } -} - -async function normalizePushRemote( - git: RelayGit, - worktreePath: string, - remote: string -): Promise { - if (!isUrlValuedRemote(remote)) { - return remote - } - return (await findRemoteNameForUrl(git, worktreePath, remote)) ?? remote -} - -async function getConfiguredPushRemote( - git: RelayGit, - worktreePath: string, - branch: string -): Promise { - // Why: mirror the local gitPush resolver so SSH worktrees do not drift to a - // different target when branch.pushRemote or remote.pushDefault is present. - const branchRemote = await getConfigValue(git, worktreePath, `branch.${branch}.remote`) - const remote = - (await getConfigValue(git, worktreePath, `branch.${branch}.pushRemote`)) ?? - (await getConfigValue(git, worktreePath, 'remote.pushDefault')) ?? - branchRemote - if (!remote) { - return null - } - const normalizedRemote = await normalizePushRemote(git, worktreePath, remote) - // The two usually name the same URL; resolving it twice reads the remote table twice. - if (!branchRemote) { - return { remote: normalizedRemote, branchRemote: null } - } - return { - remote: normalizedRemote, - branchRemote: - branchRemote === remote - ? normalizedRemote - : await normalizePushRemote(git, worktreePath, branchRemote) - } -} - -async function branchMergeTargetsConfiguredBase( - git: RelayGit, - worktreePath: string, - branch: string, - remote: string, - branchRef: string -): Promise { - return gitRefTargetsBranchOnRemote( - await getConfigValue(git, worktreePath, `branch.${branch}.base`), - remote, - branchRef - ) -} - -function canPushConfiguredMergeBranch( - pushRemote: ConfiguredPushRemote | null, - branch: string, - branchRef: string -): boolean { - if (!pushRemote) { - return false - } - if (branchRef === branch) { - return true - } - // Why: branch.merge belongs to branch.remote. A pushDefault fork must not - // inherit origin/main as its destination branch. - return pushRemote.remote !== 'origin' && pushRemote.branchRemote === pushRemote.remote -} - export async function resolveRelayPushTarget( git: RelayGit, worktreePath: string, pushTarget: unknown -): Promise { +): Promise { if (pushTarget === undefined) { - return getConfiguredPushTarget(git, worktreePath) + return resolveConfiguredGitPushTarget((args) => git(args, worktreePath)) } assertGitPushTargetShape(pushTarget) const explicitTarget: GitPushTarget = pushTarget + // Why here and not in the shared resolver: an explicit target arrives over the wire, + // so the host re-validates its shape and asks Git to vet the branch name itself. await git(['check-ref-format', '--branch', explicitTarget.branchName], worktreePath) return { remote: explicitTarget.remoteName, diff --git a/src/relay/git-handler-worktree-remove.ts b/src/relay/git-handler-worktree-remove.ts index 474e03bab88..bf8e65a066e 100644 --- a/src/relay/git-handler-worktree-remove.ts +++ b/src/relay/git-handler-worktree-remove.ts @@ -1,5 +1,6 @@ import * as path from 'node:path' import type { RemoveWorktreeResult } from '../shared/worktree/create-types' +import { isBranchCheckedOutInWorktreeError } from '../shared/git-branch-delete-refusal' import { assertWorktreeUnlockedForRemoval } from '../shared/worktree/removal' import { isSubmoduleWorktreeRemovalRefusal } from '../shared/worktree/submodule-removal' import { deleteAlreadyMergedRelayBranchAfterSafeDeleteFailure } from './git-handler-branch-cleanup' @@ -7,29 +8,6 @@ import type { GitExec } from './git-handler-ops' import type { GitCapabilityCache } from '../shared/git-capability-cache' import { readRelayWorktreeList } from './git-handler-worktree-list' -function getErrorText(error: unknown): string { - if (typeof error === 'object' && error !== null) { - const parts: string[] = [] - if ('message' in error && typeof error.message === 'string') { - parts.push(error.message) - } - if ('stderr' in error && typeof error.stderr === 'string') { - parts.push(error.stderr) - } - if ('stdout' in error && typeof error.stdout === 'string') { - parts.push(error.stdout) - } - return parts.join('\n') - } - return String(error) -} - -function isBranchCheckedOutInWorktreeError(error: unknown): boolean { - return /cannot delete branch .*(?:used by worktree|checked out)|branch .*is checked out/i.test( - getErrorText(error) - ) -} - function normalizeLocalBranchRef(branch: string): string { return branch.replace(/^refs\/heads\//, '') } diff --git a/src/relay/git-push-target-local-parity.test.ts b/src/relay/git-push-target-local-parity.test.ts new file mode 100644 index 00000000000..152b062d88e --- /dev/null +++ b/src/relay/git-push-target-local-parity.test.ts @@ -0,0 +1,209 @@ +/** + * Push-target resolution decides which remote a plain `git push` hits, and a wrong + * answer is not recoverable by retrying. The relay and the desktop used to carry + * identical ~160-line copies of it; they now share one implementation. + * + * These tests script one repository's Git config and require `git.push` over the real + * relay dispatcher and the desktop's `gitPush` to emit the *same push argv*, plus the + * argv each case is supposed to produce — so a second implementation on either side + * fails here even if it is wrong in the same direction on both. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() })) + +vi.mock('../main/git/runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock +})) + +import { gitPush } from '../main/git/remote' +import { RelayContext } from './context' +import { GitHandler } from './git-handler' +import { createMockDispatcher, type RelayDispatcher } from './git-handler-test-setup' + +const WORKTREE_PATH = '/worktree' + +type GitConfigFixture = { + /** Empty means detached HEAD: `symbolic-ref --quiet --short HEAD` prints nothing. */ + branch: string + merge?: string + branchRemote?: string + pushRemote?: string + pushDefault?: string + base?: string + /** remote name -> fetch URL, as `git remote -v` prints it. */ + remotes?: Record +} + +type GitSpyTarget = { + git(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> +} + +/** One scripted repository, driven identically by both hosts. */ +function scriptGit(fixture: GitConfigFixture) { + const configValues = new Map() + const put = (key: string, value: string | undefined): void => { + if (value !== undefined) { + configValues.set(key, value) + } + } + put(`branch.${fixture.branch}.merge`, fixture.merge) + put(`branch.${fixture.branch}.remote`, fixture.branchRemote) + put(`branch.${fixture.branch}.pushRemote`, fixture.pushRemote) + put(`branch.${fixture.branch}.base`, fixture.base) + put('remote.pushDefault', fixture.pushDefault) + + const calls: string[][] = [] + return { + calls, + run: async (args: string[]): Promise<{ stdout: string; stderr: string }> => { + calls.push(args) + if (args[0] === 'symbolic-ref') { + return { stdout: `${fixture.branch}\n`, stderr: '' } + } + if (args[0] === 'config' && args[1] === '--get') { + const value = configValues.get(args[2] ?? '') + // Why throw: `git config --get` exits 1 for a missing key, and the resolver's + // fallback chain reads that rejection, not an empty string. + if (value === undefined) { + throw Object.assign(new Error('missing config key'), { code: 1 }) + } + return { stdout: `${value}\n`, stderr: '' } + } + if (args[0] === 'remote' && args[1] === '-v') { + const lines = Object.entries(fixture.remotes ?? {}).flatMap(([name, url]) => [ + `${name}\t${url} (fetch)`, + `${name}\t${url} (push)` + ]) + return { stdout: `${lines.join('\n')}\n`, stderr: '' } + } + if (args[0] === 'push') { + return { stdout: '', stderr: '' } + } + throw new Error(`Unexpected git command: ${args.join(' ')}`) + } + } +} + +function pushArgv(calls: string[][]): string[] { + const push = calls.find((args) => args[0] === 'push') + if (!push) { + throw new Error('no push command was issued') + } + return push +} + +async function pushOverRelay(fixture: GitConfigFixture): Promise { + const dispatcher = createMockDispatcher() + const handler = new GitHandler(dispatcher as unknown as RelayDispatcher, new RelayContext()) + const script = scriptGit(fixture) + vi.spyOn(handler as unknown as GitSpyTarget, 'git').mockImplementation((args) => script.run(args)) + await dispatcher.callRequest('git.push', { worktreePath: WORKTREE_PATH }) + return pushArgv(script.calls) +} + +async function pushLocally(fixture: GitConfigFixture): Promise { + const script = scriptGit(fixture) + gitExecFileAsyncMock.mockImplementation((args: string[]) => script.run(args)) + await gitPush(WORKTREE_PATH) + return pushArgv(script.calls) +} + +async function expectSamePushArgv(fixture: GitConfigFixture, expected: string[]): Promise { + const relayArgv = await pushOverRelay(fixture) + const localArgv = await pushLocally(fixture) + expect(relayArgv).toEqual(localArgv) + expect(localArgv).toEqual(expected) +} + +const FIRST_PUBLISH = ['push', '--set-upstream', 'origin', 'HEAD'] + +beforeEach(() => { + gitExecFileAsyncMock.mockReset() +}) + +describe('relay/desktop push-target parity', () => { + it('sends a review branch to the fork its pushDefault names', async () => { + await expectSamePushArgv( + { + branch: 'review/pr-1738', + merge: 'refs/heads/contributor/fix', + branchRemote: 'fork', + pushDefault: 'fork' + }, + ['push', '--set-upstream', 'fork', 'HEAD:contributor/fix'] + ) + }) + + it('refuses to inherit origin/main as a destination for a differently named branch', async () => { + // branch.merge belongs to branch.remote; a branch tracking origin/main must + // first-publish under its own name rather than push onto main. + await expectSamePushArgv( + { + branch: 'feature/fix', + merge: 'refs/heads/main', + branchRemote: 'origin' + }, + FIRST_PUBLISH + ) + }) + + it('refuses a pushDefault fork whose branch.remote names a different remote', async () => { + await expectSamePushArgv( + { + branch: 'review/pr-1738', + merge: 'refs/heads/contributor/fix', + branchRemote: 'origin', + pushDefault: 'fork' + }, + FIRST_PUBLISH + ) + }) + + it('refuses when branch.base names the same remote branch as branch.merge', async () => { + await expectSamePushArgv( + { + branch: 'feature/fix', + merge: 'refs/heads/release', + branchRemote: 'fork', + pushRemote: 'fork', + base: 'fork/release' + }, + FIRST_PUBLISH + ) + }) + + it('resolves a URL-valued pushRemote back to its remote name', async () => { + await expectSamePushArgv( + { + branch: 'review/pr-1738', + merge: 'refs/heads/contributor/fix', + branchRemote: 'git@example.invalid:contributor/repo.git', + pushRemote: 'git@example.invalid:contributor/repo.git', + remotes: { + origin: 'git@example.invalid:upstream/repo.git', + fork: 'git@example.invalid:contributor/repo.git' + } + }, + ['push', '--set-upstream', 'fork', 'HEAD:contributor/fix'] + ) + }) + + it('treats a local-repository remote as no configured target', async () => { + await expectSamePushArgv( + { + branch: 'feature/fix', + merge: 'refs/heads/feature/fix', + branchRemote: '.' + }, + FIRST_PUBLISH + ) + }) + + it('first-publishes a branch with no configured remote at all', async () => { + await expectSamePushArgv( + { branch: 'feature/fix', merge: 'refs/heads/feature/fix' }, + FIRST_PUBLISH + ) + }) +}) diff --git a/src/relay/pty-handler-attach-replay.test.ts b/src/relay/pty-handler-attach-replay.test.ts index 6af9faec047..e289547bf0c 100644 --- a/src/relay/pty-handler-attach-replay.test.ts +++ b/src/relay/pty-handler-attach-replay.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import * as ptyShellUtils from './pty-shell-utils' +import { + PTY_ATTACH_PROVEN_EXITED_MARKER, + isProvenExitedPtyAttachRefusal +} from '../shared/pty-attach-absence-evidence' const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ mockPtySpawn: vi.fn(), @@ -87,7 +91,7 @@ describe('PtyHandler', () => { try { await expect( dispatcher.callRequest('pty.attach', { id: PTY_1, suppressReplayNotification: true }) - ).rejects.toThrow(`PTY "${PTY_1}" not found`) + ).rejects.toThrow(`PTY "${PTY_1}" not found (${PTY_ATTACH_PROVEN_EXITED_MARKER})`) } finally { aliveSpy.mockRestore() } @@ -96,9 +100,18 @@ describe('PtyHandler', () => { // is freed so a later attach also cleanly reports not-found. expect(exits).toEqual([{ id: PTY_1, paneKey: 'tab-dead:0' }]) expect(handler.activePtyCount).toBe(0) - await expect( - dispatcher.callRequest('pty.attach', { id: PTY_1, suppressReplayNotification: true }) - ).rejects.toThrow(`PTY "${PTY_1}" not found`) + const unknownId = await dispatcher + .callRequest('pty.attach', { id: PTY_1, suppressReplayNotification: true }) + .then( + () => new Error('expected the attach to be refused'), + (error: Error) => error + ) + + // The second refusal is the shape a restarted relay gives for every id the previous one minted: + // same words, no liveness check behind them. Only the probed one may be read as a death + // (docs/reference/ssh-execution-boundary.md). + expect(unknownId.message).toContain(`PTY "${PTY_1}" not found`) + expect(isProvenExitedPtyAttachRefusal(unknownId)).toBe(false) }) it('settles concurrent immediate shutdown when attach proves the shell exited', async () => { diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 1c213325bb8..42f8bdc0a77 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -35,6 +35,7 @@ import { type RelaySpawnCwdResolution } from './pty-spawn-cwd' import { PhysicalExitTracker } from '../shared/physical-exit-tracker' +import { PTY_ATTACH_PROVEN_EXITED_MARKER } from '../shared/pty-attach-absence-evidence' import { SHELL_READY_MARKER_PREFIX } from '../main/shell-ready-marker-scanner' import { createShellStartupOutputScanState, @@ -2055,7 +2056,11 @@ export class PtyHandler { // Why: verify liveness because shells can exit without node-pty onExit. if (this.reapPtyProvenExited(managed)) { - throw new Error(`PTY "${id}" not found`) + // Why the marker: this is the ONLY not-found answer backed by a liveness check. The unmarked + // one above is also thrown for an id this session map never had — every id minted before a + // relay restart — so a client that cannot tell them apart certifies deaths it never observed + // (docs/reference/ssh-execution-boundary.md). + throw new Error(`PTY "${id}" not found (${PTY_ATTACH_PROVEN_EXITED_MARKER})`) } // Why: legacy `pty-N` ids repeated across relay generations; reject conflicting identities. diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index 91db232081c..77da19ffd20 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -274,9 +274,11 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta await timeRendererStartupStep('recover-legacy-worker-terminals-post-reconnect', () => window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() ) - await timeRendererStartupStep('project-structured-session-tabs', () => - restoreLocalStructuredSessionTabsOnce() - ) + if (useAppStore.getState().settings?.experimentalStructuredNativeChat === true) { + await timeRendererStartupStep('project-structured-session-tabs', () => + restoreLocalStructuredSessionTabsOnce() + ) + } if (cancelled) { return } diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index d1aad35829a..fead2f6c7bb 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -357,6 +357,16 @@ describe('renderer startup runtime routing', () => { expect(reconnectIndex).toBeGreaterThan(capabilityIndex) }) + it('skips startup structured tab projection while the host setting is off', () => { + const source = readSource(STARTUP_HYDRATION_PATH) + const projectIndex = source.indexOf("timeRendererStartupStep('project-structured-session-tabs'") + + expect(projectIndex).toBeGreaterThanOrEqual(0) + expect(source.slice(projectIndex - 180, projectIndex)).toContain( + 'settings?.experimentalStructuredNativeChat === true' + ) + }) + it('orders packaged restoration before adoption, projection, and default creation', () => { // Why this file: the startup sequence moved out of App.tsx into the hydration hook; // the ordering it asserts is unchanged, only the module that now spells it out. diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx index 8206354f916..1456f7e30ad 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx @@ -104,7 +104,7 @@ vi.mock('@/components/new-workspace/ProjectCombobox', () => ({ value: string | null onValueChange: (value: string) => void }) => ( -

+
{options.map((option) => ( + + + {translate('auto.components.NewWorkspaceComposerCard.d6b0a96f32', 'Add project')} + + + ) : null} +
+
+ + {projectError ? ( +

+ {projectError} +

+ ) : projectOptions.length === 0 ? ( +

+ {emptyProjectMessage ?? + translate( + 'auto.components.NewWorkspaceComposerCard.addProjectBeforeWorkspace', + 'Add a project before creating a workspace.' )} - > - - - - - {translate('auto.components.NewWorkspaceComposerCard.d6b0a96f32', 'Add project')} - - - ) : null} +

+ ) : null} +
- - {projectError ? ( -

- {projectError} -

- ) : projectOptions.length === 0 ? ( -

- {emptyProjectMessage ?? - translate( - 'auto.components.NewWorkspaceComposerCard.addProjectBeforeWorkspace', - 'Add a project before creating a workspace.' - )} -

- ) : null} {shouldShowRunTargetPicker ? (