Merge remote-tracking branch 'origin/main' into brennanb2025/claude-sweep-rederivation-fence

# Conflicts:
#	src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts
#	src/main/runtime/rpc/methods/structured-agent-session.test.ts
#	src/main/runtime/rpc/methods/structured-agent-session.ts
This commit is contained in:
Merge Sim
2026-09-03 19:35:16 -07:00
382 changed files with 33964 additions and 2464 deletions
@@ -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
@@ -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:
@@ -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}$ ]]
+21 -1
View File
@@ -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
+1
View File
@@ -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
+12
View File
@@ -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.<key>', '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:<locale>-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:<locale>-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.
+1 -1
View File
@@ -12,7 +12,7 @@
</p>
<p align="center">
<sub><a href="docs/readme/README.zh-CN.md">中文</a> · <a href="docs/readme/README.ja.md">日本語</a> · <a href="docs/readme/README.ko.md">한국어</a> · <a href="docs/readme/README.es.md">Español</a> · <a href="docs/readme/README.fr.md">Français</a> · <a href="docs/readme/README.pt.md">Português</a></sub>
<sub><a href="docs/readme/README.zh-CN.md">中文</a> · <a href="docs/readme/README.ja.md">日本語</a> · <a href="docs/readme/README.ko.md">한국어</a> · <a href="docs/readme/README.es.md">Español</a> · <a href="docs/readme/README.fr.md">Français</a> · <a href="docs/readme/README.pt.md">Português</a> · <a href="docs/readme/README.uk.md">Українська</a></sub>
</p>
<p align="center">
+7
View File
@@ -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==$''']
@@ -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 = {
@@ -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
+148 -48
View File
@@ -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<AssignmentActivityKind, number> = {
}
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<RelayAssignment> {
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<RelayAssignment | null> {
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<RelayAssignment> {
@@ -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<RelayAssignment | null> {
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<RelayAssignment> {
@@ -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<RegionalRehomeFleetSafety> {
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<SqlRow[]> {
// 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<SqlRow[]> {
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<CellRow | null> {
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'
}
@@ -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())
})
})
@@ -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))
}
@@ -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<string, Set<string>> {
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<string, Set<string>>()
bounds.forEach((method, index) => {
const end = bounds[index + 1]?.start ?? lines.length
const names = callees.get(method.name) ?? new Set<string>()
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<string, Set<string>>, roots: string[]): Set<string> {
const reached = new Set<string>()
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 = '<module>'
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([])
})
})
@@ -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<RelayDatabase> {
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<typeof RelayAssignmentStore>) => Promise<void>
): 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<string, unknown>[] = []
const original = console.warn
console.warn = (line: unknown, ...rest: unknown[]) => {
try {
const parsed = JSON.parse(line as string) as Record<string, unknown>
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<SqlRow[]> {
return await this.delegate.query(sql, params)
}
async queryLocked(
sql: string,
params?: unknown[],
options?: RelayLockOptions
): Promise<SqlRow[]> {
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<T>(
operation: (transaction: RelayDatabase) => Promise<T>,
options?: RelayTransactionOptions
): Promise<T> {
return await this.delegate.transaction(
async (transaction) => await operation(new InventoryLockProbeTransaction(transaction, this)),
options
)
}
async close(): Promise<void> {}
}
class InventoryLockProbeTransaction implements RelayDatabase {
constructor(
private readonly delegate: RelayDatabase,
private readonly probe: InventoryLockProbe
) {}
async query(sql: string, params?: unknown[]): Promise<SqlRow[]> {
return await this.delegate.query(sql, params)
}
async queryLocked(
sql: string,
params?: unknown[],
options?: RelayLockOptions
): Promise<SqlRow[]> {
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<T>(operation: (transaction: RelayDatabase) => Promise<T>): Promise<T> {
return await operation(this)
}
async close(): Promise<void> {}
}
@@ -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<unknown>>()
.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<unknown>>().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)']
+96 -7
View File
@@ -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<string, unknown>
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<SqlRow[]> {
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<SqlRow[]> {
return await this.query(sql, params)
const rows = await this.query(sql, params)
this.noteHeld(options)
return rows
}
async transaction<T>(
@@ -643,6 +688,11 @@ class SqliteTransaction implements RelayDatabase {
class SqliteDatabase extends SqliteTransaction {
private tail: Promise<void> = Promise.resolve()
private readonly holds = new CellInventoryHoldSamples()
consumeHoldCounts(): CellInventoryHoldCounts {
return this.holds.consumeCounts()
}
override async query(sql: string, params: unknown[] = []): Promise<SqlRow[]> {
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<SqlRow[]> {
try {
const result = await this.client.query(postgresSql(sql), params)
@@ -693,11 +753,21 @@ class PostgresTransaction implements RelayDatabase {
params: unknown[] = [],
options: RelayLockOptions = {}
): Promise<SqlRow[]> {
// 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<SqlRow[]> {
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<T> {
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 {
+5 -2
View File
@@ -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: () => ({
@@ -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<RelayDatabase> =>
await openRelayDatabase({ databaseUrl: scopedUrl, dataDir: '' })
const results = await Promise.allSettled(
Array.from({ length: 5 }, async (): Promise<RelayDatabase> =>
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)
})
@@ -22,15 +22,37 @@ function wait(delayMs: number): Promise<void> {
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)
)
}
@@ -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)
}
}
@@ -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 {
+4 -1
View File
@@ -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<CellInventoryHoldCounts>
export type RegionalRehomeRuntimeSafety = {
observedAt: number
+13 -3
View File
@@ -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
@@ -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)')
})
})
@@ -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)
}
@@ -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<number> {
const server = createNetServer()
await new Promise<void>((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<void>((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> | 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<void>((resolve) => relay.server.once('listening', resolve))
cleanup.push(() => new Promise<void>((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/)
})
})
@@ -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)
})
+8 -1
View File
@@ -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) {
@@ -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.-]+$/)
})
@@ -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)
}
@@ -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
@@ -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}`)
}
}
})
}
+17 -26
View File
@@ -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
@@ -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).
@@ -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
+5 -5
View File
@@ -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
+20 -8
View File
@@ -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
+1 -1
View File
@@ -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,
@@ -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",
File diff suppressed because one or more lines are too long
@@ -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) {
+27
View File
@@ -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",
@@ -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-<arch>` 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`)
@@ -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'
}
}
+3
View File
@@ -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 모바일 버튼 표시',
+35 -4
View File
@@ -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('|')
@@ -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: '粗体',
@@ -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-<arch>`, 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)
}
+9
View File
@@ -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
}
}
@@ -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()
@@ -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 }}')
})
})
@@ -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 = [
@@ -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:
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 37m">
<title>downloads: 37m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 38m">
<title>downloads: 38m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">37m</text>
<text x="90" y="14">37m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">38m</text>
<text x="90" y="14">38m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

+1 -1
View File
@@ -12,7 +12,7 @@
</p>
<p align="center">
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.fr.md">Français</a> · <a href="README.pt.md">Português</a></sub>
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.fr.md">Français</a> · <a href="README.pt.md">Português</a> · <a href="README.uk.md">Українська</a></sub>
</p>
<p align="center">
+1 -1
View File
@@ -12,7 +12,7 @@
</p>
<p align="center">
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a> · <a href="README.pt.md">Português</a></sub>
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a> · <a href="README.pt.md">Português</a> · <a href="README.uk.md">Українська</a></sub>
</p>
<p align="center">
+1 -1
View File
@@ -12,7 +12,7 @@
</p>
<p align="center">
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a> · <a href="README.fr.md">Français</a> · <a href="README.pt.md">Português</a></sub>
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a> · <a href="README.fr.md">Français</a> · <a href="README.pt.md">Português</a> · <a href="README.uk.md">Українська</a></sub>
</p>
<p align="center">
+1 -1
View File
@@ -12,7 +12,7 @@
</p>
<p align="center">
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.es.md">Español</a> · <a href="README.fr.md">Français</a> · <a href="README.pt.md">Português</a></sub>
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.es.md">Español</a> · <a href="README.fr.md">Français</a> · <a href="README.pt.md">Português</a> · <a href="README.uk.md">Українська</a></sub>
</p>
<p align="center">
+1 -1
View File
@@ -12,7 +12,7 @@
</p>
<p align="center">
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a> · <a href="README.fr.md">Français</a></sub>
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a> · <a href="README.fr.md">Français</a> · <a href="README.uk.md">Українська</a></sub>
</p>
<p align="center">
+269
View File
@@ -0,0 +1,269 @@
<h1 align="center">
<a href="https://onOrca.dev"><img src="../../resources/build/icon.png" alt="Orca" width="64" valign="middle" /></a> Orca
</h1>
<p align="center">
<a href="https://github.com/stablyai/orca"><img src="https://img.shields.io/github/stars/stablyai/orca?style=flat&amp;label=%E2%98%85&amp;color=08C" alt="Зірки на GitHub" /></a>
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="Загальна кількість завантажень усіх релізів" /></a>
<img src="https://img.shields.io/badge/license-MIT-08C?style=flat" alt="Ліцензія: MIT" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Приєднатися до Discord Orca" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="Стежити за Orca в X" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="Підтримувані платформи: macOS, Windows і Linux" />
</p>
<p align="center">
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a> · <a href="README.fr.md">Français</a> · <a href="README.pt.md">Português</a></sub>
</p>
<p align="center">
<strong>AI-оркестратор для розробників рівня 100x.</strong><br/>
Запускайте Codex, Claude Code, OpenCode або Pi паралельно — кожен у власному worktree, усі під контролем в одному місці.
</p>
<h3 align="center"><a href="https://onorca.dev/download"><ins>Завантажити Orca</ins></a></h3>
<p align="center">
<img src="../assets/readme-hero.jpg" alt="Десктопний застосунок Orca запускає агентів у паралельних worktree, у кутку — супутній мобільний застосунок Orca" width="960" />
</p>
## Можливості
<table>
<tr>
<td width="50%" valign="middle">
### Супутній мобільний застосунок
Стежте за агентами та керуйте ними з телефону — отримуйте сповіщення про завершення роботи агента та надсилайте подальші вказівки, де б ви не були.
[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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/mobile"><picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Десктоп Orca із супутнім мобільним застосунком" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Паралельні worktree
Надішліть один промпт одразу п’ятьом агентам, кожен із яких працюватиме у власному ізольованому git worktree, — порівняйте результати та виконайте злиття найкращого з них.
[Документація →](https://www.onorca.dev/docs/model/worktrees)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/model/worktrees"><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="Оркестрація паралельних worktree" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Розділені термінали
Термінали рівня Ghostty з рендерингом на WebGL, необмеженою кількістю розділень і буфером прокручування, який зберігається після перезапуску.
[Документація →](https://www.onorca.dev/docs/terminal)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/terminal"><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="Розділені термінали" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Режим дизайну
Клацніть на будь-якому елементі інтерфейсу у справжньому вікні Chromium, щоб надіслати його HTML, CSS і обрізаний скриншот прямо в промпт агента.
[Документація →](https://www.onorca.dev/docs/browser/design-mode)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/browser/design-mode"><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="Вбудований браузер і режим дизайну" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### GitHub і Linear, нативно
Переглядайте PR, issue та дошки проєктів прямо в застосунку — відкривайте worktree з будь-якої задачі та рев'юйте без перемикання контексту.
[Документація →](https://www.onorca.dev/docs/review/linear)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/linear"><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Робочі процеси GitHub і Linear в Orca" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### SSH worktree
Запускайте агентів на потужній віддаленій машині з повноцінним редагуванням файлів, git і терміналами — з автоперепідключенням і прокиданням портів.
[Документація →](https://www.onorca.dev/docs/ssh)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/ssh"><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="Віддалені worktree через SSH" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Анотуйте diff-и агентів
Залишайте коментарі на будь-якому рядку diff-у й надсилайте їх агенту — рев'юйте, редагуйте та комітьте, не виходячи з Orca.
[Документація →](https://www.onorca.dev/docs/review/annotate-ai-diff)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="Анотування diff-ів, згенерованих AI" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Перетягуйте файли агентам
Редактор на базі VS Code з автозбереженням усюди — перетягуйте файли чи зображення прямо в промпт агента.
[Документація →](https://www.onorca.dev/docs/editing/file-explorer)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/editing/file-explorer"><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="Перетягування файлів і зображень у промпт агента" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Orca CLI
Агенти теж керують Orca — автоматизуйте будь-який робочий процес командами `orca worktree create`, `snapshot`, `click` і `fill`.
[Документація →](https://www.onorca.dev/docs/cli/overview)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/cli/overview"><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="Керування Orca з CLI" width="100%" /></picture></a>
</td>
</tr>
</table>
**Також у комплекті:**
- **[Швидкий пошук](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.
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" alt="Claude Code logo" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" alt="Codex logo" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Grok logo" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="MiMo Code logo" width="16" valign="middle" /> MiMo Code</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" alt="Antigravity logo" width="16" valign="middle" /> Antigravity</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Pi logo" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" alt="oh-my-pi logo" width="16" valign="middle" /> oh-my-pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" alt="Hermes Agent logo" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
<a href="https://devin.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=devin.ai&sz=64" alt="Devin logo" width="16" valign="middle" /> Devin</kbd></a> &nbsp;
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" alt="Goose logo" width="16" valign="middle" /> Goose</kbd></a> &nbsp;
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" alt="Auggie logo" width="16" valign="middle" /> Auggie</kbd></a> &nbsp;
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" alt="Autohand Code logo" width="16" valign="middle" /> Autohand Code</kbd></a> &nbsp;
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" alt="Charm logo" width="16" valign="middle" /> Charm</kbd></a> &nbsp;
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" alt="Cline logo" width="16" valign="middle" /> Cline</kbd></a> &nbsp;
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" alt="Codebuff logo" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" alt="Command Code logo" width="16" valign="middle" /> Command Code</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" alt="Continue logo" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" alt="Droid logo" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" alt="Kilocode logo" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" alt="Kimi logo" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" alt="Kiro logo" width="16" valign="middle" /> Kiro</kbd></a> &nbsp;
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" alt="Mistral Vibe logo" width="16" valign="middle" /> Mistral Vibe</kbd></a> &nbsp;
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" alt="Qwen Code logo" width="16" valign="middle" /> Qwen Code</kbd></a> &nbsp;
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" alt="Rovo Dev logo" width="16" valign="middle" /> Rovo Dev</kbd></a> &nbsp;
<kbd>+ будь-який CLI-агент</kbd>
</p>
---
## Встановлення
### Десктоп — 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.
<img src="../assets/wechat-qr-group7.jpg" alt="QR-код групи WeChat 7 спільноти Orca" width="160" />&nbsp;&nbsp;
<img src="../assets/wechat-qr-group8.jpg" alt="QR-код групи WeChat 8 спільноти Orca" width="160" />
- **Зворотний зв'язок та ідеї:** Ми випускаємо оновлення швидко. Чогось бракує? [Запропонуйте нову функцію](https://github.com/stablyai/orca/issues).
- **Конфіденційність:** Перегляньте [документацію про конфіденційність і телеметрію](https://www.onorca.dev/docs/telemetry), щоб дізнатися, які анонімні дані про використання збирає Orca і як від цього відмовитися.
- **Підтримайте нас:** Поставте [зірку](https://github.com/stablyai/orca) цьому репозиторію, щоб стежити за нашими щоденними релізами.
---
## Розробка
Хочете зробити внесок або запустити проєкт локально? Перегляньте наш посібник [CONTRIBUTING.md](../../.github/CONTRIBUTING.md).
<a href="https://github.com/stablyai/orca/graphs/contributors">
<img src="https://contrib.rocks/image?repo=stablyai/orca" alt="Контриб'ютори Orca" />
</a>
<p align="center">
<img src="../assets/star-history.png" alt="Графік історії зірок на GitHub для stablyai/orca" width="880" />
</p>
## Підписані білди
Підписання коду для Windows надано за підтримки [SignPath.io](https://signpath.io), сертифікат надано [SignPath Foundation](https://signpath.org).
## Ліцензія
Orca — безкоштовний проєкт із відкритим кодом за ліцензією [MIT](../../LICENSE).
+1 -1
View File
@@ -12,7 +12,7 @@
</p>
<p align="center">
<sub><a href="../../README.md">English</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a> · <a href="README.fr.md">Français</a> · <a href="README.pt.md">Português</a></sub>
<sub><a href="../../README.md">English</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a> · <a href="README.fr.md">Français</a> · <a href="README.pt.md">Português</a> · <a href="README.uk.md">Українська</a></sub>
</p>
<p align="center">
+17
View File
@@ -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 "<id>" 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 <tty>`, 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.
+5 -5
View File
@@ -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
+45 -36
View File
@@ -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<void> => {
await sendAnswer(formatQuestionAnswer(question, [option]))
const answerSingle = async (option: string, optionIndex: number): Promise<void> => {
const token = question.optionTokens[optionIndex]
await sendAnswer(token && token.length > 0 ? token : formatQuestionAnswer(question, [option]))
}
const submitMulti = async (): Promise<void> => {
@@ -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 ? (
<View style={styles.options}>
{optionRows.map(({ label, key }) => {
{optionRows.map(({ label, key }, optIndex) => {
const isSelected = selected.includes(label)
return (
<Pressable
@@ -93,7 +98,9 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J
isSelected && styles.optionSelected,
pressed && styles.pressed
]}
onPress={() => (question.multiSelect ? toggle(label) : answerSingle(label))}
onPress={() =>
question.multiSelect ? toggle(label) : answerSingle(label, optIndex)
}
>
{question.multiSelect ? (
<View style={[styles.checkbox, isSelected && styles.checkboxOn]}>
@@ -124,35 +131,37 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J
</Pressable>
) : null}
<View style={styles.freeTextRow}>
<TextInput
style={styles.freeInput}
value={freeText}
onChangeText={setFreeText}
placeholder={hasOptions ? 'Or type a reply…' : 'Type your reply…'}
placeholderTextColor={colors.textMuted}
selectionColor={colors.accentBlue}
onSubmitEditing={submitFreeText}
returnKeyType="send"
multiline
/>
<Pressable
accessibilityLabel="Send reply"
style={({ pressed }) => [
styles.freeSend,
!canSendFreeText && styles.freeSendDisabled,
pressed && canSendFreeText && styles.pressed
]}
onPress={submitFreeText}
disabled={!canSendFreeText}
>
<ArrowUp
size={18}
color={canSendFreeText ? colors.bgBase : colors.textMuted}
strokeWidth={2.6}
{allowOther ? (
<View style={styles.freeTextRow}>
<TextInput
style={styles.freeInput}
value={freeText}
onChangeText={setFreeText}
placeholder={hasOptions ? 'Or type a reply…' : 'Type your reply…'}
placeholderTextColor={colors.textMuted}
selectionColor={colors.accentBlue}
onSubmitEditing={submitFreeText}
returnKeyType="send"
multiline
/>
</Pressable>
</View>
<Pressable
accessibilityLabel="Send reply"
style={({ pressed }) => [
styles.freeSend,
!canSendFreeText && styles.freeSendDisabled,
pressed && canSendFreeText && styles.pressed
]}
onPress={submitFreeText}
disabled={!canSendFreeText}
>
<ArrowUp
size={18}
color={canSendFreeText ? colors.bgBase : colors.textMuted}
strokeWidth={2.6}
/>
</Pressable>
</View>
) : null}
</View>
)
}
@@ -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 ?? ''}
@@ -168,6 +168,7 @@ export function MobileSessionHeader({ controller }: { controller: MobileSessionC
{t.type === 'file' && (
<File size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
{t.type === 'agent-session' && <MobileAgentIcon agentId={t.agent} size={13} />}
{t.type === 'terminal' &&
(() => {
const agentId = resolveMobileTerminalTabAgentId(t)
@@ -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}
/>
<ActionSheetModal
visible={agentSessionActionTarget != null}
title={agentSessionActionTarget?.title || 'Chat'}
actions={closeWithBulkActions(agentSessionActionTarget, () =>
setAgentSessionActionTarget(null)
)}
onClose={() => setAgentSessionActionTarget(null)}
/>
<ActionSheetModal
visible={leaveDrafts != null}
title="Unsaved markdown changes"
+3 -1
View File
@@ -36,8 +36,10 @@ export type OpenMobileFileTapOptions<T extends FileTapSessionTab> = {
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
@@ -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<MobileNativeChatSendOutcome>
/** 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). */
@@ -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)
@@ -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
}
@@ -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<string, PendingNativeChatImage[]>
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
}
@@ -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)
)
})
})
@@ -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])
}
@@ -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)
@@ -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
@@ -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<string | null>(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')"
@@ -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<AgentJournalRenderItem['body'], { kind: 'approval' }>
}
export type StructuredQuestionItem = AgentJournalRenderItem & {
body: Extract<AgentJournalRenderItem['body'], { kind: 'question' }>
}
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<string, unknown>
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<PromptTokenPayload, { kind: 'question-free-text' }>
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
}
@@ -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<typeof vi.fn> } {
let responseIndex = 0
const sendRequest = vi.fn(async () => responses[responseIndex++])
return { sendRequest } as unknown as RpcClient & { sendRequest: ReturnType<typeof vi.fn> }
}
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'
})
})
})
@@ -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<MobileStructuredCodexLaunchResult> {
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<AgentSessionAttachResult>
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 }
}
@@ -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<TValue> =
| { status: 'accepted'; value: TValue }
| { status: 'refused'; message: string }
| { status: 'failed'; message: string }
| { status: 'unknown' }
export type StructuredAgentSessionMutationResult<TValue> =
| { status: 'accepted'; value: TValue; sameFence: boolean }
| { status: 'rejected' }
| { status: 'unknown' }
export type StructuredAgentSessionMutate = <TValue>(
method: string,
fingerprintMethod: string,
fields: Record<string, unknown>
) => Promise<StructuredAgentSessionMutationResult<TValue>>
export async function callAgentSession<TResult>(
client: RpcClient,
method: string,
params: unknown,
timeoutMs = STRUCTURED_SEND_TIMEOUT_MS,
options?: { failWhenDisconnected?: boolean }
): Promise<TResult> {
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<string, string>,
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<TValue>(args: {
client: RpcClient
method: string
fingerprintMethod: string
sessionId: string
expectedRuntimeFence: number
fields: Record<string, unknown>
clientOperationId?: string
retryUnknown?: boolean
timeoutMs?: number
}): Promise<StructuredAgentSessionMutationCallResult<TValue>> {
const {
client,
method,
fingerprintMethod,
sessionId,
expectedRuntimeFence,
fields,
clientOperationId,
retryUnknown,
timeoutMs
} = args
try {
const result = await callAgentSession<AgentSessionMutationResult<TValue>>(
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'
}
}
}
@@ -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<string, string>()
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<string, string>()
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<string, string>()
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)
})
})
@@ -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<TerminalRecord> & { handle: string }): TerminalRecord => ({
title: 'Terminal',
terminalTheme: undefined,
@@ -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
}
}
@@ -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')
})
})
@@ -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.
@@ -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)
})
})
@@ -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<T extends OpenedMobileS
export function shouldActivateOpenedMobileSessionTab(
state: OpenedMobileSessionTabActivationState
): boolean {
return (
!state.activated &&
state.activationSeq === state.latestActivationSeq &&
state.activeTabType === 'terminal' &&
state.activeTerminalHandle === state.sourceTerminalHandle
)
const sourceStillActive =
state.activeTabType === 'agent-session'
? state.sourceSessionTabId !== null &&
state.sourceSessionTabId !== undefined &&
state.activeSessionTabId === state.sourceSessionTabId
: state.activeTabType === 'terminal' &&
state.sourceTerminalHandle !== null &&
state.activeTerminalHandle === state.sourceTerminalHandle
return !state.activated && state.activationSeq === state.latestActivationSeq && sourceStillActive
}
export async function activateOpenedMobileSessionTab<T extends OpenedMobileSessionTabCandidate>(
@@ -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 }
)
})
})
@@ -141,15 +141,13 @@ export function useMobileFileTapHandlers<T extends FileTapSessionTab>(
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<T>({
client: current.client,
hostId: current.hostId,
@@ -172,6 +170,8 @@ export function useMobileFileTapHandlers<T extends FileTapSessionTab>(
latestActivationSeq: activationSeqRef.current,
sourceTerminalHandle,
activeTerminalHandle: current.activeHandleRef.current,
sourceSessionTabId: nativeChatTabId,
activeSessionTabId: current.getActiveSessionTabId(),
activeTabType: current.getActiveSessionTabType()
}),
switchSessionTab: current.switchSessionTab,
@@ -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<string | null>
nativeChatTranscriptIsLocalReadable: boolean
}): {
isTabChatView: (tabId: string) => boolean
toggleTabChatView: (tabId: string) => void
showNativeChat: boolean
showNativeChatRef: MutableRefObject<boolean>
activeChatAgent: string | null
activeChatAgentRef: MutableRefObject<string | null>
activeChatSessionId: string | null
activeChatStructured: boolean
activeChatResolution: ReturnType<typeof resolveMobileNativeChat>
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<string | null>(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
}
}
@@ -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<string, unknown>[] = []
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<string, unknown>) => {
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
@@ -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<string | null>(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
}
}
@@ -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<T> = { 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<MobileNativeChatSendOutcome>
/** 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<boolean>
}
const NO_ATTACHMENTS: PendingNativeChatImage[] = []
function withScopeAttachments(
byScope: Record<string, PendingNativeChatImage[]>,
scope: string,
next: PendingNativeChatImage[]
): Record<string, PendingNativeChatImage[]> {
if (next.length > 0) {
return { ...byScope, [scope]: next }
}
const remaining = { ...byScope }
delete remaining[scope]
return remaining
}
const defaultSleep = (ms: number): Promise<void> =>
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<string, PendingNativeChatImage[]>
>({})
const [isAttaching, setIsAttaching] = useState(false)
const [attachmentsByScope, setAttachmentsByScope] = useState<MobileNativeChatImagesByScope>({})
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<void> => {
// 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<PendingNativeChatImage, 'id'>[] = []
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<PendingNativeChatImage, 'id'>[]) => {
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 —
@@ -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<T> = { readonly current: T }
type UploadedNativeChatImage = Omit<PendingNativeChatImage, 'id'>
type ShowToast = (message: string, durationMs?: number) => void
export function useMobileNativeChatImageUpload(args: {
client: RpcClient | null
activeHandleRef: CurrentRef<string | null>
getActiveWorktreeConnectionId: () => Promise<string | null>
connState: ConnectionState
scopeKey: string | null
structuredNativeChat: boolean
showToast: ShowToast
onImagesUploaded: (scope: string, images: UploadedNativeChatImage[]) => void
onAttachSuccess?: () => void
onError?: () => void
}): {
attachImage: (source: MobileImageSource) => Promise<void>
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<void> => {
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 }
}
@@ -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<MobileNativeChatSendOutcome>
hostId: string
isTabChatView: (tabId: string) => boolean
isWorking: boolean
reportedModel: string | null
structured: {
snapshot: SessionOptionDescriptor[]
pendingId: string | null
setOption: (id: string, value: SessionOptionValue) => Promise<boolean>
invokeAction: (id: string) => Promise<boolean>
}
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<MobileNativeChatSessionOptionsController | null>(
() =>
activeChatStructured && structuredSnapshot.length > 0
? {
snapshot: structuredSnapshot,
pendingId: structuredPendingId,
setOption: setStructuredOption,
invokeAction: invokeStructuredAction,
recordCommand: () => {}
}
: null,
[
activeChatStructured,
invokeStructuredAction,
setStructuredOption,
structuredPendingId,
structuredSnapshot
]
)
const nativeChatSessionOptions = useMemo<MobileNativeChatSessionOptionPickersProps | null>(
() =>
activeChatStructured
? structuredController
? { controller: structuredController, isWorking }
: null
: sessionOptions.snapshot.length > 0
? { controller: sessionOptions, isWorking }
: null,
[activeChatStructured, isWorking, sessionOptions, structuredController]
)
return { nativeChatSessionOptions, recordCommand: sessionOptions.recordCommand }
}
@@ -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,
@@ -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),
@@ -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,

Some files were not shown because too many files have changed in this diff Show More