From 3b82d8de6422ed415e80dd2fdb75d73bcae610ea Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 11 Sep 2026 03:19:01 -0400 Subject: [PATCH 001/191] fix(runtime): let connections own host status recovery (#20003) * fix(runtime): let connections own host status recovery Verify runtime status after authenticated connection recovery and publish ordered snapshots to desktop and browser viewers. Consolidate failed-status retries in the connection owner and remove renderer retry/diagnostics merging. Adapt sidebar host-state derivation and regression coverage from Omar Shahine's original fix in https://github.com/stablyai/orca/pull/19163. Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com> * fix(runtime): show blocked hosts honestly and remove obsolete status options * fix(runtime): preserve timeout guidance and update IPC test fixtures * fix(runtime): preserve status evidence and address review gaps * test(sidebar): assert workspace host icons dimming and recovery tooltips * fix(palette): require available hosts before adding implicit badges * fix: retain disconnected host snapshots for new renderers --------- Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com> --- config/reliability-gates.jsonc | 75 +++++ ...me-environment-capability-evidence.test.ts | 7 +- ...runtime-environment-capability-evidence.ts | 7 - ...ntime-environment-connectivity-handlers.ts | 19 +- ...environment-federated-read-routing.test.ts | 22 +- .../runtime-environment-handler-channels.ts | 1 + ...me-environment-request-connections.test.ts | 16 +- ...runtime-environment-request-connections.ts | 68 +++++ ...time-environment-shared-control-support.ts | 118 ++------ ...time-environment-status-connection.test.ts | 65 ++++ .../ipc/runtime-environment-status-owner.ts | 89 ++++++ ...untime-environment-status-recovery.test.ts | 89 ++++++ ...untime-environment-support-routing.test.ts | 1 - .../runtime-environment-support-routing.ts | 6 +- ...t-transport-routing-tailscale-hint.test.ts | 8 +- .../runtime-environment-transport-routing.ts | 102 ++----- .../runtime-environments-call-routing.test.ts | 43 +-- ...time-environments-capability-cache.test.ts | 39 ++- .../runtime-environments-ipc-test-harness.ts | 56 ++++ .../ipc/runtime-environments-pairing.test.ts | 46 ++- ...me-environments-status-diagnostics.test.ts | 99 +++--- ...nvironments-subscription-lifecycle.test.ts | 32 +- ...-environments-subscription-routing.test.ts | 32 +- ...environments-subscription-teardown.test.ts | 32 +- src/main/ipc/runtime-environments.ts | 14 +- src/preload/api/runtime-api.ts | 3 + .../api/runtime-environments-bridge.ts | 14 + .../components/NewWorkspaceComposerCard.tsx | 12 +- .../cmd-j/palette-host-badge.test.ts | 19 +- .../components/cmd-j/palette-host-badge.ts | 2 +- .../settings/RepositoryHostSetupsSection.tsx | 11 +- .../use-runtime-environment-catalog.ts | 16 +- ...-runtime-environment-connection-actions.ts | 20 +- .../sidebar/AddRemoteHostDialog.tsx | 7 +- .../sidebar/HostSectionHeaderMenu.tsx | 12 +- .../sidebar/NoticeHostGlyph.test.tsx | 14 +- .../components/sidebar/NoticeHostGlyph.tsx | 10 +- ...WorktreeCard.ssh-reconnect-prompt.test.tsx | 23 +- .../sidebar/sidebar-host-options.test.ts | 5 +- .../sidebar/use-worktree-card-foundation.ts | 13 +- .../status-bar/SshStatusSegment.tsx | 16 +- .../src/hooks/ipc-events-test-harness.ts | 3 + .../ipc-events/app-lifetime-ipc-bridge.ts | 23 +- .../ipc-events/runtime-client-ipc-bridge.ts | 136 +-------- .../runtime-reconnect-host-status.test.ts | 281 ++---------------- .../src/hooks/useIpcEvents-lifecycle.test.ts | 6 +- .../runtime-host-connection-state.test.ts | 71 +++++ .../runtime/runtime-host-connection-state.ts | 42 +++ .../runtime-status-diagnostics-generation.ts | 52 ---- .../runtime-status-diagnostics-publish.ts | 106 ------- .../slices/runtime-status-diagnostics.test.ts | 89 ------ .../slices/runtime-status-recheck.test.ts | 238 --------------- .../store/slices/runtime-status-recheck.ts | 167 ----------- .../store/slices/runtime-status-refresh.ts | 16 + ...tatus-restored-browser-host-attach.test.ts | 32 +- .../slices/runtime-status-snapshot.test.ts | 111 +++++++ .../store/slices/runtime-status-snapshot.ts | 43 +++ .../src/store/slices/runtime-status-types.ts | 20 +- .../src/store/slices/runtime-status.test.ts | 42 +-- .../src/store/slices/runtime-status.ts | 75 ++--- .../web-runtime-environments-api.ts | 18 +- .../web/preload-api/web-runtime-session.ts | 54 +++- .../web-runtime-client-export-parity.test.ts | 9 +- .../web-runtime-client-timeout-budget.test.ts | 2 +- src/renderer/src/web/web-runtime-client.ts | 71 ++++- .../web/web-runtime-connection-transport.ts | 13 +- .../src/web/web-runtime-connection-waiters.ts | 30 +- .../src/web/web-runtime-request-registry.ts | 33 +- .../src/web/web-runtime-status-owner.test.ts | 49 +++ src/shared/execution-host-registry.test.ts | 32 +- src/shared/execution-host-registry.ts | 31 +- ...mote-runtime-shared-control-test-server.ts | 3 +- src/shared/runtime-host-status-owner.test.ts | 207 +++++++++++++ src/shared/runtime-host-status-owner.ts | 274 +++++++++++++++++ src/shared/runtime-host-status.ts | 44 +++ .../e2e/runtime-host-status-recovery.spec.ts | 221 ++++++++++++++ 76 files changed, 2315 insertions(+), 1612 deletions(-) create mode 100644 src/main/ipc/runtime-environment-status-connection.test.ts create mode 100644 src/main/ipc/runtime-environment-status-owner.ts create mode 100644 src/main/ipc/runtime-environment-status-recovery.test.ts delete mode 100644 src/renderer/src/store/slices/runtime-status-diagnostics-generation.ts delete mode 100644 src/renderer/src/store/slices/runtime-status-diagnostics-publish.ts delete mode 100644 src/renderer/src/store/slices/runtime-status-diagnostics.test.ts delete mode 100644 src/renderer/src/store/slices/runtime-status-recheck.test.ts delete mode 100644 src/renderer/src/store/slices/runtime-status-recheck.ts create mode 100644 src/renderer/src/store/slices/runtime-status-snapshot.test.ts create mode 100644 src/renderer/src/store/slices/runtime-status-snapshot.ts create mode 100644 src/renderer/src/web/web-runtime-status-owner.test.ts create mode 100644 src/shared/runtime-host-status-owner.test.ts create mode 100644 src/shared/runtime-host-status-owner.ts create mode 100644 src/shared/runtime-host-status.ts create mode 100644 tests/e2e/runtime-host-status-recovery.spec.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 0ba70ddcf1e..4e1f44bd7c1 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -10,6 +10,81 @@ } }, "gates": [ + { + "id": "runtime.connection-owned-host-status", + "title": "Host status recovers with its owning connection", + "maturity": "experimental", + "protection": "partial", + "owner": "runtime", + "layer": "service-integration-and-e2e", + "surfaces": [ + "sidebar host status", + "desktop runtime connection", + "browser primary connection" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["remote-runtime"], + "coverageNotes": "Real authenticated sockets plus isolated desktop and headless hosts with desktop and browser viewers; deterministic lifecycle tests cover stale results and reader deadlines.", + "motivatingLinks": ["https://github.com/stablyai/orca/pull/19163"], + "invariant": "Failed bootstrap and authenticated reconnect converge without UI triggers; one connection owner publishes verified status, with no independent healthy status polling.", + "oracle": "Observe automatic recovery, retained runtime identity on failure, ordered publications, exact request counts, isolated viewer outages, and retirement on disconnect.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/runtime-host-status-owner.test.ts src/main/ipc/runtime-environment-status-recovery.test.ts src/main/ipc/runtime-environment-status-connection.test.ts src/renderer/src/store/slices/runtime-status-snapshot.test.ts src/renderer/src/web/web-runtime-status-owner.test.ts", + "ORCA_BACKGROUND_LAUNCH=1 ORCA_E2E_WEB_CLIENT=1 pnpm exec playwright test tests/e2e/runtime-host-status-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + ], + "testFiles": [ + "src/shared/runtime-host-status-owner.test.ts", + "src/main/ipc/runtime-environment-status-recovery.test.ts", + "src/main/ipc/runtime-environment-status-connection.test.ts", + "src/renderer/src/store/slices/runtime-status-snapshot.test.ts", + "src/renderer/src/web/web-runtime-status-owner.test.ts", + "tests/e2e/runtime-host-status-recovery.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/main/ipc/runtime-environment-status-recovery.test.ts", + "assertions": [ + "recovers a saved host after its first status check fails, without another UI request" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-10", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 ORCA_E2E_WEB_CLIENT=1 pnpm exec playwright test tests/e2e/runtime-host-status-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "summary": "Desktop-host and headless-host journeys passed with desktop and browser viewers.", + "durationSeconds": 31.3 + } + ], + "runtimeBudget": { + "p95Seconds": 180, + "scope": "Target excluding builds; measured p95 not established." + }, + "flakeHistory": { + "status": "soaking", + "evidence": "Local candidate runs passed; no sustained CI history yet." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "First-status-failure oracle failed on main 58ff95becb40 (one request instead of two) and passes on the candidate. E2E verifies candidate recovery, not a baseline comparison." + }, + "performanceBudget": { + "required": false, + "evidence": "Deterministic tests assert one shared request and no healthy owner polling." + }, + "promotionCriteria": ["Collect repeated CI runs without unexplained flakes."], + "knownGaps": [ + "No live Linux, Windows, SSH, or mixed-version pair validation.", + "TCP interruption exercises reconnect, not a full real host process restart.", + "The outage begins on the first saved-host check, not by relaunching a preseeded desktop profile." + ], + "demotionRule": "Keep experimental until repeated runs establish reliability; preserve request-count and lifecycle assertions." + }, { "id": "mobile-push.headless-startup-and-policy", "title": "Headless push lifecycle and mobile delivery policy", diff --git a/src/main/ipc/runtime-environment-capability-evidence.test.ts b/src/main/ipc/runtime-environment-capability-evidence.test.ts index 4671326ef02..8c9bce5f272 100644 --- a/src/main/ipc/runtime-environment-capability-evidence.test.ts +++ b/src/main/ipc/runtime-environment-capability-evidence.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' import type { PairingOffer } from '../../shared/pairing' import { advanceRuntimeEnvironmentCapabilityIncarnation, @@ -17,7 +17,6 @@ describe('runtime environment capability evidence', () => { it('accepts evidence by dispatch order instead of completion order', () => { const older = captureRuntimeEnvironmentCapabilityEvidence('env', pairing()) const newer = captureRuntimeEnvironmentCapabilityEvidence('env', pairing()) - const pause = vi.fn() expect( applyRuntimeEnvironmentCapabilityVerdict({ @@ -30,12 +29,10 @@ describe('runtime environment capability evidence', () => { applyRuntimeEnvironmentCapabilityVerdict({ evidence: older, verdict: 'absent', - runtimeId: 'runtime-old', - onAbsent: pause + runtimeId: 'runtime-old' }) ).toBe(false) - expect(pause).not.toHaveBeenCalled() expect(isRuntimeEnvironmentCapabilityPaused('env')).toBe(false) }) diff --git a/src/main/ipc/runtime-environment-capability-evidence.ts b/src/main/ipc/runtime-environment-capability-evidence.ts index d32bda584e9..197ec71f4f8 100644 --- a/src/main/ipc/runtime-environment-capability-evidence.ts +++ b/src/main/ipc/runtime-environment-capability-evidence.ts @@ -68,8 +68,6 @@ export function applyRuntimeEnvironmentCapabilityVerdict(args: { evidence: RuntimeEnvironmentCapabilityEvidence verdict: RuntimeEnvironmentCapabilityVerdict runtimeId: string - onCapable?: () => void - onAbsent?: () => void }): boolean { const state = stateFor(args.evidence.environmentId) if ( @@ -83,11 +81,6 @@ export function applyRuntimeEnvironmentCapabilityVerdict(args: { verdict: args.verdict, runtimeId: args.runtimeId } - if (args.verdict === 'capable') { - args.onCapable?.() - } else { - args.onAbsent?.() - } return true } diff --git a/src/main/ipc/runtime-environment-connectivity-handlers.ts b/src/main/ipc/runtime-environment-connectivity-handlers.ts index 1e267d8675a..bfbc63847c4 100644 --- a/src/main/ipc/runtime-environment-connectivity-handlers.ts +++ b/src/main/ipc/runtime-environment-connectivity-handlers.ts @@ -20,6 +20,8 @@ import { verifyAndAddRuntimeEnvironmentFromPairingCode } from './runtime-environ import { clearRuntimeEnvironmentCapabilityEvidence } from './runtime-environment-capability-evidence' import { closeRemoteRuntimeRequestConnection, + getRuntimeEnvironmentStatusOwner, + getRuntimeEnvironmentStatusSnapshots, retryRemoteRuntimeSharedControlConnectionNow } from './runtime-environment-request-connections' import { @@ -29,7 +31,6 @@ import { } from './runtime-environment-manual-disconnect' import { callRuntimeEnvironment, - clearSharedControlSupport, getRuntimeEnvironmentStatus } from './runtime-environment-transport-routing' @@ -60,6 +61,9 @@ export function registerRuntimeEnvironmentConnectivityHandlers({ getUserDataPath, invalidateTransport }: ConnectivityHandlerOptions): void { + ipcMain.handle('runtimeEnvironments:getStatusSnapshots', () => + getRuntimeEnvironmentStatusSnapshots() + ) ipcMain.handle('runtimeEnvironments:list', () => listEnvironments(getUserDataPath()).map(redactRuntimeEnvironment) ) @@ -80,6 +84,12 @@ export function registerRuntimeEnvironmentConnectivityHandlers({ const result = await verifyAndAddRuntimeEnvironmentFromPairingCode(getUserDataPath(), args) if (result.ok) { clearRuntimeEnvironmentManualDisconnect(result.environment.id) + getRuntimeEnvironmentStatusOwner(getUserDataPath(), result.environment.id).acceptVerified({ + id: 'status.get', + ok: true, + result: result.runtimeStatus, + _meta: { runtimeId: result.runtimeStatus.runtimeId } + }) } return result } @@ -121,6 +131,8 @@ export function registerRuntimeEnvironmentConnectivityHandlers({ markRuntimeEnvironmentManuallyDisconnected(environment.id) invalidateTransport(environment.id) closeLegacySelectorTransport(args.selector, environment.id) + // Retain disconnected evidence for renderers that missed the teardown event. + getRuntimeEnvironmentStatusOwner(getUserDataPath(), environment.id) return { disconnected: redactRuntimeEnvironment(environment) } } ) @@ -132,7 +144,9 @@ export function registerRuntimeEnvironmentConnectivityHandlers({ ): Promise> => { const environment = resolveEnvironment(getUserDataPath(), args.selector) clearRuntimeEnvironmentManualDisconnect(environment.id) - return getRuntimeEnvironmentStatus(getUserDataPath(), environment.id, args.timeoutMs) + return getRuntimeEnvironmentStatus(getUserDataPath(), environment.id, args.timeoutMs, { + reconnect: true + }) } ) ipcMain.handle( @@ -156,7 +170,6 @@ function closeLegacySelectorTransport(selector: string, environmentId: string): return } closeRemoteRuntimeRequestConnection(selector) - clearSharedControlSupport(selector) } function registerPassiveStatusHandler(getUserDataPath: () => string): void { diff --git a/src/main/ipc/runtime-environment-federated-read-routing.test.ts b/src/main/ipc/runtime-environment-federated-read-routing.test.ts index c58a404fd39..51c77510a01 100644 --- a/src/main/ipc/runtime-environment-federated-read-routing.test.ts +++ b/src/main/ipc/runtime-environment-federated-read-routing.test.ts @@ -1,3 +1,5 @@ +import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' +vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } })) import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -20,14 +22,17 @@ vi.mock('../../shared/remote-runtime-client', () => ({ sendRemoteRuntimeRequest: sendRemoteRuntimeRequestMock })) -vi.mock('./runtime-environment-request-connections', () => ({ - sendRemoteRuntimeConnectionRequest: vi.fn(), - sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, - reconnectRemoteRuntimeSharedControlConnection: vi.fn(), - retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), - ensureRemoteRuntimeSharedControlConnection: vi.fn(), - pauseRemoteRuntimeSharedControlRetry: vi.fn() -})) +vi.mock('./runtime-environment-request-connections', async () => { + const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness') + return withRuntimeStatusOwners({ + sendRemoteRuntimeConnectionRequest: vi.fn(), + sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, + reconnectRemoteRuntimeSharedControlConnection: vi.fn(), + retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), + ensureRemoteRuntimeSharedControlConnection: vi.fn(), + pauseRemoteRuntimeSharedControlRetry: vi.fn() + }) +}) import { callRuntimeEnvironment, @@ -55,6 +60,7 @@ describe('federated read RPC transport routing', () => { }) afterEach(() => { + resetRuntimeEnvironmentStatusOwners() rmSync(userDataPath, { recursive: true, force: true }) }) diff --git a/src/main/ipc/runtime-environment-handler-channels.ts b/src/main/ipc/runtime-environment-handler-channels.ts index 0b63dea943a..23b40fe5183 100644 --- a/src/main/ipc/runtime-environment-handler-channels.ts +++ b/src/main/ipc/runtime-environment-handler-channels.ts @@ -9,6 +9,7 @@ export const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [ 'runtimeEnvironments:retryControlConnection', 'runtimeEnvironments:prepareBrowserClientHostPlacement', 'runtimeEnvironments:getStatus', + 'runtimeEnvironments:getStatusSnapshots', 'runtimeEnvironments:call', 'runtimeEnvironments:subscribe', 'runtimeEnvironments:unsubscribe' diff --git a/src/main/ipc/runtime-environment-request-connections.test.ts b/src/main/ipc/runtime-environment-request-connections.test.ts index 750d1becc6a..b02d1b06fb5 100644 --- a/src/main/ipc/runtime-environment-request-connections.test.ts +++ b/src/main/ipc/runtime-environment-request-connections.test.ts @@ -47,9 +47,9 @@ describe('runtime environment shared-control connection cache', () => { applyRuntimeEnvironmentCapabilityVerdict({ evidence: absent, verdict: 'absent', - runtimeId: 'runtime-test', - onAbsent: () => pauseRemoteRuntimeSharedControlRetry(ENVIRONMENT_ID) + runtimeId: 'runtime-test' }) + pauseRemoteRuntimeSharedControlRetry(ENVIRONMENT_ID) expect(getRemoteRuntimeSharedControlDiagnostics(ENVIRONMENT_ID)?.state).toBe('closed') await delay(400) expect(server.connectionCount()).toBe(1) @@ -58,12 +58,10 @@ describe('runtime environment shared-control connection cache', () => { applyRuntimeEnvironmentCapabilityVerdict({ evidence: capable, verdict: 'capable', - runtimeId: 'runtime-test', - onCapable: () => { - ensureRemoteRuntimeSharedControlConnection(ENVIRONMENT_ID, server.pairing) - reconnectRemoteRuntimeSharedControlConnection(ENVIRONMENT_ID) - } + runtimeId: 'runtime-test' }) + ensureRemoteRuntimeSharedControlConnection(ENVIRONMENT_ID, server.pairing) + reconnectRemoteRuntimeSharedControlConnection(ENVIRONMENT_ID) await waitFor(() => server.connectionCount() === 2) }) @@ -119,9 +117,9 @@ describe('runtime environment shared-control connection cache', () => { applyRuntimeEnvironmentCapabilityVerdict({ evidence, verdict: 'absent', - runtimeId: 'runtime-test', - onAbsent: () => pauseRemoteRuntimeSharedControlRetry(ENVIRONMENT_ID) + runtimeId: 'runtime-test' }) + pauseRemoteRuntimeSharedControlRetry(ENVIRONMENT_ID) expect(getRemoteRuntimeSharedControlDiagnostics(ENVIRONMENT_ID)?.state).toBe('reconnecting') await waitFor(() => server.connectionCount() === 2) diff --git a/src/main/ipc/runtime-environment-request-connections.ts b/src/main/ipc/runtime-environment-request-connections.ts index c1f855697e5..6ba9f273c1e 100644 --- a/src/main/ipc/runtime-environment-request-connections.ts +++ b/src/main/ipc/runtime-environment-request-connections.ts @@ -1,4 +1,9 @@ import type { PairingOffer } from '../../shared/pairing' +import { resolveEnvironment } from '../../shared/runtime-environment-store' +import { getPreferredPairingOffer } from '../../shared/runtime-environments' +import type { RuntimeHostStatusOwner } from '../../shared/runtime-host-status-owner' +import type { RuntimeStatus } from '../../shared/runtime-types' +import { createRuntimeEnvironmentStatusOwner } from './runtime-environment-status-owner' import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/protocol-version' import type { RuntimeOrchestrationEnvelope, @@ -30,6 +35,56 @@ type CachedSharedControlConnection = { const requestConnections = new Map() const sharedControlConnections = new Map() +const statusOwners = new Map() + +export function getRuntimeEnvironmentStatusOwner( + userDataPath: string, + selector: string +): RuntimeHostStatusOwner { + const environment = resolveEnvironment(userDataPath, selector) + const pairing = getPreferredPairingOffer(environment) + const key = `${userDataPath}\0${environment.pairingRevision ?? environment.createdAt}\0${getPairingKey(pairing)}` + let cached = statusOwners.get(environment.id) + if (!cached || cached.key !== key || cached.owner.read().retired) { + if (cached) { + closeRemoteRuntimeRequestConnection(environment.id) + } + const owner = createRuntimeEnvironmentStatusOwner(userDataPath, environment, { + isReady: () => getRemoteRuntimeSharedControlDiagnostics(environment.id)?.state === 'ready', + request: (signal) => + sendRemoteRuntimeSharedControlRequest( + environment.id, + pairing, + 'status.get', + undefined, + 15_000, + undefined, + signal + ), + establish: () => { + ensureRemoteRuntimeSharedControlConnection(environment.id, pairing) + reconnectRemoteRuntimeSharedControlConnection(environment.id) + }, + pause: () => pauseRemoteRuntimeSharedControlRetry(environment.id) + }) + cached = { key, owner } + statusOwners.set(environment.id, cached) + if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) { + owner.dispose() + } + } + return cached.owner +} + +export function resetRuntimeEnvironmentStatusOwners(): void { + for (const id of statusOwners.keys()) { + closeRemoteRuntimeRequestConnection(id) + } +} + +export function getRuntimeEnvironmentStatusSnapshots() { + return [...statusOwners.values()].map(({ owner }) => owner.read()) +} export function sendRemoteRuntimeConnectionRequest( environmentId: string, @@ -56,6 +111,9 @@ export function sendRemoteRuntimeConnectionRequest( } export function closeRemoteRuntimeRequestConnection(environmentId: string): void { + const status = statusOwners.get(environmentId) + statusOwners.delete(environmentId) + status?.owner.dispose() const cached = requestConnections.get(environmentId) requestConnections.delete(environmentId) cached?.connection.close() @@ -166,6 +224,16 @@ function getSharedControlConnection( transportGeneration, diagnostics }) + statusOwners + .get(environmentId) + ?.owner.connectionChanged( + diagnostics.state === 'ready' + ? 'ready' + : diagnostics.state === 'closed' || diagnostics.state === 'reconnecting' + ? 'disconnected' + : 'connecting', + diagnostics + ) } }) } diff --git a/src/main/ipc/runtime-environment-shared-control-support.ts b/src/main/ipc/runtime-environment-shared-control-support.ts index 29513e2970a..0de6a35e0f6 100644 --- a/src/main/ipc/runtime-environment-shared-control-support.ts +++ b/src/main/ipc/runtime-environment-shared-control-support.ts @@ -1,39 +1,23 @@ -import { - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, - REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY -} from '../../shared/protocol-version' -import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' -import { markEnvironmentUsed } from '../../shared/runtime-environment-store' import type { getPreferredPairingOffer, KnownRuntimeEnvironment } from '../../shared/runtime-environments' -import type { RuntimeStatus } from '../../shared/runtime-types' +import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' import { - applyRuntimeEnvironmentCapabilityVerdict, - captureRuntimeEnvironmentCapabilityEvidence, getAcceptedRuntimeEnvironmentCapabilityOutcome, - isRuntimeEnvironmentCapabilityOutcomeCurrent, - runtimeEnvironmentCapabilityOutcome, resetRuntimeEnvironmentCapabilityEvidence, type RuntimeEnvironmentCapabilityOutcome } from './runtime-environment-capability-evidence' -import { pauseRemoteRuntimeSharedControlRetry } from './runtime-environment-request-connections' - -const sharedControlSupport = new Map< - string, - { cacheKey: string; check: Promise } ->() +import { + getRuntimeEnvironmentStatusOwner, + resetRuntimeEnvironmentStatusOwners +} from './runtime-environment-request-connections' export function resetSharedControlSupport(): void { - sharedControlSupport.clear() + resetRuntimeEnvironmentStatusOwners() resetRuntimeEnvironmentCapabilityEvidence() } -export function clearSharedControlSupport(environmentId: string): void { - sharedControlSupport.delete(environmentId) -} - export async function supportsSharedControl( userDataPath: string, environment: KnownRuntimeEnvironment, @@ -48,85 +32,17 @@ export async function supportsSharedControl( if (accepted) { return accepted } - const cacheKey = getSharedControlSupportCacheKey(environment, pairing) - const cached = sharedControlSupport.get(environment.id) - if (cached?.cacheKey === cacheKey) { - const outcome = await cached.check - if (isRuntimeEnvironmentCapabilityOutcomeCurrent(outcome)) { - return outcome - } - if (sharedControlSupport.get(environment.id)?.check === cached.check) { - sharedControlSupport.delete(environment.id) - } - return { kind: 'stale_incarnation' } + const response = await getRuntimeEnvironmentStatusOwner(userDataPath, environment.id).refresh({ + timeoutMs + }) + if (!response.ok) { + throw new RemoteRuntimeClientError(response.error.code, response.error.message) } - let resolvedCacheKey = cacheKey - const evidence = captureRuntimeEnvironmentCapabilityEvidence(environment.id, pairing) - const check = (async () => { - const response = await sendRemoteRuntimeRequest( + return ( + getAcceptedRuntimeEnvironmentCapabilityOutcome( + environment.id, pairing, - 'status.get', - undefined, - timeoutMs, - undefined, - undefined, - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES - ) - if (response.ok === true) { - const verdict = response.result.capabilities?.includes( - REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY - ) - ? 'capable' - : 'absent' - const acceptedEvidence = applyRuntimeEnvironmentCapabilityVerdict({ - evidence, - verdict, - runtimeId: response._meta.runtimeId, - onAbsent: () => pauseRemoteRuntimeSharedControlRetry(environment.id) - }) - if (!acceptedEvidence) { - return { kind: 'stale_incarnation' } as const - } - markEnvironmentUsed(userDataPath, environment.id, { runtimeId: response._meta.runtimeId }) - resolvedCacheKey = getSharedControlSupportCacheKey( - environment, - pairing, - response._meta.runtimeId - ) - return runtimeEnvironmentCapabilityOutcome(evidence, verdict, response._meta.runtimeId) - } - return runtimeEnvironmentCapabilityOutcome( - evidence, - 'absent', - environment.runtimeId ?? 'unknown-runtime' - ) - })() - // Why: support belongs to the saved pairing/runtime identity, not its mutable display name. - sharedControlSupport.set(environment.id, { cacheKey, check }) - try { - const outcome = await check - const cachedAfterCheck = sharedControlSupport.get(environment.id) - if (cachedAfterCheck?.check === check && cachedAfterCheck.cacheKey !== resolvedCacheKey) { - sharedControlSupport.set(environment.id, { cacheKey: resolvedCacheKey, check }) - } - return outcome - } catch (error) { - if (sharedControlSupport.get(environment.id)?.check === check) { - sharedControlSupport.delete(environment.id) - } - throw error - } -} - -function getSharedControlSupportCacheKey( - environment: KnownRuntimeEnvironment, - pairing: ReturnType, - runtimeId = environment.runtimeId -): string { - return [ - runtimeId ?? 'unknown-runtime', - pairing.endpoint, - pairing.deviceToken, - pairing.publicKeyB64 - ].join('\0') + response._meta.runtimeId + ) ?? { kind: 'stale_incarnation' } + ) } diff --git a/src/main/ipc/runtime-environment-status-connection.test.ts b/src/main/ipc/runtime-environment-status-connection.test.ts new file mode 100644 index 00000000000..8d4fad6f9b9 --- /dev/null +++ b/src/main/ipc/runtime-environment-status-connection.test.ts @@ -0,0 +1,65 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { encodePairingOffer } from '../../shared/pairing' +import { addEnvironmentFromPairingCode } from '../../shared/runtime-environment-store' +import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' +import { + createSharedControlTestServer, + closeSharedControlTestServers +} from '../../shared/remote-runtime-shared-control-test-server' +import { getRuntimeEnvironmentStatus } from './runtime-environment-transport-routing' +import { + getRuntimeEnvironmentStatusOwner, + resetRuntimeEnvironmentStatusOwners +} from './runtime-environment-request-connections' + +vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } })) +const profiles: string[] = [] +afterEach(async () => { + resetRuntimeEnvironmentStatusOwners() + await closeSharedControlTestServers() + profiles.splice(0).forEach((profile) => rmSync(profile, { recursive: true, force: true })) +}) + +it('publishes real same-socket verification after every authenticated reconnect', async () => { + let runtimeId = 'host-before' + const server = await createSharedControlTestServer({ + resultForRequest: () => ({ + runtimeId, + capabilities: [REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY] + }) + }) + const profile = mkdtempSync(join(tmpdir(), 'orca-status-socket-')) + profiles.push(profile) + const environment = addEnvironmentFromPairingCode(profile, { + name: 'host', + pairingCode: encodePairingOffer(server.pairing) + }) + await getRuntimeEnvironmentStatus(profile, environment.id) + const owner = getRuntimeEnvironmentStatusOwner(profile, environment.id) + await vi.waitFor( + () => { + expect(owner.read()).toMatchObject({ transport: 'ready', verification: 'verified' }) + expect(server.requests).toHaveLength(2) + }, + { timeout: 3_000 } + ) + expect(server.connectionCount()).toBe(2) // Bootstrap plus persistent control. + runtimeId = 'host-after' + server.closeClients() + await vi.waitFor( + () => { + expect(owner.read().status?.runtimeId).toBe('host-after') + expect(owner.read().verification).toBe('verified') + }, + { timeout: 3_000 } + ) + expect(server.connectionCount()).toBe(3) + expect(server.requests.map((request) => request.method)).toEqual([ + 'status.get', + 'status.get', + 'status.get' + ]) +}) diff --git a/src/main/ipc/runtime-environment-status-owner.ts b/src/main/ipc/runtime-environment-status-owner.ts new file mode 100644 index 00000000000..4ac3c067f74 --- /dev/null +++ b/src/main/ipc/runtime-environment-status-owner.ts @@ -0,0 +1,89 @@ +import { BrowserWindow } from 'electron' +import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' +import { + ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, + REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY +} from '../../shared/protocol-version' +import { + getPreferredPairingOffer, + type KnownRuntimeEnvironment +} from '../../shared/runtime-environments' +import { markEnvironmentUsed } from '../../shared/runtime-environment-store' +import { RuntimeHostStatusOwner } from '../../shared/runtime-host-status-owner' +import { + RUNTIME_HOST_STATUS_CHANNEL, + type RuntimeHostStatusResponse +} from '../../shared/runtime-host-status' +import { + applyRuntimeEnvironmentCapabilityVerdict, + getAcceptedRuntimeEnvironmentCapabilityOutcome, + captureRuntimeEnvironmentCapabilityEvidence +} from './runtime-environment-capability-evidence' +import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect' + +export function createRuntimeEnvironmentStatusOwner( + userDataPath: string, + environment: KnownRuntimeEnvironment, + transport: { + isReady: () => boolean + request: (signal: AbortSignal) => Promise + establish: () => void + pause: () => void + } +): RuntimeHostStatusOwner { + const pairing = getPreferredPairingOffer(environment) + let evidence = captureRuntimeEnvironmentCapabilityEvidence(environment.id, pairing) + return new RuntimeHostStatusOwner({ + environmentId: environment.id, + pairingRevision: environment.pairingRevision ?? environment.createdAt, + request: (signal) => { + evidence = captureRuntimeEnvironmentCapabilityEvidence(environment.id, pairing) + return transport.isReady() && + getAcceptedRuntimeEnvironmentCapabilityOutcome(environment.id, pairing, null)?.kind === + 'supported' + ? transport.request(signal) + : sendRemoteRuntimeRequest( + pairing, + 'status.get', + undefined, + 15_000, + undefined, + signal, + ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES + ) + }, + verified: (response, active) => { + const capable = + response.result.capabilities?.includes(REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY) ?? false + const accepted = applyRuntimeEnvironmentCapabilityVerdict({ + evidence, + verdict: capable ? 'capable' : 'absent', + runtimeId: response._meta.runtimeId + }) + if (accepted && active && !isRuntimeEnvironmentManuallyDisconnected(environment.id)) { + markEnvironmentUsed(userDataPath, environment.id, { + runtimeId: response._meta.runtimeId, + pairedDeviceId: response.result.pairedDeviceId + }) + if (capable) { + transport.establish() + } else { + transport.pause() + } + } + return capable && active + }, + publish: (snapshot) => { + for (const window of BrowserWindow.getAllWindows()) { + if (window.isDestroyed()) { + continue + } + try { + window.webContents.send(RUNTIME_HOST_STATUS_CHANNEL, snapshot) + } catch { + /* A renderer can close during publication. */ + } + } + } + }) +} diff --git a/src/main/ipc/runtime-environment-status-recovery.test.ts b/src/main/ipc/runtime-environment-status-recovery.test.ts new file mode 100644 index 00000000000..82e94ea62e0 --- /dev/null +++ b/src/main/ipc/runtime-environment-status-recovery.test.ts @@ -0,0 +1,89 @@ +import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { addEnvironmentFromPairingCode } from '../../shared/runtime-environment-store' +import { pairingCode } from './runtime-environments-ipc-test-harness' +import { + getRuntimeEnvironmentStatus, + resetSharedControlSupport +} from './runtime-environment-transport-routing' + +const { request, publish } = vi.hoisted(() => ({ request: vi.fn(), publish: vi.fn() })) +vi.mock('../../shared/remote-runtime-client', () => ({ + sendRemoteRuntimeRequest: request, + subscribeRemoteRuntimeRequest: vi.fn() +})) +vi.mock('electron', () => ({ + BrowserWindow: { + getAllWindows: () => [ + { + isDestroyed: () => false, + webContents: { send: publish } + } + ] + } +})) + +let profile: string +beforeEach(() => { + vi.useFakeTimers() + request.mockReset() + publish.mockReset() + profile = mkdtempSync(join(tmpdir(), 'orca-status-recovery-')) +}) +afterEach(() => { + resetSharedControlSupport() + vi.useRealTimers() + rmSync(profile, { recursive: true, force: true }) +}) + +it('recovers a saved host after its first status check fails, without another UI request', async () => { + const environment = addEnvironmentFromPairingCode(profile, { + name: 'offline-at-startup', + pairingCode: pairingCode() + }) + request + .mockRejectedValueOnce( + Object.assign(new Error('host offline'), { code: 'runtime_unavailable' }) + ) + .mockResolvedValue({ + id: 'status', + ok: true, + result: { runtimeId: 'host-1', graphStatus: 'ready', capabilities: [] }, + _meta: { runtimeId: 'host-1' } + }) + expect((await getRuntimeEnvironmentStatus(profile, environment.id)).ok).toBe(false) + await vi.advanceTimersByTimeAsync(3_000) + expect(request).toHaveBeenCalledTimes(2) + expect(publish).toHaveBeenCalledWith( + 'runtimeEnvironments:statusChanged', + expect.objectContaining({ + environmentId: environment.id, + verification: 'verified', + status: expect.objectContaining({ runtimeId: 'host-1' }) + }) + ) + await vi.advanceTimersByTimeAsync(300_000) + expect(request).toHaveBeenCalledTimes(2) +}) + +it('a passive capability check does not strand later active bootstrap recovery', async () => { + const environment = addEnvironmentFromPairingCode(profile, { + name: 'passive-first', + pairingCode: pairingCode() + }) + request + .mockResolvedValueOnce({ + id: 'status', + ok: true, + result: { runtimeId: 'host-1', capabilities: [REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY] }, + _meta: { runtimeId: 'host-1' } + }) + .mockRejectedValue(new Error('host offline')) + await getRuntimeEnvironmentStatus(profile, environment.id, undefined, { observeOnly: true }) + await getRuntimeEnvironmentStatus(profile, environment.id) + await vi.advanceTimersByTimeAsync(3_000) + expect(request).toHaveBeenCalledTimes(3) +}) diff --git a/src/main/ipc/runtime-environment-support-routing.test.ts b/src/main/ipc/runtime-environment-support-routing.test.ts index feb09ee481a..8fdde15a81c 100644 --- a/src/main/ipc/runtime-environment-support-routing.test.ts +++ b/src/main/ipc/runtime-environment-support-routing.test.ts @@ -57,7 +57,6 @@ describe('runtime environment support routing', () => { ).resolves.toMatchObject({ ok: true }) expect(supportsMock).toHaveBeenCalledTimes(2) - expect(clearSupportMock).toHaveBeenCalledOnce() expect(supported).toHaveBeenCalledOnce() expect(unsupported).not.toHaveBeenCalled() }) diff --git a/src/main/ipc/runtime-environment-support-routing.ts b/src/main/ipc/runtime-environment-support-routing.ts index e2503ad4445..9566b2fc1b7 100644 --- a/src/main/ipc/runtime-environment-support-routing.ts +++ b/src/main/ipc/runtime-environment-support-routing.ts @@ -18,10 +18,7 @@ import { type RuntimeEnvironmentCapabilityOutcome } from './runtime-environment-capability-evidence' import { runtimeEnvironmentRevisionFailure } from './runtime-environment-revision-guard' -import { - clearSharedControlSupport, - supportsSharedControl -} from './runtime-environment-shared-control-support' +import { supportsSharedControl } from './runtime-environment-shared-control-support' import { sendRemoteRuntimeRequestAbortable, sendRemoteRuntimeSharedControlRequestAbortable @@ -205,7 +202,6 @@ export async function routeRuntimeEnvironmentCallBySupport(args: { } return response } - clearSharedControlSupport(environment.id) environment = resolveEnvironment(args.userDataPath, environment.id) } return runtimeEnvironmentChangedFailure(environment, args.method) diff --git a/src/main/ipc/runtime-environment-transport-routing-tailscale-hint.test.ts b/src/main/ipc/runtime-environment-transport-routing-tailscale-hint.test.ts index a44f5df84c6..bb2bbdac322 100644 --- a/src/main/ipc/runtime-environment-transport-routing-tailscale-hint.test.ts +++ b/src/main/ipc/runtime-environment-transport-routing-tailscale-hint.test.ts @@ -1,20 +1,23 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { generateKeyPair, publicKeyToBase64 } from '../../shared/e2ee-crypto' import { encodePairingOffer, type PairingOffer } from '../../shared/pairing' import { addEnvironmentFromPairingCode } from '../../shared/runtime-environment-store' import { callRuntimeEnvironment, getRuntimeEnvironmentStatus, - subscribeRuntimeEnvironment + subscribeRuntimeEnvironment, + resetSharedControlSupport } from './runtime-environment-transport-routing' // Why: prove the wiring, not just the helper — an unreachable endpoint exercises // the real WebSocket failure → reject → Tailscale-hint join points the settings // probe (returned ok:false) and in-use calls (thrown) actually use. +vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } })) + let userDataPath: string function seedEnvironment(name: string, endpoint: string): string { @@ -39,6 +42,7 @@ beforeEach(() => { }) afterEach(() => { + resetSharedControlSupport() rmSync(userDataPath, { recursive: true, force: true }) }) diff --git a/src/main/ipc/runtime-environment-transport-routing.ts b/src/main/ipc/runtime-environment-transport-routing.ts index 70f49816603..b19b0d9e376 100644 --- a/src/main/ipc/runtime-environment-transport-routing.ts +++ b/src/main/ipc/runtime-environment-transport-routing.ts @@ -1,8 +1,5 @@ import { getPreferredPairingOffer } from '../../shared/runtime-environments' -import { - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, - REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY -} from '../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/protocol-version' import { resolveEnvironment, markEnvironmentUsed } from '../../shared/runtime-environment-store' import { isOrchestrationMutation } from '../../shared/orchestration-rpc-contract' import type { @@ -11,33 +8,22 @@ import type { } from '../../shared/runtime-rpc-envelope' import type { RuntimeStatus } from '../../shared/runtime-types' import { - sendRemoteRuntimeRequest, subscribeRemoteRuntimeRequest, type RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' import { withRemoteRuntimeTailscaleHint } from '../../shared/remote-runtime-tailscale-hint' import { enqueueRuntimeCall } from './runtime-environment-call-queue' -import { - ensureRemoteRuntimeSharedControlConnection, - pauseRemoteRuntimeSharedControlRetry, - reconnectRemoteRuntimeSharedControlConnection -} from './runtime-environment-request-connections' +import { getRuntimeEnvironmentStatusOwner } from './runtime-environment-request-connections' import { sendRemoteRuntimeConnectionRequestAbortable, sendRemoteRuntimeRequestAbortable } from './runtime-environment-abortable-requests' import { attachRemoteControlDiagnostics } from './runtime-environment-status-diagnostics' -import { - applyRuntimeEnvironmentCapabilityVerdict, - captureRuntimeEnvironmentCapabilityEvidence -} from './runtime-environment-capability-evidence' + import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect' import { runtimeEnvironmentRevisionFailure } from './runtime-environment-revision-guard' import { withTailscaleHintForResponse } from './runtime-environment-tailscale-response' -import { - clearSharedControlSupport, - resetSharedControlSupport -} from './runtime-environment-shared-control-support' +import { resetSharedControlSupport } from './runtime-environment-shared-control-support' import { executeSupportRoutedCall, shouldRouteCallBySupport, @@ -47,72 +33,31 @@ import { const DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS = 15_000 -export { clearSharedControlSupport, resetSharedControlSupport } +export { resetSharedControlSupport } export async function getRuntimeEnvironmentStatus( userDataPath: string, selector: string, timeoutMs?: number, - options?: { observeOnly?: true } + options?: { observeOnly?: true; signal?: AbortSignal; reconnect?: true } ): Promise> { const environment = resolveEnvironment(userDataPath, selector) - const pairing = getPreferredPairingOffer(environment) - const evidence = captureRuntimeEnvironmentCapabilityEvidence(environment.id, pairing) - let response: RuntimeRpcResponse - try { - response = await sendRemoteRuntimeRequest( - pairing, - 'status.get', - undefined, - timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS, - undefined, - undefined, - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES - ) - } catch (error) { - // Why: the status UI needs shared-control diagnostics most when the - // fresh status probe failed and the host is reconnecting/offline. - return attachRemoteControlDiagnostics( - withTailscaleHintForResponse( - { - id: 'status.get', - ok: false, - error: { - code: 'runtime_unavailable', - message: error instanceof Error ? error.message : String(error) - }, - _meta: { runtimeId: environment.runtimeId } - }, - pairing.endpoint - ), - environment.id - ) - } - if (response.ok === true) { - const verdict = response.result.capabilities?.includes(REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY) - ? 'capable' - : 'absent' - const accepted = applyRuntimeEnvironmentCapabilityVerdict({ - evidence, - verdict, - runtimeId: response._meta.runtimeId, - onCapable: () => { - if (!options?.observeOnly && !isRuntimeEnvironmentManuallyDisconnected(environment.id)) { - ensureRemoteRuntimeSharedControlConnection(environment.id, pairing) - reconnectRemoteRuntimeSharedControlConnection(environment.id) - } - }, - onAbsent: () => pauseRemoteRuntimeSharedControlRetry(environment.id) - }) - if (accepted && !options?.observeOnly) { - markEnvironmentUsed(userDataPath, environment.id, { - runtimeId: response._meta.runtimeId, - pairedDeviceId: response.result.pairedDeviceId - }) + if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) { + return { + id: 'status.get', + ok: false, + error: { + code: 'runtime_manually_disconnected', + message: 'Runtime environment is manually disconnected.' + } } } + const response = await getRuntimeEnvironmentStatusOwner(userDataPath, environment.id).refresh({ + timeoutMs, + ...options + }) return attachRemoteControlDiagnostics( - withTailscaleHintForResponse(response, pairing.endpoint), + withTailscaleHintForResponse(response, getPreferredPairingOffer(environment).endpoint), environment.id ) } @@ -127,6 +72,15 @@ export async function callRuntimeEnvironment( envelope?: RuntimeOrchestrationEnvelope, options?: { signal?: AbortSignal } ): Promise> { + if (method === 'status.get') { + const environment = resolveEnvironment(userDataPath, selector) + const failure = runtimeEnvironmentRevisionFailure( + environment, + expectedEnvironmentPairingRevision, + method + ) + return failure ?? getRuntimeEnvironmentStatus(userDataPath, selector, timeoutMs, options) + } const environment = resolveEnvironment(userDataPath, selector) // Why: connection failures reject (they don't resolve as ok:false), so the // Tailscale hint is applied to the thrown error here — wrapping the resolved diff --git a/src/main/ipc/runtime-environments-call-routing.test.ts b/src/main/ipc/runtime-environments-call-routing.test.ts index ef92dc66826..e6f8946527d 100644 --- a/src/main/ipc/runtime-environments-call-routing.test.ts +++ b/src/main/ipc/runtime-environments-call-routing.test.ts @@ -1,3 +1,4 @@ +import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -44,6 +45,7 @@ const { })) vi.mock('electron', () => ({ + BrowserWindow: { getAllWindows: () => [] }, app: { getPath: getPathMock }, ipcMain: { handle: handleMock, @@ -58,18 +60,23 @@ vi.mock('../../shared/remote-runtime-client', () => ({ subscribeRemoteRuntimeRequest: subscribeRemoteRuntimeRequestMock })) -vi.mock('./runtime-environment-request-connections', () => ({ - sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, - sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, - subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, - getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, - reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock, - retryRemoteRuntimeSharedControlConnectionsNow: retryRemoteRuntimeSharedControlConnectionsNowMock, - retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), - ensureRemoteRuntimeSharedControlConnection: vi.fn(), - pauseRemoteRuntimeSharedControlRetry: vi.fn(), - closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock -})) +vi.mock('./runtime-environment-request-connections', async () => { + const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness') + return withRuntimeStatusOwners({ + sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, + sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, + subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, + getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, + reconnectRemoteRuntimeSharedControlConnection: + reconnectRemoteRuntimeSharedControlConnectionMock, + retryRemoteRuntimeSharedControlConnectionsNow: + retryRemoteRuntimeSharedControlConnectionsNowMock, + retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), + ensureRemoteRuntimeSharedControlConnection: vi.fn(), + pauseRemoteRuntimeSharedControlRetry: vi.fn(), + closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock + }) +}) import { registerRuntimeEnvironmentHandlers } from './runtime-environments' import { channelHandlerLookup, pairingCode } from './runtime-environments-ipc-test-harness' @@ -112,6 +119,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) afterEach(() => { + resetRuntimeEnvironmentStatusOwners() rmSync(userDataPath, { recursive: true, force: true }) }) @@ -339,7 +347,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { undefined, 15_000, undefined, - undefined, + expect.any(AbortSignal), ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES ) expect(sendRemoteRuntimeSharedControlRequestMock).toHaveBeenCalledWith( @@ -451,7 +459,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { } ) - it('keeps uncoded call failures on the rejected IPC fallback path', async () => { + it('returns uncoded status failures through the owner response', async () => { registerRuntimeEnvironmentHandlers(store as never) sendRemoteRuntimeRequestMock.mockRejectedValue(new Error('shared down')) @@ -464,9 +472,10 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'runtimeEnvironments:call' ) - await expect(call(null, { selector: 'desk', method: 'status.get' })).rejects.toThrow( - 'shared down' - ) + await expect(call(null, { selector: 'desk', method: 'status.get' })).resolves.toMatchObject({ + ok: false, + error: { code: 'runtime_unavailable', message: 'shared down' } + }) }) it('does not fall back after a shared-control request fails on a supported runtime', async () => { diff --git a/src/main/ipc/runtime-environments-capability-cache.test.ts b/src/main/ipc/runtime-environments-capability-cache.test.ts index 8ac11d69dd1..32f724df981 100644 --- a/src/main/ipc/runtime-environments-capability-cache.test.ts +++ b/src/main/ipc/runtime-environments-capability-cache.test.ts @@ -1,3 +1,4 @@ +import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -37,6 +38,7 @@ const { })) vi.mock('electron', () => ({ + BrowserWindow: { getAllWindows: () => [] }, app: { getPath: getPathMock }, ipcMain: { handle: handleMock, @@ -51,18 +53,23 @@ vi.mock('../../shared/remote-runtime-client', () => ({ subscribeRemoteRuntimeRequest: subscribeRemoteRuntimeRequestMock })) -vi.mock('./runtime-environment-request-connections', () => ({ - sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, - sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, - subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, - getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, - reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock, - retryRemoteRuntimeSharedControlConnectionsNow: retryRemoteRuntimeSharedControlConnectionsNowMock, - retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), - ensureRemoteRuntimeSharedControlConnection: vi.fn(), - pauseRemoteRuntimeSharedControlRetry: vi.fn(), - closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock -})) +vi.mock('./runtime-environment-request-connections', async () => { + const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness') + return withRuntimeStatusOwners({ + sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, + sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, + subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, + getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, + reconnectRemoteRuntimeSharedControlConnection: + reconnectRemoteRuntimeSharedControlConnectionMock, + retryRemoteRuntimeSharedControlConnectionsNow: + retryRemoteRuntimeSharedControlConnectionsNowMock, + retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), + ensureRemoteRuntimeSharedControlConnection: vi.fn(), + pauseRemoteRuntimeSharedControlRetry: vi.fn(), + closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock + }) +}) import { registerRuntimeEnvironmentHandlers } from './runtime-environments' import { channelHandlerLookup, pairingCode } from './runtime-environments-ipc-test-harness' @@ -105,6 +112,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) afterEach(() => { + resetRuntimeEnvironmentStatusOwners() rmSync(userDataPath, { recursive: true, force: true }) }) @@ -182,9 +190,10 @@ describe('registerRuntimeEnvironmentHandlers', () => { { selector: string; method: string; params?: unknown; timeoutMs?: number }, { ok: true; result: unknown } >('runtimeEnvironments:call') - await expect(call(null, { selector: 'desk', method: 'repo.list' })).rejects.toThrow( - 'probe failed' - ) + await expect(call(null, { selector: 'desk', method: 'repo.list' })).resolves.toMatchObject({ + ok: false, + error: { code: 'runtime_unavailable', message: 'probe failed' } + }) await expect(call(null, { selector: 'desk', method: 'repo.list' })).resolves.toMatchObject({ ok: true, result: { repos: [] } diff --git a/src/main/ipc/runtime-environments-ipc-test-harness.ts b/src/main/ipc/runtime-environments-ipc-test-harness.ts index 016352793cb..e97e45ede1a 100644 --- a/src/main/ipc/runtime-environments-ipc-test-harness.ts +++ b/src/main/ipc/runtime-environments-ipc-test-harness.ts @@ -1,6 +1,62 @@ import { expect } from 'vitest' import type { Mock } from 'vitest' +import { getPreferredPairingOffer } from '../../shared/runtime-environments' import { encodePairingOffer } from '../../shared/pairing' +import { resolveEnvironment } from '../../shared/runtime-environment-store' +import { createRuntimeEnvironmentStatusOwner } from './runtime-environment-status-owner' +import type { RuntimeHostStatusOwner } from '../../shared/runtime-host-status-owner' +import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect' + +/** Keep IPC tests on the production owner while replacing only its transport. */ +export function withRuntimeStatusOwners>(transport: T) { + const owners = new Map() + return { + ...transport, + getRuntimeEnvironmentStatusOwner: (profile: string, selector: string) => { + const environment = resolveEnvironment(profile, selector) + let owner = owners.get(environment.id) + if (!owner || owner.read().retired) { + owner = createRuntimeEnvironmentStatusOwner(profile, environment, { + isReady: () => + transport.getRemoteRuntimeSharedControlDiagnostics?.(environment.id)?.state === 'ready', + request: (signal) => + transport.sendRemoteRuntimeSharedControlRequest( + environment.id, + undefined, + 'status.get', + undefined, + 15_000, + undefined, + signal + ), + establish: () => { + transport.ensureRemoteRuntimeSharedControlConnection?.( + environment.id, + getPreferredPairingOffer(environment) + ) + transport.reconnectRemoteRuntimeSharedControlConnection?.(environment.id) + }, + pause: () => transport.pauseRemoteRuntimeSharedControlRetry?.(environment.id) + }) + owners.set(environment.id, owner) + if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) { + owner.dispose() + } + } + return owner + }, + getRuntimeEnvironmentStatusSnapshots: () => [...owners.values()].map((owner) => owner.read()), + resetRuntimeEnvironmentStatusOwners: () => { + owners.forEach((owner) => owner.dispose()) + owners.clear() + }, + closeRemoteRuntimeRequestConnection: (...args: unknown[]) => { + owners.get(args[0] as string)?.dispose() + owners.delete(args[0] as string) + transport.closeRemoteRuntimeRequestConnection(...args) + } + } +} export function pairingCode(endpoint = 'ws://127.0.0.1:6768'): string { return encodePairingOffer({ diff --git a/src/main/ipc/runtime-environments-pairing.test.ts b/src/main/ipc/runtime-environments-pairing.test.ts index 87d6c2698ab..ce492a7a740 100644 --- a/src/main/ipc/runtime-environments-pairing.test.ts +++ b/src/main/ipc/runtime-environments-pairing.test.ts @@ -1,3 +1,5 @@ +import type { RuntimeHostStatusSnapshot } from '../../shared/runtime-host-status' +import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -44,6 +46,7 @@ const { })) vi.mock('electron', () => ({ + BrowserWindow: { getAllWindows: () => [] }, app: { getPath: getPathMock }, ipcMain: { handle: handleMock, @@ -58,18 +61,23 @@ vi.mock('../../shared/remote-runtime-client', () => ({ subscribeRemoteRuntimeRequest: subscribeRemoteRuntimeRequestMock })) -vi.mock('./runtime-environment-request-connections', () => ({ - sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, - sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, - subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, - getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, - reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock, - retryRemoteRuntimeSharedControlConnectionsNow: retryRemoteRuntimeSharedControlConnectionsNowMock, - retryRemoteRuntimeSharedControlConnectionNow: retryRemoteRuntimeSharedControlConnectionNowMock, - ensureRemoteRuntimeSharedControlConnection: vi.fn(), - pauseRemoteRuntimeSharedControlRetry: vi.fn(), - closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock -})) +vi.mock('./runtime-environment-request-connections', async () => { + const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness') + return withRuntimeStatusOwners({ + sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, + sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, + subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, + getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, + reconnectRemoteRuntimeSharedControlConnection: + reconnectRemoteRuntimeSharedControlConnectionMock, + retryRemoteRuntimeSharedControlConnectionsNow: + retryRemoteRuntimeSharedControlConnectionsNowMock, + retryRemoteRuntimeSharedControlConnectionNow: retryRemoteRuntimeSharedControlConnectionNowMock, + ensureRemoteRuntimeSharedControlConnection: vi.fn(), + pauseRemoteRuntimeSharedControlRetry: vi.fn(), + closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock + }) +}) import { registerRuntimeEnvironmentHandlers } from './runtime-environments' import { channelHandlerLookup, pairingCode } from './runtime-environments-ipc-test-harness' @@ -125,6 +133,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) afterEach(() => { + resetRuntimeEnvironmentStatusOwners() rmSync(userDataPath, { recursive: true, force: true }) }) @@ -132,6 +141,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { registerRuntimeEnvironmentHandlers(store as never) expect(handleMock.mock.calls.map((call) => call[0])).toEqual([ + 'runtimeEnvironments:getStatusSnapshots', 'runtimeEnvironments:list', 'runtimeEnvironments:addFromPairingCode', 'runtimeEnvironments:verifyAndAddFromPairingCode', @@ -166,6 +176,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'runtimeEnvironments:retryControlConnection', 'runtimeEnvironments:prepareBrowserClientHostPlacement', 'runtimeEnvironments:getStatus', + 'runtimeEnvironments:getStatusSnapshots', 'runtimeEnvironments:call', 'runtimeEnvironments:subscribe', 'runtimeEnvironments:unsubscribe', @@ -467,6 +478,13 @@ describe('registerRuntimeEnvironmentHandlers', () => { ok: false, error: { code: 'runtime_manually_disconnected' } }) + const getSnapshots = handler( + 'runtimeEnvironments:getStatusSnapshots' + ) + // A new renderer only has the snapshot read, not the earlier disconnect event. + expect(await getSnapshots(null, undefined)).toMatchObject([ + { environmentId: added.environment.id, retired: true, transport: 'disconnected' } + ]) const call = handler< { selector: string; method: string }, { ok: boolean; error?: { code: string } } @@ -492,6 +510,10 @@ describe('registerRuntimeEnvironmentHandlers', () => { result: { runtimeId: 'runtime-remote' } }) expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledOnce() + expect(await getSnapshots(null, undefined)).toMatchObject([ + { environmentId: added.environment.id, verification: 'verified' } + ]) + expect((await getSnapshots(null, undefined))[0].retired).not.toBe(true) }) it('marks environments owned by ephemeral VM runtimes in the public list', async () => { diff --git a/src/main/ipc/runtime-environments-status-diagnostics.test.ts b/src/main/ipc/runtime-environments-status-diagnostics.test.ts index b210e7c209b..9fe3d6baf0e 100644 --- a/src/main/ipc/runtime-environments-status-diagnostics.test.ts +++ b/src/main/ipc/runtime-environments-status-diagnostics.test.ts @@ -1,3 +1,4 @@ +import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -44,6 +45,7 @@ const { })) vi.mock('electron', () => ({ + BrowserWindow: { getAllWindows: () => [] }, app: { getPath: getPathMock }, ipcMain: { handle: handleMock, @@ -58,18 +60,23 @@ vi.mock('../../shared/remote-runtime-client', () => ({ subscribeRemoteRuntimeRequest: subscribeRemoteRuntimeRequestMock })) -vi.mock('./runtime-environment-request-connections', () => ({ - sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, - sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, - subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, - getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, - reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock, - retryRemoteRuntimeSharedControlConnectionsNow: retryRemoteRuntimeSharedControlConnectionsNowMock, - retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), - ensureRemoteRuntimeSharedControlConnection: ensureRemoteRuntimeSharedControlConnectionMock, - pauseRemoteRuntimeSharedControlRetry: pauseRemoteRuntimeSharedControlRetryMock, - closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock -})) +vi.mock('./runtime-environment-request-connections', async () => { + const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness') + return withRuntimeStatusOwners({ + sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, + sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, + subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, + getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, + reconnectRemoteRuntimeSharedControlConnection: + reconnectRemoteRuntimeSharedControlConnectionMock, + retryRemoteRuntimeSharedControlConnectionsNow: + retryRemoteRuntimeSharedControlConnectionsNowMock, + retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), + ensureRemoteRuntimeSharedControlConnection: ensureRemoteRuntimeSharedControlConnectionMock, + pauseRemoteRuntimeSharedControlRetry: pauseRemoteRuntimeSharedControlRetryMock, + closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock + }) +}) import { registerRuntimeEnvironmentHandlers } from './runtime-environments' import { channelHandlerLookup, pairingCode } from './runtime-environments-ipc-test-harness' @@ -114,6 +121,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) afterEach(() => { + resetRuntimeEnvironmentStatusOwners() rmSync(userDataPath, { recursive: true, force: true }) }) @@ -148,9 +156,9 @@ describe('registerRuntimeEnvironmentHandlers', () => { expect.objectContaining({ endpoint: 'ws://127.0.0.1:6768', deviceToken: 'device-token' }), 'status.get', undefined, - 50, - undefined, + 15_000, undefined, + expect.any(AbortSignal), ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES ) expect(reconnectRemoteRuntimeSharedControlConnectionMock).toHaveBeenCalledWith( @@ -319,36 +327,41 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) }) - it('returns shared-control diagnostics when saved remote runtime status throws', async () => { - registerRuntimeEnvironmentHandlers(store as never) - getRemoteRuntimeSharedControlDiagnosticsMock.mockReturnValue({ - state: 'reconnecting', - pendingRequestCount: 0, - subscriptionCount: 1, - reconnectAttempt: 2, - lastConnectedAt: 123, - lastClose: { code: 1006, reason: '' }, - lastError: 'closed' - }) - sendRemoteRuntimeRequestMock.mockRejectedValue(new Error('socket closed')) + it.each(['runtimeEnvironments:getStatus', 'runtimeEnvironments:connect'])( + 'preserves failure diagnostics and guidance on %s', + async (channel) => { + registerRuntimeEnvironmentHandlers(store as never) + getRemoteRuntimeSharedControlDiagnosticsMock.mockReturnValue({ + state: 'reconnecting', + pendingRequestCount: 0, + subscriptionCount: 1, + reconnectAttempt: 2, + lastConnectedAt: 123, + lastClose: { code: 1006, reason: '' }, + lastError: 'closed' + }) + sendRemoteRuntimeRequestMock.mockRejectedValue( + new Error('Could not connect to the remote Orca runtime.') + ) - const add = handler< - { name: string; pairingCode: string }, - { environment: { id: string; name: string } } - >('runtimeEnvironments:addFromPairingCode') - await add(null, { name: 'desk', pairingCode: pairingCode() }) + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + await add(null, { name: 'desk', pairingCode: pairingCode() }) - const getStatus = handler< - { selector: string; timeoutMs?: number }, - { ok: false; error: { message: string; data?: { remoteControl?: { state: string } } } } - >('runtimeEnvironments:getStatus') + const getStatus = handler< + { selector: string; timeoutMs?: number }, + { ok: false; error: { message: string; data?: { remoteControl?: { state: string } } } } + >(channel) - await expect(getStatus(null, { selector: 'desk' })).resolves.toMatchObject({ - ok: false, - error: { - message: 'socket closed', - data: { remoteControl: { state: 'reconnecting' } } - } - }) - }) + await expect(getStatus(null, { selector: 'desk' })).resolves.toMatchObject({ + ok: false, + error: { + message: expect.stringContaining('connect both devices to Tailscale'), + data: { remoteControl: { state: 'reconnecting' } } + } + }) + } + ) }) diff --git a/src/main/ipc/runtime-environments-subscription-lifecycle.test.ts b/src/main/ipc/runtime-environments-subscription-lifecycle.test.ts index 53a07442c3b..ed4dcd62182 100644 --- a/src/main/ipc/runtime-environments-subscription-lifecycle.test.ts +++ b/src/main/ipc/runtime-environments-subscription-lifecycle.test.ts @@ -1,3 +1,4 @@ +import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -38,6 +39,7 @@ const { })) vi.mock('electron', () => ({ + BrowserWindow: { getAllWindows: () => [] }, app: { getPath: getPathMock }, ipcMain: { handle: handleMock, @@ -52,18 +54,23 @@ vi.mock('../../shared/remote-runtime-client', () => ({ subscribeRemoteRuntimeRequest: subscribeRemoteRuntimeRequestMock })) -vi.mock('./runtime-environment-request-connections', () => ({ - sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, - sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, - subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, - getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, - reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock, - retryRemoteRuntimeSharedControlConnectionsNow: retryRemoteRuntimeSharedControlConnectionsNowMock, - retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), - ensureRemoteRuntimeSharedControlConnection: vi.fn(), - pauseRemoteRuntimeSharedControlRetry: vi.fn(), - closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock -})) +vi.mock('./runtime-environment-request-connections', async () => { + const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness') + return withRuntimeStatusOwners({ + sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, + sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, + subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, + getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, + reconnectRemoteRuntimeSharedControlConnection: + reconnectRemoteRuntimeSharedControlConnectionMock, + retryRemoteRuntimeSharedControlConnectionsNow: + retryRemoteRuntimeSharedControlConnectionsNowMock, + retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), + ensureRemoteRuntimeSharedControlConnection: vi.fn(), + pauseRemoteRuntimeSharedControlRetry: vi.fn(), + closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock + }) +}) import { invalidateRuntimeEnvironmentTransport, @@ -109,6 +116,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) afterEach(() => { + resetRuntimeEnvironmentStatusOwners() rmSync(userDataPath, { recursive: true, force: true }) }) diff --git a/src/main/ipc/runtime-environments-subscription-routing.test.ts b/src/main/ipc/runtime-environments-subscription-routing.test.ts index 494f0b9ea6b..0ef70f7ac96 100644 --- a/src/main/ipc/runtime-environments-subscription-routing.test.ts +++ b/src/main/ipc/runtime-environments-subscription-routing.test.ts @@ -1,3 +1,4 @@ +import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -40,6 +41,7 @@ const { })) vi.mock('electron', () => ({ + BrowserWindow: { getAllWindows: () => [] }, app: { getPath: getPathMock }, ipcMain: { handle: handleMock, @@ -54,18 +56,23 @@ vi.mock('../../shared/remote-runtime-client', () => ({ subscribeRemoteRuntimeRequest: subscribeRemoteRuntimeRequestMock })) -vi.mock('./runtime-environment-request-connections', () => ({ - sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, - sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, - subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, - getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, - reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock, - retryRemoteRuntimeSharedControlConnectionsNow: retryRemoteRuntimeSharedControlConnectionsNowMock, - retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), - ensureRemoteRuntimeSharedControlConnection: vi.fn(), - pauseRemoteRuntimeSharedControlRetry: vi.fn(), - closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock -})) +vi.mock('./runtime-environment-request-connections', async () => { + const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness') + return withRuntimeStatusOwners({ + sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, + sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, + subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, + getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, + reconnectRemoteRuntimeSharedControlConnection: + reconnectRemoteRuntimeSharedControlConnectionMock, + retryRemoteRuntimeSharedControlConnectionsNow: + retryRemoteRuntimeSharedControlConnectionsNowMock, + retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), + ensureRemoteRuntimeSharedControlConnection: vi.fn(), + pauseRemoteRuntimeSharedControlRetry: vi.fn(), + closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock + }) +}) import { registerRuntimeEnvironmentHandlers } from './runtime-environments' import { channelHandlerLookup, pairingCode } from './runtime-environments-ipc-test-harness' @@ -108,6 +115,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) afterEach(() => { + resetRuntimeEnvironmentStatusOwners() rmSync(userDataPath, { recursive: true, force: true }) }) diff --git a/src/main/ipc/runtime-environments-subscription-teardown.test.ts b/src/main/ipc/runtime-environments-subscription-teardown.test.ts index 13a98e1057a..afb1adf457a 100644 --- a/src/main/ipc/runtime-environments-subscription-teardown.test.ts +++ b/src/main/ipc/runtime-environments-subscription-teardown.test.ts @@ -1,3 +1,4 @@ +import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -38,6 +39,7 @@ const { })) vi.mock('electron', () => ({ + BrowserWindow: { getAllWindows: () => [] }, app: { getPath: getPathMock }, ipcMain: { handle: handleMock, @@ -52,18 +54,23 @@ vi.mock('../../shared/remote-runtime-client', () => ({ subscribeRemoteRuntimeRequest: subscribeRemoteRuntimeRequestMock })) -vi.mock('./runtime-environment-request-connections', () => ({ - sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, - sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, - subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, - getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, - reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock, - retryRemoteRuntimeSharedControlConnectionsNow: retryRemoteRuntimeSharedControlConnectionsNowMock, - retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), - ensureRemoteRuntimeSharedControlConnection: vi.fn(), - pauseRemoteRuntimeSharedControlRetry: vi.fn(), - closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock -})) +vi.mock('./runtime-environment-request-connections', async () => { + const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness') + return withRuntimeStatusOwners({ + sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, + sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, + subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, + getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, + reconnectRemoteRuntimeSharedControlConnection: + reconnectRemoteRuntimeSharedControlConnectionMock, + retryRemoteRuntimeSharedControlConnectionsNow: + retryRemoteRuntimeSharedControlConnectionsNowMock, + retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), + ensureRemoteRuntimeSharedControlConnection: vi.fn(), + pauseRemoteRuntimeSharedControlRetry: vi.fn(), + closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock + }) +}) vi.mock('../browser/paired-runtime-browser-client-host-runtime', () => ({ retirePairedRuntimeBrowserClientHostEnvironment: retirePairedRuntimeBrowserClientHostEnvironmentMock @@ -115,6 +122,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) afterEach(() => { + resetRuntimeEnvironmentStatusOwners() rmSync(userDataPath, { recursive: true, force: true }) }) diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index 7d9a261eef9..ac7a4107bc8 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -1,6 +1,6 @@ import { app, ipcMain } from 'electron' import { randomUUID } from 'node:crypto' -import { resolveEnvironment } from '../../shared/runtime-environment-store' +import { listEnvironments, resolveEnvironment } from '../../shared/runtime-environment-store' import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' import type { Store } from '../persistence' import { @@ -8,14 +8,16 @@ import { registerRuntimeEnvironmentConnectivityHandlers, registerRuntimeEnvironmentPassiveHandlers } from './runtime-environment-connectivity-handlers' -import { closeRemoteRuntimeRequestConnection } from './runtime-environment-request-connections' +import { + closeRemoteRuntimeRequestConnection, + getRuntimeEnvironmentStatusOwner +} from './runtime-environment-request-connections' import { registerRuntimeEnvironmentRecoveryHandler } from './runtime-environment-recovery-handler' import { advanceRuntimeEnvironmentTransportGeneration, getRuntimeEnvironmentTransportGeneration } from './runtime-environment-transport-generation' import { - clearSharedControlSupport, resetSharedControlSupport, subscribeRuntimeEnvironment } from './runtime-environment-transport-routing' @@ -64,7 +66,6 @@ export function invalidateRuntimeEnvironmentTransport(environmentId: string): Pr advanceRuntimeEnvironmentCapabilityIncarnation(environmentId) advanceRuntimeEnvironmentTransportGeneration(environmentId) closeRemoteRuntimeRequestConnection(environmentId) - clearSharedControlSupport(environmentId) closeSubscriptionsForEnvironment(environmentId) return retirePairedRuntimeBrowserClientHostEnvironment( environmentId, @@ -97,6 +98,11 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { }) registerRuntimeEnvironmentRecoveryHandler() registerRuntimeEnvironmentPassiveHandlers(getUserDataPath) + for (const environment of listEnvironments(getUserDataPath())) { + if (!isRuntimeEnvironmentManuallyDisconnected(environment.id)) { + getRuntimeEnvironmentStatusOwner(getUserDataPath(), environment.id).activate() + } + } ipcMain.handle( 'runtimeEnvironments:subscribe', async ( diff --git a/src/preload/api/runtime-api.ts b/src/preload/api/runtime-api.ts index f7f553ec2c0..7fdf229dd88 100644 --- a/src/preload/api/runtime-api.ts +++ b/src/preload/api/runtime-api.ts @@ -1,3 +1,4 @@ +import type { RuntimeHostStatusSnapshot } from '../../shared/runtime-host-status' import type { RuntimeBrowserDriverState, RuntimeRendererSyncWindowGraph, @@ -77,6 +78,8 @@ export type RuntimeApi = { ) => () => void } runtimeEnvironments: { + getStatusSnapshots: () => Promise + onStatusChanged: (callback: (snapshot: RuntimeHostStatusSnapshot) => void) => () => void list: () => Promise addFromPairingCode: (args: { name: string diff --git a/src/preload/api/runtime-environments-bridge.ts b/src/preload/api/runtime-environments-bridge.ts index ddfa498dc74..63c31df52f8 100644 --- a/src/preload/api/runtime-environments-bridge.ts +++ b/src/preload/api/runtime-environments-bridge.ts @@ -1,4 +1,8 @@ import { ipcRenderer } from 'electron' +import { + RUNTIME_HOST_STATUS_CHANNEL, + type RuntimeHostStatusSnapshot +} from '../../shared/runtime-host-status' import type { VerifyAndAddRuntimeEnvironmentResult } from '../../shared/remote-pairing-verification' import type { RuntimeStatus } from '../../shared/runtime-types' import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' @@ -12,6 +16,16 @@ import { import type { PreloadApi } from '../api-types' export const runtimeEnvironmentsApi = { + getStatusSnapshots: (): Promise => + ipcRenderer.invoke('runtimeEnvironments:getStatusSnapshots'), + onStatusChanged: (callback: (snapshot: RuntimeHostStatusSnapshot) => void): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + snapshot: RuntimeHostStatusSnapshot + ): void => callback(snapshot) + ipcRenderer.on(RUNTIME_HOST_STATUS_CHANNEL, listener) + return () => ipcRenderer.removeListener(RUNTIME_HOST_STATUS_CHANNEL, listener) + }, list: (): Promise => ipcRenderer.invoke('runtimeEnvironments:list'), addFromPairingCode: (args: { diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index d17bb8ba56d..7145bed732c 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -242,17 +242,11 @@ export default function NewWorkspaceComposerCard( selector: action.environmentId, timeoutMs: 15_000 }) - const runtimeStatus = unwrapRuntimeRpcResult(response) - useAppStore.getState().setRuntimeEnvironmentStatus(action.environmentId, { - status: runtimeStatus, - checkedAt: Date.now() - }) + unwrapRuntimeRpcResult(response) + await useAppStore.getState().readRuntimeHostStatusSnapshots() } catch (error) { if (action.kind === 'runtime') { - useAppStore.getState().setRuntimeEnvironmentStatus(action.environmentId, { - status: null, - checkedAt: Date.now() - }) + await useAppStore.getState().readRuntimeHostStatusSnapshots() } toast.error( error instanceof Error diff --git a/src/renderer/src/components/cmd-j/palette-host-badge.test.ts b/src/renderer/src/components/cmd-j/palette-host-badge.test.ts index cd6310c8580..8d15ff8d034 100644 --- a/src/renderer/src/components/cmd-j/palette-host-badge.test.ts +++ b/src/renderer/src/components/cmd-j/palette-host-badge.test.ts @@ -83,8 +83,7 @@ describe('getPaletteHostBadge', () => { repos: [{ executionHostId: 'runtime:env-1' }], sshTargetLabels: new Map(), settings: { activeRuntimeEnvironmentId: 'env-2' }, - // A live status makes the runtime 'available'; without it the host reads - // 'disconnected' and the badge is suppressed (covered below). + // Only verified availability enables unfiltered host badges. runtimeStatusByEnvironmentId: new Map([ [ 'env-1', @@ -145,3 +144,19 @@ describe('getPaletteHostBadge', () => { expect(getPaletteHostBadge(null, hosts)).toBeNull() }) }) + +it.each(['connecting', 'blocked', 'disconnected', 'error'] as const)( + 'does not infer reachability from %s health, but preserves explicit filter labels', + (health) => { + const hosts = buildSidebarHostOptions({ + repos: [{ executionHostId: 'runtime:env-1' }], + sshTargetLabels: new Map(), + settings: { activeRuntimeEnvironmentId: null } + }).map((host) => (host.kind === 'runtime' ? { ...host, health } : host)) + expect(getPaletteHostBadge({ connectionId: null }, hosts)).toBeNull() + expect(getPaletteHostBadge({ executionHostId: 'runtime:env-1' }, hosts, true)).toEqual({ + hostId: 'runtime:env-1', + label: 'env-1' + }) + } +) diff --git a/src/renderer/src/components/cmd-j/palette-host-badge.ts b/src/renderer/src/components/cmd-j/palette-host-badge.ts index 8ac94adeae4..01a9e33d3ac 100644 --- a/src/renderer/src/components/cmd-j/palette-host-badge.ts +++ b/src/renderer/src/components/cmd-j/palette-host-badge.ts @@ -17,7 +17,7 @@ export type PaletteHostBadge = { // unlike the sidebar gate, which lists disconnected hosts so users can connect. function hasActiveRemoteHost(hostOptions: readonly SidebarHostOption[]): boolean { return hostOptions.some( - (host) => host.id !== LOCAL_EXECUTION_HOST_ID && host.health !== 'disconnected' + (host) => host.id !== LOCAL_EXECUTION_HOST_ID && host.health === 'available' ) } diff --git a/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx b/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx index 94854bb623d..07bf5bfb54b 100644 --- a/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx +++ b/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx @@ -28,7 +28,7 @@ import { } from '@/store/slices/runtime-environment-ssh' import { isConnectedRuntimeHostState, - runtimeHostConnectionState + runtimeHostConnectionStateForEntry } from '@/runtime/runtime-host-connection-state' type RepositoryHostSetupsSectionProps = { @@ -227,14 +227,7 @@ export function RepositoryHostSetupsSection({ ? runtimeStatusByEnvironmentId.get(runtimeOwnerEnvironmentId) : undefined const runtimeOwnerState = runtimeOwnerEnvironmentId - ? runtimeHostConnectionState({ - hasStatusEntry: Boolean(runtimeOwnerStatusEntry), - status: runtimeOwnerStatusEntry?.status, - remoteControl: - runtimeOwnerStatusEntry?.remoteControl ?? - runtimeOwnerStatusEntry?.status?.remoteControl ?? - null - }) + ? runtimeHostConnectionStateForEntry(runtimeOwnerStatusEntry) : null const runtimeOwnerReachable = runtimeOwnerState === null || isConnectedRuntimeHostState(runtimeOwnerState) diff --git a/src/renderer/src/components/settings/use-runtime-environment-catalog.ts b/src/renderer/src/components/settings/use-runtime-environment-catalog.ts index a4acfb2b121..5470bf5b6e4 100644 --- a/src/renderer/src/components/settings/use-runtime-environment-catalog.ts +++ b/src/renderer/src/components/settings/use-runtime-environment-catalog.ts @@ -51,10 +51,7 @@ export function useRuntimeEnvironmentCatalog(): RuntimeEnvironmentCatalog { // linger in the sidebar registry. useAppStore.getState().setRuntimeEnvironments(nextEnvironments) if (verified) { - useAppStore.getState().setRuntimeEnvironmentStatus(verified.environmentId, { - status: verified.runtimeStatus, - checkedAt: Date.now() - }) + await useAppStore.getState().readRuntimeHostStatusSnapshots() } if (mountedRef.current) { setEnvironments(visibleEnvironments) @@ -93,10 +90,7 @@ export function useRuntimeEnvironmentCatalog(): RuntimeEnvironmentCatalog { const runtimeStatus = unwrapRuntimeRpcResult(response) // Why: feed the live status into the store so sidebar host pickers // reflect manual refreshes, not just the settings pane. - useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { - status: runtimeStatus, - checkedAt: Date.now() - }) + await useAppStore.getState().readRuntimeHostStatusSnapshots() if (!mountedRef.current) { return } @@ -114,11 +108,7 @@ export function useRuntimeEnvironmentCatalog(): RuntimeEnvironmentCatalog { // Why: record the failed probe (null status) so the sidebar can // distinguish unreachable from never-checked. const remoteControl = extractRuntimeTransportDiagnostics(error) - useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { - status: null, - ...(remoteControl ? { remoteControl } : {}), - checkedAt: Date.now() - }) + await useAppStore.getState().readRuntimeHostStatusSnapshots() if (!mountedRef.current) { return } diff --git a/src/renderer/src/components/settings/use-runtime-environment-connection-actions.ts b/src/renderer/src/components/settings/use-runtime-environment-connection-actions.ts index 062fde45ff4..2174c3633c6 100644 --- a/src/renderer/src/components/settings/use-runtime-environment-connection-actions.ts +++ b/src/renderer/src/components/settings/use-runtime-environment-connection-actions.ts @@ -40,14 +40,7 @@ export function useRuntimeEnvironmentConnectionActions({ await window.api.runtimeEnvironments.disconnect({ selector: environment.id }) // Why: disconnect is non-destructive; keep the saved server but show the // user that this live client is no longer attached to it. - useAppStore.getState().setRuntimeEnvironmentStatus( - environment.id, - { - status: null, - checkedAt: Date.now() - }, - { suppressDisconnectToast: true } - ) + await useAppStore.getState().readRuntimeHostStatusSnapshots() if (mountedRef.current) { setDetailsByEnvironmentId((current) => ({ ...current, @@ -96,10 +89,7 @@ export function useRuntimeEnvironmentConnectionActions({ const compatibility = evaluateHostDetails(runtimeStatus) // Why: row Connect is reachability only. The Advanced selector is the // explicit default-host control and should be the only active-server path. - useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { - status: runtimeStatus, - checkedAt: Date.now() - }) + await useAppStore.getState().readRuntimeHostStatusSnapshots() if (mountedRef.current) { setDetailsByEnvironmentId((current) => ({ ...current, @@ -143,11 +133,7 @@ export function useRuntimeEnvironmentConnectionActions({ } catch (error) { const message = error instanceof Error ? error.message : 'Failed to connect server.' const remoteControl = extractRuntimeTransportDiagnostics(error) - useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { - status: null, - ...(remoteControl ? { remoteControl } : {}), - checkedAt: Date.now() - }) + await useAppStore.getState().readRuntimeHostStatusSnapshots() if (mountedRef.current) { setDetailsByEnvironmentId((current) => ({ ...current, diff --git a/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx b/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx index 03658792d94..1c6ba1ba75a 100644 --- a/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx @@ -68,7 +68,7 @@ export function AddRemoteHostDialog({ const setSshTargetsMetadata = useAppStore((s) => s.setSshTargetsMetadata) const recordSshRepoReadoptions = useAppStore((s) => s.recordSshRepoReadoptions) const setRuntimeEnvironments = useAppStore((s) => s.setRuntimeEnvironments) - const setRuntimeEnvironmentStatus = useAppStore((s) => s.setRuntimeEnvironmentStatus) + const readRuntimeHostStatusSnapshots = useAppStore((s) => s.readRuntimeHostStatusSnapshots) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const busy = isSaving || isBulkImporting || resolvingConfigAlias !== null @@ -283,10 +283,7 @@ export function AddRemoteHostDialog({ } const environments = await window.api.runtimeEnvironments.list() setRuntimeEnvironments(environments) - setRuntimeEnvironmentStatus(result.environment.id, { - status: result.runtimeStatus, - checkedAt: Date.now() - }) + await readRuntimeHostStatusSnapshots() toast.success( translate('auto.components.sidebar.AddRemoteHostDialog.serverSaved', 'Remote server added.') ) diff --git a/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx b/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx index 3aead74fd81..31d2fab5b91 100644 --- a/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx +++ b/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx @@ -141,13 +141,10 @@ export function HostSectionHeaderMenu({ row }: { row: HostHeaderRow }): React.JS selector: parsed.environmentId, timeoutMs: 10_000 }) - const runtimeStatus = unwrapRuntimeRpcResult(response) + unwrapRuntimeRpcResult(response) // Why: feed the probe result into the shared store so the host header and // other host pickers reflect this check without a separate fetch. - useAppStore.getState().setRuntimeEnvironmentStatus(parsed.environmentId, { - status: runtimeStatus, - checkedAt: Date.now() - }) + await useAppStore.getState().readRuntimeHostStatusSnapshots() toast.success( translate( 'auto.components.sidebar.HostSectionHeaderMenu.7f1a2b3c4d', @@ -160,10 +157,7 @@ export function HostSectionHeaderMenu({ row }: { row: HostHeaderRow }): React.JS } catch (err) { // Why: record the failed probe so the host registry can drop a previously // healthy verdict instead of showing stale "compatible" state. - useAppStore.getState().setRuntimeEnvironmentStatus(parsed.environmentId, { - status: null, - checkedAt: Date.now() - }) + await useAppStore.getState().readRuntimeHostStatusSnapshots() toast.error( err instanceof Error ? err.message diff --git a/src/renderer/src/components/sidebar/NoticeHostGlyph.test.tsx b/src/renderer/src/components/sidebar/NoticeHostGlyph.test.tsx index 46bf7cd371f..efd47b976ec 100644 --- a/src/renderer/src/components/sidebar/NoticeHostGlyph.test.tsx +++ b/src/renderer/src/components/sidebar/NoticeHostGlyph.test.tsx @@ -83,7 +83,8 @@ describe('NoticeHostGlyph', () => { ) }) - it('marks a paired runtime with no live status as disconnected', async () => { + it('marks a paired runtime a probe found unreachable as disconnected', async () => { + runtimeStatusByEnvironmentId.set('openclaw-env', { status: null }) const container = await render('runtime:openclaw-env') expect(container.querySelector('[data-testid="tooltip"]')?.textContent).toBe( @@ -91,6 +92,17 @@ describe('NoticeHostGlyph', () => { ) }) + it('does not call a host disconnected before its first probe answers', async () => { + // No entry means "not asked yet", not "asked and unreachable" — collapsing the two + // painted every remote row destructive between launch and the first probe. + const container = await render('runtime:openclaw-env') + + expect(container.querySelector('[data-testid="tooltip"]')?.textContent).toBe( + 'Project on openclaw' + ) + expect(container.querySelector('svg')?.getAttribute('class')).not.toContain('text-destructive') + }) + it('gives the local host the monitor glyph the run-target rows use', async () => { const container = await render('local', 'Local Mac') diff --git a/src/renderer/src/components/sidebar/NoticeHostGlyph.tsx b/src/renderer/src/components/sidebar/NoticeHostGlyph.tsx index db1616203dd..7c070450e7d 100644 --- a/src/renderer/src/components/sidebar/NoticeHostGlyph.tsx +++ b/src/renderer/src/components/sidebar/NoticeHostGlyph.tsx @@ -5,6 +5,10 @@ import { HostRowIcon } from '../host-row-icon' import { useAppStore } from '@/store' import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' import { translate } from '@/i18n/i18n' +import { + isDisconnectedRuntimeHostState, + runtimeHostConnectionStateForEntry +} from '@/runtime/runtime-host-connection-state' type NoticeHostGlyphProps = { hostId: ExecutionHostId @@ -26,11 +30,15 @@ export default function NoticeHostGlyph({ keyboardFocusable }: NoticeHostGlyphProps): React.JSX.Element | null { const host = parseExecutionHostId(hostId) + // Why the shared derivation, not raw truthiness: an absent entry means "not probed yet", + // which is not the same verdict as a probe that came back unreachable. const isDisconnected = useAppStore((s) => { if (host?.kind !== 'runtime') { return false } - return !s.runtimeStatusByEnvironmentId.get(host.environmentId)?.status + return isDisconnectedRuntimeHostState( + runtimeHostConnectionStateForEntry(s.runtimeStatusByEnvironmentId.get(host.environmentId)) + ) }) if (!host) { diff --git a/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx index 1fe2f4b9530..55ef3263ad4 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx @@ -218,18 +218,35 @@ describe('WorktreeCard SSH reconnect prompt', () => { expect(markup).not.toContain('Retry SSH connection') }) - it('marks a runtime-host worktree disconnected when its environment has no status', () => { + it('marks a runtime-host worktree disconnected once a probe finds it unreachable', () => { + runtimeEnvironments = [{ id: 'env-1', name: 'Remote Mac' }] + runtimeStatusByEnvironmentId.set('env-1', { status: null }) + const runtimeRepo: Repo = { + ...makeRepo(), + connectionId: undefined, + executionHostId: 'runtime:env-1' + } + const markup = renderToStaticMarkup( + + ) + expect(markup).toContain('Remote Mac disconnected') + }) + + // Why: "not probed yet" is not "probed and unreachable" — collapsing them painted every + // remote card destructive and dimmed between launch and the first probe answering. + it('leaves a runtime-host worktree undimmed before its first probe answers', () => { runtimeEnvironments = [{ id: 'env-1', name: 'Remote Mac' }] const runtimeRepo: Repo = { ...makeRepo(), connectionId: undefined, executionHostId: 'runtime:env-1' } - // No status entry for env-1 → host is disconnected. const markup = renderToStaticMarkup( ) - expect(markup).toContain('Remote Mac disconnected') + expect(markup).not.toContain('Remote Mac disconnected') + expect(markup).toContain('Project on Remote Mac') + expect(markup).not.toContain('opacity-60') }) it('distinguishes connected worktrees on different Orca servers', () => { diff --git a/src/renderer/src/components/sidebar/sidebar-host-options.test.ts b/src/renderer/src/components/sidebar/sidebar-host-options.test.ts index 454268c24c4..6fe8d0d5901 100644 --- a/src/renderer/src/components/sidebar/sidebar-host-options.test.ts +++ b/src/renderer/src/components/sidebar/sidebar-host-options.test.ts @@ -77,11 +77,10 @@ describe('sidebar host options', () => { }) expect(hosts.map((host) => host.id)).toEqual(['local', 'runtime:runtime-1']) - // Without live status the focused runtime has no proof of reachability, so it - // reads 'disconnected' rather than defaulting to 'available'/"Connected". + // A first probe still in progress is not evidence of disconnection. expect(hosts.find((host) => host.id === 'runtime:runtime-1')).toMatchObject({ detail: 'Orca server', - health: 'disconnected' + health: 'connecting' }) }) diff --git a/src/renderer/src/components/sidebar/use-worktree-card-foundation.ts b/src/renderer/src/components/sidebar/use-worktree-card-foundation.ts index fc339bbe33f..d8ec20bb45a 100644 --- a/src/renderer/src/components/sidebar/use-worktree-card-foundation.ts +++ b/src/renderer/src/components/sidebar/use-worktree-card-foundation.ts @@ -10,6 +10,10 @@ import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-ov import { getWorkspacePortsByWorktreeId } from '@/lib/workspace-port-groups' import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { hydrateRuntimeEnvironmentSshState } from '@/runtime/runtime-environment-ssh-state' +import { + isDisconnectedRuntimeHostState, + runtimeHostConnectionStateForEntry +} from '@/runtime/runtime-host-connection-state' import { useAppStore } from '@/store' import { selectRuntimeAwareSshStatus, @@ -177,12 +181,17 @@ export function useWorktreeCardFoundation({ const runtimeHostLabel = runtimeHostId ? (getHostDisplayLabelOverrides(settings).get(runtimeHostId) ?? runtimeEnvironmentName) : null - // Why: runtime ("Orca server") hosts get the same disconnected dimming as SSH when their environment has no live status. + // Why the shared derivation, not raw truthiness: an absent entry means "not probed yet", + // which is not the same verdict as a probe that came back unreachable. const isRuntimeDisconnected = useAppStore((s) => { if (!runtimeOwnerEnvironmentId) { return false } - return !s.runtimeStatusByEnvironmentId.get(runtimeOwnerEnvironmentId)?.status + return isDisconnectedRuntimeHostState( + runtimeHostConnectionStateForEntry( + s.runtimeStatusByEnvironmentId.get(runtimeOwnerEnvironmentId) + ) + ) }) const [titleRenaming, setTitleRenaming] = useState(false) const [showRenameErrorDialog, setShowRenameErrorDialog] = useState(false) diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.tsx b/src/renderer/src/components/status-bar/SshStatusSegment.tsx index be74c0fd7f2..cfaeeeb6bdc 100644 --- a/src/renderer/src/components/status-bar/SshStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/SshStatusSegment.tsx @@ -33,7 +33,7 @@ import { } from './remote-host-connection-status' import { isConnectedRuntimeHostState, - runtimeHostConnectionState, + runtimeHostConnectionStateForEntry, runtimeStatusForOverall } from '@/runtime/runtime-host-connection-state' import { refreshRuntimeProjectWorktreesAndLineage } from '@/hooks/runtime-project-refresh-scheduler' @@ -74,7 +74,7 @@ export function SshStatusSegment({ const settings = useAppStore((s) => s.settings) const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) - const setRuntimeEnvironmentStatus = useAppStore((s) => s.setRuntimeEnvironmentStatus) + const readRuntimeHostStatusSnapshots = useAppStore((s) => s.readRuntimeHostStatusSnapshots) const hydrateRuntimeEnvironmentStatuses = useAppStore((s) => s.hydrateRuntimeEnvironmentStatuses) const remoteWorkspaceSyncStatusByTargetId = useAppStore( (s) => s.remoteWorkspaceSyncStatusByTargetId @@ -105,7 +105,7 @@ export function SshStatusSegment({ return { id: environment.id, label: override || environment.name || environment.id, - hasStatusEntry: Boolean(statusEntry), + snapshot: statusEntry?.snapshot, status: statusEntry?.status ?? null, active: settings?.activeRuntimeEnvironmentId === environment.id, remoteControl: statusEntry?.remoteControl ?? statusEntry?.status?.remoteControl ?? null @@ -113,7 +113,7 @@ export function SshStatusSegment({ }) const runtimeHostRows = runtimeHosts.map((host) => ({ ...host, - state: runtimeHostConnectionState(host) + state: runtimeHostConnectionStateForEntry(runtimeStatusByEnvironmentId.get(host.id)) })) // Available remote servers are online even when they are not the active runtime. // Keep host health separate from the advanced active-server selection. @@ -152,11 +152,7 @@ export function SshStatusSegment({ async (environmentId: string): Promise => { try { await window.api.runtimeEnvironments.disconnect({ selector: environmentId }) - setRuntimeEnvironmentStatus( - environmentId, - { status: null, checkedAt: Date.now() }, - { suppressDisconnectToast: true } - ) + await readRuntimeHostStatusSnapshots() recordFeatureInteraction('ssh') } catch (err) { toast.error( @@ -169,7 +165,7 @@ export function SshStatusSegment({ ) } }, - [recordFeatureInteraction, setRuntimeEnvironmentStatus] + [recordFeatureInteraction, readRuntimeHostStatusSnapshots] ) if (targets.length === 0 && runtimeHosts.length === 0) { diff --git a/src/renderer/src/hooks/ipc-events-test-harness.ts b/src/renderer/src/hooks/ipc-events-test-harness.ts index ae9efcc798e..b3d1ef71b98 100644 --- a/src/renderer/src/hooks/ipc-events-test-harness.ts +++ b/src/renderer/src/hooks/ipc-events-test-harness.ts @@ -140,6 +140,9 @@ export async function loadIpcEventsHarness( dispatchEvent: vi.fn(), api: new Proxy( { + runtimeEnvironments: createApiNamespaceStub({ + getStatusSnapshots: () => Promise.resolve([]) + }), ui: createApiNamespaceStub({ getZoomLevel: () => 0, consumePendingOpenSettings: () => Promise.resolve(false), diff --git a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts index fa683b7188f..c5964d77fec 100644 --- a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts @@ -1,3 +1,4 @@ +import type { RuntimeHostStatusSnapshot } from '../../../../shared/runtime-host-status' import { getTabIdsAwaitingHostHydrationRemount } from '@/lib/parked-terminal-host-hydration' import { emitAutomationsChangedWindowEvent } from '@/lib/automations-changed-window-event' import { createBackgroundSleepingAgentWakeDispatcher } from '@/lib/wake-sleeping-agents-in-background' @@ -63,13 +64,23 @@ export function installAppLifetimeIpcEvents( ) const worktreeRuntime = createWorktreeEventRuntime(unsubs, isRuntimeEnvironmentActive) - const onSharedControlDiagnostics = window.api.runtimeEnvironments?.onSharedControlDiagnostics - if (onSharedControlDiagnostics) { - unsubs.push( - onSharedControlDiagnostics((event) => { - useAppStore.getState().publishRuntimeEnvironmentDiagnostics(event) + const statusApi = window.api.runtimeEnvironments + if (statusApi?.onStatusChanged) { + const apply = (snapshot: RuntimeHostStatusSnapshot): void => { + useAppStore.getState().applyRuntimeHostStatusSnapshot(snapshot) + } + let stopped = false + unsubs.push(statusApi.onStatusChanged(apply), () => { + stopped = true + }) + void statusApi + .getStatusSnapshots() + .then((snapshots) => { + if (!stopped) { + snapshots.forEach(apply) + } }) - ) + .catch((error) => console.error('Failed to read runtime status snapshots:', error)) } const unsubscribeRuntimeEnvironmentStore = registerRuntimeClientIpcBridge(unsubs, worktreeRuntime) registerProjectCatalogIpcBridge( diff --git a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts index dc85e375124..a5db87bbce1 100644 --- a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts @@ -29,20 +29,6 @@ import { } from './runtime-environment-subscription-selection' import type { WorktreeEventRuntime } from './worktree-event-runtime' -/** Backoff for re-asking status.get after a probe that failed on its own socket. */ -const RUNTIME_STATUS_PROBE_RETRY_DELAYS_MS = [2_000, 10_000] - -/** - * Why a request carries its reason: `reconnected` must re-ask even when the cache reads - * reachable (a restart changes the runtimeId under an unchanged-looking status), while - * `recordedUnreachable` is satisfied by any answer that clears the offline verdict. - */ -type RuntimeStatusProbeTrigger = 'reconnected' | 'recordedUnreachable' - -function isRuntimeStatusRecordedUnreachable(environmentId: string): boolean { - return useAppStore.getState().runtimeStatusByEnvironmentId?.get(environmentId)?.status === null -} - export function registerRuntimeClientIpcBridge( unsubs: (() => void)[], worktreeRuntime: WorktreeEventRuntime @@ -148,107 +134,6 @@ export function registerRuntimeClientIpcBridge( }) } - const inFlightRuntimeStatusProbes = new Set() - const trailingRuntimeStatusProbes = new Map() - const runtimeStatusProbeRetryTimers = new Map>() - let runtimeStatusProbesStopped = false - // Why not the desired-subscription set alone: a host that is not the active environment - // drops out of it the moment anything records its status unreachable, so gating the - // retry on it cancels the retry exactly where recovery matters. Only removal - // (or a still-unhydrated catalog behind an active id) decides whether to keep asking, - // and the tombstone covers the window where settings still names a deleted host active. - const shouldProbeRuntimeStatus = (environmentId: string): boolean => { - const state = useAppStore.getState() - if (state.removedRuntimeEnvironmentIds?.has(environmentId)) { - return false - } - return ( - (state.runtimeEnvironments ?? []).some((environment) => environment.id === environmentId) || - getRuntimeClientEventEnvironmentIds(state).includes(environmentId) - ) - } - // Why: concurrent probes resolve in arbitrary order, so a slow one can publish its - // stale answer over a newer one and leave the sidebar naming a superseded runtime. - const probeRuntimeStatus = ( - environmentId: string, - attempt = 0, - trigger: RuntimeStatusProbeTrigger = 'reconnected' - ): void => { - if (runtimeStatusProbesStopped || !shouldProbeRuntimeStatus(environmentId)) { - return - } - if (inFlightRuntimeStatusProbes.has(environmentId)) { - // Serialize, don't drop: the in-flight answer predates this request, so a reconnect - // that lands mid-probe would otherwise go unasked — and a probe that succeeds - // schedules no retry to pick it up later. A reconnect outranks a queued - // recorded-unreachable request, which the in-flight answer may already settle. - if (trigger === 'reconnected' || !trailingRuntimeStatusProbes.has(environmentId)) { - trailingRuntimeStatusProbes.set(environmentId, trigger) - } - return - } - const pendingRetry = runtimeStatusProbeRetryTimers.get(environmentId) - if (pendingRetry !== undefined) { - clearTimeout(pendingRetry) - runtimeStatusProbeRetryTimers.delete(environmentId) - } - inFlightRuntimeStatusProbes.add(environmentId) - void useAppStore - .getState() - // publishUnreachable: false — the transport that just proved this host alive is not the - // socket status.get dials, so a failed probe here is unverifiable and must publish nothing. - .refreshRuntimeEnvironmentStatus(environmentId, undefined, { publishUnreachable: false }) - .catch(() => false) - .then((reachable) => { - inFlightRuntimeStatusProbes.delete(environmentId) - const trailingTrigger = trailingRuntimeStatusProbes.get(environmentId) - if (trailingTrigger !== undefined) { - trailingRuntimeStatusProbes.delete(environmentId) - // A newer reconnect asked while this one was dialing: restart the attempt chain. - // A resubscribe only asked because the cache read unreachable, so skip the extra - // socket + E2EE handshake when this answer already cleared that. - if ( - trailingTrigger === 'reconnected' || - isRuntimeStatusRecordedUnreachable(environmentId) - ) { - probeRuntimeStatus(environmentId, 0, trailingTrigger) - return - } - } - // Why: status.get dials its own short-lived socket, so it can fail while the - // control transport that just proved the host is up stays healthy. That failure - // is unverifiable and publishes nothing, so no store transition, resubscribe or - // further trigger follows — without this bounded retry one unlucky probe leaves - // a host already recorded offline stranded until the next transport gap. - const retryDelayMs = RUNTIME_STATUS_PROBE_RETRY_DELAYS_MS[attempt] - if ( - reachable || - retryDelayMs === undefined || - runtimeStatusProbesStopped || - !shouldProbeRuntimeStatus(environmentId) - ) { - return - } - runtimeStatusProbeRetryTimers.set( - environmentId, - setTimeout(() => { - runtimeStatusProbeRetryTimers.delete(environmentId) - probeRuntimeStatus(environmentId, attempt + 1) - }, retryDelayMs) - ) - }) - } - unsubs.push(() => { - // The flag, not just the timers: a probe still in flight at teardown would - // otherwise schedule a fresh retry chain after the bridge is gone. - runtimeStatusProbesStopped = true - trailingRuntimeStatusProbes.clear() - for (const retryTimer of runtimeStatusProbeRetryTimers.values()) { - clearTimeout(retryTimer) - } - runtimeStatusProbeRetryTimers.clear() - }) - const runtimeClientEventsSync = createRuntimeClientEventsSync({ getDesiredEnvironmentIds: () => getRuntimeClientEventEnvironmentIds(useAppStore.getState()), getSubscriptionKey: (environmentId) => buildRuntimeClientEventEnvironmentKey([environmentId]), @@ -271,7 +156,13 @@ export function registerRuntimeClientIpcBridge( () => { invalidateRuntimeClientEventReplay({ getSshStateReference: () => useAppStore.getState().sshStateByEnvironment, - refreshRuntimeStatus: () => probeRuntimeStatus(environmentId), + refreshRuntimeStatus: () => { + const state = useAppStore.getState() + const snapshot = state.runtimeStatusByEnvironmentId.get(environmentId)?.snapshot + if (!snapshot || snapshot.transport === 'unknown') { + void state.refreshRuntimeEnvironmentStatus(environmentId) + } + }, requestProjectRefresh: () => runtimeProjectRefreshScheduler.request(environmentId), markEnvironmentSshStateStale: () => useAppStore.getState().markEnvironmentSshStateStale(environmentId), @@ -281,19 +172,6 @@ export function registerRuntimeClientIpcBridge( }) } ) - // Why: only a reconnect of an already-ready transport replays with the tag above. - // A connection whose first ready lands after the host recovered (app started, or - // the env was added, while it was down) never replays, so the recorded-unreachable - // verdict this subscribe just disproved has to be re-asked here. Kept off the - // returned promise so subscription registration/teardown ordering is unchanged. - void subscription.then( - () => { - if (isRuntimeStatusRecordedUnreachable(environmentId)) { - probeRuntimeStatus(environmentId, 0, 'recordedUnreachable') - } - }, - () => {} - ) return subscription }, onEvent: handleRuntimeClientEvent diff --git a/src/renderer/src/hooks/ipc-events/runtime-reconnect-host-status.test.ts b/src/renderer/src/hooks/ipc-events/runtime-reconnect-host-status.test.ts index 0d605a3fab1..67050a77c19 100644 --- a/src/renderer/src/hooks/ipc-events/runtime-reconnect-host-status.test.ts +++ b/src/renderer/src/hooks/ipc-events/runtime-reconnect-host-status.test.ts @@ -67,16 +67,12 @@ describe('remote Orca server reconnect', () => { }[] = [] let liveRuntimeId = 'remote-runtime' let failingStatusProbes = 0 - let blockNextStatusProbe = false - let releaseBlockedStatusProbe: (() => void) | null = null beforeEach(() => { subscriptionResponders = [] unsubs = [] liveRuntimeId = 'remote-runtime' failingStatusProbes = 0 - blockNextStatusProbe = false - releaseBlockedStatusProbe = null // Module-level sonner double: without this a toast from an earlier test leaks into // the assertions below. vi.mocked(toast.warning).mockClear() @@ -88,12 +84,6 @@ describe('remote Orca server reconnect', () => { // Captured before the block so a probe that is still dialing answers with the // runtime it was dispatched against, not with whatever restarted meanwhile. const dispatchedRuntimeId = liveRuntimeId - if (blockNextStatusProbe) { - blockNextStatusProbe = false - await new Promise((resolve) => { - releaseBlockedStatusProbe = resolve - }) - } if (failingStatusProbes > 0) { failingStatusProbes -= 1 // status.get dials its own socket; it can fail while the control transport is up. @@ -170,261 +160,42 @@ describe('remote Orca server reconnect', () => { } as unknown as WorktreeEventRuntime) } - it('re-probes a replayed subscription while the cached status still looks reachable', async () => { + it('keeps legacy event recovery as a single request, with retries owned outside the renderer', async () => { + vi.useFakeTimers() startBridge() await settle() - expect(sidebarHostHealth()).toBe('available') - - // The gap was short enough that nothing probed during it, so the cached status still - // names the pre-restart runtime and the replay tag is the only evidence it is stale. - liveRuntimeId = 'remote-runtime-restarted' + failingStatusProbes = 1 replaySubscription() await settle() - - expect( - useAppStore.getState().runtimeStatusByEnvironmentId.get(ENVIRONMENT_ID)?.status?.runtimeId - ).toBe('remote-runtime-restarted') - expect(sidebarHostHealth()).toBe('available') - }) - - it('returns the sidebar host to online when the first subscription lands untagged', async () => { - // A connection that was never ready does not replay: nothing tags its first - // response, so a client that booted while the host was down has only the - // successful subscribe as evidence that the recorded verdict is stale. - useAppStore.setState({ - runtimeStatusByEnvironmentId: new Map([ - [ENVIRONMENT_ID, { status: null, checkedAt: Date.now() }] - ]) as never - }) - expect(sidebarHostHealth()).toBe('disconnected') - - startBridge() - await settle() - - expect(sidebarHostHealth()).toBe('available') - expect(subscriptionResponders.length).toBeGreaterThan(0) - }) - - it('re-asks after a probe that failed while the transport stayed up', async () => { - vi.useFakeTimers() - // Already recorded unreachable, so the failing probe's `null` is an unchanged - // re-publication: it writes nothing and leaves no store transition for the - // resubscribe path to key off. Only a retry can still recover this host. - useAppStore.setState({ - runtimeStatusByEnvironmentId: new Map([ - [ENVIRONMENT_ID, { status: null, checkedAt: Date.now() }] - ]) as never - }) - failingStatusProbes = 1 - - startBridge() - await settle() - expect(sidebarHostHealth()).toBe('disconnected') - - await vi.advanceTimersByTimeAsync(2_000) - await settle() - - expect(sidebarHostHealth()).toBe('available') - }) - - it('stops re-asking a host that keeps refusing, instead of polling it forever', async () => { - vi.useFakeTimers() - useAppStore.setState({ - runtimeStatusByEnvironmentId: new Map([ - [ENVIRONMENT_ID, { status: null, checkedAt: Date.now() }] - ]) as never - }) - failingStatusProbes = Number.POSITIVE_INFINITY - - startBridge() - await settle() + expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(1) await vi.advanceTimersByTimeAsync(120_000) - await settle() - - // One probe on the successful subscribe plus the two bounded retries. - expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(3) + expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(1) }) - it('stops probing once the bridge is torn down mid-probe', async () => { - vi.useFakeTimers() + it('does not add a status request when a connection-owned subscription replays', async () => { + useAppStore.getState().applyRuntimeHostStatusSnapshot({ + environmentId: ENVIRONMENT_ID, + pairingRevision: 1, + sequence: 100, + checkedAt: 1, + transport: 'ready', + verification: 'verified', + status: liveRuntimeStatus() + }) + startBridge() + await settle() + replaySubscription() + await settle() + expect(window.api.runtimeEnvironments.getStatus).not.toHaveBeenCalled() + expect(sidebarHostHealth()).toBe('available') + }) + + it('does not start UI recovery machinery when an initial subscription attaches', async () => { useAppStore.setState({ - runtimeStatusByEnvironmentId: new Map([ - [ENVIRONMENT_ID, { status: null, checkedAt: Date.now() }] - ]) as never - }) - failingStatusProbes = Number.POSITIVE_INFINITY - blockNextStatusProbe = true - - startBridge() - await settle() - expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(1) - - // Teardown clears scheduled retries, but this probe has not answered yet. - stopBridge?.() - stopBridge = null - for (const unsub of unsubs.splice(0)) { - unsub() - } - releaseBlockedStatusProbe?.() - await settle() - await vi.advanceTimersByTimeAsync(120_000) - await settle() - - expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(1) - }) - - it('re-asks for a saved host that is not the active environment', async () => { - vi.useFakeTimers() - // A non-active host is only in the desired-subscription set while its status is non-null, - // so gating the retry on that set would strand it offline with its subscription already - // torn down the moment anything else (an explicit disconnect, the toast's own retry) - // records the same outage. - useAppStore.setState({ - settings: { activeRuntimeEnvironmentId: 'env-laptop' } as never + runtimeStatusByEnvironmentId: new Map([[ENVIRONMENT_ID, { status: null, checkedAt: 1 }]]) }) startBridge() await settle() - expect(sidebarHostHealth()).toBe('available') - - failingStatusProbes = 1 - replaySubscription() - await settle() - useAppStore.getState().setRuntimeEnvironmentStatus(ENVIRONMENT_ID, { - status: null, - checkedAt: Date.now() - }) - expect(sidebarHostHealth()).toBe('disconnected') - - await vi.advanceTimersByTimeAsync(2_000) - await settle() - - expect(sidebarHostHealth()).toBe('available') - }) - - it('re-asks after a reconnect that lands while an earlier probe is still dialing', async () => { - blockNextStatusProbe = true - startBridge() - await settle() - replaySubscription() - await settle() - expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(1) - - // The host restarts while that probe is still on its own socket: the transport drops, - // reconnects and replays again. Serializing is right, dropping the request is not — - // the in-flight answer predates the restart this replay is reporting. - liveRuntimeId = 'remote-runtime-restarted' - replaySubscription() - await settle() - expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(1) - - releaseBlockedStatusProbe?.() - await settle() - - expect( - useAppStore.getState().runtimeStatusByEnvironmentId.get(ENVIRONMENT_ID)?.status?.runtimeId - ).toBe('remote-runtime-restarted') - }) - - it('does not resurrect a host removed while a retry was pending', async () => { - vi.useFakeTimers() - useAppStore.setState({ - runtimeStatusByEnvironmentId: new Map([ - [ENVIRONMENT_ID, { status: null, checkedAt: Date.now() }] - ]) as never - }) - failingStatusProbes = Number.POSITIVE_INFINITY - - startBridge() - await settle() - expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(1) - - // The user deletes the remote server before the first retry fires. Settings can still - // name it as active until the next settings read, so removal is what has to stop this. - useAppStore.getState().setRuntimeEnvironments([]) - await settle() - expect(useAppStore.getState().runtimeStatusByEnvironmentId.has(ENVIRONMENT_ID)).toBe(false) - - await vi.advanceTimersByTimeAsync(120_000) - await settle() - - expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(1) - // buildExecutionHostRegistry enumerates the status map, so a re-published entry for a - // deleted id puts that host back in the sidebar under its raw id. - expect(useAppStore.getState().runtimeStatusByEnvironmentId.has(ENVIRONMENT_ID)).toBe(false) - }) - - it('keeps a live cached status when the replay-triggered probe fails on its own socket', async () => { - startBridge() - await settle() - expect(sidebarHostHealth()).toBe('available') - // Asserted over every write, not just the end state: a demotion that a later probe - // undoes still flashed the sidebar offline and still fired the toast. - let recordedUnreachable = false - unsubs.push( - useAppStore.subscribe((state) => { - recordedUnreachable ||= - state.runtimeStatusByEnvironmentId.get(ENVIRONMENT_ID)?.status === null - }) - ) - - // status.get dials its own short-lived socket, so its failure is unverifiable — and the - // transport that just replayed is proof the host is up. Recording it as offline would - // manufacture the stuck-offline sidebar this re-probe exists to cure. - failingStatusProbes = 1 - replaySubscription() - await settle() - - expect(recordedUnreachable).toBe(false) - expect(sidebarHostHealth()).toBe('available') - expect(toast.warning).not.toHaveBeenCalled() - }) - - it('returns a stuck-offline host to online when the replayed probe answers', async () => { - // The reported bug: the sidebar stayed offline after the connection recovered. The first - // probe failing keeps the recorded verdict unreachable, so only the replay recovers it - // (its retry chain is still parked behind a 2s timer this test never advances). - useAppStore.setState({ - runtimeStatusByEnvironmentId: new Map([ - [ENVIRONMENT_ID, { status: null, checkedAt: Date.now() }] - ]) as never - }) - failingStatusProbes = 1 - startBridge() - await settle() - expect(sidebarHostHealth()).toBe('disconnected') - - replaySubscription() - await settle() - - expect(sidebarHostHealth()).toBe('available') - }) - - it('does not dial a second status socket when the in-flight probe already answered', async () => { - startBridge() - await settle() - expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(0) - - // A reconnect re-probes unconditionally, and that probe is still on its own socket. - blockNextStatusProbe = true - replaySubscription() - await settle() - expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(1) - - // Meanwhile the outage's own failed probe is recorded, which resubscribes; that - // resubscribe resolves against a cache that still reads unreachable. - useAppStore.getState().setRuntimeEnvironmentStatus(ENVIRONMENT_ID, { - status: null, - checkedAt: Date.now() - }) - await settle() - expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(1) - - releaseBlockedStatusProbe?.() - await settle() - - // The resubscribe only wanted an answer for a host the cache called unreachable, and - // the probe it waited on gave one: a second status.get is a whole extra socket dial. - expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(1) - expect(sidebarHostHealth()).toBe('available') + expect(window.api.runtimeEnvironments.getStatus).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts index 5991c40c4de..2e13d9382e4 100644 --- a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts @@ -30,7 +30,7 @@ const EXPECTED_DIRECT_CALLBACK_METHODS = [ 'runtime.onNativeChatLaunchDraftResolved', 'runtime.onTerminalDriverChanged', 'runtime.onTerminalFitOverrideChanged', - 'runtimeEnvironments.onSharedControlDiagnostics', + 'runtimeEnvironments.onStatusChanged', 'settings.onChanged', 'ssh.onCredentialRequest', 'ssh.onCredentialResolved', @@ -106,7 +106,7 @@ const EXPECTED_DIRECT_CALLBACK_METHODS = [ const EXPECTED_CALLBACK_REGISTRATION_SEQUENCE = [ 'ui.onMobileMarkdownRequest', 'automations.onChanged', - 'runtimeEnvironments.onSharedControlDiagnostics', + 'runtimeEnvironments.onStatusChanged', 'repos.onChanged', 'worktrees.onChanged', 'worktrees.onHeadIdentitiesChanged', @@ -382,7 +382,7 @@ describe('useIpcEvents App-lifetime lifecycle', () => { ).toEqual([ 'ui.onMobileMarkdownRequest', 'automations.onChanged', - 'runtimeEnvironments.onSharedControlDiagnostics', + 'runtimeEnvironments.onStatusChanged', 'runtimeEnvironments.subscribe', ...EXPECTED_CALLBACK_REGISTRATION_SEQUENCE.slice(3) ]) diff --git a/src/renderer/src/runtime/runtime-host-connection-state.test.ts b/src/renderer/src/runtime/runtime-host-connection-state.test.ts index 8db0c0eb010..30ee06a5e98 100644 --- a/src/renderer/src/runtime/runtime-host-connection-state.test.ts +++ b/src/renderer/src/runtime/runtime-host-connection-state.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest' import type { RuntimeStatus } from '../../../shared/runtime-types' import { isConnectedRuntimeHostState, + isDisconnectedRuntimeHostState, runtimeHostConnectionState, + runtimeHostConnectionStateForEntry, runtimeStatusForOverall } from './runtime-host-connection-state' @@ -177,3 +179,72 @@ describe('runtime host connection state', () => { ).toBe('disconnected') }) }) + +describe('runtime host connection state for a recorded status entry', () => { + it('separates a host that was never probed from one a probe found unreachable', () => { + // The sidebar read raw truthiness, which collapsed these two into the same red glyph. + expect(runtimeHostConnectionStateForEntry(undefined)).toBe('checking') + expect(runtimeHostConnectionStateForEntry({ status: null })).toBe('disconnected') + }) + + it('reads the remote-control diagnostics recorded beside a failed probe', () => { + expect( + runtimeHostConnectionStateForEntry({ + status: null, + remoteControl: remoteControl('reconnecting') + }) + ).toBe('reconnecting') + }) + + it('agrees with the status bar that a closed control channel is disconnected', () => { + expect( + runtimeHostConnectionStateForEntry({ + status: makeStatus({ remoteControl: remoteControl('closed') }) + }) + ).toBe('disconnected') + }) + + it('names only the disconnected verdict as disconnected', () => { + expect(isDisconnectedRuntimeHostState('disconnected')).toBe(true) + for (const state of [ + 'connected', + 'checking', + 'reconnecting', + 'runtime-unavailable', + 'workspace-window-closed' + ] as const) { + expect(isDisconnectedRuntimeHostState(state)).toBe(false) + } + }) +}) + +function remoteControl( + state: NonNullable['state'] +): NonNullable { + return { + state, + pendingRequestCount: 0, + subscriptionCount: 0, + reconnectAttempt: 1, + lastConnectedAt: null, + lastClose: null, + lastError: null + } +} + +it('does not report reconnecting after verification is terminally blocked', () => { + expect( + runtimeHostConnectionStateForEntry({ + status: null, + snapshot: { + environmentId: 'browser', + pairingRevision: 1, + sequence: 1, + checkedAt: 1, + status: null, + verification: 'blocked', + transport: 'disconnected' + } + }) + ).toBe('disconnected') +}) diff --git a/src/renderer/src/runtime/runtime-host-connection-state.ts b/src/renderer/src/runtime/runtime-host-connection-state.ts index 6094c995804..c10477415a1 100644 --- a/src/renderer/src/runtime/runtime-host-connection-state.ts +++ b/src/renderer/src/runtime/runtime-host-connection-state.ts @@ -1,3 +1,4 @@ +import type { RuntimeHostStatusSnapshot } from '../../../shared/runtime-host-status' import type { RuntimeStatus } from '../../../shared/runtime-types' import { isRuntimeWorkspaceWindowClosed } from '../../../shared/runtime-workspace-window-availability' @@ -97,3 +98,44 @@ export function isConnectedRuntimeHostState(state: RuntimeHostConnectionState): state === 'connected' || state === 'runtime-unavailable' || state === 'workspace-window-closed' ) } + +/** + * Only this verdict earns the destructive glyph. 'checking' and 'reconnecting' are + * unverifiable, not down, per docs/reference/ssh-execution-boundary.md. + */ +export function isDisconnectedRuntimeHostState(state: RuntimeHostConnectionState): boolean { + return state === 'disconnected' +} + +/** The same derivation, read straight off a recorded status entry. */ +export function runtimeHostConnectionStateForEntry( + entry: + | { + status: RuntimeStatus | null + remoteControl?: RuntimeStatus['remoteControl'] | null + snapshot?: RuntimeHostStatusSnapshot + } + | null + | undefined +): RuntimeHostConnectionState { + if (entry?.snapshot) { + const snapshot = entry.snapshot + if (snapshot.retired || snapshot.verification === 'blocked') { + return 'disconnected' + } + if (snapshot.transport === 'disconnected') { + return 'reconnecting' + } + if (snapshot.verification === 'checking' && !entry.status) { + return 'checking' + } + if (snapshot.transport === 'ready' && snapshot.verification !== 'verified') { + return 'runtime-unavailable' + } + } + return runtimeHostConnectionState({ + hasStatusEntry: Boolean(entry), + status: entry?.status ?? null, + remoteControl: entry?.remoteControl ?? entry?.status?.remoteControl ?? null + }) +} diff --git a/src/renderer/src/store/slices/runtime-status-diagnostics-generation.ts b/src/renderer/src/store/slices/runtime-status-diagnostics-generation.ts deleted file mode 100644 index 22ba23cbbb1..00000000000 --- a/src/renderer/src/store/slices/runtime-status-diagnostics-generation.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../../../shared/protocol-version' -import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime-shared-control-types' -import type { RuntimeEnvironmentStatus } from './runtime-status' - -const diagnosticsGenerationByEnvironment = new Map() - -export function updateRuntimeEnvironmentStatusOverlay( - state: Map, - environmentId: string, - status: RuntimeEnvironmentStatus -): Map { - const current = state.get(environmentId) - if (!current || current.status?.runtimeId !== status.status?.runtimeId) { - return state - } - return new Map(state).set(environmentId, status) -} - -export function acceptRuntimeEnvironmentDiagnosticsGeneration( - environmentId: string, - transportGeneration: number -): boolean { - const previous = diagnosticsGenerationByEnvironment.get(environmentId) - if (previous !== undefined && transportGeneration < previous) { - return false - } - diagnosticsGenerationByEnvironment.set(environmentId, transportGeneration) - return true -} - -export function clearRuntimeEnvironmentDiagnosticsGenerationsForTests(): void { - diagnosticsGenerationByEnvironment.clear() -} - -export function mergePushedRuntimeEnvironmentDiagnostics(args: { - environmentId: string - transportGeneration: number - diagnostics: RemoteRuntimeSharedConnectionDiagnostics - current: RuntimeEnvironmentStatus | undefined - publish: (status: RuntimeEnvironmentStatus) => void -}): void { - if ( - !args.current?.status?.capabilities?.includes(REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY) || - !acceptRuntimeEnvironmentDiagnosticsGeneration(args.environmentId, args.transportGeneration) - ) { - return - } - args.publish({ - ...args.current, - status: { ...args.current.status, remoteControl: args.diagnostics } - }) -} diff --git a/src/renderer/src/store/slices/runtime-status-diagnostics-publish.ts b/src/renderer/src/store/slices/runtime-status-diagnostics-publish.ts deleted file mode 100644 index e51c8621bae..00000000000 --- a/src/renderer/src/store/slices/runtime-status-diagnostics-publish.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime-shared-control-types' -import type { AppState } from '../types' -import type { RuntimeEnvironmentStatus } from './runtime-status' -import * as diagnosticsGeneration from './runtime-status-diagnostics-generation' -import * as runtimeStatusRecheck from './runtime-status-recheck' - -export function updateRuntimeStatusStore( - state: AppState, - updater: (state: Map) => Map -): AppState | Pick { - const next = updater(state.runtimeStatusByEnvironmentId) - return next === state.runtimeStatusByEnvironmentId - ? state - : { runtimeStatusByEnvironmentId: next } -} - -export function publishRuntimeEnvironmentDiagnostics(args: { - environmentId: string - transportGeneration: number - diagnostics: RemoteRuntimeSharedConnectionDiagnostics - getCurrent: () => RuntimeEnvironmentStatus | undefined - updateState: (status: RuntimeEnvironmentStatus) => boolean - afterPublish?: (status: RuntimeEnvironmentStatus) => void -}): void { - diagnosticsGeneration.mergePushedRuntimeEnvironmentDiagnostics({ - environmentId: args.environmentId, - transportGeneration: args.transportGeneration, - diagnostics: args.diagnostics, - current: args.getCurrent(), - publish: (status) => { - if (args.updateState(status)) { - args.afterPublish?.(status) - } - } - }) -} - -export function applyRuntimeEnvironmentStatusOverlay(args: { - environmentId: string - status: RuntimeEnvironmentStatus - setState: ( - updater: (state: Map) => Map - ) => void -}): boolean { - let updated = false - args.setState((state) => { - const next = diagnosticsGeneration.updateRuntimeEnvironmentStatusOverlay( - state, - args.environmentId, - args.status - ) - updated = next !== state - return next - }) - return updated -} - -export function createRuntimeEnvironmentDiagnosticsPublisher(args: { - getCurrent: (environmentId: string) => RuntimeEnvironmentStatus | undefined - setState: ( - updater: (state: Map) => Map - ) => void - afterPublish: (environmentId: string, status: RuntimeEnvironmentStatus) => void -}): (event: { - environmentId: string - transportGeneration: number - diagnostics: RemoteRuntimeSharedConnectionDiagnostics -}) => void { - return (event) => - publishRuntimeEnvironmentDiagnostics({ - ...event, - getCurrent: () => args.getCurrent(event.environmentId), - updateState: (status) => - applyRuntimeEnvironmentStatusOverlay({ - environmentId: event.environmentId, - status, - setState: args.setState - }), - afterPublish: (status) => args.afterPublish(event.environmentId, status) - }) -} - -export function createRuntimeEnvironmentDiagnosticsSlicePublisher(args: { - getCurrent: (environmentId: string) => RuntimeEnvironmentStatus | undefined - setState: ( - updater: (state: Map) => Map - ) => void - getStore: () => AppState - getConnectionGeneration: (environmentId: string) => number -}): (event: { - environmentId: string - transportGeneration: number - diagnostics: RemoteRuntimeSharedConnectionDiagnostics -}) => void { - return createRuntimeEnvironmentDiagnosticsPublisher({ - getCurrent: args.getCurrent, - setState: args.setState, - afterPublish: (environmentId, status) => - runtimeStatusRecheck.reconcileRuntimeStatusForSlice( - environmentId, - status.status, - args.getStore, - () => args.getConnectionGeneration(environmentId) - ) - }) -} diff --git a/src/renderer/src/store/slices/runtime-status-diagnostics.test.ts b/src/renderer/src/store/slices/runtime-status-diagnostics.test.ts deleted file mode 100644 index 9faecdcabf1..00000000000 --- a/src/renderer/src/store/slices/runtime-status-diagnostics.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { create } from 'zustand' -import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../../../shared/protocol-version' -import type { RuntimeStatus } from '../../../../shared/runtime-types' -import { createRuntimeStatusSlice, type RuntimeStatusSlice } from './runtime-status' - -function makeStatus(overrides: Partial = {}): RuntimeStatus { - return { - runtimeId: 'runtime-a', - rendererGraphEpoch: 0, - graphStatus: 'ready', - authoritativeWindowId: null, - liveTabCount: 3, - liveLeafCount: 0, - runtimeProtocolVersion: 3, - minCompatibleRuntimeClientVersion: 3, - capabilities: ['browser.screencast.v1'], - ...overrides - } as RuntimeStatus -} - -function createSliceStore() { - return create()((...a) => ({ - ...createRuntimeStatusSlice(...(a as unknown as Parameters)) - })) -} - -describe('runtime-status diagnostics', () => { - it('merges transport diagnostics into the complete status and fences stale pushes', () => { - const store = createSliceStore() - const status = makeStatus({ - capabilities: ['browser.screencast.v1', REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY] - }) - store.getState().setRuntimeEnvironmentStatus('env-a', { status, checkedAt: 1 }) - const closed = { - state: 'closed' as const, - pendingRequestCount: 0, - subscriptionCount: 1, - reconnectAttempt: 2, - lastConnectedAt: 1, - lastClose: { code: 1006, reason: 'network' }, - lastError: 'connection lost' - } - store.getState().publishRuntimeEnvironmentDiagnostics({ - environmentId: 'env-a', - transportGeneration: 3, - diagnostics: closed - }) - expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status).toMatchObject({ - runtimeId: 'runtime-a', - capabilities: expect.arrayContaining([ - 'browser.screencast.v1', - REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY - ]), - liveTabCount: 3, - remoteControl: closed - }) - store.getState().publishRuntimeEnvironmentDiagnostics({ - environmentId: 'env-a', - transportGeneration: 2, - diagnostics: { ...closed, state: 'ready' } - }) - expect( - store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status?.remoteControl?.state - ).toBe('closed') - }) - - it('ignores diagnostics after the latest status drops shared-control support', () => { - const store = createSliceStore() - const status = makeStatus({ capabilities: [] }) - store.getState().setRuntimeEnvironmentStatus('env-a', { status, checkedAt: 1 }) - - store.getState().publishRuntimeEnvironmentDiagnostics({ - environmentId: 'env-a', - transportGeneration: 3, - diagnostics: { - state: 'reconnecting', - pendingRequestCount: 0, - subscriptionCount: 1, - reconnectAttempt: 2, - lastConnectedAt: 1, - lastClose: { code: 1006, reason: 'network' }, - lastError: 'connection lost' - } - }) - - expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status).toBe(status) - }) -}) diff --git a/src/renderer/src/store/slices/runtime-status-recheck.test.ts b/src/renderer/src/store/slices/runtime-status-recheck.test.ts deleted file mode 100644 index 9f09580c390..00000000000 --- a/src/renderer/src/store/slices/runtime-status-recheck.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { create } from 'zustand' -import { toast } from 'sonner' -import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../../../shared/protocol-version' -import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' -import type { RuntimeStatus } from '../../../../shared/runtime-types' -import { - clearRuntimeEnvironmentConnectionGenerationsForTests, - createRuntimeStatusSlice, - setRuntimeEnvironmentConnectionGenerationForTests, - type RuntimeStatusSlice -} from './runtime-status' -import { clearRuntimeStatusRechecksForTests } from './runtime-status-recheck' - -vi.mock('sonner', () => ({ toast: { warning: vi.fn(), dismiss: vi.fn() } })) - -beforeEach(() => { - vi.useFakeTimers() - clearRuntimeStatusRechecksForTests() - clearRuntimeEnvironmentConnectionGenerationsForTests() - vi.mocked(toast.warning).mockReset() -}) - -afterEach(() => { - clearRuntimeStatusRechecksForTests() - vi.useRealTimers() - vi.unstubAllGlobals() -}) - -describe('runtime status recheck', () => { - it('publishes an observe-only ready result through the setter', async () => { - const getStatus = vi.fn().mockResolvedValue(response(status('ready'))) - const store = createStore(getStatus) - - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('awaiting_ready'), - checkedAt: 1 - }) - await vi.advanceTimersByTimeAsync(3_000) - - expect(getStatus).toHaveBeenCalledWith({ - selector: 'env-a', - timeoutMs: 10_000, - observeOnly: true - }) - expect( - store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status?.remoteControl - ).toMatchObject({ - state: 'ready' - }) - await vi.advanceTimersByTimeAsync(120_000) - expect(getStatus).toHaveBeenCalledOnce() - }) - - it('continues indefinitely on the capped ladder, including unchanged publishes', async () => { - const getStatus = vi.fn().mockResolvedValue(response(status('reconnecting'))) - const store = createStore(getStatus) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('reconnecting'), - checkedAt: 1 - }) - - await vi.advanceTimersByTimeAsync(3_000 + 6_000 + 12_000 + 30_000 + 60_000 + 60_000) - - expect(getStatus).toHaveBeenCalledTimes(6) - expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.checkedAt).toBe(1) - }) - - it('cancels on removal, capability loss, and null without probing again', async () => { - const getStatus = vi.fn() - const store = createStore(getStatus) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('awaiting_authenticated'), - checkedAt: 1 - }) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: { ...status('awaiting_authenticated'), capabilities: [] }, - checkedAt: 2 - }) - await vi.advanceTimersByTimeAsync(60_000) - expect(getStatus).not.toHaveBeenCalled() - - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('awaiting_ready'), - checkedAt: 3 - }) - store.getState().setRuntimeEnvironments([]) - await vi.advanceTimersByTimeAsync(60_000) - expect(getStatus).not.toHaveBeenCalled() - }) - - it('cancels an armed probe when the connection generation changes', async () => { - const getStatus = vi.fn() - const store = createStore(getStatus) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('awaiting_ready'), - checkedAt: 1 - }) - - setRuntimeEnvironmentConnectionGenerationForTests('env-a', 2) - await vi.advanceTimersByTimeAsync(3_000) - - expect(getStatus).not.toHaveBeenCalled() - }) - - it('restarts the ladder for a newly published connection generation', async () => { - const getStatus = vi.fn().mockResolvedValue(response(status('ready', 'rt-next'))) - const store = createStore(getStatus) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('awaiting_ready'), - checkedAt: 1 - }) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('awaiting_ready', 'rt-next'), - checkedAt: 2 - }) - - await vi.advanceTimersByTimeAsync(3_000) - - expect(getStatus).toHaveBeenCalledOnce() - expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status?.runtimeId).toBe( - 'rt-next' - ) - }) - - it('discards an in-flight result after a ready publish bumps the epoch', async () => { - const pending = deferred>() - const getStatus = vi.fn().mockReturnValue(pending.promise) - const store = createStore(getStatus) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('awaiting_ready'), - checkedAt: 1 - }) - await vi.advanceTimersByTimeAsync(3_000) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('ready'), - checkedAt: 2 - }) - - pending.resolve(response(status('reconnecting'))) - await Promise.resolve() - await Promise.resolve() - - expect( - store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status?.remoteControl - ).toMatchObject({ - state: 'ready' - }) - }) - - it('keeps setter side effects when a recheck discovers disconnection', async () => { - const getStatus = vi.fn().mockResolvedValue({ - id: 'status.get', - ok: false, - error: { - code: 'runtime_unavailable', - message: 'offline', - data: { remoteControl: status('reconnecting').remoteControl } - }, - _meta: { runtimeId: 'rt' } - }) - const store = createStore(getStatus) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('awaiting_ready'), - checkedAt: 1 - }) - - await vi.advanceTimersByTimeAsync(3_000) - - expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status).toBeNull() - expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.remoteControl).toMatchObject( - { - state: 'reconnecting' - } - ) - expect(toast.warning).toHaveBeenCalledOnce() - }) -}) - -function createStore(getStatus: ReturnType) { - vi.stubGlobal('window', { - api: { runtimeEnvironments: { getStatus, list: vi.fn() } } - }) - const store = create()((...args) => ({ - ...createRuntimeStatusSlice(...(args as unknown as Parameters)) - })) - store.getState().setRuntimeEnvironments([environment()]) - return store -} - -function status( - controlState: NonNullable['state'], - runtimeId = 'rt' -): RuntimeStatus { - return { - runtimeId, - rendererGraphEpoch: 1, - graphStatus: 'ready', - authoritativeWindowId: null, - liveTabCount: 0, - liveLeafCount: 0, - capabilities: [REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY], - remoteControl: { - state: controlState, - pendingRequestCount: 0, - subscriptionCount: 0, - reconnectAttempt: 1, - lastConnectedAt: null, - lastClose: null, - lastError: null - } - } as RuntimeStatus -} - -function response(result: RuntimeStatus) { - return { id: 'status.get', ok: true as const, result, _meta: { runtimeId: result.runtimeId } } -} - -function environment(): PublicKnownRuntimeEnvironment { - return { - id: 'env-a', - name: 'Dev Box', - createdAt: 1, - updatedAt: 1, - lastUsedAt: null, - runtimeId: 'rt', - endpoints: [{ id: 'ws', kind: 'websocket', label: 'WebSocket', endpoint: 'ws://x' }], - preferredEndpointId: 'ws' - } -} - -function deferred() { - let resolve: (value: T) => void = () => {} - const promise = new Promise((done) => { - resolve = done - }) - return { promise, resolve } -} diff --git a/src/renderer/src/store/slices/runtime-status-recheck.ts b/src/renderer/src/store/slices/runtime-status-recheck.ts deleted file mode 100644 index 303e5b4318d..00000000000 --- a/src/renderer/src/store/slices/runtime-status-recheck.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../../../shared/protocol-version' -import type { RuntimeStatus } from '../../../../shared/runtime-types' -import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client' -import { extractRuntimeTransportDiagnostics } from '@/runtime/runtime-status-probe-diagnostics' -import type { RuntimeEnvironmentStatus } from './runtime-status' - -const RECHECK_DELAYS_MS = [3_000, 6_000, 12_000, 30_000, 60_000] - -type RecheckState = { - epoch: number - attempt: number - timer: ReturnType | null - inFlight: boolean - connectionGeneration: number - environmentExists: () => boolean - getConnectionGeneration: () => number - publish: (status: RuntimeEnvironmentStatus) => void -} - -type RuntimeStatusStore = { - runtimeEnvironments: readonly { id: string }[] - setRuntimeEnvironmentStatus: (environmentId: string, status: RuntimeEnvironmentStatus) => void -} - -const rechecks = new Map() - -export function reconcileRuntimeStatusRecheck(args: { - environmentId: string - status: RuntimeStatus | null - connectionGeneration: number - environmentExists: () => boolean - getConnectionGeneration: () => number - publish: (status: RuntimeEnvironmentStatus) => void -}): void { - if (!shouldRecheck(args.status)) { - cancelRuntimeStatusRecheck(args.environmentId) - return - } - let state = rechecks.get(args.environmentId) - if (state && state.connectionGeneration !== args.connectionGeneration) { - cancelRuntimeStatusRecheck(args.environmentId) - state = undefined - } - if (!state) { - state = { - epoch: 0, - attempt: 0, - timer: null, - inFlight: false, - connectionGeneration: args.connectionGeneration, - environmentExists: args.environmentExists, - getConnectionGeneration: args.getConnectionGeneration, - publish: args.publish - } - rechecks.set(args.environmentId, state) - } else { - state.connectionGeneration = args.connectionGeneration - state.environmentExists = args.environmentExists - state.getConnectionGeneration = args.getConnectionGeneration - state.publish = args.publish - } - armRuntimeStatusRecheck(args.environmentId, state) -} - -export function reconcileRuntimeStatusForSlice( - environmentId: string, - status: RuntimeStatus | null, - get: () => RuntimeStatusStore, - getConnectionGeneration: () => number -): void { - reconcileRuntimeStatusRecheck({ - environmentId, - status, - connectionGeneration: getConnectionGeneration(), - environmentExists: () => - get().runtimeEnvironments.some((environment) => environment.id === environmentId), - getConnectionGeneration, - publish: (nextStatus) => get().setRuntimeEnvironmentStatus(environmentId, nextStatus) - }) -} - -export function cancelRuntimeStatusRecheck(environmentId: string): void { - const state = rechecks.get(environmentId) - if (!state) { - return - } - state.epoch += 1 - if (state.timer) { - clearTimeout(state.timer) - } - rechecks.delete(environmentId) -} - -export function cancelRuntimeStatusRechecks(environmentIds: Iterable): void { - for (const environmentId of environmentIds) { - cancelRuntimeStatusRecheck(environmentId) - } -} - -export function clearRuntimeStatusRechecksForTests(): void { - cancelRuntimeStatusRechecks([...rechecks.keys()]) -} - -function shouldRecheck(status: RuntimeStatus | null): boolean { - return Boolean( - status?.capabilities?.includes(REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY) && - status.remoteControl && - status.remoteControl.state !== 'ready' - ) -} - -function armRuntimeStatusRecheck(environmentId: string, state: RecheckState): void { - if (state.timer || state.inFlight) { - return - } - const delay = RECHECK_DELAYS_MS[Math.min(state.attempt, RECHECK_DELAYS_MS.length - 1)] - const generation = state.connectionGeneration - state.attempt += 1 - state.timer = setTimeout( - () => void fireRuntimeStatusRecheck(environmentId, state, generation), - delay - ) -} - -async function fireRuntimeStatusRecheck( - environmentId: string, - state: RecheckState, - generation: number -): Promise { - state.timer = null - const epoch = state.epoch - if ( - rechecks.get(environmentId) !== state || - !state.environmentExists() || - state.getConnectionGeneration() !== generation - ) { - cancelRuntimeStatusRecheck(environmentId) - return - } - state.inFlight = true - let nextEntry: RuntimeEnvironmentStatus - try { - const response = await window.api.runtimeEnvironments.getStatus({ - selector: environmentId, - timeoutMs: 10_000, - observeOnly: true - }) - nextEntry = { status: unwrapRuntimeRpcResult(response), checkedAt: Date.now() } - } catch (error: unknown) { - const remoteControl = extractRuntimeTransportDiagnostics(error) - nextEntry = { - status: null, - ...(remoteControl ? { remoteControl } : {}), - checkedAt: Date.now() - } - } - state.inFlight = false - if ( - rechecks.get(environmentId) !== state || - state.epoch !== epoch || - !state.environmentExists() || - state.getConnectionGeneration() !== generation - ) { - return - } - state.publish(nextEntry) -} diff --git a/src/renderer/src/store/slices/runtime-status-refresh.ts b/src/renderer/src/store/slices/runtime-status-refresh.ts index 638a3fae636..fb27eec6a43 100644 --- a/src/renderer/src/store/slices/runtime-status-refresh.ts +++ b/src/renderer/src/store/slices/runtime-status-refresh.ts @@ -15,6 +15,22 @@ export async function refreshRuntimeEnvironmentStatus( selector: environmentId, timeoutMs }) + if (window.api.runtimeEnvironments.getStatusSnapshots) { + try { + const snapshots = await window.api.runtimeEnvironments.getStatusSnapshots() + const snapshot = snapshots.find((entry) => entry.environmentId === environmentId) + if (snapshot) { + publish({ + snapshot, + status: snapshot.verification === 'verified' ? snapshot.status : null, + checkedAt: snapshot.checkedAt + }) + } + } catch (error) { + console.error('Failed to read runtime host status snapshot:', error) + } + return response.ok + } const status = unwrapRuntimeRpcResult(response) if (getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentRevision) { return false diff --git a/src/renderer/src/store/slices/runtime-status-restored-browser-host-attach.test.ts b/src/renderer/src/store/slices/runtime-status-restored-browser-host-attach.test.ts index 0ba4d645026..d4b788cd49a 100644 --- a/src/renderer/src/store/slices/runtime-status-restored-browser-host-attach.test.ts +++ b/src/renderer/src/store/slices/runtime-status-restored-browser-host-attach.test.ts @@ -73,14 +73,10 @@ describe('restored client-hosted browser host attach on reachability', () => { }) }) - // The reconnect policy suppresses the *failure* publish only. A probe that answered still owes - // both recovery follow-ups, or a restored client-hosted page never comes back after the gap. - it('runs both recovery follow-ups on a success when the caller opted out of publishing failures', async () => { + it('runs both recovery follow-ups after a successful refresh', async () => { stubApi(vi.fn().mockResolvedValue(createCompatibleRuntimeStatusResponse('runtime-a'))) - await storeWithRestoredHandles(true) - .getState() - .refreshRuntimeEnvironmentStatus('env-a', undefined, { publishUnreachable: false }) + await storeWithRestoredHandles(true).getState().refreshRuntimeEnvironmentStatus('env-a') expect(prepareBrowserClientHostPlacement).toHaveBeenCalledWith({ selector: 'env-a', @@ -89,24 +85,12 @@ describe('restored client-hosted browser host attach on reachability', () => { expect(replayClientHostedBrowserCloseIntents).toHaveBeenCalledWith('env-a', expect.anything()) }) - // Under either policy a failed probe owes *no* follow-ups: it verified nothing, so there is no - // recovered host to reattach restored pages to and no one to replay closes at. - it.each([ - { name: 'the default policy', options: undefined }, - { name: 'a caller that opted out of publishing', options: { publishUnreachable: false } } - ])( - 'starts no browser client host when the environment is unreachable: $name', - async (scenario) => { - stubApi(vi.fn().mockRejectedValue(new Error('unreachable'))) - - await storeWithRestoredHandles(true) - .getState() - .refreshRuntimeEnvironmentStatus('env-a', undefined, scenario.options) - - expect(prepareBrowserClientHostPlacement).not.toHaveBeenCalled() - expect(replayClientHostedBrowserCloseIntents).not.toHaveBeenCalled() - } - ) + it('runs no recovery follow-ups when the environment is unreachable', async () => { + stubApi(vi.fn().mockRejectedValue(new Error('unreachable'))) + await storeWithRestoredHandles(true).getState().refreshRuntimeEnvironmentStatus('env-a') + expect(prepareBrowserClientHostPlacement).not.toHaveBeenCalled() + expect(replayClientHostedBrowserCloseIntents).not.toHaveBeenCalled() + }) it('starts no browser client host for restored pages the server hosts', async () => { stubApi(vi.fn().mockResolvedValue(createCompatibleRuntimeStatusResponse('runtime-a'))) diff --git a/src/renderer/src/store/slices/runtime-status-snapshot.test.ts b/src/renderer/src/store/slices/runtime-status-snapshot.test.ts new file mode 100644 index 00000000000..fa437065b28 --- /dev/null +++ b/src/renderer/src/store/slices/runtime-status-snapshot.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { create } from 'zustand' +import { toast } from 'sonner' +import { + createRuntimeStatusSlice, + clearRuntimeEnvironmentConnectionGenerationsForTests, + type RuntimeStatusSlice +} from './runtime-status' +import type { RuntimeHostStatusSnapshot } from '../../../../shared/runtime-host-status' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import { runtimeHostConnectionStateForEntry } from '@/runtime/runtime-host-connection-state' + +vi.mock('sonner', () => ({ toast: { warning: vi.fn(), dismiss: vi.fn() } })) +vi.mock('@/runtime/restored-client-hosted-browser-host-attach', () => ({ + ensureBrowserClientHostsForRestoredPages: vi.fn(), + ensureBrowserClientHostForRestartedRuntime: vi.fn() +})) +vi.mock('@/runtime/client-hosted-browser-close-intent-replay', () => ({ + replayClientHostedBrowserCloseIntents: vi.fn() +})) + +beforeEach(() => { + clearRuntimeEnvironmentConnectionGenerationsForTests() + vi.clearAllMocks() +}) +const environment = { + id: 'env-a', + name: 'Host', + createdAt: 1, + pairingRevision: 1, + endpoints: [], + preferredEndpointId: '' +} as unknown as PublicKnownRuntimeEnvironment +function store() { + const value = create()((...args) => + createRuntimeStatusSlice(...(args as unknown as Parameters)) + ) + value.getState().setRuntimeEnvironments([environment]) + return value +} +function snapshot( + sequence: number, + patch: Partial = {} +): RuntimeHostStatusSnapshot { + return { + environmentId: 'env-a', + pairingRevision: 1, + sequence, + checkedAt: sequence, + transport: 'ready', + verification: 'verified', + status: { runtimeId: 'rt-1' } as RuntimeStatus, + ...patch + } +} + +it('hydrates both viewers and rejects an older read after a newer publication', () => { + for (const viewer of [store(), store()]) { + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(2)) + viewer + .getState() + .applyRuntimeHostStatusSnapshot(snapshot(1, { status: null, verification: 'unavailable' })) + expect(viewer.getState().runtimeStatusByEnvironmentId.get('env-a')?.status?.runtimeId).toBe( + 'rt-1' + ) + } +}) + +it('represents failed verification honestly without manufacturing a session restart or toast', () => { + const viewer = store() + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(1)) + const generation = viewer + .getState() + .runtimeStatusByEnvironmentId.get('env-a')?.connectionGeneration + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(2, { verification: 'unavailable' })) + expect( + runtimeHostConnectionStateForEntry(viewer.getState().runtimeStatusByEnvironmentId.get('env-a')) + ).toBe('runtime-unavailable') + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(3)) + expect(viewer.getState().runtimeStatusByEnvironmentId.get('env-a')?.connectionGeneration).toBe( + generation + ) + expect(toast.warning).not.toHaveBeenCalled() + viewer + .getState() + .applyRuntimeHostStatusSnapshot(snapshot(4, { status: { runtimeId: 'rt-2' } as RuntimeStatus })) + expect( + viewer.getState().runtimeStatusByEnvironmentId.get('env-a')?.connectionGeneration + ).toBeGreaterThan(generation ?? 0) +}) + +it('retains disconnect ordering and rejects publications for removed or replaced pairings', () => { + const viewer = store() + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(1)) + viewer + .getState() + .applyRuntimeHostStatusSnapshot( + snapshot(3, { retired: true, verification: 'blocked', transport: 'disconnected' }) + ) + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(2)) + expect( + runtimeHostConnectionStateForEntry(viewer.getState().runtimeStatusByEnvironmentId.get('env-a')) + ).toBe('disconnected') + viewer.getState().setRuntimeEnvironments([{ ...environment, pairingRevision: 2 }]) + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(4)) + expect(viewer.getState().runtimeStatusByEnvironmentId.has('env-a')).toBe(false) + viewer.getState().setRuntimeEnvironments([]) + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(5, { pairingRevision: 2 })) + expect(viewer.getState().runtimeStatusByEnvironmentId.has('env-a')).toBe(false) +}) diff --git a/src/renderer/src/store/slices/runtime-status-snapshot.ts b/src/renderer/src/store/slices/runtime-status-snapshot.ts new file mode 100644 index 00000000000..b3543471d29 --- /dev/null +++ b/src/renderer/src/store/slices/runtime-status-snapshot.ts @@ -0,0 +1,43 @@ +import type { RuntimeHostStatusSnapshot } from '../../../../shared/runtime-host-status' +import type { AppState } from '../types' +import type { RuntimeEnvironmentStatus } from './runtime-status-types' +import { ensureBrowserClientHostsForRestoredPages } from '@/runtime/restored-client-hosted-browser-host-attach' +import { replayClientHostedBrowserCloseIntents } from '@/runtime/client-hosted-browser-close-intent-replay' + +export function applyRuntimeHostStatusSnapshot( + snapshot: RuntimeHostStatusSnapshot, + state: AppState, + publishEvidence: (entry: RuntimeEnvironmentStatus) => void +): void { + const environment = state.runtimeEnvironments.find((entry) => entry.id === snapshot.environmentId) + if ( + !environment || + (environment.pairingRevision ?? environment.createdAt) !== snapshot.pairingRevision + ) { + return + } + const previous = state.runtimeStatusByEnvironmentId.get(snapshot.environmentId) + if (previous?.snapshot && previous.snapshot.sequence >= snapshot.sequence) { + return + } + const entry: RuntimeEnvironmentStatus = { + snapshot, + checkedAt: snapshot.checkedAt, + connectionGeneration: previous?.connectionGeneration, + status: snapshot.verification === 'verified' && !snapshot.retired ? snapshot.status : null, + remoteControl: snapshot.remoteControl + } + if (entry.status) { + if (snapshot.remoteControl) { + entry.status = { ...entry.status, remoteControl: snapshot.remoteControl } + } + state.setRuntimeEnvironmentStatus(snapshot.environmentId, entry) + if (previous?.status == null) { + void ensureBrowserClientHostsForRestoredPages(state) + void replayClientHostedBrowserCloseIntents(snapshot.environmentId, state) + } + } else { + // Lost contact or a failed method observes no runtime session ending. + publishEvidence(entry) + } +} diff --git a/src/renderer/src/store/slices/runtime-status-types.ts b/src/renderer/src/store/slices/runtime-status-types.ts index c34feaf07bf..78e47123cf5 100644 --- a/src/renderer/src/store/slices/runtime-status-types.ts +++ b/src/renderer/src/store/slices/runtime-status-types.ts @@ -1,8 +1,9 @@ +import type { RuntimeHostStatusSnapshot } from '../../../../shared/runtime-host-status' import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' import type { RuntimeStatus } from '../../../../shared/runtime-types' -import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime-shared-control-types' export type RuntimeEnvironmentStatus = { + snapshot?: RuntimeHostStatusSnapshot status: RuntimeStatus | null remoteControl?: RuntimeStatus['remoteControl'] | null appVersion?: string | null @@ -10,11 +11,9 @@ export type RuntimeEnvironmentStatus = { connectionGeneration?: number } -export type RuntimeStatusRefreshOptions = { - publishUnreachable?: boolean -} - export type RuntimeStatusSlice = { + readRuntimeHostStatusSnapshots: () => Promise + applyRuntimeHostStatusSnapshot: (snapshot: RuntimeHostStatusSnapshot) => void runtimeEnvironments: readonly PublicKnownRuntimeEnvironment[] runtimeEnvironmentCatalogHydrated: boolean runtimeEnvironmentCatalogSettled: boolean @@ -26,17 +25,8 @@ export type RuntimeStatusSlice = { status: RuntimeEnvironmentStatus, options?: { suppressDisconnectToast?: boolean } ) => void - publishRuntimeEnvironmentDiagnostics: (args: { - environmentId: string - transportGeneration: number - diagnostics: RemoteRuntimeSharedConnectionDiagnostics - }) => void clearRuntimeEnvironmentStatus: (environmentId: string) => void retainRuntimeEnvironmentStatuses: (environmentIds: Iterable) => void - refreshRuntimeEnvironmentStatus: ( - environmentId: string, - timeoutMs?: number, - options?: RuntimeStatusRefreshOptions - ) => Promise + refreshRuntimeEnvironmentStatus: (environmentId: string, timeoutMs?: number) => Promise hydrateRuntimeEnvironmentStatuses: () => Promise } diff --git a/src/renderer/src/store/slices/runtime-status.test.ts b/src/renderer/src/store/slices/runtime-status.test.ts index 8e3260957bf..fdc843c748a 100644 --- a/src/renderer/src/store/slices/runtime-status.test.ts +++ b/src/renderer/src/store/slices/runtime-status.test.ts @@ -710,33 +710,37 @@ describe('runtime-status slice', () => { clearRuntimeCompatibilityCacheForTests() }) - // Both directions of the failure-publication policy, from one failing probe. A user-initiated - // check publishes the outage it just observed; a caller holding live transport evidence must - // not, because status.get dials its own socket and its failure is unverifiable, not exited. - it.each([ - { name: 'a user-initiated check', options: undefined, publishes: true }, - { name: 'publishUnreachable defaulted', options: {}, publishes: true }, - { - name: 'a caller that opted out of publishing', - options: { publishUnreachable: false }, - publishes: false - } - ])('records null and returns false when a runtime refresh fails: $name', async (scenario) => { + it('records null and returns false when a runtime refresh fails', async () => { const getStatus = vi.fn().mockRejectedValue(new Error('closed')) stubRuntimeEnvironmentApi({ getStatus }) const store = createSliceStore() const cached = makeStatus() store.getState().setRuntimeEnvironmentStatus('env-a', { status: cached, checkedAt: 1 }) - const reachable = await store - .getState() - .refreshRuntimeEnvironmentStatus('env-a', undefined, scenario.options) + const reachable = await store.getState().refreshRuntimeEnvironmentStatus('env-a') - // The dial-answered contract the bridge's bounded retry chain reads is policy-independent. expect(reachable).toBe(false) - expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status).toBe( - scenario.publishes ? null : cached - ) + expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status).toBe(null) + }) + + it('preserves successful reachability when reading its snapshot fails', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + getStatus: vi.fn().mockResolvedValue(createCompatibleRuntimeStatusResponse('runtime-a')), + getStatusSnapshots: vi.fn().mockRejectedValue(new Error('IPC read failed')) + } + } + }) + try { + const store = createSliceStore() + expect(await store.getState().refreshRuntimeEnvironmentStatus('env-a')).toBe(true) + expect(store.getState().runtimeStatusByEnvironmentId.has('env-a')).toBe(false) + expect(log).toHaveBeenCalled() + } finally { + log.mockRestore() + } }) it('hydrates saved environments through the single-environment refresh path', async () => { diff --git a/src/renderer/src/store/slices/runtime-status.ts b/src/renderer/src/store/slices/runtime-status.ts index 10985f389d8..c11fba3308a 100644 --- a/src/renderer/src/store/slices/runtime-status.ts +++ b/src/renderer/src/store/slices/runtime-status.ts @@ -1,11 +1,7 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { RuntimeStatusSlice } from './runtime-status-types' -export type { - RuntimeEnvironmentStatus, - RuntimeStatusRefreshOptions, - RuntimeStatusSlice -} from './runtime-status-types' +export type { RuntimeEnvironmentStatus, RuntimeStatusSlice } from './runtime-status-types' import { runtimeEnvironmentStatusesEqual } from './runtime-environment-status-equality' import { clearRecentRuntimeCompatibilityFailure, @@ -20,21 +16,16 @@ import { import { reconcileCatalogRows } from './repo-identity-reconcile' import { createRuntimeStatusHydration } from './runtime-status-hydration' import { refreshRuntimeEnvironmentStatus } from './runtime-status-refresh' -import * as runtimeStatusDiagnostics from './runtime-status-diagnostics-generation' import * as runtimeStatusConnectionGeneration from './runtime-status-connection-generation' import { replayClientHostedBrowserCloseIntents } from '@/runtime/client-hosted-browser-close-intent-replay' import { ensureBrowserClientHostForRestartedRuntime, ensureBrowserClientHostsForRestoredPages } from '@/runtime/restored-client-hosted-browser-host-attach' -import * as runtimeStatusRecheck from './runtime-status-recheck' -import * as runtimeStatusDiagnosticsPublish from './runtime-status-diagnostics-publish' +import { applyRuntimeHostStatusSnapshot } from './runtime-status-snapshot' export const clearRuntimeEnvironmentConnectionGenerationsForTests = (): void => { - runtimeStatusRecheck.cancelRuntimeStatusRechecks( - runtimeStatusConnectionGeneration.clearRuntimeEnvironmentConnectionGenerations() - ) - runtimeStatusDiagnostics.clearRuntimeEnvironmentDiagnosticsGenerationsForTests() + runtimeStatusConnectionGeneration.clearRuntimeEnvironmentConnectionGenerations() } export { @@ -52,6 +43,15 @@ export const createRuntimeStatusSlice: StateCreator { + try { + const snapshots = await window.api.runtimeEnvironments.getStatusSnapshots() + snapshots.forEach((snapshot) => get().applyRuntimeHostStatusSnapshot(snapshot)) + } catch (error) { + console.error('Failed to read runtime host status:', error) + } + }, + setRuntimeEnvironments: (environments) => { const previousRevisionById = new Map( get().runtimeEnvironments.map((environment) => [ @@ -76,7 +76,6 @@ export const createRuntimeStatusSlice: StateCreator environment.id) .filter((id) => !nextIds.has(id)) - runtimeStatusRecheck.cancelRuntimeStatusRechecks([...removedIds, ...replacedEnvironmentIds]) set((s) => { const keep = new Set(environments.map((environment) => environment.id)) const nextStatuses = new Map(s.runtimeStatusByEnvironmentId) @@ -155,15 +154,29 @@ export const createRuntimeStatusSlice: StateCreator + applyRuntimeHostStatusSnapshot(snapshot, get(), (entry) => { + set((s) => ({ + runtimeStatusByEnvironmentId: new Map(s.runtimeStatusByEnvironmentId).set( + snapshot.environmentId, + entry + ) + })) + }), + setRuntimeEnvironmentStatus: (environmentId, status, options) => { const previous = get().runtimeStatusByEnvironmentId.get(environmentId) + if (previous?.snapshot && !status.snapshot) { + return + } + const previousVerifiedStatus = previous?.snapshot?.status ?? previous?.status const pairedDeviceId = status.status?.pairedDeviceId?.trim() // A new runtime id under a known previous one is a restart, not a first connect: the guests are // still ours to host, but only a fresh attach hands them back to the replacement runtime. const runtimeRestarted = Boolean( status.status !== null && - previous?.status != null && - previous.status.runtimeId !== status.status.runtimeId + previousVerifiedStatus != null && + previousVerifiedStatus.runtimeId !== status.status.runtimeId ) // Why: a non-null status proves the runtime just answered, so drop any stale // "offline" compat failure before this online transition fires the @@ -177,7 +190,8 @@ export const createRuntimeStatusSlice: StateCreator - get().runtimeEnvironments.some((environment) => environment.id === environmentId), - getConnectionGeneration: () => - runtimeStatusConnectionGeneration.getRuntimeEnvironmentConnectionGeneration(environmentId), - publish: (entry) => get().setRuntimeEnvironmentStatus(environmentId, entry) - }) if (runtimeRestarted) { void ensureBrowserClientHostForRestartedRuntime(get(), environmentId) } @@ -250,18 +253,7 @@ export const createRuntimeStatusSlice: StateCreator get().runtimeStatusByEnvironmentId.get(environmentId), - setState: (updater) => - set((s) => runtimeStatusDiagnosticsPublish.updateRuntimeStatusStore(s, updater)), - getStore: get, - getConnectionGeneration: - runtimeStatusConnectionGeneration.getRuntimeEnvironmentConnectionGeneration - }), - clearRuntimeEnvironmentStatus: (environmentId) => { - runtimeStatusRecheck.cancelRuntimeStatusRecheck(environmentId) dismissRuntimeDisconnectedToast(environmentId) set((s) => { runtimeStatusConnectionGeneration.advanceRuntimeEnvironmentConnectionGeneration(environmentId) @@ -278,7 +270,6 @@ export const createRuntimeStatusSlice: StateCreator + refreshRuntimeEnvironmentStatus: (environmentId, timeoutMs = 10_000) => refreshRuntimeEnvironmentStatus(environmentId, timeoutMs, (entry) => { - if (entry.status === null && options?.publishUnreachable === false) { - // Unverifiable, not exited: leave the cached verdict for the caller's retry to settle. + if (entry.snapshot) { + get().applyRuntimeHostStatusSnapshot(entry.snapshot) return } // Why: setRuntimeEnvironmentStatus drops any stale compat failure on a non-null diff --git a/src/renderer/src/web/preload-api/web-runtime-environments-api.ts b/src/renderer/src/web/preload-api/web-runtime-environments-api.ts index e6ce2fd5a5d..8e9b0ecc245 100644 --- a/src/renderer/src/web/preload-api/web-runtime-environments-api.ts +++ b/src/renderer/src/web/preload-api/web-runtime-environments-api.ts @@ -16,6 +16,9 @@ import { translateHostAccessLinkError } from '@/lib/remote-pairing-copy' import { callEnvironmentEnvelope } from './web-runtime-calls' import { closeActiveRuntimeClients, + subscribeWebRuntimeStatus, + readWebRuntimeStatusSnapshots, + observeWebRuntimeStatus, disconnectActiveRuntimeEnvironment, getClientForEnvironment, manuallyDisconnectedEnvironmentIds, @@ -29,6 +32,8 @@ export function createRuntimeEnvironmentsApi(): NonNullable< Partial['runtimeEnvironments'] > { return { + onStatusChanged: subscribeWebRuntimeStatus, + getStatusSnapshots: async () => readWebRuntimeStatusSnapshots(), list: async () => { const environment = requireActiveEnvironmentOrNull() return environment ? [redactStoredWebRuntimeEnvironment(environment)] : [] @@ -146,6 +151,12 @@ export function createRuntimeEnvironmentsApi(): NonNullable< manuallyDisconnectedEnvironmentIds.clear() closeActiveRuntimeClients() webRuntimeState.activeEnvironment = nextEnvironment + getClientForEnvironment(nextEnvironment).statusOwner?.acceptVerified({ + id: 'status.get', + ok: true, + result: runtimeStatus, + _meta: { runtimeId: runtimeStatus.runtimeId } + }) return { ok: true, environment: redactStoredWebRuntimeEnvironment(nextEnvironment), @@ -173,6 +184,7 @@ export function createRuntimeEnvironmentsApi(): NonNullable< connect: ({ selector, timeoutMs }) => { const environment = resolveEnvironment(selector) manuallyDisconnectedEnvironmentIds.delete(environment.id) + closeActiveRuntimeClients() return callEnvironmentEnvelope( environment.id, 'status.get', @@ -180,8 +192,10 @@ export function createRuntimeEnvironmentsApi(): NonNullable< timeoutMs ) }, - getStatus: ({ selector, timeoutMs }) => - callEnvironmentEnvelope(selector, 'status.get', undefined, timeoutMs), + getStatus: ({ selector, timeoutMs, observeOnly }) => + observeOnly + ? observeWebRuntimeStatus(selector, timeoutMs) + : callEnvironmentEnvelope(selector, 'status.get', undefined, timeoutMs), retryControlConnection: () => Promise.resolve(), prepareBrowserClientHostPlacement: async () => ({ kind: 'server' }), call: ({ selector, method, params, timeoutMs }) => diff --git a/src/renderer/src/web/preload-api/web-runtime-session.ts b/src/renderer/src/web/preload-api/web-runtime-session.ts index 17a19136002..15b6cb63ee7 100644 --- a/src/renderer/src/web/preload-api/web-runtime-session.ts +++ b/src/renderer/src/web/preload-api/web-runtime-session.ts @@ -1,3 +1,7 @@ +import type { + RuntimeHostStatusSnapshot, + RuntimeHostStatusResponse +} from '../../../../shared/runtime-host-status' import type { WorktreeVisibilityDefaults } from '../../../../shared/global-settings-types' import { RuntimeRpcCallQueuePool } from '../../../../shared/runtime-rpc-call-queue' import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' @@ -30,6 +34,43 @@ export const webRuntimeState: { cachedDetectedWorktrees: null } +const statusListeners = new Set<(snapshot: RuntimeHostStatusSnapshot) => void>() +export function subscribeWebRuntimeStatus( + callback: (snapshot: RuntimeHostStatusSnapshot) => void +): () => void { + statusListeners.add(callback) + return () => { + statusListeners.delete(callback) + } +} +export function readWebRuntimeStatusSnapshots(): RuntimeHostStatusSnapshot[] { + const snapshot = webRuntimeState.activeClient?.statusOwner?.read() + return snapshot ? [snapshot] : [] +} +export async function observeWebRuntimeStatus( + selector: string, + timeoutMs?: number +): Promise { + const environment = resolveEnvironment(selector) + if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { + return manuallyDisconnectedResponse(environment) + } + const existing = webRuntimeState.activeClient?.statusOwner + if (existing) { + return existing.refresh({ timeoutMs, observeOnly: true }) + } + const transient = new WebRuntimeClient(getPreferredWebPairingOffer(environment), { + reconnect: false + }) + try { + return (await transient.call('status.get', undefined, { + timeoutMs + })) as RuntimeHostStatusResponse + } finally { + transient.close() + } +} + export const manuallyDisconnectedEnvironmentIds = new Set() export const runtimeCallQueuePool = new RuntimeRpcCallQueuePool() @@ -50,7 +91,18 @@ export function getClientForEnvironment( webRuntimeState.activeClientEnvironmentId !== environment.id ) { webRuntimeState.activeClient?.close() - webRuntimeState.activeClient = new WebRuntimeClient(getPreferredWebPairingOffer(environment)) + webRuntimeState.activeClient = new WebRuntimeClient(getPreferredWebPairingOffer(environment), { + status: { + environmentId: environment.id, + pairingRevision: environment.pairingRevision ?? environment.createdAt, + publish: (snapshot) => { + for (const listener of statusListeners) { + listener(snapshot) + } + }, + verified: (response) => updateEnvironmentFromResponse(environment, response) + } + }) webRuntimeState.activeClientEnvironmentId = environment.id } return webRuntimeState.activeClient diff --git a/src/renderer/src/web/web-runtime-client-export-parity.test.ts b/src/renderer/src/web/web-runtime-client-export-parity.test.ts index 27dce452549..6ba87eaad36 100644 --- a/src/renderer/src/web/web-runtime-client-export-parity.test.ts +++ b/src/renderer/src/web/web-runtime-client-export-parity.test.ts @@ -6,8 +6,13 @@ it('keeps the paired-web client public export surface exact', () => { expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() expectTypeOf>().toEqualTypeOf< - [pairing: WebPairingOffer] + [ + pairing: WebPairingOffer, + options?: ConstructorParameters[1] + ] + >() + expectTypeOf().toEqualTypeOf< + 'call' | 'close' | 'subscribe' | 'statusOwner' >() - expectTypeOf().toEqualTypeOf<'call' | 'close' | 'subscribe'>() expect(Object.keys(WebClient)).toEqual(['WebRuntimeClient']) }) diff --git a/src/renderer/src/web/web-runtime-client-timeout-budget.test.ts b/src/renderer/src/web/web-runtime-client-timeout-budget.test.ts index 71568adc4da..95417748271 100644 --- a/src/renderer/src/web/web-runtime-client-timeout-budget.test.ts +++ b/src/renderer/src/web/web-runtime-client-timeout-budget.test.ts @@ -70,7 +70,7 @@ describe('WebRuntimeClient timeout budget', () => { await vi.advanceTimersByTimeAsync(60_000) expect(settled).toBe(false) - expect(waitForConnected).toHaveBeenCalledWith(25) + expect(waitForConnected).toHaveBeenCalledWith(25, undefined) resolveConnection() await Promise.resolve() diff --git a/src/renderer/src/web/web-runtime-client.ts b/src/renderer/src/web/web-runtime-client.ts index 7bc756ca0c0..8a3b944f701 100644 --- a/src/renderer/src/web/web-runtime-client.ts +++ b/src/renderer/src/web/web-runtime-client.ts @@ -1,3 +1,8 @@ +import { RuntimeHostStatusOwner } from '../../../shared/runtime-host-status-owner' +import type { + RuntimeHostStatusSnapshot, + RuntimeHostStatusResponse +} from '../../../shared/runtime-host-status' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import { WebRuntimeConnectionTransport } from './web-runtime-connection-transport' import { subscribeWebRuntimeFileWatch } from './web-runtime-file-watch-subscription' @@ -24,11 +29,62 @@ export class WebRuntimeClient { private readonly fileWatchTeardownRetries = new Map Promise>>() private readonly childClients = new Set() - constructor(private readonly pairing: WebPairingOffer) { - this.transport = new WebRuntimeConnectionTransport(pairing, { - now: () => this.now(), - isDocumentVisible: () => this.isDocumentVisible() - }) + readonly statusOwner?: RuntimeHostStatusOwner + + constructor( + private readonly pairing: WebPairingOffer, + options: { + reconnect?: boolean + status?: { + environmentId: string + pairingRevision: number + publish: (snapshot: RuntimeHostStatusSnapshot) => void + verified: (response: RuntimeHostStatusResponse) => void + } + } = {} + ) { + this.transport = new WebRuntimeConnectionTransport( + pairing, + { + now: () => this.now(), + isDocumentVisible: () => this.isDocumentVisible() + }, + { + reconnect: options.reconnect, + onStateChanged: (state) => { + if (state === 'auth-failed') { + this.statusOwner?.authenticationRejected() + } + this.statusOwner?.connectionChanged( + state === 'connected' + ? 'ready' + : state === 'disconnected' || state === 'auth-failed' + ? 'disconnected' + : 'connecting' + ) + } + } + ) + if (options.status) { + const status = options.status + this.statusOwner = new RuntimeHostStatusOwner({ + ...status, + persistent: true, + request: (signal) => + this.transport.call('status.get', undefined, { + timeoutMs: 15_000, + signal + }) as Promise, + verified: (response) => { + status.verified(response) + return true + } + }) + this.statusOwner.connectionChanged( + this.transport.state === 'connected' ? 'ready' : 'connecting' + ) + this.statusOwner.activate() + } } call( @@ -36,7 +92,9 @@ export class WebRuntimeClient { params?: unknown, options?: { timeoutMs?: number } ): Promise> { - return this.transport.call(method, params, options) + return method === 'status.get' && this.statusOwner + ? this.statusOwner.refresh(options) + : this.transport.call(method, params, options) } async subscribe( @@ -94,6 +152,7 @@ export class WebRuntimeClient { } close(options: { notifySubscriptions?: boolean } = {}): void { + this.statusOwner?.dispose() const shouldNotifySubscriptions = options.notifySubscriptions ?? true for (const child of Array.from(this.childClients)) { child.close({ notifySubscriptions: shouldNotifySubscriptions }) diff --git a/src/renderer/src/web/web-runtime-connection-transport.ts b/src/renderer/src/web/web-runtime-connection-transport.ts index d62cf3b48ba..1ead257bbc5 100644 --- a/src/renderer/src/web/web-runtime-connection-transport.ts +++ b/src/renderer/src/web/web-runtime-connection-transport.ts @@ -43,7 +43,11 @@ export class WebRuntimeConnectionTransport { constructor( private readonly pairing: WebPairingOffer, - clock: { now: () => number; isDocumentVisible: () => boolean } + clock: { now: () => number; isDocumentVisible: () => boolean }, + private readonly lifecycle: { + onStateChanged?: (state: WebRuntimeConnectionState) => void + reconnect?: boolean + } = {} ) { this.serverPublicKey = publicKeyFromBase64(pairing.publicKeyB64) this.connectionWaiters = new WebRuntimeConnectionWaiters({ @@ -60,7 +64,7 @@ export class WebRuntimeConnectionTransport { this.requestRegistry = new WebRuntimeRequestRegistry({ deviceToken: pairing.deviceToken, nextId: () => this.nextId(), - waitForConnected: (timeoutMs) => this.connectionWaiters.wait(timeoutMs), + waitForConnected: (timeoutMs, signal) => this.connectionWaiters.wait(timeoutMs, signal), sendEncrypted: (message) => this.sendEncrypted(message) }) this.heartbeat = new WebRuntimeConnectionHeartbeat({ @@ -82,7 +86,7 @@ export class WebRuntimeConnectionTransport { async call( method: string, params?: unknown, - options?: { timeoutMs?: number } + options?: { timeoutMs?: number; signal?: AbortSignal } ): Promise> { return this.requestRegistry.call(method, params, options) } @@ -153,6 +157,7 @@ export class WebRuntimeConnectionTransport { } else if (next === 'auth-failed') { this.connectionWaiters.rejectAll(createWebRuntimeUnauthorizedError()) } + this.lifecycle.onStateChanged?.(next) } private openConnection(): void { @@ -232,7 +237,7 @@ export class WebRuntimeConnectionTransport { } private scheduleReconnect(): void { - if (this.reconnectTimer || this.intentionallyClosed) { + if (this.reconnectTimer || this.intentionallyClosed || this.lifecycle.reconnect === false) { return } const delay = withReconnectJitter( diff --git a/src/renderer/src/web/web-runtime-connection-waiters.ts b/src/renderer/src/web/web-runtime-connection-waiters.ts index c16e30cd779..a8f3d081e86 100644 --- a/src/renderer/src/web/web-runtime-connection-waiters.ts +++ b/src/renderer/src/web/web-runtime-connection-waiters.ts @@ -13,7 +13,10 @@ export class WebRuntimeConnectionWaiters { constructor(private readonly options: WebRuntimeConnectionWaiterOptions) {} - wait(timeoutMs = 30_000): Promise { + wait(timeoutMs = 30_000, signal?: AbortSignal): Promise { + if (signal?.aborted) { + return Promise.reject(signal.reason) + } if (this.options.getState() === 'connected') { return Promise.resolve() } @@ -24,11 +27,20 @@ export class WebRuntimeConnectionWaiters { return Promise.reject(new Error('Remote Orca runtime connection closed.')) } return new Promise((resolve, reject) => { - const timeout = window.setTimeout(() => { - const index = this.waiters.findIndex((waiter) => waiter.resolve === resolve) + const cleanup = (): void => { + window.clearTimeout(timeout) + signal?.removeEventListener('abort', abort) + const index = this.waiters.indexOf(waiter) if (index !== -1) { this.waiters.splice(index, 1) } + } + const abort = (): void => { + cleanup() + reject(signal?.reason) + } + const timeout = window.setTimeout(() => { + cleanup() reject( new Error( withRemoteRuntimeTailscaleHint( @@ -38,16 +50,18 @@ export class WebRuntimeConnectionWaiters { ) ) }, timeoutMs) - this.waiters.push({ + const waiter = { resolve: () => { - window.clearTimeout(timeout) + cleanup() resolve() }, - reject: (error) => { - window.clearTimeout(timeout) + reject: (error: Error) => { + cleanup() reject(error) } - }) + } + this.waiters.push(waiter) + signal?.addEventListener('abort', abort, { once: true }) }) } diff --git a/src/renderer/src/web/web-runtime-request-registry.ts b/src/renderer/src/web/web-runtime-request-registry.ts index 1347d580f00..0329e208ba3 100644 --- a/src/renderer/src/web/web-runtime-request-registry.ts +++ b/src/renderer/src/web/web-runtime-request-registry.ts @@ -6,7 +6,7 @@ const REQUEST_TIMEOUT_MS = 30_000 type WebRuntimeRequestRegistryOptions = { deviceToken: string nextId: () => string - waitForConnected: (timeoutMs?: number) => Promise + waitForConnected: (timeoutMs?: number, signal?: AbortSignal) => Promise sendEncrypted: (message: unknown) => boolean } @@ -18,17 +18,41 @@ export class WebRuntimeRequestRegistry { async call( method: string, params?: unknown, - callOptions?: { timeoutMs?: number } + callOptions?: { timeoutMs?: number; signal?: AbortSignal } ): Promise> { - await this.options.waitForConnected(callOptions?.timeoutMs) + const signal = callOptions?.signal + await this.options.waitForConnected(callOptions?.timeoutMs, signal) + signal?.throwIfAborted() return new Promise((resolve, reject) => { const id = this.options.nextId() const timeoutMs = callOptions?.timeoutMs ?? REQUEST_TIMEOUT_MS const timeout = window.setTimeout(() => { this.pending.delete(id) + cleanup() reject(new Error(`Request timed out: ${method}`)) }, timeoutMs) - this.pending.set(id, { method, resolve, reject, timeout }) + const cleanup = (): void => { + signal?.removeEventListener('abort', abort) + } + const abort = (): void => { + this.pending.delete(id) + window.clearTimeout(timeout) + cleanup() + reject(signal?.reason) + } + signal?.addEventListener('abort', abort, { once: true }) + this.pending.set(id, { + method, + resolve: (value) => { + cleanup() + resolve(value) + }, + reject: (error) => { + cleanup() + reject(error) + }, + timeout + }) if ( !this.options.sendEncrypted({ id, @@ -39,6 +63,7 @@ export class WebRuntimeRequestRegistry { ) { this.pending.delete(id) window.clearTimeout(timeout) + cleanup() reject(new Error('Remote Orca runtime is not connected.')) } }) diff --git a/src/renderer/src/web/web-runtime-status-owner.test.ts b/src/renderer/src/web/web-runtime-status-owner.test.ts new file mode 100644 index 00000000000..7abfe6f19f4 --- /dev/null +++ b/src/renderer/src/web/web-runtime-status-owner.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import WebSocket from 'ws' +import { + createSharedControlTestServer, + closeSharedControlTestServers +} from '../../../shared/remote-runtime-shared-control-test-server' +import { WebRuntimeClient } from './web-runtime-client' + +const clients: WebRuntimeClient[] = [] +beforeEach(() => { + vi.stubGlobal('WebSocket', WebSocket) + vi.stubGlobal('window', { + setTimeout, + clearTimeout, + setInterval, + clearInterval, + atob: (value: string) => Buffer.from(value, 'base64').toString('binary'), + btoa: (value: string) => Buffer.from(value, 'binary').toString('base64') + }) +}) +afterEach(async () => { + clients.splice(0).forEach((client) => client.close()) + await closeSharedControlTestServers() + vi.unstubAllGlobals() +}) + +it('primary browser status follows the authenticated socket and closing it retires the owner', async () => { + let runtimeId = 'before' + const server = await createSharedControlTestServer({ + resultForRequest: () => ({ runtimeId, capabilities: [] }) + }) + const publish = vi.fn() + const client = new WebRuntimeClient(server.pairing, { + status: { environmentId: 'browser', pairingRevision: 1, publish, verified: vi.fn() } + }) + clients.push(client) + await expect + .poll(() => client.statusOwner?.read().verification, { timeout: 3_000 }) + .toBe('verified') + expect(client.statusOwner?.read().status?.runtimeId).toBe('before') + runtimeId = 'after' + server.closeClients() + await expect + .poll(() => client.statusOwner?.read().status?.runtimeId, { timeout: 3_000 }) + .toBe('after') + expect(client.statusOwner?.read().transport).toBe('ready') + client.close() + expect(publish.mock.lastCall?.[0]).toMatchObject({ retired: true, verification: 'blocked' }) +}) diff --git a/src/shared/execution-host-registry.test.ts b/src/shared/execution-host-registry.test.ts index e509fe05bfc..e397e8ed5bf 100644 --- a/src/shared/execution-host-registry.test.ts +++ b/src/shared/execution-host-registry.test.ts @@ -320,17 +320,15 @@ describe('execution host registry', () => { ]) }) - it('includes runtime hosts from repo ownership but marks them disconnected without live status', () => { + it('keeps runtime hosts checking before their first status result', () => { const hosts = buildExecutionHostRegistry({ repos: [{ connectionId: null, executionHostId: 'runtime:env-2' }], settings: { activeRuntimeEnvironmentId: null } }) - // No live status means no evidence the Orca server is reachable, so it must - // read 'disconnected' rather than defaulting to 'available'/"Connected". expect(hosts).toMatchObject([ { id: 'local', health: 'local' }, - { id: 'runtime:env-2', kind: 'runtime', label: 'env-2', health: 'disconnected' } + { id: 'runtime:env-2', kind: 'runtime', label: 'env-2', health: 'connecting' } ]) }) @@ -373,3 +371,29 @@ describe('execution host registry', () => { ]) }) }) + +it('keeps an initial unknown-transport verification connecting', () => { + const hosts = buildExecutionHostRegistry({ + repos: [], + settings: null, + runtimeEnvironments: [{ id: 'host', name: 'Host' }], + runtimeStatusByEnvironmentId: new Map([ + [ + 'host', + { + status: null, + snapshot: { + environmentId: 'host', + pairingRevision: 1, + sequence: 1, + checkedAt: 0, + status: null, + verification: 'checking', + transport: 'unknown' + } + } + ] + ]) + }) + expect(hosts.find((host) => host.id === 'runtime:host')?.health).toBe('connecting') +}) diff --git a/src/shared/execution-host-registry.ts b/src/shared/execution-host-registry.ts index a970f9da45c..bad6b40f257 100644 --- a/src/shared/execution-host-registry.ts +++ b/src/shared/execution-host-registry.ts @@ -1,3 +1,4 @@ +import type { RuntimeHostStatusSnapshot } from './runtime-host-status' import { LOCAL_EXECUTION_HOST_ID, getLocalExecutionHostLabel, @@ -49,6 +50,7 @@ type RuntimeEnvironmentSummary = { } type RuntimeHostStatus = { + snapshot?: RuntimeHostStatusSnapshot status?: RuntimeStatus | null remoteControl?: RuntimeStatus['remoteControl'] | null appVersion?: string | null @@ -158,9 +160,24 @@ function addRuntimeHost( const hostId = toRuntimeExecutionHostId(environmentId) const runtimeStatus = statusByEnvironmentId?.get(environmentId) const status = runtimeStatus?.status - const compatibility = runtimeCompatibility(status) + const snapshot = runtimeStatus?.snapshot + const metadata = status ?? snapshot?.status + const compatibility = runtimeCompatibility(metadata) const remoteControl = runtimeStatus?.remoteControl ?? status?.remoteControl - const controlHealth = runtimeControlHealth(remoteControl) + const controlHealth = snapshot?.retired + ? 'disconnected' + : snapshot?.verification === 'blocked' + ? 'blocked' + : !runtimeStatus || + snapshot?.verification === 'checking' || + snapshot?.transport === 'disconnected' || + snapshot?.transport === 'connecting' + ? 'connecting' + : snapshot?.transport === 'ready' + ? compatibility?.kind === 'blocked' + ? 'blocked' + : 'available' + : runtimeControlHealth(remoteControl) setHost(hosts, { id: hostId, kind: 'runtime', @@ -168,12 +185,12 @@ function addRuntimeHost( detail: 'Orca server', health: controlHealth ?? runtimeHealth(status, compatibility, remoteControl), compatibility: compatibility ?? undefined, - capabilities: status?.capabilities, - appVersion: runtimeStatus?.appVersion ?? status?.appVersion ?? null, - protocolVersion: status?.runtimeProtocolVersion ?? status?.protocolVersion ?? null, + capabilities: metadata?.capabilities, + appVersion: runtimeStatus?.appVersion ?? metadata?.appVersion ?? null, + protocolVersion: metadata?.runtimeProtocolVersion ?? metadata?.protocolVersion ?? null, minCompatibleClientVersion: - status?.minCompatibleRuntimeClientVersion ?? status?.minCompatibleMobileVersion ?? null, - platform: status?.hostPlatform ?? null, + metadata?.minCompatibleRuntimeClientVersion ?? metadata?.minCompatibleMobileVersion ?? null, + platform: metadata?.hostPlatform ?? null, remoteControlState: remoteControl ?? null, ...(source ? { source } : {}) }) diff --git a/src/shared/remote-runtime-shared-control-test-server.ts b/src/shared/remote-runtime-shared-control-test-server.ts index 0e4adb1a69c..701ea0e5893 100644 --- a/src/shared/remote-runtime-shared-control-test-server.ts +++ b/src/shared/remote-runtime-shared-control-test-server.ts @@ -20,6 +20,7 @@ export type SharedControlTestServer = { } type ServerOptions = { + resultForRequest?: (method: string) => unknown delaySubscriptionReady?: boolean sendKeepaliveBeforeResponse?: boolean keepaliveDelayMs?: number @@ -174,7 +175,7 @@ function handleRequest( const streaming = isStreamingMethod(request.method) const result = streaming ? { type: 'ready', subscriptionId: `${request.method}:subscription` } - : { method: request.method } + : (options.resultForRequest?.(request.method) ?? { method: request.method }) const sendResponse = (): void => { if (options.sendUnknownResponseBeforeResponse) { sendEncrypted(ws, sharedKey, { diff --git a/src/shared/runtime-host-status-owner.test.ts b/src/shared/runtime-host-status-owner.test.ts new file mode 100644 index 00000000000..32331991988 --- /dev/null +++ b/src/shared/runtime-host-status-owner.test.ts @@ -0,0 +1,207 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { RuntimeHostStatusOwner } from './runtime-host-status-owner' +import { runtimeHostStatusFailure, type RuntimeHostStatusResponse } from './runtime-host-status' +import type { RuntimeStatus } from './runtime-types' + +const owners: RuntimeHostStatusOwner[] = [] +beforeEach(() => vi.useFakeTimers()) +afterEach(() => { + owners.splice(0).forEach((owner) => owner.dispose()) + vi.useRealTimers() +}) +function success(runtimeId = 'host-1'): RuntimeHostStatusResponse & { ok: true } { + return { + id: 'status', + ok: true, + result: { runtimeId, capabilities: [] } as unknown as RuntimeStatus, + _meta: { runtimeId } + } +} +function deferred() { + let resolve!: (response: RuntimeHostStatusResponse) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} +function createOwner(persistent = false) { + const request = vi + .fn<(signal: AbortSignal) => Promise>() + .mockResolvedValue(success()) + const publish = vi.fn() + const verified = vi.fn((_response: RuntimeHostStatusResponse, _active: boolean) => persistent) + const owner = new RuntimeHostStatusOwner({ + environmentId: 'env-a', + pairingRevision: 1, + persistent, + request, + publish, + verified + }) + owners.push(owner) + return { owner, request, publish, verified } +} + +it('shares one verification between viewers with independent deadlines', async () => { + const { owner, request } = createOwner() + const pending = deferred() + request.mockReturnValue(pending.promise) + const impatient = owner.refresh({ timeoutMs: 100 }) + const patient = owner.refresh({ timeoutMs: 1_000 }) + await vi.advanceTimersByTimeAsync(100) + expect((await impatient).ok).toBe(false) + expect(request).toHaveBeenCalledOnce() + expect(request.mock.calls[0][0].aborted).toBe(false) + pending.resolve(success()) + expect((await patient).ok).toBe(true) +}) + +it('uses ready transitions, not diagnostic updates or a healthy polling timer', async () => { + const { owner, request } = createOwner(true) + await owner.refresh() + owner.connectionChanged('ready') + await vi.advanceTimersByTimeAsync(0) + owner.connectionChanged('ready') + await vi.advanceTimersByTimeAsync(300_000) + expect(request).toHaveBeenCalledTimes(2) + owner.connectionChanged('disconnected') + await vi.advanceTimersByTimeAsync(300_000) + expect(request).toHaveBeenCalledTimes(2) + owner.connectionChanged('ready') + await vi.advanceTimersByTimeAsync(0) + expect(request).toHaveBeenCalledTimes(3) +}) + +it('retries a failed status operation while retaining healthy transport and last good metadata', async () => { + const { owner, request } = createOwner(true) + owner.connectionChanged('ready') + await owner.refresh() + request.mockResolvedValueOnce(runtimeHostStatusFailure('runtime_unavailable', 'status timed out')) + await owner.refresh() + expect(owner.read()).toMatchObject({ + transport: 'ready', + verification: 'unavailable', + status: { runtimeId: 'host-1' } + }) + await vi.advanceTimersByTimeAsync(3_000) + expect(owner.read().verification).toBe('verified') + expect(request).toHaveBeenCalledTimes(3) +}) + +it('retires a lost-socket request before explicit fallback and rejects its late result', async () => { + const { owner, request } = createOwner(true) + owner.connectionChanged('ready') + const old = deferred() + request.mockReturnValueOnce(old.promise) + const waiting = owner.refresh() + owner.connectionChanged('disconnected') + expect(request.mock.calls[0][0].aborted).toBe(true) + request.mockResolvedValueOnce(success('fallback-host')) + expect((await owner.refresh()).ok).toBe(true) + expect((await waiting).ok).toBe(true) + old.resolve(success('obsolete-host')) + await vi.advanceTimersByTimeAsync(0) + expect(owner.read().status?.runtimeId).toBe('fallback-host') + owner.connectionChanged('ready') + await vi.advanceTimersByTimeAsync(0) + expect(request).toHaveBeenCalledTimes(3) +}) + +it('a reconnect transfers waiting readers to a fresh verification', async () => { + const { owner, request } = createOwner(true) + owner.connectionChanged('ready') + const old = deferred() + request.mockReturnValueOnce(old.promise) + const waiting = owner.refresh() + owner.connectionChanged('disconnected') + owner.connectionChanged('ready') + expect((await waiting).ok).toBe(true) + old.resolve(success('old')) + await vi.advanceTimersByTimeAsync(0) + expect(owner.read().status?.runtimeId).toBe('host-1') +}) + +it('disconnect settles readers and prevents late results and retry resurrection', async () => { + const { owner, request, publish } = createOwner() + const old = deferred() + request.mockReturnValue(old.promise) + const waiting = owner.refresh() + owner.dispose() + expect((await waiting).ok).toBe(false) + const sequence = owner.read().sequence + old.resolve(success()) + owner.connectionChanged('ready') + await vi.advanceTimersByTimeAsync(300_000) + expect(owner.read()).toMatchObject({ retired: true, sequence }) + expect(publish.mock.lastCall?.[0].retired).toBe(true) + expect(request).toHaveBeenCalledOnce() +}) + +it('passive reads create neither standing retries nor connection intent', async () => { + const { owner, request, verified } = createOwner() + request.mockResolvedValueOnce(runtimeHostStatusFailure('runtime_unavailable', 'offline')) + await owner.refresh({ observeOnly: true }) + await vi.advanceTimersByTimeAsync(300_000) + expect(request).toHaveBeenCalledOnce() + await owner.refresh({ observeOnly: true }) + expect(verified.mock.lastCall?.[1]).toBe(false) +}) + +it('authentication rejection blocks automatic verification until explicit reconnect', async () => { + const { owner, request } = createOwner(true) + owner.connectionChanged('ready') + request.mockResolvedValueOnce(runtimeHostStatusFailure('unauthorized', 're-pair')) + await owner.refresh() + owner.connectionChanged('disconnected') + owner.connectionChanged('ready') + await vi.advanceTimersByTimeAsync(300_000) + expect(request).toHaveBeenCalledOnce() + expect((await owner.refresh({ reconnect: true })).ok).toBe(true) +}) + +it('blocks a rejected reconnect even without an outstanding status request', async () => { + const { owner, request } = createOwner(true) + owner.connectionChanged('ready') + await owner.refresh() + owner.connectionChanged('disconnected') + owner.authenticationRejected() + expect(owner.read()).toMatchObject({ verification: 'blocked', status: { runtimeId: 'host-1' } }) + owner.connectionChanged('ready') + await vi.advanceTimersByTimeAsync(300_000) + expect(request).toHaveBeenCalledOnce() +}) + +it('cancelling one reader leaves the shared request available to other readers', async () => { + const { owner, request } = createOwner() + const pending = deferred() + request.mockReturnValue(pending.promise) + const controller = new AbortController() + const cancelled = owner.refresh({ signal: controller.signal }) + const remaining = owner.refresh() + const rejection = expect(cancelled).rejects.toThrow('cancelled') + controller.abort(new Error('cancelled')) + await rejection + expect(request.mock.calls[0][0].aborted).toBe(false) + pending.resolve(success()) + expect((await remaining).ok).toBe(true) +}) + +it.each(['unknown', 'ready'] as const)( + 'distinguishes the caller deadline with %s transport', + async (transport) => { + const { owner, request } = createOwner() + owner.connectionChanged(transport) + request.mockReturnValue(deferred().promise) + const response = owner.refresh({ timeoutMs: 100 }) + await vi.advanceTimersByTimeAsync(100) + expect(await response).toMatchObject({ + ok: false, + error: { + message: + transport === 'ready' + ? 'Status request timed out.' + : 'Timed out waiting for the remote Orca runtime.' + } + }) + } +) diff --git a/src/shared/runtime-host-status-owner.ts b/src/shared/runtime-host-status-owner.ts new file mode 100644 index 00000000000..d9c52395c89 --- /dev/null +++ b/src/shared/runtime-host-status-owner.ts @@ -0,0 +1,274 @@ +import { + isRuntimeHostStatusBlocked, + runtimeHostStatusError, + runtimeHostStatusFailure, + type RuntimeHostStatusResponse, + type RuntimeHostStatusSnapshot +} from './runtime-host-status' + +const RETRY_DELAYS_MS = [3_000, 6_000, 12_000, 30_000, 60_000] +const REQUEST_TIMEOUT_MS = 15_000 +let publicationSequence = 0 + +type Waiter = { + resolve: (response: RuntimeHostStatusResponse) => void + cleanup: () => void +} + +type StatusOwnerOptions = { + environmentId: string + pairingRevision: number + persistent?: boolean + request: (signal: AbortSignal) => Promise + verified: (response: Extract, active: boolean) => boolean + publish: (snapshot: RuntimeHostStatusSnapshot) => void +} + +/** One verification and one retry slot, shared by all readers of this connection. */ +export class RuntimeHostStatusOwner { + private active = false + private disposed = false + private persistent: boolean + private attempt = 0 + private retry: ReturnType | null = null + private request: AbortController | null = null + private readonly waiters = new Set() + private response: RuntimeHostStatusResponse = runtimeHostStatusFailure( + 'runtime_unavailable', + 'Status has not been checked.' + ) + private snapshot: RuntimeHostStatusSnapshot + + constructor(private readonly options: StatusOwnerOptions) { + this.persistent = options.persistent ?? false + this.snapshot = { + environmentId: options.environmentId, + pairingRevision: options.pairingRevision, + sequence: ++publicationSequence, + checkedAt: 0, + status: null, + verification: 'checking', + transport: 'unknown' + } + } + + read(): RuntimeHostStatusSnapshot { + return this.snapshot + } + + activate(): void { + if (this.active || this.disposed) { + return + } + this.active = true + this.startRequest() + } + + acceptVerified(response: Extract): void { + if (this.disposed) { + return + } + this.active = true + this.retireRequest() + this.clearRetry() + this.complete(response) + } + + refresh( + options: { timeoutMs?: number; observeOnly?: true; reconnect?: true; signal?: AbortSignal } = {} + ): Promise { + if (options.signal?.aborted) { + return Promise.reject(options.signal.reason) + } + if (this.disposed) { + return Promise.resolve(this.response) + } + if (!options.observeOnly) { + this.active = true + } + if (options.reconnect) { + this.attempt = 0 + this.update({ verification: 'checking' }) + } + if (this.snapshot.verification === 'blocked') { + return Promise.resolve(this.response) + } + const result = new Promise((resolve, reject) => { + const release = (): void => { + waiter.cleanup() + this.waiters.delete(waiter) + if (!this.active && this.waiters.size === 0) { + this.retireRequest() + } + } + const abort = (): void => { + release() + reject(options.signal?.reason) + } + const timer = setTimeout(() => { + release() + resolve( + runtimeHostStatusFailure( + 'runtime_unavailable', + this.snapshot.transport === 'ready' + ? 'Status request timed out.' + : 'Timed out waiting for the remote Orca runtime.' + ) + ) + }, options.timeoutMs ?? REQUEST_TIMEOUT_MS) + const waiter: Waiter = { + resolve, + cleanup: () => { + clearTimeout(timer) + options.signal?.removeEventListener('abort', abort) + } + } + this.waiters.add(waiter) + options.signal?.addEventListener('abort', abort, { once: true }) + }) + this.startRequest() + return result + } + + connectionChanged( + transport: RuntimeHostStatusSnapshot['transport'], + remoteControl?: RuntimeHostStatusSnapshot['remoteControl'] + ): void { + if (this.disposed) { + return + } + const previous = this.snapshot.transport + this.update({ transport, ...(remoteControl !== undefined ? { remoteControl } : {}) }) + if (transport === previous) { + return + } + if (previous === 'ready') { + this.retireRequest() + this.clearRetry() + if (this.snapshot.verification !== 'blocked') { + this.update({ verification: 'unavailable' }) + } + } + if (transport === 'ready' && this.snapshot.verification !== 'blocked') { + // A pre-reconnect answer cannot verify the new socket's runtime. + this.retireRequest() + if (this.active || this.waiters.size > 0) { + this.startRequest() + } + } + } + + authenticationRejected(): void { + if (this.disposed) { + return + } + this.retireRequest() + this.clearRetry() + this.complete(runtimeHostStatusFailure('unauthorized', 'Pair this client again.')) + } + + dispose(): void { + if (this.disposed) { + return + } + this.disposed = true + this.active = false + this.retireRequest() + this.clearRetry() + this.response = runtimeHostStatusFailure( + 'runtime_manually_disconnected', + 'Runtime environment was disconnected or replaced.' + ) + this.update({ retired: true, transport: 'disconnected', verification: 'blocked' }) + this.settleWaiters() + } + + private startRequest(): void { + if (this.disposed || this.request || this.snapshot.verification === 'blocked') { + return + } + this.clearRetry() + const controller = new AbortController() + this.request = controller + if (this.snapshot.verification !== 'verified') { + this.update({ verification: 'checking' }) + } + void this.verify(controller) + } + + private async verify(controller: AbortController): Promise { + let response: RuntimeHostStatusResponse + try { + response = await this.options.request(controller.signal) + } catch (error) { + response = runtimeHostStatusError(error) + if (error instanceof TypeError || error instanceof SyntaxError) { + console.error('Runtime status verification failed:', error) + response = runtimeHostStatusFailure('invalid_runtime_response', error.message) + } + } + if (this.request !== controller || this.disposed) { + return + } + this.request = null + this.complete(response) + } + + private complete(response: RuntimeHostStatusResponse): void { + this.response = response + if (response.ok) { + this.attempt = 0 + this.update({ status: response.result, checkedAt: Date.now(), verification: 'verified' }) + this.persistent = this.options.verified(response, this.active) + } else { + this.update({ + checkedAt: Date.now(), + verification: isRuntimeHostStatusBlocked(response) ? 'blocked' : 'unavailable' + }) + this.scheduleRetry() + } + this.settleWaiters() + } + + private scheduleRetry(): void { + if ( + !this.active || + this.disposed || + this.snapshot.verification === 'blocked' || + (this.persistent && this.snapshot.transport !== 'ready') + ) { + return + } + const delay = RETRY_DELAYS_MS[Math.min(this.attempt++, RETRY_DELAYS_MS.length - 1)] + this.retry = setTimeout(() => { + this.retry = null + this.startRequest() + }, delay) + } + + private settleWaiters(): void { + for (const waiter of this.waiters) { + waiter.cleanup() + waiter.resolve(this.response) + } + this.waiters.clear() + } + + private retireRequest(): void { + const request = this.request + this.request = null + request?.abort() + } + + private clearRetry(): void { + if (this.retry) { + clearTimeout(this.retry) + } + this.retry = null + } + + private update(patch: Partial): void { + this.snapshot = { ...this.snapshot, ...patch, sequence: ++publicationSequence } + this.options.publish(this.snapshot) + } +} diff --git a/src/shared/runtime-host-status.ts b/src/shared/runtime-host-status.ts new file mode 100644 index 00000000000..4dd7ff7cc22 --- /dev/null +++ b/src/shared/runtime-host-status.ts @@ -0,0 +1,44 @@ +import type { RemoteRuntimeSharedConnectionDiagnostics } from './remote-runtime-shared-control-types' +import type { RuntimeRpcFailure, RuntimeRpcResponse } from './runtime-rpc-envelope' +import type { RuntimeStatus } from './runtime-types' + +export const RUNTIME_HOST_STATUS_CHANNEL = 'runtimeEnvironments:statusChanged' + +/** Local client state; never exchanged with the paired host. */ +export type RuntimeHostStatusSnapshot = { + environmentId: string + pairingRevision: number + sequence: number + checkedAt: number + status: RuntimeStatus | null + verification: 'checking' | 'verified' | 'unavailable' | 'blocked' + transport: 'unknown' | 'connecting' | 'ready' | 'disconnected' + remoteControl?: RemoteRuntimeSharedConnectionDiagnostics | null + retired?: true +} + +export type RuntimeHostStatusResponse = RuntimeRpcResponse + +export function runtimeHostStatusFailure(code: string, message: string): RuntimeRpcFailure { + return { id: 'status.get', ok: false, error: { code, message } } +} + +export function runtimeHostStatusError(error: unknown): RuntimeRpcFailure { + const code = + error instanceof Error && 'code' in error && typeof error.code === 'string' + ? error.code + : 'runtime_unavailable' + return runtimeHostStatusFailure(code, error instanceof Error ? error.message : String(error)) +} + +export function isRuntimeHostStatusBlocked(response: RuntimeRpcFailure): boolean { + return [ + 'unauthorized', + 'forbidden', + 'invalid_argument', + 'invalid_runtime_response', + 'protocol_version_mismatch', + 'method_not_found', + 'unsupported_method' + ].includes(response.error.code) +} diff --git a/tests/e2e/runtime-host-status-recovery.spec.ts b/tests/e2e/runtime-host-status-recovery.spec.ts new file mode 100644 index 00000000000..09707606da1 --- /dev/null +++ b/tests/e2e/runtime-host-status-recovery.spec.ts @@ -0,0 +1,221 @@ +import { createConnection, createServer, type Socket, type AddressInfo } from 'node:net' +import type { Page } from '@stablyai/playwright-test' +import { decodePairingOffer, encodePairingOffer } from '../../src/shared/pairing' +import { expect, test } from './helpers/orca-app' +import { + createRuntimeDesktopPairingOffer, + launchPairedElectronClient, + launchPairedWebClient, + type RuntimeDesktopPairingOffer +} from './helpers/paired-electron-client' +import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host' + +async function interruptibleHost(offer: RuntimeDesktopPairingOffer) { + const pairing = decodePairingOffer(offer.pairingUrl) + const endpoint = new URL(pairing.endpoint) + const sockets = new Set() + let online = true + const server = createServer((client) => { + if (!online) { + client.destroy() + return + } + const host = createConnection({ host: endpoint.hostname, port: Number(endpoint.port) }) + for (const socket of [client, host]) { + sockets.add(socket) + socket.on('error', () => { + client.destroy() + host.destroy() + }) + socket.on('close', () => { + sockets.delete(socket) + client.destroy() + host.destroy() + }) + } + client.pipe(host).pipe(client) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() as AddressInfo + const pairingUrl = encodePairingOffer({ ...pairing, endpoint: `ws://127.0.0.1:${address.port}` }) + let webClientUrl: string | undefined + if (offer.webClientUrl) { + const url = new URL(offer.webClientUrl) + url.search = '' + url.hash = new URLSearchParams({ pairing: pairingUrl }).toString() + webClientUrl = url.href + } + return { + offer: { pairingUrl, webClientUrl }, + setOnline(value: boolean) { + online = value + if (!online) { + sockets.forEach((socket) => socket.destroy()) + } + }, + async close() { + sockets.forEach((socket) => socket.destroy()) + await new Promise((resolve) => server.close(() => resolve())) + } + } +} + +async function statusEvidence(page: Page, environmentId?: string) { + return page.evaluate((id) => { + const entries = window.__store?.getState().runtimeStatusByEnvironmentId + const entry = id ? entries?.get(id) : entries?.values().next().value + return entry?.snapshot + ? { + verification: entry.snapshot.verification, + transport: entry.snapshot.transport, + runtimeId: entry.status?.runtimeId, + sequence: entry.snapshot.sequence + } + : null + }, environmentId) +} + +async function expectWorkspaceHostAppearance( + page: Page, + disconnected: boolean, + hostLabel?: string +) { + const cards = page.locator('[data-worktree-card-surface="true"]') + const card = ( + hostLabel ? cards.filter({ has: page.getByText(hostLabel, { exact: true }) }) : cards + ).first() + await expect(card).toBeVisible() + await expect(card).toHaveCSS('opacity', disconnected ? '0.6' : '1') + const icon = card.locator(disconnected ? 'svg.lucide-server-off' : 'svg.lucide-server').first() + await expect(icon).toBeVisible() + await expect( + card.locator(disconnected ? 'svg.lucide-server' : 'svg.lucide-server-off') + ).toHaveCount(0) + await expect(icon).toHaveClass(disconnected ? /text-destructive/ : /text-muted-foreground/) + await icon.hover() + await expect( + page.getByRole('tooltip', { name: disconnected ? /disconnected/i : /Project on/ }) + ).toBeVisible() + await page.mouse.move(900, 600) +} + +for (const topology of ['desktop', 'headless'] as const) { + test(`connection-owned status recovers with a ${topology} host and independent viewers`, async ({ + electronApp, + orcaPage: page, + testRepoPath + }, testInfo) => { + test.setTimeout(180_000) + let headless: Awaited> | null = null + let proxy: Awaited> | undefined + let client: Awaited> | undefined + let browser: Awaited> | undefined + try { + headless = + topology === 'headless' + ? await launchHeadlessPairedRuntimeHost({ pinnedServePort: true }) + : null + const offer = headless?.offer ?? (await createRuntimeDesktopPairingOffer(page)) + await (headless + ? headless.client.call('repo.add', { path: testRepoPath }) + : page.evaluate(async (path) => { + await window.api.repos.add({ path }) + await window.__store?.getState().fetchRepos() + }, testRepoPath)) + proxy = await interruptibleHost(offer) + client = await launchPairedElectronClient(offer, testInfo, 'Direct host') + proxy.setOnline(false) + const offlineId = await client.page.evaluate(async (pairingCode) => { + const { environment } = await window.api.runtimeEnvironments.addFromPairingCode({ + name: 'Recovering host', + pairingCode + }) + const store = window.__store!.getState() + store.setRuntimeEnvironments(await window.api.runtimeEnvironments.list()) + await store.refreshRuntimeEnvironmentStatus(environment.id, 1_000) + return environment.id + }, proxy.offer.pairingUrl) + await expect + .poll(() => statusEvidence(client!.page, offlineId)) + .toMatchObject({ verification: 'unavailable' }) + expect(await statusEvidence(client!.page, client.environmentId)).toMatchObject({ + verification: 'verified' + }) + proxy.setOnline(true) + await expect + .poll(() => statusEvidence(client!.page, offlineId), { timeout: 30_000 }) + .toMatchObject({ verification: 'verified', transport: 'ready' }) + const initial = await statusEvidence(client!.page, offlineId) + await expectWorkspaceHostAppearance(client.page, false, 'Recovering host') + await expect(client.page.getByText('Recovering host', { exact: true }).first()).toBeVisible() + await client.page.screenshot({ path: testInfo.outputPath(`${topology}-recovered.png`) }) + browser = await launchPairedWebClient(electronApp, proxy.offer) + await expect + .poll(() => statusEvidence(browser!.page), { timeout: 30_000 }) + .toMatchObject({ verification: 'verified', transport: 'ready' }) + await expectWorkspaceHostAppearance(browser.page, false) + proxy.setOnline(false) + await expect + .poll(() => statusEvidence(client!.page, offlineId)) + .toMatchObject({ transport: 'disconnected' }) + await expect + .poll(() => statusEvidence(browser!.page), { timeout: 30_000 }) + .toMatchObject({ transport: 'disconnected' }) + expect(await statusEvidence(client!.page, client.environmentId)).toMatchObject({ + verification: 'verified', + transport: 'ready' + }) + await expectWorkspaceHostAppearance(client.page, false, 'Recovering host') + await expectWorkspaceHostAppearance(client.page, false, 'Direct host') + await expectWorkspaceHostAppearance(browser.page, false) + await client.page.screenshot({ + path: testInfo.outputPath(`${topology}-sidebar-reconnecting.png`) + }) + await browser.page.screenshot({ + path: testInfo.outputPath(`${topology}-browser-reconnecting.png`) + }) + proxy.setOnline(true) + await expect + .poll(() => statusEvidence(client!.page, offlineId), { timeout: 30_000 }) + .toMatchObject({ verification: 'verified', transport: 'ready' }) + await expect + .poll(() => statusEvidence(browser!.page), { timeout: 30_000 }) + .toMatchObject({ verification: 'verified', transport: 'ready' }) + expect((await statusEvidence(client!.page, offlineId))!.sequence).toBeGreaterThan( + initial!.sequence + ) + await expectWorkspaceHostAppearance(client.page, false, 'Recovering host') + await expectWorkspaceHostAppearance(browser.page, false) + await browser.page.screenshot({ + path: testInfo.outputPath(`${topology}-browser-recovered.png`) + }) + await client.page.evaluate(async (selector) => { + await window.api.runtimeEnvironments.disconnect({ selector }) + }, offlineId) + await expect + .poll(() => statusEvidence(client!.page, offlineId)) + .toMatchObject({ verification: 'blocked', transport: 'disconnected' }) + await expectWorkspaceHostAppearance(client.page, true, 'Recovering host') + await expectWorkspaceHostAppearance(client.page, false, 'Direct host') + await client.page.screenshot({ + path: testInfo.outputPath(`${topology}-sidebar-disconnected.png`) + }) + await client.page.evaluate(async (selector) => { + await window.api.runtimeEnvironments.connect({ selector }) + }, offlineId) + await expect + .poll(() => statusEvidence(client!.page, offlineId), { timeout: 30_000 }) + .toMatchObject({ verification: 'verified', transport: 'ready' }) + await expectWorkspaceHostAppearance(client.page, false, 'Recovering host') + await expectWorkspaceHostAppearance(browser.page, false) + await client.page.screenshot({ + path: testInfo.outputPath(`${topology}-sidebar-restored.png`) + }) + } finally { + await browser?.dispose() + await client?.dispose() + await proxy?.close() + await headless?.dispose() + } + }) +} From fab78c766952404a1296d519a1dd76df6ed8d12e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:19:57 -0700 Subject: [PATCH 002/191] fix(native-chat): show one live-turn indicator, and make Thinking mean reasoning (#19977) * native-chat: render one indicator row for the live desktop turn The turn-timing row and the spinner+activity line were two rows saying "Working" at once. A settled turn keeps its own row; the live turn now has only the spinner row, labelled provider activity -> Thinking -> Working for N through the shared resolver. Reasoning is the turn's content, so it no longer becomes the activity label, and "Thinking" now means the turn is reasoning right now rather than that it has produced no output yet. * mobile: give the live turn row a spinner and the shared indicator label Mobile's per-turn row is already the only live indicator on the structured lane, but it pulsed a bare word and never showed what the provider said it was doing. It now renders a spinner beside the same resolved label desktop uses, and reads reasoning from the journal instead of inferring it from missing output. The bridge lane's four prompt/interrupt write seams move to one module so the controller stays under its line cap. * codex: mark streamed reasoning as reasoning too, and pin the provider markers The settled reasoning item carried the marker but the streaming one did not, so a live Codex turn - the only time the indicator is on screen - never read as reasoning. Both paths now stamp it; a plan document keeps its own presentation and must never read as reasoning. * fix(native-chat): tighten live turn reasoning state --------- Co-authored-by: Merge Sim --- .../session/MobileNativeChatMessage.test.ts | 7 +- .../src/session/MobileNativeChatMessage.tsx | 2 +- .../src/session/MobileNativeChatOverlay.tsx | 1 + .../MobileNativeChatTurnStatus.test.ts | 41 +- .../session/MobileNativeChatTurnStatus.tsx | 55 +- .../src/session/MobileNativeChatView.test.ts | 71 ++- mobile/src/session/MobileNativeChatView.tsx | 18 +- .../mobile-native-chat-controller-contract.ts | 7 +- .../use-mobile-bridge-chat-prompt-writes.ts | 65 ++ .../use-mobile-native-chat-controller.ts | 45 +- .../use-mobile-native-chat-turn-disclosure.ts | 25 +- .../use-mobile-native-chat-turn-status.ts | 17 +- .../use-mobile-structured-agent-session.ts | 16 +- ...-mobile-structured-turn-indicator.test.tsx | 138 +++++ ...ude-structured-journal-translation.test.ts | 7 +- .../claude-structured-journal-translation.ts | 7 +- .../codex-notice-item-translation.test.ts | 11 +- .../codex-structured-item-translation.test.ts | 28 +- .../codex-structured-item-translation.ts | 16 +- ...red-journal-translation-settlement.test.ts | 5 +- .../NativeChatMessageList.test.tsx | 444 -------------- .../native-chat/NativeChatMessageList.tsx | 30 +- ...iveChatMessageList.turn-indicator.test.tsx | 563 ++++++++++++++++++ .../NativeChatTurnActivityLine.tsx | 43 +- .../native-chat/NativeChatWorkingStatus.tsx | 17 +- .../native-chat-transcript-slots.test.ts | 6 +- .../native-chat-transcript-slots.ts | 8 +- .../use-native-chat-elapsed-seconds.ts | 14 + .../use-native-chat-turn-status.ts | 9 +- .../use-structured-agent-session.ts | 2 +- .../native-chat-turn-activity.test.ts | 48 +- .../native-chat-turn-activity.ts | 13 +- src/shared/native-chat-turn-status.test.ts | 108 ++-- src/shared/native-chat-turn-status.ts | 75 ++- ...tive-chat-unverifiable-turn-status.test.ts | 6 +- ...structured-agent-session-live-turn.test.ts | 113 ++++ .../structured-agent-session-live-turn.ts | 76 +++ .../structured-agent-session-projection.ts | 48 +- ...ructured-agent-session-turn-timing.test.ts | 2 +- 39 files changed, 1449 insertions(+), 758 deletions(-) create mode 100644 mobile/src/session/use-mobile-bridge-chat-prompt-writes.ts create mode 100644 mobile/src/session/use-mobile-structured-turn-indicator.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-native-chat-elapsed-seconds.ts rename src/{renderer/src/components/native-chat => shared}/native-chat-turn-activity.test.ts (72%) rename src/{renderer/src/components/native-chat => shared}/native-chat-turn-activity.ts (86%) create mode 100644 src/shared/structured-agent-session-live-turn.test.ts create mode 100644 src/shared/structured-agent-session-live-turn.ts diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index e09d1631a8d..67e90132866 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -9,6 +9,7 @@ vi.mock('react-native', async () => { const Text = ({ children, ...props }: { children?: unknown }): unknown => React.createElement('Text', props, children) return { + ActivityIndicator: 'ActivityIndicator', Animated: { Text, Value: class { @@ -268,12 +269,12 @@ describe('MobileNativeChatMessage', () => { expect(tree.root.findAllByType('Wrench' as never)).toHaveLength(0) }) - it('renders the turn status row under a user message', () => { + it('renders the settled turn status row under a user message', () => { const tree = render(userMessage([{ type: 'text', text: 'go' }]), { structuredActivityUi: true, - turnStatus: { startedAt: Date.now(), thinking: true, workedSeconds: null } + turnStatus: { startedAt: Date.now() - 3_000, thinking: false, workedSeconds: 3 } }) - expect(textIn(tree.root)).toContain('Thinking') + expect(textIn(tree.root)).toContain('Worked for 3s') }) it('does not render a turn status row without one', () => { diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index 5d013688249..9b480fdd7d9 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -82,7 +82,7 @@ function MobileNativeChatMessageImpl({ /** Multiplies all chat text sizes for pinch-to-zoom (1 = no change). */ fontScale?: number onOpenFile?: (relativePath: string) => void - /** This turn's status row, rendered under a user message (desktop parity). */ + /** This settled turn's status row, rendered under its user message. */ turnStatus?: NativeChatTurnStatus | null /** Whether the turn caret has disclosed this turn's activity. */ turnExpanded?: boolean diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 997083cb26e..389beb8eaad 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -73,6 +73,7 @@ export function MobileNativeChatOverlay({ agentWorking={controller.nativeChatAgentWorking} canStop={controller.nativeChatCanStop} structuredActivityUi={controller.nativeChatStructured} + turnIndicator={controller.nativeChatTurnIndicator} workingStartedAt={controller.nativeChatWorkingStartedAt} settledTurns={controller.nativeChatSettledTurns} streaming={streaming} diff --git a/mobile/src/session/MobileNativeChatTurnStatus.test.ts b/mobile/src/session/MobileNativeChatTurnStatus.test.ts index 78ac01e0d37..6b6a1d49e07 100644 --- a/mobile/src/session/MobileNativeChatTurnStatus.test.ts +++ b/mobile/src/session/MobileNativeChatTurnStatus.test.ts @@ -7,18 +7,8 @@ vi.mock('react-native', async () => { const Text = ({ children, ...props }: { children?: unknown }): unknown => React.createElement('Text', props, children) return { - Animated: { - Text, - Value: class { - constructor(private value: number) {} - setValue(next: number): void { - this.value = next - } - }, - loop: (animation: unknown) => animation, - sequence: () => ({ start: vi.fn(), stop: vi.fn() }), - timing: () => ({ start: vi.fn(), stop: vi.fn() }) - }, + ActivityIndicator: (props: Record) => + React.createElement('ActivityIndicator', props), Pressable: ({ children, ...props }: { children?: unknown }) => React.createElement('Pressable', props, children), Text, @@ -49,6 +39,7 @@ describe('MobileNativeChatTurnStatus', () => { startedAt: number | null thinking: boolean workedSeconds?: number | null + activityText?: string | null expanded?: boolean onToggleExpanded?: () => void }): ReactTestRenderer { @@ -61,12 +52,16 @@ describe('MobileNativeChatTurnStatus', () => { const labels = (node: ReactTestInstance): string[] => node.findAllByType('Text' as never).map((text) => String(text.children.join(''))) - it('reads "Thinking" before the turn produces output', () => { + const spinners = (node: ReactTestInstance): ReactTestInstance[] => + node.findAllByType('ActivityIndicator' as never) + + it('reads "Thinking" beside one spinner while the turn reasons', () => { const tree = render({ startedAt: Date.now(), thinking: true }) expect(labels(tree.root)).toEqual(['Thinking']) + expect(spinners(tree.root)).toHaveLength(1) }) - it('counts up once the turn is producing output', () => { + it('counts up on that same single row when the turn is not reasoning', () => { const startedAt = Date.now() const tree = render({ startedAt, thinking: false }) expect(labels(tree.root)).toEqual(['Working for 0s']) @@ -74,6 +69,19 @@ describe('MobileNativeChatTurnStatus', () => { vi.advanceTimersByTime(12_000) }) expect(labels(tree.root)).toEqual(['Working for 12s']) + expect(spinners(tree.root)).toHaveLength(1) + }) + + it('lets provider activity text beat both fallbacks and hold the clock', () => { + const tree = render({ + startedAt: Date.now(), + thinking: true, + activityText: 'Running pnpm test' + }) + expect(labels(tree.root)).toEqual(['Running pnpm test']) + expect(spinners(tree.root)).toHaveLength(1) + // No label consumes the duration, so nothing schedules a tick for it. + expect(vi.getTimerCount()).toBe(0) }) it('settles to a tappable "Worked for" row that toggles the turn', () => { @@ -98,9 +106,10 @@ describe('MobileNativeChatTurnStatus', () => { expect(labels(tree.root)).toEqual(['Worked for 5s']) }) - it('holds no interval once the turn has settled', () => { - render({ startedAt: Date.now(), thinking: false, workedSeconds: 5 }) + it('holds no interval, and no spinner, once the turn has settled', () => { + const tree = render({ startedAt: Date.now(), thinking: false, workedSeconds: 5 }) expect(vi.getTimerCount()).toBe(0) + expect(spinners(tree.root)).toHaveLength(0) }) it('announces the live row to assistive tech', () => { diff --git a/mobile/src/session/MobileNativeChatTurnStatus.tsx b/mobile/src/session/MobileNativeChatTurnStatus.tsx index 4ce73cdcd38..acf922265f6 100644 --- a/mobile/src/session/MobileNativeChatTurnStatus.tsx +++ b/mobile/src/session/MobileNativeChatTurnStatus.tsx @@ -1,7 +1,8 @@ -import { useEffect, useRef, useState } from 'react' -import { Animated, Pressable, StyleSheet, Text, View } from 'react-native' +import { useEffect, useState } from 'react' +import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native' import { ChevronRight } from 'lucide-react-native' import { + formatNativeChatActiveTurnLabel, formatNativeChatTurnStatusLabel, NATIVE_CHAT_TURN_STATUS_COPY, nativeChatElapsedSeconds @@ -25,48 +26,38 @@ function useElapsedSeconds(startedAt: number | null, counting: boolean): number return counting ? nativeChatElapsedSeconds(startedAt, mountedAt, now) : 0 } -/** The per-turn status row — "Thinking", then "Working for 12s" while the turn - * runs, settling to a tappable "Worked for 3m 4s" that discloses the turn's - * tool activity. Desktop parity: `NativeChatWorkingStatus`. */ +/** The per-turn status row. While the turn runs it is the one live indicator — a + * spinner beside what the provider says it is doing, else "Thinking", else + * "Working for 12s". It settles to a tappable "Worked for 3m 4s" that discloses + * the turn's tool activity. Desktop parity: `NativeChatTurnActivityLine` for the + * live row, `NativeChatWorkingStatus` for the settled one. */ export function MobileNativeChatTurnStatus({ startedAt, thinking, workedSeconds, + activityText, expanded = false, onToggleExpanded }: { startedAt: number | null thinking: boolean workedSeconds?: number | null + /** Provider activity copy for a live turn; outranks the other two labels. */ + activityText?: string | null expanded?: boolean onToggleExpanded?: () => void }): React.JSX.Element { - const counting = !thinking && workedSeconds == null + const settled = workedSeconds != null + const counting = !settled && !thinking && !activityText?.trim() const elapsedSeconds = useElapsedSeconds(startedAt, counting) - const label = formatNativeChatTurnStatusLabel({ thinking, workedSeconds, elapsedSeconds }) + const label = settled + ? formatNativeChatTurnStatusLabel({ thinking, workedSeconds, elapsedSeconds }) + : formatNativeChatActiveTurnLabel({ activityText, thinking, elapsedSeconds }) - const pulse = useRef(new Animated.Value(1)).current - useEffect(() => { - if (!thinking) { - pulse.setValue(1) - return - } - const animation = Animated.loop( - Animated.sequence([ - Animated.timing(pulse, { toValue: 0.45, duration: 700, useNativeDriver: true }), - Animated.timing(pulse, { toValue: 1, duration: 700, useNativeDriver: true }) - ]) - ) - animation.start() - return () => animation.stop() - }, [pulse, thinking]) - - const rowStyle = [styles.row, thinking ? null : styles.rowSettled] - - if (workedSeconds != null && onToggleExpanded) { + if (settled && onToggleExpanded) { return ( [...rowStyle, pressed && styles.pressed]} + style={({ pressed }) => [styles.row, styles.rowSettled, pressed && styles.pressed]} onPress={onToggleExpanded} hitSlop={6} accessibilityRole="button" @@ -83,11 +74,14 @@ export function MobileNativeChatTurnStatus({ return ( - {label} + {settled ? null : } + + {label} + ) } @@ -109,7 +103,8 @@ const styles = StyleSheet.create({ }, label: { color: colors.textMuted, - fontSize: typography.bodySize + fontSize: typography.bodySize, + flexShrink: 1 }, caretOpen: { transform: [{ rotate: '90deg' }] diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index 63bfb715445..c573d89cc89 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -73,6 +73,7 @@ type Overrides = { onSend?: (text: string) => Promise pending?: Parameters[0]['pending'] structuredActivityUi?: boolean + turnIndicator?: Parameters[0]['turnIndicator'] agentWorking?: boolean canStop?: boolean sendSurfaceId?: string @@ -273,20 +274,74 @@ describe('MobileNativeChatView', () => { return (renderedRow(id) as { props: Record }).props } + function footerProps(): Record | null { + const list = renderer!.root.find((node) => node.type === 'FlatList') + const footer = list.props.ListFooterComponent as + | { props: Record } + | null + | undefined + return footer?.props ?? null + } + function workingIndicators(): ReactTestInstance[] { return renderer!.root.findAll((node) => node.type === 'WorkingIndicator') } - it('gives the live user turn a status row and drops the three-dot indicator', async () => { - const folded = [userTurn('u1', 'go')] + it('puts the live status at the turn tail and drops the three-dot indicator', async () => { + const folded = [userTurn('u1', 'go'), assistantTurn('a1', 'still working')] await render({ messages: folded, folded, structuredActivityUi: true, agentWorking: true }) const props = rowProps('u1') expect(props.structuredActivityUi).toBe(true) - expect(props.turnStatus).toMatchObject({ thinking: true, workedSeconds: null }) + expect(props.turnStatus).toBeNull() + // Nothing reports reasoning, so the one live footer counts instead of guessing. + expect(footerProps()).toMatchObject({ thinking: false, workedSeconds: null }) + expect(listIds().at(-1)).toBe('a1') expect(props.activeTurnIsWorking).toBe(true) expect(workingIndicators()).toHaveLength(0) }) + it('reports the live turn as thinking only when its journal says it is reasoning', async () => { + const folded = [userTurn('u1', 'go')] + await render({ + messages: folded, + folded, + structuredActivityUi: true, + agentWorking: true, + turnIndicator: { thinking: true, activityText: null } + }) + expect(rowProps('u1').turnStatus).toBeNull() + expect(footerProps()).toMatchObject({ thinking: true, workedSeconds: null }) + }) + + it('hands the live row the provider activity copy that outranks its fallbacks', async () => { + const folded = [userTurn('u1', 'go')] + await render({ + messages: folded, + folded, + structuredActivityUi: true, + agentWorking: true, + turnIndicator: { thinking: true, activityText: 'Running pnpm test' } + }) + expect(footerProps()).toMatchObject({ + thinking: true, + activityText: 'Running pnpm test' + }) + }) + + it('keeps the activity copy on the live footer instead of a historical row', async () => { + const folded = [userTurn('u1', 'go'), userTurn('u2', 'again')] + await render({ + messages: folded, + folded, + structuredActivityUi: true, + agentWorking: true, + turnIndicator: { thinking: false, activityText: 'Running pnpm test' } + }) + expect(rowProps('u1')).not.toHaveProperty('turnActivityText') + expect(rowProps('u2')).not.toHaveProperty('turnActivityText') + expect(footerProps()).toMatchObject({ activityText: 'Running pnpm test' }) + }) + it('keeps the bridge lane on the three-dot indicator with no turn status', async () => { const folded = [userTurn('u1', 'go')] await render({ messages: folded, folded, agentWorking: true }) @@ -294,13 +349,15 @@ describe('MobileNativeChatView', () => { expect(props.structuredActivityUi).toBe(false) expect(props.turnStatus).toBeNull() expect(props.activeTurnIsWorking).toBe(false) + expect(footerProps()).toBeNull() expect(workingIndicators()).toHaveLength(1) }) it('settles the finished turn to a tappable duration', async () => { const folded = [userTurn('u1', 'go'), assistantTurn('a1', 'done')] await render({ messages: folded, folded, structuredActivityUi: true, agentWorking: true }) - expect(rowProps('u1').turnStatus).toMatchObject({ thinking: false, workedSeconds: null }) + expect(rowProps('u1').turnStatus).toBeNull() + expect(footerProps()).toMatchObject({ thinking: false, workedSeconds: null }) await update({ messages: folded, folded, structuredActivityUi: true, agentWorking: false }) const settled = rowProps('u1') expect(settled.turnStatus).toMatchObject({ thinking: false }) @@ -309,6 +366,7 @@ describe('MobileNativeChatView', () => { ) expect(settled.onToggleTurn).toBeTypeOf('function') expect(settled.activeTurnIsWorking).toBe(false) + expect(footerProps()).toBeNull() }) it('hangs no status row on an assistant row', async () => { @@ -317,6 +375,7 @@ describe('MobileNativeChatView', () => { expect(rowProps('a1').turnStatus).toBeNull() // The assistant row still belongs to the live turn, so its tool row stays visible. expect(rowProps('a1').activeTurnIsWorking).toBe(true) + expect(footerProps()).toMatchObject({ workedSeconds: null }) }) it('does not carry a running turn clock across chat surfaces', async () => { @@ -331,7 +390,7 @@ describe('MobileNativeChatView', () => { agentWorking: true, sendSurfaceId: 'host\0worktree\0tab-a' }) - expect(rowProps('u1').turnStatus).toMatchObject({ startedAt: 1_000 }) + expect(footerProps()).toMatchObject({ startedAt: 1_000 }) vi.setSystemTime(12_000) const secondTab = [userTurn('u2', 'second')] @@ -343,7 +402,7 @@ describe('MobileNativeChatView', () => { sendSurfaceId: 'host\0worktree\0tab-b' }) - expect(rowProps('u2').turnStatus).toMatchObject({ startedAt: 12_000 }) + expect(footerProps()).toMatchObject({ startedAt: 12_000 }) } finally { vi.useRealTimers() } diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 440e05e6b9d..67fc93506a9 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -13,7 +13,10 @@ import { GestureDetector, GestureHandlerRootView } from 'react-native-gesture-ha import { ArrowDown, ChevronsDownUp, ChevronsUpDown, Square } from 'lucide-react-native' import type { AskAnswerSelection, AskPrompt } from '../../../src/shared/native-chat-ask' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' +import type { + NativeChatLiveTurnIndicator, + NativeChatSettledTurns +} from '../../../src/shared/native-chat-turn-status' import { colors } from '../theme/mobile-theme' import { styles } from './mobile-native-chat-view-styles' import { @@ -53,6 +56,8 @@ type Props = { /** Structured lane: per-turn "Working for N" status plus live tool progress, * replacing the bridge lane's static three-dot working row (desktop parity). */ structuredActivityUi?: boolean + /** What labels the live turn's one indicator row (structured lane only). */ + turnIndicator?: NativeChatLiveTurnIndicator | null /** Structured lane: host-recorded turn timing feeding the per-turn status rows. */ workingStartedAt?: number | null settledTurns?: NativeChatSettledTurns | null @@ -136,6 +141,7 @@ export function MobileNativeChatView({ agentWorking, canStop = agentWorking, structuredActivityUi = false, + turnIndicator = null, workingStartedAt, settledTurns, onStop, @@ -259,14 +265,17 @@ export function MobileNativeChatView({ [hasMore, loadingEarlier, onLoadEarlier] ) - // Per-turn "Thinking / Working for N / Worked for N" rows. The structured lane - // owns them; the bridge lane keeps its three-dot indicator. + // Per-turn status rows: one live indicator while the turn runs, then a settled + // "Worked for N" row. The structured lane owns them; the bridge lane keeps its + // three-dot indicator. const turns = useMobileNativeChatTurnDisclosure({ messages: data, enabled: structuredActivityUi, isWorking: agentWorking === true, workingStartedAt, settledTurns, + thinking: turnIndicator?.thinking === true, + activityText: turnIndicator?.activityText ?? null, scopeKey: sendSurfaceId }) @@ -331,11 +340,12 @@ export function MobileNativeChatView({ ) : null } ListFooterComponent={ - turns.activeTurnIsUnanchored && turns.active ? ( + structuredActivityUi && agentWorking && turns.active ? ( ) : null } diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index da1b91acb25..36c07215e8e 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -6,7 +6,10 @@ import type { } from '../../../src/shared/native-chat-ask' import type { detectAgentPermission } from './mobile-native-chat-permission' import type { parseAgentQuestion } from './mobile-native-chat-question' -import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' +import type { + NativeChatLiveTurnIndicator, + NativeChatSettledTurns +} from '../../../src/shared/native-chat-turn-status' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import type { MobileNativeChatPendingMessage } from './use-mobile-native-chat-drafts' import type { useMobileNativeChatSession } from './use-mobile-native-chat-session' @@ -29,6 +32,8 @@ export type MobileNativeChatController = { /** Structured lane: drives the per-turn status row and live tool progress. */ nativeChatStructured: boolean nativeChatAgentWorking: boolean + /** What labels the live turn's one indicator row; null off the structured lane. */ + nativeChatTurnIndicator: NativeChatLiveTurnIndicator | null /** Structured lane: host-recorded turn timing for the per-turn status rows. */ nativeChatWorkingStartedAt: number | null nativeChatSettledTurns: NativeChatSettledTurns | null diff --git a/mobile/src/session/use-mobile-bridge-chat-prompt-writes.ts b/mobile/src/session/use-mobile-bridge-chat-prompt-writes.ts new file mode 100644 index 00000000000..2dddad48e16 --- /dev/null +++ b/mobile/src/session/use-mobile-bridge-chat-prompt-writes.ts @@ -0,0 +1,65 @@ +import type { MutableRefObject } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send' +import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send' +import { useMobileNativeChatCancelAsk } from './use-mobile-native-chat-cancel-ask' +import { useMobileNativeChatStop } from './use-mobile-native-chat-stop' +import type { MobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send' + +/** The bridge lane's four prompt/interrupt write seams. They share one enable + * gate and chain through the answer seam's `cancelPending`, so a caller cannot + * wire one of them to a different lane or forget to drop in-flight answer + * writes before an Escape. The structured lane answers over RPC instead. */ +export function useMobileBridgeChatPromptWrites(args: { + client: RpcClient | null + enabled: boolean + handleRef: MutableRefObject + deviceTokenRef: MutableRefObject + agentRef: MutableRefObject + /** Changes on chat session swap; cancels pending writes when it does. */ + sessionId: string | null + streamIdentity: string + onSendError: (message: string) => void +}): { + answerAsk: MobileNativeChatAnswerSend['answerAsk'] + cancelAsk: () => Promise + respondPermission: (send: string) => Promise + stop: () => void +} { + const { client, enabled, handleRef, deviceTokenRef, streamIdentity, onSendError } = args + const { answerAsk, cancelPending } = useMobileNativeChatAnswerSend({ + client, + enabled, + handleRef, + deviceTokenRef, + agentRef: args.agentRef, + sessionId: args.sessionId, + streamIdentity, + onSendError + }) + const cancelAsk = useMobileNativeChatCancelAsk({ + client, + enabled, + handleRef, + deviceTokenRef, + cancelPending, + onSendError + }) + const respondPermission = useMobileNativeChatPermissionSend({ + client, + enabled, + handleRef, + deviceTokenRef, + onSendError + }) + const stop = useMobileNativeChatStop({ + client, + enabled, + handleRef, + deviceTokenRef, + streamIdentity, + cancelPending, + onSendError + }) + return { answerAsk, cancelAsk, respondPermission, stop } +} diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 5b941367451..4087694b567 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -2,10 +2,7 @@ import { useLayoutEffect, useRef, type MutableRefObject } from 'react' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' 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' -import { useMobileNativeChatCancelAsk } from './use-mobile-native-chat-cancel-ask' 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' @@ -14,10 +11,10 @@ import { useMobileNativeChatSessionOptionController } from './use-mobile-native- import { useMobileNativeChatSessionLane } from './use-mobile-native-chat-session-lane' 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 { useMobileBridgeChatPromptWrites } from './use-mobile-bridge-chat-prompt-writes' import { useMobileNativeChatActiveResolution } from './use-mobile-native-chat-active-resolution' export type { MobileNativeChatController } from './mobile-native-chat-controller-contract' @@ -171,42 +168,19 @@ export function useMobileNativeChatController(args: { ? client != null && activeChatSessionId != null && connState === 'connected' : nativeChatInputLeaseReady && connState === 'connected' - const { answerAsk: handleNativeChatAnswerAsk, cancelPending: cancelNativeChatAnswer } = - useMobileNativeChatAnswerSend({ - client, - enabled: inputSendable && !activeChatStructured, - handleRef: activeHandleRef, - deviceTokenRef, - agentRef: activeChatAgentRef, - sessionId: activeChatSessionId, - streamIdentity, - onSendError - }) - - const handleNativeChatCancelAsk = useMobileNativeChatCancelAsk({ - client, - enabled: inputSendable && !activeChatStructured, - handleRef: activeHandleRef, - deviceTokenRef, - cancelPending: cancelNativeChatAnswer, - onSendError - }) - - const legacyHandleNativeChatRespondPermission = useMobileNativeChatPermissionSend({ - client, - enabled: inputSendable && !activeChatStructured, - handleRef: activeHandleRef, - deviceTokenRef, - onSendError - }) - - const handleNativeChatStop = useMobileNativeChatStop({ + const { + answerAsk: handleNativeChatAnswerAsk, + cancelAsk: handleNativeChatCancelAsk, + respondPermission: legacyHandleNativeChatRespondPermission, + stop: handleNativeChatStop + } = useMobileBridgeChatPromptWrites({ client, enabled: inputSendable && !activeChatStructured, handleRef: activeHandleRef, deviceTokenRef, + agentRef: activeChatAgentRef, + sessionId: activeChatSessionId, streamIdentity, - cancelPending: cancelNativeChatAnswer, onSendError }) @@ -299,6 +273,7 @@ export function useMobileNativeChatController(args: { /** Structured lane: drives the per-turn status row and live tool progress. */ nativeChatStructured: activeChatStructured, nativeChatAgentWorking, + nativeChatTurnIndicator: activeChatStructured ? structuredNativeChat.turnIndicator : null, nativeChatWorkingStartedAt: activeChatStructured ? structuredNativeChat.workingStartedAt : null, nativeChatSettledTurns: activeChatStructured ? structuredNativeChat.settledTurns : null, nativeChatCanStop: activeChatStructured diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts index 5620c1390b2..a67a6a88663 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts @@ -28,6 +28,8 @@ export function useMobileNativeChatTurnDisclosure({ isWorking, workingStartedAt, settledTurns, + thinking = false, + activityText = null, scopeKey }: { messages: readonly NativeChatMessage[] @@ -36,12 +38,16 @@ export function useMobileNativeChatTurnDisclosure({ workingStartedAt?: number | null /** Host-recorded durations; they outrank whatever this client observed. */ settledTurns?: NativeChatSettledTurns | null + /** Whether the turn is reasoning right now, derived from its journal content. */ + thinking?: boolean + /** What the provider says the live turn is doing; outranks the other labels. */ + activityText?: string | null /** Host/worktree/tab identity for timing and disclosure isolation. */ scopeKey: string }): { active: NativeChatTurnStatus | null - /** True when the live turn has no user message to hang its status row under. */ - activeTurnIsUnanchored: boolean + /** The live turn's provider activity copy, for the footer row. */ + activeActivityText: string | null onToggleTurn: (turnKey: string) => void resolveRow: (index: number, message: NativeChatMessage) => MobileNativeChatTurnRow } { @@ -51,6 +57,7 @@ export function useMobileNativeChatTurnDisclosure({ isWorking, workingStartedAt, settledTurns, + thinking, scopeKey }) const [expandedTurns, setExpandedTurns] = useState<{ @@ -93,17 +100,16 @@ export function useMobileNativeChatTurnDisclosure({ }, [enabled, messages]) const { active, activeTurnKey, completedByTurn } = turnStatuses + const activeActivityText = enabled && isWorking ? (activityText ?? null) : null const resolveRow = useCallback( (index: number, message: NativeChatMessage): MobileNativeChatTurnRow => { const turnKey = turnKeys[index] const turnStatus = !enabled || message.role !== 'user' ? null - : turnKey === activeTurnKey - ? active - : turnKey - ? (completedByTurn[turnKey] ?? null) - : null + : turnKey + ? (completedByTurn[turnKey] ?? null) + : null return { turnStatus, turnExpanded: turnKey ? expandedTurnIds.has(turnKey) : false, @@ -120,15 +126,14 @@ export function useMobileNativeChatTurnDisclosure({ (turnKey === undefined && activeTurnKey === MOBILE_UNANCHORED_TURN_KEY)) } }, - [turnKeys, enabled, activeTurnKey, active, completedByTurn, expandedTurnIds, isWorking] + [turnKeys, enabled, activeTurnKey, completedByTurn, expandedTurnIds, isWorking] ) return { active, + activeActivityText, /** Stable for a given chat scope, so it never disturbs a row's memo. */ onToggleTurn: toggleExpandedTurn, - activeTurnIsUnanchored: - enabled && active != null && activeTurnKey === MOBILE_UNANCHORED_TURN_KEY, resolveRow } } diff --git a/mobile/src/session/use-mobile-native-chat-turn-status.ts b/mobile/src/session/use-mobile-native-chat-turn-status.ts index 5686acde515..f7cbb2dd887 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-status.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-status.ts @@ -1,7 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { - nativeChatTurnHasResponse, reduceNativeChatTurnTiming, selectNativeChatTurnStatuses, type NativeChatSettledTurns, @@ -27,6 +26,7 @@ export function useMobileNativeChatTurnStatus({ isWorking, workingStartedAt, settledTurns, + thinking = false, scopeKey }: { messages: readonly NativeChatMessage[] @@ -35,6 +35,8 @@ export function useMobileNativeChatTurnStatus({ workingStartedAt?: number | null /** Host-recorded durations; they outrank whatever this client observed. */ settledTurns?: NativeChatSettledTurns | null + /** Whether the turn is reasoning right now, derived from its journal content. */ + thinking?: boolean /** Host/worktree/tab identity. Timings never carry across chat surfaces. */ scopeKey: string }): { @@ -45,7 +47,6 @@ export function useMobileNativeChatTurnStatus({ const latestUserIndex = enabled ? messages.findLastIndex((message) => message.role === 'user') : -1 - const hasCurrentTurnResponse = enabled && nativeChatTurnHasResponse(messages, latestUserIndex) const latestUserId = latestUserIndex !== -1 ? (messages[latestUserIndex]?.id ?? null) : null const activeTurnKey = latestUserId ?? MOBILE_UNANCHORED_TURN_KEY const [scopedTiming, setScopedTiming] = useState(() => ({ @@ -95,6 +96,7 @@ export function useMobileNativeChatTurnStatus({ // turn re-renders ~20x/s. Without this, every settled turn's row gets fresh // props each tick and the memoized message rows all re-render. const turnIsWorking = enabled && isWorking + const turnIsThinking = enabled && thinking const settledByTurn = enabled ? (settledTurns ?? undefined) : undefined const statuses = useMemo( () => @@ -102,17 +104,10 @@ export function useMobileNativeChatTurnStatus({ activeTurnKey, isWorking: turnIsWorking, workingStartedAt, - hasCurrentTurnResponse, + thinking: turnIsThinking, settledByTurn }), - [ - timingByTurn, - activeTurnKey, - turnIsWorking, - workingStartedAt, - hasCurrentTurnResponse, - settledByTurn - ] + [timingByTurn, activeTurnKey, turnIsWorking, workingStartedAt, turnIsThinking, settledByTurn] ) return { ...statuses, activeTurnKey } } diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 5c458bbdd9f..938e881aec7 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -11,10 +11,12 @@ import { import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection' +import { hasUnansweredStructuredAgentSessionDispatch } from '../../../src/shared/structured-agent-session-projection' import { activeStructuredAgentSessionTurnId, - hasUnansweredStructuredAgentSessionDispatch -} from '../../../src/shared/structured-agent-session-projection' + isStructuredAgentSessionThinking +} from '../../../src/shared/structured-agent-session-live-turn' +import { selectStructuredAgentTurnActivity } from '../../../src/shared/native-chat-turn-activity' import { pendingStructuredApproval, pendingStructuredQuestion, @@ -31,6 +33,7 @@ import type { RpcClient } from '../transport/rpc-client' import type { MobileChatPermission } from './mobile-native-chat-permission' import type { MobileChatQuestion } from './mobile-native-chat-question' import type { MobileNativeChatSession } from './use-mobile-native-chat-session' +import type { NativeChatLiveTurnIndicator } from '../../../src/shared/native-chat-turn-status' import { useMobileStructuredAgentState } from './use-mobile-structured-agent-state' import { useMobileStructuredPromptResponses } from './use-mobile-structured-prompt-responses' import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options' @@ -43,6 +46,8 @@ type StructuredMobileSession = ReturnType ({ thinking, activityText }), [thinking, activityText]) const status = state.status === 'idle' ? 'idle' : state.status const approvalPrompt = useMemo( () => state.items.find(pendingStructuredApproval) ?? null, @@ -296,6 +307,7 @@ export function useMobileStructuredAgentSession(args: { turnId !== null || hasUnansweredStructuredAgentSessionDispatch(state.submissions, state.fence), turnId, + turnIndicator, ...turnTiming, sendWithOutcome, cancel, diff --git a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx new file mode 100644 index 00000000000..8369ff03a17 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx @@ -0,0 +1,138 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' +import type { RpcClient } from '../transport/rpc-client' +import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' + +function journalItem( + sequence: number, + body: AgentJournalRenderItem['body'] +): AgentJournalRenderItem { + return { itemId: `item-${sequence}`, revision: 1, sequence, observedAt: sequence, body } +} + +function snapshot(items: AgentJournalRenderItem[], fence: number): AgentSessionSubscribeEvent { + const newest = items.length + return { + type: 'snapshot', + sessionId: 'session-1', + fence, + page: { + sessionId: 'session-1', + epoch: 'epoch-1', + fence, + direction: 'tail', + items, + removedItemIds: [], + submissions: [], + window: { + oldest: { epoch: 'epoch-1', sequence: 1 }, + newest: { epoch: 'epoch-1', sequence: newest }, + nextCursor: { epoch: 'epoch-1', sequence: newest + 1 } + }, + liveCursor: { epoch: 'epoch-1', sequence: newest }, + hasOlder: false, + hasNewer: false + } + } as AgentSessionSubscribeEvent +} + +/** What the one live indicator row reads, resolved off the session journal. */ +describe('useMobileStructuredAgentSession turn indicator', () => { + let renderer: ReactTestRenderer | null = null + let hook: ReturnType | null = null + let listener: ((value: unknown) => void) | null = null + const sendRequest = vi.fn(async (method: string) => ({ + ok: true, + result: + method === 'agentSession.options' + ? { + models: [{ id: 'gpt-fast', label: 'GPT Fast', isDefault: true, efforts: [] }], + current: { model: 'gpt-fast' } + } + : {}, + _meta: { runtimeId: 'r1' } + })) + const subscribe = vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => { + listener = onData + return vi.fn() + }) + const client = { sendRequest, subscribe } as unknown as RpcClient + // Stable across renders: a fresh callback would re-run the hold/subscribe effect + // and release the session out from under the test. + const onSendError = vi.fn() + + function Harness(): null { + hook = useMobileStructuredAgentSession({ + client, + sessionId: 'session-1', + sourceIdentity: 'host-a\0workspace-a', + enabled: true, + connected: true, + agent: 'codex', + onSendError + } as never) + return null + } + + beforeEach(() => { + vi.clearAllMocks() + listener = null + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + hook = null + }) + + const runningTurn = journalItem(1, { kind: 'turn', turnId: 'turn-1', state: 'running' }) + const reasoning = journalItem(2, { + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Weighing two approaches' }] + }) + + it('reads the live turn as reasoning while reasoning is its newest content', async () => { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).not.toBeNull()) + + act(() => { + listener?.(snapshot([runningTurn, reasoning], 3)) + }) + + expect(hook?.turnIndicator).toEqual({ thinking: true, activityText: null }) + }) + + it('hands the row the provider copy once real content ends the reasoning', async () => { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).not.toBeNull()) + + act(() => { + listener?.( + snapshot( + [ + runningTurn, + reasoning, + journalItem(3, { + kind: 'tool-call', + name: 'shell', + input: { command: 'pnpm lint' }, + state: 'running' + }), + journalItem(4, { kind: 'status', text: 'Updating the plan' }) + ], + 3 + ) + ) + }) + + expect(hook?.turnIndicator).toEqual({ thinking: false, activityText: 'Updating the plan' }) + }) +}) diff --git a/src/main/claude/claude-structured-journal-translation.test.ts b/src/main/claude/claude-structured-journal-translation.test.ts index 44d7e3a1251..7a8ecf54344 100644 --- a/src/main/claude/claude-structured-journal-translation.test.ts +++ b/src/main/claude/claude-structured-journal-translation.test.ts @@ -569,8 +569,11 @@ describe('Claude structured journal translation', () => { translator.handle(message('assistant', 'assistant-thinking', [{ type: 'thinking', thinking }])) expect(state.items.at(-1)?.body).toEqual({ - kind: 'status', - text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + kind: 'message', + role: 'reasoning', + blocks: [ + { type: 'text', text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text } + ] }) }) diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index 478645d62ed..8b71149cba2 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -180,8 +180,11 @@ export function createClaudeJournalTranslator( const thinking = claudeThinkingText(outputEnvelope) if (thinking) { deps.sink.appendItem(claudeThinkingIdentity(envelope.sessionId, envelope.uuid), { - kind: 'status', - text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + kind: 'message', + role: 'reasoning', + blocks: [ + { type: 'text', text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text } + ] }) changed = true } diff --git a/src/main/codex/codex-notice-item-translation.test.ts b/src/main/codex/codex-notice-item-translation.test.ts index 15f638c4015..14a87cb8cc5 100644 --- a/src/main/codex/codex-notice-item-translation.test.ts +++ b/src/main/codex/codex-notice-item-translation.test.ts @@ -22,11 +22,16 @@ describe('plan document translation', () => { expect( codexItemBody({ id: 'r', type: 'reasoning', summary: ['Thinking through the problem.'] }) ).toEqual({ - kind: 'status', - text: 'Thinking through the problem.' + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Thinking through the problem.' }] }) expect(codexStreamingJournalItem({ id: 'r', type: 'reasoning' }, 'Thinking…')).toEqual({ - body: { kind: 'status', text: 'Thinking…' }, + body: { + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Thinking…' }] + }, handled: true }) }) diff --git a/src/main/codex/codex-structured-item-translation.test.ts b/src/main/codex/codex-structured-item-translation.test.ts index 3f8ec524002..53cf94e265c 100644 --- a/src/main/codex/codex-structured-item-translation.test.ts +++ b/src/main/codex/codex-structured-item-translation.test.ts @@ -10,6 +10,7 @@ import { codexItemIdentity, codexJournalItem, codexMessageBlocks, + codexStreamingJournalItem, CodexTurnOrdinals, MAX_CODEX_TURN_ORDINAL_BYTES, MAX_CODEX_TURN_ORDINAL_ENTRIES, @@ -601,12 +602,18 @@ describe('codex item bodies', () => { body: { kind: 'status', text, presentation: 'plan-document' }, handled: true }) + // A plan is a durable artifact, so it must never read as the model reasoning now. + expect(codexItemBody({ type: 'plan', id: 'plan-document', text })).not.toMatchObject({ + kind: 'message', + role: 'reasoning' + }) }) - it('renders reasoning as status and exposes an unknown item as a provider frame', () => { + it('renders reasoning as a typed message and exposes an unknown item as a provider frame', () => { expect(codexItemBody({ type: 'reasoning', id: 'r', text: 'thinking' })).toEqual({ - kind: 'status', - text: 'thinking' + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'thinking' }] }) expect(codexItemBody({ type: 'reasoning', id: 'r' })).toBeNull() expect(codexItemBody({ type: 'agentMessage', id: 'm', text: '' })).toBeNull() @@ -617,6 +624,15 @@ describe('codex item bodies', () => { }) }) + it('keeps non-reasoning item streams as status activity', () => { + expect( + codexStreamingJournalItem({ type: 'somethingCodexAddedLater', id: 'x' }, 'still working') + ).toEqual({ + body: { kind: 'status', text: 'still working' }, + handled: true + }) + }) + it('gives an mcp tool call a typed body with its own arguments as input', () => { expect( codexItemBody({ @@ -843,7 +859,11 @@ describe('codex item bodies', () => { summary: ['first', 'second'], content: [{ text: 'fallback' }] }) - ).toEqual({ kind: 'status', text: 'first\nsecond' }) + ).toEqual({ + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'first\nsecond' }] + }) }) it('refuses a value that is not a thread item at all', () => { diff --git a/src/main/codex/codex-structured-item-translation.ts b/src/main/codex/codex-structured-item-translation.ts index 52d7d5ae47f..34f07eefda7 100644 --- a/src/main/codex/codex-structured-item-translation.ts +++ b/src/main/codex/codex-structured-item-translation.ts @@ -85,6 +85,10 @@ export type CodexJournalItem = { handled: boolean } +function reasoningMessageBody(text: string): AgentJournalItemBody { + return { kind: 'message', role: 'reasoning', blocks: [{ type: 'text', text }] } +} + function commandItem(item: CodexThreadItem): CodexJournalItem { const output = readFirstString(item, ['aggregatedOutput', 'aggregated_output']) const bounded = output === null ? null : boundInlineText(output, DEFAULT_JOURNAL_PAYLOAD_LIMITS) @@ -272,7 +276,7 @@ export function codexJournalItem(item: CodexThreadItem): CodexJournalItem { handled: true } } - if (item.type === 'reasoning' || item.type === 'plan') { + if (item.type === 'reasoning') { const text = readTextContent(item, 'text') ?? readTextContent(item, 'summary') ?? @@ -281,7 +285,7 @@ export function codexJournalItem(item: CodexThreadItem): CodexJournalItem { body: text === null ? null - : { kind: 'status', text: boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text }, + : reasoningMessageBody(boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text), handled: true } } @@ -331,5 +335,11 @@ export function codexStreamingJournalItem(item: CodexThreadItem, text: string): } } const bounded = boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS) - return { body: { kind: 'status', text: bounded.text }, handled: true } + return { + body: + item.type === 'reasoning' + ? reasoningMessageBody(bounded.text) + : { kind: 'status', text: bounded.text }, + handled: true + } } diff --git a/src/main/codex/codex-structured-journal-translation-settlement.test.ts b/src/main/codex/codex-structured-journal-translation-settlement.test.ts index f8a5c7a1671..b5e60699ce1 100644 --- a/src/main/codex/codex-structured-journal-translation-settlement.test.ts +++ b/src/main/codex/codex-structured-journal-translation-settlement.test.ts @@ -799,8 +799,9 @@ describe('codex journal translation', () => { const reduced = new Map(tap.rows.map((row) => [row.key, row.body])) expect(reduced.get('orca:codex-item%3Athread-abc%3Ar-1')).toEqual({ - kind: 'status', - text: 'thinking' + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'thinking' }] }) expect(reduced.get('orca:codex-item%3Athread-abc%3Apatch-1')).toMatchObject({ kind: 'diff', diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx index 9a30aa67eba..d6d74a20304 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx @@ -95,221 +95,6 @@ describe('NativeChatMessageList assistant messages', () => { expect(document.querySelector('.text-destructive')).toBeNull() }) - it('keeps a reduced-motion-safe spinner activity line at the tail of a no-tool Codex turn', () => { - render( - - ) - - const activity = screen.getByText('Working…') - const row = activity.closest('[data-native-chat-turn-activity]') - const spinner = row?.querySelector('svg') - expect(activity).not.toHaveClass('animate-pulse', 'animate-spin') - expect(spinner).toHaveClass('size-4', 'animate-spin', 'motion-reduce:animate-none') - expect(row).toHaveAttribute('aria-live', 'polite') - expect(screen.getByText('The answer is still streaming.').compareDocumentPosition(row!)).toBe( - Node.DOCUMENT_POSITION_FOLLOWING - ) - }) - - it('keeps the broad fallback distinct from the running tool row', () => { - render( - - ) - - const toolLabel = screen.getByText('Running pnpm test') - expect(toolLabel).toHaveClass('animate-pulse') - expect(screen.getAllByText('Running pnpm test')).toHaveLength(1) - const activity = screen.getByText('Working…') - expect(activity.textContent).not.toBe(toolLabel.textContent) - expect(activity).not.toHaveTextContent('shell') - expect(activity).not.toHaveTextContent('pnpm test') - const spinner = activity.closest('[data-native-chat-turn-activity]')?.querySelector('svg') - expect(activity).not.toHaveClass('animate-pulse', 'animate-spin') - expect(spinner).toHaveClass('animate-spin', 'motion-reduce:animate-none') - }) - - it('uses the broad fallback after a tool settles', () => { - render( - - ) - - const settledTool = screen.getByText('shell') - const activity = screen.getByText('Working…') - expect(activity.textContent).not.toBe(settledTool.textContent) - expect(activity).not.toHaveTextContent('shell') - expect(activity).not.toHaveTextContent('pnpm test') - expect(activity).not.toHaveClass('animate-pulse', 'animate-spin') - expect(activity.closest('[data-native-chat-turn-activity]')?.querySelector('svg')).toHaveClass( - 'animate-spin' - ) - }) - - it('keeps a completed tool row static while the turn tail spins, then removes the tail', () => { - const workingSession: NativeChatLiveSession = { - ...session, - status: 'working', - messages: [ - { - id: 'assistant-settled-tool', - role: 'assistant', - blocks: [ - { - type: 'tool-call', - name: 'shell', - input: { command: 'pnpm test' }, - state: 'completed' - }, - { type: 'tool-result', output: 'passed' } - ], - timestamp: 1, - source: 'transcript' - } - ] - } - const { container, rerender } = render( - - ) - - const settledTool = screen.getByText('shell') - expect(settledTool).toHaveTextContent('shell pnpm test') - expect(settledTool.closest('button')?.querySelector('.animate-pulse')).toBeNull() - expect(settledTool.closest('button')?.querySelector('.lucide-check')).toBeInTheDocument() - const activity = screen.getByText('Preparing the answer') - expect(activity).not.toHaveClass('animate-pulse', 'animate-spin') - expect(activity.closest('[data-native-chat-turn-activity]')?.querySelector('svg')).toHaveClass( - 'animate-spin' - ) - - rerender( - - ) - - expect(container.querySelector('[data-native-chat-turn-activity]')).toBeNull() - expect(container.querySelector('.animate-pulse')).toBeNull() - expect(container.querySelector('.animate-spin')).toBeNull() - }) - - it('keeps bridge chats on the legacy activity chrome', () => { - render( - - ) - - expect(screen.queryByText('Thinking')).toBeNull() - expect(screen.queryByRole('button', { name: 'Toggle turn details' })).toBeNull() - expect(screen.queryByText('Running sleep 5')).toBeNull() - expect(document.querySelectorAll('.animate-bounce')).toHaveLength(3) - }) - it('keeps the current tool live when a stale completed lifecycle meets active hook state', () => { render( { expect(screen.getByText('Running sleep 5')).toBeInTheDocument() }) - - it('shows a stable thinking status directly below the user message', () => { - const { container } = render( - - ) - - const user = screen.getByText('Start the task') - const thinking = screen.getByText('Thinking') - expect(user.compareDocumentPosition(thinking)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) - expect(thinking.parentElement).not.toHaveClass('border-b') - expect(thinking.parentElement).toHaveClass('text-sm') - expect(container.querySelector('.animate-bounce')).toBeNull() - expect(thinking).toHaveClass('animate-pulse') - expect(container.querySelectorAll('.size-1.5.animate-pulse')).toHaveLength(0) - }) - - it('places the thinking status directly after the latest user message', () => { - render( - - ) - - const user = screen.getByText('Run the checks') - const status = screen.getByText('Working for 0s') - const assistant = screen.getByText('I am checking now.') - expect(user.compareDocumentPosition(status)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) - expect(status.compareDocumentPosition(assistant)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) - expect(status.parentElement).toHaveClass('border-b') - }) - - it('shows elapsed working time once tool activity starts', () => { - render( - - ) - - expect(screen.getByText('Working for 3s')).toBeInTheDocument() - }) - - it('keeps the completed duration below the user message', () => { - const startedAt = Date.now() - 3000 - const turnSession: NativeChatLiveSession = { - ...session, - status: 'working', - messages: [ - { - id: 'user-complete', - role: 'user', - blocks: [{ type: 'text', text: 'Complete this task' }], - timestamp: startedAt, - source: 'transcript' - }, - { - id: 'assistant-complete', - role: 'assistant', - blocks: [{ type: 'text', text: 'Task complete.' }], - timestamp: Date.now(), - source: 'transcript' - } - ] - } - const { rerender } = render( - - ) - - rerender( - - ) - - const user = screen.getByText('Complete this task') - const status = screen.getByText('Worked for 3s') - const assistant = screen.getByText('Task complete.') - expect(user.compareDocumentPosition(status)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) - expect(status.compareDocumentPosition(assistant)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) - - rerender( - - ) - - expect(screen.getByText('Worked for 3s')).toBeInTheDocument() - expect(screen.getByText('Thinking')).toBeInTheDocument() - }) - - it("uses the completed caret to expand that turn's tool details", () => { - const startedAt = Date.now() - 3000 - render( - - ) - - const status = screen.getByRole('button', { name: 'Toggle turn details' }) - expect(status).toHaveAttribute('aria-expanded', 'false') - expect(screen.queryByRole('button', { name: /1× shell/ })).toBeNull() - fireEvent.click(status) - expect(status).toHaveAttribute('aria-expanded', 'true') - const tool = screen.getByRole('button', { name: /1× shell/ }) - expect(tool).toHaveAttribute('aria-expanded', 'true') - expect(screen.getAllByRole('button', { name: /shell pwd/ })[1]).toHaveAttribute( - 'aria-expanded', - 'false' - ) - }) }) // List-level, because every defect this feature has shipped so far lived in the diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index 6728aa99439..3da6283057a 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -9,11 +9,10 @@ import { nativeChatTaskListPredecessors } from './native-chat-task-list-history' import { NativeChatTaskList } from './NativeChatTaskList' import { projectNativeChatTaskListFrames } from './native-chat-task-list-frames' import { shouldShowNativeChatTypingIndicator } from './native-chat-typing-indicator' -import { NativeChatWorkingStatus } from './NativeChatWorkingStatus' import { useNativeChatTurnStatus } from './use-native-chat-turn-status' import { NativeChatTypingIndicatorRow } from './NativeChatTypingIndicatorRow' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' -import type { NativeChatTurnActivity } from './native-chat-turn-activity' +import type { NativeChatTurnActivity } from '../../../../shared/native-chat-turn-activity' import { NativeChatTurnActivityLine } from './NativeChatTurnActivityLine' import { NativeChatDisclosureContext, @@ -29,6 +28,7 @@ import { useNativeChatTranscriptWindow } from './use-native-chat-transcript-wind import { useNativeChatTranscriptScroll } from './use-native-chat-transcript-scroll' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { isStructuredAgentSessionThinking } from '../../../../shared/structured-agent-session-live-turn' import type { NativeChatSettledTurns } from '../../../../shared/native-chat-turn-status' import { nativeChatTurnDiffs, @@ -150,12 +150,19 @@ export function NativeChatMessageList({ : new Map(), [journalItems, messages, turnKeys] ) + // "Thinking" is real reasoning content at the tail of the turn, not the absence + // of output — the latter reports thinking while the request is merely in flight. + const thinking = useMemo( + () => (journalItems ? isStructuredAgentSessionThinking(journalItems) : false), + [journalItems] + ) const turnStatuses = useNativeChatTurnStatus({ messages, latestUserIndex, isWorking: showTurnStatus && isWorking, workingStartedAt: showTurnStatus ? workingStartedAt : null, - settledTurns: showTurnStatus ? settledTurns : null + settledTurns: showTurnStatus ? settledTurns : null, + thinking }) const lifecycleWorking = session.transcriptLifecycle?.state === 'working' const slots = useMemo( @@ -169,7 +176,6 @@ export function NativeChatMessageList({ turnStatuses, turnDiffs, showTurnStatus, - showTypingIndicator, isWorking, lifecycleWorking }), @@ -181,7 +187,6 @@ export function NativeChatMessageList({ messages, receipts, showTurnStatus, - showTypingIndicator, turnDiffs, turnKeys, turnStatuses @@ -280,18 +285,11 @@ export function NativeChatMessageList({ context={rowContext} window={transcriptWindow} /> - {showTurnStatus && - latestUserIndex === -1 && - turnStatuses.active && - showTypingIndicator ? ( - - ) : null} {showTurnStatus && isWorking ? ( - + ) : null} {!showTurnStatus && showTypingIndicator ? : null} diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx new file mode 100644 index 00000000000..ab706436f91 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx @@ -0,0 +1,563 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import type { NativeChatLiveSession } from './use-native-chat-live-session' +import { NativeChatMessageList } from './NativeChatMessageList' +import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' +import type { + AgentJournalItemBody, + AgentJournalRenderItem +} from '../../../../shared/agent-session-journal-types' + +// The turn record this host writes, and the legacy status row an older host sends. +const turnItem: AgentJournalItemBody = { kind: 'turn', turnId: 'turn-1', state: 'running' } +const legacyTurnRow: AgentJournalItemBody = { + kind: 'status', + text: 'Codex is working…', + turnLifecycle: { turnId: 'turn-1', state: 'running' } +} +const reasoningRow: AgentJournalItemBody = { + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: '' }] +} + +function journalItem(sequence: number, body: AgentJournalItemBody): AgentJournalRenderItem { + return { itemId: `item-${sequence}`, revision: 1, sequence, observedAt: sequence, body } +} + +let restoreViewport = (): void => {} +beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() +}) +afterAll(() => restoreViewport()) +afterEach(cleanup) + +const session: NativeChatLiveSession = { + messages: [ + { + id: 'assistant-1', + role: 'assistant', + blocks: [{ type: 'text', text: 'Selectable agent response.' }], + timestamp: 1, + source: 'transcript' + } + ], + status: 'ready', + sessionId: 'session-1', + agent: 'codex', + hasMore: false, + loadingEarlier: false, + loadEarlier: vi.fn(), + readPhase: 'ready' +} + +// The live turn renders exactly one indicator row; a settled turn keeps its own. +describe('NativeChatMessageList turn indicator', () => { + it('keeps a reduced-motion-safe spinner on the live row of a no-tool Codex turn', () => { + render( + + ) + + const activity = screen.getByText('Working for 0s') + const row = activity.closest('[data-native-chat-turn-activity]') + const spinner = row?.querySelector('svg') + expect(activity).not.toHaveClass('animate-pulse', 'animate-spin') + expect(spinner).toHaveClass('size-4', 'animate-spin', 'motion-reduce:animate-none') + expect(row).toHaveAttribute('aria-live', 'polite') + expect(screen.getByText('The answer is still streaming.').compareDocumentPosition(row!)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ) + }) + + it('keeps the live row distinct from the running tool row', () => { + render( + + ) + + const toolLabel = screen.getByText('Running pnpm test') + expect(toolLabel).toHaveClass('animate-pulse') + expect(screen.getAllByText('Running pnpm test')).toHaveLength(1) + const activity = screen.getByText('Working for 0s') + expect(activity.textContent).not.toBe(toolLabel.textContent) + expect(activity).not.toHaveTextContent('shell') + expect(activity).not.toHaveTextContent('pnpm test') + const spinner = activity.closest('[data-native-chat-turn-activity]')?.querySelector('svg') + expect(activity).not.toHaveClass('animate-pulse', 'animate-spin') + expect(spinner).toHaveClass('animate-spin', 'motion-reduce:animate-none') + }) + + it('keeps the live row up after a tool settles', () => { + render( + + ) + + const settledTool = screen.getByText('shell') + const activity = screen.getByText('Working for 0s') + expect(activity.textContent).not.toBe(settledTool.textContent) + expect(activity).not.toHaveTextContent('shell') + expect(activity).not.toHaveTextContent('pnpm test') + expect(activity).not.toHaveClass('animate-pulse', 'animate-spin') + expect(activity.closest('[data-native-chat-turn-activity]')?.querySelector('svg')).toHaveClass( + 'animate-spin' + ) + }) + + it('keeps a completed tool row static while the turn tail spins, then removes the tail', () => { + const workingSession: NativeChatLiveSession = { + ...session, + status: 'working', + messages: [ + { + id: 'assistant-settled-tool', + role: 'assistant', + blocks: [ + { + type: 'tool-call', + name: 'shell', + input: { command: 'pnpm test' }, + state: 'completed' + }, + { type: 'tool-result', output: 'passed' } + ], + timestamp: 1, + source: 'transcript' + } + ] + } + const { container, rerender } = render( + + ) + + const settledTool = screen.getByText('shell') + expect(settledTool).toHaveTextContent('shell pnpm test') + expect(settledTool.closest('button')?.querySelector('.animate-pulse')).toBeNull() + expect(settledTool.closest('button')?.querySelector('.lucide-check')).toBeInTheDocument() + const activity = screen.getByText('Preparing the answer') + expect(activity).not.toHaveClass('animate-pulse', 'animate-spin') + expect(activity.closest('[data-native-chat-turn-activity]')?.querySelector('svg')).toHaveClass( + 'animate-spin' + ) + + rerender( + + ) + + expect(container.querySelector('[data-native-chat-turn-activity]')).toBeNull() + expect(container.querySelector('.animate-pulse')).toBeNull() + expect(container.querySelector('.animate-spin')).toBeNull() + }) + + it('keeps bridge chats on the legacy activity chrome', () => { + render( + + ) + + expect(screen.queryByText('Thinking')).toBeNull() + expect(screen.queryByRole('button', { name: 'Toggle turn details' })).toBeNull() + expect(screen.queryByText('Running sleep 5')).toBeNull() + expect(document.querySelectorAll('.animate-bounce')).toHaveLength(3) + }) + + it('reads "Thinking" on the one live row while the turn is reasoning', () => { + const { container } = render( + + ) + + const user = screen.getByText('Start the task') + const thinking = screen.getByText('Thinking') + expect(user.compareDocumentPosition(thinking)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + // One indicator, not a "Thinking" row stacked above a spinning "Working…" row. + expect(container.querySelectorAll('[data-native-chat-turn-status]')).toHaveLength(1) + expect(thinking.closest('[data-native-chat-turn-activity]')?.querySelector('svg')).toHaveClass( + 'animate-spin' + ) + expect(container.querySelector('.animate-bounce')).toBeNull() + }) + + it('does not reuse completed-turn reasoning while the next dispatch is pending', () => { + render( + + ) + + expect(screen.queryByText('Thinking')).toBeNull() + expect(screen.getByText('Working for 0s')).toBeInTheDocument() + }) + + it('lets provider activity text beat the reasoning label on the same single row', () => { + const { container } = render( + + ) + + expect(screen.getByText('Exploring the repo layout')).toBeInTheDocument() + expect(screen.queryByText('Thinking')).toBeNull() + expect(container.querySelectorAll('[data-native-chat-turn-status]')).toHaveLength(1) + }) + + it('places the one live row after the newest content in the turn', () => { + render( + + ) + + const status = screen.getByText('Working for 0s') + const assistant = screen.getByText('I am checking now.') + // The live row trails the newest content instead of sitting under the prompt. + expect(assistant.compareDocumentPosition(status)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect(document.querySelectorAll('[data-native-chat-turn-status]')).toHaveLength(1) + }) + + it('shows elapsed working time once tool activity starts', () => { + render( + + ) + + expect(screen.getByText('Working for 3s')).toBeInTheDocument() + }) + + it('keeps the completed duration below the user message', () => { + const startedAt = Date.now() - 3000 + const turnSession: NativeChatLiveSession = { + ...session, + status: 'working', + messages: [ + { + id: 'user-complete', + role: 'user', + blocks: [{ type: 'text', text: 'Complete this task' }], + timestamp: startedAt, + source: 'transcript' + }, + { + id: 'assistant-complete', + role: 'assistant', + blocks: [{ type: 'text', text: 'Task complete.' }], + timestamp: Date.now(), + source: 'transcript' + } + ] + } + const { rerender } = render( + + ) + + rerender( + + ) + + const user = screen.getByText('Complete this task') + const status = screen.getByText('Worked for 3s') + const assistant = screen.getByText('Task complete.') + expect(user.compareDocumentPosition(status)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect(status.compareDocumentPosition(assistant)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + + rerender( + + ) + + expect(screen.getByText('Worked for 3s')).toBeInTheDocument() + expect(screen.getByText('Working for 0s')).toBeInTheDocument() + }) + + it("uses the completed caret to expand that turn's tool details", () => { + const startedAt = Date.now() - 3000 + render( + + ) + + const status = screen.getByRole('button', { name: 'Toggle turn details' }) + expect(status).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByRole('button', { name: /1× shell/ })).toBeNull() + fireEvent.click(status) + expect(status).toHaveAttribute('aria-expanded', 'true') + const tool = screen.getByRole('button', { name: /1× shell/ }) + expect(tool).toHaveAttribute('aria-expanded', 'true') + expect(screen.getAllByRole('button', { name: /shell pwd/ })[1]).toHaveAttribute( + 'aria-expanded', + 'false' + ) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx b/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx index da11773105d..72e11ad25a8 100644 --- a/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx @@ -1,18 +1,55 @@ import { Loader2 } from 'lucide-react' import { translate } from '@/i18n/i18n' -import type { NativeChatTurnActivity } from './native-chat-turn-activity' +import type { NativeChatTurnActivity } from '../../../../shared/native-chat-turn-activity' +import { + describeNativeChatActiveTurnLabel, + NATIVE_CHAT_TURN_STATUS_COPY, + type NativeChatTurnStatus +} from '../../../../shared/native-chat-turn-status' +import { useNativeChatElapsedSeconds } from './use-native-chat-elapsed-seconds' +/** The live turn's one indicator: a spinner plus whatever the turn can say about + * itself — the provider's activity text, else that it is reasoning, else how + * long it has been working. A settled turn keeps its own `NativeChatWorkingStatus` + * row; this one is only ever rendered while the turn is in flight. */ export function NativeChatTurnActivityLine({ - activity + activity, + status }: { activity?: NativeChatTurnActivity | null + status?: NativeChatTurnStatus | null }): React.JSX.Element { - const label = activity?.text ?? translate('components.native-chat.status.working', 'Working…') + const thinking = status?.thinking === true + // The clock only ticks when its number is the label; activity text and + // "Thinking" carry no duration. + const counting = status != null && !thinking && !activity?.text + const elapsedSeconds = useNativeChatElapsedSeconds(status?.startedAt ?? null, counting) + const resolved = describeNativeChatActiveTurnLabel({ + activityText: activity?.text, + thinking, + elapsedSeconds + }) + const label = + resolved.source === 'activity' + ? resolved.text + : status == null + ? translate('components.native-chat.status.working', 'Working…') + : resolved.key === 'thinking' + ? translate( + 'components.native-chat.status.thinking', + NATIVE_CHAT_TURN_STATUS_COPY.thinking + ) + : translate( + 'components.native-chat.status.workingFor', + NATIVE_CHAT_TURN_STATUS_COPY.workingFor, + { value0: resolved.duration } + ) return (
diff --git a/src/renderer/src/components/native-chat/NativeChatWorkingStatus.tsx b/src/renderer/src/components/native-chat/NativeChatWorkingStatus.tsx index e6c38de83f5..8b910daa936 100644 --- a/src/renderer/src/components/native-chat/NativeChatWorkingStatus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatWorkingStatus.tsx @@ -1,13 +1,11 @@ -import { useState } from 'react' import { ChevronRight } from 'lucide-react' import { translate } from '@/i18n/i18n' -import { useNow } from '@/hooks/use-now' import { describeNativeChatTurnStatus, formatNativeChatDuration, - NATIVE_CHAT_TURN_STATUS_COPY, - nativeChatElapsedSeconds + NATIVE_CHAT_TURN_STATUS_COPY } from '../../../../shared/native-chat-turn-status' +import { useNativeChatElapsedSeconds } from './use-native-chat-elapsed-seconds' export { formatNativeChatDuration } @@ -24,15 +22,8 @@ export function NativeChatWorkingStatus({ expanded?: boolean onToggleExpanded?: () => void }): React.JSX.Element { - // Why: elapsed seconds is ordinary render dataflow, not an external system. - // The shared 1s clock is visibility-gated and collapses every in-flight turn - // onto one tick, instead of one interval plus one commit per turn. const counting = !thinking && workedSeconds == null - const now = useNow(1_000, counting) - // Why: preserves the old effect's `startedAt ?? Date.now()` epoch for the - // single frame before the turn's startedAt lands. - const [mountedAt] = useState(() => Date.now()) - const elapsedSeconds = counting ? nativeChatElapsedSeconds(startedAt, mountedAt, now) : 0 + const elapsedSeconds = useNativeChatElapsedSeconds(startedAt, counting) const { key, duration } = describeNativeChatTurnStatus({ thinking, @@ -67,6 +58,7 @@ export function NativeChatWorkingStatus({ return ( | 0:-@html 17:delimiter.curly.svelte@svelte 18:-@typescript 27:delimiter.curly.svelte@svelte 28:-@html 29:delimiter.curly.svelte@svelte 30:-@typescript 35:delimiter.curly.svelte@svelte 36:-@html | embed=html", + "{@html 'raw'} | 0:keyword.control.svelte@svelte 6:-@typescript 21:delimiter.curly.svelte@svelte | embed=none", + " | | embed=html", + " | 0:tag.svelte@svelte | embed=none", ] `) }) -}) -describe('svelte tokenizer regressions', () => { - // Regression: when a Svelte file starts with `{#if}`, `{name}`, or `{@html}`, - // no html embed is active yet. Earlier drafts unconditionally emitted - // `nextEmbedded: '@pop'` from root, which Monaco rejects with - // "cannot pop embedded language if not inside one". The fix splits the - // entry-only `root` state from the html-embedded `markup` state. - it('does not pop a non-existent embed when a file starts with a Svelte block', () => { - const action = findRuleAction('root', '{#if foo}') - expect(action).toMatchObject({ switchTo: '@svelteBlockExpressionEnter' }) - expect(action?.nextEmbedded).toBeUndefined() + // Regression (the field failure): the first interpolation of a file threw + // "cannot pop embedded language if not inside one" — every Svelte file with a + // `{}` in it, which is essentially all of them. + it('highlights every interpolation of a markup line', () => { + const [line] = tokenizeSvelte('

a {first} b {second} c

') + + expect(tokenLanguages(line)).toEqual([ + 'html', + 'svelte', + 'typescript', + 'svelte', + 'html', + 'svelte', + 'typescript', + 'svelte', + 'html' + ]) }) - it('starts the html embed and switches to markup when markup begins', () => { - expect(findRuleAction('root', '

Counter

')).toMatchObject({ - switchTo: '@markup', - nextEmbedded: 'html' - }) + it('opens a file on a Svelte block without popping a missing embed', () => { + // No html embed exists yet at file start, so the block's entry rule must not + // pop one — Monarch throws outright if it does. + const [line] = tokenizeSvelte('{#if count > 0}') + + expect(tokenTypeAt(line, 0)).toBe('keyword.control') + expect(tokenLanguages(line)).toEqual(['svelte', 'typescript', 'svelte']) }) - // Regression: while the html embed is active, only parent rules whose action - // pops the embed are consulted before delegating to html. The first draft - // omitted `nextEmbedded: '@pop'` from ``)).toEqual([ + ['html'], + ['svelte'], + [embeddedLanguageId], + ['svelte'] + ]) + }) + + it.each([ + ['`)).toEqual([ + ['html'], + ['svelte'], + [embeddedLanguageId], + ['svelte'] + ]) + }) +}) + +describe('svelte root state invariant', () => { + // Structural on purpose: behaviour can only reach the root rules some fixture + // happens to exercise, and a root rule that pops an embed throws on the very + // first character of a file. Guard every root rule, exercised or not. + it('has no root rule that pops an embedded language', () => { + const rootRules = (svelteMonarchLanguage.tokenizer as Record).root + const popRules = rootRules.filter( + (rule) => + Array.isArray(rule) && (rule[1] as { nextEmbedded?: string })?.nextEmbedded === '@pop' + ) + + expect(popRules).toEqual([]) }) }) diff --git a/src/renderer/src/lib/monaco-languages/register-vue.test.ts b/src/renderer/src/lib/monaco-languages/register-vue.test.ts index 3898dea7a38..8483b1a9713 100644 --- a/src/renderer/src/lib/monaco-languages/register-vue.test.ts +++ b/src/renderer/src/lib/monaco-languages/register-vue.test.ts @@ -1,110 +1,28 @@ import { describe, expect, it, vi } from 'vitest' +import { + endEmbeddedLanguages, + formatTokenizedLines, + tokenizeMonarchDocument, + tokenLanguages, + tokenLanguagesPerLine +} from './monarch-tokenizer-test-harness' import { registerVueLanguage, vueLanguageConfiguration, vueMonarchLanguage } from './register-vue' -type MonarchAction = { - next?: string - nextEmbedded?: string - switchTo?: string -} -type MonarchRule = [RegExp, string | MonarchAction, string?] | { include: string } - -function normalizeState(nextState: string): string { - return nextState.startsWith('@') ? nextState.slice(1) : nextState +// Driven through the real `MonarchTokenizer`: a rule-table walk cannot tell a +// working grammar from one that throws on every `{{ }}`, which is how broken +// Vue highlighting shipped green. +function tokenizeVue(source: string) { + return tokenizeMonarchDocument('vue', vueMonarchLanguage, source) } -function isRuleEntry(rule: MonarchRule): rule is [RegExp, string | MonarchAction, string?] { - return Array.isArray(rule) +/** Which languages actually cover each line — a dropped embed shows up as `vue`. */ +function languagesPerLine(source: string): string[][] { + return tokenLanguagesPerLine(tokenizeVue(source)) } -function getRuleAction(rule: [RegExp, string | MonarchAction, string?]): MonarchAction | undefined { - const [, action, nextStateShortcut] = rule - return typeof action === 'object' - ? action - : nextStateShortcut - ? { next: nextStateShortcut } - : undefined -} - -function findRuleAction(state: string, source: string): MonarchAction | undefined { - const tokenizer = vueMonarchLanguage.tokenizer as Record - const stateRules = tokenizer[state] ?? tokenizer[state.split('.')[0]] - const matchedRule = stateRules.find((rule) => { - if (!isRuleEntry(rule)) { - return false - } - const [regexp] = rule - regexp.lastIndex = 0 - const match = regexp.exec(source) - return match !== null && match.index === 0 - }) - - return matchedRule && isRuleEntry(matchedRule) ? getRuleAction(matchedRule) : undefined -} - -function collectFixtureRuleActions(source: string): { - line: number - state: string - matched: string - nextState?: string - nextEmbedded?: string - switchTo?: string -}[] { - const ruleActions: { - line: number - state: string - matched: string - nextState?: string - nextEmbedded?: string - switchTo?: string - }[] = [] - const tokenizer = vueMonarchLanguage.tokenizer as Record - const lines = source.split('\n') - const checks: { line: number; state: string; pattern: string }[] = [ - { line: 1, state: 'root', pattern: '' }, - { line: 2, state: 'templateBody', pattern: '{{' }, - { line: 2, state: 'templateExpression', pattern: '}}' }, - { line: 3, state: 'templateBody', pattern: '' }, - { line: 5, state: 'root', pattern: '' }, - { line: 7, state: 'scriptBody.typescript', pattern: '' }, - { line: 9, state: 'root', pattern: '' }, - { line: 11, state: 'styleBody.css', pattern: '' } - ] - - checks.forEach((check) => { - const line = lines.at(check.line - 1) ?? '' - const stateRules = tokenizer[check.state] ?? tokenizer[check.state.split('.')[0]] - const matchedRule = stateRules.find((rule) => { - if (!isRuleEntry(rule)) { - return false - } - const [regexp] = rule - regexp.lastIndex = 0 - const match = regexp.exec(line) - return match !== null && match[0] === check.pattern - }) - if (!matchedRule || !isRuleEntry(matchedRule)) { - return - } - - const actionObject = getRuleAction(matchedRule) - - ruleActions.push({ - line: check.line, - state: check.state, - matched: check.pattern, - nextState: actionObject?.next ? normalizeState(actionObject.next) : undefined, - nextEmbedded: actionObject?.nextEmbedded, - switchTo: actionObject?.switchTo ? normalizeState(actionObject.switchTo) : undefined - }) - }) - - return ruleActions -} - -describe('registerVueLanguage', () => { +describe('registerVueLanguage registration', () => { + // Structural by necessity: covers the registration call itself (ids, + // extensions, idempotence), which tokenizing cannot observe. it('registers the vue language, Monarch tokenizer, and configuration once', () => { const languages: { id: string }[] = [{ id: 'typescript' }] const register = vi.fn((entry: { id: string }) => { @@ -136,8 +54,10 @@ describe('registerVueLanguage', () => { expect(setLanguageConfiguration).toHaveBeenCalledTimes(1) expect(setLanguageConfiguration).toHaveBeenCalledWith('vue', vueLanguageConfiguration) }) +}) - it('captures Vue tokenizer transitions for a representative SFC fixture', () => { +describe('vue tokenization', () => { + it('tokenizes a representative SFC', () => { const fixture = ` @@ -150,121 +70,110 @@ const message = 'hello' p { color: rebeccapurple; } ` - const ruleActions = collectFixtureRuleActions(fixture) - - expect(ruleActions).toMatchInlineSnapshot(` + expect(formatTokenizedLines(tokenizeVue(fixture))).toMatchInlineSnapshot(` [ - { - "line": 1, - "matched": "", - "nextEmbedded": "html", - "nextState": undefined, - "state": "templateOpen", - "switchTo": "templateBody", - }, - { - "line": 2, - "matched": "{{", - "nextEmbedded": "@pop", - "nextState": undefined, - "state": "templateBody", - "switchTo": "templateExpressionEnter", - }, - { - "line": 2, - "matched": "}}", - "nextEmbedded": "@pop", - "nextState": undefined, - "state": "templateExpression", - "switchTo": "templateBodyReenter", - }, - { - "line": 3, - "matched": "", - "nextEmbedded": "@pop", - "nextState": "pop", - "state": "templateBody", - "switchTo": undefined, - }, - { - "line": 5, - "matched": "", - "nextEmbedded": "$S2", - "nextState": undefined, - "state": "scriptOpen.typescript", - "switchTo": "scriptBody.$S2", - }, - { - "line": 7, - "matched": "", - "nextEmbedded": "@pop", - "nextState": "pop", - "state": "scriptBody.typescript", - "switchTo": undefined, - }, - { - "line": 9, - "matched": "", - "nextEmbedded": "$S2", - "nextState": undefined, - "state": "styleOpen.css", - "switchTo": "styleBody.$S2", - }, - { - "line": 11, - "matched": "", - "nextEmbedded": "@pop", - "nextState": "pop", - "state": "styleBody.css", - "switchTo": undefined, - }, + " | 0:tag.vue@vue | embed=none", + " | | embed=none", + " | 0:tag.vue@vue | embed=none", + " | | embed=none", + " | 0:tag.vue@vue | embed=none", ] `) }) - it('tracks embedded languages from Vue block attributes', () => { - expect(findRuleAction('templateExpressionEnter', 'message }}')).toMatchObject({ - nextEmbedded: 'typescript', - switchTo: '@templateExpression' - }) - expect(findRuleAction('scriptLangValue.typescript', '"js"')).toMatchObject({ - switchTo: '@scriptOpen.javascript' - }) - expect(findRuleAction('scriptLangValue.javascript', '"ts"')).toMatchObject({ - switchTo: '@scriptOpen.typescript' - }) - expect(findRuleAction('scriptLangValue.typescript', 'js')).toMatchObject({ - switchTo: '@scriptOpen.javascript' - }) - expect(findRuleAction('styleLangValue.css', '"scss"')).toMatchObject({ - switchTo: '@styleOpen.scss' - }) - expect(findRuleAction('styleLangValue.css', 'less')).toMatchObject({ - switchTo: '@styleOpen.less' - }) + // Regression: every `{{ }}` threw "cannot pop embedded language if not inside + // one" once the template body lost its html embed. + it('highlights every interpolation in a template line', () => { + const [, line] = tokenizeVue('') + + expect(tokenLanguages(line)).toEqual([ + 'html', + 'vue', + 'typescript', + 'vue', + 'html', + 'vue', + 'typescript', + 'vue', + 'html' + ]) + }) + + it('embeds the template body as html', () => { + expect(endEmbeddedLanguages(tokenizeVue(''))).toEqual([ + 'html', + 'html', + null + ]) + }) + + it('keeps the template embedded across a comment before it', () => { + expect(languagesPerLine('\n')).toEqual([ + ['vue'], + ['vue'], + ['html'], + ['vue'] + ]) + }) + + it('does not enter typescript for an empty interpolation', () => { + // `{{}}` pops html on entry but never pushes typescript; the close must + // unwind only the state, or it pops an embed that is not there. + const [, line] = tokenizeVue('') + + expect(tokenLanguages(line)).toEqual(['html', 'vue', 'html']) + }) +}) + +describe('vue embedded language attributes', () => { + it.each([ + ['`)).toEqual([ + ['vue'], + [embeddedLanguageId], + ['vue'] + ]) + }) + + it.each([ + ['`)).toEqual([ + ['vue'], + [embeddedLanguageId], + ['vue'] + ]) + }) +}) + +describe('vue root state invariant', () => { + // Structural on purpose: behaviour can only reach the root rules some fixture + // happens to exercise, and a root rule that pops an embed throws on the very + // first character of a file. Guard every root rule, exercised or not. + it('has no root rule that pops an embedded language', () => { + const rootRules = (vueMonarchLanguage.tokenizer as Record).root + const popRules = rootRules.filter( + (rule) => + Array.isArray(rule) && (rule[1] as { nextEmbedded?: string })?.nextEmbedded === '@pop' + ) + + expect(popRules).toEqual([]) }) }) From 6bb2b0c6d7fb33e974dc534e807689d0f421708b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:07:16 -0700 Subject: [PATCH 005/191] =?UTF-8?q?test(runtime):=20capture=20real=20Antig?= =?UTF-8?q?ravity=20transcripts=20=E2=80=94=20the=20detector=20is=20invert?= =?UTF-8?q?ed=20on=20live=20output=20(#19983)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(runtime): capture real agent PTY transcripts before rewriting Antigravity readiness Antigravity readiness has been written five times against a five-line screen typed from memory. There is no Antigravity transcript in this repository, so every attempt was a guess tested against another guess. This adds the recorder, the protocol and the fixture-driven suite so the sixth attempt can be written against evidence, and changes no detector logic. - config/scripts/capture-agent-pty-transcript.mjs records a live agent session through a real PTY, escapes and wrapping intact. Ctrl-] is consumed by the recorder and never forwarded, which is the only way to end a capture while a dialog still owns the screen. - config/scripts/pty-transcript-secret-scan.mjs finds account identifiers and credentials, redacts them with same-length placeholders so wrapping survives, and recognises its own placeholders so a scrubbed file verifies clean. - src/main/runtime/antigravity-readiness-transcripts.test.ts asserts a verdict per transcript and skips by name until the transcripts land, with a doc-coverage ratchet and a guard that a fixture contains escape bytes. The escape-byte guard exists because the three cursor-agent fixtures carry a comment claiming they were captured verbatim through Orca, yet contain zero ESC bytes and zero carriage returns. That comment is corrected here to say what those files are; the fixtures and the rules built on them are untouched. * test(runtime): capture real Antigravity transcripts, and pin what they prove `agy` 1.1.25 turned out to be installed, so the transcripts this scaffold was built for now exist. Six are recorded from live sessions and committed; the rest are named as skipped, because reaching them would mean signing the operator out or deleting their config. The captures invert the story. On real output the shipped detector refuses a genuinely ready screen and accepts a live `/model` picker: - Antigravity paints a block-glyph logo down the left, so the model row never starts a line. `startsWith('gemini', trimmedStart)` cannot match a real ready screen, on any account or model. Stripping the logo flips the same screen to ready, which means a decorative glyph decides readiness today. - The `/model` picker prints `Gemini 3.x Flash` one per line, at line start, and a bare `>` composer sits earlier in the tail. Both halves of the rule are satisfied while a dialog owns the screen. - For an API-key user the identity row reads `Gemini API key` — no `@`, no domain — and `AGY_CLI_HIDE_ACCOUNT_INFO=1` removes the row entirely. The account-row requirement of attempts 4 and 5 can never pass for those users. - The banner is printed once and never reprinted after a dialog is dismissed, so `headerIndex` cannot be the ordering anchor. Four suite cases are pinned as KNOWN DEFECT: they assert what the detector does so CI stays honest instead of permanently red, and flip to failing the moment someone fixes it. No detector logic changed. The recorder gains `--send ":"` because a dialog capture has to be driven and an unattended run has no TTY, and the scrub scanner gains a UUID rule because agy prints a resumable conversation id on exit. * test(runtime): capture agy mid-turn, and make the scan file reviewable Answers the busy-frame question a P1 review raised against attempt six, with two new captures from a live turn. At the frame level the review is right: a busy frame parks the caret with the same bytes as an idle one, `CR ESC[2A ESC[2C`, and the only differing row — `esc to cancel` versus `? for shortcuts` — is erased by that park. At the retained-tail level it does not reproduce. Each spinner tick is its own repaint with its own `CR ESC[2A`, two rows higher than the frame's, which splices the composer away: a live turn's tail ends on `⣟ Generating...`, with no bare caret to match. A constructed input that keeps the park and edits only the status text is not faithful, because a live turn has a spinner row repainting below the composer. The residual is the gap between a frame park and the next tick, where the tail does end on the bare caret. Quiescence-gated paths are safe there because ticks keep arriving; text-only paths are not, and for those the capture supports one clause: a braille glyph on the last visible line means working. That predicate already exists here for cursor-agent and should be reused, scoped to the last line — a first-run transcript prints `⠾ Signing in...` during startup. Also in this commit, from the same review: - pty-transcript-secret-scan.mjs held raw 0x00-0x1f bytes in a character class, so the one file gating real PTY data into history was binary to git and unreviewable in a diff. It now tests codepoints, which the formatter cannot fold back into control bytes. - Pin `src/main/runtime/__fixtures__/*.txt` as -text. A Windows checkout would otherwise normalise line endings and rewrite the CR bytes that make these files evidence. The recorder now stops appending at the stop moment rather than through shutdown: an agent repaints an idle frame on its way out, which was overwriting the mid-turn state the capture existed to record. * test(tooling): allowlist the transcript scan test in the batch-shim ratchet pty-transcript-secret-scan.test.mjs asserts that the capture recorder routes an 'agy.cmd' shim through cmd.exe, so the shim literal it names is the assertion, not a spawn. Fits the existing assert-on-shim-files category. --- .gitattributes | 4 + .gitignore | 2 + AGENTS.md | 4 + .../scripts/capture-agent-pty-transcript.mjs | 283 ++++++++++++++++++ config/scripts/pty-transcript-secret-scan.mjs | 135 +++++++++ .../pty-transcript-secret-scan.test.mjs | 133 ++++++++ .../windows-cmd-shim-spawn-boundary.test.mjs | 1 + .../reference/agent-pty-transcript-capture.md | 129 ++++++++ .../antigravity-readiness-evidence.md | 263 ++++++++++++++++ package.json | 1 + .../antigravity-busy-mid-turn.meta.json | 9 + .../antigravity-busy-mid-turn.txt | 38 +++ .../antigravity-busy-turn-ended.meta.json | 9 + .../antigravity-busy-turn-ended.txt | 42 +++ ...tigravity-dialog-command-palette.meta.json | 9 + .../antigravity-dialog-command-palette.txt | 41 +++ .../antigravity-dialog-dismissed.meta.json | 9 + .../antigravity-dialog-dismissed.txt | 54 ++++ .../antigravity-dialog-model-picker.meta.json | 9 + .../antigravity-dialog-model-picker.txt | 56 ++++ ...tigravity-dialog-trust-workspace.meta.json | 9 + .../antigravity-dialog-trust-workspace.txt | 12 + ...ravity-ready-account-info-hidden.meta.json | 9 + .../antigravity-ready-account-info-hidden.txt | 13 + ...avity-ready-api-key-gemini-model.meta.json | 9 + ...antigravity-ready-api-key-gemini-model.txt | 13 + .../agent-transcript-pane-test-harness.ts | 79 +++++ .../antigravity-readiness-transcripts.test.ts | 281 +++++++++++++++++ ...rminal-interactive-wait-visibility.test.ts | 83 +---- 29 files changed, 1665 insertions(+), 74 deletions(-) create mode 100644 config/scripts/capture-agent-pty-transcript.mjs create mode 100644 config/scripts/pty-transcript-secret-scan.mjs create mode 100644 config/scripts/pty-transcript-secret-scan.test.mjs create mode 100644 docs/reference/agent-pty-transcript-capture.md create mode 100644 docs/reference/antigravity-readiness-evidence.md create mode 100644 src/main/runtime/__fixtures__/antigravity-busy-mid-turn.meta.json create mode 100644 src/main/runtime/__fixtures__/antigravity-busy-mid-turn.txt create mode 100644 src/main/runtime/__fixtures__/antigravity-busy-turn-ended.meta.json create mode 100644 src/main/runtime/__fixtures__/antigravity-busy-turn-ended.txt create mode 100644 src/main/runtime/__fixtures__/antigravity-dialog-command-palette.meta.json create mode 100644 src/main/runtime/__fixtures__/antigravity-dialog-command-palette.txt create mode 100644 src/main/runtime/__fixtures__/antigravity-dialog-dismissed.meta.json create mode 100644 src/main/runtime/__fixtures__/antigravity-dialog-dismissed.txt create mode 100644 src/main/runtime/__fixtures__/antigravity-dialog-model-picker.meta.json create mode 100644 src/main/runtime/__fixtures__/antigravity-dialog-model-picker.txt create mode 100644 src/main/runtime/__fixtures__/antigravity-dialog-trust-workspace.meta.json create mode 100644 src/main/runtime/__fixtures__/antigravity-dialog-trust-workspace.txt create mode 100644 src/main/runtime/__fixtures__/antigravity-ready-account-info-hidden.meta.json create mode 100644 src/main/runtime/__fixtures__/antigravity-ready-account-info-hidden.txt create mode 100644 src/main/runtime/__fixtures__/antigravity-ready-api-key-gemini-model.meta.json create mode 100644 src/main/runtime/__fixtures__/antigravity-ready-api-key-gemini-model.txt create mode 100644 src/main/runtime/agent-transcript-pane-test-harness.ts create mode 100644 src/main/runtime/antigravity-readiness-transcripts.test.ts diff --git a/.gitattributes b/.gitattributes index 1aa7969e805..1b447a9189e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -31,6 +31,10 @@ # the reviewable change, and pin LF because they are compared byte-for-byte. # Not -diff: the shell diff is the review surface when a wrapper does change. /src/main/__fixtures__/shell-wrapper-snapshots/*.txt linguist-generated=true text eol=lf +# Captured agent PTY transcripts. -text, not `text eol=lf` like the wrapper snapshots above: +# these carry real CR and CRLF bytes as the terminal emitted them, and line-ending +# normalisation on a Windows checkout would rewrite the evidence the fixture exists to be. +/src/main/runtime/__fixtures__/*.txt -text # Generated runtime English subset: compared byte-for-byte by # verify:localization-runtime-catalog, so a CRLF checkout would fail the gate. /src/renderer/src/i18n/en-runtime-required.json linguist-generated=true text eol=lf diff --git a/.gitignore b/.gitignore index 913dfc4a045..e5207a25015 100644 --- a/.gitignore +++ b/.gitignore @@ -103,7 +103,9 @@ docs/** !docs/agent-skill-sharing-implementation-checklist.md !docs/mobile-terminal-shortcut-bar.md !docs/reference/ +!docs/reference/agent-pty-transcript-capture.md !docs/reference/agent-status-store.md +!docs/reference/antigravity-readiness-evidence.md !docs/reference/git-compatibility.md !docs/reference/headless-linux-server.md !docs/reference/ime-regression-checklist.md diff --git a/AGENTS.md b/AGENTS.md index 5ff66b95b0f..f1ce31e404b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,10 @@ All changes must consider folder workspaces as well as git worktrees. Don't assu The execution host owns agent status in one store, the hook server's, and every reader (sidebar, `worktree ps`, mobile, dashboard) subscribes to it. Before adding a producer, a cache, or a reader-side precedence rule, read [`docs/reference/agent-status-store.md`](./docs/reference/agent-status-store.md): new producers write into that store, and readers keep only presentation policy. +## Agent Terminal Screens + +A rule that reads what an agent CLI paints on a terminal — readiness, blocked prompts, idle — must be written against a captured transcript, not a remembered screen. Record one with [`docs/reference/agent-pty-transcript-capture.md`](./docs/reference/agent-pty-transcript-capture.md), which keeps escapes and wrapping intact and scrubs account identifiers before they reach git. Antigravity readiness has no transcript yet and five failed attempts without one; before touching it, read [`docs/reference/antigravity-readiness-evidence.md`](./docs/reference/antigravity-readiness-evidence.md). + ## Remote Wire Compatibility Clients and remote Orca servers update independently, so mixed versions are the normal state. Before changing anything a paired client and host exchange — RPC params, stream frames, or the content either side publishes over them — follow [`docs/reference/remote-wire-compatibility.md`](./docs/reference/remote-wire-compatibility.md). A new optional field is safe; a new stream opcode must be capability-negotiated because decoders drop unknown opcodes silently; and changing what the host publishes reaches old clients even with no wire change. diff --git a/config/scripts/capture-agent-pty-transcript.mjs b/config/scripts/capture-agent-pty-transcript.mjs new file mode 100644 index 00000000000..a60d0adbdd5 --- /dev/null +++ b/config/scripts/capture-agent-pty-transcript.mjs @@ -0,0 +1,283 @@ +/** + * Records a live agent CLI session through a real PTY into a test fixture, bytes intact. + * + * Why a PTY and not `agy | tee`: a pipe is not a terminal, so the CLI renders its + * non-interactive path — no alternate screen, no caret, no dialogs. The detector under + * test only ever sees the PTY shape, so that is the only shape worth capturing. + * + * Nothing here strips escapes, folds CRs, or rewraps lines: the transcript is written + * exactly as the terminal received it. See docs/reference/agent-pty-transcript-capture.md. + */ +import { createWriteStream, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { + formatFindings, + redactTranscript, + scanTranscriptForSecrets +} from './pty-transcript-secret-scan.mjs' + +const REPO_ROOT = resolve(import.meta.dirname, '..', '..') +const FIXTURE_DIR = join(REPO_ROOT, 'src', 'main', 'runtime', '__fixtures__') +const STOP_KEY = 0x1d // Ctrl-], consumed by the recorder and never forwarded to the agent. +const NAME_RE = /^[a-z0-9][a-z0-9-]*$/ + +const USAGE = `Capture a raw agent PTY transcript into src/main/runtime/__fixtures__/. + + node config/scripts/capture-agent-pty-transcript.mjs --name [options] -- [args...] + node config/scripts/capture-agent-pty-transcript.mjs --scan [--redact] + +Options + --name Output fixture name, e.g. antigravity-ready-personal-non-gemini + --out Write somewhere other than the fixture directory + --cols --rows Pin the PTY size (default: this terminal's size, else 120x40) + --duration Stop unattended after N seconds + --send ":" Type into the PTY at (repeatable; \\r \\n \\t \\e escapes) + --note "" Recorded in the .meta.json sidecar + --scan Scan existing transcripts for identifiers/credentials and exit + --redact With --scan: rewrite each finding as a same-length placeholder + +Press Ctrl-] to end a capture. That key is consumed here, so the agent keeps whatever +dialog it is showing — which is the only way to capture a dialog that owns the screen.` + +function parseArgs(argv) { + const options = { cols: null, rows: null, duration: null, scan: [], sends: [], redact: false } + const command = [] + let cursor = 0 + let afterSeparator = false + while (cursor < argv.length) { + const arg = argv[cursor] + if (afterSeparator) { + command.push(arg) + cursor += 1 + continue + } + if (arg === '--') { + afterSeparator = true + } else if (arg === '--redact') { + options.redact = true + } else if (arg === '--help' || arg === '-h') { + options.help = true + } else if (arg === '--scan') { + while (cursor + 1 < argv.length && !argv[cursor + 1].startsWith('--')) { + cursor += 1 + options.scan.push(argv[cursor]) + } + } else if (arg === '--send') { + cursor += 1 + options.sends.push(parseSend(argv[cursor])) + } else if (arg.startsWith('--')) { + const key = arg.slice(2) + cursor += 1 + options[key] = argv[cursor] + } + cursor += 1 + } + for (const key of ['cols', 'rows', 'duration']) { + options[key] = options[key] == null ? null : Number(options[key]) + } + return { options, command } +} + +// String.fromCharCode, not a literal: the formatter rewrites an escape sequence into a raw +// control byte in source, which is unreadable and survives badly in diffs. +const ESC = String.fromCharCode(27) +const SEND_ESCAPES = { r: '\r', n: '\n', t: '\t', e: ESC, '\\': '\\' } + +/** `":"` — a keystroke to deliver at a fixed offset, for an unattended dialog capture. */ +function parseSend(value) { + const separator = String(value ?? '').indexOf(':') + if (separator === -1) { + throw new Error(`--send expects ":", got ${String(value)}`) + } + const atMs = Number(value.slice(0, separator)) + if (!Number.isFinite(atMs)) { + throw new Error( + `--send delay must be a number of milliseconds, got ${value.slice(0, separator)}` + ) + } + const text = value + .slice(separator + 1) + .replace(/\\(.)/g, (whole, code) => SEND_ESCAPES[code] ?? whole) + return { atMs, text } +} + +function runScan(files, redact) { + let failed = false + for (const file of files) { + const path = resolve(file) + const text = readFileSync(path, 'utf8') + if (redact) { + const { text: redacted, redacted: count } = redactTranscript(text) + writeFileSync(path, redacted) + console.log(`${file}: redacted ${count} span(s) in place, same length each.`) + continue + } + const findings = scanTranscriptForSecrets(text) + console.log(formatFindings(file, findings)) + failed ||= findings.length > 0 + } + return failed ? 1 : 0 +} + +function resolveSpawn(command) { + // node-pty cannot run a .cmd/.bat shim directly on Windows; those need cmd.exe. + if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(command[0])) { + return { file: 'cmd.exe', args: ['/c', `"${command[0]}"`, ...command.slice(1)] } + } + return { file: command[0], args: command.slice(1) } +} + +async function runCapture(options, command) { + const name = options.name + if (typeof name === 'string' && !NAME_RE.test(name)) { + console.error(`--name must be lowercase kebab-case; got ${name}`) + return 2 + } + const outPath = options.out ? resolve(options.out) : join(FIXTURE_DIR, `${name}.txt`) + mkdirSync(dirname(outPath), { recursive: true }) + + const pty = await import('node-pty').catch((error) => { + console.error( + `node-pty failed to load. Build it for plain node first: + node config/scripts/ensure-native-runtime.mjs --runtime=node +${String(error)}` + ) + return null + }) + if (pty === null) { + return 2 + } + + const cols = options.cols ?? process.stdout.columns ?? 120 + const rows = options.rows ?? process.stdout.rows ?? 40 + const { file, args } = resolveSpawn(command) + const term = pty.spawn(file, args, { + name: 'xterm-256color', + cols, + rows, + cwd: process.cwd(), + env: { ...process.env, TERM: 'xterm-256color' }, + encoding: null + }) + + const sink = createWriteStream(outPath) + let recording = true + term.onData((chunk) => { + const bytes = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk + // Why recording stops before the kill: an agent repaints an idle frame on its way out, so + // a transcript that keeps writing through shutdown ends on that frame instead of on the + // state you stopped to capture. A mid-turn or dialog capture cannot survive that. + if (recording) { + sink.write(bytes) + } + process.stdout.write(bytes) + }) + + const wasRaw = process.stdin.isTTY === true && process.stdin.isRaw === true + if (process.stdin.isTTY) { + process.stdin.setRawMode(true) + } + process.stdin.resume() + let stopping = false + const stop = () => { + if (stopping) { + return + } + stopping = true + recording = false + try { + term.kill() + } catch { + // The agent may have exited on its own; the transcript is already on disk. + } + } + process.stdin.on('data', (chunk) => { + if (chunk.includes(STOP_KEY)) { + stop() + return + } + term.write(chunk.toString('binary')) + }) + // Why scripted input: a dialog capture has to be driven, and CI (or an agent) has no TTY to + // type into. The keystrokes ride the same PTY a human's would, so the capture is unchanged. + const sendTimers = options.sends.map((send) => setTimeout(() => term.write(send.text), send.atMs)) + const durationTimer = options.duration === null ? null : setTimeout(stop, options.duration * 1000) + + const exitCode = await new Promise((resolveExit) => { + term.onExit(({ exitCode: code }) => resolveExit(code ?? 0)) + }) + for (const timer of sendTimers) { + clearTimeout(timer) + } + if (durationTimer !== null) { + clearTimeout(durationTimer) + } + if (process.stdin.isTTY) { + process.stdin.setRawMode(wasRaw) + } + process.stdin.pause() + await new Promise((done) => sink.end(done)) + + writeMeta(outPath, { command, cols, rows, note: options.note ?? null, exitCode }) + const findings = scanTranscriptForSecrets(readFileSync(outPath, 'utf8')) + console.log(`\nTranscript: ${outPath}`) + console.log(formatFindings('scrub check', findings)) + if (findings.length > 0) { + console.log( + `Scrub with: + node config/scripts/capture-agent-pty-transcript.mjs --scan ${outPath} --redact` + ) + } + return 0 +} + +function writeMeta(outPath, details) { + const metaPath = outPath.replace(/\.txt$/, '.meta.json') + writeFileSync( + metaPath, + `${JSON.stringify( + { + capturedAt: new Date().toISOString(), + platform: process.platform, + command: details.command, + cols: details.cols, + rows: details.rows, + note: details.note, + exitCode: details.exitCode + }, + null, + 2 + )}\n` + ) +} + +async function main() { + const { options, command } = parseArgs(process.argv.slice(2)) + if (options.help === true) { + console.log(USAGE) + return 0 + } + if (options.scan.length > 0) { + return runScan(options.scan, options.redact) + } + if (command.length === 0 || (options.name === undefined && options.out === undefined)) { + console.error(USAGE) + return 2 + } + return runCapture(options, command) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().then( + (code) => { + process.exitCode = code + }, + (error) => { + console.error(error) + process.exitCode = 1 + } + ) +} + +export { parseArgs, resolveSpawn } diff --git a/config/scripts/pty-transcript-secret-scan.mjs b/config/scripts/pty-transcript-secret-scan.mjs new file mode 100644 index 00000000000..1d93204ccda --- /dev/null +++ b/config/scripts/pty-transcript-secret-scan.mjs @@ -0,0 +1,135 @@ +// Finds account identifiers and credentials in a captured PTY transcript before it is committed. +import os from 'node:os' + +// Why same-length replacements: a transcript's value is its exact wrapping and column +// alignment. Shortening a redacted span reflows the screen and destroys the evidence. +const EMAIL_DOMAIN = '@example.com' +const PLACEHOLDER_UUID = '00000000-0000-4000-8000-000000000000' + +/** Ordered most-specific first; the first pattern to claim a span owns it. */ +function buildPatterns() { + const username = os.userInfo().username + const hostname = os.hostname() + const patterns = [ + { kind: 'jwt', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}/g }, + { kind: 'google-api-key', re: /\bAIza[0-9A-Za-z_-]{20,}/g }, + { kind: 'google-refresh-token', re: /\b1\/\/[0-9A-Za-z_-]{20,}/g }, + { kind: 'vendor-key', re: /\b(?:sk-|ghp_|gho_|github_pat_|xoxb-|xoxp-)[A-Za-z0-9_-]{16,}/g }, + { kind: 'bearer-token', re: /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/gi }, + { kind: 'email', re: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g }, + // Why a UUID counts: agy prints a resumable conversation id on exit, and installation and + // project ids look the same. They identify the operator's session, not just its shape. + { kind: 'uuid', re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi }, + { kind: 'opaque-token', re: /\b[A-Za-z0-9_-]{40,}\b/g } + ] + if (username.length >= 3) { + patterns.splice(5, 0, { kind: 'local-username', re: literalPattern(username) }) + } + if (hostname.length >= 3) { + patterns.splice(5, 0, { kind: 'local-hostname', re: literalPattern(hostname) }) + } + return patterns +} + +function literalPattern(value) { + return new RegExp(value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g') +} + +/** + * @param {string} text raw transcript, escapes intact + * @returns {{kind: string, line: number, column: number, index: number, match: string}[]} + */ +export function scanTranscriptForSecrets(text) { + const claimed = [] + const findings = [] + for (const { kind, re } of buildPatterns()) { + re.lastIndex = 0 + let match = re.exec(text) + while (match !== null) { + const start = match.index + const end = start + match[0].length + if (!claimed.some(([from, to]) => start < to && end > from)) { + claimed.push([start, end]) + if (!isAlreadyScrubbed(kind, match[0])) { + findings.push({ kind, index: start, match: match[0], ...locate(text, start) }) + } + } + match = re.exec(text) + } + } + return findings.sort((left, right) => left.index - right.index) +} + +// Why: a scrubbed fixture must verify clean, so this scanner has to recognise its own +// placeholders — otherwise "prove it's gone" can never pass and the check gets ignored. +const PLACEHOLDER_DOMAIN_RE = /@(?:example\.(?:com|org|net)|localhost)$/i + +function isAlreadyScrubbed(kind, match) { + if (kind === 'email') { + return PLACEHOLDER_DOMAIN_RE.test(match) + } + if (kind === 'uuid') { + return match.toLowerCase() === PLACEHOLDER_UUID + } + return /^(.)\1*$/.test(match) +} + +function locate(text, index) { + let line = 1 + let lineStart = 0 + for (let cursor = 0; cursor < index; cursor += 1) { + if (text.charCodeAt(cursor) === 10) { + line += 1 + lineStart = cursor + 1 + } + } + return { line, column: index - lineStart + 1 } +} + +/** Same-length stand-in so redaction cannot reflow the captured screen. */ +export function placeholderFor(kind, length) { + if (kind === 'uuid' && length === PLACEHOLDER_UUID.length) { + return PLACEHOLDER_UUID + } + if (kind === 'email' && length > EMAIL_DOMAIN.length) { + return 'u'.repeat(length - EMAIL_DOMAIN.length) + EMAIL_DOMAIN + } + return kind === 'local-username' || kind === 'local-hostname' + ? 'x'.repeat(length) + : 'X'.repeat(length) +} + +/** @returns {{text: string, redacted: number}} */ +export function redactTranscript(text) { + const findings = scanTranscriptForSecrets(text) + let out = '' + let cursor = 0 + for (const finding of findings) { + out += text.slice(cursor, finding.index) + out += placeholderFor(finding.kind, finding.match.length) + cursor = finding.index + finding.match.length + } + return { text: out + text.slice(cursor), redacted: findings.length } +} + +export function formatFindings(label, findings) { + if (findings.length === 0) { + return `${label}: clean — no account identifier or credential shapes found.` + } + const rows = findings.map( + (finding) => ` ${finding.line}:${finding.column} ${finding.kind} ${preview(finding.match)}` + ) + return [`${label}: ${findings.length} finding(s) — scrub before committing.`, ...rows].join('\n') +} + +// Why a codepoint test and not a character class: a control-byte range written as an escape is +// folded back into raw 0x00-0x1f bytes by the formatter, which makes this file binary to the VCS +// and leaves the one file gating real PTY data into history unreviewable in a diff. +function preview(value) { + const head = value.length <= 24 ? value : `${value.slice(0, 21)}...` + let printable = '' + for (const char of head) { + printable += (char.codePointAt(0) ?? 0) < 0x20 ? '?' : char + } + return printable +} diff --git a/config/scripts/pty-transcript-secret-scan.test.mjs b/config/scripts/pty-transcript-secret-scan.test.mjs new file mode 100644 index 00000000000..2d3cd894da0 --- /dev/null +++ b/config/scripts/pty-transcript-secret-scan.test.mjs @@ -0,0 +1,133 @@ +// The scrub gate is the only thing standing between a live agent transcript and a +// committed account identifier, so it is pinned on the shapes those transcripts carry. +import { readdirSync, readFileSync } from 'node:fs' +import os from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + formatFindings, + placeholderFor, + redactTranscript, + scanTranscriptForSecrets +} from './pty-transcript-secret-scan.mjs' +import { parseArgs, resolveSpawn } from './capture-agent-pty-transcript.mjs' + +describe('pty transcript secret scan', () => { + it('finds the account row of a ready screen', () => { + const findings = scanTranscriptForSecrets('Antigravity CLI 1.1.17\njin.woo@acme.dev (Business)') + expect(findings).toHaveLength(1) + expect(findings[0]).toMatchObject({ kind: 'email', line: 2, column: 1 }) + }) + + it('finds credentials an agent may echo while signing in', () => { + const kinds = scanTranscriptForSecrets( + [ + 'token: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dBjftJeZ4CVP', + 'key: AIzaSyA1234567890abcdefghijklmnopqrstu', + 'refresh: 1//0gLm34XyZabcdefghijklmnopqrstuvwx', + 'Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345' + ].join('\n') + ).map((finding) => finding.kind) + expect(kinds).toEqual(['jwt', 'google-api-key', 'google-refresh-token', 'bearer-token']) + }) + + it('flags this machine’s own username, which a prompt line leaks', () => { + const username = os.userInfo().username + const findings = scanTranscriptForSecrets(`~/Users/${username}/orca/repo\n> `) + expect(findings.some((finding) => finding.kind === 'local-username')).toBe(true) + }) + + it('finds the resumable conversation id agy prints on exit', () => { + const findings = scanTranscriptForSecrets( + 'Resume with -c (or command below):\nagy --conversation=26dc1986-9eec-456a-a534-d93e5c1076c2' + ) + expect(findings).toHaveLength(1) + expect(findings[0].kind).toBe('uuid') + expect(placeholderFor('uuid', findings[0].match.length)).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/ + ) + }) + + it('reports a clean transcript as clean', () => { + const findings = scanTranscriptForSecrets('Antigravity CLI 1.1.17\nSonnet 4.6 (High)\n> ') + expect(findings).toEqual([]) + expect(formatFindings('fixture', findings)).toContain('clean') + }) + + it('claims a span once, so a token inside an email is not double-reported', () => { + const findings = scanTranscriptForSecrets('longlivedaccountname@corp.internal') + expect(findings).toHaveLength(1) + }) + + it('passes a fixture that is already scrubbed, so "prove it is gone" can succeed', () => { + const scrubbed = `uuuu@example.com\n${'X'.repeat(44)}` + expect(scanTranscriptForSecrets(scrubbed)).toEqual([]) + }) +}) + +describe('redaction', () => { + it('replaces every finding with the same number of characters', () => { + // Why length matters: the fixture's value is its exact wrapping. A shorter + // replacement reflows the screen and invalidates the capture. + const text = 'Antigravity CLI 1.1.17\njin.woo@acme.dev (Antigravity Business)\n> ' + const { text: redacted, redacted: count } = redactTranscript(text) + expect(count).toBe(1) + expect(redacted).toHaveLength(text.length) + expect(redacted).not.toContain('jin.woo@acme.dev') + expect(scanTranscriptForSecrets(redacted)).toEqual([]) + expect(redactTranscript(redacted).redacted).toBe(0) + }) + + it('keeps a redacted email shaped like an email', () => { + expect(placeholderFor('email', 'a@b.example.com'.length)).toMatch(/^u+@example\.com$/) + }) + + it('leaves the rest of the screen byte-for-byte untouched', () => { + const text = 'line one\nuser@corp.io\nline three' + expect(redactTranscript(text).text.split('\n')[2]).toBe('line three') + }) +}) + +describe('committed transcripts', () => { + // Why in CI and not just in the recorder: a transcript is committed once and read forever. + // The capture-time warning is skippable; this is not. + const fixtureDir = join(import.meta.dirname, '..', '..', 'src', 'main', 'runtime', '__fixtures__') + const transcripts = readdirSync(fixtureDir).filter((entry) => entry.endsWith('.txt')) + + it.each(transcripts)('%s carries no account identifier or credential', (name) => { + const findings = scanTranscriptForSecrets(readFileSync(join(fixtureDir, name), 'utf8')) + expect(formatFindings(name, findings)).toContain('clean') + }) +}) + +describe('capture argv', () => { + it('splits recorder options from the agent command', () => { + const { options, command } = parseArgs([ + '--name', + 'antigravity-ready-personal-non-gemini', + '--cols', + '120', + '--', + 'agy', + '--model', + 'sonnet' + ]) + expect(options.name).toBe('antigravity-ready-personal-non-gemini') + expect(options.cols).toBe(120) + expect(command).toEqual(['agy', '--model', 'sonnet']) + }) + + it('collects a multi-file scan list', () => { + const { options } = parseArgs(['--scan', 'a.txt', 'b.txt', '--redact']) + expect(options.scan).toEqual(['a.txt', 'b.txt']) + expect(options.redact).toBe(true) + }) + + it('routes a Windows shim through cmd.exe, which node-pty cannot spawn directly', () => { + expect(resolveSpawn(['agy.cmd', '--model', 'sonnet'])).toEqual( + process.platform === 'win32' + ? { file: 'cmd.exe', args: ['/c', '"agy.cmd"', '--model', 'sonnet'] } + : { file: 'agy.cmd', args: ['--model', 'sonnet'] } + ) + }) +}) diff --git a/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs b/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs index a8c2cb3f4e7..253605781cf 100644 --- a/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs +++ b/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs @@ -46,6 +46,7 @@ const WINDOWS_SHIM_SPAWN_ALLOWLIST = [ 'config/scripts/electron-builder-config.test.mjs', 'config/scripts/ensure-native-runtime.test.mjs', 'config/scripts/live-remote-freeze-rpc.mjs', + 'config/scripts/pty-transcript-secret-scan.test.mjs', 'config/scripts/remote-agent-session-authority-repro.mjs', // Platform-local build paths; the win32 branch is dead code on both. 'config/scripts/build-mac-local.mjs', diff --git a/docs/reference/agent-pty-transcript-capture.md b/docs/reference/agent-pty-transcript-capture.md new file mode 100644 index 00000000000..934f0028a93 --- /dev/null +++ b/docs/reference/agent-pty-transcript-capture.md @@ -0,0 +1,129 @@ +# Capturing an agent PTY transcript + +Orca's readiness and blocked-prompt rules are text rules over what an agent CLI paints on a +terminal. They are only as good as the screens they were written against. This is how to record +one, byte for byte, so a rule can be pinned to evidence instead of to a remembered screen. + +Related: [`antigravity-readiness-evidence.md`](./antigravity-readiness-evidence.md) names the +specific Antigravity transcripts that are still missing and what each one decides. + +## The recorder + +``` +node config/scripts/capture-agent-pty-transcript.mjs --name [options] -- [args...] +``` + +It allocates a real PTY, spawns the agent inside it, mirrors the session to your terminal so you +can drive it by hand, and appends every byte it receives to +`src/main/runtime/__fixtures__/.txt`. It does not strip escapes, fold `\r`, rewrap +lines, or normalise anything — the file is what the terminal received. + +- **Ending a capture:** press Ctrl+]. The recorder consumes that key and + never forwards it, which is the only way to end a capture _while a dialog still owns the + screen_. Quitting the agent instead would first dismiss the dialog you came to record. +- `--cols N --rows M` pin the PTY size (default: your terminal's). Wrapping is part of the + evidence, so record the size — the sidecar does it for you. +- `--duration S` stops unattended after S seconds, for a screen that needs no interaction. +- `--send ":"` types into the PTY at a fixed offset, repeatable, with `\r` `\n` `\t` `\e` + escapes. A dialog capture has to be driven, and an unattended run (CI, or an agent) has no TTY to + type into; the keystrokes ride the same PTY a human's would. For example, the committed + `antigravity-dialog-model-picker.txt` was recorded with + `--duration 24 --send "14000:/model" --send "16000:\r"`, which leaves the picker owning the + screen when the capture stops. +- `--note ""` records the account type, plan, model and CLI version in the sidecar. +- `--out ` writes outside the fixture directory (use it for a first dry run). + +Each capture also writes `.meta.json` with the timestamp, platform, command, +PTY size, note and exit code. Commit it with the transcript; the version and account type behind +a screen are not recoverable from the bytes. + +**Prerequisite:** `node-pty` must be built for plain Node: + +``` +node config/scripts/ensure-native-runtime.mjs --runtime=node +``` + +Orca itself does not need to be running, and the recorder never touches Orca state. + +### Platform notes + +- **macOS / Linux:** nothing special. `TERM=xterm-256color` is set for the child. +- **Windows:** run it from Windows Terminal / PowerShell, not a Git Bash (MSYS) pane — MSYS + rewrites arguments that start with `/`, which mangles the `cmd.exe /c` hand-off. A `.cmd` or + `.bat` agent shim cannot be spawned by node-pty directly, so the recorder routes those through + `cmd.exe` for you. +- **WSL:** capture _inside_ the distro (run the recorder from the distro's checkout). Recording + `wsl.exe` from the Windows side adds the login-shell banner to the transcript. +- **SSH:** record on the execution host. A transcript recorded locally is not evidence about what + a remote agent prints. + +## Privacy: scrub before committing + +A live agent screen routinely contains things that must not enter git history: + +| Scrub | Why | +| ---------------------------------------------------------------------- | ---------------------------------------------------- | +| Account email / sign-in identifier | The account row on a ready screen prints it verbatim | +| Org, tenant or team name | Identifies a customer | +| Machine hostname and OS username | Appear in prompts, paths and the OSC title | +| Absolute home paths (`/Users/`, `C:\Users\`) | Contain the username | +| JWTs, `AIza…` keys, `1//…` refresh tokens, `Bearer …`, `sk-…`, `ghp_…` | Live credentials; a sign-in screen can echo one | +| Private repo, branch and ticket names | Leak roadmap detail | +| Anything you pasted into the agent during the capture | You typed it; it is in the transcript | + +The recorder scans the file as soon as the capture ends and prints every hit with a line and +column. To scrub: + +``` +node config/scripts/capture-agent-pty-transcript.mjs --scan src/main/runtime/__fixtures__/.txt --redact +``` + +Redaction replaces each finding with a **same-length** placeholder (`u…u@example.com`, `XXXX…`). +Length matters: a transcript's value is its exact wrapping and column alignment, and a shorter +replacement reflows the screen and destroys the evidence. + +### Verify it is gone + +1. `node config/scripts/capture-agent-pty-transcript.mjs --scan src/main/runtime/__fixtures__/.txt` + must print `clean` and exit `0`. It recognises its own placeholders, so a scrubbed file passes. +2. Grep for the specifics the scanner cannot know: + `rg -n -i -- "$(whoami)|||" src/main/runtime/__fixtures__/.txt` +3. Read it once with escapes visible: `LC_ALL=C cat -v src/main/runtime/__fixtures__/.txt`. + The scanner matches shapes; only a human catches a project name. +4. Check the sidecar too — `--note` text is free-form and is committed. + +`config/scripts/pty-transcript-secret-scan.test.mjs` re-scans every committed +`__fixtures__/*.txt`, so a transcript that skips step 1 fails the suite. + +## Consuming a transcript in a test + +Feed the raw bytes through the runtime rather than into a matcher directly: escape handling, +tail retention and title tracking all live in `onPtyData`, and a rule tested on pre-normalised +text is tested on something no pane ever sees. + +`src/main/runtime/agent-transcript-pane-test-harness.ts` builds the pane; +`src/main/runtime/terminal-interactive-wait-visibility.test.ts` (cursor-agent) and +`src/main/runtime/antigravity-readiness-transcripts.test.ts` (Antigravity) are the two consumers. + +## Worked example: the Antigravity captures + +The six committed `antigravity-*.txt` fixtures were recorded this way on macOS against +`agy` 1.1.25. Two points generalise: + +- **Reach a state without mutating the operator's config.** The ready-screen captures ran in a + directory the CLI already trusted, so no trust answer was written. Where a dialog could only be + reached by signing the operator out or deleting their settings, it was left uncaptured and + recorded as such rather than forced. +- **An environment variable is a legitimate capture knob** where a setting is not. + `AGY_CLI_HIDE_ACCOUNT_INFO=1` produced a second ready screen with no account row, which is + evidence no amount of reasoning about the first screen could have supplied. It changes nothing + on disk. + +## Known gap in the existing captures + +The three `cursor-agent-*.txt` fixtures contain **no escape bytes and no carriage returns**. +Whatever produced them went through a renderer and a clipboard, so they preserve wording and +box-drawing glyphs but not the caret, the cursor moves, the repaints, or whether the CLI uses the +alternate screen buffer. They are good enough for the wording-based rules built on them and are +not evidence for anything else. New captures made with this recorder keep those bytes; the +Antigravity scaffold asserts their presence so a pasted screen cannot pass as a capture. diff --git a/docs/reference/antigravity-readiness-evidence.md b/docs/reference/antigravity-readiness-evidence.md new file mode 100644 index 00000000000..0010fa76ded --- /dev/null +++ b/docs/reference/antigravity-readiness-evidence.md @@ -0,0 +1,263 @@ +# Antigravity readiness: what the transcripts show + +`findAntigravityReadyPromptIndex` in `src/main/runtime/terminal-wait-detection.ts` decides whether +an Antigravity pane is ready for a prompt. It has been written five times, each version tuned +against a five-line screen typed from memory into a `.spec.ts` fixture. Three of the first four +were found worse than the bug they replaced, and the fifth was reverted. + +Real transcripts now exist. They were recorded from a live `agy` on macOS with +[`agent-pty-transcript-capture.md`](./agent-pty-transcript-capture.md) and are committed under +`src/main/runtime/__fixtures__/`. `src/main/runtime/antigravity-readiness-transcripts.test.ts` +replays them through the runtime. + +**Headline: on real output the current detector is inverted.** It refuses a genuinely ready screen +and accepts a live model picker. The five attempts argued about which extra condition to add; none +of them had noticed that the condition they all shared — a line beginning with the model name — +never matches a real Antigravity ready screen at all. + +## Versions + +| Thing | Value | +| ------------------------- | ----------------------------- | +| `agy --version` | `1.1.25` | +| Banner printed by the TUI | `Antigravity CLI 1.2.0` | +| Captured | 2026-09-10, macOS, 120x40 PTY | + +The binary and its own banner disagree. Any rule keyed to a version string must read the banner, +not `--version`, and must tolerate the two disagreeing. + +## What the captures are + +| Fixture | What it is | +| -------------------------------------------- | --------------------------------------------------------- | +| `antigravity-ready-api-key-gemini-model.txt` | Ready screen, API-key identity, Gemini 3.7 Flash (Low) | +| `antigravity-ready-account-info-hidden.txt` | The same ready screen with `AGY_CLI_HIDE_ACCOUNT_INFO=1` | +| `antigravity-dialog-trust-workspace.txt` | Workspace trust dialog, live and unanswered | +| `antigravity-dialog-model-picker.txt` | `/model` picker, live and unanswered | +| `antigravity-dialog-command-palette.txt` | Slash-command palette, live and unanswered | +| `antigravity-dialog-dismissed.txt` | `/model` picker dismissed with esc, then settled | +| `antigravity-busy-mid-turn.txt` | A real turn, recording stopped while the spinner was live | +| `antigravity-busy-turn-ended.txt` | The same turn after it ended and the composer returned | + +## What could not be captured, and why + +Nothing below was faked. Each is a case the recorder could not reach without changing the +operator's account state or configuration, which is out of bounds. + +| Missing | Why | +| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `antigravity-ready-business-non-gemini.txt` | This machine has no OAuth session — the CLI prints _"You are currently not signed in"_ and authenticates from `GEMINI_API_KEY`. Reaching a Business ready screen means signing someone in. | +| A non-Gemini model on any ready screen | `agy models` offers 11 models, all Gemini, and `settings.json` pins `modelProvider: gemini`. A non-Gemini row is not reachable from this account. | +| `antigravity-dialog-sign-in.txt` | Unsetting `GEMINI_API_KEY` does not reach the sign-in dialog; the CLI refuses to start because `modelProvider` is pinned. Reaching it means editing the operator's `settings.json`. | +| `antigravity-dialog-theme-picker.txt` | There is no `/theme` command in 1.2.0 (`Unknown command: /theme`). The picker appears only in first-run onboarding, which means deleting the operator's config. | +| `antigravity-dialog-privacy-notice.txt` | First-run onboarding, as above. | +| `antigravity-dialog-update-banner.txt` | Cannot be forced; no update was pending during the session. | + +Each remains as a named, skipping case in the suite so it is visible rather than forgotten. + +## What the transcripts show + +### 1. The ready screen's model row is not at the start of a line + +The ready screen prints a block-glyph logo down the left, and the identity, model and path rows are +painted **on the same physical lines as the logo**. What Orca derives is: + +``` +▀▀▀▀▀▀ Gemini API key +▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low) +▄▀▀ ▀▀▄ ~ +``` + +The detector requires `normalized.startsWith('gemini', trimmedStart)` on a trimmed line. The +trimmed line starts with `▀`. It never matches. Measured three ways on the real screen: + +| Input | `isKnownReadyPromptPreview` | +| ------------------------------------------------------ | --------------------------- | +| Real ready screen | `false` | +| The same screen with the logo glyphs stripped | `true` | +| Real ready screen followed by the live `/model` picker | `true` | + +So the logo — decoration, and suppressible with `AGY_CLI_HIDE_LOGO` — is what decides readiness +today, and the live dialog is what supplies the model line the ready screen could not. + +### 2. The dialog is what satisfies the model rule + +`/model` prints its options one per line: + +``` +Gemini 3.8 Flash +> Gemini 3.7 Flash (current) +Gemini 3.1 Pro +``` + +Those lines _do_ begin with `Gemini`, and a bare `>` composer line sits earlier in the same tail +from before the picker opened. Both halves of the rule are satisfied **while a dialog owns the +screen**, and the pane reads ready. This is the false-ready hazard the last three attempts were +each trying to close, reproduced from a real capture. + +### 3. `>` is the dialog selection marker, not only the composer caret + +Every dialog uses `>` to mark the highlighted row: `> Yes, I trust this folder`, +`> Gemini 3.7 Flash (current)`, `> /add-dir`. The idle composer is a line whose whole trimmed +content is `>`. That distinction is the only thing separating them, which means the relaxation +proposed in PRs #15840 and #15852 — accept any line _beginning_ with `>` — would make the trust +dialog and the model picker read as ready. On 1.2.0 the idle composer is a bare `>`; those PRs' +1.1.17 mode-banner claim could not be reproduced here and may be mode-specific. + +### 4. There is no email account row, and the row can be switched off entirely + +For an API-key user the identity row reads literally `Gemini API key`. There is no `@`, no +domain, nothing an account-row rule can key on. Separately, `AGY_CLI_HIDE_ACCOUNT_INFO=1` — a +supported environment variable in the binary — removes the row from a fully ready screen, which +`antigravity-ready-account-info-hidden.txt` captures. + +### 5. Dialogs are drawn two different ways, and the banner is never reprinted + +The trust dialog and the sign-in splash take the **alternate screen** (`ESC[?1049h` … `ESC[?1049l`). +The model picker and command palette are drawn **in place on the main screen** with erase-to-EOL. +After dismissal the CLI prints `⎿ Exited /model command` and redraws the composer — it does **not** +reprint the banner. The header stays where it was at startup. + +### 6. Rows are positioned with cursor addressing, not newlines + +The status row is written with absolute and relative moves (`ESC[13;99H`, `ESC[83X ESC[83C`), so +`? for shortcuts` and `Gemini 3.7 Flash · low` end up on one derived line. Any rule that assumes +one screen row equals one `\n`-delimited line is reading a different document than the user sees. + +## 8. Busy frames park the caret exactly like idle frames — the spinner is what differs + +The frame that ends a turn-in-progress and the frame that ends an idle screen park the cursor with +the **same bytes**. Only the hint row differs, and the park erases it: + +``` +idle: ? for shortcuts ESC[83X ESC[83C Gemini 3.7 Flash · low CR ESC[2A ESC[2C ESC[?25h +busy: esc to cancel ESC[85X ESC[85C Gemini 3.7 Flash · low CR ESC[2A ESC[2C ESC[?25h +``` + +So a rule that keys on "the caret is the last thing in the tail" cannot tell busy from idle **on the +frame alone**. What saves it is what comes next. Each spinner tick is its own repaint with its own +park, two rows higher than the frame's: + +``` +ESC[?25l CR ESC[2A ⣯ Generating ESC[11D ESC[?25h +ESC[?25l CR ESC[2A ⣟ Generating. ESC[12D ESC[?25h +``` + +That second `CR ESC[2A` splices the composer row away, so the retained tail during a live turn ends +on the spinner row, not on the caret. Measured on `antigravity-busy-mid-turn.txt`: + +| Capture | last retained line | bare `>` line present | +| -------------------------------------------- | ------------------ | --------------------- | +| `antigravity-ready-api-key-gemini-model.txt` | `>` | **yes** | +| `antigravity-busy-mid-turn.txt` | `⣟ Generating...` | **no** | + +**Consequence for a caret-based rule:** it already answers "not ready" for a real mid-turn capture, +because there is no bare caret in the tail to match. A constructed input that keeps the park bytes +and only edits the status text is not faithful to a live turn — a live turn has a spinner row +repainting _below_ the composer. + +**The residual window, and the clause it implies.** Between a frame park and the next spinner tick +the tail does end on the bare caret and is indistinguishable from idle. The gap is one tick +interval. Any readiness path gated on sustained quiescence is safe, because ticks keep arriving and +the pane is never quiet; a path that only inspects retained text is not. For those paths the +evidence supports one clause, and only one: + +> **A braille glyph (U+2800–U+28FF) on the last visible line of the retained tail means working.** + +That predicate already exists in this file for cursor-agent (`CURSOR_BUSY_SPINNER_RE`) and should be +reused rather than reinvented. It must be scoped to the **last visible line**, not the whole tail: +a first-run transcript prints `⠾ Signing in...` during startup, which would otherwise pin a ready +screen as busy forever. + +Nothing else in the capture distinguishes the two states. The hint row (`esc to cancel` versus +`? for shortcuts`) is erased by the park in both cases, the park offsets are identical, and +`ESC[?25l`/`ESC[?25h` fencing appears around every repaint, idle or busy. + +## Confirmed / refuted, by attempt + +Evidence column names the fixture; all quoted text is from the committed transcripts. + +### Attempt 1 — the rule at HEAD + +| # | Claim | Verdict | Evidence | +| ---- | -------------------------------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1.1 | A ready screen prints the banner `Antigravity CLI` | **Confirmed** | `Antigravity CLI 1.2.0` in both ready fixtures | +| 1.1b | …and its last occurrence in the tail is the live one | **Refuted** | The trust dialog's own body says _"Antigravity CLI requires permission to read, edit, and execute files here"_, so `lastIndexOf` lands inside the dialog | +| 1.2 | The model row begins with the vendor word `Gemini` | **Refuted** | `▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low)` — the logo precedes it; never at line start | +| 1.3 | The caret line's whole trimmed content is `>` | **Confirmed** on 1.2.0 idle | bare `>` in both ready fixtures | +| 1.3b | …and only the composer prints `>` | **Refuted** | `> Yes, I trust this folder`, `> Gemini 3.7 Flash (current)`, `> /add-dir` | +| 1.4 | A ready screen prints the workspace path on its own line | **Refuted** | the path shares its line with logo glyphs (`▄▀▀ ▀▀▄ ~`) | + +### Attempt 2 (loop 1) — blacklist the model line + +| # | Claim | Verdict | Evidence | +| --- | ------------------------------------------ | ----------- | ---------------------------------------------------------------------------------------------------------------- | +| 2.1 | Dialog model-row wording is enumerable | **Refuted** | the palette lists 50+ commands with free-form descriptions; the picker prints whatever models the account offers | +| 2.2 | A dialog never reproduces a real model row | **Refuted** | the `/model` picker prints four real model rows, one per line, at line start | + +### Attempt 3 (loop 2) — structural ordering on `headerIndex` + +| # | Claim | Verdict | Evidence | +| --- | -------------------------------------------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------- | +| 3.1 | A live dialog is printed below the ready chrome | **Confirmed** for in-place dialogs | picker and palette append below the composer | +| 3.2 | The banner is reprinted when a dialog is dismissed | **Refuted** | `antigravity-dialog-dismissed.txt` shows `⎿ Exited /model command` and a redrawn composer, no banner | +| 3.3 | Antigravity does not use the alternate screen | **Refuted** | `ESC[?1049h` opens the trust dialog and the sign-in splash | +| 3.4 | No full repaint per keystroke | **Partly refuted** | typing `/mod` repaints the palette region on each keystroke with `ESC[K` | + +Because of 3.2, `headerIndex` cannot be the anchor: it never advances. Ordering can only be +expressed against the model/caret positions, which is what 1.2 and 1.3b just invalidated. + +### Attempt 4 (loop 3) — require a positive account row + +| # | Claim | Verdict | Evidence | +| --- | ---------------------------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| 4.1 | Every ready screen prints an account row | **Refuted, twice** | API-key identity prints `Gemini API key` (no `@`); `AGY_CLI_HIDE_ACCOUNT_INFO=1` removes the row entirely | +| 4.2 | A startup dialog never contains an `@`-and-`.` token | **Not reachable here** | none of the captured dialogs contains one, but the palette shows free-form skill descriptions, which are user-authored text | +| 4.3 | The account row is distinguishable from prose | **Refuted** | the row is not a distinct line; it shares one with the logo | + +### Attempt 5 (PR #19749, reverted) — ordering + account row + +| # | Claim | Verdict | Evidence | +| --- | -------------------------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 5.1 | Ordering plus an account row separates ready from dialog | **Refuted** | the account row is optional (4.1) and the ordering anchor never moves (3.2) | +| 5.2 | Executing both builds was sufficient verification | **Refuted** | the executed input was the hand-written fixture, so the check reproduced the fixture's assumptions. The real screen disagrees with that fixture on the model row, the path row and the account row | +| 5.3 | The wedge is a model-name problem | **Refuted** | it is a line-start problem. Even `Gemini 3.7 Flash (Low)` — a Gemini model — fails, because a logo glyph precedes it | + +### Cross-cutting + +| # | Question | Answer | +| --- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| X1 | Does `agy` set an OSC title distinguishing busy from idle? | **No.** Not one OSC title sequence appears in any capture. Title-based readiness is unavailable for this agent | +| X2 | Does it repaint with bare `\r`? | **Yes**, constantly, plus `ESC[K` and absolute cursor moves | +| X3 | Does the caret survive in the tail? | **Yes** — a bare `>` line is present in every ready capture | +| X4 | Banner-to-caret distance | ~8 derived lines on a 120x40 PTY; the banner falls outside the 6-line preview window, so only the full retained tail can see it | +| X5 | Pane title on the trust screen versus ready | Identical: none | + +## Can attempt six be written? + +Yes — but not as a variation on any of the five. Every one of them refined a predicate over +`\n`-delimited lines, and that is the layer where the evidence says the information is not. + +What the captures support: + +- **The one stable, dialog-free ready marker is a line whose entire trimmed content is `>`.** It is + present in every ready capture and absent from every dialog capture, because a dialog's `>` always + carries its selected row's label. This is a much narrower rule than any attempt used, and it is + the only one that survived contact with the transcripts. +- **Drop the model-row requirement.** It matches dialogs and not ready screens. Keeping it inverted + the detector. +- **Do not require an account row.** It is optional by environment variable and carries no email for + API-key users. +- **Do not anchor on `headerIndex`.** The banner is printed once and never reprinted. +- **The blocked-signal path already works** for the trust dialog: `antigravity-dialog-trust-workspace.txt` + is correctly refused today, by wording, not by structure. + +What is still unknown and should be captured before shipping: the sign-in, theme, privacy and +update dialogs, and any ready screen where the composer is not idle (accept-edits and plan mode, +which PRs #15840 and #15852 describe from a screenshot). A bare-`>` rule is only as good as the +claim that those modes still end on a bare `>`; that claim is untested. + +The honest summary is that this is a screen-shaped problem being solved with line-shaped tools. A +rule over the derived tail can be made much better than what ships today, but the durable fix is to +ask the terminal emulator what the bottom row of the screen actually is, rather than inferring it +from a byte stream that was written with cursor addressing. diff --git a/package.json b/package.json index 9feaad74882..4f03d793aa6 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "test": "node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts", "test:skill-sharing:release": "vitest run --config config/vitest.config.ts src/main/skills src/main/runtime/rpc/methods/skills.test.ts src/relay/skill-install-handler.test.ts src/shared/skill-bundle-install-contract.test.ts src/shared/skill-install-contract.test.ts src/shared/skill-install-failure.test.ts src/shared/skill-package-manifest.test.ts", "test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs", + "capture:agent-transcript": "node config/scripts/ensure-native-runtime.mjs --runtime=node && node config/scripts/capture-agent-pty-transcript.mjs", "check:reliability-gates": "node config/scripts/check-reliability-gates.mjs", "check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs", "check:ts-nocheck-ratchet": "node config/scripts/check-ts-nocheck-ratchet.mjs", diff --git a/src/main/runtime/__fixtures__/antigravity-busy-mid-turn.meta.json b/src/main/runtime/__fixtures__/antigravity-busy-mid-turn.meta.json new file mode 100644 index 00000000000..4e875eb047a --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-busy-mid-turn.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-11T06:10:52.713Z", + "platform": "darwin", + "command": ["agy"], + "cols": 120, + "rows": 40, + "note": "agy TUI 1.2.0; recording stopped ~0.3s after submit, while the spinner was live; no shutdown repaint in the file", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-busy-mid-turn.txt b/src/main/runtime/__fixtures__/antigravity-busy-mid-turn.txt new file mode 100644 index 00000000000..8f3645800f7 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-busy-mid-turn.txt @@ -0,0 +1,38 @@ +[?2026$p[?2027$p[>4m[=0;1u[?1049h[?25l[?5W[?2004h[>4;2m[=1;1u[?u +▄▀▀▄ +▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀ + ▄▀▀ ▀▀▄ + ▄▀▀ ▀▀▄ + + Welcome to the Antigravity CLI. You are currently not signed in. + + ⣾ Signing in... No authentication methods available. + + Press ctrl+c or ctrl+d twice to exit.[>4m[=0;1u[?1049l[>4;2m[=1;1u[?u[0 q  +▄▀▀▄ Antigravity CLI 1.2.0 +▀▀▀▀▀▀ Gemini API key +▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low) + ▄▀▀ ▀▀▄ ~ + ▄▀▀ ▀▀▄ + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +? for shortcutsGemini 3.7 Flash · low [?25h[?25lI[?25h[?25ln ab + + G[?25h[?25lout 8[?25h[?25l0 wo[?25h[?25lrds,[?25h[?25lexpla[?25h[?25lin w[?25h[?25lhat a[?25h[?25l pse[?25h[?25lud[?25h[?25loter[?25h[?25lminal[?25h[?25l is.[?25h[?25l[?25h[?25l + +? for shortcuts[?25h[?25lM +> In about 80 words, explain what a pseudoterminal is. +⣷ Generating... +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +esc to cancelGemini 3.7 Flash · low [?25h[?25lng + +[?25h[?25l ⣯ Generating + +[?25h[?25l ⣟ Generating. + +[?25h \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/antigravity-busy-turn-ended.meta.json b/src/main/runtime/__fixtures__/antigravity-busy-turn-ended.meta.json new file mode 100644 index 00000000000..084be8e54bc --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-busy-turn-ended.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-11T06:13:00.364Z", + "platform": "darwin", + "command": ["agy"], + "cols": 120, + "rows": 40, + "note": "agy TUI 1.2.0; recording stopped after the turn ended and the composer returned, with the process still alive. This account's API key cannot complete a turn, so the turn ends in a backend error", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-busy-turn-ended.txt b/src/main/runtime/__fixtures__/antigravity-busy-turn-ended.txt new file mode 100644 index 00000000000..e10de85d361 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-busy-turn-ended.txt @@ -0,0 +1,42 @@ +[?2026$p[?2027$p[?5W[?2004h[>4;2m[=1;1u[?u[0 q  +▄▀▀▄ Antigravity CLI 1.2.0 +▀▀▀▀▀▀ Gemini API key +▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low) + ▄▀▀ ▀▀▄ ~ + ▄▀▀ ▀▀▄ + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +? for shortcutsGemini 3.7 Flash · low [?25h[?25lIn + + G[?25h[?25labo[?25h[?25lut 80[?25h[?25l wo[?25h[?25lrds[?25h[?25l, ex[?25h[?25lpla[?25h[?25lin wh[?25h[?25lat a[?25h[?25lpseudo[?25h[?25ltermi[?25h[?25lnal is[?25h[?25l.[?25h[?25l + +? for shortcuts[?25h[?25lM +> In about 80 words, explain what a pseudoterminal is. +⣾ Generating... +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +esc to cancelGemini 3.7 Flash · low [?25h[?25l ⣷ Generatin + +[?25h[?25l ⣯ Generating + +[?25h[?25l ⣟ Generating. + +[?25h[?25l ⡿ Generating... + +[?25h[?25l ⢿ Generatin + +[?25h[?25l  +⚠ Agent execution terminated due to error. +Error ID: 00000000-0000-4000-8000-000000000000-2 +⢿ Generating... +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +esc to cancelGemini 3.7 Flash · low [?25h[?25l  + + + +? for shortcuts[?25h \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/antigravity-dialog-command-palette.meta.json b/src/main/runtime/__fixtures__/antigravity-dialog-command-palette.meta.json new file mode 100644 index 00000000000..e098a1677ab --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-dialog-command-palette.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-11T04:34:32.974Z", + "platform": "darwin", + "command": ["agy"], + "cols": 120, + "rows": 40, + "note": "agy TUI 1.2.0; slash-command palette live, unanswered", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-dialog-command-palette.txt b/src/main/runtime/__fixtures__/antigravity-dialog-command-palette.txt new file mode 100644 index 00000000000..9bf02cc0ff9 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-dialog-command-palette.txt @@ -0,0 +1,41 @@ +[?2026$p[?2027$p[?5W[?2004h[>4;2m[=1;1u[?u[0 q  +▄▀▀▄ Antigravity CLI 1.2.0 +▀▀▀▀▀▀ Gemini API key +▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low) + ▄▀▀ ▀▀▄ ~ + ▄▀▀ ▀▀▄ + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +? for shortcutsGemini 3.7 Flash · low [?25h[?25l/ + +> /add-dir  Add a directory to the workspace + /agents List available custom agents + /artifact View and review artifacts + /btw Ask a side question without interrupting the current task + /changelog Show release notes and changes + ↓ 50 more + + ↑/↓ Navigate · enter Select · tab Complete + Gemini 3.7 Flash · low [?25h[?25l + + + + + + + + + +esc to cancel[?25h[>4m[=0;1u + + + + + + + + + +[?2004l[0 q \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/antigravity-dialog-dismissed.meta.json b/src/main/runtime/__fixtures__/antigravity-dialog-dismissed.meta.json new file mode 100644 index 00000000000..8e8d5043fdf --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-dialog-dismissed.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-11T04:35:06.866Z", + "platform": "darwin", + "command": ["agy"], + "cols": 120, + "rows": 40, + "note": "agy TUI 1.2.0; /model picker opened then dismissed with esc, settled before stop", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-dialog-dismissed.txt b/src/main/runtime/__fixtures__/antigravity-dialog-dismissed.txt new file mode 100644 index 00000000000..bb35ae33af2 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-dialog-dismissed.txt @@ -0,0 +1,54 @@ +[?2026$p[?2027$p[?5W[?2004h[>4;2m[=1;1u[?u[0 q  +▄▀▀▄ Antigravity CLI 1.2.0 +▀▀▀▀▀▀ Gemini API key +▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low) + ▄▀▀ ▀▀▄ ~ + ▄▀▀ ▀▀▄ + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +? for shortcutsGemini 3.7 Flash · low [?25h[?25l/mod + +> /model Set a model, or run a single prompt on another model + /permissioned-github Guidelines for interacting with GitHub and request permissions from the user when commands f... + + ↑/↓ Navigate · enter Select · tab Complete +esc to cancelGemini 3.7 Flash · low [?25h[?25l + + + + +/model + +  + + ↑/↓ Navigate · enter Select · tab Complete +esc to cancelGemini 3.7 Flash · low [?25h[?25l[0 q + +Switch Model + + Gemini 3.8 Flash +> Gemini 3.7 Flash (current) + Gemini 3.6 Flash + Gemini 3.1 Pro + + Effort ◂  ◉──────────────○──────────────○  ▸ +  low  medium high  + Faster responses, lighter reasoning — great for simpler tasks + +Keyboard: ↑/↓ Navigate ←/→ Effort enter Select esc Go Back + + Gemini 3.7 Flash · low [0 q> /model + ⎿ Exited /model command + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +Gemini 3.7 Flash · low [?25h[?25l + +? for shortcuts[?25h[>4m[=0;1u + +[?2004l[0 q +Resume with -c (or command below): +agy --conversation=00000000-0000-4000-8000-000000000000 diff --git a/src/main/runtime/__fixtures__/antigravity-dialog-model-picker.meta.json b/src/main/runtime/__fixtures__/antigravity-dialog-model-picker.meta.json new file mode 100644 index 00000000000..9a4e5c0c8e1 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-dialog-model-picker.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-11T04:34:10.855Z", + "platform": "darwin", + "command": ["agy"], + "cols": 120, + "rows": 40, + "note": "agy TUI 1.2.0; /model picker live, unanswered, killed while it owns the screen", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-dialog-model-picker.txt b/src/main/runtime/__fixtures__/antigravity-dialog-model-picker.txt new file mode 100644 index 00000000000..6a09f6082f8 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-dialog-model-picker.txt @@ -0,0 +1,56 @@ +[?2026$p[?2027$p[?5W[?2004h[>4;2m[=1;1u[?u[0 q  +▄▀▀▄ Antigravity CLI 1.2.0 +▀▀▀▀▀▀ Gemini API key +▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low) + ▄▀▀ ▀▀▄ ~ + ▄▀▀ ▀▀▄ + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +? for shortcutsGemini 3.7 Flash · low [?25h[?25l/mo + +> /model Set a model, or run a single prompt on another model + /migrate-workflows Automatically migrate legacy workflows to modern skills across global and workspace configur... + /permissions Manage tool permissions + /agy-customizations Comprehensive guide and reference for the Antigravity Customization System. Use to explain h... + /permissioned-github Guidelines for interacting with GitHub and request permissions from the user when commands f... + + ↑/↓ Navigate · enter Select · tab Complete +? for shortcutsGemini 3.7 Flash · low [?25h[?25l + + + + +/model + +  + + ↑/↓ Navigate · enter Select · tab Complete +esc to cancelGemini 3.7 Flash · low [?25h[?25l[0 q + +Switch Model + +> Gemini 3.8 Flash + Gemini 3.7 Flash (current) + Gemini 3.6 Flash + Gemini 3.1 Pro + + Effort ◂  ●━━━━━━━━━━━━━━◉──────────────○  ▸ +  low  medium  high  + Balanced speed and reasoning quality for most tasks + +Keyboard: ↑/↓ Navigate ←/→ Effort enter Select esc Go Back + +? for shortcutsGemini 3.7 Flash · low  Gemini 3.8 Flash +> Gemini 3.7 Flash + + + +◂  ◉──────────────○ + low  medium  +Faster responses, lighter reasoning — great for simpler tasks + + + +  G[>4m[=0;1u [?25h[?2004l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/antigravity-dialog-trust-workspace.meta.json b/src/main/runtime/__fixtures__/antigravity-dialog-trust-workspace.meta.json new file mode 100644 index 00000000000..07fb15ab6f7 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-dialog-trust-workspace.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-11T04:35:20.989Z", + "platform": "darwin", + "command": ["agy"], + "cols": 120, + "rows": 40, + "note": "agy TUI 1.2.0; workspace trust dialog live and unanswered in a throwaway untrusted directory", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-dialog-trust-workspace.txt b/src/main/runtime/__fixtures__/antigravity-dialog-trust-workspace.txt new file mode 100644 index 00000000000..b2e1b342199 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-dialog-trust-workspace.txt @@ -0,0 +1,12 @@ +[?2026$p[?2027$p[>4m[=0;1u[?1049h[?25l[?5W[?2004h[>4;2m[=1;1u[?uAccessing workspace: + +/private/tmp/agy-trust-scratch-77950 + +Do you trust the contents of this project? + +Antigravity CLI requires permission to read, edit, and execute files here. + +> Yes, I trust this folder + No, exit + + ↑/↓ Navigate · enter ConfirmGemini 3.7 Flash · low[>4m[=0;1u [?1049l[?25h[?2004l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/antigravity-ready-account-info-hidden.meta.json b/src/main/runtime/__fixtures__/antigravity-ready-account-info-hidden.meta.json new file mode 100644 index 00000000000..9607841cf6e --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-ready-account-info-hidden.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-11T04:33:34.954Z", + "platform": "darwin", + "command": ["agy"], + "cols": 120, + "rows": 40, + "note": "same session as antigravity-ready-api-key-gemini-model but with AGY_CLI_HIDE_ACCOUNT_INFO=1", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-ready-account-info-hidden.txt b/src/main/runtime/__fixtures__/antigravity-ready-account-info-hidden.txt new file mode 100644 index 00000000000..b93514374e0 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-ready-account-info-hidden.txt @@ -0,0 +1,13 @@ +[?2026$p[?2027$p[?5W[?2004h[>4;2m[=1;1u[?u[0 q  +▄▀▀▄ Antigravity CLI 1.2.0 +▀▀▀▀▀▀ Gemini 3.7 Flash (Low) +▀▀▀▀▀▀▀▀ ~ + ▄▀▀ ▀▀▄ + ▄▀▀ ▀▀▄ + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +? for shortcutsGemini 3.7 Flash · low [?25h[>4m[=0;1u + +[?2004l[0 q \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/antigravity-ready-api-key-gemini-model.meta.json b/src/main/runtime/__fixtures__/antigravity-ready-api-key-gemini-model.meta.json new file mode 100644 index 00000000000..97a54e107dc --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-ready-api-key-gemini-model.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-11T04:33:14.819Z", + "platform": "darwin", + "command": ["agy"], + "cols": 120, + "rows": 40, + "note": "agy binary 1.1.25, TUI banner 1.2.0; Gemini API key identity (no OAuth sign-in); model Gemini 3.7 Flash (Low); workspace ~", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-ready-api-key-gemini-model.txt b/src/main/runtime/__fixtures__/antigravity-ready-api-key-gemini-model.txt new file mode 100644 index 00000000000..c9501f1caac --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-ready-api-key-gemini-model.txt @@ -0,0 +1,13 @@ +[?2026$p[?2027$p[?5W[?2004h[>4;2m[=1;1u[?u[0 q  +▄▀▀▄ Antigravity CLI 1.2.0 +▀▀▀▀▀▀ Gemini API key +▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low) + ▄▀▀ ▀▀▄ ~ + ▄▀▀ ▀▀▄ + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +? for shortcutsGemini 3.7 Flash · low [?25h[>4m[=0;1u + +[?2004l[0 q \ No newline at end of file diff --git a/src/main/runtime/agent-transcript-pane-test-harness.ts b/src/main/runtime/agent-transcript-pane-test-harness.ts new file mode 100644 index 00000000000..f3a9a64793c --- /dev/null +++ b/src/main/runtime/agent-transcript-pane-test-harness.ts @@ -0,0 +1,79 @@ +// One pane builder for every suite that replays a captured agent transcript through the runtime. +import { vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' + +const TRANSCRIPT_PANE_LEAF_ID = '11111111-1111-4111-8111-111111111111' +const TRANSCRIPT_PANE_TAB_ID = 'tab-1' +const TRANSCRIPT_PANE_WORKTREE_ID = 'wt-1' +export const TRANSCRIPT_PANE_PTY_ID = 'pty-1' + +export type TranscriptPaneOptions = { + paneTitle: string + foregroundProcess: string | null + data: string + /** Set for a pane whose PTY lives on an SSH host or WSL distro rather than locally. */ + connectionId?: string + /** Simulates a PTY controller whose foreground probe never settles. */ + foregroundProbeHangs?: boolean + onForegroundProbe?: () => void +} + +export async function createTranscriptPane( + options: TranscriptPaneOptions +): Promise<{ runtime: OrcaRuntimeService; handle: string }> { + const runtime = new OrcaRuntimeService(null) + const internals = runtime as unknown as { + resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise + } + vi.spyOn(internals, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({ + id: TRANSCRIPT_PANE_WORKTREE_ID, + path: '/repo/app', + connectionId: options.connectionId ?? null, + repo: null, + folderWorkspace: null + }) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: TRANSCRIPT_PANE_PTY_ID, incarnationId: 'inc-1' }), + write: () => true, + kill: () => true, + getForegroundProcess: (): Promise => { + options.onForegroundProbe?.() + return options.foregroundProbeHangs === true + ? new Promise(() => {}) + : Promise.resolve(options.foregroundProcess) + } + }) + const terminal = await runtime.createTerminal(`id:${TRANSCRIPT_PANE_WORKTREE_ID}`, { + tabId: TRANSCRIPT_PANE_TAB_ID, + leafId: TRANSCRIPT_PANE_LEAF_ID, + title: 'Terminal' + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: TRANSCRIPT_PANE_TAB_ID, + worktreeId: TRANSCRIPT_PANE_WORKTREE_ID, + title: 'Terminal', + activeLeafId: TRANSCRIPT_PANE_LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: TRANSCRIPT_PANE_TAB_ID, + worktreeId: TRANSCRIPT_PANE_WORKTREE_ID, + leafId: TRANSCRIPT_PANE_LEAF_ID, + paneRuntimeId: 1, + ptyId: TRANSCRIPT_PANE_PTY_ID, + paneTitle: options.paneTitle + } + ] + }) + // Why the guard: a restore seed is only applied to a never-written record, so the restore + // cases must not write an empty chunk first. + if (options.data.length > 0) { + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, options.data, Date.now()) + } + return { runtime, handle: terminal.handle } +} diff --git a/src/main/runtime/antigravity-readiness-transcripts.test.ts b/src/main/runtime/antigravity-readiness-transcripts.test.ts new file mode 100644 index 00000000000..3ac7707565f --- /dev/null +++ b/src/main/runtime/antigravity-readiness-transcripts.test.ts @@ -0,0 +1,281 @@ +/** + * Pins Antigravity readiness to captured transcripts instead of hand-written fixtures. + * + * Five detector attempts were tuned against a five-line screen someone typed from memory, and + * three of them shipped worse behaviour than the bug they replaced. Nothing here asserts what + * Antigravity prints: the transcripts do. Six are recorded from a live `agy`; the rest name + * themselves as skipped until someone can reach them. + * + * Four cases are pinned as KNOWN DEFECT: on real output the shipped detector refuses the ready + * screen and accepts the live model picker. Those assert what it does, not what it should. + * + * Capture protocol: docs/reference/agent-pty-transcript-capture.md + * What each transcript decides: docs/reference/antigravity-readiness-evidence.md + */ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { createTranscriptPane } from './agent-transcript-pane-test-harness' +import { extractLastOscTitle } from '../../shared/osc-title-extraction' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +const FIXTURE_DIR = join(__dirname, '__fixtures__') +const EVIDENCE_DOC = join( + __dirname, + '..', + '..', + '..', + 'docs', + 'reference', + 'antigravity-readiness-evidence.md' +) +// Why asymmetric: a ready verdict has to survive the settle window, while a refusal only has to +// hold for one poll. Keeping the refusal short keeps seven transcripts off the suite's clock. +const READY_TIMEOUT_MS = 2_000 +const REFUSAL_TIMEOUT_MS = 600 +/** Antigravity's binary, as Orca launches and probes it (`tui-agent-config.ts` detectCmd). */ +const ANTIGRAVITY_COMMAND = 'agy' +// String.fromCharCode, not a literal: the formatter rewrites an escape sequence into a raw +// control byte in source, which is unreadable and survives badly in diffs. +const ESC = String.fromCharCode(27) + +type TranscriptCase = { + /** Fixture basename; `.txt` under `__fixtures__/`. */ + name: string + /** Capture in docs/reference/antigravity-readiness-evidence.md. */ + capture: string + what: string + /** What a correct detector must answer. Not what the shipped one answers. */ + expectReady: boolean + /** + * Set where the shipped detector contradicts the transcript. The case then runs inverted, so + * CI pins the defect instead of going permanently red — and flips to failing the moment + * someone fixes it, which is exactly when these expectations need re-reading. + */ + knownDefect?: string +} + +const TRANSCRIPTS: readonly TranscriptCase[] = [ + { + name: 'antigravity-ready-api-key-gemini-model', + capture: 'B', + what: 'ready screen, API-key identity — the account row reads "Gemini API key", not an email', + expectReady: true, + knownDefect: 'refused: the model row never starts a line, the logo shares it' + }, + { + name: 'antigravity-ready-account-info-hidden', + capture: 'B', + what: 'ready screen with AGY_CLI_HIDE_ACCOUNT_INFO=1 — no account row at all', + expectReady: true, + knownDefect: 'refused: same line-start defect, and no account row exists to require' + }, + { + name: 'antigravity-dialog-trust-workspace', + capture: 'C', + what: 'workspace trust dialog owning the screen', + expectReady: false + }, + { + name: 'antigravity-dialog-model-picker', + capture: 'C', + what: 'model picker owning the screen', + expectReady: false, + knownDefect: "accepted: the picker's own `Gemini 3.x Flash` rows satisfy the model rule" + }, + { + name: 'antigravity-dialog-command-palette', + capture: 'C', + what: 'slash-command palette owning the screen', + expectReady: false + }, + { + name: 'antigravity-busy-mid-turn', + capture: 'E', + what: 'mid-turn, spinner live — the pane is working, not waiting for a prompt', + expectReady: false + }, + { + // Expected ready because the turn is over and the composer is back on screen. The captured + // turn ends in a backend error, which is the only ending this account's key can produce. + name: 'antigravity-busy-turn-ended', + capture: 'E', + what: 'the turn has ended and the composer has returned, process still alive', + expectReady: true, + knownDefect: 'refused: the retained tail ends on the error block, with no composer row in it' + }, + { + name: 'antigravity-dialog-dismissed', + capture: 'D', + what: 'the screen immediately after the model picker is dismissed', + expectReady: true, + knownDefect: 'refused: the banner is not reprinted and no model row starts a line' + }, + // Not captured: this machine's agy has no OAuth session and offers only Gemini models, and + // reaching the rest would mean signing the operator out or deleting their config. See + // docs/reference/antigravity-readiness-evidence.md § What could not be captured. + { + name: 'antigravity-ready-business-non-gemini', + capture: 'A', + what: 'ready screen, Business account, non-Gemini model', + expectReady: true + }, + { + name: 'antigravity-dialog-sign-in', + capture: 'C', + what: 'sign-in dialog owning the screen', + expectReady: false + }, + { + name: 'antigravity-dialog-theme-picker', + capture: 'C', + what: 'theme picker owning the screen', + expectReady: false + }, + { + name: 'antigravity-dialog-privacy-notice', + capture: 'C', + what: 'privacy notice owning the screen', + expectReady: false + }, + { + name: 'antigravity-dialog-update-banner', + capture: 'C', + what: 'update banner owning the screen', + expectReady: false + } +] + +function fixturePath(name: string): string { + return join(FIXTURE_DIR, `${name}.txt`) +} + +/** + * A `tui-idle` wait ends three ways, and only one of them is readiness: it resolves satisfied, it + * resolves unsatisfied with a blocked reason, or it rejects with `timeout` because nothing ever + * looked ready. The orchestrator treats the last two identically — no prompt is delivered — so + * they are both `ready: false` here. This is the shape `worker-start` sees. + */ +async function readinessVerdict( + transcript: string, + timeoutMs: number +): Promise<{ ready: boolean; blockedReason: unknown; outcome: string }> { + const { runtime, handle } = await createTranscriptPane({ + // Why the transcript's own title: every attempt guessed at Antigravity's title. A raw + // capture carries the OSC bytes, so the pane wears whatever the CLI actually set. + paneTitle: extractLastOscTitle(transcript) ?? ANTIGRAVITY_COMMAND, + foregroundProcess: ANTIGRAVITY_COMMAND, + data: transcript + }) + try { + const result = (await runtime.waitForTerminal(handle, { + condition: 'tui-idle', + timeoutMs + })) as { satisfied?: boolean; blockedReason?: unknown } + return { + ready: result.satisfied === true, + blockedReason: result.blockedReason ?? null, + outcome: result.satisfied === true ? 'satisfied' : 'unsatisfied' + } + } catch (error) { + return { ready: false, blockedReason: null, outcome: `rejected: ${String(error)}` } + } +} + +describe('Antigravity readiness, decided by captured transcripts', () => { + for (const transcript of TRANSCRIPTS) { + const path = fixturePath(transcript.name) + const captured = existsSync(path) + const label = `capture ${transcript.capture}: ${transcript.what}` + + // A pinned defect asserts what the detector DOES, so CI is honest rather than permanently + // red; fixing the detector flips this case to failing, which is when these expectations + // need re-reading. The correct answer stays in `expectReady` and in the test's name. + const shipped = + transcript.knownDefect === undefined ? transcript.expectReady : !transcript.expectReady + const verdictName = + transcript.knownDefect === undefined + ? `${label} → ${transcript.expectReady ? 'ready' : 'not ready'}` + : `${label} → must be ${transcript.expectReady ? 'ready' : 'not ready'}; KNOWN DEFECT, ${transcript.knownDefect}` + + it.skipIf(!captured)( + verdictName, + async () => { + // A refusal only has to hold for one poll; a ready verdict has to survive the settle + // window. Keeping the refusal short keeps eleven transcripts off the suite's clock. + const verdict = await readinessVerdict( + readFileSync(path, 'utf8'), + transcript.expectReady ? READY_TIMEOUT_MS : REFUSAL_TIMEOUT_MS + ) + // A silent dialog carries no blocked-signal wording, so the assertion is only that Orca + // does not call the pane ready and type a prompt into a dialog that owns the screen. + expect({ ready: verdict.ready, outcome: verdict.outcome }).toMatchObject({ + ready: shipped + }) + }, + READY_TIMEOUT_MS + 10_000 + ) + + it.skipIf(!captured)(`${label} was captured raw, not pasted from a rendered screen`, () => { + const text = readFileSync(path, 'utf8') + // Why: a transcript with no escape bytes went through a terminal's renderer and a + // human's clipboard. It cannot answer what the caret or chrome looked like. + expect(text).toContain(ESC) + }) + } + + it('documents every transcript the detector is allowed to depend on', () => { + // Why a test: the doc is the operator's checklist. A name that drifts out of it is a + // transcript nobody will capture, and a case that silently skips forever. + const doc = readFileSync(EVIDENCE_DOC, 'utf8') + for (const transcript of TRANSCRIPTS) { + expect(doc).toContain(`${transcript.name}.txt`) + } + }) + + it('reports how much evidence exists, so a fully skipped run is visible', () => { + const missing = TRANSCRIPTS.filter( + (transcript) => !existsSync(fixturePath(transcript.name)) + ).map((transcript) => `${transcript.name}.txt`) + if (missing.length > 0) { + console.info( + `Antigravity transcripts: ${TRANSCRIPTS.length - missing.length}/${TRANSCRIPTS.length} captured. Missing: ${missing.join(', ')}` + ) + } + expect(missing.length).toBeLessThanOrEqual(TRANSCRIPTS.length) + }) +}) + +describe('scaffold self-check', () => { + // Why these two live here: when a transcript lands and fails, the failure has to mean the + // capture disagreed with the detector — not that the harness or the timeouts are broken. + // Neither case is evidence about Antigravity; both are shapes the current detector already + // decides, used only to prove the plumbing reaches a verdict. + it('reaches a ready verdict through the harness', async () => { + const verdict = await readinessVerdict( + [ + 'Antigravity CLI 1.0.3', + 'user@example.com (Antigravity Business)', + 'Gemini 3.5 Flash (High)', + '~/orca/workspaces/orca/agy-dispatch-issue', + '>' + ].join('\n'), + READY_TIMEOUT_MS + ) + expect(verdict.ready).toBe(true) + }) + + it('reaches a not-ready verdict through the harness', async () => { + const verdict = await readinessVerdict( + 'Do you trust this workspace directory?\nPress t to trust\n', + REFUSAL_TIMEOUT_MS + ) + expect(verdict.ready).toBe(false) + }) +}) diff --git a/src/main/runtime/terminal-interactive-wait-visibility.test.ts b/src/main/runtime/terminal-interactive-wait-visibility.test.ts index 173c3482b14..5e652af41f6 100644 --- a/src/main/runtime/terminal-interactive-wait-visibility.test.ts +++ b/src/main/runtime/terminal-interactive-wait-visibility.test.ts @@ -3,7 +3,10 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { OrcaRuntimeService } from './orca-runtime' +import { + createTranscriptPane as createPane, + TRANSCRIPT_PANE_PTY_ID as PTY_ID +} from './agent-transcript-pane-test-harness' import { assertTerminalAgentSendable } from './rpc/terminal-agent-send-guard' vi.mock('electron', () => ({ @@ -13,12 +16,11 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp') } })) -const LEAF_ID = '11111111-1111-4111-8111-111111111111' -const TAB_ID = 'tab-1' -const WORKTREE_ID = 'wt-1' -const PTY_ID = 'pty-1' - -// Captured verbatim from cursor-agent 2026.08.11-e8db854 driven through Orca. +// cursor-agent 2026.08.11-e8db854's screens, but NOT raw PTY output: these files contain no +// escape bytes and no carriage returns, so they came through a terminal's renderer and a +// clipboard. They evidence wording, ordering and glyphs — which is all the rules below key on — +// and evidence nothing about the caret, cursor moves, repaints or the alternate screen buffer. +// Record new fixtures with config/scripts/capture-agent-pty-transcript.mjs, which keeps the bytes. function fixture(name: string): string { return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8') } @@ -39,73 +41,6 @@ function agentStatusOsc(state: string): string { return `]9999;${JSON.stringify({ state, prompt: 'ship it', agentType: 'claude' })}` } -async function createPane(options: { - paneTitle: string - foregroundProcess: string | null - data: string - /** Set for a pane whose PTY lives on an SSH host or WSL distro rather than locally. */ - connectionId?: string - /** Simulates a PTY controller whose foreground probe never settles. */ - foregroundProbeHangs?: boolean - onForegroundProbe?: () => void -}): Promise<{ runtime: OrcaRuntimeService; handle: string }> { - const runtime = new OrcaRuntimeService(null) - const internals = runtime as unknown as { - resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise - } - vi.spyOn(internals, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({ - id: WORKTREE_ID, - path: '/repo/app', - connectionId: options.connectionId ?? null, - repo: null, - folderWorkspace: null - }) - runtime.setPtyController({ - spawn: vi.fn().mockResolvedValue({ id: PTY_ID, incarnationId: 'inc-1' }), - write: () => true, - kill: () => true, - getForegroundProcess: (): Promise => { - options.onForegroundProbe?.() - return options.foregroundProbeHangs === true - ? new Promise(() => {}) - : Promise.resolve(options.foregroundProcess) - } - }) - const terminal = await runtime.createTerminal(`id:${WORKTREE_ID}`, { - tabId: TAB_ID, - leafId: LEAF_ID, - title: 'Terminal' - }) - runtime.attachWindow(1) - runtime.syncWindowGraph(1, { - tabs: [ - { - tabId: TAB_ID, - worktreeId: WORKTREE_ID, - title: 'Terminal', - activeLeafId: LEAF_ID, - layout: null - } - ], - leaves: [ - { - tabId: TAB_ID, - worktreeId: WORKTREE_ID, - leafId: LEAF_ID, - paneRuntimeId: 1, - ptyId: PTY_ID, - paneTitle: options.paneTitle - } - ] - }) - // Why the guard: a restore seed is only applied to a never-written record, so the restore - // cases must not write an empty chunk first. - if (options.data.length > 0) { - runtime.onPtyData(PTY_ID, options.data, Date.now()) - } - return { runtime, handle: terminal.handle } -} - // cursor-agent renders a braille spinner in its OSC title while it works, and Orca reads // that as `working`; the title is identical whether it is running a command or waiting. const CURSOR_TITLE = '⠇ Cursor Agent' From 78e985cd993082e27168f6587983724e4b5798ea Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:25:17 -0700 Subject: [PATCH 006/191] fix(pi): claim the status pane when the inherited owner PID is dead (STA-5245) (#16631) * fix(pi): claim the status pane when the inherited owner PID is dead (STA-5245) The managed pi/omp/prime-agent status extension suppressed itself whenever ORCA_PI_STATUS_OWNED held a PID other than its own, with no check that the owner still existed. A restart leaves the previous owner's PID in the inherited env, so every later load returned early and the pane stopped reporting status permanently. Probe the owner before suppressing. Only ESRCH proves it is gone; any other probe result keeps suppression so a live foreign owner still cannot double-report. This mirrors the tri-state in main/agent-hooks/managed-hook-owner-identity.ts, which the extension cannot import because it loads inside the pi/omp runtime with no Orca deps. Also extracts the generated-source test harness into its own module so the suite stays under the max-lines limit. * fix(pi): validate inherited status owner pid markers --------- Co-authored-by: Neil --- .github/workflows/pi-owner-runtime.yml | 29 ++++ .../pi/agent-status-extension-test-harness.ts | 5 + src/main/pi/agent-status-handler-source.ts | 20 ++- .../pi/agent-status-owner-recovery.test.ts | 89 ++++++++++++ tests/tools/pi-owner-runtime-smoke.mjs | 128 ++++++++++++++++++ 5 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/pi-owner-runtime.yml create mode 100644 src/main/pi/agent-status-owner-recovery.test.ts create mode 100644 tests/tools/pi-owner-runtime-smoke.mjs diff --git a/.github/workflows/pi-owner-runtime.yml b/.github/workflows/pi-owner-runtime.yml new file mode 100644 index 00000000000..373afb7a539 --- /dev/null +++ b/.github/workflows/pi-owner-runtime.yml @@ -0,0 +1,29 @@ +name: Pi owner runtime verification +on: + pull_request: + paths: + - 'src/main/pi/agent-status-handler-source.ts' + - 'tests/tools/pi-owner-runtime-smoke.mjs' + - '.github/workflows/pi-owner-runtime.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + runtime: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + - name: Install pinned extension loader + run: npm install --prefix .cache/pi-owner --ignore-scripts --no-audit --no-fund @earendil-works/pi-coding-agent@0.83.0 + - name: Verify real owner exit and hook delivery + run: node tests/tools/pi-owner-runtime-smoke.mjs .cache/pi-owner/node_modules/@earendil-works/pi-coding-agent diff --git a/src/main/pi/agent-status-extension-test-harness.ts b/src/main/pi/agent-status-extension-test-harness.ts index eec2b615017..810bc3d04d5 100644 --- a/src/main/pi/agent-status-extension-test-harness.ts +++ b/src/main/pi/agent-status-extension-test-harness.ts @@ -24,6 +24,7 @@ type FakeCurlChild = { } export type AgentStatusExtensionHarness = { + killMock: ReturnType fetchMock: ReturnType spawnMock: ReturnType spawnedChildren: FakeCurlChild[] @@ -57,6 +58,7 @@ export const AGENT_STATUS_EXTENSION_SELF_PID = 4242 export function createAgentStatusExtensionHarness(args: { kind: 'pi' | 'omp' | 'prime-agent' + killImpl?: (pid: number, signal: number) => void env?: Record pid?: number title?: string @@ -115,7 +117,9 @@ export function createAgentStatusExtensionHarness(args: { throw new Error(`unexpected require(${specifier})`) }) + const killMock = vi.fn(args.killImpl ?? (() => undefined)) const processMock = { + kill: killMock, env: { ...BASE_ENV, ...(args.kind === 'prime-agent' ? { PRIME_AGENT_INTERNAL_DAEMON_WORKER: '1' } : {}), @@ -172,6 +176,7 @@ export function createAgentStatusExtensionHarness(args: { return { fetchMock, + killMock, spawnMock, spawnedChildren, fsMock, diff --git a/src/main/pi/agent-status-handler-source.ts b/src/main/pi/agent-status-handler-source.ts index 9d02abbd78d..5a778a1c81f 100644 --- a/src/main/pi/agent-status-handler-source.ts +++ b/src/main/pi/agent-status-handler-source.ts @@ -88,13 +88,31 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] '// etc.), so we forward the raw object verbatim under the same field', '// names Claude uses (tool_name / tool_input) and let the server pick the', '// preview. Keeps tool-name knowledge centralized on the receiver side.', + '// Why: a restarted agent inherits the previous owner PID through env, so a', + '// dead owner must be claimable or the pane goes silent for good. Only ESRCH', + '// proves the owner is gone -- every other probe result keeps suppression, so', + '// a live foreign owner still cannot double-report. Mirrors the tri-state in', + '// main/agent-hooks/managed-hook-owner-identity.ts, which this runtime cannot', + '// import (the extension loads inside pi/omp with no Orca deps).', + 'function isStatusOwnerAlive(pid: string): boolean {', + ' const parsed = Number(pid)', + ' if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 0x7fffffff) return false', + " if (typeof process.kill !== 'function') return true", + ' try {', + ' process.kill(parsed, 0)', + ' return true', + ' } catch (err: unknown) {', + " return (err as { code?: string } | null)?.code !== 'ESRCH'", + ' }', + '}', + '', "// Why: child agents inherit the lead's pane env; only its process may", '// register status hooks. PID identity keeps in-process reloads reporting.', 'export default function (pi): void {', ...primeDaemonWorkerGuard, ` const ownerPid = process.env.${ownerEnv}`, ' const selfPid = String(process.pid)', - ' if (ownerPid && ownerPid !== selfPid) return', + ' if (ownerPid && ownerPid !== selfPid && isStatusOwnerAlive(ownerPid)) return', ` process.env.${ownerEnv} = selfPid`, ...sessionStartHandler, ` pi.on('before_agent_start', (event${ctxParam}) => {`, diff --git a/src/main/pi/agent-status-owner-recovery.test.ts b/src/main/pi/agent-status-owner-recovery.test.ts new file mode 100644 index 00000000000..d176bcb8dae --- /dev/null +++ b/src/main/pi/agent-status-owner-recovery.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { + createAgentStatusExtensionHarness as createHarness, + AGENT_STATUS_EXTENSION_SELF_PID as SELF_PID +} from './agent-status-extension-test-harness' + +describe('Pi status owner recovery', () => { + it.each(['pi', 'omp', 'prime-agent'] as const)( + 'claims the pane for a restarted %s agent whose inherited owner PID is dead', + async (kind) => { + // Why: STA-5245 -- a restart leaves a dead owner PID in the inherited env. + // Without a liveness probe the guard suppresses every later load, so the + // pane never reports status again. + const ownerKey = + kind === 'prime-agent' ? 'ORCA_PRIME_AGENT_STATUS_OWNED' : 'ORCA_PI_STATUS_OWNED' + const harness = createHarness({ + kind, + pid: SELF_PID, + env: { [ownerKey]: String(SELF_PID - 1) }, + killImpl: () => { + throw Object.assign(new Error('ESRCH'), { code: 'ESRCH' }) + } + }) + + expect(harness.killMock).toHaveBeenCalledWith(SELF_PID - 1, 0) + expect(harness.handlers.agent_end).toBeTypeOf('function') + expect(harness.processEnv[ownerKey]).toBe(String(SELF_PID)) + + await harness.callHook('agent_end') + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + } + ) + + it.each(['EPERM', 'EACCES', 'EINVAL', undefined])( + 'keeps suppression for unverifiable probe error %s', + (code) => { + // Why: EPERM means the owner exists but belongs to another user, so + // claiming the pane there would reintroduce double-reporting. + const harness = createHarness({ + kind: 'pi', + pid: SELF_PID, + env: { ORCA_PI_STATUS_OWNED: String(SELF_PID - 1) }, + killImpl: () => { + throw Object.assign(new Error('probe failed'), { code }) + } + }) + + expect(harness.handlers).toEqual({}) + expect(harness.processEnv.ORCA_PI_STATUS_OWNED).toBe(String(SELF_PID - 1)) + } + ) + + it('claims the pane when the inherited owner PID is not a usable pid', () => { + // Why: a truncated/garbage marker is not evidence of a live owner. + const harness = createHarness({ + kind: 'pi', + pid: SELF_PID, + env: { ORCA_PI_STATUS_OWNED: 'not-a-pid' } + }) + + expect(harness.killMock).not.toHaveBeenCalled() + expect(harness.handlers.agent_end).toBeTypeOf('function') + expect(harness.processEnv.ORCA_PI_STATUS_OWNED).toBe(String(SELF_PID)) + }) + + it('claims the pane when the inherited owner PID exceeds safe integer precision', () => { + const harness = createHarness({ + kind: 'pi', + pid: SELF_PID, + env: { ORCA_PI_STATUS_OWNED: '99999999999999999999999' } + }) + + expect(harness.killMock).not.toHaveBeenCalled() + expect(harness.handlers.agent_end).toBeTypeOf('function') + expect(harness.processEnv.ORCA_PI_STATUS_OWNED).toBe(String(SELF_PID)) + }) + + it('claims the pane when the inherited owner PID exceeds the process API range', () => { + const harness = createHarness({ + kind: 'pi', + pid: SELF_PID, + env: { ORCA_PI_STATUS_OWNED: String(2 ** 31) } + }) + + expect(harness.killMock).not.toHaveBeenCalled() + expect(harness.handlers.agent_end).toBeTypeOf('function') + expect(harness.processEnv.ORCA_PI_STATUS_OWNED).toBe(String(SELF_PID)) + }) +}) diff --git a/tests/tools/pi-owner-runtime-smoke.mjs b/tests/tools/pi-owner-runtime-smoke.mjs new file mode 100644 index 00000000000..204627340ac --- /dev/null +++ b/tests/tools/pi-owner-runtime-smoke.mjs @@ -0,0 +1,128 @@ +// Run: node tests/tools/pi-owner-runtime-smoke.mjs /path/to/pi-coding-agent +import assert from 'node:assert/strict' +import { once } from 'node:events' +import { mkdtemp, writeFile, rm } from 'node:fs/promises' +import { createServer } from 'node:http' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { build } from 'esbuild' + +const piRoot = resolve(process.argv[2] || '') +assert.ok(process.argv[2], 'Pass an installed pi-coding-agent package directory') +const scratch = await mkdtemp(join(tmpdir(), 'orca-pi-owner-')) +const received = [] +const server = createServer(async (request, response) => { + let body = '' + for await (const chunk of request) { + body += chunk + } + received.push(JSON.parse(body)) + response.end('{}') +}) +try { + const bundle = join(scratch, 'orca.cjs') + await build({ + stdin: { + contents: [ + "export { getPiAgentStatusExtensionSource } from './src/main/pi/agent-status-extension-source';", + "export { runProcess } from './src/shared/child-process/run-process';" + ].join('\n'), + resolveDir: process.cwd() + }, + bundle: true, + platform: 'node', + format: 'cjs', + outfile: bundle, + packages: 'external' + }) + const { getPiAgentStatusExtensionSource, runProcess } = createRequire(import.meta.url)(bundle) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const dead = await runProcess({ + program: process.execPath, + args: ['-e', 'console.log(process.pid)'] + }) + assert.equal(dead.code, 0) + const deadPid = Number(dead.stdout.trim()) + assert.throws(() => process.kill(deadPid, 0), { code: 'ESRCH' }) + const worker = join(scratch, 'worker.mjs') + const moduleUrl = (file) => JSON.stringify(pathToFileURL(join(piRoot, file)).href) + await writeFile( + worker, + ` + import assert from 'node:assert/strict' + import { loadExtensions } from ${moduleUrl('dist/core/extensions/loader.js')} + import { ExtensionRunner } from ${moduleUrl('dist/core/extensions/runner.js')} + import { SessionManager } from ${moduleUrl('dist/core/session-manager.js')} + const loaded = await loadExtensions([process.argv[2]], process.cwd()) + assert.deepEqual(loaded.errors, []) + const runner = new ExtensionRunner(loaded.extensions, loaded.runtime, process.cwd(), SessionManager.inMemory(process.cwd()), undefined) + const errors = [] + runner.onError(error => errors.push(error)) + await runner.emit({ type: 'agent_start' }) + await new Promise(resolve => setTimeout(resolve, 250)) + assert.deepEqual(errors, []) + console.log(JSON.stringify({pid: process.pid, owner: process.env[process.argv[3]], handlers: loaded.extensions[0].handlers.size})) + ` + ) + const results = [] + for (const kind of ['pi', 'omp', 'prime-agent']) { + const ownerKey = + kind === 'prime-agent' ? 'ORCA_PRIME_AGENT_STATUS_OWNED' : 'ORCA_PI_STATUS_OWNED' + for (const scenario of ['baseline-dead', 'fixed-dead', 'fixed-live']) { + let source = getPiAgentStatusExtensionSource(kind) + if (scenario === 'baseline-dead') { + const guard = 'if (ownerPid && ownerPid !== selfPid && isStatusOwnerAlive(ownerPid)) return' + assert.ok( + source.includes(guard), + 'Baseline mutation must replace the actual ownership guard' + ) + source = source.replace(guard, 'if (ownerPid && ownerPid !== selfPid) return') + } + const extension = join(scratch, `${kind}-${scenario}.ts`) + await writeFile(extension, source) + const before = received.length + const owner = scenario === 'fixed-live' ? process.pid : deadPid + const child = await runProcess({ + program: process.execPath, + args: [worker, extension, ownerKey], + cwd: scratch, + env: { + ...process.env, + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_PANE_KEY: 'owner-proof', + ORCA_AGENT_HOOK_PORT: String(server.address().port), + ORCA_AGENT_HOOK_TOKEN: 'isolated-proof-token', + ORCA_AGENT_HOOK_ENV: 'proof', + ORCA_AGENT_HOOK_ENDPOINT: '', + ORCA_PI_STATUS_OWNED: '', + ORCA_PRIME_AGENT_STATUS_OWNED: '', + PRIME_AGENT_INTERNAL_DAEMON_WORKER: kind === 'prime-agent' ? '1' : '', + [ownerKey]: String(owner) + }, + timeoutMs: 15000 + }) + assert.equal(child.code, 0, child.stderr) + const observation = JSON.parse(child.stdout.trim().split('\n').at(-1)) + const shouldReport = scenario === 'fixed-dead' + assert.equal( + received.length - before, + shouldReport ? 1 : 0, + `${kind}/${scenario}: HTTP delivery` + ) + assert.equal(observation.owner, String(shouldReport ? observation.pid : owner)) + assert.equal(observation.handlers > 0, shouldReport) + if (shouldReport) { + assert.equal(received.at(-1).payload.hook_event_name, 'agent_start') + } + results.push({ kind, scenario, posts: received.length - before, ...observation }) + } + } + console.log(JSON.stringify({ platform: process.platform, results }, null, 2)) +} finally { + server.closeAllConnections() + server.close() + await rm(scratch, { recursive: true, force: true }) +} From 22d12388a5e619940cd898815dc707865f768555 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:27:15 -0700 Subject: [PATCH 007/191] fix(pi): load extension providers for source control generation (#20070) --- .github/workflows/pi-provider-runtime.yml | 28 ++++ .../commit-message-agent-specs-primary.ts | 1 - src/shared/commit-message-plan.test.ts | 23 ++++ tests/tools/pi-provider-runtime-smoke.mjs | 130 ++++++++++++++++++ 4 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/pi-provider-runtime.yml create mode 100644 tests/tools/pi-provider-runtime-smoke.mjs diff --git a/.github/workflows/pi-provider-runtime.yml b/.github/workflows/pi-provider-runtime.yml new file mode 100644 index 00000000000..837c38baf9a --- /dev/null +++ b/.github/workflows/pi-provider-runtime.yml @@ -0,0 +1,28 @@ +name: Pi extension provider verification +on: + pull_request: + paths: + - 'src/shared/commit-message-agent-specs-primary.ts' + - 'tests/tools/pi-provider-runtime-smoke.mjs' + - '.github/workflows/pi-provider-runtime.yml' +permissions: + contents: read +jobs: + runtime: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + - name: Install pinned Pi runtime + run: npm install --prefix .cache/pi-provider --ignore-scripts --no-audit --no-fund @earendil-works/pi-coding-agent@0.84.2 + - name: Verify extension model generation before and after + run: node tests/tools/pi-provider-runtime-smoke.mjs .cache/pi-provider/node_modules/@earendil-works/pi-coding-agent/dist/cli.js diff --git a/src/shared/commit-message-agent-specs-primary.ts b/src/shared/commit-message-agent-specs-primary.ts index e6ba42f775b..3e42a4c5865 100644 --- a/src/shared/commit-message-agent-specs-primary.ts +++ b/src/shared/commit-message-agent-specs-primary.ts @@ -197,7 +197,6 @@ export function buildPrimaryCommitMessageAgentSpecs({ '--print', '--no-session', '--no-tools', - '--no-extensions', '--no-skills', '--no-context-files', '--mode', diff --git a/src/shared/commit-message-plan.test.ts b/src/shared/commit-message-plan.test.ts index 0b728307bf7..3c2fa62aea5 100644 --- a/src/shared/commit-message-plan.test.ts +++ b/src/shared/commit-message-plan.test.ts @@ -2,6 +2,29 @@ import { describe, expect, it } from 'vitest' import { planCommitMessageGeneration, planAgentBinary } from './commit-message-plan' describe('planCommitMessageGeneration', () => { + it('keeps extension-provided Pi models available in generated Git text plans', () => { + const result = planCommitMessageGeneration( + { agentId: 'pi', model: 'local-extension/model' }, + 'Write a commit message' + ) + expect(result.ok).toBe(true) + if (!result.ok) { + throw new Error(result.error) + } + expect(result.plan.args).not.toContain('--no-extensions') + expect(result.plan.args).toEqual( + expect.arrayContaining([ + '--no-session', + '--no-tools', + '--no-skills', + '--no-context-files', + '--model', + 'local-extension/model' + ]) + ) + expect(result.plan.stdinPayload).toBe('Write a commit message') + }) + it('plans Claude non-interactive generation with the prompt on stdin only', () => { const result = planCommitMessageGeneration( { diff --git a/tests/tools/pi-provider-runtime-smoke.mjs b/tests/tools/pi-provider-runtime-smoke.mjs new file mode 100644 index 00000000000..bda4f6a0f89 --- /dev/null +++ b/tests/tools/pi-provider-runtime-smoke.mjs @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict' +import { once } from 'node:events' +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { createServer } from 'node:http' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { build } from 'esbuild' +const piCli = process.argv[2] && resolve(process.argv[2]) +assert.ok(piCli, 'Pass the installed Pi CLI entrypoint') +const scratch = await mkdtemp(join(tmpdir(), 'orca-pi-provider-')) +const requests = [] +const server = createServer(async (req, res) => { + let body = '' + for await (const part of req) { + body += part + } + requests.push(JSON.parse(body)) + res.writeHead(200, { 'content-type': 'text/event-stream' }) + for (const chunk of [ + { + id: 'proof', + object: 'chat.completion.chunk', + choices: [ + { + index: 0, + delta: { role: 'assistant', content: 'fixture-generated-commit' }, + finish_reason: null + } + ] + }, + { + id: 'proof', + object: 'chat.completion.chunk', + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } + } + ]) { + res.write(`data: ${JSON.stringify(chunk)}\n\n`) + } + res.end('data: [DONE]\n\n') +}) +try { + const bundle = join(scratch, 'orca.cjs') + await build({ + stdin: { + contents: + "export {planCommitMessageGeneration} from './src/shared/commit-message-plan'; export {runProcess} from './src/shared/child-process/run-process';", + resolveDir: process.cwd() + }, + bundle: true, + platform: 'node', + format: 'cjs', + outfile: bundle, + packages: 'external' + }) + const { planCommitMessageGeneration, runProcess } = createRequire(import.meta.url)(bundle) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const dir = join(scratch, 'agent') + await mkdir(join(dir, 'extensions'), { recursive: true }) + await writeFile( + join(dir, 'extensions', 'provider.ts'), + `export default function(pi){pi.registerProvider('orca-proof',{name:'Proof',baseUrl:'http://127.0.0.1:${server.address().port}/v1',apiKey:'fixture-only',api:'openai-completions',models:[{id:'local',name:'Proof',reasoning:false,input:['text'],cost:{input:0,output:0,cacheRead:0,cacheWrite:0},contextWindow:8192,maxTokens:256}]})}` + ) + await writeFile( + join(dir, 'settings.json'), + JSON.stringify({ defaultProvider: 'orca-proof', defaultModel: 'local' }) + ) + const planned = planCommitMessageGeneration( + { agentId: 'pi', model: 'orca-proof/local' }, + 'Generate one short commit message.' + ) + assert.equal(planned.ok, true) + const fixedArgs = planned.plan.args + assert.ok(!fixedArgs.includes('--no-extensions')) + const variants = [ + ['baseline', [...fixedArgs, '--no-extensions']], + ['extensions-enabled', fixedArgs] + ] + const results = [] + for (const [variant, args] of variants) { + const n = requests.length + const result = await runProcess({ + program: process.execPath, + args: [piCli, ...args], + cwd: scratch, + env: { + PATH: process.env.PATH, + SystemRoot: process.env.SystemRoot, + WINDIR: process.env.WINDIR, + HOME: scratch, + USERPROFILE: scratch, + ORCA_BACKGROUND_LAUNCH: '1', + PI_CODING_AGENT_DIR: dir + }, + input: planned.plan.stdinPayload, + timeoutMs: 20000 + }) + results.push({ + variant, + args, + code: result.code, + stdout: result.stdout, + stderr: result.stderr, + requests: requests.length - n + }) + } + assert.equal(results[0].requests, 0) + assert.notEqual(results[0].code, 0) + assert.equal(results[1].code, 0, results[1].stderr) + assert.match(results[1].stdout, /fixture-generated-commit/) + assert.equal(results[1].requests, 1) + console.log( + JSON.stringify( + { + scope: + 'Actual Pi CLI and production command planner; isolated extension provider with local OpenAI-compatible fixture.', + platform: process.platform, + results + }, + null, + 2 + ) + ) +} finally { + server.closeAllConnections() + server.close() + await rm(scratch, { recursive: true, force: true }) +} From 20c56249d51d4a58392e04b0de7d52fa4c24060f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:45:57 -0700 Subject: [PATCH 008/191] fix(terminal): keep a deliberately slept workspace cold until it is woken (#20075) * fix(terminal): keep a deliberately slept workspace cold until it is woken Sleeping a workspace kills its PTYs but keeps its panes mounted and keeps each tab's session id as a wake hint. Any later remount of those panes (recovery, parking, portals) reattached that dead id, and the daemon's create-or-attach spawned a fresh shell, so slept workspaces revived on their own (#10205). The existing sleep-intent marker now outlives teardown and gates the deferred connect itself, so both the reattach and fresh-spawn arms stay cold. It is released by activating the workspace, by any PTY binding to one of its tabs (CLI, automation, client wake), and by purge. A queued startup still connects. Reproduces the community root cause from gatsby74 in #13343; the regression e2e remounts a slept hidden pane and fails on main. Co-authored-by: gatsby74 Co-authored-by: mmarabel * fix(terminal): let a slept pane wait for its wake instead of latching cold A pane whose connect ran while its workspace was slept used to mark itself connected and stop; nothing re-armed it, so a wake that produced a live PTY before the user clicked (CLI create, background agent resume, split panes) left panes stranded. The connect now waits on the sleep marker and resumes when the marker clears, and a torn-down pane drops its listener. Tabs created with a live PTY clear the marker too, the sleep flow marks each workspace only when its own teardown starts, and purge forgets the marker without waking anything. * fix(terminal): wake a waiting pane once, in its remounted generation Activation clears the sleep marker after the set() that bumps dead tabs' generations, and the waiting pane only resumes its connect when its tab generation is still current. Otherwise the stale pane and its remounted successor both reattached the same session id on a deliberate wake. * fix(terminal): resolve the waiting pane's tab by either id and re-arm after wake The wake listener looked the tab up by the pane's render id, which can be a unified id whose terminal tab lives under entityId, so the generation check declined forever for those panes. Mount, fresh spawn, and the wake listener now share one live resolver. The wait flag resets when the listener fires so a second sleep can hold the pane again, listener dispatch is guarded, folder activation clears after its own set(), and the sleep flow re-asserts the marker after each teardown while releasing a workspace the user activated meanwhile. * fix(terminal): ignore PTY binds that land inside the sleep teardown window A spawn resolving while shutdown was still awaiting the host bound a PTY and cleared the marker, waking every waiting pane mid-sleep; re-marking afterwards could not un-connect them. The sleep flow now scopes each teardown so binds in that window are not wakes. The e2e asserts a deliberate wake yields exactly one PTY, and the dispose test proves the listener is gone. --------- Co-authored-by: Jinwoo-H Co-authored-by: mmarabel --- .../sidebar/sleep-worktree-flow.test.ts | 74 +++- .../components/sidebar/sleep-worktree-flow.ts | 52 ++- ...-connection-deliberate-sleep-guard.test.ts | 362 ++++++++++++++++++ .../pty-connection/connect-pane-pty.ts | 41 +- .../pty-connection/fresh-spawn-start.ts | 51 +-- .../pty-connection/run-deferred-connect.ts | 41 ++ .../pty-connection/terminal-tab-id.ts | 51 +++ src/renderer/src/lib/worktree-sleep-intent.ts | 58 ++- .../worktree-sleep-intent-lifecycle.test.ts | 167 ++++++++ .../session/set-active-folder-workspace.ts | 3 + .../worktrees/session/set-active-worktree.ts | 6 + .../worktrees/teardown/remove-worktree.ts | 2 + .../teardown/worktree-purge-state.ts | 5 + .../store/terminals/terminal-pty-bindings.ts | 3 + .../store/terminals/terminal-tab-creation.ts | 5 + tests/e2e/helpers/slept-workspace-probe.ts | 133 +++++++ .../e2e/slept-workspace-remount-wake.spec.ts | 87 +++++ 17 files changed, 1026 insertions(+), 115 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.test.ts create mode 100644 src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts create mode 100644 tests/e2e/helpers/slept-workspace-probe.ts create mode 100644 tests/e2e/slept-workspace-remount-wake.spec.ts diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts index b63b37f2926..c38f0d4e36d 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts @@ -1,9 +1,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => { - const state = { - activeWorktreeId: null as string | null, - setActiveWorktree: vi.fn(), + const state: { + activeWorktreeId: string | null + setActiveWorktree: ReturnType + shutdownWorktreeBrowsers: ReturnType + shutdownWorktreeTerminals: ReturnType + suppressPtyExit: ReturnType + consumeSuppressedPtyExit: ReturnType + tabsByWorktree: Record + ptyIdsByTabId: Record + } = { + activeWorktreeId: null, + setActiveWorktree: vi.fn((worktreeId: string | null) => { + state.activeWorktreeId = worktreeId + }), shutdownWorktreeBrowsers: vi.fn().mockResolvedValue(undefined), shutdownWorktreeTerminals: vi.fn().mockResolvedValue(undefined), suppressPtyExit: vi.fn(), @@ -33,7 +44,8 @@ vi.mock('@/store', () => ({ vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) vi.mock('@/lib/worktree-sleep-intent', () => ({ clearWorktreeSleepIntent: mocks.clearWorktreeSleepIntent, - markWorktreeSleepIntent: mocks.markWorktreeSleepIntent + markWorktreeSleepIntent: mocks.markWorktreeSleepIntent, + withWorktreeSleepTeardown: (_worktreeId: string, teardown: () => Promise) => teardown() })) import { runSleepWorktree, runSleepWorktrees } from './sleep-worktree-flow' @@ -95,19 +107,17 @@ describe('runSleepWorktree', () => { expect(activeClear).toBeLessThan(browsersCall) }) - it('marks active sleep intent before clearing the active slept worktree', async () => { + it('marks sleep intent before clearing the active slept worktree and keeps it after teardown', async () => { mocks.state.activeWorktreeId = 'wt-1' await runSleepWorktree('wt-1') expect(mocks.markWorktreeSleepIntent).toHaveBeenCalledWith('wt-1') - expect(mocks.clearWorktreeSleepIntent).toHaveBeenCalledWith('wt-1') const markCall = mocks.markWorktreeSleepIntent.mock.invocationCallOrder[0] const activeClear = mocks.state.setActiveWorktree.mock.invocationCallOrder[0] - const terminalShutdown = mocks.state.shutdownWorktreeTerminals.mock.invocationCallOrder[0] - const clearCall = mocks.clearWorktreeSleepIntent.mock.invocationCallOrder[0] expect(markCall).toBeLessThan(activeClear) - expect(terminalShutdown).toBeLessThan(clearCall) + // Why: the marker outlives a successful sleep so mounted panes stay cold until an explicit wake. + expect(mocks.clearWorktreeSleepIntent).not.toHaveBeenCalled() }) it('preserves active row position through section-scoped sidebar row ids', async () => { @@ -181,14 +191,56 @@ describe('runSleepWorktree', () => { expect(pinnedGetBoundingClientRect).not.toHaveBeenCalled() }) - it('leaves activeWorktreeId alone when sleeping a background worktree', async () => { + it('leaves activeWorktreeId alone and marks a background worktree slept', async () => { mocks.state.activeWorktreeId = 'wt-other' await runSleepWorktree('wt-1') expect(mocks.state.setActiveWorktree).not.toHaveBeenCalled() expect(mocks.state.suppressPtyExit).not.toHaveBeenCalled() - expect(mocks.markWorktreeSleepIntent).not.toHaveBeenCalled() + expect(mocks.markWorktreeSleepIntent).toHaveBeenCalledWith('wt-1') + expect(mocks.clearWorktreeSleepIntent).not.toHaveBeenCalled() + }) + + it('leaves a worktree the user activated mid-batch awake', async () => { + let releaseFirst: () => void = () => {} + mocks.state.shutdownWorktreeBrowsers.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFirst = resolve + }) + ) + + const run = runSleepWorktrees(['wt-1', 'wt-2']) + await Promise.resolve() + // Why: the user clicked wt-2 while wt-1 was tearing down; sleeping it anyway + // must not leave the active workspace marked with no clear pending. + mocks.state.activeWorktreeId = 'wt-2' + releaseFirst() + await run + + expect(mocks.clearWorktreeSleepIntent).toHaveBeenLastCalledWith('wt-2') + }) + + it('marks each worktree only when its own teardown starts', async () => { + let releaseFirst: () => void = () => {} + mocks.state.shutdownWorktreeBrowsers.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFirst = resolve + }) + ) + + const run = runSleepWorktrees(['wt-1', 'wt-2']) + await Promise.resolve() + + // Why: wt-2 is still awake while wt-1 tears down; marking it early would + // hold its panes cold and swallow its activity. + expect(mocks.markWorktreeSleepIntent).toHaveBeenCalledWith('wt-1') + expect(mocks.markWorktreeSleepIntent).not.toHaveBeenCalledWith('wt-2') + releaseFirst() + await run + expect(mocks.markWorktreeSleepIntent).toHaveBeenCalledWith('wt-2') }) it('surfaces a toast and skips terminals when browsers throws', async () => { diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts index cf474b28e54..1e414a81800 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts @@ -1,6 +1,10 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' -import { clearWorktreeSleepIntent, markWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' +import { + clearWorktreeSleepIntent, + markWorktreeSleepIntent, + withWorktreeSleepTeardown +} from '@/lib/worktree-sleep-intent' import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } from '@/hooks/useVirtualizedScrollAnchor' import { translate } from '@/i18n/i18n' @@ -141,15 +145,15 @@ export async function runSleepWorktrees(worktreeIds: readonly string[]): Promise shutdownWorktreeBrowsers, shutdownWorktreeTerminals } = useAppStore.getState() - let activeSleepIntentWorktreeId: string | null = null - if (activeWorktreeId && worktreeIds.includes(activeWorktreeId)) { - const restoreSidebarPosition = preserveSidebarWorktreePosition(activeWorktreeId) + const sleptActiveWorktreeId = + activeWorktreeId && worktreeIds.includes(activeWorktreeId) ? activeWorktreeId : null + if (sleptActiveWorktreeId) { + const restoreSidebarPosition = preserveSidebarWorktreePosition(sleptActiveWorktreeId) // Why: clearing the active workspace can unmount TerminalPanes before - // shutdownWorktreeTerminals writes PTY suppressions. Use a non-rendering - // intent marker so those exits do not stamp activity, without inserting an - // extra Zustand update that can disturb the sidebar's scroll restoration. - markWorktreeSleepIntent(activeWorktreeId) - activeSleepIntentWorktreeId = activeWorktreeId + // shutdownWorktreeTerminals writes PTY suppressions; mark first so those + // exits do not stamp activity. Kept off the store so it cannot disturb the + // sidebar's scroll restoration. + markWorktreeSleepIntent(sleptActiveWorktreeId) setActiveWorktree(null) restoreSidebarPosition() } @@ -157,13 +161,17 @@ export async function runSleepWorktrees(worktreeIds: readonly string[]): Promise const failedWorktreeIds = new Set() try { for (const worktreeId of worktreeIds) { + // Why: the marker outlives teardown so the panes left mounted stay cold + // until an explicit wake (#10205); mark per workspace so an earlier + // slow teardown never leaves a later, still-awake one marked. + markWorktreeSleepIntent(worktreeId) try { // Why: sleep mirrors removeWorktree's shutdown sequence — browsers first // so destroyPersistentWebview unregisters the Chromium guests before any // other teardown runs, terminals second so the PTY kill uses the same // ordering on both paths. Without the browser thunk here, sleep leaks // browserPagesByWorkspace entries and live webviews for the slept worktree. - await shutdownWorktreeBrowsers(worktreeId) + await withWorktreeSleepTeardown(worktreeId, () => shutdownWorktreeBrowsers(worktreeId)) } catch (err) { console.error('[sleep-worktree] browser shutdown failed', { worktreeId, error: err }) failedWorktreeIds.add(worktreeId) @@ -178,9 +186,15 @@ export async function runSleepWorktrees(worktreeIds: readonly string[]): Promise // history dir (local) or relay session id (SSH); it also captures // serializer buffers into buffersByLeafId for SSH wake to reseed // scrollback. See DESIGN_DOC_TERMINAL_HISTORY_FIX_V2.md §3.3.c. - await shutdownWorktreeTerminals(worktreeId, { keepIdentifiers: true }) - if (typeof window !== 'undefined' && window.api?.ephemeralVm?.suspendWorkspace) { - await window.api.ephemeralVm.suspendWorkspace({ workspaceId: worktreeId }) + await withWorktreeSleepTeardown(worktreeId, async () => { + await shutdownWorktreeTerminals(worktreeId, { keepIdentifiers: true }) + if (typeof window !== 'undefined' && window.api?.ephemeralVm?.suspendWorkspace) { + await window.api.ephemeralVm.suspendWorkspace({ workspaceId: worktreeId }) + } + }) + // Why: a workspace the user activated during the batch is awake by their choice. + if (useAppStore.getState().activeWorktreeId === worktreeId) { + clearWorktreeSleepIntent(worktreeId) } } catch (err) { console.error('[sleep-worktree] terminal or host suspension failed', { @@ -192,12 +206,12 @@ export async function runSleepWorktrees(worktreeIds: readonly string[]): Promise } } } finally { - if (activeSleepIntentWorktreeId) { - clearWorktreeSleepIntent(activeSleepIntentWorktreeId) - if (failedWorktreeIds.has(activeSleepIntentWorktreeId)) { - // Why: any failed sleep step must leave the workspace visible and retryable. - setActiveWorktree(activeSleepIntentWorktreeId) - } + // Why: a failed sleep leaves the workspace awake and retryable. + for (const worktreeId of failedWorktreeIds) { + clearWorktreeSleepIntent(worktreeId) + } + if (sleptActiveWorktreeId && failedWorktreeIds.has(sleptActiveWorktreeId)) { + setActiveWorktree(sleptActiveWorktreeId) } } if (errors.length > 0) { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.test.ts new file mode 100644 index 00000000000..b49a80d9fe3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.test.ts @@ -0,0 +1,362 @@ +import type * as React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + createMockTransport, + createPane, + createManager, + LEAF_1, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +const { + resetAndRefreshAllTerminalWebglAtlases, + scheduleTerminalWebglAtlasRecovery, + scheduleRuntimeGraphSync, + shouldSeedCacheTimerOnInitialTitle, + toastInfo, + notifyCodexPaneBoundForStaleSweep +} = vi.hoisted(() => ({ + resetAndRefreshAllTerminalWebglAtlases: vi.fn(), + scheduleTerminalWebglAtlasRecovery: vi.fn(), + scheduleRuntimeGraphSync: vi.fn(), + shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), + toastInfo: vi.fn(), + notifyCodexPaneBoundForStaleSweep: vi.fn() +})) + +let mockStoreState: StoreState +let transportFactoryQueue: MockTransport[] = [] +let createdTransportOptions: Record[] = [] +let storeSubscribers: ((state: StoreState) => void)[] = [] + +vi.mock('@/runtime/sync-runtime-graph', () => ({ + scheduleRuntimeGraphSync +})) + +vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ + ...(await importOriginal>()), + resetAndRefreshAllTerminalWebglAtlases +})) + +vi.mock('./terminal-webgl-atlas-recovery', () => ({ + scheduleTerminalWebglAtlasRecovery +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => mockStoreState, + subscribe: (listener: (state: StoreState) => void) => { + storeSubscribers.push(listener) + return () => { + storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) + } + } + } +})) + +vi.mock('@/lib/agent-status', async (importOriginal) => { + const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') + return buildAgentStatusModuleMock(await importOriginal>()) +}) + +vi.mock('./cache-timer-seeding', () => ({ + shouldSeedCacheTimerOnInitialTitle +})) + +vi.mock('sonner', () => ({ + toast: { + info: toastInfo + } +})) + +vi.mock('@/lib/codex-stale-pane-sweep', () => ({ + notifyCodexPaneBoundForStaleSweep +})) + +// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useCallback: unknown>(fn: T): T => fn + } +}) + +vi.mock('./pty-transport', () => ({ + createIpcPtyTransport: vi.fn((options: Record) => { + createdTransportOptions.push(options) + const nextTransport = transportFactoryQueue.shift() + if (!nextTransport) { + throw new Error('No mock transport queued') + } + return nextTransport + }) +})) + +vi.mock('./remote-runtime-pty-transport', () => ({ + createRemoteRuntimePtyTransport: vi.fn( + (_environmentId: string, options: Record) => { + createdTransportOptions.push(options) + const nextTransport = transportFactoryQueue.shift() + if (!nextTransport) { + throw new Error('No mock transport queued') + } + return nextTransport + } + ) +})) + +// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. +vi.mock('./pty-dispatcher', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + getEagerPtyBufferHandle: vi.fn(() => undefined) + } +}) + +function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) +} + +describe('deliberate sleep keeps mounted panes cold', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + installTerminalTestGlobals() + }) + + afterEach(async () => { + const { clearWorktreeSleepIntent } = await import('@/lib/worktree-sleep-intent') + clearWorktreeSleepIntent('wt-1') + await restoreTerminalTestGlobals() + }) + + // Why: manual sleep keeps tab.ptyId as a wake hint, so a remount would take the + // REATTACH arm and the daemon would respawn a shell for the dead id (#10205). + it('does not reattach a slept pane through its retained session id', async () => { + const { connectPanePty } = await import('./pty-connection') + const { markWorktreeSleepIntent } = await import('@/lib/worktree-sleep-intent') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + activeWorktreeId: 'wt-other', + tabsByWorktree: { 'wt-1': [{ id: 'tab-slept', ptyId: 'wt-1@@dead' }] } + } + markWorktreeSleepIntent('wt-1') + const deps = createDeps({ + tabId: 'tab-slept', + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'wt-1@@dead' }, + isVisibleRef: { current: false } + }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + + expect(transport.connect).not.toHaveBeenCalled() + }) + + it('does not fresh-spawn a slept pane that has no session id', async () => { + const { connectPanePty } = await import('./pty-connection') + const { markWorktreeSleepIntent } = await import('@/lib/worktree-sleep-intent') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState = { ...mockStoreState, activeWorktreeId: 'wt-other' } + markWorktreeSleepIntent('wt-1') + const deps = createDeps({ tabId: 'tab-slept-bare', isVisibleRef: { current: false } }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + + expect(transport.connect).not.toHaveBeenCalled() + }) + + it('connects a waiting pane once the workspace is woken', async () => { + const { connectPanePty } = await import('./pty-connection') + const { clearWorktreeSleepIntent, markWorktreeSleepIntent } = + await import('@/lib/worktree-sleep-intent') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + activeWorktreeId: 'wt-other', + tabsByWorktree: { 'wt-1': [{ id: 'tab-woken', ptyId: 'wt-1@@dead' }] } + } + markWorktreeSleepIntent('wt-1') + const deps = createDeps({ + tabId: 'tab-woken', + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'wt-1@@dead' }, + isVisibleRef: { current: false } + }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + expect(transport.connect).not.toHaveBeenCalled() + + clearWorktreeSleepIntent('wt-1') + await flushAsyncTicks() + + expect(transport.connect).toHaveBeenCalledTimes(1) + }) + + it('does not connect a waiting pane whose tab was remounted by the wake', async () => { + const { connectPanePty } = await import('./pty-connection') + const { clearWorktreeSleepIntent, markWorktreeSleepIntent } = + await import('@/lib/worktree-sleep-intent') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + activeWorktreeId: 'wt-other', + tabsByWorktree: { 'wt-1': [{ id: 'tab-remounted', ptyId: 'wt-1@@dead', generation: 0 }] } + } + markWorktreeSleepIntent('wt-1') + const deps = createDeps({ + tabId: 'tab-remounted', + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'wt-1@@dead' }, + isVisibleRef: { current: false } + }) + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + + // Why: activation bumps generation in the same set() that precedes the clear. + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-remounted', ptyId: 'wt-1@@dead', generation: 1 }] } + } + clearWorktreeSleepIntent('wt-1') + await flushAsyncTicks() + + expect(transport.connect).not.toHaveBeenCalled() + }) + + it('resumes a waiting pane mounted under a unified tab id', async () => { + const { connectPanePty } = await import('./pty-connection') + const { clearWorktreeSleepIntent, markWorktreeSleepIntent } = + await import('@/lib/worktree-sleep-intent') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + activeWorktreeId: 'wt-other', + tabsByWorktree: { 'wt-1': [{ id: 'tab-entity', ptyId: null, generation: 3 }] }, + getTab: (id: string) => + id === 'unified-1' ? { id, contentType: 'terminal', entityId: 'tab-entity' } : null + } as never + markWorktreeSleepIntent('wt-1') + const deps = createDeps({ tabId: 'unified-1', isVisibleRef: { current: false } }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + expect(transport.connect).not.toHaveBeenCalled() + + clearWorktreeSleepIntent('wt-1') + await flushAsyncTicks() + + expect(transport.connect).toHaveBeenCalledTimes(1) + }) + + it('re-arms after a wake so a second sleep can hold the pane again', async () => { + const { connectPanePty } = await import('./pty-connection') + const { clearWorktreeSleepIntent, markWorktreeSleepIntent } = + await import('@/lib/worktree-sleep-intent') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + let releaseCwd: (cwd: string) => void = () => {} + const cwdPromise = new Promise((resolve) => { + releaseCwd = resolve + }) + markWorktreeSleepIntent('wt-1') + const deps = createDeps({ tabId: 'tab-resleep', isVisibleRef: { current: false }, cwdPromise }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + // Wake: the pane leaves the sleep gate and parks on the cwd gate. + clearWorktreeSleepIntent('wt-1') + await flushAsyncTicks() + // Sleep again before the cwd settles, then wake again. + markWorktreeSleepIntent('wt-1') + releaseCwd('/cwd') + await flushAsyncTicks() + expect(transport.connect).not.toHaveBeenCalled() + clearWorktreeSleepIntent('wt-1') + await flushAsyncTicks() + + expect(transport.connect).toHaveBeenCalledTimes(1) + }) + + it('drops the wake listener when a waiting pane is disposed', async () => { + const { connectPanePty } = await import('./pty-connection') + const { clearWorktreeSleepIntent, markWorktreeSleepIntent } = + await import('@/lib/worktree-sleep-intent') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + markWorktreeSleepIntent('wt-1') + const deps = createDeps({ tabId: 'tab-disposed', isVisibleRef: { current: false } }) + + const binding = connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + binding.dispose() + // Why: the connect body already refuses a disposed session, so prove the + // listener itself is gone: a wake after dispose reaches no subscriber. + const wakeCalls: number[] = [] + const { onWorktreeSleepIntentCleared } = await import('@/lib/worktree-sleep-intent') + onWorktreeSleepIntentCleared('wt-1', () => wakeCalls.push(1)) + clearWorktreeSleepIntent('wt-1') + await flushAsyncTicks() + + expect(transport.connect).not.toHaveBeenCalled() + expect(wakeCalls).toHaveLength(1) + }) + + it('still connects a slept pane that carries a queued startup', async () => { + const { connectPanePty } = await import('./pty-connection') + const { markWorktreeSleepIntent } = await import('@/lib/worktree-sleep-intent') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + markWorktreeSleepIntent('wt-1') + const deps = createDeps({ + tabId: 'tab-slept-startup', + isVisibleRef: { current: false }, + startup: { command: 'printf wake', launchAgent: undefined } + }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + + expect(transport.connect).toHaveBeenCalledTimes(1) + }) + + it('connects normally once the marker is released', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const deps = createDeps({ + tabId: 'tab-awake', + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'wt-1@@live' }, + isVisibleRef: { current: false } + }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + + expect(transport.connect).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts index 2b55854c676..30e1352204d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts @@ -36,7 +36,7 @@ import { installPtyInputRecovery } from './pty-input-recovery' import { installPtyInputForward } from './pty-input-forward' import { installPtyResizeGeometry } from './pty-resize-geometry' import { installSessionReconcileDispose } from './session-reconcile-dispose' -import { resolveTerminalTabId } from './terminal-tab-id' +import { findTerminalTabForPane } from './terminal-tab-id' /** * Establishes a binding between a terminal pane and its corresponding PTY stream, @@ -50,43 +50,8 @@ export function connectPanePty( const session = { pane, manager, deps } as ConnectPanePtySession session.shouldRefreshForegroundSynchronously = (): boolean => !session.manager.hasWebglRenderer(session.pane.id) - const state = useAppStore.getState() - const unifiedTab = state.getTab?.(deps.tabId) - const initialOwnerWorktreeId = - state.getTerminalTabOwnerWorktreeId?.(deps.tabId) ?? - (unifiedTab?.contentType === 'terminal' - ? state.getTerminalTabOwnerWorktreeId?.(unifiedTab.entityId) - : null) - const terminalTabId = resolveTerminalTabId( - { - getTab: state.getTab, - hasTerminalTab: (candidateId) => - Boolean( - state.tabsByWorktree[deps.worktreeId]?.some( - (candidate) => candidate.id === candidateId - ) || - (initialOwnerWorktreeId - ? state.tabsByWorktree[initialOwnerWorktreeId]?.some( - (candidate) => candidate.id === candidateId - ) - : false) - ) - }, - deps.tabId - ) - const ownerWorktreeId = - state.getTerminalTabOwnerWorktreeId?.(terminalTabId) ?? initialOwnerWorktreeId - const terminalTab = - state.tabsByWorktree[deps.worktreeId]?.find((candidate) => candidate.id === terminalTabId) ?? - (ownerWorktreeId - ? state.tabsByWorktree[ownerWorktreeId]?.find((candidate) => candidate.id === terminalTabId) - : undefined) ?? - // Why: folder/worktree migrations can leave the pane's render key stale for one commit. - Object.values(state.tabsByWorktree) - .find((tabs) => tabs.some((candidate) => candidate.id === terminalTabId)) - ?.find((candidate) => candidate.id === terminalTabId) - const tab = terminalTab ?? (unifiedTab && 'generation' in unifiedTab ? unifiedTab : null) - session.tabGeneration = tab?.generation ?? 0 + session.tabGeneration = + findTerminalTabForPane(useAppStore.getState(), deps.worktreeId, deps.tabId)?.generation ?? 0 // Why: recovery ownership belongs to this xterm instance. A request that // settles after remount must not remount its already-replaced successor. session.terminalRecoveryGeneration = captureTerminalPaneRecoveryGeneration(session.deps.tabId) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts index b1319e3137c..05b1ad7fff0 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts @@ -15,7 +15,7 @@ import type { } from './fresh-spawn-types' import type { ConnectPanePtySession } from './connect-pane-pty-session' -import { resolveTerminalTabId } from './terminal-tab-id' +import { findTerminalTabForPane } from './terminal-tab-id' export function bindStartFreshSpawn(session: ConnectPanePtySession): void { session.startFreshSpawn = ( @@ -111,51 +111,10 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { ...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}), ...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}), ...(session.shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), - shouldContinue: () => { - const state = useAppStore.getState() - const unifiedTab = state.getTab?.(session.deps.tabId) - const initialOwnerWorktreeId = - state.getTerminalTabOwnerWorktreeId?.(session.deps.tabId) ?? - (unifiedTab?.contentType === 'terminal' - ? state.getTerminalTabOwnerWorktreeId?.(unifiedTab.entityId) - : null) - const terminalTabId = resolveTerminalTabId( - { - getTab: state.getTab, - hasTerminalTab: (candidateId) => - Boolean( - state.tabsByWorktree[session.deps.worktreeId]?.some( - (candidate) => candidate.id === candidateId - ) || - (initialOwnerWorktreeId - ? state.tabsByWorktree[initialOwnerWorktreeId]?.some( - (candidate) => candidate.id === candidateId - ) - : false) - ) - }, - session.deps.tabId - ) - const ownerWorktreeId = - state.getTerminalTabOwnerWorktreeId?.(terminalTabId) ?? initialOwnerWorktreeId - const terminalTab = - state.tabsByWorktree[session.deps.worktreeId]?.find( - (candidate) => candidate.id === terminalTabId - ) ?? - (ownerWorktreeId - ? state.tabsByWorktree[ownerWorktreeId]?.find( - (candidate) => candidate.id === terminalTabId - ) - : undefined) - const fallbackTab = Object.values(state.tabsByWorktree) - .find((tabs) => tabs.some((candidate) => candidate.id === terminalTabId)) - ?.find((candidate) => candidate.id === terminalTabId) - const currentTab = - terminalTab ?? - fallbackTab ?? - (unifiedTab && 'generation' in unifiedTab ? unifiedTab : null) - return !session.disposed && (currentTab?.generation ?? 0) === session.tabGeneration - }, + shouldContinue: () => + !session.disposed && + (findTerminalTabForPane(useAppStore.getState(), session.deps.worktreeId, session.deps.tabId) + ?.generation ?? 0) === session.tabGeneration, callbacks: outputCallbacks.callbacks }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts b/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts index 120805f9d29..3dc1c3aaef5 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts @@ -1,9 +1,13 @@ import { createTerminalZeroDimensionsMessage } from '../../../../../shared/terminal-zero-dimensions-diagnostic' import { isWorktreeRemovalFenceError } from '../../../../../shared/worktree/removal-fence-error' import { safeFit } from '@/lib/pane-manager/pane-tree-ops' +import { useAppStore } from '@/store' import { createCodexBackfillErrorDetector } from '../codex-backfill-error-detector' +import { hasWorktreeSleepIntent, onWorktreeSleepIntentCleared } from '@/lib/worktree-sleep-intent' import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' +import { recordPtyConnectDiagnostic } from './pty-connect-limits' +import { findTerminalTabForPane } from './terminal-tab-id' import type { ConnectPanePtySession } from './connect-pane-pty-session' import { bindBuildColdRestoreAgentResumeStartup } from './cold-restore-resume-startup' @@ -25,11 +29,48 @@ export function installRunDeferredConnect(session: ConnectPanePtySession): void const cwdPromise = session.deps.cwdPromise let cwdPromiseSettled = cwdPromise === undefined let cwdPromiseWaitStarted = false + let wakeWaitStarted = false session.runDeferredConnect = (): void => { if (session.connectStarted) { return } + // Why: a deliberately slept workspace keeps its panes mounted, so connecting + // would reattach the retained session id and respawn the shell (#10205). + // Wait for the wake instead; a queued startup is an explicit launch. + if (hasWorktreeSleepIntent(session.deps.worktreeId) && !session.paneStartup) { + session.cancelScheduledConnectFrame() + if (session.connectFallbackTimer !== null) { + clearTimeout(session.connectFallbackTimer) + session.connectFallbackTimer = null + } + if (!wakeWaitStarted) { + wakeWaitStarted = true + recordPtyConnectDiagnostic( + `pane=${session.pane.id} tab=${session.deps.tabId} -> WAIT FOR WAKE (deliberate sleep)` + ) + const unsubscribe = onWorktreeSleepIntentCleared(session.deps.worktreeId, () => { + wakeWaitStarted = false + const index = session.waitTeardowns.indexOf(unsubscribe) + if (index !== -1) { + session.waitTeardowns.splice(index, 1) + } + // Why: an activation wake bumps the tab generation in the same tick and the + // remounted pane connects on its own; a stale generation must not connect too. + const currentTab = findTerminalTabForPane( + useAppStore.getState(), + session.deps.worktreeId, + session.deps.tabId + ) + if (!session.disposed && (currentTab?.generation ?? 0) === session.tabGeneration) { + session.runDeferredConnect() + } + }) + // Why: disposal unsubscribes so a torn-down pane never connects on a later wake. + session.waitTeardowns.push(unsubscribe) + } + return + } if (!cwdPromiseSettled) { session.cancelScheduledConnectFrame() if (session.connectFallbackTimer !== null) { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/terminal-tab-id.ts b/src/renderer/src/components/terminal-pane/pty-connection/terminal-tab-id.ts index c4e02f82af0..1055d69fe15 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/terminal-tab-id.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/terminal-tab-id.ts @@ -13,3 +13,54 @@ export function resolveTerminalTabId(state: TerminalTabLookup, tabId: string): s const unifiedTab = state.getTab?.(tabId) return unifiedTab?.contentType === 'terminal' ? unifiedTab.entityId : tabId } + +type TerminalTabRecord = { id: string; generation?: number } +type TerminalTabState = { + getTab?: ( + tabId: string + ) => ({ contentType: string; entityId: string } & Partial) | null + tabsByWorktree: Record + getTerminalTabOwnerWorktreeId?: (tabId: string) => string | null | undefined +} + +/** + * Resolve the live terminal tab (or unified tab) a pane renders for, by either id + * form. Why the fallbacks: folder/worktree migrations can leave the pane's render + * key stale for one commit, and a unified id's terminal tab lives under entityId. + */ +export function findTerminalTabForPane( + state: TerminalTabState, + worktreeId: string, + tabId: string +): TerminalTabRecord | null { + const unifiedTab = state.getTab?.(tabId) + const initialOwnerWorktreeId = + state.getTerminalTabOwnerWorktreeId?.(tabId) ?? + (unifiedTab?.contentType === 'terminal' + ? state.getTerminalTabOwnerWorktreeId?.(unifiedTab.entityId) + : null) + const hasTabIn = (id: string | null | undefined, candidateId: string): boolean => + Boolean(id && state.tabsByWorktree[id]?.some((candidate) => candidate.id === candidateId)) + const terminalTabId = resolveTerminalTabId( + { + getTab: state.getTab, + hasTerminalTab: (candidateId) => + hasTabIn(worktreeId, candidateId) || hasTabIn(initialOwnerWorktreeId, candidateId) + }, + tabId + ) + const ownerWorktreeId = + state.getTerminalTabOwnerWorktreeId?.(terminalTabId) ?? initialOwnerWorktreeId + const byId = (id: string | null | undefined): TerminalTabRecord | undefined => + id ? state.tabsByWorktree[id]?.find((candidate) => candidate.id === terminalTabId) : undefined + return ( + byId(worktreeId) ?? + byId(ownerWorktreeId) ?? + Object.values(state.tabsByWorktree) + .flat() + .find((candidate) => candidate.id === terminalTabId) ?? + (unifiedTab && 'generation' in unifiedTab + ? { id: unifiedTab.entityId, generation: unifiedTab.generation } + : null) + ) +} diff --git a/src/renderer/src/lib/worktree-sleep-intent.ts b/src/renderer/src/lib/worktree-sleep-intent.ts index 7beac240803..6512abec692 100644 --- a/src/renderer/src/lib/worktree-sleep-intent.ts +++ b/src/renderer/src/lib/worktree-sleep-intent.ts @@ -1,13 +1,69 @@ +// Why: a slept workspace keeps its panes mounted with only dead PTYs behind them. +// Any pane connect that runs while the marker is set waits here, and the clear +// that marks the workspace awake resumes every waiting connect. const sleepingWorktreeIds = new Set() +const tearingDownWorktreeIds = new Set() +const wakeListenersByWorktreeId = new Map void>>() export function markWorktreeSleepIntent(worktreeId: string): void { sleepingWorktreeIds.add(worktreeId) } -export function clearWorktreeSleepIntent(worktreeId: string): void { +/** + * Why: a spawn that resolves while the sleep teardown is still awaiting its host + * would bind a PTY and clear the marker, waking every waiting pane mid-sleep. + * Binds during the teardown window are not wakes. + */ +export async function withWorktreeSleepTeardown( + worktreeId: string, + teardown: () => Promise +): Promise { + tearingDownWorktreeIds.add(worktreeId) + try { + return await teardown() + } finally { + tearingDownWorktreeIds.delete(worktreeId) + } +} + +export function clearWorktreeSleepIntent(worktreeId: string | null): void { + if (!worktreeId || tearingDownWorktreeIds.has(worktreeId)) { + return + } + if (!sleepingWorktreeIds.delete(worktreeId)) { + return + } + const listeners = wakeListenersByWorktreeId.get(worktreeId) + wakeListenersByWorktreeId.delete(worktreeId) + for (const listener of listeners ?? []) { + try { + listener() + } catch (error) { + // Why: one pane's connect failure must not strand its siblings or throw out of a store action. + console.error('[sleep-intent] wake listener failed', { worktreeId, error }) + } + } +} + +// Why: a purged worktree must not wake its panes; they are being unmounted. +export function forgetWorktreeSleepIntent(worktreeId: string): void { sleepingWorktreeIds.delete(worktreeId) + tearingDownWorktreeIds.delete(worktreeId) + wakeListenersByWorktreeId.delete(worktreeId) } export function hasWorktreeSleepIntent(worktreeId: string | null): boolean { return worktreeId !== null && sleepingWorktreeIds.has(worktreeId) } + +export function onWorktreeSleepIntentCleared(worktreeId: string, listener: () => void): () => void { + const listeners = wakeListenersByWorktreeId.get(worktreeId) ?? new Set<() => void>() + listeners.add(listener) + wakeListenersByWorktreeId.set(worktreeId, listeners) + return () => { + listeners.delete(listener) + if (listeners.size === 0 && wakeListenersByWorktreeId.get(worktreeId) === listeners) { + wakeListenersByWorktreeId.delete(worktreeId) + } + } +} diff --git a/src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts b/src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts new file mode 100644 index 00000000000..8b3c73e2ff1 --- /dev/null +++ b/src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts @@ -0,0 +1,167 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as intent from '@/lib/worktree-sleep-intent' +import { buildWorktreePurgeState } from './worktrees/teardown/worktree-purge-state' +import { createTestStore, makeWorktree, seedStore } from './store-test-helpers' +import { createStoreCascadesMockApi } from './store-cascades-test-harness' + +const { clearWorktreeSleepIntent, hasWorktreeSleepIntent, markWorktreeSleepIntent } = intent +const WORKTREE_ID = 'repo1::/path/wt1' +const FOLDER_KEY = 'folder:folder-1' + +createStoreCascadesMockApi() + +function seedWorktree(store: ReturnType): void { + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: WORKTREE_ID, repoId: 'repo1', path: '/path/wt1' })] + }, + refreshGitHubForWorktree: vi.fn(), + refreshGitHubForWorktreeIfStale: vi.fn() + }) +} + +// Why this suite exists: the sleep marker outlives teardown so mounted panes stay cold +// (#10205). Every route that makes a workspace awake again must release it, or the +// workspace is stuck cold and its PTY exits stop counting as activity. +describe('worktree sleep intent lifecycle', () => { + beforeEach(() => { + clearWorktreeSleepIntent(WORKTREE_ID) + clearWorktreeSleepIntent(FOLDER_KEY) + }) + + it('is released by activating the worktree', () => { + const store = createTestStore() + seedWorktree(store) + markWorktreeSleepIntent(WORKTREE_ID) + + store.getState().setActiveWorktree(WORKTREE_ID) + + expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(false) + }) + + it('survives the sleep flow clearing the active selection', () => { + const store = createTestStore() + seedWorktree(store) + markWorktreeSleepIntent(WORKTREE_ID) + + store.getState().setActiveWorktree(null) + + expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(true) + }) + + it('is released by activating a folder workspace', () => { + const store = createTestStore() + store.setState({ + folderWorkspaces: [ + { + id: 'folder-1', + projectGroupId: 'group-1', + name: 'Folder', + folderPath: '/folder', + executionHostId: 'local', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 + } + ] + }) + markWorktreeSleepIntent(FOLDER_KEY) + + store.getState().setActiveFolderWorkspace('folder-1') + + expect(hasWorktreeSleepIntent(FOLDER_KEY)).toBe(false) + }) + + it('is released when any PTY binds to a tab in the worktree', () => { + const store = createTestStore() + seedWorktree(store) + const tab = store.getState().createTab(WORKTREE_ID, undefined, undefined, { activate: false }) + markWorktreeSleepIntent(WORKTREE_ID) + + store.getState().updateTabPtyId(tab.id, 'pty-cli-created') + + expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(false) + }) + + it('is released when a tab is created with a live PTY', () => { + const store = createTestStore() + seedWorktree(store) + markWorktreeSleepIntent(WORKTREE_ID) + + store.getState().createTab(WORKTREE_ID, undefined, undefined, { + activate: false, + initialPtyId: 'pty-cli-created' + }) + + expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(false) + }) + + it('notifies wake listeners once and only on a real clear', () => { + const { onWorktreeSleepIntentCleared } = intent + const woke = vi.fn() + markWorktreeSleepIntent(WORKTREE_ID) + const unsubscribe = onWorktreeSleepIntentCleared(WORKTREE_ID, woke) + + clearWorktreeSleepIntent('repo1::/path/other') + expect(woke).not.toHaveBeenCalled() + clearWorktreeSleepIntent(WORKTREE_ID) + clearWorktreeSleepIntent(WORKTREE_ID) + expect(woke).toHaveBeenCalledTimes(1) + + markWorktreeSleepIntent(WORKTREE_ID) + clearWorktreeSleepIntent(WORKTREE_ID) + expect(woke).toHaveBeenCalledTimes(1) + unsubscribe() + }) + + it('ignores a PTY bind that lands while the sleep teardown is in flight', async () => { + const store = createTestStore() + seedWorktree(store) + const tab = store.getState().createTab(WORKTREE_ID, undefined, undefined, { activate: false }) + markWorktreeSleepIntent(WORKTREE_ID) + const woke = vi.fn() + intent.onWorktreeSleepIntentCleared(WORKTREE_ID, woke) + + await intent.withWorktreeSleepTeardown(WORKTREE_ID, async () => { + store.getState().updateTabPtyId(tab.id, 'pty-late-spawn') + }) + + expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(true) + expect(woke).not.toHaveBeenCalled() + store.getState().updateTabPtyId(tab.id, 'pty-after-teardown') + expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(false) + }) + + it('keeps notifying siblings when one wake listener throws', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const woke = vi.fn() + markWorktreeSleepIntent(WORKTREE_ID) + intent.onWorktreeSleepIntentCleared(WORKTREE_ID, () => { + throw new Error('boom') + }) + intent.onWorktreeSleepIntentCleared(WORKTREE_ID, woke) + + expect(() => clearWorktreeSleepIntent(WORKTREE_ID)).not.toThrow() + expect(woke).toHaveBeenCalledTimes(1) + errorSpy.mockRestore() + }) + + it('is forgotten without waking panes when the worktree is purged', () => { + const store = createTestStore() + seedWorktree(store) + markWorktreeSleepIntent(WORKTREE_ID) + const woke = vi.fn() + intent.onWorktreeSleepIntentCleared(WORKTREE_ID, woke) + + store.setState(buildWorktreePurgeState(store.getState(), [WORKTREE_ID])) + + expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(false) + expect(woke).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts b/src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts index d2b5af79114..b89da1ace44 100644 --- a/src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts +++ b/src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts @@ -9,6 +9,7 @@ import { } from '../listing/detected-worktree-meta' import { shouldDeferActivationTerminalPrep } from './activation-terminal-prep' import { deriveActiveSurfaceForWorktree } from '../../tabs/tabs-surface' +import { clearWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' export function createSetActiveFolderWorkspace( set: WorktreeSliceSet, @@ -62,6 +63,8 @@ export function createSetActiveFolderWorkspace( : s.folderWorkspaces } }) + // Why: cleared after the set() so a waiting pane connects against the activated state. + clearWorktreeSleepIntent(workspaceKey) if (workspace.isUnread) { void get().updateFolderWorkspace( folderWorkspaceId, diff --git a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts index b4ec0b0f99a..31c7e8bbb37 100644 --- a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts @@ -24,6 +24,7 @@ import { } from '../listing/detected-worktree-meta' import { persistPassiveWorktreeMetaForOwner } from '../listing/worktree-owner-settings' import { resolveActivatedWorktreeSurface } from './active-worktree-surface' +import { clearWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import { pendingActivationTerminalPrepCancels, shouldDeferActivationTerminalPrep @@ -206,6 +207,11 @@ export function createSetActiveWorktree( } }) + // Why: any activation is an explicit wake (null is the sleep flow clearing selection). + // Cleared after the set() above so a pane still waiting on the marker connects once, + // in the remounted generation, instead of connecting and then being remounted. + clearWorktreeSleepIntent(worktreeId) + if (worktreeId && shouldPrepareTerminalTabs) { const prepareTerminalTabs = (): void => { pendingActivationTerminalPrepCancels.delete(worktreeId) diff --git a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts index 075172ddce1..a6a9c87d838 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts @@ -6,6 +6,7 @@ import { parseExecutionHostId } from '../../../../../../shared/execution-host' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' import { forgetHugeRepoWarningDismissalsForWorktrees } from '@/lib/source-control-huge-repo-warning-dismissals' +import { forgetWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast' import { resolveWorktreeOperationRouteResult, @@ -222,6 +223,7 @@ export function createRemoveWorktree( // Why: invalidate stale probes once deletion is authoritative, so an old toast can't mutate a same-path replacement. forgetHugeRepoWarningDismissalsForWorktrees([worktreeId]) + forgetWorktreeSleepIntent(worktreeId) // Why: forget-local is legal while the host is unreachable, so record the removal here too — otherwise an // in-flight metadata read that snapshotted this row re-appends it, and disconnected polls never drop it. if (hostId && parseExecutionHostId(hostId)?.kind === 'ssh') { diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts index 78a57da7955..e2f6e5945fe 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts @@ -8,6 +8,7 @@ import { createWorktreePurgeOmitters } from './worktree-purge-omitters' import { removeDeleteStatesForWorktreeIds } from './worktree-delete-state' import { removeWorktreeVisitEntriesForTargets } from '@/lib/worktree-visit-recency' import { forgetAmbiguousOwnerWarnings } from '../listing/worktree-owner-settings' +import { forgetWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' export function buildWorktreePurgeState( s: AppState, @@ -18,6 +19,10 @@ export function buildWorktreePurgeState( ) const worktreeIdSet = new Set(normalizedTargets.map((target) => target.id)) pruneHostedReviewLinkMutationGenerations(worktreeIdSet) + // Why: ids are repo::path, so a worktree recreated at the same path must not inherit a stale sleep. + for (const id of worktreeIdSet) { + forgetWorktreeSleepIntent(id) + } // Why: every authoritative and explicit purge converges here, so a deleted path can't inherit stale UI state. forgetHugeRepoWarningDismissalsForWorktrees(worktreeIdSet) forgetAmbiguousOwnerWarnings(worktreeIdSet) diff --git a/src/renderer/src/store/terminals/terminal-pty-bindings.ts b/src/renderer/src/store/terminals/terminal-pty-bindings.ts index 2e0397aff45..499ea63e8de 100644 --- a/src/renderer/src/store/terminals/terminal-pty-bindings.ts +++ b/src/renderer/src/store/terminals/terminal-pty-bindings.ts @@ -9,6 +9,7 @@ import { isRemoteRuntimePtyId } from './terminal-pty-identities' import { omitUnverifiedPtyLossTabIds } from './terminal-unverified-pty-loss' +import { clearWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import { omitDisownedPtyIds } from './terminal-disowned-pty-sources' export function createTerminalPtyBindingActions( @@ -275,6 +276,8 @@ export function createTerminalPtyBindingActions( ...(shouldBumpSortEpoch ? { sortEpoch: s.sortEpoch + 1 } : {}) } }) + // Why: a bound PTY means the workspace is awake by any route (CLI, automation, client wake), not only activation. + clearWorktreeSleepIntent(worktreeId) // Why: activation spawns come from clicking a worktree, not work in it — skip the lastActivityAt stamp and sortEpoch bump; other spawn reasons still bump. if (worktreeId && !wasActivationSpawn && !isRemoteRuntimeMirror) { get().bumpWorktreeActivity(worktreeId) diff --git a/src/renderer/src/store/terminals/terminal-tab-creation.ts b/src/renderer/src/store/terminals/terminal-tab-creation.ts index 11f9d1d2a59..83310850475 100644 --- a/src/renderer/src/store/terminals/terminal-tab-creation.ts +++ b/src/renderer/src/store/terminals/terminal-tab-creation.ts @@ -1,3 +1,4 @@ +import { clearWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { isValidHostTerminalTabId } from '../../../../shared/terminal-tab-id' import { emptyLayoutSnapshot, singlePaneLayoutSnapshot } from '../slices/terminal-helpers' @@ -271,6 +272,10 @@ export function createTerminalTabCreationActions( } } }) + if (options?.initialPtyId) { + // Why: a tab born with a live PTY (CLI/runtime create) wakes the workspace like any other bind. + clearWorktreeSleepIntent(worktreeId) + } const shouldRecordInteraction = options?.recordInteraction ?? (!options?.pendingActivationSpawn && !options?.initialPtyId) if (shouldRecordInteraction) { diff --git a/tests/e2e/helpers/slept-workspace-probe.ts b/tests/e2e/helpers/slept-workspace-probe.ts new file mode 100644 index 00000000000..b2b5635b526 --- /dev/null +++ b/tests/e2e/helpers/slept-workspace-probe.ts @@ -0,0 +1,133 @@ +/** + * Shared probes for GH #10205: a deliberately slept workspace must stay cold. + * Drives the shipping sleep path (sidebar context menu) and reads both the + * renderer's live PTY model and host truth. + */ +import type { Locator, Page } from '@stablyai/playwright-test' +import { expect } from '@stablyai/playwright-test' +import { ensureTerminalVisible } from './store' +import { waitForActivePanePtyId, waitForActiveTerminalManager } from './terminal' + +export type WorkspaceSample = { + livePtyCount: number + tabCount: number + tabIds: string[] + mountedTabIds: string[] + tabPtyHints: (string | null)[] +} + +export function rowLocator(page: Page, worktreeId: string): Locator { + return page + .locator( + `[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(worktreeId)}]` + ) + .first() +} + +export async function readWorkspaceSample( + page: Page, + worktreeId: string +): Promise { + return page.evaluate((id) => { + const state = window.__store?.getState() + if (!state) { + throw new Error('window.__store is not available') + } + const tabs = state.tabsByWorktree[id] ?? [] + const tabIds = new Set(tabs.map((tab) => tab.id)) + const managers = window.__paneManagers + return { + livePtyCount: tabs.reduce( + (count, tab) => count + (state.ptyIdsByTabId[tab.id]?.length ?? 0), + 0 + ), + tabCount: tabs.length, + tabIds: tabs.map((tab) => tab.id), + mountedTabIds: managers + ? Array.from(managers.keys()).filter((tabId) => tabIds.has(tabId)) + : [], + tabPtyHints: tabs.map((tab) => tab.ptyId ?? null) + } + }, worktreeId) +} + +/** Host-side truth: a revived workspace shows a freshly created live session here. */ +export async function readHostLiveTerminalCount(page: Page, worktreeId: string): Promise { + return (await page.evaluate(async (id) => { + const result = await window.api.runtime.call({ + method: 'terminal.list', + params: { worktree: `id:${id}`, requireFreshPtyLiveness: true } + }) + if (!result.ok) { + throw new Error(result.error.message) + } + return (result.result as { totalCount: number }).totalCount + }, worktreeId)) as number +} + +/** Connect-verdict lines (REATTACH / ATTACH / FRESH SPAWN / SKIP SPAWN) for one workspace. */ +export async function readConnectDiagnostics(page: Page, worktreeId: string): Promise { + return page.evaluate((id) => { + const state = window.__store?.getState() + const target = globalThis as unknown as Record + const diag = (target.__ptyConnectDiag as string[] | undefined) ?? [] + const tabIds = new Set((state?.tabsByWorktree[id] ?? []).map((tab) => tab.id)) + // Pane ids restart at 1 per worktree, so a verdict line is attributed to the + // tab named by the most recent connect line for that same pane id. + const tabByPaneId = new Map() + const owned: string[] = [] + for (const line of diag) { + const connect = /^pane=(\d+) tab=(\S+) /.exec(line) + if (connect) { + tabByPaneId.set(connect[1], connect[2]) + if (tabIds.has(connect[2])) { + owned.push(line) + } + continue + } + const verdict = /^pane=(\d+) ->/.exec(line) + if (verdict) { + const tabId = tabByPaneId.get(verdict[1]) + if (tabId && tabIds.has(tabId)) { + owned.push(line) + } + } + } + return owned + }, worktreeId) +} + +export async function giveWorkspaceALivePty(page: Page, worktreeId: string): Promise { + await page.evaluate((id) => { + window.__store?.getState().setActiveWorktree(id) + }, worktreeId) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + return waitForActivePanePtyId(page, 30_000) +} + +/** The shipping sleep path: right-click the sidebar row, click "Sleep". */ +export async function sleepWorkspaceViaSidebar(page: Page, worktreeId: string): Promise { + const row = rowLocator(page, worktreeId) + await expect(row).toBeVisible() + await row.scrollIntoViewIfNeeded() + const scope = row.locator('[data-worktree-context-menu-scope="worktree"]').first() + const target = (await scope.count()) > 0 ? scope : row + await target.click({ button: 'right' }) + const sleepItem = page.getByRole('menuitem', { name: 'Sleep', exact: true }).first() + await expect(sleepItem).toBeVisible() + await sleepItem.click() +} + +export async function activateWorkspaceByClick(page: Page, worktreeId: string): Promise { + const row = rowLocator(page, worktreeId) + await expect(row).toBeVisible() + await row.scrollIntoViewIfNeeded() + await row.click() + await expect + .poll(() => page.evaluate(() => window.__store?.getState().activeWorktreeId ?? null), { + timeout: 10_000, + message: `sidebar click did not activate ${worktreeId}` + }) + .toBe(worktreeId) +} diff --git a/tests/e2e/slept-workspace-remount-wake.spec.ts b/tests/e2e/slept-workspace-remount-wake.spec.ts new file mode 100644 index 00000000000..f820a7c7f69 --- /dev/null +++ b/tests/e2e/slept-workspace-remount-wake.spec.ts @@ -0,0 +1,87 @@ +/** + * GH #10205: a manual sleep keeps the tab's session id as a wake hint, so a later + * remount of its still-mounted pane reattaches that dead id and the daemon spawns + * a fresh shell. Production parking timings are deliberate: a shrunk park delay + * unmounts the slept panes and hides the behavior. + */ +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { getAllWorktreeIds, waitForSessionReady } from './helpers/store' +import { + activateWorkspaceByClick, + giveWorkspaceALivePty, + readConnectDiagnostics, + readHostLiveTerminalCount, + readWorkspaceSample, + sleepWorkspaceViaSidebar +} from './helpers/slept-workspace-probe' + +const OBSERVATION_MS = 8_000 +const SAMPLE_INTERVAL_MS = 200 + +async function assertStaysCold(page: Page, worktreeId: string): Promise { + let peakLivePty = 0 + let peakTabs = 0 + const deadline = Date.now() + OBSERVATION_MS + while (Date.now() < deadline) { + const sample = await readWorkspaceSample(page, worktreeId) + peakLivePty = Math.max(peakLivePty, sample.livePtyCount) + peakTabs = Math.max(peakTabs, sample.tabCount) + await page.waitForTimeout(SAMPLE_INTERVAL_MS) + } + const hostLive = await readHostLiveTerminalCount(page, worktreeId) + const diag = await readConnectDiagnostics(page, worktreeId) + console.error(`[#10205] ${JSON.stringify({ peakLivePty, peakTabs, hostLive, diag })}`) + expect(peakLivePty, 'slept workspace grew a live PTY').toBe(0) + expect(peakTabs, 'slept workspace grew a tab').toBe(1) + expect(hostLive, 'host created a session for the slept workspace').toBe(0) + // Why: proves the gate held rather than the pane having quietly unmounted. + expect(diag.at(-1), 'remounted pane did not wait for the wake').toContain('WAIT FOR WAKE') +} + +test('remounting a slept hidden pane does not respawn its PTY', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + const [slept, other] = await getAllWorktreeIds(orcaPage) + expect(other, 'seeded repo must expose two worktrees').toBeTruthy() + await giveWorkspaceALivePty(orcaPage, slept) + await giveWorkspaceALivePty(orcaPage, other) + await activateWorkspaceByClick(orcaPage, slept) + expect((await readWorkspaceSample(orcaPage, slept)).livePtyCount).toBeGreaterThan(0) + + await sleepWorkspaceViaSidebar(orcaPage, slept) + await expect + .poll(async () => (await readWorkspaceSample(orcaPage, slept)).livePtyCount, { + timeout: 20_000, + message: 'sleep did not release the workspace PTYs' + }) + .toBe(0) + await activateWorkspaceByClick(orcaPage, other) + + const sample = await readWorkspaceSample(orcaPage, slept) + const sleptTabId = sample.tabIds[0] + expect(sleptTabId, 'slept workspace must retain a tab').toBeTruthy() + // Presence preconditions: the pane is still mounted and still carries its wake hint, + // otherwise a remount has nothing to reattach and the oracle passes vacuously. + expect(sample.mountedTabIds, 'slept pane was parked before the remount').toContain(sleptTabId) + expect(sample.tabPtyHints[0], 'sleep must keep the session id as a wake hint').toBeTruthy() + + const remounted = await orcaPage.evaluate( + (tabId) => window.__store?.getState().remountTerminalTabForRecovery(tabId) ?? false, + sleptTabId + ) + expect(remounted, 'remountTerminalTabForRecovery did not find the slept tab').toBe(true) + await assertStaysCold(orcaPage, slept) + + // Non-vacuity: a deliberate click must still wake it, and exactly once — the + // waiting pane and its remounted successor must not both reattach. + await activateWorkspaceByClick(orcaPage, slept) + await expect + .poll(async () => (await readWorkspaceSample(orcaPage, slept)).livePtyCount, { + timeout: 40_000, + message: 'the slept workspace never wakes even on deliberate activation' + }) + .toBeGreaterThan(0) + await orcaPage.waitForTimeout(3_000) + expect((await readWorkspaceSample(orcaPage, slept)).livePtyCount).toBe(1) + expect(await readHostLiveTerminalCount(orcaPage, slept)).toBe(1) +}) From 94c2f96ea46e9f0a182eb2c7ad18068ab08a2b91 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:50:13 -0700 Subject: [PATCH 009/191] perf(daemon): stop scanning every cell for OSC links that cannot exist (#20077) collectHeadlessOscLinkRanges walks every cell of every row on each snapshot, and called xterm's getCell without the reuse argument its own docs recommend, so a link-free scrollback paid a CellData allocation per cell for a guaranteed empty result. Skip the scan when xterm holds no OSC 8 registration, and reuse one cell when it does. Measured over a 5000-row link-free buffer at 200 cols, same harness back to back, median of 25: 43.85ms -> 0.00ms. This is our bug, not xterm's: xterm already reuses cells in its own serializer and documents the getCell(x, cell) overload for exactly this. --- .../daemon/headless-osc-link-ranges.test.ts | 63 +++++++++++++++++++ src/main/daemon/headless-osc-link-ranges.ts | 27 ++++++-- 2 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 src/main/daemon/headless-osc-link-ranges.test.ts diff --git a/src/main/daemon/headless-osc-link-ranges.test.ts b/src/main/daemon/headless-osc-link-ranges.test.ts new file mode 100644 index 00000000000..cf8f9f757e2 --- /dev/null +++ b/src/main/daemon/headless-osc-link-ranges.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { HeadlessEmulator } from './headless-emulator' + +// Why this suite: collectHeadlessOscLinkRanges skips its per-cell scan when +// xterm holds no OSC 8 registration. That skip is only safe if it can never +// fire while a link is reachable, so each case below pins one way it could. +let emulator: HeadlessEmulator | undefined + +const link = (uri: string, text: string): string => `\x1b]8;;${uri}\x1b\\${text}\x1b]8;;\x1b\\` + +afterEach(() => { + emulator?.dispose() + emulator = undefined +}) + +describe('headless OSC link ranges', () => { + it('finds a link written into the buffer', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write(`before ${link('https://example.com/a', 'CLICK')} after`) + + const ranges = emulator.getSnapshot().oscLinks ?? [] + expect(ranges).toHaveLength(1) + expect(ranges[0]).toMatchObject({ row: 0, uri: 'https://example.com/a' }) + }) + + it('returns nothing for a buffer that never emitted a link', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('plain output with no hyperlink\r\n'.repeat(50)) + + expect(emulator.getSnapshot().oscLinks).toEqual([]) + }) + + // The dangerous case: restored ranges are seeded without xterm registering + // anything, so an early-out keyed only on the registry would drop them. + it('still maps restored ranges when the buffer itself has no link', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('restored row') + const restored = { row: 0, startCol: 0, endCol: 4, uri: 'https://example.com/restored' } + emulator.setRestoredOscLinks([restored]) + + expect(emulator.getSnapshot().oscLinks).toEqual([restored]) + }) + + it('finds links far down a long scrollback, not just the visible screen', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24, scrollback: 5_000 }) + await emulator.write(`${link('https://example.com/top', 'TOP')}\r\n`) + await emulator.write('filler\r\n'.repeat(2_000)) + + const ranges = emulator.getSnapshot({ scrollbackRows: 5_000 }).oscLinks ?? [] + expect(ranges.map((range) => range.uri)).toContain('https://example.com/top') + }) + + it('keeps every distinct link when several are present', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write( + `${link('https://example.com/1', 'ONE')} ${link('https://example.com/2', 'TWO')}` + ) + + const uris = (emulator.getSnapshot().oscLinks ?? []).map((range) => range.uri) + expect(uris).toContain('https://example.com/1') + expect(uris).toContain('https://example.com/2') + }) +}) diff --git a/src/main/daemon/headless-osc-link-ranges.ts b/src/main/daemon/headless-osc-link-ranges.ts index 418a0c65166..ea017a7b928 100644 --- a/src/main/daemon/headless-osc-link-ranges.ts +++ b/src/main/daemon/headless-osc-link-ranges.ts @@ -1,10 +1,14 @@ -import type { Terminal } from '@xterm/headless' +import type { IBufferCell, IBufferLine, Terminal } from '@xterm/headless' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' type TerminalWithOscLinks = Terminal & { _core?: { _oscLinkService?: { getLinkData: (linkId: number) => { uri?: string } | undefined + // Why read it: xterm registers every OSC 8 id here, so an empty registry + // proves the buffer holds no hyperlink and the per-cell scan can be skipped. + // Optional because it is private — an xterm that renames it just scans. + _dataByLinkId?: { size?: number } } } } @@ -14,6 +18,11 @@ type CellWithOscLink = { hasExtendedAttrs?: () => boolean } +/** True when xterm holds no OSC 8 registration at all, so no cell can carry one. */ +function hasNoRegisteredOscLinks(service: { _dataByLinkId?: { size?: number } }): boolean { + return service._dataByLinkId?.size === 0 +} + export function collectHeadlessOscLinkRanges( terminal: Terminal, scrollbackRows: number | undefined, @@ -26,9 +35,19 @@ export function collectHeadlessOscLinkRanges( return [] } const buffer = terminal.buffer.active + // Why before the scan: the walk below reads every cell of every row, and a + // session that never emitted a hyperlink — the overwhelming majority — would + // pay that for a guaranteed-empty result. `restoredLinks` still needs mapping. + if (hasNoRegisteredOscLinks(service) && restoredLinks.length === 0) { + return [] + } const startRow = scrollbackRows === undefined ? 0 : Math.max(0, buffer.length - terminal.rows - scrollbackRows) const ranges: TerminalOscLinkRange[] = [] + // Why one cell for the whole walk: xterm's getCell allocates a fresh CellData + // per call unless handed a target, which is a per-cell allocation across the + // entire scrollback. See the IBufferLine.getCell docs. + const scratchCell = buffer.getNullCell() for (let row = startRow; row < buffer.length; row += 1) { const line = buffer.getLine(row) if (!line) { @@ -38,7 +57,7 @@ export function collectHeadlessOscLinkRanges( let currentUrlId = 0 let currentStart = -1 for (let col = 0; col <= lineLength; col += 1) { - const urlId = col < lineLength ? getOscLinkIdAtCell(line, col) : 0 + const urlId = col < lineLength ? getOscLinkIdAtCell(line, col, scratchCell) : 0 if (urlId === currentUrlId) { continue } @@ -83,8 +102,8 @@ function dedupeOscLinkRanges(ranges: TerminalOscLinkRange[]): TerminalOscLinkRan }) } -function getOscLinkIdAtCell(line: { getCell: (col: number) => unknown }, col: number): number { - const cell = line.getCell(col) as CellWithOscLink | undefined +function getOscLinkIdAtCell(line: IBufferLine, col: number, scratchCell: IBufferCell): number { + const cell = line.getCell(col, scratchCell) as (IBufferCell & CellWithOscLink) | undefined // Why: OSC link IDs live in extended cell attrs; missing attrs means no link. return cell?.hasExtendedAttrs?.() && cell.extended?.urlId ? cell.extended.urlId : 0 } From a0799d8f1c0fe37ddcef17e63498069f34f25669 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 11 Sep 2026 04:50:42 -0700 Subject: [PATCH 010/191] fix(terminal): move the recovery ledger onto the tab row and gate it on observed outcome (#20025) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(terminal): move the recovery ledger onto the tab row and gate it on outcome The recovery budget lived in module-level Maps keyed by tabId. Anything keyed outside the row needs a release path, and that release fired on every remount-driven pane disposal, so each remount erased the budget it had just consumed (crash b5cfc6ca). Put the ledger on TerminalTab and write it in the same set() as the generation bump: reading the budget is now reading the tab, so releasing it independently has no expression. Counting was also the wrong control. Every remount mounts a pane that captures a FRESH recovery epoch, so the epoch check can never refuse its request — recovery re-requested the exact action that had just failed with no evidence anything changed. Gate on an observed outcome instead, reusing the direct-SSH pane retry vocabulary (success | failed | timed-out | superseded) and its settle call sites: an unsettled attempt blocks the next one, and a settled failure refuses the same reason until a new trigger arrives (generation move, or the user's Retry). The 3-per-5min cap stays as a breadcrumb-emitting backstop, not the control. viewMode now also lands on the row from the local toggles, mirroring how pin already does it, so the chat-ownership guard reads one index instead of OR-ing two. * fix(terminal): persist the row's viewMode and keep both chat-ownership reads The narrowed chat-ownership guard read a field the session schema strips: terminalTabSchema never declared viewMode, so the terminal row lost it on every load while the unified tab kept it. After a restart the row read undefined and recovery would remount a chat-owned tab's hidden surface — the race #19745's guard exists to prevent. Declare viewMode on terminalTabSchema so the row is durable, and keep the disjunction rather than replacing it. The schema cannot retroactively add the field to sessions already on disk, so the first load after upgrade still has it only on the unified tab; and for a safety check over two partly-redundant sources, a hole in either index should err toward declining a heal. Also cover three structural guards that no test was holding: both remote ledger-carry paths (terminal-build, remote-workspace-session-merge) and the only success settle in the state machine, including its placement past the failure branches. * fix(terminal): settle a fresh spawn's outcome and prove the ownership guard across a reload spawn-left-pane-unbound was the one recovery reason with no success settle: its remount heals by spawning, not reattaching, so it reached none of the reattach settle points and left the attempt 'pending' for the full 31s bound. A fresh spawn that binds a PTY now reports it, the dual of the unbound settle that already reported failure. Two tests outside src/ still called remountTerminalTabForRecovery by its old boolean contract and broke CI; both are updated to the admission result. Also strips the client-local recovery ledger at the remote-workspace projection boundary, in the type as well as the destructure, so a future producer cannot put another machine's Date.now() on the wire. * fix(terminal): resolve the pane's tab row once for both epochs after the main merge #20034 replaced connect-pane-pty's inline tab resolution with findTerminalTabForPane, and this branch had rewritten the line below it to read the recovery epoch off the row that block used to bind. The merge was textually clean and semantically broken: `terminalTab` no longer existed, so typecheck failed and every test that connects a pane threw ReferenceError. Resolve the row once through the new helper and feed both epochs from it, which keeps #20034's refactor and this branch's reason for reading the row here — a second lookup would put another tabsByWorktree scan on the connect path. captureTabRecoveryGeneration is narrowed to the one field it reads so the helper's record type can carry it. --- .../terminal-pane/TerminalPaneSurface.tsx | 5 +- ...ty-connection-hidden-delivery-gate.test.ts | 11 +- ...connection-spawn-left-pane-unbound.test.ts | 46 +++ ...y-connection-terminal-input-gating.test.ts | 16 +- .../agent-idle-working-handlers.ts | 12 +- .../pty-connection/connect-pane-pty.ts | 15 +- .../deferred-session-reattach-connect.ts | 4 +- .../direct-ssh-reattach-recovery.test.ts | 15 +- .../direct-ssh-reattach-recovery.ts | 9 +- .../pty-connection/direct-ssh-retry-status.ts | 2 +- .../pty-connection/fresh-spawn-start.ts | 8 + .../pty-connection/reattach-result-handler.ts | 9 + .../reattach-success-settle.test.ts | 146 ++++++++ .../pty-connection/terminal-tab-id.ts | 11 +- .../unbound-pane-spawn-recovery.test.ts | 14 +- .../unbound-pane-spawn-recovery.ts | 2 +- ...l-pane-recovery-unsettled-fallback.test.ts | 154 +++++++++ .../terminal-pane-recovery.test.ts | 313 ++++++++++++------ .../terminal-pane/terminal-pane-recovery.ts | 248 +++++++------- .../terminal-pane-surface-ownership.test.ts | 255 ++++++++++++++ .../terminal-recovery-ledger-test-driver.ts | 42 +++ .../terminal-recovery-ledger-test-store.ts | 96 ++++++ ...space-session-merge-local-survival.test.ts | 35 ++ .../hooks/remote-workspace-session-merge.ts | 6 +- .../src/lib/session-write-subscriber.test.ts | 37 +++ .../src/lib/session-write-subscriber.ts | 5 +- .../src/lib/workspace-session-patch.test.ts | 13 +- src/renderer/src/lib/workspace-session.ts | 4 +- .../mirrored-terminal-recovery-ledger.test.ts | 74 +++++ .../web-session-tabs-sync/terminal-build.ts | 4 + .../src/store/slices/tab-view-mode.test.ts | 37 +++ .../store/slices/tabs/tabs-host-mirroring.ts | 26 +- .../store/slices/tabs/tabs-label-actions.ts | 19 +- .../terminal-tab-recovery-remount.test.ts | 83 ++++- .../src/store/slices/worktree-helpers.ts | 24 +- src/renderer/src/store/slices/worktrees.ts | 2 + .../session/worktree-slice-lookups.ts | 78 ++++- .../terminals/terminal-tab-recovery-ledger.ts | 218 ++++++++++++ ...emote-workspace-session-projection.test.ts | 46 +++ .../remote-workspace-session-projection.ts | 13 +- src/shared/remote-workspace-types.ts | 8 +- src/shared/terminal-tab-types.ts | 57 ++++ src/shared/workspace-session-schema.ts | 6 + .../workspace-session-terminal-schema.test.ts | 49 +++ ...untime-rejected-input-remount.unit.test.ts | 15 +- ...al-quick-command-pre-bind-recovery.spec.ts | 6 +- 46 files changed, 1996 insertions(+), 302 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/reattach-success-settle.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-recovery-unsettled-fallback.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-surface-ownership.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-recovery-ledger-test-driver.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-recovery-ledger-test-store.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/mirrored-terminal-recovery-ledger.test.ts create mode 100644 src/renderer/src/store/terminals/terminal-tab-recovery-ledger.ts diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx index 4df42a74de2..8e301366c14 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx @@ -176,7 +176,10 @@ export function TerminalPaneSurface({ return requestTerminalPaneRecovery({ tabId, ptyId, - reason: 'reattach-unverifiable' + reason: 'reattach-unverifiable', + // The user asking again is the new trigger that reopens + // a reason an observed failure has closed. + trigger: 'user' }).then((recovered) => { if (recovered) { dismissTerminalError() diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-delivery-gate.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-delivery-gate.test.ts index 645c522fcfc..844db35baee 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hidden-delivery-gate.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-delivery-gate.test.ts @@ -17,6 +17,11 @@ import { restoreTerminalTestGlobals } from './pty-connection-test-environment' +/** What remountTerminalTabForRecovery answers now that it reports admission. */ +const REMOUNTED = { remounted: true as const, generation: 1 } +const TAB_MISSING = { remounted: false as const, declinedBy: 'tab-missing' as const } +const AUTOMATIC_REQUEST = expect.objectContaining({ trigger: 'automatic' }) + const { resetAndRefreshAllTerminalWebglAtlases, scheduleTerminalWebglAtlasRecovery, @@ -300,7 +305,7 @@ describe('connectPanePty', () => { it('kicks pane recovery when reveal finds the write pipeline certified dead', async () => { // 2026-07-13 fossil-pane incident: bytes drop while hidden, pipeline certified dead, cert recovery empty — reveal must re-kick it. enableMainAuthority() - const remountTerminalTabForRecovery = vi.fn<(tabId: string) => boolean>(() => true) + const remountTerminalTabForRecovery = vi.fn(() => REMOUNTED) mockStoreState = { ...mockStoreState, remountTerminalTabForRecovery } as StoreState const { _resetTerminalPaneRecoveryForTests } = await import('./terminal-pane-recovery') _resetTerminalPaneRecoveryForTests() @@ -319,7 +324,7 @@ describe('connectPanePty', () => { dataCallback('hidden output\r\n', { seq: 16, rawLength: 16 }) // Pipeline dies while hidden; certification-time recovery finds no remountable tab (budget unconsumed, no retry timer). - remountTerminalTabForRecovery.mockReturnValueOnce(false) + remountTerminalTabForRecovery.mockReturnValueOnce(TAB_MISSING as never) const ackCredit = vi.fn() const { writeTerminalOutput } = await import('@/lib/pane-manager/pane-terminal-output-scheduler') @@ -345,7 +350,7 @@ describe('connectPanePty', () => { // Restore stays skipped (a dead pipeline can't parse the snapshot), but recovery got exactly one re-kick. expect(getMainBufferSnapshot).not.toHaveBeenCalled() expect(remountTerminalTabForRecovery).toHaveBeenCalledTimes(2) - expect(remountTerminalTabForRecovery).toHaveBeenLastCalledWith('tab-1') + expect(remountTerminalTabForRecovery).toHaveBeenLastCalledWith('tab-1', AUTOMATIC_REQUEST) // Latched per xterm instance: repeat restore attempts do not spam. _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'hidden-drop', markerSeq: 96 }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-spawn-left-pane-unbound.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-spawn-left-pane-unbound.test.ts index 63778d251c0..1318d9f79e2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-spawn-left-pane-unbound.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-spawn-left-pane-unbound.test.ts @@ -223,4 +223,50 @@ describe('fresh spawn leaves a local pane unbound', () => { expect.objectContaining({ reason: 'spawn-left-pane-unbound' }) ) }) + + // The other half of the observation gate for this reason. A remount for + // 'spawn-left-pane-unbound' heals by spawning, not by reattaching, so it + // reaches none of the reattach settle points. Binding a PTY IS the outcome, + // and reporting it is what keeps the attempt from sitting 'pending' for the + // whole settlement bound and blocking the tab's next recovery. + it('settles the tab recovery attempt as a success when the spawn binds a PTY', async () => { + const { connectPanePty } = await import('./pty-connection') + const settleTerminalTabRecovery = vi.fn() + mockStoreState = { ...mockStoreState, settleTerminalTabRecovery } as StoreState + const transport = createMockTransport('pty-bound') + transportFactoryQueue.push(transport) + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ tabId: 'tab-bound-spawn' }) as never + ) + await flushAsyncTicks(40) + + expect(settleTerminalTabRecovery).toHaveBeenCalledWith('tab-bound-spawn', 0, 'success') + }) + + // The failure half, which already had a settle point: the same call reports + // 'failed' before it asks for the remount, so a spawn that keeps failing is + // refused as a settled failure rather than retried on the cooldown. + it('settles the attempt as failed before asking for the remount', async () => { + const { connectPanePty } = await import('./pty-connection') + const settleTerminalTabRecovery = vi.fn() + mockStoreState = { ...mockStoreState, settleTerminalTabRecovery } as StoreState + const transport = createMockTransport() + transport.connect.mockImplementation(async () => null) + transportFactoryQueue.push(transport) + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ tabId: 'tab-unbound-spawn' }) as never + ) + await flushAsyncTicks(40) + + expect(settleTerminalTabRecovery).toHaveBeenCalledWith('tab-unbound-spawn', 0, 'failed') + expect(settleTerminalTabRecovery.mock.invocationCallOrder[0]).toBeLessThan( + requestTerminalPaneRecovery.mock.invocationCallOrder[0] + ) + }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts index 7853d305145..4659fccb5c5 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts @@ -23,6 +23,10 @@ import { restoreTerminalTestGlobals } from './pty-connection-test-environment' +/** What remountTerminalTabForRecovery answers now that it reports admission. */ +const REMOUNTED = { remounted: true as const, generation: 1 } +const AUTOMATIC_REQUEST = expect.objectContaining({ trigger: 'automatic' }) + const { resetAndRefreshAllTerminalWebglAtlases, scheduleTerminalWebglAtlasRecovery, @@ -787,7 +791,7 @@ describe('connectPanePty', () => { const { connectPanePty } = await import('./pty-connection') const { _resetTerminalPaneRecoveryForTests } = await import('./terminal-pane-recovery') _resetTerminalPaneRecoveryForTests() - const remountTerminalTabForRecovery = vi.fn<(tabId: string) => boolean>(() => true) + const remountTerminalTabForRecovery = vi.fn(() => REMOUNTED) mockStoreState = { ...mockStoreState, remountTerminalTabForRecovery } as StoreState const transport = createMockTransport('daemon-pty') let writeUnavailable: (() => void) | undefined @@ -803,7 +807,7 @@ describe('connectPanePty', () => { await flushAsyncTicks(6) expect(window.api.pty.hasPty).toHaveBeenCalledWith('daemon-pty') - expect(remountTerminalTabForRecovery).toHaveBeenCalledWith('tab-1') + expect(remountTerminalTabForRecovery).toHaveBeenCalledWith('tab-1', AUTOMATIC_REQUEST) _resetTerminalPaneRecoveryForTests() }) @@ -811,7 +815,7 @@ describe('connectPanePty', () => { const { connectPanePty } = await import('./pty-connection') const { _resetTerminalPaneRecoveryForTests } = await import('./terminal-pane-recovery') _resetTerminalPaneRecoveryForTests() - const remountTerminalTabForRecovery = vi.fn<(tabId: string) => boolean>(() => true) + const remountTerminalTabForRecovery = vi.fn(() => REMOUNTED) mockStoreState = { ...mockStoreState, remountTerminalTabForRecovery } as StoreState const transport = createMockTransport('daemon-pty') let writeUnavailable: (() => void) | undefined @@ -826,7 +830,7 @@ describe('connectPanePty', () => { await flushAsyncTicks(6) writeUnavailable?.() await flushAsyncTicks(6) - expect(remountTerminalTabForRecovery).toHaveBeenCalledWith('tab-1') + expect(remountTerminalTabForRecovery).toHaveBeenCalledWith('tab-1', AUTOMATIC_REQUEST) // The surviving tail of `echo hi; rm -rf x`: reaching the fresh shell would // let the user's own Enter run `rm -rf x` (#10065 follow-up). @@ -848,7 +852,7 @@ describe('connectPanePty', () => { const { connectPanePty } = await import('./pty-connection') const { settleTerminalWriteStallWatch, WRITE_PIPELINE_STALL_CHECK_MS } = await import('@/lib/pane-manager/terminal-write-pipeline-health') - const remountTerminalTabForRecovery = vi.fn<(tabId: string) => boolean>(() => true) + const remountTerminalTabForRecovery = vi.fn(() => REMOUNTED) mockStoreState = { ...mockStoreState, remountTerminalTabForRecovery } as StoreState const transport = createMockTransport('pty-wedged') transportFactoryQueue.push(transport) @@ -865,7 +869,7 @@ describe('connectPanePty', () => { expect(transport.sendInput).toHaveBeenCalledWith('x') expect(pane.terminal.write).toHaveBeenCalledWith('', expect.any(Function)) - expect(remountTerminalTabForRecovery).toHaveBeenCalledWith('tab-1') + expect(remountTerminalTabForRecovery).toHaveBeenCalledWith('tab-1', AUTOMATIC_REQUEST) binding.dispose() }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts b/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts index 8516eab7f19..3e379b2d4f8 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts @@ -12,6 +12,7 @@ import { isWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id' import type { DirectSshPaneRetryAttempt } from '@/store/slices/direct-ssh-terminal-recovery' import { directSshAuthoritiesEqual } from '@/store/slices/direct-ssh-terminal-authority-ledger' +import { settleTerminalPaneRecovery } from '../terminal-pane-recovery' import type { ConnectPanePtySession } from './connect-pane-pty-session' export function installAgentIdleWorkingHandlers(session: ConnectPanePtySession): void { @@ -235,11 +236,16 @@ export function installAgentIdleWorkingHandlers(session: ConnectPanePtySession): } return canAdopt } - session.settleDirectSshPaneRetryAttempt = ( + // One settle for this pane's attach attempt, reporting to both ledgers that + // track it: the direct-SSH pane retry (when a lease owns this attempt) and + // the tab's recovery ledger. Keeping them on one call is what stops a second + // settle path drifting out of step with the first. + session.settlePaneAttachAttempt = ( attempt: DirectSshRetryLease | undefined, - status: 'failed' | 'timed-out' + status: 'success' | 'failed' | 'timed-out' ): void => { - if (!attempt) { + settleTerminalPaneRecovery(session.deps.tabId, session.terminalRecoveryGeneration, status) + if (!attempt || status === 'success') { return } useAppStore.getState().settleDirectSshPaneRetry?.({ diff --git a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts index 30e1352204d..d966d59f98b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts @@ -2,10 +2,8 @@ import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager' import { useAppStore } from '@/store' import { TerminalKittyKeyboardModeTracker } from '../../../../../shared/terminal-kitty-keyboard-mode-tracker' import type { PtyConnectionDeps } from '../pty-connection-types' -import { - captureTerminalPaneRecoveryGeneration, - registerTerminalPaneRecoveryInstance -} from '../terminal-pane-recovery' +import { registerTerminalPaneRecoveryInstance } from '../terminal-pane-recovery' +import { captureTabRecoveryGeneration } from '@/store/terminals/terminal-tab-recovery-ledger' import { RESET_TERMINAL_CURSOR_STYLE } from '../../../../../shared/terminal-mode-reset-profiles' import { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' import { createTerminalStructuralReplayCoordinator } from '@/lib/pane-manager/terminal-structural-replay-coordinator' @@ -50,11 +48,14 @@ export function connectPanePty( const session = { pane, manager, deps } as ConnectPanePtySession session.shouldRefreshForegroundSynchronously = (): boolean => !session.manager.hasWebglRenderer(session.pane.id) - session.tabGeneration = - findTerminalTabForPane(useAppStore.getState(), deps.worktreeId, deps.tabId)?.generation ?? 0 + // One lookup for both epochs: the remount generation and the recovery + // ledger's both live on this row, so resolving it twice would put a second + // scan of tabsByWorktree on the connect path. + const terminalTab = findTerminalTabForPane(useAppStore.getState(), deps.worktreeId, deps.tabId) + session.tabGeneration = terminalTab?.generation ?? 0 // Why: recovery ownership belongs to this xterm instance. A request that // settles after remount must not remount its already-replaced successor. - session.terminalRecoveryGeneration = captureTerminalPaneRecoveryGeneration(session.deps.tabId) + session.terminalRecoveryGeneration = captureTabRecoveryGeneration(terminalTab) session.terminalRecoveryInstance = registerTerminalPaneRecoveryInstance(session.deps.tabId) session.mountFollowsTerminalPark = session.deps.mountFollowsTerminalPark session.authoritativeReattachGeneration = 0 diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts index 66cfc4f526b..3548d3f7b9f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts @@ -90,7 +90,7 @@ export function startDeferredSessionReattach( if (typeof gen === 'number') { void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {}) } - session.settleDirectSshPaneRetryAttempt(session.directSshRetryAttempt, 'failed') + session.settlePaneAttachAttempt(session.directSshRetryAttempt, 'failed') return } if (!result && expiredReattachError) { @@ -153,7 +153,7 @@ export function startDeferredSessionReattach( } if (message.includes(PANE_OWNER_UNVERIFIED_ERROR)) { session.reportError(message) - session.settleDirectSshPaneRetryAttempt(session.directSshRetryAttempt, 'failed') + session.settlePaneAttachAttempt(session.directSshRetryAttempt, 'failed') return } warnTerminalLifecycleAnomaly('restored PTY reattach threw', { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-reattach-recovery.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-reattach-recovery.test.ts index 03f3da2f158..0e8af7bfd8d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-reattach-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-reattach-recovery.test.ts @@ -13,21 +13,23 @@ describe('recoverUnverifiableDirectSshReattach', () => { it('retries through the exact direct SSH lease when one exists', () => { const attempt = { attemptId: 'attempt-1' } - const settleDirectSshPaneRetryAttempt = vi.fn() + const settlePaneAttachAttempt = vi.fn() recoverUnverifiableDirectSshReattach( - { directSshRetryAttempt: attempt, settleDirectSshPaneRetryAttempt } as never, + { directSshRetryAttempt: attempt, settlePaneAttachAttempt } as never, 'ssh:target@@pty-1' ) - expect(settleDirectSshPaneRetryAttempt).toHaveBeenCalledExactlyOnceWith(attempt, 'failed') + expect(settlePaneAttachAttempt).toHaveBeenCalledExactlyOnceWith(attempt, 'failed') expect(requestTerminalPaneRecovery).not.toHaveBeenCalled() }) it('remounts over the preserved PTY when no retry lease exists', () => { + const settlePaneAttachAttempt = vi.fn() recoverUnverifiableDirectSshReattach( { directSshRetryAttempt: undefined, + settlePaneAttachAttempt, deps: { tabId: 'tab-1' }, terminalRecoveryGeneration: 2, terminalRecoveryInstance: { id: 3 } @@ -35,6 +37,13 @@ describe('recoverUnverifiableDirectSshReattach', () => { 'ssh:target@@pty-1' ) + // Settled before the re-request: this failure is the outcome of the + // remount that mounted this pane, and the ledger must read it that way + // before the pane asks for the same action again (crash b5cfc6ca). + expect(settlePaneAttachAttempt).toHaveBeenCalledExactlyOnceWith(undefined, 'failed') + expect(settlePaneAttachAttempt.mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(requestTerminalPaneRecovery).mock.invocationCallOrder[0] + ) expect(requestTerminalPaneRecovery).toHaveBeenCalledExactlyOnceWith({ tabId: 'tab-1', ptyId: 'ssh:target@@pty-1', diff --git a/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-reattach-recovery.ts b/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-reattach-recovery.ts index f32896fc254..ed19e677a97 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-reattach-recovery.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-reattach-recovery.ts @@ -5,8 +5,13 @@ export function recoverUnverifiableDirectSshReattach( session: ConnectPanePtySession, ptyId: string | null | undefined ): void { - if (session.directSshRetryAttempt) { - session.settleDirectSshPaneRetryAttempt(session.directSshRetryAttempt, 'failed') + // Read before settling: the settle clears the lease this branch tests. + const directSshRetryOwnsRecovery = Boolean(session.directSshRetryAttempt) + // Settle BEFORE requesting: this failure is the outcome of the remount that + // mounted this pane. Requesting first would ask for a repeat of the action + // that just failed while its ledger still read 'pending' — the storm. + session.settlePaneAttachAttempt(session.directSshRetryAttempt, 'failed') + if (directSshRetryOwnsRecovery) { return } void requestTerminalPaneRecovery({ diff --git a/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-status.ts b/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-status.ts index a0f72936c5d..bcc34b05070 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-status.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-status.ts @@ -55,7 +55,7 @@ export function installDirectSshRetryStatus(session: ConnectPanePtySession): voi if (session.directSshPaneRetrySettlementCancelled) { return } - session.settleDirectSshPaneRetryAttempt(attempt, 'timed-out') + session.settlePaneAttachAttempt(attempt, 'timed-out') }, DIRECT_SSH_PANE_RETRY_SETTLEMENT_TIMEOUT_MS) session.directSshPaneRetrySettlementTimers.add(timer) void promise diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts index 05b1ad7fff0..66e95d98556 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts @@ -278,6 +278,14 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { session.armDirectSshPaneRetryTimeout(trackedPromise, session.directSshRetryAttempt) void trackedPromise.then((spawnedPtyId) => { if (spawnedPtyId) { + // The dual of settleSpawnThatLeftPaneUnbound below, and the only place a + // FRESH spawn can report an outcome: the pane it heals has no PTY to + // reattach to, so it never reaches the reattach handler that settles + // every other recovery reason. Without this the healed attempt sits + // 'pending' for the whole settlement bound and blocks the tab's next + // recovery. Generation-gated in the store, so a spawn with no recovery + // attempt in flight writes nothing. + session.settlePaneAttachAttempt?.(undefined, 'success') return } queueMicrotask(() => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts index 6450a71567c..d485e526631 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts @@ -45,6 +45,7 @@ type ReattachResultSession = ReattachPayloadSession & | 'sampleVisiblePaneForegroundAgent' | 'scheduleReattachIdleAgentCursorReset' | 'serializeHiddenOutputSnapshot' + | 'settlePaneAttachAttempt' | 'setPanePtyFitBinding' | 'startFreshColdRestoreAgentResume' | 'structuralReplayCoordinator' @@ -163,6 +164,14 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi if (!isCurrentReattachPayload()) { return false } + // The first authoritative attach of the pane a recovery remount produced: + // the observation the ledger was waiting for. Placed past the no-PTY-id and + // session-expired branches so a failure can never be reported as a success. + // Those branches do NOT all settle: only the no-PTY-id arm does, and only + // when `session.connectionId` is set (:120). The local arm and the + // sessionExpired arm fall through to startFreshColdRestoreAgentResume and + // leave the attempt pending, which the 31s bound then ages out. + session.settlePaneAttachAttempt?.(undefined, 'success') // Strict precedence snapshot > replay > coldRestore: paint exactly one, else overlapping tails duplicate TUI output on worktree switch. const hasStructuralReplay = Boolean( connectResult?.snapshot || connectResult?.replay || connectResult?.coldRestore diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-success-settle.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-success-settle.test.ts new file mode 100644 index 00000000000..dcc7279b9c5 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-success-settle.test.ts @@ -0,0 +1,146 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { bindHandleReattachResult } from './reattach-result-handler' +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +/** + * `handleReattachResult` holds the only `success` settle in the recovery state + * machine. Without it every attempt stays `pending` until the 31s settlement + * bound ages out, so the entire "observed success" half of the gate — the thing + * that distinguishes this design from the counting budget it replaces — can be + * deleted with no other test noticing. + * + * Placement is load-bearing too. Settling at the `authoritativeReattachGeneration` + * bump instead would mark the storm's OWN failure path (`reattach returned no + * PTY id` → recoverUnverifiableDirectSshReattach) as a success and reopen the + * loop, so the negative case below is as important as the positive one. + */ +const mocks = vi.hoisted(() => ({ + state: { tabsByWorktree: {}, terminalLayoutsByTabId: {} } +})) + +vi.mock('@/store', () => ({ + useAppStore: { getState: () => mocks.state } +})) +vi.mock('@/lib/codex-stale-pane-sweep', () => ({ notifyCodexPaneBoundForStaleSweep: vi.fn() })) +vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync: vi.fn() })) + +type SettleSpy = ReturnType + +function buildSession(overrides: Record = {}): { + session: ConnectPanePtySession + settlePaneAttachAttempt: SettleSpy +} { + const settlePaneAttachAttempt = vi.fn() + const transport = { + getPtyId: () => 'pty-1', + disconnect: vi.fn(), + serializeBuffer: vi.fn() + } + const session = { + settlePaneAttachAttempt, + transport, + disposed: false, + transportStreamGeneration: 0, + authoritativeReattachGeneration: 0, + pane: { id: 'pane-1', leafId: 'leaf-1', terminal: {} }, + deps: { + tabId: 'tab-1', + worktreeId: 'wt-1', + paneTransportsRef: { current: new Map([['pane-1', transport]]) }, + isVisibleRef: { current: true }, + clearTabPtyId: vi.fn(), + updateTabPtyId: vi.fn(), + restoredLeafId: null + }, + connectionId: null, + directSshRetryAttempt: undefined, + capturedDirectSshRetryPtyAccepted: false, + rejectObsoleteDirectSshReattach: () => false, + registerEffectiveLaunchConfig: vi.fn(), + clearExitedPanePtyLayoutBinding: vi.fn(), + syncPanePtyLayoutBinding: vi.fn(), + startFreshColdRestoreAgentResume: vi.fn(), + setPanePtyFitBinding: vi.fn(), + reportPanePtyVisibility: vi.fn(), + registerSideEffectFactConsumerForPty: vi.fn(), + syncHiddenRendererPtyDelivery: vi.fn(), + ...overrides + } as unknown as ConnectPanePtySession + bindHandleReattachResult(session) + return { session, settlePaneAttachAttempt } +} + +/** The pane-transport registry is keyed by pane id; the bag is deliberately untyped. */ +function setPaneTransports(session: ConnectPanePtySession, transports: Map): void { + ;(session.deps as unknown as { paneTransportsRef: { current: unknown } }).paneTransportsRef = { + current: transports + } +} + +/** + * Only the settle is under assertion here; everything downstream of it has its + * own tests and needs a far larger session bag than this. A throw BEFORE the + * settle still fails the test, which is the regression this pins. + */ +async function driveReattach( + session: ConnectPanePtySession, + result: unknown, + staleSessionId?: string | null +): Promise { + try { + await session.handleReattachResult(result, staleSessionId) + } catch { + // See above. + } +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.state = { tabsByWorktree: {}, terminalLayoutsByTabId: {} } +}) + +describe('handleReattachResult recovery settle', () => { + it('reports success once the attach payload is authoritative', async () => { + const { session, settlePaneAttachAttempt } = buildSession() + + await driveReattach(session, { id: 'pty-1', isReattach: true }) + + expect(settlePaneAttachAttempt).toHaveBeenCalledWith(undefined, 'success') + }) + + it('does not report success for a reattach that returned no PTY id', async () => { + // The storm's own path. Settling this as success would clear the ledger the + // failure is about to be written to, and the chain would restart. + const { session, settlePaneAttachAttempt } = buildSession({ + connectionId: 'ssh-1', + transport: { + getPtyId: () => null, + disconnect: vi.fn(), + serializeBuffer: vi.fn() + } + }) + setPaneTransports(session, new Map([['pane-1', session.transport]])) + + await driveReattach(session, undefined, null) + + expect(settlePaneAttachAttempt).not.toHaveBeenCalledWith(undefined, 'success') + expect(settlePaneAttachAttempt).toHaveBeenCalledWith(undefined, 'failed') + }) + + it('does not report success for an expired session', async () => { + const { session, settlePaneAttachAttempt } = buildSession() + + await driveReattach(session, { id: 'pty-1', sessionExpired: true }, 'pty-old') + + expect(settlePaneAttachAttempt).not.toHaveBeenCalledWith(undefined, 'success') + }) + + it('does not report success for a superseded transport', async () => { + const { session, settlePaneAttachAttempt } = buildSession() + setPaneTransports(session, new Map()) + + await driveReattach(session, { id: 'pty-1', isReattach: true }) + + expect(settlePaneAttachAttempt).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/terminal-tab-id.ts b/src/renderer/src/components/terminal-pane/pty-connection/terminal-tab-id.ts index 1055d69fe15..ace325c5d0f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/terminal-tab-id.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/terminal-tab-id.ts @@ -1,3 +1,5 @@ +import type { TerminalTab } from '../../../../../shared/terminal-tab-types' + type TerminalTabLookup = { getTab?: (tabId: string) => { contentType: string; entityId: string } | null hasTerminalTab?: (tabId: string) => boolean @@ -14,7 +16,14 @@ export function resolveTerminalTabId(state: TerminalTabLookup, tabId: string): s return unifiedTab?.contentType === 'terminal' ? unifiedTab.entityId : tabId } -type TerminalTabRecord = { id: string; generation?: number } +// `recovery` rides along because the connect path reads the tab's remount +// generation and its recovery epoch off the SAME row — both live on it, and +// resolving the row twice would put a second tabsByWorktree scan on that path. +type TerminalTabRecord = { + id: string + generation?: number + recovery?: TerminalTab['recovery'] +} type TerminalTabState = { getTab?: ( tabId: string diff --git a/src/renderer/src/components/terminal-pane/pty-connection/unbound-pane-spawn-recovery.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/unbound-pane-spawn-recovery.test.ts index 25bcd4fe1ed..749d8c73492 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/unbound-pane-spawn-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/unbound-pane-spawn-recovery.test.ts @@ -13,7 +13,7 @@ function buildSession(overrides: Record = {}): never { terminalRecoveryGeneration: 2, terminalRecoveryInstance: { id: 3 }, directSshRetryAttempt: undefined, - settleDirectSshPaneRetryAttempt: vi.fn(), + settlePaneAttachAttempt: vi.fn(), ...overrides } as never } @@ -37,24 +37,24 @@ describe('settleSpawnThatLeftPaneUnbound', () => { it('leaves recovery to the direct SSH retry ledger when it holds a lease', () => { const attempt = { attemptId: 'attempt-1' } - const settleDirectSshPaneRetryAttempt = vi.fn() + const settlePaneAttachAttempt = vi.fn() settleSpawnThatLeftPaneUnbound( - buildSession({ directSshRetryAttempt: attempt, settleDirectSshPaneRetryAttempt }) + buildSession({ directSshRetryAttempt: attempt, settlePaneAttachAttempt }) ) - expect(settleDirectSshPaneRetryAttempt).toHaveBeenCalledExactlyOnceWith(attempt, 'failed') + expect(settlePaneAttachAttempt).toHaveBeenCalledExactlyOnceWith(attempt, 'failed') expect(requestTerminalPaneRecovery).not.toHaveBeenCalled() }) it('settles the spawn as failed before remounting', () => { - const settleDirectSshPaneRetryAttempt = vi.fn() + const settlePaneAttachAttempt = vi.fn() settleSpawnThatLeftPaneUnbound( - buildSession({ deps: { tabId: 'tab-settle' }, settleDirectSshPaneRetryAttempt }) + buildSession({ deps: { tabId: 'tab-settle' }, settlePaneAttachAttempt }) ) - expect(settleDirectSshPaneRetryAttempt).toHaveBeenCalledExactlyOnceWith(undefined, 'failed') + expect(settlePaneAttachAttempt).toHaveBeenCalledExactlyOnceWith(undefined, 'failed') expect(requestTerminalPaneRecovery).toHaveBeenCalledOnce() }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/unbound-pane-spawn-recovery.ts b/src/renderer/src/components/terminal-pane/pty-connection/unbound-pane-spawn-recovery.ts index 698df12651c..a6dfd3c5a96 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/unbound-pane-spawn-recovery.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/unbound-pane-spawn-recovery.ts @@ -19,7 +19,7 @@ import type { ConnectPanePtySession } from './connect-pane-pty-session' export function settleSpawnThatLeftPaneUnbound(session: ConnectPanePtySession): void { // Read before settling: the settle clears the lease this branch tests. const directSshRetryOwnsRecovery = Boolean(session.directSshRetryAttempt) - session.settleDirectSshPaneRetryAttempt(session.directSshRetryAttempt, 'failed') + session.settlePaneAttachAttempt(session.directSshRetryAttempt, 'failed') if (directSshRetryOwnsRecovery) { return } diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-recovery-unsettled-fallback.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-recovery-unsettled-fallback.test.ts new file mode 100644 index 00000000000..7e04fb3368d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-recovery-unsettled-fallback.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + _resetTerminalPaneRecoveryForTests, + captureTerminalPaneRecoveryGeneration, + requestTerminalPaneRecovery +} from './terminal-pane-recovery' +import { bumpTabGeneration, settleCurrentRecovery } from './terminal-recovery-ledger-test-driver' +import { + recoveryLedgerMocks as mocks, + resetRecoveryLedgerStore, + setTerminalTabs +} from './terminal-recovery-ledger-test-store' + +/** + * The deliberate softening, stressed. + * + * An aged-out `pending` is NOT read as an observed failure. That is a choice: + * `spawn-left-pane-unbound` mounts a pane with no PTY binding, so a remount + * that succeeds goes down the fresh-spawn path, which reaches no reattach + * handler and therefore reports no `success`. Treating the timeout as failure + * would refuse that reason forever on the one pane kind that cannot report. + * + * What bounds it instead is the cooldown and the window cap. These tests pin + * that bound, the breadcrumb it leaves, and — the part that matters most — + * which triggers can still move a tab whose pane never reports anything. + */ + +vi.mock('@/store', async () => { + const store = await import('./terminal-recovery-ledger-test-store') + return { useAppStore: { getState: () => store.recoveryLedgerStoreState() } } +}) + +vi.mock('@/lib/crash-breadcrumb-recorder', async () => { + const store = await import('./terminal-recovery-ledger-test-store') + return { recordRendererCrashBreadcrumb: store.recoveryLedgerMocks.recordRendererCrashBreadcrumb } +}) + +/** The one reason with no `success` settle path, and the one this bound exists for. */ +const NEVER_SETTLES = { + tabId: 'tab-1', + ptyId: null, + reason: 'spawn-left-pane-unbound' +} as const + +beforeEach(() => { + _resetTerminalPaneRecoveryForTests() + resetRecoveryLedgerStore() + setTerminalTabs([{ id: 'tab-1' }]) + vi.stubGlobal('window', { api: { pty: { hasPty: mocks.hasPty } } }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.useFakeTimers() + vi.setSystemTime(0) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + vi.useRealTimers() +}) + +describe('a pane that never reports an outcome', () => { + it('bounds a never-settling tab at three remounts per window and says so', async () => { + // t=0 admits: no ledger yet. + expect(await requestTerminalPaneRecovery(NEVER_SETTLES)).toBe(true) + + // Inside the settlement bound, past the cooldown: only the unsettled + // attempt refuses this, and nothing has been observed to justify a retry. + vi.setSystemTime(16_000) + expect(await requestTerminalPaneRecovery(NEVER_SETTLES)).toBe(false) + expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledTimes(1) + + // Past 31s the pending ages out. NOT an observed failure — it falls + // through to the cooldown, which has elapsed, so a second remount lands. + vi.setSystemTime(31_000) + expect(await requestTerminalPaneRecovery(NEVER_SETTLES)).toBe(true) + vi.setSystemTime(62_000) + expect(await requestTerminalPaneRecovery(NEVER_SETTLES)).toBe(true) + expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledTimes(3) + + // The backstop. Every further ask inside the window is refused, loudly. + for (const now of [93_000, 124_000, 200_000, 299_000]) { + vi.setSystemTime(now) + expect(await requestTerminalPaneRecovery(NEVER_SETTLES)).toBe(false) + } + expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledTimes(3) + expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledWith( + 'terminal_pane_recovery_window_cap', + { tabId: 'tab-1', reason: 'spawn-left-pane-unbound' } + ) + + // And it is a rolling window, not a permanent stop: once the first attempt + // ages out of it the tab may heal again. + vi.setSystemTime(301_000) + expect(await requestTerminalPaneRecovery(NEVER_SETTLES)).toBe(true) + expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledTimes(4) + }) + + it('lets the user reopen an unsettled attempt the automatic path is holding', async () => { + expect(await requestTerminalPaneRecovery(NEVER_SETTLES)).toBe(true) + vi.setSystemTime(16_000) + expect(await requestTerminalPaneRecovery(NEVER_SETTLES)).toBe(false) + + // Retry in the error toast: the user asking IS the new evidence, so it + // clears the unsettled refusal AND the cooldown. + expect(await requestTerminalPaneRecovery({ ...NEVER_SETTLES, trigger: 'user' })).toBe(true) + expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledTimes(2) + }) + + it('lets a PTY rebind reopen it the moment the pane finally reports', async () => { + expect(await requestTerminalPaneRecovery(NEVER_SETTLES)).toBe(true) + vi.setSystemTime(16_000) + expect(await requestTerminalPaneRecovery(NEVER_SETTLES)).toBe(false) + + // An authoritative attach lands: reattach-result-handler settles 'success'. + settleCurrentRecovery('tab-1', 'success') + expect(await requestTerminalPaneRecovery(NEVER_SETTLES)).toBe(true) + expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledTimes(2) + }) + + it('lets an authority change supersede an attempt still sitting pending', async () => { + expect(await requestTerminalPaneRecovery(NEVER_SETTLES)).toBe(true) + vi.setSystemTime(16_000) + expect(await requestTerminalPaneRecovery(NEVER_SETTLES)).toBe(false) + + // SSH authority rotation / activation respawn bumps tab.generation. + bumpTabGeneration('tab-1') + expect(await requestTerminalPaneRecovery(NEVER_SETTLES)).toBe(true) + expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledTimes(2) + }) + + it('keeps the cap above every trigger but the external lifecycle remount', async () => { + for (const now of [0, 31_000, 62_000]) { + vi.setSystemTime(now) + expect(await requestTerminalPaneRecovery(NEVER_SETTLES)).toBe(true) + } + vi.setSystemTime(93_000) + + // The backstop is deliberately unconditional: a user Retry and an authority + // rotation both still hit it, or anything bumping generation each cycle + // would lift the ceiling along with it. + expect(await requestTerminalPaneRecovery({ ...NEVER_SETTLES, trigger: 'user' })).toBe(false) + bumpTabGeneration('tab-1') + expect(await requestTerminalPaneRecovery(NEVER_SETTLES)).toBe(false) + expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledTimes(3) + + // Host hydration remounts every live pane and writes no ledger, so it is + // the one trigger above the cap — and it cannot itself loop, because it + // only fires on a lifecycle event. + const external = captureTerminalPaneRecoveryGeneration('tab-1') + expect(external).toBeGreaterThan(0) + expect(await requestTerminalPaneRecovery({ ...NEVER_SETTLES, trigger: 'external' })).toBe(true) + expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledTimes(4) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts index 9e4289f06cb..a2523d0e5ee 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts @@ -3,46 +3,32 @@ import { _resetTerminalPaneRecoveryForTests, captureTerminalPaneRecoveryGeneration, registerTerminalPaneRecoveryInstance, - requestTerminalPaneRecovery + requestTerminalPaneRecovery, + settleTerminalPaneRecovery } from './terminal-pane-recovery' +import { requestAndSettle, settleCurrentRecovery } from './terminal-recovery-ledger-test-driver' +import { + recoveryLedgerMocks as mocks, + resetRecoveryLedgerStore, + setTerminalTabs, + terminalTabs +} from './terminal-recovery-ledger-test-store' import { isTerminalInputQuarantined } from './terminal-input-quarantine' -type StoredTerminalTab = { id: string; viewMode?: 'terminal' | 'chat' } +vi.mock('@/store', async () => { + const store = await import('./terminal-recovery-ledger-test-store') + return { useAppStore: { getState: () => store.recoveryLedgerStoreState() } } +}) -const mocks = vi.hoisted(() => ({ - remountTerminalTabForRecovery: vi.fn<(tabId: string) => boolean>(() => true), - getTab: vi.fn<() => { viewMode?: 'terminal' | 'chat' } | null>(() => ({})), - // The remount index. Kept separate from getTab so a test can stage the - // drift between the two that crash b5cfc6ca rode in on. - terminalTabs: [] as StoredTerminalTab[], - recordRendererCrashBreadcrumb: vi.fn(), - hasPty: vi.fn<(id: string) => Promise>(async () => true) -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => ({ - remountTerminalTabForRecovery: mocks.remountTerminalTabForRecovery, - getTab: mocks.getTab, - tabsByWorktree: { 'repo1::/path/wt1': mocks.terminalTabs } - }) - } -})) - -vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ - recordRendererCrashBreadcrumb: mocks.recordRendererCrashBreadcrumb -})) +vi.mock('@/lib/crash-breadcrumb-recorder', async () => { + const store = await import('./terminal-recovery-ledger-test-store') + return { recordRendererCrashBreadcrumb: store.recoveryLedgerMocks.recordRendererCrashBreadcrumb } +}) beforeEach(() => { _resetTerminalPaneRecoveryForTests() - mocks.remountTerminalTabForRecovery.mockClear() - mocks.remountTerminalTabForRecovery.mockReturnValue(true) - mocks.getTab.mockClear() - mocks.getTab.mockReturnValue({}) - mocks.terminalTabs = [{ id: 'tab-1' }, { id: 'tab-ssh' }] - mocks.recordRendererCrashBreadcrumb.mockClear() - mocks.hasPty.mockClear() - mocks.hasPty.mockResolvedValue(true) + resetRecoveryLedgerStore() + setTerminalTabs([{ id: 'tab-1' }, { id: 'tab-ssh' }]) vi.stubGlobal('window', { api: { pty: { hasPty: mocks.hasPty } } }) @@ -56,37 +42,6 @@ afterEach(() => { }) describe('requestTerminalPaneRecovery', () => { - it('does not remount a terminal surface hidden behind native chat', async () => { - mocks.getTab.mockReturnValue({ viewMode: 'chat' }) - - await expect( - requestTerminalPaneRecovery({ - tabId: 'tab-1', - ptyId: 'pty-1', - reason: 'input-undeliverable' - }) - ).resolves.toBe(false) - expect(mocks.remountTerminalTabForRecovery).not.toHaveBeenCalled() - expect(mocks.hasPty).not.toHaveBeenCalled() - }) - - it('does not remount a chat-owned tab the unified tab index has dropped', async () => { - // The drift crash b5cfc6ca documents: present in tabsByWorktree, gone from - // unifiedTabsByWorktree. getTab answers null, so the guard used to pass. - mocks.getTab.mockReturnValue(null) - mocks.terminalTabs = [{ id: 'tab-1', viewMode: 'chat' }] - - await expect( - requestTerminalPaneRecovery({ - tabId: 'tab-1', - ptyId: 'pty-1', - reason: 'input-undeliverable' - }) - ).resolves.toBe(false) - expect(mocks.remountTerminalTabForRecovery).not.toHaveBeenCalled() - expect(mocks.hasPty).not.toHaveBeenCalled() - }) - it('remounts the tab and records a breadcrumb for a certified-dead pipeline', async () => { const result = await requestTerminalPaneRecovery({ tabId: 'tab-1', @@ -118,8 +73,6 @@ describe('requestTerminalPaneRecovery', () => { }) it('records a breadcrumb when the tab cannot be remounted, without consuming budget', async () => { - mocks.remountTerminalTabForRecovery.mockReturnValue(false) - const result = await requestTerminalPaneRecovery({ tabId: 'tab-gone', ptyId: 'pty-1', @@ -132,7 +85,7 @@ describe('requestTerminalPaneRecovery', () => { { tabId: 'tab-gone', reason: 'restore-blocked' } ) // Budget untouched: a later request for the same tab may still remount. - mocks.remountTerminalTabForRecovery.mockReturnValue(true) + setTerminalTabs([...terminalTabs(), { id: 'tab-gone' }]) expect( await requestTerminalPaneRecovery({ tabId: 'tab-gone', @@ -147,7 +100,7 @@ describe('requestTerminalPaneRecovery', () => { vi.setSystemTime(0) expect( - await requestTerminalPaneRecovery({ tabId: 'tab-1', ptyId: 'pty-1', reason: 'write-stalled' }) + await requestAndSettle({ tabId: 'tab-1', ptyId: 'pty-1', reason: 'write-stalled' }) ).toBe(true) expect( await requestTerminalPaneRecovery({ tabId: 'tab-1', ptyId: 'pty-1', reason: 'replay-wedged' }) @@ -161,25 +114,140 @@ describe('requestTerminalPaneRecovery', () => { expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledTimes(2) }) + it('refuses a second request while the last remount has reported nothing', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + + expect( + await requestTerminalPaneRecovery({ tabId: 'tab-1', ptyId: 'pty-1', reason: 'write-stalled' }) + ).toBe(true) + // Past the cooldown, inside the cap — only the unsettled attempt refuses it. + vi.setSystemTime(16_000) + expect( + await requestTerminalPaneRecovery({ tabId: 'tab-1', ptyId: 'pty-1', reason: 'replay-wedged' }) + ).toBe(false) + expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledTimes(1) + + settleCurrentRecovery('tab-1', 'success') + expect( + await requestTerminalPaneRecovery({ tabId: 'tab-1', ptyId: 'pty-1', reason: 'replay-wedged' }) + ).toBe(true) + }) + + it('refuses the same reason again once a pane reported it failed', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + + expect( + await requestAndSettle( + { tabId: 'tab-ssh', ptyId: 'ssh:target@@pty-1', reason: 'reattach-unverifiable' }, + 'failed' + ) + ).toBe(true) + + // Far past every window: counting would have healed, evidence has not. + for (const now of [16_000, 60_000, 600_000]) { + vi.setSystemTime(now) + expect( + await requestTerminalPaneRecovery({ + tabId: 'tab-ssh', + ptyId: 'ssh:target@@pty-1', + reason: 'reattach-unverifiable' + }) + ).toBe(false) + } + expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledTimes(1) + + // The user pressing Retry is the new trigger the refusal waits for. + expect( + await requestTerminalPaneRecovery({ + tabId: 'tab-ssh', + ptyId: 'ssh:target@@pty-1', + reason: 'reattach-unverifiable', + trigger: 'user' + }) + ).toBe(true) + }) + + it('reopens a settled failure when the row moves to a new generation', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + await requestAndSettle( + { tabId: 'tab-ssh', ptyId: 'ssh:target@@pty-1', reason: 'reattach-unverifiable' }, + 'failed' + ) + vi.setSystemTime(60_000) + expect( + await requestTerminalPaneRecovery({ + tabId: 'tab-ssh', + ptyId: 'ssh:target@@pty-1', + reason: 'reattach-unverifiable' + }) + ).toBe(false) + + // An SSH authority rotation / activation respawn bumps tab.generation. + setTerminalTabs( + terminalTabs().map((tab) => + tab.id === 'tab-ssh' ? { ...tab, generation: (tab.generation ?? 0) + 1 } : tab + ) + ) + expect( + await requestTerminalPaneRecovery({ + tabId: 'tab-ssh', + ptyId: 'ssh:target@@pty-1', + reason: 'reattach-unverifiable' + }) + ).toBe(true) + }) + + it('does not reopen a settled failure when a host rebuild drops generation', async () => { + // A remote-runtime snapshot rebuilds the row without `generation`. That is a + // field going missing, not a new trigger — reading it as one would restore + // the tab's allowance on every republication. + vi.useFakeTimers() + vi.setSystemTime(0) + await requestAndSettle( + { tabId: 'tab-ssh', ptyId: 'ssh:target@@pty-1', reason: 'reattach-unverifiable' }, + 'failed' + ) + const rebuilt = terminalTabs().map((tab) => + tab.id === 'tab-ssh' ? { id: tab.id, recovery: tab.recovery } : tab + ) + setTerminalTabs(rebuilt) + + vi.setSystemTime(60_000) + expect( + await requestTerminalPaneRecovery({ + tabId: 'tab-ssh', + ptyId: 'ssh:target@@pty-1', + reason: 'reattach-unverifiable' + }) + ).toBe(false) + }) + it('caps recoveries per window to prevent remount storms', async () => { vi.useFakeTimers() for (let attempt = 0; attempt < 5; attempt += 1) { vi.setSystemTime(attempt * 20_000) - await requestTerminalPaneRecovery({ + await requestAndSettle({ tabId: 'tab-1', ptyId: 'pty-1', reason: 'write-stalled' }) } expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledTimes(3) + expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledWith( + 'terminal_pane_recovery_window_cap', + { tabId: 'tab-1', reason: 'write-stalled' } + ) }) - it('releases recovery budget and retries when the tab closes', async () => { + it('drops the budget with the row the tab closure removes', async () => { vi.useFakeTimers() const instance = registerTerminalPaneRecoveryInstance('tab-1') for (let attempt = 0; attempt < 4; attempt += 1) { vi.setSystemTime(attempt * 20_000) - await requestTerminalPaneRecovery({ + await requestAndSettle({ tabId: 'tab-1', ptyId: 'pty-1', reason: 'write-stalled' @@ -188,14 +256,21 @@ describe('requestTerminalPaneRecovery', () => { expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledTimes(3) expect(vi.getTimerCount()).toBe(1) - mocks.terminalTabs = [] - mocks.getTab.mockReturnValue(null) + // Disposing the pane must NOT release anything: that release is what erased + // every consumed remount and let the cap lapse (crash b5cfc6ca). instance.unregister() + expect(captureTerminalPaneRecoveryGeneration('tab-1')).toBe(3) + expect( + await requestTerminalPaneRecovery({ + tabId: 'tab-1', + ptyId: 'pty-1', + reason: 'write-stalled' + }) + ).toBe(false) + // Closing the tab drops the row, and the budget with it — same object. + setTerminalTabs([{ id: 'tab-1' }]) expect(captureTerminalPaneRecoveryGeneration('tab-1')).toBe(0) - expect(vi.getTimerCount()).toBe(0) - mocks.terminalTabs = [{ id: 'tab-1' }] - mocks.getTab.mockReturnValue({}) expect( await requestTerminalPaneRecovery({ tabId: 'tab-1', @@ -210,7 +285,7 @@ describe('requestTerminalPaneRecovery', () => { vi.setSystemTime(0) for (let attempt = 0; attempt < 3; attempt += 1) { vi.setSystemTime(attempt * 20_000) - await requestTerminalPaneRecovery({ + await requestAndSettle({ tabId: 'tab-1', ptyId: 'pty-1', reason: 'write-stalled' @@ -241,7 +316,9 @@ describe('requestTerminalPaneRecovery', () => { vi.useFakeTimers() for (let attempt = 0; attempt < 4; attempt += 1) { vi.setSystemTime(attempt * 20_000) - await requestTerminalPaneRecovery({ + // Each remounted pane attaches, then wedges again minutes later: the + // window cap, not the outcome gate, is what this test is about. + await requestAndSettle({ tabId: 'tab-ssh', ptyId: 'ssh:target@@pty-1', reason: 'reattach-unverifiable', @@ -280,7 +357,7 @@ describe('requestTerminalPaneRecovery', () => { it('retries a fresh replacement xterm that wedges during the cooldown', async () => { vi.useFakeTimers() vi.setSystemTime(0) - await requestTerminalPaneRecovery({ + await requestAndSettle({ tabId: 'tab-1', ptyId: 'pty-1', reason: 'write-stalled', @@ -305,7 +382,7 @@ describe('requestTerminalPaneRecovery', () => { it('does not let an awaited scheduled retry remount a newer generation', async () => { vi.useFakeTimers() vi.setSystemTime(0) - await requestTerminalPaneRecovery({ + await requestAndSettle({ tabId: 'tab-1', ptyId: 'pty-1', reason: 'write-stalled', @@ -337,7 +414,8 @@ describe('requestTerminalPaneRecovery', () => { }) ).toBe(true) resolveLiveness?.(true) - await Promise.resolve() + // Drain the resumed probe here, or its remount lands in the next test. + await vi.advanceTimersByTimeAsync(0) expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledTimes(2) }) @@ -379,7 +457,7 @@ describe('requestTerminalPaneRecovery', () => { it('keeps a sibling pane retry when the first requesting split is disposed', async () => { vi.useFakeTimers() vi.setSystemTime(0) - await requestTerminalPaneRecovery({ + await requestAndSettle({ tabId: 'tab-1', ptyId: 'pty-1', reason: 'write-stalled' @@ -408,7 +486,7 @@ describe('requestTerminalPaneRecovery', () => { it('does not abandon a certified sibling behind a failed liveness retry', async () => { vi.useFakeTimers() vi.setSystemTime(0) - await requestTerminalPaneRecovery({ + await requestAndSettle({ tabId: 'tab-1', ptyId: 'pty-initial', reason: 'write-stalled' @@ -447,6 +525,7 @@ describe('requestTerminalPaneRecovery', () => { }) it('budgets tabs independently', async () => { + setTerminalTabs([...terminalTabs(), { id: 'tab-2' }]) expect( await requestTerminalPaneRecovery({ tabId: 'tab-1', ptyId: 'pty-1', reason: 'write-stalled' }) ).toBe(true) @@ -616,8 +695,6 @@ describe('requestTerminalPaneRecovery', () => { }) it('does not consume budget when the tab no longer exists', async () => { - mocks.remountTerminalTabForRecovery.mockReturnValue(false) - const result = await requestTerminalPaneRecovery({ tabId: 'tab-gone', ptyId: 'pty-1', @@ -670,8 +747,6 @@ describe('requestTerminalPaneRecovery', () => { }) it('does not arm when the remount never happened', async () => { - mocks.remountTerminalTabForRecovery.mockReturnValue(false) - const result = await requestTerminalPaneRecovery({ tabId: 'tab-gone', ptyId: 'pty-1', @@ -687,23 +762,28 @@ describe('requestTerminalPaneRecovery', () => { // Crash b5cfc6ca (1.4.198, Windows): 8878 'terminal_pane_recovery_remount' // breadcrumbs, every one reason='reattach-unverifiable', across 8 tabs in // 122.4s (median gap 10ms) — ~1110 per tab against a cap of 3 per 5min. The - // renderer then died allocating a 512x512 SkBitmap. Only one line outside the - // test reset clears recoveryTimestampsByTabId: the unregister() branch that - // fires when getTab() cannot see the tab. getTab reads unifiedTabsByWorktree - // while remountTerminalTabForRecovery reads and bumps tabsByWorktree, so a tab - // present in one index and absent from the other remounts and then erases the - // budget that remount just consumed. + // renderer then died allocating a 512x512 SkBitmap. + // + // The trigger was two tab indices: the budget lived in a module Map keyed by + // tabId, and the pane-disposal release erased it whenever getTab (which reads + // unifiedTabsByWorktree) could not see a tab remountTerminalTabForRecovery + // (which reads tabsByWorktree) still held. The mechanism is what mattered: + // each remount mounted a pane that captured a FRESH epoch, so the epoch check + // could never refuse its request, and a counting budget was the only thing + // between the failure and its own repetition. describe('unverifiable reattach remount storm (crash b5cfc6ca)', () => { const STORM_CYCLES = 200 const OBSERVED_MEDIAN_GAP_MS = 10 // One production reattach cycle: connect-pane-pty captures the epoch and - // registers the xterm, the reattach answers unverifiable - // (recoverUnverifiableDirectSshReattach), and the remount disposes that - // xterm — session-reconcile-dispose unregisters the instance. + // registers the xterm (connect-pane-pty.ts), the reattach answers + // unverifiable, recoverUnverifiableDirectSshReattach settles this pane's + // attempt 'failed' and re-requests, and the remount disposes that xterm — + // session-reconcile-dispose unregisters the instance. async function driveUnverifiableReattachCycle(tabId: string): Promise { const terminalRecoveryGeneration = captureTerminalPaneRecoveryGeneration(tabId) const instance = registerTerminalPaneRecoveryInstance(tabId) + settleTerminalPaneRecovery(tabId, terminalRecoveryGeneration, 'failed') await requestTerminalPaneRecovery({ tabId, ptyId: 'ssh:target@@pty-1', @@ -726,16 +806,47 @@ describe('requestTerminalPaneRecovery', () => { vi.setSystemTime(0) }) - it('caps remounts when the remounted tab is invisible to getTab', async () => { - // remountTerminalTabForRecovery still succeeds — the tab is in - // tabsByWorktree, which is what the 8878 remount breadcrumbs prove. + it('collapses the reported storm to a single remount', async () => { + // The reported run produced ~1110 remounts on this tab. One remount is + // admitted; after its pane reports the same reason failed, every later + // request is refused on evidence — not on a count, and not on a timer. + await driveStorm('tab-ssh') + + expect(mocks.remountTerminalTabForRecovery.mock.calls.length).toBe(1) + }) + + it('stops a slow failure chain the cooldown would have waved through', async () => { + // Gaps wider than the cooldown: counting would allow the cap's worth of + // remounts before noticing. Evidence stops it at the first observed + // failure — the chain never gets a second identical attempt. + for (let cycle = 0; cycle < 10; cycle += 1) { + vi.setSystemTime(cycle * 20_000) + await driveUnverifiableReattachCycle('tab-ssh') + } + + expect(mocks.remountTerminalTabForRecovery.mock.calls.length).toBe(1) + }) + + it('stays capped for a tab the unified index cannot see', async () => { + // The pre-#19745 trigger: present in tabsByWorktree, absent from + // unifiedTabsByWorktree. Nothing reads the unified index for budget or + // existence any more, so the drift has no expression at all. mocks.getTab.mockReturnValue(null) await driveStorm('tab-ssh') - // Pre-fix this ran one remount per cycle. The 15s cooldown — not the - // window cap — coalesces the whole 10ms-gap storm into the first. expect(mocks.remountTerminalTabForRecovery.mock.calls.length).toBe(1) }) + + it('still refuses when every cycle also disposes and re-registers its xterm', async () => { + // The disposal path is the one that used to release the budget. It now + // releases nothing that a remount wrote, so a 200-cycle dispose storm + // cannot restore the tab's allowance. + await driveStorm('tab-ssh') + const ledger = terminalTabs().find((tab) => tab.id === 'tab-ssh')?.recovery + + expect(ledger?.attemptedAt).toHaveLength(1) + expect(ledger?.outcome).toBe('failed') + }) }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts index 59182ca7b63..2e6326953da 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts @@ -1,7 +1,17 @@ import { useAppStore } from '@/store' import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' -import { isTerminalTabPresent } from '@/store/slices/terminal-tab-retirement' import { locateTerminalTab } from '@/store/terminals/terminal-tab-location' +import { + admitTerminalRecoveryRemount, + captureTabRecoveryGeneration +} from '@/store/terminals/terminal-tab-recovery-ledger' +import type { + TerminalRecoveryDecline, + TerminalRecoveryRemountRequest, + TerminalRecoveryRemountResult, + TerminalRecoveryTrigger +} from '@/store/terminals/terminal-tab-recovery-ledger' +import type { TerminalPaneRecoveryReason } from '../../../../shared/terminal-tab-types' import { _resetTerminalInputQuarantineForTests, armTerminalInputQuarantine @@ -17,25 +27,13 @@ import { // proven remount seam — bumping the tab's generation unmounts TerminalPane, // detach() preserves the live PTY, and the remounted pane builds a fresh // xterm that reattaches and replays the daemon snapshot. No shell restart. +// +// The budget and the epoch live on the tab row (terminal-tab-recovery-ledger), +// not in maps keyed by tabId here. Only the mounted-xterm registry below is +// still module-level: an xterm instance genuinely outlives no store row, so it +// has nothing to shadow. -export type TerminalPaneRecoveryReason = - | 'write-stalled' - | 'replay-wedged' - | 'input-undeliverable' - // The paired runtime that owns the PTY refused this write and said so on the - // wire. Distinct from 'input-undeliverable' because it skips the liveness - // probe: main's registry holds no entry for a `remote:` id, so `pty:hasPty` - // routes it to the local provider and answers a fabricated "dead". The - // rejection frame is the evidence instead — it came from the process that - // owns the PTY, over a connection that is by construction still up. - | 'input-rejected-by-host' - | 'reattach-unverifiable' - // A restore was requested for a certified-dead pipeline (reveal path). - | 'restore-blocked' - // A spawn resolved without a PTY id, so the pane is mounted with no transport - // binding. pty:data for the old id then lands in the pre-handler buffer, which - // ACKs it — main's delivery health stays green while the pane shows nothing. - | 'spawn-left-pane-unbound' +export type { TerminalPaneRecoveryReason } type RecoveryRequest = { tabId: string @@ -47,6 +45,9 @@ type RecoveryRequest = { /** Identifies the concrete mounted xterm making the request. Disposal * invalidates delayed work even when the tab's recovery epoch is unchanged. */ terminalRecoveryInstanceId?: number + /** Defaults to 'automatic'. 'user' marks the explicit Retry in the error + * toast, which is itself the new trigger a settled failure waits for. */ + trigger?: TerminalRecoveryTrigger /** Remote panes (runtime mirrors, app-SSH) must prove the PTY alive before * an input-undeliverable remount: pty:hasPty answers null for ids the local * registry doesn't own, and treating null as "proceed" would let a @@ -62,19 +63,6 @@ type RecoveryRequest = { endpointReplaced?: boolean } -// Why a cap exists: recovery must never loop. If the remounted pane wedges -// again (e.g. a deterministic parser throw in restored content), repeated -// bumps would remount-storm. The window is generous because a legitimate -// second recovery (new wedge minutes later) should still work. -const MAX_RECOVERIES_PER_WINDOW = 3 -const RECOVERY_WINDOW_MS = 5 * 60_000 -// Why a cooldown exists: one incident can trip several detectors (stall watch, -// replay guard, input path) within seconds; the first remount fixes all of -// them, the rest must coalesce instead of re-remounting mid-reattach. -const RECOVERY_COOLDOWN_MS = 15_000 - -const recoveryTimestampsByTabId = new Map() -const recoveryGenerationByTabId = new Map() const activeTerminalRecoveryInstanceIds = new Set() const pendingRetryByTabId = new Map< string, @@ -85,46 +73,40 @@ const pendingRetryByTabId = new Map< >() let nextTerminalRecoveryInstanceId = 0 -type RecoveryBudget = - | { allowed: true } - | { allowed: false; declinedBy: 'window-cap'; retryInMs: number } - | { allowed: false; declinedBy: 'cooldown'; retryInMs: number } - -function shouldScheduleRecoveryRetry(request: RecoveryRequest, budget: RecoveryBudget): boolean { - return ( - !budget.allowed && - (budget.declinedBy === 'cooldown' - ? request.terminalRecoveryGeneration !== undefined - : request.reason !== 'reattach-unverifiable') - ) +function toRemountRequest(request: RecoveryRequest, now: number): TerminalRecoveryRemountRequest { + return { + reason: request.reason, + trigger: request.trigger ?? 'automatic', + ...(request.terminalRecoveryGeneration === undefined + ? {} + : { generation: request.terminalRecoveryGeneration }), + now + } } -function recoveryBudget(tabId: string, now: number): RecoveryBudget { - const timestamps = recoveryTimestampsByTabId.get(tabId) ?? [] - const recent = timestamps.filter((t) => now - t < RECOVERY_WINDOW_MS) - if (recent.length !== timestamps.length) { - recoveryTimestampsByTabId.set(tabId, recent) +function shouldScheduleRecoveryRetry( + request: RecoveryRequest, + decline: TerminalRecoveryDecline +): decline is Extract { + if (decline.declinedBy === 'cooldown') { + return request.terminalRecoveryGeneration !== undefined } - if (recent.length >= MAX_RECOVERIES_PER_WINDOW) { - return { - allowed: false, - declinedBy: 'window-cap', - retryInMs: recent[0] + RECOVERY_WINDOW_MS - now - } + if (decline.declinedBy === 'unsettled') { + // A pane that never reports leaves 'pending' standing; re-asking once the + // settlement bound elapses is how that tab gets a second chance at all. + return request.terminalRecoveryGeneration !== undefined } - const last = recent.at(-1) - if (last !== undefined && now - last < RECOVERY_COOLDOWN_MS) { - return { - allowed: false, - declinedBy: 'cooldown', - retryInMs: last + RECOVERY_COOLDOWN_MS - now - } + if (decline.declinedBy === 'window-cap') { + return request.reason !== 'reattach-unverifiable' } - return { allowed: true } + // 'settled-failure' deliberately schedules nothing: a retry timer would be + // the counting loop again. Only a new trigger reopens that reason. + return false } export function captureTerminalPaneRecoveryGeneration(tabId: string): number { - return recoveryGenerationByTabId.get(tabId) ?? 0 + const state = useAppStore.getState() + return captureTabRecoveryGeneration(locateTerminalTab(state.tabsByWorktree, tabId)?.tab) } export function registerTerminalPaneRecoveryInstance(tabId: string): { @@ -142,15 +124,10 @@ export function registerTerminalPaneRecoveryInstance(tabId: string): { if (pendingRetry?.requestsByInstanceId.size === 0) { cancelPendingRecoveryRetry(tabId) } - // Read the SAME index remountTerminalTabForRecovery mutates. getTab answers - // from unifiedTabsByWorktree, which several slices let drift out of sync with - // tabsByWorktree; on the direct-SSH path that drift made every remount erase - // the budget it had just consumed, so the cap never held (crash b5cfc6ca). - if (!isTerminalTabPresent(useAppStore.getState(), tabId)) { - recoveryTimestampsByTabId.delete(tabId) - recoveryGenerationByTabId.delete(tabId) - cancelPendingRecoveryRetry(tabId) - } + // No budget release here, by construction: the ledger is a field on the + // tab row, so closing the tab drops it and nothing else can. Releasing it + // from a pane disposal is what erased every consumed remount and let the + // cap lapse (crash b5cfc6ca). } } } @@ -211,6 +188,21 @@ function cancelPendingRecoveryRetry(tabId: string): void { } } +function handleDeclinedRecovery(request: RecoveryRequest, decline: TerminalRecoveryDecline): false { + if (decline.declinedBy === 'window-cap') { + // The backstop firing means the outcome gate let a loop through. That is a + // bug in the gate, so leave a trace rather than only declining quietly. + recordRendererCrashBreadcrumb('terminal_pane_recovery_window_cap', { + tabId: request.tabId, + reason: request.reason + }) + } + if (shouldScheduleRecoveryRetry(request, decline)) { + scheduleRecoveryRetry(request, decline.retryInMs) + } + return false +} + /** * Remount the pane's tab to rebuild its renderer over the live PTY. Returns * true when a remount was actually requested. @@ -222,29 +214,41 @@ function cancelPendingRecoveryRetry(tabId: string): void { * either way: a remount rebuilds the renderer over the PTY it already had. */ export async function requestTerminalPaneRecovery(request: RecoveryRequest): Promise { - if (!isCurrentTerminalRecoveryRequest(request)) { - return false - } - // A terminal-backed tab is intentionally hidden while native chat owns the - // provider. Late xterm callbacks from that hidden surface must not remount - // the tab and race the handoff's owner transition. Ask both indices: local - // toggles only patch the unified tab, but that index can transiently drop a - // row the remount index still holds (crash b5cfc6ca) and a hole there must - // not read as "not chat-owned". - const state = useAppStore.getState() if ( - state.getTab?.(request.tabId)?.viewMode === 'chat' || - locateTerminalTab(state.tabsByWorktree, request.tabId)?.tab.viewMode === 'chat' + request.terminalRecoveryInstanceId !== undefined && + !activeTerminalRecoveryInstanceIds.has(request.terminalRecoveryInstanceId) ) { return false } - const budget = recoveryBudget(request.tabId, Date.now()) - if (!budget.allowed) { - if (shouldScheduleRecoveryRetry(request, budget)) { - scheduleRecoveryRetry(request, budget.retryInMs) - } + const state = useAppStore.getState() + const tab = locateTerminalTab(state.tabsByWorktree, request.tabId)?.tab + // A terminal-backed tab is intentionally hidden while native chat owns the + // provider. Late xterm callbacks from that hidden surface must not remount + // the tab and race the handoff's owner transition. + // + // Both indices, deliberately. The row is now the durable record (viewMode + // persists on it, and the local toggles patch it in the same set() as the + // unified tab), but a session written before that lives on disk with viewMode + // only on the unified tab, so the row reads undefined on the first load after + // upgrade. More generally this is a disjunction over two partly-redundant + // sources for a SAFETY check: a hole in either index errs toward refusing a + // heal on a hidden surface, never toward remounting a chat-owned one. + if (tab?.viewMode === 'chat' || state.getTab?.(request.tabId)?.viewMode === 'chat') { return false } + // Fail fast before the liveness probe. The authoritative admission runs + // again inside remountTerminalTabForRecovery's write. + const admission = admitTerminalRecoveryRemount(tab, toRemountRequest(request, Date.now())) + if (!admission.admitted) { + if (admission.declinedBy === 'stale-generation') { + return false + } + // 'tab-missing' deliberately falls through: the store call below is what + // records the remount-unavailable breadcrumb for a vanished tab. + if (admission.declinedBy !== 'tab-missing') { + return handleDeclinedRecovery(request, admission) + } + } // 'input-rejected-by-host' is deliberately absent: no local probe can speak // for the id it carries, and its evidence already came from the PTY's owner. if (request.reason === 'input-undeliverable') { @@ -267,22 +271,12 @@ export async function requestTerminalPaneRecovery(request: RecoveryRequest): Pro // over a dead PTY degrades to the existing dead-pane rendering, not a // broken state. } - // Re-check the budget across the await: a concurrent detector may have - // already consumed it for this tab. - if (!isCurrentTerminalRecoveryRequest(request)) { - return false - } - const recheck = recoveryBudget(request.tabId, Date.now()) - if (!recheck.allowed) { - if (shouldScheduleRecoveryRetry(request, recheck)) { - scheduleRecoveryRetry(request, recheck.retryInMs) - } - return false - } } - let remounted = false + let result: TerminalRecoveryRemountResult try { - remounted = useAppStore.getState().remountTerminalTabForRecovery(request.tabId) + result = useAppStore + .getState() + .remountTerminalTabForRecovery(request.tabId, toRemountRequest(request, Date.now())) } catch { // Why: recovery fires from timer and write-callback contexts (stall watch, // replay guard, onData) — it is best-effort by contract and must never @@ -296,23 +290,21 @@ export async function requestTerminalPaneRecovery(request: RecoveryRequest): Pro }) return false } - if (!remounted) { - // Why: this was the one silent outcome — the tab is gone from the store - // (closed/orphaned), so retrying is pointless, but the trace must show - // that a certified-dead pane asked for recovery and none happened. - recordRendererCrashBreadcrumb('terminal_pane_recovery_remount_unavailable', { - tabId: request.tabId, - reason: request.reason - }) - return false + if (!result.remounted) { + if (result.declinedBy === 'tab-missing') { + // Why: this was the one silent outcome — the tab is gone from the store + // (closed/orphaned), so retrying is pointless, but the trace must show + // that a certified-dead pane asked for recovery and none happened. + recordRendererCrashBreadcrumb('terminal_pane_recovery_remount_unavailable', { + tabId: request.tabId, + reason: request.reason + }) + return false + } + return result.declinedBy === 'stale-generation' + ? false + : handleDeclinedRecovery(request, result) } - const timestamps = recoveryTimestampsByTabId.get(request.tabId) ?? [] - timestamps.push(Date.now()) - recoveryTimestampsByTabId.set(request.tabId, timestamps) - recoveryGenerationByTabId.set( - request.tabId, - captureTerminalPaneRecoveryGeneration(request.tabId) + 1 - ) // A remount replaces every pane xterm in the tab; a previously scheduled // retry would only re-remount the fresh, healthy panes. cancelPendingRecoveryRetry(request.tabId) @@ -334,9 +326,21 @@ export async function requestTerminalPaneRecovery(request: RecoveryRequest): Pro return true } +/** Report what this mounted pane observed for the recovery epoch it captured. + * Reuses the direct-SSH pane retry vocabulary so a pane settles both ledgers + * from the same call sites. Ignored unless the epoch is still current. */ +export function settleTerminalPaneRecovery( + tabId: string, + generation: number | undefined, + outcome: 'success' | 'failed' | 'timed-out' | 'superseded' +): void { + if (generation === undefined) { + return + } + useAppStore.getState().settleTerminalTabRecovery?.(tabId, generation, outcome) +} + export function _resetTerminalPaneRecoveryForTests(): void { - recoveryTimestampsByTabId.clear() - recoveryGenerationByTabId.clear() activeTerminalRecoveryInstanceIds.clear() nextTerminalRecoveryInstanceId = 0 for (const pendingRetry of pendingRetryByTabId.values()) { diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-surface-ownership.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-surface-ownership.test.ts new file mode 100644 index 00000000000..e27e7179ffb --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-surface-ownership.test.ts @@ -0,0 +1,255 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { parseWorkspaceSession } from '../../../../shared/workspace-session-schema' +import { + _resetTerminalPaneRecoveryForTests, + requestTerminalPaneRecovery +} from './terminal-pane-recovery' + +/** + * Who owns the rendered surface, and therefore whether recovery may remount it. + * + * A terminal-backed tab is intentionally hidden while native chat owns the + * provider. Late xterm callbacks from that hidden surface must not remount the + * tab and race the handoff's owner transition (#19745). + * + * The guard reads BOTH indices on purpose. The terminal row is the durable + * record — viewMode persists on it, and the local toggles patch it in the same + * set() as the unified tab — but a session written before the row carried + * viewMode loads with it only on the unified tab. More generally this is a + * disjunction over two partly-redundant sources for a safety check: a hole in + * either index errs toward declining a heal, never toward remounting a + * chat-owned surface. + */ +type StoredTerminalTab = Pick + +const WORKTREE_ID = 'repo1::/path/wt1' + +const mocks = vi.hoisted(() => ({ + tabsByWorktree: {} as Record, + remountTerminalTabForRecovery: vi.fn(() => ({ remounted: true as const, generation: 1 })), + getTab: vi.fn<() => { viewMode?: 'terminal' | 'chat' } | null>(() => ({})), + hasPty: vi.fn<(id: string) => Promise>(async () => true) +})) + +function setTerminalTabs(tabs: StoredTerminalTab[]): void { + mocks.tabsByWorktree = { [WORKTREE_ID]: tabs } +} + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ + tabsByWorktree: mocks.tabsByWorktree, + remountTerminalTabForRecovery: mocks.remountTerminalTabForRecovery, + getTab: mocks.getTab + }) + } +})) + +vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ + recordRendererCrashBreadcrumb: vi.fn() +})) + +beforeEach(() => { + _resetTerminalPaneRecoveryForTests() + mocks.remountTerminalTabForRecovery.mockClear() + mocks.getTab.mockClear() + mocks.getTab.mockReturnValue({}) + mocks.hasPty.mockClear() + mocks.hasPty.mockResolvedValue(true) + setTerminalTabs([{ id: 'tab-1' }]) + vi.stubGlobal('window', { api: { pty: { hasPty: mocks.hasPty } } }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('terminal surface ownership', () => { + it('does not remount a terminal surface hidden behind native chat', async () => { + setTerminalTabs([{ id: 'tab-1', viewMode: 'chat' }]) + + await expect( + requestTerminalPaneRecovery({ + tabId: 'tab-1', + ptyId: 'pty-1', + reason: 'input-undeliverable' + }) + ).resolves.toBe(false) + expect(mocks.remountTerminalTabForRecovery).not.toHaveBeenCalled() + expect(mocks.hasPty).not.toHaveBeenCalled() + }) + + it('does not remount a chat-owned tab the unified tab index has dropped', async () => { + // The drift crash b5cfc6ca documents: present in tabsByWorktree, gone from + // unifiedTabsByWorktree. getTab answers null, and the guard reads the row. + mocks.getTab.mockReturnValue(null) + setTerminalTabs([{ id: 'tab-1', viewMode: 'chat' }]) + + await expect( + requestTerminalPaneRecovery({ + tabId: 'tab-1', + ptyId: 'pty-1', + reason: 'input-undeliverable' + }) + ).resolves.toBe(false) + expect(mocks.remountTerminalTabForRecovery).not.toHaveBeenCalled() + expect(mocks.hasPty).not.toHaveBeenCalled() + }) + + it('refuses a chat-owned tab whose row lost viewMode across a restart', async () => { + // The upgrade transition: a session written before viewMode was declared on + // terminalTabSchema has it only on the unified tab, so the row loads + // undefined. Reading the row alone remounted a chat-owned hidden surface on + // the first launch after upgrade — the race the guard exists to stop. + mocks.getTab.mockReturnValue({ viewMode: 'chat' }) + setTerminalTabs([{ id: 'tab-1' }]) + + await expect( + requestTerminalPaneRecovery({ + tabId: 'tab-1', + ptyId: 'pty-1', + reason: 'write-stalled' + }) + ).resolves.toBe(false) + expect(mocks.remountTerminalTabForRecovery).not.toHaveBeenCalled() + }) + + it('refuses a chat-owned row the unified tab index has no opinion on', async () => { + // The other direction: the row is authoritative even when getTab is blind. + mocks.getTab.mockReturnValue(null) + setTerminalTabs([{ id: 'tab-1', viewMode: 'chat' }]) + + await expect( + requestTerminalPaneRecovery({ + tabId: 'tab-1', + ptyId: 'pty-1', + reason: 'write-stalled' + }) + ).resolves.toBe(false) + expect(mocks.remountTerminalTabForRecovery).not.toHaveBeenCalled() + }) + + it('heals a terminal-owned tab both indices agree on', async () => { + mocks.getTab.mockReturnValue({ viewMode: 'terminal' }) + setTerminalTabs([{ id: 'tab-1', viewMode: 'terminal' }]) + + await expect( + requestTerminalPaneRecovery({ + tabId: 'tab-1', + ptyId: 'pty-1', + reason: 'write-stalled' + }) + ).resolves.toBe(true) + }) + + // The upgrade population, driven through the real loader rather than a stub: + // a session an OLDER build wrote carries viewMode only on the unified tab, + // because terminalTabSchema did not declare it yet. Zod strips what it does + // not declare, so the reloaded ROW reads undefined while the reloaded UNIFIED + // TAB still says 'chat'. Only the second arm of the guard's disjunction can + // refuse this one — which is why the arm #19745 added was kept. + it('refuses a chat-owned tab an older build persisted without a row viewMode', async () => { + const loaded = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: WORKTREE_ID, + activeTabId: 'tab-1', + tabsByWorktree: { + // Exactly what a pre-viewMode build wrote for the terminal row. + [WORKTREE_ID]: [ + { + id: 'tab-1', + ptyId: null, + worktreeId: WORKTREE_ID, + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + ] + }, + unifiedTabs: { + [WORKTREE_ID]: [ + { + id: 'tab-1', + entityId: 'terminal-1', + groupId: 'group-1', + worktreeId: WORKTREE_ID, + contentType: 'terminal', + label: 'Terminal 1', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0, + viewMode: 'chat' + } + ] + }, + terminalLayoutsByTabId: {} + }) + expect(loaded.ok).toBe(true) + const session = loaded.ok ? loaded.value : null + const reloadedRow = session?.tabsByWorktree[WORKTREE_ID]?.[0] + const reloadedUnifiedTab = session?.unifiedTabs?.[WORKTREE_ID]?.[0] + // The premise: the load boundary really did drop the row's ownership. + expect(reloadedRow?.viewMode).toBeUndefined() + expect(reloadedUnifiedTab?.viewMode).toBe('chat') + + setTerminalTabs([reloadedRow as StoredTerminalTab]) + mocks.getTab.mockReturnValue(reloadedUnifiedTab as { viewMode?: 'terminal' | 'chat' }) + + await expect( + requestTerminalPaneRecovery({ + tabId: 'tab-1', + ptyId: 'pty-1', + reason: 'write-stalled' + }) + ).resolves.toBe(false) + expect(mocks.remountTerminalTabForRecovery).not.toHaveBeenCalled() + }) + + // The case a same-process test cannot reach: the row goes to disk and comes + // back through the real Zod loader. A field the schema does not declare is + // stripped there, silently, and every in-session assertion still passes. + it('still refuses a chat-owned tab after a real persist/parse round trip', async () => { + const loaded = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: WORKTREE_ID, + activeTabId: 'tab-1', + tabsByWorktree: { + [WORKTREE_ID]: [ + { + id: 'tab-1', + ptyId: null, + worktreeId: WORKTREE_ID, + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0, + viewMode: 'chat' + } + ] + }, + terminalLayoutsByTabId: {} + }) + expect(loaded.ok).toBe(true) + setTerminalTabs( + (loaded.ok ? loaded.value.tabsByWorktree[WORKTREE_ID] : []) as StoredTerminalTab[] + ) + // Blind on purpose: only the reloaded row can refuse this. + mocks.getTab.mockReturnValue(null) + + await expect( + requestTerminalPaneRecovery({ + tabId: 'tab-1', + ptyId: 'pty-1', + reason: 'write-stalled' + }) + ).resolves.toBe(false) + expect(mocks.remountTerminalTabForRecovery).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-recovery-ledger-test-driver.ts b/src/renderer/src/components/terminal-pane/terminal-recovery-ledger-test-driver.ts new file mode 100644 index 00000000000..fff300172eb --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-recovery-ledger-test-driver.ts @@ -0,0 +1,42 @@ +import { + captureTerminalPaneRecoveryGeneration, + requestTerminalPaneRecovery, + settleTerminalPaneRecovery +} from './terminal-pane-recovery' +import { setTerminalTabs, terminalTabs } from './terminal-recovery-ledger-test-store' + +// One request/settle cycle over the real recovery module, for suites driving +// the ledger through terminal-recovery-ledger-test-store's fake store. Separate +// from that module because the `@/store` mock factory imports it, and a factory +// that reached back into the module under test would deadlock. + +/** What a mounted pane reports for the tab's current recovery attempt. */ +export function settleCurrentRecovery( + tabId: string, + outcome: 'success' | 'failed' | 'timed-out' +): void { + settleTerminalPaneRecovery(tabId, captureTerminalPaneRecoveryGeneration(tabId), outcome) +} + +/** A full cycle: request, then the pane the remount mounted reports back. + * Recovery gates on an observed outcome, so a caller that never reports is + * refused — these are the callers that DO report. */ +export async function requestAndSettle( + request: Parameters[0], + outcome: 'success' | 'failed' | 'timed-out' = 'success' +): Promise { + const recovered = await requestTerminalPaneRecovery(request) + if (recovered) { + settleCurrentRecovery(request.tabId, outcome) + } + return recovered +} + +/** The new trigger an SSH authority rotation or activation respawn supplies. */ +export function bumpTabGeneration(tabId: string): void { + setTerminalTabs( + terminalTabs().map((tab) => + tab.id === tabId ? { ...tab, generation: (tab.generation ?? 0) + 1 } : tab + ) + ) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-recovery-ledger-test-store.ts b/src/renderer/src/components/terminal-pane/terminal-recovery-ledger-test-store.ts new file mode 100644 index 00000000000..c6290453d59 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-recovery-ledger-test-store.ts @@ -0,0 +1,96 @@ +import { vi, type Mock } from 'vitest' +import { + createRemountTerminalTabForRecovery, + createSettleTerminalTabRecovery +} from '@/store/slices/worktrees/session/worktree-slice-lookups' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' + +// Why a shared fake store: the recovery budget is a field on the tab row, so a +// fake that only answered booleans cannot express what these modules read. The +// store actions below are the REAL ones, driven over a minimal state bag — +// every suite that exercises the ledger needs exactly that, and a second copy +// would be free to drift from the shape the store actually writes. +// +// Deliberately imports nothing from terminal-pane-recovery: the `@/store` mock +// factory imports THIS module, so a back-edge to the module under test would +// deadlock the factory. The request/settle cycle lives in the sibling driver. + +export type StoredTerminalTab = Pick + +export const WORKTREE_ID = 'repo1::/path/wt1' + +/** Explicit, because an inferred `vi.fn()` shape is not portable across projects. */ +type RecoveryLedgerMocks = { + state: { + tabsByWorktree: Record + terminalLayoutsByTabId: Record + pendingStartupByTabId: Record + } + remountTerminalTabForRecovery: Mock<(tabId: string) => void> + getTab: Mock<() => { viewMode?: 'terminal' | 'chat' } | null> + recordRendererCrashBreadcrumb: Mock<(...args: unknown[]) => void> + hasPty: Mock<(id: string) => Promise> +} + +export const recoveryLedgerMocks: RecoveryLedgerMocks = { + state: { + tabsByWorktree: {} as Record, + terminalLayoutsByTabId: {} as Record, + pendingStartupByTabId: {} as Record + }, + // Records tabIds the store ACTUALLY remounted, so every assertion keeps + // meaning "a remount happened" rather than "a remount was asked for". + remountTerminalTabForRecovery: vi.fn<(tabId: string) => void>(), + getTab: vi.fn<() => { viewMode?: 'terminal' | 'chat' } | null>(() => ({})), + recordRendererCrashBreadcrumb: vi.fn(), + hasPty: vi.fn<(id: string) => Promise>(async () => true) +} + +const storeSet = (updater: unknown): void => { + const patch = + typeof updater === 'function' + ? (updater as (state: unknown) => object)(recoveryLedgerMocks.state) + : (updater as object) + Object.assign(recoveryLedgerMocks.state, patch) +} +const storeGet = (): unknown => recoveryLedgerMocks.state + +const realRemount = createRemountTerminalTabForRecovery(storeSet as never, storeGet as never) +const realSettle = createSettleTerminalTabRecovery(storeSet as never, storeGet as never) + +const recordingRemount: typeof realRemount = (tabId, request) => { + const result = realRemount(tabId, request) + if (result.remounted) { + recoveryLedgerMocks.remountTerminalTabForRecovery(tabId) + } + return result +} + +/** The `@/store` surface these suites mock, wired to the real store actions. */ +export function recoveryLedgerStoreState(): Record { + return { + ...recoveryLedgerMocks.state, + remountTerminalTabForRecovery: recordingRemount, + settleTerminalTabRecovery: realSettle, + getTab: recoveryLedgerMocks.getTab + } +} + +export function terminalTabs(): StoredTerminalTab[] { + return (recoveryLedgerMocks.state.tabsByWorktree[WORKTREE_ID] ?? []) as StoredTerminalTab[] +} + +export function setTerminalTabs(tabs: StoredTerminalTab[]): void { + recoveryLedgerMocks.state.tabsByWorktree = { [WORKTREE_ID]: tabs } +} + +export function resetRecoveryLedgerStore(): void { + recoveryLedgerMocks.remountTerminalTabForRecovery.mockReset() + recoveryLedgerMocks.getTab.mockClear() + recoveryLedgerMocks.getTab.mockReturnValue({}) + recoveryLedgerMocks.state.terminalLayoutsByTabId = {} + recoveryLedgerMocks.state.pendingStartupByTabId = {} + recoveryLedgerMocks.recordRendererCrashBreadcrumb.mockClear() + recoveryLedgerMocks.hasPty.mockClear() + recoveryLedgerMocks.hasPty.mockResolvedValue(true) +} diff --git a/src/renderer/src/hooks/remote-workspace-session-merge-local-survival.test.ts b/src/renderer/src/hooks/remote-workspace-session-merge-local-survival.test.ts index 361493b34ef..b3ed31bb963 100644 --- a/src/renderer/src/hooks/remote-workspace-session-merge-local-survival.test.ts +++ b/src/renderer/src/hooks/remote-workspace-session-merge-local-survival.test.ts @@ -379,4 +379,39 @@ describe('local rows the snapshot carries no answer for', () => { expect(merged.defaultTerminalTabsAppliedByWorktreeId?.[WORKTREE]).toBe(true) }) + + // The recovery ledger is client-local: the host has never heard of it and its + // snapshot never carries one. Letting a reconnect erase it hands the tab a + // fresh remount allowance on every republication — which is the remount storm + // (b5cfc6ca) the ledger exists to end, restored on a timer. + describe('client-local recovery ledger', () => { + const ledger = { + attemptedAt: [1000], + generation: 1, + outcome: 'failed' as const, + startedAt: 1000, + reason: 'reattach-unverifiable' as const, + tabGeneration: 1 + } + + it('survives a reconnect the host snapshot knows nothing about', () => { + const local = terminalTab('agent', { generation: 1, recovery: ledger }) + const current = sessionState({ tabsByWorktree: { [WORKTREE]: [local] } }) + const remote = sessionState({ tabsByWorktree: { [WORKTREE]: [terminalTab('agent')] } }) + + const merged = merge(current, remote, { [WORKTREE]: [local] }) + + expect(merged.tabsByWorktree[WORKTREE][0].recovery).toEqual(ledger) + }) + + it('leaves a tab that never recovered without one', () => { + const local = terminalTab('agent') + const current = sessionState({ tabsByWorktree: { [WORKTREE]: [local] } }) + const remote = sessionState({ tabsByWorktree: { [WORKTREE]: [terminalTab('agent')] } }) + + const merged = merge(current, remote, { [WORKTREE]: [local] }) + + expect(merged.tabsByWorktree[WORKTREE][0].recovery).toBeUndefined() + }) + }) }) diff --git a/src/renderer/src/hooks/remote-workspace-session-merge.ts b/src/renderer/src/hooks/remote-workspace-session-merge.ts index fa247d8bbbd..f8c2c9dabf2 100644 --- a/src/renderer/src/hooks/remote-workspace-session-merge.ts +++ b/src/renderer/src/hooks/remote-workspace-session-merge.ts @@ -14,7 +14,11 @@ function preserveNewerLocalTerminalFields(remote: TerminalTab, local: TerminalTa const preserved = { ...remote, generation: local.generation, - ptyId: local.ptyId + ptyId: local.ptyId, + // Why: the recovery ledger is client-local and travels with generation — + // a remote snapshot that dropped it would hand the tab a fresh remount + // allowance on every republication, which is the storm again (b5cfc6ca). + ...(local.recovery ? { recovery: local.recovery } : {}) } return local.pendingActivationSpawn ? { ...preserved, pendingActivationSpawn: local.pendingActivationSpawn } diff --git a/src/renderer/src/lib/session-write-subscriber.test.ts b/src/renderer/src/lib/session-write-subscriber.test.ts index 80111781534..9753d8303b7 100644 --- a/src/renderer/src/lib/session-write-subscriber.test.ts +++ b/src/renderer/src/lib/session-write-subscriber.test.ts @@ -389,6 +389,43 @@ describe('createSessionWriteSubscriber', () => { cleanup() }) + it('ignores recovery-ledger-only changes', () => { + // Why: the ledger is stripped from the persisted session, so churning it + // must not rebuild and rewrite the durable payload on every remount. + const persist = vi.fn<(payload: WorkspaceSessionWrite) => void>() + const cleanup = createSessionWriteSubscriber({ store: useAppStore, persist }) + + useAppStore.setState({ + workspaceSessionReady: true, + hydrationSucceeded: true, + ...makeTerminalSessionState('bash') + }) + vi.advanceTimersByTime(200) + persist.mockClear() + + useAppStore.setState({ + tabsByWorktree: { + 'wt-1': [ + { + ...useAppStore.getState().tabsByWorktree['wt-1'][0], + recovery: { + attemptedAt: [1], + generation: 1, + outcome: 'pending', + startedAt: 1, + reason: 'reattach-unverifiable', + tabGeneration: 0 + } + } + ] + } + }) + vi.advanceTimersByTime(200) + + expect(persist).not.toHaveBeenCalled() + cleanup() + }) + it('ignores decorative unified terminal label churn', () => { const persist = vi.fn<(payload: WorkspaceSessionWrite) => void>() const cleanup = createSessionWriteSubscriber({ store: useAppStore, persist }) diff --git a/src/renderer/src/lib/session-write-subscriber.ts b/src/renderer/src/lib/session-write-subscriber.ts index d54675e15ab..685a8e56e3f 100644 --- a/src/renderer/src/lib/session-write-subscriber.ts +++ b/src/renderer/src/lib/session-write-subscriber.ts @@ -14,7 +14,10 @@ type UnifiedTab = UnifiedTabsByWorktree[string][number] const TERMINAL_TAB_LIVE_TITLE_KEYS = new Set(['title']) // Why: this handoff flag is stripped from workspace sessions, so toggling it // alone should not rebuild and rewrite the durable session payload. -const TERMINAL_TAB_TRANSIENT_SESSION_KEYS = new Set(['pendingActivationSpawn']) +const TERMINAL_TAB_TRANSIENT_SESSION_KEYS = new Set([ + 'pendingActivationSpawn', + 'recovery' +]) function terminalTabChangedForSession(prev: TerminalTab, next: TerminalTab): boolean { if (prev === next) { diff --git a/src/renderer/src/lib/workspace-session-patch.test.ts b/src/renderer/src/lib/workspace-session-patch.test.ts index 2f604fb67bb..d2084d6fd03 100644 --- a/src/renderer/src/lib/workspace-session-patch.test.ts +++ b/src/renderer/src/lib/workspace-session-patch.test.ts @@ -182,7 +182,15 @@ describe('buildWorkspaceSessionPatch', () => { title: 'shell', ptyId: 'pty-1', worktreeId: localWorktreeId, - pendingActivationSpawn: true + pendingActivationSpawn: true, + recovery: { + attemptedAt: [1], + generation: 1, + outcome: 'pending', + startedAt: 1, + reason: 'reattach-unverifiable', + tabGeneration: 1 + } } as never ] }, @@ -217,6 +225,9 @@ describe('buildWorkspaceSessionPatch', () => { ].sort() ) expect('pendingActivationSpawn' in patch.tabsByWorktree![localWorktreeId][0]).toBe(false) + // Why: the recovery ledger describes a mounted pane's in-flight heal; a + // persisted one would refuse the first legitimate recovery after restart. + expect('recovery' in patch.tabsByWorktree![localWorktreeId][0]).toBe(false) expect(patch.terminalLayoutsByTabId?.['tab-local'].buffersByLeafId).toBeUndefined() expect(patch.terminalLayoutsByTabId?.['tab-local'].scrollbackRefsByLeafId).toBeUndefined() }) diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index 330a150b636..affa2b36ca4 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -204,12 +204,14 @@ export function buildSanitizedTabsByWorktree( tabsByWorktree: WorkspaceSessionSnapshot['tabsByWorktree'] ): WorkspaceSessionState['tabsByWorktree'] { // Why: strip transient pendingActivationSpawn — session:set persists without Zod re-parse, so a stale flag would drop the first PTY spawn on restart. + // Same for the recovery ledger: it describes a mounted pane's in-flight heal, so a persisted one would refuse the first recovery after restart. return Object.fromEntries( Object.entries(tabsByWorktree).map(([worktreeId, tabs]) => [ worktreeId, tabs.map((tab) => { - const { pendingActivationSpawn: _unused, ...rest } = tab + const { pendingActivationSpawn: _unused, recovery: _recovery, ...rest } = tab void _unused + void _recovery return rest }) ]) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/mirrored-terminal-recovery-ledger.test.ts b/src/renderer/src/runtime/web-session-tabs-sync/mirrored-terminal-recovery-ledger.test.ts new file mode 100644 index 00000000000..d1992a27030 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/mirrored-terminal-recovery-ledger.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import type { TerminalTab, TerminalTabRecoveryLedger } from '../../../../shared/terminal-tab-types' +import { buildMirroredTerminalTabs } from './terminal-build' +import { toWebTerminalSurfaceTabId } from '../web-terminal-surface-id' + +/** + * The recovery ledger is client-local. The host publishes no such field, so a + * rebuild that does not carry the existing one restores this tab's remount + * allowance on EVERY snapshot — which is the remount storm (b5cfc6ca) the + * ledger exists to end, re-armed on the host's publication cadence. + * + * `generation` is deliberately not asserted here: the host carries none and the + * rebuild emits none, which is why `isSupersededLedger` compares strictly + * forward (`>`) rather than `!==`. See terminal-tab-recovery-ledger.ts. + */ +const WORKTREE = 'repo-1::worktree-1' +const ENVIRONMENT = 'env-1' +const HOST_TAB = 'host-tab-1' + +const LEDGER: TerminalTabRecoveryLedger = { + attemptedAt: [1_000], + generation: 1, + outcome: 'failed', + startedAt: 1_000, + reason: 'reattach-unverifiable', + tabGeneration: 1 +} + +function snapshot(): RuntimeMobileSessionTabsResult { + return { + worktree: WORKTREE, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: null, + activeTabType: null, + tabs: [ + { + type: 'terminal', + id: 'surface-1', + parentTabId: HOST_TAB, + leafId: 'leaf-1', + title: 'Terminal', + status: 'ready', + terminal: 'handle-1', + isActive: true + } + ] + } as RuntimeMobileSessionTabsResult +} + +function rebuild(existing?: Partial): TerminalTab { + const localTabId = toWebTerminalSurfaceTabId(HOST_TAB) + const existingById = new Map( + existing ? [[localTabId, { id: localTabId, ...existing } as TerminalTab]] : [] + ) + const [mirrored] = buildMirroredTerminalTabs(snapshot(), ENVIRONMENT, existingById, {}, 0, 1_000) + return mirrored!.tab +} + +describe('buildMirroredTerminalTabs recovery ledger', () => { + it('carries the client-local ledger across a host snapshot rebuild', () => { + expect(rebuild({ recovery: LEDGER }).recovery).toEqual(LEDGER) + }) + + it('emits none for a tab that never recovered', () => { + expect(rebuild({}).recovery).toBeUndefined() + }) + + it('emits none for a tab the client has never seen', () => { + expect(rebuild().recovery).toBeUndefined() + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts b/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts index dfc3759009d..aacb445bbe3 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts @@ -171,6 +171,10 @@ export function buildMirroredTerminalTabs( // without this dropped the client's agent-prompt label on every snapshot. ...(existing?.generatedTitle ? { generatedTitle: existing.generatedTitle } : {}), ...(existing?.aiVaultTitle ? { aiVaultTitle: existing.aiVaultTitle } : {}), + // Why: the recovery ledger is client-local and the host carries none, so + // rebuilding without it would restore this tab's remount allowance on + // every snapshot — the counting loop recovery is meant to end (b5cfc6ca). + ...(existing?.recovery ? { recovery: existing.recovery } : {}), ...(quickCommandLabel ? { quickCommandLabel } : {}), ...(startupCwd ? { startupCwd } : {}), customTitle: existing?.customTitle ?? null, diff --git a/src/renderer/src/store/slices/tab-view-mode.test.ts b/src/renderer/src/store/slices/tab-view-mode.test.ts index aa892756b23..56b99c13ba4 100644 --- a/src/renderer/src/store/slices/tab-view-mode.test.ts +++ b/src/renderer/src/store/slices/tab-view-mode.test.ts @@ -81,4 +81,41 @@ describe('tab view mode', () => { store.getState().toggleTabViewMode('missing-tab') expect(store.getState().unifiedTabsByWorktree[WT]).toBe(before) }) + + // Why: terminal-pane recovery asks the terminal row who owns the surface. + // Host sync already writes viewMode there; only these local toggles skipped + // it, which is why the guard had to OR two indices to get a safe answer. + describe('mirrors onto the terminal row', () => { + function terminalRow(tabId: string) { + return store.getState().tabsByWorktree[WT]?.find((tab) => tab.id === tabId) + } + + beforeEach(() => { + const tabId = store.getState().createTab(WT).id + store.setState({ + unifiedTabsByWorktree: { + [WT]: [ + ...store.getState().unifiedTabsByWorktree[WT].filter((tab) => tab.id !== tabId), + makeUnifiedTab({ id: tabId, entityId: tabId, worktreeId: WT, groupId: 'g-left' }) + ] + } + } as Partial) + rowTabId = tabId + }) + + let rowTabId = '' + + it('toggleTabViewMode patches the row in the same write', () => { + store.getState().toggleTabViewMode(rowTabId) + expect(terminalRow(rowTabId)?.viewMode).toBe('chat') + + store.getState().toggleTabViewMode(rowTabId) + expect(terminalRow(rowTabId)?.viewMode).toBe('terminal') + }) + + it('setTabViewMode patches the row in the same write', () => { + store.getState().setTabViewMode(rowTabId, 'chat') + expect(terminalRow(rowTabId)?.viewMode).toBe('chat') + }) + }) }) diff --git a/src/renderer/src/store/slices/tabs/tabs-host-mirroring.ts b/src/renderer/src/store/slices/tabs/tabs-host-mirroring.ts index e87a34f81c2..66f09743e37 100644 --- a/src/renderer/src/store/slices/tabs/tabs-host-mirroring.ts +++ b/src/renderer/src/store/slices/tabs/tabs-host-mirroring.ts @@ -2,23 +2,27 @@ import type { AppState } from '../../types' import type { TerminalTab } from '../../../../../shared/terminal-tab-types' import { findTabAndWorktree } from '../tab-group-state' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { locateTerminalTab } from '../../terminals/terminal-tab-location' -export function patchTerminalTabPinned( +/** + * Mirror a host-tracked unified-tab field onto its terminal row, in whichever + * bucket actually holds the row. Reconcile derives these fields from the + * TerminalTab, so a local toggle that only patched the unified tab would be + * recomputed away by the next host snapshot — and recovery's chat-ownership + * guard reads the row, so a lagging row lets a hidden chat surface remount. + */ +export function patchTerminalTabRow( tabsByWorktree: Record, - worktreeId: string, tabId: string, - isPinned: boolean + patch: Partial> ): Partial> { - const tabs = tabsByWorktree[worktreeId] - if (!tabs?.some((tab) => tab.id === tabId)) { + const location = locateTerminalTab(tabsByWorktree, tabId) + if (!location) { return {} } - return { - tabsByWorktree: { - ...tabsByWorktree, - [worktreeId]: tabs.map((tab) => (tab.id === tabId ? { ...tab, isPinned } : tab)) - } - } + const nextTabs = tabsByWorktree[location.worktreeId].slice() + nextTabs[location.index] = { ...location.tab, ...patch } + return { tabsByWorktree: { ...tabsByWorktree, [location.worktreeId]: nextTabs } } } // Why: pin is host-authoritative for remote-server tabs, so mirror it (like setTabColor) or it's lost on reconnect/other clients. diff --git a/src/renderer/src/store/slices/tabs/tabs-label-actions.ts b/src/renderer/src/store/slices/tabs/tabs-label-actions.ts index 8acb925bfae..42b8e3d7163 100644 --- a/src/renderer/src/store/slices/tabs/tabs-label-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-label-actions.ts @@ -6,7 +6,7 @@ import { applyTabOrderSortValues, partitionPinnedTabOrder } from './tabs-tab-ord import { mirrorTabPinnedToHost, mirrorTabViewModeToHost, - patchTerminalTabPinned + patchTerminalTabRow } from './tabs-host-mirroring' export function createTabsLabelActions( @@ -62,7 +62,13 @@ export function createTabsLabelActions( }, setTabViewMode: (tabId, mode) => { - set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { viewMode: mode }) ?? {}) + set((state) => ({ + ...patchTab(state.unifiedTabsByWorktree, tabId, { viewMode: mode }), + // Why the row too: viewMode is declared on both types and host-sync + // already writes it to the row. Only these local toggles skipped it, so + // readers had to OR the two indices to find out who owns the surface. + ...patchTerminalTabRow(state.tabsByWorktree, tabId, { viewMode: mode }) + })) mirrorTabViewModeToHost(get(), tabId, mode) }, @@ -86,7 +92,10 @@ export function createTabsLabelActions( (terminal) => terminal.id === found.tab.entityId )?.launchAgent ?? null toggled = { from: fromMode, to: nextMode, agent } - return patchTab(state.unifiedTabsByWorktree, tabId, { viewMode: nextMode }) ?? {} + return { + ...patchTab(state.unifiedTabsByWorktree, tabId, { viewMode: nextMode }), + ...patchTerminalTabRow(state.tabsByWorktree, tabId, { viewMode: nextMode }) + } }) // Why: emit after the state write so the event reflects the committed mode. const committed = toggled as { @@ -141,7 +150,7 @@ export function createTabsLabelActions( [worktreeId]: applyTabOrderSortValues(tabs, tabOrder) }, // Why: reconcile derives pin from the TerminalTab, so mirror it there too or a host snapshot recomputes isPinned:false and un-pins during the echo window. - ...patchTerminalTabPinned(state.tabsByWorktree, worktreeId, tabId, true), + ...patchTerminalTabRow(state.tabsByWorktree, tabId, { isPinned: true }), groupsByWorktree: { ...state.groupsByWorktree, [worktreeId]: updateGroup(groups, { ...group, tabOrder }) @@ -178,7 +187,7 @@ export function createTabsLabelActions( ...state.unifiedTabsByWorktree, [worktreeId]: applyTabOrderSortValues(tabs, tabOrder) }, - ...patchTerminalTabPinned(state.tabsByWorktree, worktreeId, tabId, false), + ...patchTerminalTabRow(state.tabsByWorktree, tabId, { isPinned: false }), groupsByWorktree: { ...state.groupsByWorktree, [worktreeId]: updateGroup(groups, { ...group, tabOrder }) diff --git a/src/renderer/src/store/slices/terminal-tab-recovery-remount.test.ts b/src/renderer/src/store/slices/terminal-tab-recovery-remount.test.ts index d3b96433e75..b1db16ae413 100644 --- a/src/renderer/src/store/slices/terminal-tab-recovery-remount.test.ts +++ b/src/renderer/src/store/slices/terminal-tab-recovery-remount.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import { createTestStore, makeWorktree, seedStore } from './store-test-helpers' import { isTerminalTabPresent } from './terminal-tab-retirement' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' const WORKTREE_ID = 'repo1::/path/wt1' @@ -21,7 +23,7 @@ describe('remountTerminalTabForRecovery', () => { const remounted = store.getState().remountTerminalTabForRecovery(tabId) - expect(remounted).toBe(true) + expect(remounted.remounted).toBe(true) const after = store.getState().tabsByWorktree[WORKTREE_ID].find((tab) => tab.id === tabId) expect(after?.generation ?? 0).toBe((before?.generation ?? 0) + 1) // Recovery is not user interaction — the remount's PTY updates must not @@ -36,7 +38,7 @@ describe('remountTerminalTabForRecovery', () => { store.getState().queueTabStartupCommand(tabId, startup) const before = store.getState().pendingStartupByTabId[tabId] - expect(store.getState().remountTerminalTabForRecovery(tabId)).toBe(true) + expect(store.getState().remountTerminalTabForRecovery(tabId).remounted).toBe(true) const after = store.getState().pendingStartupByTabId[tabId] expect(after).toEqual(before) @@ -59,7 +61,10 @@ describe('remountTerminalTabForRecovery', () => { const store = createTestStore() seedWorktreeWithTab(store) - expect(store.getState().remountTerminalTabForRecovery('missing-tab')).toBe(false) + expect(store.getState().remountTerminalTabForRecovery('missing-tab')).toEqual({ + remounted: false, + declinedBy: 'tab-missing' + }) }) }) @@ -72,7 +77,7 @@ describe('isTerminalTabPresent as the recovery existence check', () => { const tabId = seedWorktreeWithTab(store) expect(isTerminalTabPresent(store.getState(), tabId)).toBe(true) - expect(store.getState().remountTerminalTabForRecovery(tabId)).toBe(true) + expect(store.getState().remountTerminalTabForRecovery(tabId).remounted).toBe(true) }) it('stays true when the tab is missing from the unified tab index', () => { @@ -90,7 +95,7 @@ describe('isTerminalTabPresent as the recovery existence check', () => { store.setState({ tabsByWorktree: { [WORKTREE_ID]: [] } }) expect(isTerminalTabPresent(store.getState(), tabId)).toBe(false) - expect(store.getState().remountTerminalTabForRecovery(tabId)).toBe(false) + expect(store.getState().remountTerminalTabForRecovery(tabId).remounted).toBe(false) }) // The budget release still has to fire for a real close, or a closed tab's @@ -104,3 +109,71 @@ describe('isTerminalTabPresent as the recovery existence check', () => { expect(isTerminalTabPresent(store.getState(), tabId)).toBe(false) }) }) + +// Not every workspace is a repository checkout. The ledger is keyed to the tab +// ROW and resolved through locateTerminalTab, which scans every bucket in +// tabsByWorktree — so a folder workspace and the floating-terminal bucket must +// behave identically without a single branch for them. The predecessor kept the +// budget in a module map keyed by tabId, and its row patcher made the caller +// name the bucket, which is where a non-worktree key could go wrong. +describe.each([ + ['a repository worktree', WORKTREE_ID], + ['a folder workspace', folderWorkspaceKey('fw-1')], + ['the floating terminal bucket', FLOATING_TERMINAL_WORKTREE_ID] +])('the recovery ledger on %s', (_label, bucketId) => { + function seedBucket(store: ReturnType): string { + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: WORKTREE_ID, repoId: 'repo1', path: '/path/wt1' })] + } + }) + return store.getState().createTab(bucketId).id + } + + const AUTOMATIC = { reason: 'write-stalled', trigger: 'automatic', now: 0 } as const + + it('admits, observes and then refuses the same reason until a new trigger', () => { + const store = createTestStore() + const tabId = seedBucket(store) + const row = (): { recovery?: unknown } | undefined => + store.getState().tabsByWorktree[bucketId]?.find((tab) => tab.id === tabId) + + const first = store.getState().remountTerminalTabForRecovery(tabId, AUTOMATIC) + expect(first.remounted).toBe(true) + // The ledger landed on the row in this bucket, not in a worktree-keyed map. + expect(row()?.recovery).toMatchObject({ outcome: 'pending', reason: 'write-stalled' }) + + // Unsettled blocks the next automatic ask, even past the cooldown. + expect( + store.getState().remountTerminalTabForRecovery(tabId, { ...AUTOMATIC, now: 16_000 }) + ).toEqual({ remounted: false, declinedBy: 'unsettled', retryInMs: 15_000 }) + + if (!first.remounted) { + throw new Error('unreachable: the first remount was admitted') + } + store.getState().settleTerminalTabRecovery(tabId, first.generation, 'failed') + expect(row()?.recovery).toMatchObject({ outcome: 'failed' }) + expect( + store.getState().remountTerminalTabForRecovery(tabId, { ...AUTOMATIC, now: 600_000 }) + ).toEqual({ remounted: false, declinedBy: 'settled-failure' }) + + // The user asking again is the new trigger the refusal waits for. + expect( + store + .getState() + .remountTerminalTabForRecovery(tabId, { ...AUTOMATIC, trigger: 'user', now: 600_000 }) + .remounted + ).toBe(true) + }) + + it('drops the ledger with the row when the tab closes', () => { + const store = createTestStore() + const tabId = seedBucket(store) + store.getState().remountTerminalTabForRecovery(tabId, AUTOMATIC) + + store.getState().closeTab(tabId) + + expect(isTerminalTabPresent(store.getState(), tabId)).toBe(false) + expect(store.getState().tabsByWorktree[bucketId]?.some((tab) => tab.id === tabId)).toBeFalsy() + }) +}) diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 3fa3219fa32..d6d9e9de74f 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -25,6 +25,11 @@ import type { import type { WorktreeRemovalTarget } from '../../../../shared/worktree/removal' import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector' import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { TerminalPaneRecoveryOutcome } from '../../../../shared/terminal-tab-types' +import type { + TerminalRecoveryRemountRequest, + TerminalRecoveryRemountResult +} from '../terminals/terminal-tab-recovery-ledger' import type { RemoveWorktreeOptions } from './worktree-removal-options' import type { HostQualifiedDetectedWorktreeResult, @@ -310,9 +315,24 @@ export type WorktreeSlice = { * TerminalPane unmounts, detaches (preserving a live PTY), and remounts with * a fresh xterm that reattaches and replays. Used by terminal-pane-recovery * when a pane's write pipeline is certified dead or its input is - * undeliverable while the PTY is alive. Returns false when the tab is gone. + * undeliverable while the PTY is alive. + * + * The generation bump and the tab's recovery ledger are written together, so + * the budget cannot outlive — or be released independently of — the row it + * belongs to. Omitting the request marks an external lifecycle remount: it + * skips admission and writes no ledger. */ - remountTerminalTabForRecovery: (tabId: string) => boolean + remountTerminalTabForRecovery: ( + tabId: string, + request?: TerminalRecoveryRemountRequest + ) => TerminalRecoveryRemountResult + /** Record what a mounted pane observed for its recovery attempt. Ignored + * unless `generation` is the row's current, still-pending ledger epoch. */ + settleTerminalTabRecovery: ( + tabId: string, + generation: number, + outcome: Exclude + ) => void setActiveFolderWorkspace: (folderWorkspaceId: string, executionHostId?: ExecutionHostId) => void setRenamingWorktreeId: (request: string | WorktreeRenameRequest | null) => void allWorktrees: () => Worktree[] diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 606a5a26857..330d27e7bb2 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -52,6 +52,7 @@ import { createGetKnownWorktreeById, createPurgeWorktreeTerminalState, createRemountTerminalTabForRecovery, + createSettleTerminalTabRecovery, createSetRenamingWorktreeId } from './worktrees/session/worktree-slice-lookups' import { createPurgeStaleRuntimeHostState } from './worktrees/teardown/purge-stale-runtime-host-state' @@ -108,6 +109,7 @@ export const createWorktreeSlice: StateCreator seedActiveWorktreeLastVisitedIfMissing: createSeedActiveWorktreeLastVisitedIfMissing(set, get), setRenamingWorktreeId: createSetRenamingWorktreeId(set, get), remountTerminalTabForRecovery: createRemountTerminalTabForRecovery(set, get), + settleTerminalTabRecovery: createSettleTerminalTabRecovery(set, get), setActiveWorktree: createSetActiveWorktree(set, get), setActiveFolderWorkspace: createSetActiveFolderWorkspace(set, get), allWorktrees: createAllWorktrees(set, get), diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts b/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts index 055171c7cfd..78e0e397bca 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts @@ -5,6 +5,15 @@ import { getTerminalActivationSpawnSuppression } from '../../terminal-activation import { findKnownWorktreeById } from '../listing/detected-worktree-meta' import { buildWorktreePurgeState } from '../teardown/worktree-purge-state' import { locateTerminalTab } from '../../../terminals/terminal-tab-location' +import { + admitTerminalRecoveryRemount, + nextTerminalRecoveryLedger, + settledTerminalRecoveryLedger +} from '../../../terminals/terminal-tab-recovery-ledger' +import type { + TerminalRecoveryRemountRequest, + TerminalRecoveryRemountResult +} from '../../../terminals/terminal-tab-recovery-ledger' export function createSetRenamingWorktreeId( set: WorktreeSliceSet, @@ -21,26 +30,57 @@ export function createRemountTerminalTabForRecovery( set: WorktreeSliceSet, _get: WorktreeSliceGet ): WorktreeSlice['remountTerminalTabForRecovery'] { - return (tabId) => { - let remounted = false + return (tabId, request) => { + const remountRequest: TerminalRecoveryRemountRequest = request ?? { + // The lifetime bridge's host-hydration remount is an external trigger: it + // is not a heal attempt, so it neither consumes nor consults the ledger. + reason: 'reattach-unverifiable', + trigger: 'external', + now: Date.now() + } + let result: TerminalRecoveryRemountResult = { + remounted: false, + declinedBy: 'tab-missing' + } set((s) => { const location = locateTerminalTab(s.tabsByWorktree, tabId) - if (!location) { + // Why re-admit inside the write: the caller's read happened before an + // async liveness probe, and a concurrent detector may have consumed the + // budget across it. Locating the row and spending its budget is one step. + const admission = admitTerminalRecoveryRemount(location?.tab, remountRequest) + if (!location || !admission.admitted) { + if (admission.admitted) { + result = { remounted: false, declinedBy: 'tab-missing' } + } else { + const { admitted: _admitted, ...decline } = admission + result = { remounted: false, ...decline } + } return {} } const { worktreeId, index, tab } = location const nextTabs = s.tabsByWorktree[worktreeId].slice() const pendingStartup = s.pendingStartupByTabId[tabId] + // Why: bump generation to remount a pane whose renderer died while its PTY stayed alive, so it reattaches, not spawns. + const nextTabGeneration = (tab.generation ?? 0) + 1 + // An external remount is not a heal attempt, so it writes no ledger. The + // generation bump alone supersedes any ledger already on the row, which + // is exactly right: an external remount IS a new trigger. + const recovery = + remountRequest.trigger === 'external' + ? tab.recovery + : nextTerminalRecoveryLedger(tab, remountRequest, nextTabGeneration) nextTabs[index] = { ...tab, - // Why: bump generation to remount a pane whose renderer died while its PTY stayed alive, so it reattaches, not spawns. - generation: (tab.generation ?? 0) + 1, + generation: nextTabGeneration, // Why: recovery isn't a user interaction — suppress its PTY updates from reshuffling Recent, like activation remounts. pendingActivationSpawn: getTerminalActivationSpawnSuppression( s.terminalLayoutsByTabId[tab.id] - ) + ), + // The remount and the budget it spends are one write, so no disposal, + // release path or index drift can undo half of it (crash b5cfc6ca). + ...(recovery ? { recovery } : {}) } - remounted = true + result = { remounted: true, generation: recovery?.generation ?? 0 } return { tabsByWorktree: { ...s.tabsByWorktree, @@ -58,7 +98,29 @@ export function createRemountTerminalTabForRecovery( : {}) } }) - return remounted + return result + } +} + +export function createSettleTerminalTabRecovery( + set: WorktreeSliceSet, + _get: WorktreeSliceGet +): WorktreeSlice['settleTerminalTabRecovery'] { + return (tabId, generation, outcome) => { + set((s) => { + const location = locateTerminalTab(s.tabsByWorktree, tabId) + if (!location) { + return {} + } + const { worktreeId, index, tab } = location + const recovery = settledTerminalRecoveryLedger(tab, generation, outcome) + if (!recovery) { + return {} + } + const nextTabs = s.tabsByWorktree[worktreeId].slice() + nextTabs[index] = { ...tab, recovery } + return { tabsByWorktree: { ...s.tabsByWorktree, [worktreeId]: nextTabs } } + }) } } diff --git a/src/renderer/src/store/terminals/terminal-tab-recovery-ledger.ts b/src/renderer/src/store/terminals/terminal-tab-recovery-ledger.ts new file mode 100644 index 00000000000..b06b6de5d30 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-tab-recovery-ledger.ts @@ -0,0 +1,218 @@ +import { DIRECT_SSH_PANE_RETRY_SETTLEMENT_TIMEOUT_MS } from '@/components/terminal-pane/pty-connection/pty-connect-limits' +import type { + TerminalPaneRecoveryOutcome, + TerminalPaneRecoveryReason, + TerminalTab, + TerminalTabRecoveryLedger +} from '../../../../shared/terminal-tab-types' + +// Why this module exists: recovery's budget used to live in module-level Maps +// keyed by tabId. Anything keyed outside the row needs a release path, and the +// release fired on every remount-driven pane disposal — so each remount erased +// the budget it had just consumed and the cap never held (crash b5cfc6ca). +// The ledger now lives on the row, so "the budget released itself" has no +// expression: reading the budget IS reading the tab. +// +// The control is not the count. A remount that mounts a pane which fails the +// same way is not evidence that anything changed, so recovery gates on an +// OBSERVED outcome, borrowing the direct-SSH pane retry vocabulary +// (DirectSshPaneRetryResult): an attempt that has not settled blocks the next +// one, and a settled failure refuses the same reason until a new trigger. + +// Backstop only — a breadcrumb-emitting ceiling for a loop the outcome gate +// somehow failed to catch. The outcome gate is what stops a storm. +export const MAX_RECOVERIES_PER_WINDOW = 3 +export const RECOVERY_WINDOW_MS = 5 * 60_000 +// Why a cooldown exists: one incident can trip several detectors (stall watch, +// replay guard, input path) within seconds; the first remount fixes all of +// them, the rest must coalesce instead of re-remounting mid-reattach. +export const RECOVERY_COOLDOWN_MS = 15_000 +// Why reuse the direct-SSH settlement timeout: the same 31s bound already +// decides when a pane's attach attempt has stopped being in flight. A 'pending' +// ledger older than that describes a pane that never reported, not one still +// working, so it must stop blocking rather than wedge recovery forever. +export const RECOVERY_SETTLEMENT_TIMEOUT_MS = DIRECT_SSH_PANE_RETRY_SETTLEMENT_TIMEOUT_MS + +/** Why a request exists at all. Only 'automatic' is subject to the + * settled-failure refusal: a user pressing Retry, or an external lifecycle + * remount, IS the new trigger the refusal is waiting for. */ +export type TerminalRecoveryTrigger = 'automatic' | 'user' | 'external' + +export type TerminalRecoveryRemountRequest = { + reason: TerminalPaneRecoveryReason + trigger: TerminalRecoveryTrigger + /** The recovery epoch the requesting pane captured, when it has one. */ + generation?: number + now: number +} + +export type TerminalRecoveryDecline = + | { declinedBy: 'tab-missing' } + | { declinedBy: 'stale-generation' } + | { declinedBy: 'settled-failure' } + | { declinedBy: 'window-cap'; retryInMs: number } + | { declinedBy: 'unsettled'; retryInMs: number } + | { declinedBy: 'cooldown'; retryInMs: number } + +export type TerminalRecoveryAdmission = + | { admitted: true } + | ({ admitted: false } & TerminalRecoveryDecline) + +export type TerminalRecoveryRemountResult = + /** `generation` is the ledger epoch the remounted pane will capture. */ + { remounted: true; generation: number } | ({ remounted: false } & TerminalRecoveryDecline) + +const ADMITTED: TerminalRecoveryAdmission = { admitted: true } + +function recentAttempts(ledger: TerminalTabRecoveryLedger, now: number): number[] { + return ledger.attemptedAt.filter((at) => now - at < RECOVERY_WINDOW_MS) +} + +/** True once the ledger describes an attempt nothing can still settle: the row + * moved to a generation this ledger never saw (authority change, SSH pane + * retry, activation respawn, external remount). Derived, so no writer can + * forget to mark it — and none can mark it wrongly either. */ +function isSupersededLedger(tab: TerminalTab, ledger: TerminalTabRecoveryLedger): boolean { + // Strictly forward: generation only ever increments, so a row that reads + // LOWER is a host-snapshot rebuild that dropped the field, not a new trigger. + // Treating that as one would hand the tab a fresh allowance per snapshot. + return (tab.generation ?? 0) > ledger.tabGeneration +} + +export function readTerminalRecoveryOutcome( + tab: TerminalTab, + now: number +): TerminalPaneRecoveryOutcome | null { + const ledger = tab.recovery + if (!ledger) { + return null + } + if (isSupersededLedger(tab, ledger)) { + return 'superseded' + } + if (ledger.outcome === 'pending' && now - ledger.startedAt >= RECOVERY_SETTLEMENT_TIMEOUT_MS) { + return 'timed-out' + } + return ledger.outcome +} + +/** Narrowed to the one field it reads, so the connect path can pass the row it + * already resolved rather than looking the full TerminalTab up a second time. */ +export function captureTabRecoveryGeneration( + tab: Pick | null | undefined +): number { + return tab?.recovery?.generation ?? 0 +} + +/** + * The single admission decision. Runs read-only to fail a request fast, and + * again inside the store write so a probe's await cannot open a window for two + * panes to both consume the budget. + */ +export function admitTerminalRecoveryRemount( + tab: TerminalTab | null | undefined, + request: TerminalRecoveryRemountRequest +): TerminalRecoveryAdmission { + if (!tab) { + return { admitted: false, declinedBy: 'tab-missing' } + } + const ledger = tab.recovery + if ( + request.generation !== undefined && + request.generation !== captureTabRecoveryGeneration(tab) + ) { + return { admitted: false, declinedBy: 'stale-generation' } + } + if (request.trigger === 'external' || !ledger) { + return ADMITTED + } + const recent = recentAttempts(ledger, request.now) + if (recent.length >= MAX_RECOVERIES_PER_WINDOW) { + // Unconditional: the backstop must survive supersession, or anything that + // bumps tab.generation each cycle would lift the ceiling along with it. + return { + admitted: false, + declinedBy: 'window-cap', + retryInMs: recent[0] + RECOVERY_WINDOW_MS - request.now + } + } + if (request.trigger === 'user') { + // The user asking again IS the new evidence. Only the window cap — the + // backstop against a loop neither side can see — survives it. + return ADMITTED + } + const outcome = readTerminalRecoveryOutcome(tab, request.now) + if (outcome !== 'superseded') { + if (ledger.outcome === 'pending') { + if (outcome === 'pending') { + // Re-requesting under an unsettled attempt is the storm: the remounted + // pane fails the same way and asks again with a freshly captured epoch, + // so an epoch check can never refuse it. Nothing has been observed yet. + return { + admitted: false, + declinedBy: 'unsettled', + retryInMs: ledger.startedAt + RECOVERY_SETTLEMENT_TIMEOUT_MS - request.now + } + } + // Aged past the settlement bound with nobody reporting. Deliberately NOT + // read as an observed failure: a pane kind with no settle path would + // otherwise wedge its tab's recovery forever. The cooldown and the window + // cap bound it instead. + } else if ( + (ledger.outcome === 'failed' || ledger.outcome === 'timed-out') && + ledger.reason === request.reason + ) { + // A pane OBSERVED this reason fail after the last remount. Repeating it + // re-requests exactly the action that just failed with no evidence + // anything changed — wait for a real trigger (generation move, or user). + return { admitted: false, declinedBy: 'settled-failure' } + } + } + const last = recent.at(-1) + if (last !== undefined && request.now - last < RECOVERY_COOLDOWN_MS) { + return { + admitted: false, + declinedBy: 'cooldown', + retryInMs: last + RECOVERY_COOLDOWN_MS - request.now + } + } + return ADMITTED +} + +/** The ledger a remount writes, in the same object as the generation bump. */ +export function nextTerminalRecoveryLedger( + tab: TerminalTab, + request: TerminalRecoveryRemountRequest, + nextTabGeneration: number +): TerminalTabRecoveryLedger { + const previous = tab.recovery + // Carried across supersession on purpose — see the window-cap note above. + const carriedAttempts = previous ? recentAttempts(previous, request.now) : [] + return { + attemptedAt: [...carriedAttempts, request.now], + generation: captureTabRecoveryGeneration(tab) + 1, + outcome: 'pending', + startedAt: request.now, + reason: request.reason, + tabGeneration: nextTabGeneration + } +} + +/** Record what the mounted pane observed. Returns null when this settlement is + * not the current attempt's, so the caller can leave the store untouched. */ +export function settledTerminalRecoveryLedger( + tab: TerminalTab, + generation: number, + outcome: Exclude +): TerminalTabRecoveryLedger | null { + const ledger = tab.recovery + if ( + !ledger || + ledger.generation !== generation || + ledger.outcome !== 'pending' || + isSupersededLedger(tab, ledger) + ) { + return null + } + return { ...ledger, outcome } +} diff --git a/src/shared/remote-workspace-session-projection.test.ts b/src/shared/remote-workspace-session-projection.test.ts index fdccd75b8e9..a11026e3581 100644 --- a/src/shared/remote-workspace-session-projection.test.ts +++ b/src/shared/remote-workspace-session-projection.test.ts @@ -6,6 +6,52 @@ import { import { getDefaultWorkspaceSession } from './constants' describe('remote workspace session projection', () => { + // The transient set this boundary mirrors. `recovery` is the tab's in-flight + // heal, timestamped with THIS machine's clock, and `pendingActivationSpawn` is + // a one-shot mount handoff — neither means anything on another client's row, + // and a foreign `startedAt` would be compared against the reader's Date.now(). + it('strips client-local transient tab fields on the way out', () => { + const session = { + ...getDefaultWorkspaceSession(), + activeRepoId: 'repo-a', + activeWorktreeId: 'repo-a::/srv/app', + activeTabId: 'tab-1', + tabsByWorktree: { + 'repo-a::/srv/app': [ + { + id: 'tab-1', + ptyId: 'pty-1', + worktreeId: 'repo-a::/srv/app', + title: 'Remote', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + pendingActivationSpawn: true, + recovery: { + attemptedAt: [1_000], + generation: 1, + outcome: 'failed' as const, + startedAt: 1_000, + reason: 'reattach-unverifiable' as const, + tabGeneration: 1 + } + } + ] + }, + terminalLayoutsByTabId: {} + } + + const projected = exportRemoteWorkspaceSession(session, { + isTargetWorktree: (worktreeId) => worktreeId.startsWith('repo-a::') + }) + + const exported = projected.tabsByWorktreePath['/srv/app'][0] as Record + expect(exported.recovery).toBeUndefined() + expect(exported.pendingActivationSpawn).toBeUndefined() + expect(exported.id).toBe('tab-1') + }) + it('exports terminal state using remote worktree paths instead of local repo ids', () => { const session = { ...getDefaultWorkspaceSession(), diff --git a/src/shared/remote-workspace-session-projection.ts b/src/shared/remote-workspace-session-projection.ts index 7f923050d36..29b3e15cd1d 100644 --- a/src/shared/remote-workspace-session-projection.ts +++ b/src/shared/remote-workspace-session-projection.ts @@ -32,9 +32,20 @@ function worktreePathFromId(worktreeId: string): string | null { } function tabToRemote(tab: TerminalTab, worktreePath: string): RemoteWorkspaceTerminalTab { - const { worktreeId: _worktreeId, pendingActivationSpawn: _pendingActivationSpawn, ...rest } = tab + // `recovery` joins the transient set for the same reason as + // pendingActivationSpawn: it describes THIS client's in-flight heal, and its + // timestamps are this machine's clock. On another client's row they would be + // compared against a foreign `Date.now()`. Nothing hands an unsanitized + // session to this boundary today; stripping here keeps that from mattering. + const { + worktreeId: _worktreeId, + pendingActivationSpawn: _pendingActivationSpawn, + recovery: _recovery, + ...rest + } = tab void _worktreeId void _pendingActivationSpawn + void _recovery return { ...rest, worktreePath } } diff --git a/src/shared/remote-workspace-types.ts b/src/shared/remote-workspace-types.ts index 4d6eec6021e..9544b709f9c 100644 --- a/src/shared/remote-workspace-types.ts +++ b/src/shared/remote-workspace-types.ts @@ -1,6 +1,12 @@ import type { TerminalLayoutSnapshot, TerminalTab } from './terminal-tab-types' -export type RemoteWorkspaceTerminalTab = Omit & { +// Transient client-local fields are omitted, not merely unset: `recovery` is +// this client's in-flight heal, stamped with this machine's clock, so the type +// must not let a future producer put one on the wire. +export type RemoteWorkspaceTerminalTab = Omit< + TerminalTab, + 'worktreeId' | 'pendingActivationSpawn' | 'recovery' +> & { worktreePath: string } diff --git a/src/shared/terminal-tab-types.ts b/src/shared/terminal-tab-types.ts index 1c455333ea9..d99472e2fde 100644 --- a/src/shared/terminal-tab-types.ts +++ b/src/shared/terminal-tab-types.ts @@ -1,6 +1,58 @@ import type { AiVaultSessionTitle } from './ai-vault-session-title' import type { TuiAgent } from './tui-agent' +/** Why recovery reasons live in the shared row type: the tab row carries the + * recovery ledger, and the ledger records which reason it last acted on. */ +export type TerminalPaneRecoveryReason = + | 'write-stalled' + | 'replay-wedged' + | 'input-undeliverable' + // The paired runtime that owns the PTY refused this write and said so on the + // wire. Distinct from 'input-undeliverable' because it skips the liveness + // probe: main's registry holds no entry for a `remote:` id, so `pty:hasPty` + // routes it to the local provider and answers a fabricated "dead". The + // rejection frame is the evidence instead — it came from the process that + // owns the PTY, over a connection that is by construction still up. + | 'input-rejected-by-host' + | 'reattach-unverifiable' + // A restore was requested for a certified-dead pipeline (reveal path). + | 'restore-blocked' + // A spawn resolved without a PTY id, so the pane is mounted with no transport + // binding. pty:data for the old id then lands in the pre-handler buffer, which + // ACKs it — main's delivery health stays green while the pane shows nothing. + | 'spawn-left-pane-unbound' + +/** Same vocabulary the direct-SSH pane retry ledger settles with + * (DirectSshPaneRetryResult), so a pane reports both through one call. */ +export type TerminalPaneRecoveryOutcome = + | 'pending' + | 'success' + | 'failed' + | 'timed-out' + | 'superseded' + +/** The tab's recovery ledger. Lives on the row — not in a module- or + * store-level map keyed by tabId — so a tab's existence and its recovery + * budget are the same object: nothing can release the budget while keeping + * the row, and closing the tab drops both together (crash b5cfc6ca). */ +export type TerminalTabRecoveryLedger = { + /** Remount timestamps inside the rolling window. Backstop, not the control. */ + attemptedAt: number[] + /** Recovery epoch. A mounted pane captures it and stale requests are refused. */ + generation: number + /** What the mounted pane observed for the attempt this ledger describes. */ + outcome: TerminalPaneRecoveryOutcome + /** When that attempt was requested. Bounds how long 'pending' may block. */ + startedAt: number + /** The reason this attempt acted on. A settled failure refuses the SAME + * reason again until a new trigger arrives. */ + reason: TerminalPaneRecoveryReason + /** `tab.generation` right after the remount. Any later bump — authority + * change, SSH pane retry, activation respawn — is a new trigger, so the + * mismatch alone supersedes this ledger. No writer required. */ + tabGeneration: number +} + // ─── Terminal Tab (legacy — used by persistence and TerminalContentSlice) ─ export type TerminalTab = { id: string @@ -53,6 +105,11 @@ export type TerminalTab = { * `sortEpoch` increments. Split layouts use a numeric count because one tab * can remount several panes. Never persisted — it is a transient handoff. */ pendingActivationSpawn?: boolean | number + /** Transient recovery ledger for this tab. Never persisted — it describes a + * mounted pane's in-flight heal, and a stale one would refuse the first + * legitimate recovery after restart. Stripped exactly like + * `pendingActivationSpawn` (buildSanitizedTabsByWorktree). */ + recovery?: TerminalTabRecoveryLedger } export type TerminalPaneSplitDirection = 'vertical' | 'horizontal' diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 48fe00a7f4d..0a24551381e 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -99,6 +99,12 @@ const terminalTabSchema = z.object({ customTitle: z.string().nullable(), color: z.string().nullable(), isPinned: z.boolean().optional(), + // Why: recovery asks the terminal row who owns the surface, so a row that + // loses viewMode on reload reads as "not chat-owned" and lets a hidden chat + // surface remount itself. Declared here so the row survives the parse, with + // the same `.catch('terminal')` degradation the unified tab uses below. + // Legacy rows that predate this stay undefined → 'terminal' in the renderer. + viewMode: z.enum(['terminal', 'chat']).catch('terminal').optional(), sortOrder: z.number(), createdAt: z.number(), generation: z.number().optional(), diff --git a/src/shared/workspace-session-terminal-schema.test.ts b/src/shared/workspace-session-terminal-schema.test.ts index 5878b70a1b9..18a930fa492 100644 --- a/src/shared/workspace-session-terminal-schema.test.ts +++ b/src/shared/workspace-session-terminal-schema.test.ts @@ -72,4 +72,53 @@ describe('parseWorkspaceSession terminal fields', () => { expect(result.value.tabsByWorktree.wt).toEqual([]) } }) + + // Why this matters beyond persistence hygiene: terminal-pane recovery asks + // the terminal ROW who owns the surface. While the row lost viewMode on load, + // a chat-owned tab read as "not chat-owned" after every restart and recovery + // would remount its hidden surface — the race #19745's guard exists to stop. + describe('terminal row viewMode', () => { + function parseRow(row: Record): Record | undefined { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: 'wt', + activeTabId: 'tab1', + tabsByWorktree: { + wt: [ + { + id: 'tab1', + ptyId: null, + worktreeId: 'wt', + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0, + ...row + } + ] + }, + terminalLayoutsByTabId: {} + }) + expect(result.ok).toBe(true) + return result.ok ? result.value.tabsByWorktree.wt[0] : undefined + } + + it('survives the load boundary so a restored row still reads chat-owned', () => { + expect(parseRow({ viewMode: 'chat' })?.viewMode).toBe('chat') + }) + + it('keeps an explicit terminal mode', () => { + expect(parseRow({ viewMode: 'terminal' })?.viewMode).toBe('terminal') + }) + + it('leaves a row persisted by an older build undefined rather than failing', () => { + expect(parseRow({})?.viewMode).toBeUndefined() + }) + + it('degrades an unknown mode from a newer build instead of dropping the tab', () => { + // .catch('terminal') — the safe default, never a whole-session parse failure. + expect(parseRow({ viewMode: 'holographic' })?.viewMode).toBe('terminal') + }) + }) }) diff --git a/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts b/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts index fae82b45d44..3967f7934eb 100644 --- a/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts +++ b/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts @@ -27,7 +27,11 @@ type StoreState = Record let mockStoreState: StoreState let storeSubscribers: ((state: StoreState) => void)[] = [] -const remountTerminalTabForRecovery = vi.fn<(tabId: string) => boolean>(() => true) +/** The store action reports admission now, not a bare boolean. */ +const REMOUNTED = { remounted: true as const, generation: 1 } +const remountTerminalTabForRecovery = vi.fn<(tabId: string, request?: unknown) => typeof REMOUNTED>( + () => REMOUNTED +) vi.mock('@/store', () => ({ useAppStore: { @@ -278,7 +282,7 @@ describe('host-rejected paired-runtime input reaches a pane remount', () => { vi.resetModules() vi.clearAllMocks() storeSubscribers = [] - remountTerminalTabForRecovery.mockReturnValue(true) + remountTerminalTabForRecovery.mockReturnValue(REMOUNTED) mockStoreState = { activeWorktreeId: 'wt-1', activeWorkspaceExecutionHostId: `runtime:${ENVIRONMENT_ID}`, @@ -417,7 +421,12 @@ describe('host-rejected paired-runtime input reaches a pane remount', () => { // Hop 1: the host turned the refusal into the negotiated frame. await vi.waitFor(() => expect(hostOpcodes).toContain(TerminalStreamOpcode.WriteUnavailable)) // Hop 2 (the one that was missing): it survives pane recovery as a remount. - await vi.waitFor(() => expect(remountTerminalTabForRecovery).toHaveBeenCalledWith('tab-1')) + await vi.waitFor(() => + expect(remountTerminalTabForRecovery).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ reason: 'input-rejected-by-host', trigger: 'automatic' }) + ) + ) binding.dispose() _resetTerminalPaneRecoveryForTests() diff --git a/tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts b/tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts index c734301d33f..fd4915d0ce3 100644 --- a/tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts +++ b/tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts @@ -160,14 +160,16 @@ process.stdout.write(${JSON.stringify(`${marker}\n`)}) observe() }, blocked.tabId) - const remounted = await orcaPage.evaluate((tabId) => { + // No request argument: an external lifecycle remount, which skips the + // recovery ledger entirely and so reports generation 0. + const remountResult = await orcaPage.evaluate((tabId) => { const state = window.__store?.getState() if (!state) { throw new Error('Renderer store unavailable') } return state.remountTerminalTabForRecovery(tabId) }, blocked.tabId) - expect(remounted).toBe(true) + expect(remountResult).toMatchObject({ remounted: true }) // Keep the original pre-spawn attempt gated until React has committed the // successor pane. Releasing earlier lets a loaded CI renderer finish the From 9aa0f7e77d366c23a3cc8de2da32ae550d397dc0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:36:35 +0000 Subject: [PATCH 011/191] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 4331989b66d..dce3559fd10 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 47m + + downloads: 48m @@ -15,7 +15,7 @@ downloads downloads - 47m - 47m + 48m + 48m From 08a24efaba619dc4d2a2da0abfc6efe687e8a72a Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:23:04 -0400 Subject: [PATCH 012/191] fix(push): preserve distinct Android alerts while offline (#20066) --- cloud/apps/push/src/fcm-client.test.ts | 37 +++++++------------ cloud/apps/push/src/fcm-client.ts | 30 ++++++--------- cloud/apps/push/src/push-delivery-message.ts | 2 + .../push/src/push-dismissal-provider.test.ts | 4 ++ .../push/src/push-notification-sound.test.ts | 6 ++- cloud/apps/push/src/push-pane-routing.test.ts | 27 ++++++++++++++ cloud/apps/push/src/push-server-send.test.ts | 4 +- .../push-contract/src/send-messages.ts | 3 +- 8 files changed, 66 insertions(+), 47 deletions(-) create mode 100644 cloud/apps/push/src/push-pane-routing.test.ts diff --git a/cloud/apps/push/src/fcm-client.test.ts b/cloud/apps/push/src/fcm-client.test.ts index 4a8d1fb41f7..8843e7aab95 100644 --- a/cloud/apps/push/src/fcm-client.test.ts +++ b/cloud/apps/push/src/fcm-client.test.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' import { describe, expect, it } from 'vitest' -import { fcmCollapseKey, FcmClient, type FcmRequest, type FcmResponse } from './fcm-client.js' +import { FcmClient, type FcmRequest, type FcmResponse } from './fcm-client.js' import { buildPushDelivery } from './push-delivery-message.js' const NOW = 1_700_000_000_000 @@ -20,6 +20,7 @@ function delivery(agentState: 'needs-input' | null = 'needs-input') { agentState, title: 'Agent needs input', body: 'Waiting on your answer', + paneKey: 'tab-b:pane-1', worktreeId: 'wt-1' } }) @@ -59,27 +60,14 @@ describe('fcm client', () => { expect(JSON.parse(request.body)).toEqual({ message: { token: TOKEN, - notification: { title: 'Agent needs input', body: 'Waiting on your answer' }, - android: { - priority: 'HIGH', - ttl: '300s', - collapse_key: createHash('sha256') - .update( - createHash('sha256') - .update(JSON.stringify([HOST, 'note-1'])) - .digest('hex') - ) - .digest('hex') - .slice(0, 32), - notification: { - channel_id: 'orca-desktop', - tag: createHash('sha256') - .update(JSON.stringify([HOST, 'note-1'])) - .digest('hex') - } - }, + android: { priority: 'HIGH', ttl: '300s' }, data: { + title: 'Agent needs input', + message: 'Waiting on your answer', + tag: delivery().collapseId, + channelId: 'orca-desktop', hostFingerprint: HOST, + paneKey: 'tab-b:pane-1', worktreeId: 'wt-1', notificationId: 'note-1', notificationSeq: '7', @@ -96,7 +84,7 @@ describe('fcm client', () => { await fcm.send(delivery(null), { token: TOKEN }) const message = JSON.parse(fake.requests[0]!.body) as { message: { - android: { collapse_key: string; notification: { tag: string } } + android: Record data: Record } } @@ -108,9 +96,10 @@ describe('fcm client', () => { .update(JSON.stringify([HOST, 'note-1'])) .digest('hex') expect(message.message.data.coalescedCount).toBeUndefined() - expect(message.message.android.notification.tag).toBe(tag) - expect(message.message.android.collapse_key).toBe(fcmCollapseKey(tag)) - expect(message.message.android.collapse_key).toHaveLength(32) + expect(message.message.data.tag).toBe(tag) + expect(message.message.android).not.toHaveProperty('collapse_key') + expect(message.message).not.toHaveProperty('notification') + expect(message.message.data).not.toHaveProperty('body') }) it('marks an unregistered token dead from the status or the error detail', async () => { diff --git a/cloud/apps/push/src/fcm-client.ts b/cloud/apps/push/src/fcm-client.ts index c22bd3309cd..0b58aae80f5 100644 --- a/cloud/apps/push/src/fcm-client.ts +++ b/cloud/apps/push/src/fcm-client.ts @@ -1,5 +1,4 @@ import { providerRetryAfter } from './provider-retry-delay.js' -import { createHash } from 'node:crypto' import { PUSH_DEFAULTS } from '@orca-cloud/push-contract' import { orcaDataStrings, type PushDelivery } from './push-delivery-message.js' import type { PushProviderOutcome } from './push-provider-outcome.js' @@ -22,12 +21,6 @@ type FcmErrorBody = { error?: { status?: unknown; message?: unknown; details?: { errorCode?: unknown }[] } } -// FCM collapse_key is a short opaque string, so the collapse id is hashed -// rather than truncated: truncation would merge unrelated notifications. -export function fcmCollapseKey(collapseId: string): string { - return createHash('sha256').update(collapseId).digest('hex').slice(0, 32) -} - export function fcmMessageBody(input: { delivery: PushDelivery token: string @@ -39,24 +32,23 @@ export function fcmMessageBody(input: { return JSON.stringify({ message: { token: input.token, - ...(delivery.orca.kind === 'dismiss' - ? {} - : { notification: { title: delivery.title, body: delivery.body } }), android: { priority: 'HIGH', - ttl: `${Math.max(0, Math.ceil((delivery.expiresAt - now) / 1000))}s`, - collapse_key: fcmCollapseKey(delivery.collapseId), + ttl: `${Math.max(0, Math.ceil((delivery.expiresAt - now) / 1000))}s` + }, + // Notification payloads collapse offline; Expo renders these data messages natively. + data: { + ...orcaDataStrings(delivery.orca), ...(delivery.orca.kind === 'dismiss' ? {} : { - notification: { - channel_id: - delivery.sound === false ? `${input.channelId}-silent` : input.channelId, - tag: delivery.collapseId - } + title: delivery.title, + message: delivery.body, + tag: delivery.collapseId, + channelId: delivery.sound === false ? `${input.channelId}-silent` : input.channelId, + ...(delivery.sound === false ? { sound: '' } : {}) }) - }, - data: orcaDataStrings(delivery.orca) + } } }) } diff --git a/cloud/apps/push/src/push-delivery-message.ts b/cloud/apps/push/src/push-delivery-message.ts index bc2c1a5d9b2..3bd2dae6e7c 100644 --- a/cloud/apps/push/src/push-delivery-message.ts +++ b/cloud/apps/push/src/push-delivery-message.ts @@ -5,6 +5,7 @@ export type PushOrcaData = { kind?: 'alert' | 'dismiss' hostFingerprint: string worktreeId?: string + paneKey?: string notificationId?: string notificationSeq: number notificationEpoch: string @@ -51,6 +52,7 @@ export function buildPushDelivery(input: { orca: { ...(notification.kind ? { kind: notification.kind } : {}), hostFingerprint, + ...(notification.paneKey === undefined ? {} : { paneKey: notification.paneKey }), ...(notification.worktreeId === undefined ? {} : { worktreeId: notification.worktreeId }), ...(notification.notificationId === undefined ? {} diff --git a/cloud/apps/push/src/push-dismissal-provider.test.ts b/cloud/apps/push/src/push-dismissal-provider.test.ts index 15362105777..65572e8401c 100644 --- a/cloud/apps/push/src/push-dismissal-provider.test.ts +++ b/cloud/apps/push/src/push-dismissal-provider.test.ts @@ -23,5 +23,9 @@ it('dismissal provider payloads cannot display a new alert or play a sound', () const android = JSON.parse(fcmMessageBody({ delivery, token: 'test', channelId: 'test' })).message expect(android).not.toHaveProperty('notification') expect(android.android).not.toHaveProperty('notification') + expect(android.android).not.toHaveProperty('collapse_key') + expect(android.data).not.toHaveProperty('title') + expect(android.data).not.toHaveProperty('message') + expect(android.data).not.toHaveProperty('sound') expect(android.data.kind).toBe('dismiss') }) diff --git a/cloud/apps/push/src/push-notification-sound.test.ts b/cloud/apps/push/src/push-notification-sound.test.ts index 4dd1b85504f..ede4dcd3291 100644 --- a/cloud/apps/push/src/push-notification-sound.test.ts +++ b/cloud/apps/push/src/push-notification-sound.test.ts @@ -23,7 +23,11 @@ it('carries a silent preference through validation to APNs and Android payloads' expect(JSON.parse(apnsBody(delivery)).aps).not.toHaveProperty('sound') expect( JSON.parse(fcmMessageBody({ delivery, token: 'test-token', channelId: 'orca-desktop' })).message - .android.notification.channel_id + .data.channelId ).toBe('orca-desktop-silent') + expect( + JSON.parse(fcmMessageBody({ delivery, token: 'test-token', channelId: 'orca-desktop' })).message + .data.sound + ).toBe('') expect(JSON.parse(apnsBody({ ...delivery, sound: undefined })).aps.sound).toBe('default') }) diff --git a/cloud/apps/push/src/push-pane-routing.test.ts b/cloud/apps/push/src/push-pane-routing.test.ts new file mode 100644 index 00000000000..66ce0b3a38c --- /dev/null +++ b/cloud/apps/push/src/push-pane-routing.test.ts @@ -0,0 +1,27 @@ +import { expect, it } from 'vitest' +import { PushNotificationSchema } from '@orca-cloud/push-contract' +import { buildPushDelivery, orcaDataStrings } from './push-delivery-message.js' + +it('preserves pane identity for both APNs and FCM, and accepts older workspace-only messages', () => { + const base = { + notificationSeq: 1, + notificationEpoch: 'epoch', + source: 'agent-task-complete', + agentState: 'finished', + title: 'Done', + body: '', + worktreeId: 'folder:/work' + } + const paneKey = 'tab-b:11111111-1111-4111-8111-111111111111' + for (const extra of [{}, { paneKey }]) { + const notification = PushNotificationSchema.parse({ ...base, ...extra }) + const delivery = buildPushDelivery({ + notification, + hostFingerprint: 'host', + registrationId: 'phone', + expiresAt: Date.now() + 300000 + }) + expect(delivery.orca.paneKey).toBe('paneKey' in extra ? paneKey : undefined) + expect(orcaDataStrings(delivery.orca).paneKey).toBe('paneKey' in extra ? paneKey : undefined) + } +}) diff --git a/cloud/apps/push/src/push-server-send.test.ts b/cloud/apps/push/src/push-server-send.test.ts index 2a93020ed1f..4b14b77eaec 100644 --- a/cloud/apps/push/src/push-server-send.test.ts +++ b/cloud/apps/push/src/push-server-send.test.ts @@ -56,7 +56,7 @@ describe('push gateway send route', () => { await harness.flushDeliveries() expect(harness.fcmRequests).toHaveLength(1) expect(JSON.parse(harness.fcmRequests[0]!.body)).toMatchObject({ - message: { token: FCM_TOKEN, notification: { title: 'Agent needs input' } } + message: { token: FCM_TOKEN, data: { title: 'Agent needs input' } } }) const afterDeath = await harness.post( @@ -179,7 +179,7 @@ describe('push gateway send route', () => { const message = JSON.parse(harness.fcmRequests[0]!.body) as { message: { android: { notification: { tag: string } }; data: Record } } - expect(message.message.android.notification.tag).toMatch(/^[a-f0-9]{64}$/) + expect(message.message.data.tag).toMatch(/^[a-f0-9]{64}$/) expect(message.message.data.coalescedCount).toBeUndefined() }) diff --git a/cloud/packages/push-contract/src/send-messages.ts b/cloud/packages/push-contract/src/send-messages.ts index 57d1c2e2745..a8959c18b05 100644 --- a/cloud/packages/push-contract/src/send-messages.ts +++ b/cloud/packages/push-contract/src/send-messages.ts @@ -26,7 +26,8 @@ export const PushNotificationSchema = z agentState: PushAgentStateSchema.nullable(), title: z.string().min(1).max(PUSH_LIMITS.titleMaxChars), body: z.string().max(PUSH_LIMITS.bodyMaxChars), - worktreeId: z.string().min(1).max(2048).optional() + worktreeId: z.string().min(1).max(2048).optional(), + paneKey: z.string().min(1).max(2048).optional() }) .strict() .refine( From fb19c969a6e45d2ca57f2d4727fffb4660a61c4f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:00:09 -0700 Subject: [PATCH 013/191] feat(native-chat): show when a Codex goal is set, changed, or cleared (#19923) * feat(native-chat): show when a Codex goal is set, changed, or cleared Codex never emits the model's `create_goal` call as an item, so `thread/goal/updated` is the only truthful evidence that a goal exists. Both goal notifications were classified `status-chrome`, which meant no typed handler read them and no row was written -- the only thing reaching the reader was the model's own prose. That prose can be wrong: in a session where no goal was ever created the model still wrote "Goal created: ...". Classify both frames as timeline-substantive and give them a sentence, so the reader can tell a goal that exists from one the model merely claimed. Status is translated rather than echoed, and an unrecognised future status still reads as "Goal updated: " instead of the bare opcode. Codex re-sends the goal as its token and time counters climb, so rows are deduped on what a reader would notice -- objective, status and budget. A live session sent the same goal three times in one turn with only accounting moving. * fix(native-chat): write the goal signature separator as an escape, not a raw NUL A literal NUL byte in the source made git classify the file as binary, which hid its diff from review. The string built at runtime is unchanged. * fix(native-chat): make Codex goal rows retry-safe * fix(native-chat): preserve Codex goal identity on resume * fix(codex): ignore empty goal clear snapshots * fix(codex): preserve goal lifecycle across rewinds --------- Co-authored-by: Merge Sim --- src/main/codex/codex-goal-journal-identity.ts | 47 +++ .../codex/codex-goal-journal-rows.test.ts | 127 ++++++ src/main/codex/codex-goal-journal-rows.ts | 74 ++++ ...-structured-journal-goal-admission.test.ts | 231 +++++++++++ ...dex-structured-journal-goal-resume.test.ts | 365 ++++++++++++++++++ ...codex-structured-journal-goal-rows.test.ts | 156 ++++++++ .../codex/codex-structured-journal-goals.ts | 177 +++++++++ .../codex/codex-structured-journal-limits.ts | 2 + .../codex/codex-structured-journal-sink.ts | 16 + .../codex-structured-journal-translation.ts | 8 + .../journal-store.test.ts | 16 + .../agent-session-journal/journal-store.ts | 7 + .../provider-frame-disposition.ts | 6 +- ...ructured-agent-session-event-sink-queue.ts | 2 + ...tructured-agent-session-event-sink.test.ts | 22 +- .../structured-agent-session-event-sink.ts | 39 ++ .../structured-agent-session-rewind.test.ts | 71 +++- .../structured-agent-session-rewind.ts | 6 +- .../structured-rewind-recovery.ts | 12 +- ...> structured-rewind-retained-host-rows.ts} | 21 +- .../unhandled-provider-frame.test.ts | 3 - .../unhandled-provider-frame.ts | 7 +- 22 files changed, 1391 insertions(+), 24 deletions(-) create mode 100644 src/main/codex/codex-goal-journal-identity.ts create mode 100644 src/main/codex/codex-goal-journal-rows.test.ts create mode 100644 src/main/codex/codex-goal-journal-rows.ts create mode 100644 src/main/codex/codex-structured-journal-goal-admission.test.ts create mode 100644 src/main/codex/codex-structured-journal-goal-resume.test.ts create mode 100644 src/main/codex/codex-structured-journal-goal-rows.test.ts create mode 100644 src/main/codex/codex-structured-journal-goals.ts rename src/main/native-chat/agent-session-wire/{structured-rewind-retained-turns.ts => structured-rewind-retained-host-rows.ts} (57%) diff --git a/src/main/codex/codex-goal-journal-identity.ts b/src/main/codex/codex-goal-journal-identity.ts new file mode 100644 index 00000000000..5203b7b62d5 --- /dev/null +++ b/src/main/codex/codex-goal-journal-identity.ts @@ -0,0 +1,47 @@ +import { createHash } from 'node:crypto' +import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' + +export type CodexGoalJournalState = { + thread: string + signature: string + occurrence: string +} + +const GOAL_IDENTITY_PREFIX = 'codex-goal' +const DIGEST_PATTERN = /^[0-9a-f]{64}$/ + +export function codexGoalJournalDigest(value: string): string { + return createHash('sha256').update(value).digest('hex') +} + +export function codexGoalJournalIdentity( + thread: string, + signature: string, + occurrence: string +): AgentJournalItemIdentity { + return { + provider: 'orca', + clientMessageId: `${GOAL_IDENTITY_PREFIX}:${thread}:${signature}:${occurrence}` + } +} + +/** Recognizes only the host-owned rows used to record Codex goal lifecycle state. */ +export function parseCodexGoalJournalItemId(itemId: string): CodexGoalJournalState | null { + const identity = parseAgentJournalItemKey(itemId) + if (identity?.provider !== 'orca') { + return null + } + const [prefix, thread, signature, occurrence, ...rest] = identity.clientMessageId.split(':') + return prefix === GOAL_IDENTITY_PREFIX && + DIGEST_PATTERN.test(thread ?? '') && + DIGEST_PATTERN.test(signature ?? '') && + DIGEST_PATTERN.test(occurrence ?? '') && + rest.length === 0 + ? { + thread: thread as string, + signature: signature as string, + occurrence: occurrence as string + } + : null +} diff --git a/src/main/codex/codex-goal-journal-rows.test.ts b/src/main/codex/codex-goal-journal-rows.test.ts new file mode 100644 index 00000000000..dcbc11a816f --- /dev/null +++ b/src/main/codex/codex-goal-journal-rows.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest' +import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame' +import { codexGoalRowSignature, codexGoalRowText } from './codex-goal-journal-rows' + +/** The shape a live Codex app-server session emits for `thread/goal/updated`. */ +function goalFrame(overrides: { goal?: Record } = {}): Record { + return { + threadId: '01a08cc2-f96e-76d0-bb74-88b9bc0b03fc', + turnId: '01a08cc2-fa6a-7541-a4c7-67d98a6e40c2', + goal: { + threadId: '01a08cc2-f96e-76d0-bb74-88b9bc0b03fc', + objective: 'Keep the current scratch directory tidy.', + status: 'active', + tokenBudget: null, + tokensUsed: 0, + timeUsedSeconds: 0, + createdAt: 1789067988, + updatedAt: 1789067988, + ...overrides.goal + } + } +} + +describe('codexGoalRowText', () => { + it('leads with the objective the goal actually carries', () => { + expect(codexGoalRowText('thread/goal/updated', goalFrame())).toBe( + 'Goal set: Keep the current scratch directory tidy.' + ) + }) + + it.each([ + ['paused', 'Goal paused'], + ['blocked', 'Goal blocked'], + ['complete', 'Goal complete'], + ['usageLimited', 'Goal stopped — usage limit'], + ['budgetLimited', 'Goal stopped — token budget spent'] + ])('says what %s means rather than echoing the status', (status, prefix) => { + expect(codexGoalRowText('thread/goal/updated', goalFrame({ goal: { status } }))).toBe( + `${prefix}: Keep the current scratch directory tidy.` + ) + }) + + it('still says something true for a status this build does not know', () => { + expect( + codexGoalRowText('thread/goal/updated', goalFrame({ goal: { status: 'somethingNew' } })) + ).toBe('Goal updated: Keep the current scratch directory tidy.') + }) + + it('reports a cleared goal, and ignores unrelated methods', () => { + expect(codexGoalRowText('thread/goal/cleared', {})).toBe('Goal cleared') + expect(codexGoalRowText('thread/tokenUsage/updated', goalFrame())).toBeNull() + }) + + it('falls back to the prefix alone when no objective survives', () => { + expect(codexGoalRowText('thread/goal/updated', goalFrame({ goal: { objective: ' ' } }))).toBe( + 'Goal set' + ) + expect(codexGoalRowText('thread/goal/updated', {})).toBe('Goal updated') + }) +}) + +describe('codexGoalRowSignature', () => { + it('ignores the counters that climb on every turn', () => { + // Two frames one live turn apart: only accounting moved. + const first = codexGoalRowSignature('thread/goal/updated', goalFrame()) + const later = codexGoalRowSignature( + 'thread/goal/updated', + goalFrame({ goal: { tokensUsed: 25999, timeUsedSeconds: 8, updatedAt: 1789067996 } }) + ) + expect(later).toBe(first) + }) + + it('separates visible objective and status changes', () => { + const base = codexGoalRowSignature('thread/goal/updated', goalFrame()) + expect( + codexGoalRowSignature('thread/goal/updated', goalFrame({ goal: { status: 'complete' } })) + ).not.toBe(base) + expect( + codexGoalRowSignature('thread/goal/updated', goalFrame({ goal: { objective: 'Ship it.' } })) + ).not.toBe(base) + }) + + it('does not append an identical visible row for a budget-only change', () => { + const base = codexGoalRowSignature('thread/goal/updated', goalFrame()) + expect( + codexGoalRowSignature('thread/goal/updated', goalFrame({ goal: { tokenBudget: 50_000 } })) + ).toBe(base) + }) + + it('has no signature for a frame that is not a goal', () => { + expect(codexGoalRowSignature('thread/tokenUsage/updated', goalFrame())).toBeNull() + }) +}) + +describe('goal frames as journal rows', () => { + it('journals the goal instead of dropping it as chrome', () => { + const row = unhandledProviderFrameJournalItem( + 'codex', + 'notification:thread/goal/updated', + goalFrame() + ) + + expect(row?.classification).toBe('timeline-substantive') + expect(row?.body.text).toBe('Goal set: Keep the current scratch directory tidy.') + // The raw frame stays available behind the row's disclosure. + expect(row?.body.providerFrame?.kind).toBe('notification:thread/goal/updated') + }) + + it('journals a cleared goal', () => { + const row = unhandledProviderFrameJournalItem('codex', 'notification:thread/goal/cleared', { + threadId: '01a08cc2-f96e-76d0-bb74-88b9bc0b03fc' + }) + + expect(row?.body.text).toBe('Goal cleared') + }) + + it('never shows the bare opcode, which is what a plain reclassify would have done', () => { + const row = unhandledProviderFrameJournalItem( + 'codex', + 'notification:thread/goal/updated', + goalFrame() + ) + + expect(row?.body.text).not.toContain('notification:') + expect(row?.body.text).not.toContain('codex · ') + }) +}) diff --git a/src/main/codex/codex-goal-journal-rows.ts b/src/main/codex/codex-goal-journal-rows.ts new file mode 100644 index 00000000000..36dac339901 --- /dev/null +++ b/src/main/codex/codex-goal-journal-rows.ts @@ -0,0 +1,74 @@ +/** + * Codex thread goals reach us only as notifications: the `create_goal` tool call the + * model makes is never emitted as an item, so `thread/goal/updated` is the single + * truthful signal that a goal exists. The model narrates goals in prose either way, + * and that prose can be wrong — it claims "Goal created" in sessions where no goal + * was ever set — so the row below is what lets a reader tell the two apart. + */ + +const GOAL_UPDATED_METHOD = 'thread/goal/updated' +const GOAL_CLEARED_METHOD = 'thread/goal/cleared' + +/** Status values Codex can report, mapped to how a reader would say them. */ +const GOAL_STATUS_PREFIX: Record = { + active: 'Goal set', + paused: 'Goal paused', + blocked: 'Goal blocked', + complete: 'Goal complete', + usageLimited: 'Goal stopped — usage limit', + budgetLimited: 'Goal stopped — token budget spent' +} + +function goalRecord(payload: unknown): Record | null { + if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) { + return null + } + const goal = (payload as Record).goal + return typeof goal === 'object' && goal !== null && !Array.isArray(goal) + ? (goal as Record) + : null +} + +export function isCodexGoalFrameMethod(method: string): boolean { + return method === GOAL_UPDATED_METHOD || method === GOAL_CLEARED_METHOD +} + +/** The sentence for a goal frame, or null when the frame is not one. */ +export function codexGoalRowText(method: string, payload: unknown): string | null { + if (method === GOAL_CLEARED_METHOD) { + return 'Goal cleared' + } + if (method !== GOAL_UPDATED_METHOD) { + return null + } + const goal = goalRecord(payload) + const objective = typeof goal?.objective === 'string' ? goal.objective.trim() : '' + const status = typeof goal?.status === 'string' ? goal.status : '' + // An unknown future status still says something true rather than falling back to + // the bare opcode. + const prefix = GOAL_STATUS_PREFIX[status] ?? 'Goal updated' + return objective ? `${prefix}: ${objective}` : prefix +} + +/** + * What changes the visible sentence. Counters and budget stay in the raw disclosure but + * cannot append another row with identical copy. + */ +export function codexGoalRowSignature(method: string, payload: unknown): string | null { + if (method === GOAL_CLEARED_METHOD) { + return GOAL_CLEARED_METHOD + } + if (method !== GOAL_UPDATED_METHOD) { + return null + } + const goal = goalRecord(payload) + const objective = typeof goal?.objective === 'string' ? goal.objective.trim() : '' + const status = typeof goal?.status === 'string' ? goal.status : '' + return `${GOAL_UPDATED_METHOD}\u0000${status}\u0000${objective}` +} + +/** Provider-owned goal generation, stable while accounting counters change. */ +export function codexGoalGeneration(payload: unknown): string | null { + const createdAt = goalRecord(payload)?.createdAt + return typeof createdAt === 'number' && Number.isFinite(createdAt) ? String(createdAt) : null +} diff --git a/src/main/codex/codex-structured-journal-goal-admission.test.ts b/src/main/codex/codex-structured-journal-goal-admission.test.ts new file mode 100644 index 00000000000..5fe926d18c9 --- /dev/null +++ b/src/main/codex/codex-structured-journal-goal-admission.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { CodexJournalGoals } from './codex-structured-journal-goals' +import { + createCodexJournalTranslator, + MAX_CODEX_GENERIC_ROWS_PER_TURN +} from './codex-structured-journal-translation' +import { MAX_CODEX_GOAL_THREADS } from './codex-structured-journal-limits' + +const THREAD = '01a08cc2-f96e-76d0-bb74-88b9bc0b03fc' + +function goalFrame(goal: Record = {}): Record { + return { + threadId: THREAD, + turnId: 'turn-1', + goal: { + threadId: THREAD, + objective: 'Keep the current scratch directory tidy.', + status: 'active', + tokenBudget: null, + tokensUsed: 0, + timeUsedSeconds: 0, + createdAt: 1789067988, + updatedAt: 1789067988, + ...goal + } + } +} + +function texts(rows: readonly AgentJournalItemBody[]): string[] { + return rows.map((row) => (row.kind === 'status' ? row.text : '')) +} + +describe('codex goal lifecycle admission', () => { + it.each(['append', 'publish'] as const)( + 'retries the same goal after rejected %s without losing or duplicating its row', + (stage) => { + let reject = true + let successfulPublishes = 0 + const rows = new Map() + const identities: string[] = [] + const lifecycleOptions: boolean[] = [] + const sink = { + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {}, + tryAppendItem: (identity, body, options) => { + if (stage === 'append' && reject) { + return { accepted: false, reason: 'backpressure' } as const + } + const key = agentJournalItemKey(identity) + identities.push(key) + rows.set(key, body) + lifecycleOptions.push(options?.lifecycle === true) + return { accepted: true } as const + }, + tryPublish: (options) => { + if (stage === 'publish' && reject) { + return { accepted: false, reason: 'backpressure' } as const + } + successfulPublishes += 1 + lifecycleOptions.push(options?.lifecycle === true) + return { accepted: true } as const + } + } satisfies StructuredAgentSessionEventSink + const translator = createCodexJournalTranslator({ sink }) + const event = { + type: 'notification' as const, + sessionId: 'session', + threadId: THREAD, + method: 'thread/goal/updated', + params: goalFrame() + } + + expect(translator.handle(event)).toEqual({ accepted: false, reason: 'backpressure' }) + reject = false + expect(translator.handle(event)).toEqual({ accepted: true }) + + expect(rows.size).toBe(1) + expect(new Set(identities)).toHaveLength(1) + expect(successfulPublishes).toBe(1) + expect(lifecycleOptions.every(Boolean)).toBe(true) + translator.dispose() + } + ) + + it('does not let the generic-row cap permanently hide the first goal evidence', () => { + const rows: AgentJournalItemBody[] = [] + const sink = { + appendItem: (_identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => + rows.push(body), + appendTombstone: () => {}, + publish: () => {} + } satisfies StructuredAgentSessionEventSink + const translator = createCodexJournalTranslator({ sink }) + for (let index = 0; index < MAX_CODEX_GENERIC_ROWS_PER_TURN; index += 1) { + translator.handle({ + type: 'notification', + sessionId: 'session', + threadId: THREAD, + method: 'process/exited', + params: { threadId: THREAD, turnId: 'turn-1', processId: `process-${index}` } + }) + } + + translator.handle({ + type: 'notification', + sessionId: 'session', + threadId: THREAD, + method: 'thread/goal/updated', + params: goalFrame() + }) + translator.handle({ + type: 'notification', + sessionId: 'session', + threadId: THREAD, + method: 'thread/goal/updated', + params: { ...goalFrame({ tokensUsed: 1 }), turnId: 'turn-2' } + }) + + expect(texts(rows).filter((text) => text.startsWith('Goal '))).toEqual([ + 'Goal set: Keep the current scratch directory tidy.' + ]) + translator.dispose() + }) + + it('keeps repeated lifecycle states distinct across status cycles and goal recreation', () => { + const rows = new Map() + const sink = { + appendItem: (identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => + rows.set(agentJournalItemKey(identity), body), + appendTombstone: () => {}, + publish: () => {} + } satisfies StructuredAgentSessionEventSink + const goals = new CodexJournalGoals(sink) + const update = (goal: Record = {}) => + goals.handle({ threadId: THREAD, method: 'thread/goal/updated', params: goalFrame(goal) }) + const clear = () => + goals.handle({ + threadId: THREAD, + method: 'thread/goal/cleared', + params: { threadId: THREAD } + }) + + update() + update({ status: 'paused' }) + update() + clear() + update() + clear() + clear() + + expect(texts([...rows.values()])).toEqual([ + 'Goal set: Keep the current scratch directory tidy.', + 'Goal paused: Keep the current scratch directory tidy.', + 'Goal set: Keep the current scratch directory tidy.', + 'Goal cleared', + 'Goal set: Keep the current scratch directory tidy.', + 'Goal cleared' + ]) + goals.dispose() + }) + + it('bounds thread state with LRU eviction while stable identities keep one history row', () => { + const writes: string[] = [] + const rows = new Map() + const sink = { + appendItem: (identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => { + const key = agentJournalItemKey(identity) + writes.push(key) + rows.set(key, body) + }, + appendTombstone: () => {}, + publish: () => {} + } satisfies StructuredAgentSessionEventSink + const goals = new CodexJournalGoals(sink) + const send = (threadId: string) => + goals.handle({ threadId, method: 'thread/goal/updated', params: goalFrame() }) + + for (let index = 0; index < MAX_CODEX_GOAL_THREADS; index += 1) { + send(`thread-${index}`) + } + const threadZeroIdentity = writes[0] + const threadOneIdentity = writes[1] + send('thread-0') + send('thread-over-cap') + expect(writes).toHaveLength(MAX_CODEX_GOAL_THREADS + 1) + + send('thread-1') + expect(writes).toHaveLength(MAX_CODEX_GOAL_THREADS + 2) + expect(writes.at(-1)).toBe(threadOneIdentity) + expect(rows).toHaveLength(MAX_CODEX_GOAL_THREADS + 1) + + send('thread-0') + expect(writes.at(-1)).toBe(threadOneIdentity) + expect(writes.filter((identity) => identity === threadZeroIdentity)).toHaveLength(1) + goals.dispose() + }) + + it('releases duplicate-suppression state on session clear and dispose', () => { + const identities: string[] = [] + const sink = { + appendItem: (identity: AgentJournalItemIdentity) => { + identities.push(agentJournalItemKey(identity)) + }, + appendTombstone: () => {}, + publish: () => {} + } satisfies StructuredAgentSessionEventSink + const goals = new CodexJournalGoals(sink) + const event = { threadId: THREAD, method: 'thread/goal/updated', params: goalFrame() } + + goals.handle(event) + goals.handle(event) + expect(identities).toHaveLength(1) + + goals.clear() + goals.handle(event) + expect(identities).toHaveLength(2) + expect(new Set(identities)).toHaveLength(1) + + goals.dispose() + goals.handle(event) + expect(identities).toHaveLength(3) + expect(new Set(identities)).toHaveLength(1) + }) +}) diff --git a/src/main/codex/codex-structured-journal-goal-resume.test.ts b/src/main/codex/codex-structured-journal-goal-resume.test.ts new file mode 100644 index 00000000000..4cf87b790bb --- /dev/null +++ b/src/main/codex/codex-structured-journal-goal-resume.test.ts @@ -0,0 +1,365 @@ +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalRenderItem +} from '../../shared/agent-session-journal-types' +import { + createDeferredStructuredAgentSessionEventSink, + type StructuredAgentSessionEventTarget +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { CodexJournalGoals } from './codex-structured-journal-goals' +import { MAX_CODEX_GOAL_THREADS } from './codex-structured-journal-limits' + +const THREAD = '01a08cc2-f96e-76d0-bb74-88b9bc0b03fc' + +function goalFrame(goal: Record = {}): Record { + return { + threadId: THREAD, + turnId: 'turn-1', + goal: { + threadId: THREAD, + objective: 'Keep the current scratch directory tidy.', + status: 'active', + tokenBudget: null, + tokensUsed: 0, + timeUsedSeconds: 0, + createdAt: 1789067988, + updatedAt: 1789067988, + ...goal + } + } +} + +function goalJournal( + options: Parameters[0] = {} +) { + let rowSequence = 0 + let publishes = 0 + let epochNumber = 1 + let visits = 0 + let visitedItems = 0 + const rows = new Map() + const writes: string[] = [] + const deferred = createDeferredStructuredAgentSessionEventSink(options) + const journal = { + get epoch() { + return `epoch-${epochNumber}` + }, + appendItem: async (identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => { + rowSequence += 1 + const itemId = agentJournalItemKey(identity) + const existing = rows.get(itemId) + const revision = (existing?.revision ?? 0) + 1 + writes.push(itemId) + rows.set(itemId, { + itemId, + body, + revision, + sequence: existing?.sequence ?? rowSequence, + observedAt: existing?.observedAt ?? rowSequence + }) + return { cursor: { epoch: `epoch-${epochNumber}`, sequence: rowSequence }, itemId, revision } + }, + snapshot: () => ({ + sessionId: 'session', + cursor: { epoch: `epoch-${epochNumber}`, sequence: rowSequence }, + items: [...rows.values()].sort((left, right) => left.sequence - right.sequence), + submissions: [] + }), + visitItems: (visit: (itemId: string, sequence: number) => void) => { + visits += 1 + for (const item of rows.values()) { + visitedItems += 1 + visit(item.itemId, item.sequence) + } + } + } as unknown as StructuredAgentSessionEventTarget['journal'] + const target = { + journal, + fence: 1, + publish: () => { + publishes += 1 + } + } + deferred.bind(target) + return { + sink: deferred.sink, + writes, + rows: () => journal.snapshot().items, + publishes: () => publishes, + visits: () => visits, + visitedItems: () => visitedItems, + seedProviderItems: (count: number) => { + for (let index = 0; index < count; index += 1) { + rowSequence += 1 + const identity = { + provider: 'codex' as const, + threadId: THREAD, + turnId: `seed-${index}`, + ordinal: 0 + } + const itemId = agentJournalItemKey(identity) + rows.set(itemId, { + itemId, + body: { kind: 'message', role: 'assistant', blocks: [] }, + revision: 1, + sequence: rowSequence, + observedAt: rowSequence + }) + } + }, + replaceEpoch: () => { + epochNumber += 1 + rowSequence = 0 + rows.clear() + }, + rebind: () => deferred.bind(target), + unbind: deferred.unbind, + drained: deferred.drained + } +} + +function texts(rows: readonly AgentJournalItemBody[]): string[] { + return rows.map((row) => (row.kind === 'status' ? row.text : '')) +} + +describe('codex goal lifecycle resume', () => { + it('does not append a cleared snapshot when the journal has no prior goal occurrence', async () => { + const journal = goalJournal() + journal.unbind() + const resumed = new CodexJournalGoals(journal.sink) + + expect( + resumed.handle({ + threadId: THREAD, + method: 'thread/goal/cleared', + params: { threadId: THREAD, turnId: null, clearedAt: 1789068999 } + }) + ).toEqual({ accepted: true }) + expect(journal.writes).toHaveLength(0) + + journal.rebind() + await journal.drained() + + expect(journal.writes).toHaveLength(0) + expect(journal.publishes()).toBe(0) + resumed.dispose() + }) + + it('does not revisit durable history for accounting-only updates', async () => { + const journal = goalJournal() + const goals = new CodexJournalGoals(journal.sink) + goals.handle({ threadId: THREAD, method: 'thread/goal/updated', params: goalFrame() }) + await journal.drained() + const visits = journal.visits() + + for (let index = 1; index <= 10; index += 1) { + goals.handle({ + threadId: THREAD, + method: 'thread/goal/updated', + params: goalFrame({ + tokensUsed: index * 1_000, + timeUsedSeconds: index, + updatedAt: 1789067988 + index + }) + }) + } + await journal.drained() + + expect(journal.visits()).toBe(visits) + expect(journal.writes).toHaveLength(1) + goals.dispose() + }) + + it('rebuilds dedupe state after the journal epoch is replaced', async () => { + const journal = goalJournal() + const goals = new CodexJournalGoals(journal.sink) + const event = { threadId: THREAD, method: 'thread/goal/updated', params: goalFrame() } + + goals.handle(event) + await journal.drained() + expect(journal.writes).toHaveLength(1) + + journal.replaceEpoch() + goals.handle(event) + await journal.drained() + + expect(journal.writes).toHaveLength(2) + expect(texts(journal.rows().map((row) => row.body))).toEqual([ + 'Goal set: Keep the current scratch directory tidy.' + ]) + expect(journal.visits()).toBe(2) + goals.dispose() + }) + + it('visits a large journal once per epoch when thread churn exceeds the transient LRU', async () => { + const journal = goalJournal() + journal.seedProviderItems(10_000) + const goals = new CodexJournalGoals(journal.sink) + const threadCount = MAX_CODEX_GOAL_THREADS + 1 + const sendRound = () => { + for (let index = 0; index < threadCount; index += 1) { + goals.handle({ + threadId: `thread-${index}`, + method: 'thread/goal/updated', + params: goalFrame() + }) + } + } + + sendRound() + await journal.drained() + for (let round = 0; round < 10; round += 1) { + sendRound() + } + await journal.drained() + + expect(journal.visits()).toBe(1) + expect(journal.visitedItems()).toBe(10_000) + expect(journal.writes).toHaveLength(threadCount) + goals.dispose() + }) + + it('resolves queued thread transitions from one shared durable projection', async () => { + const journal = goalJournal() + journal.seedProviderItems(10_000) + journal.unbind() + const goals = new CodexJournalGoals(journal.sink) + + for (let index = 0; index < MAX_CODEX_GOAL_THREADS; index += 1) { + goals.handle({ + threadId: `thread-${index}`, + method: 'thread/goal/updated', + params: goalFrame() + }) + } + expect(journal.visits()).toBe(0) + + journal.rebind() + await journal.drained() + + expect(journal.visits()).toBe(1) + expect(journal.visitedItems()).toBe(10_000) + expect(journal.writes).toHaveLength(MAX_CODEX_GOAL_THREADS) + goals.dispose() + }) + + it('retries a journal-derived transition after lifecycle backpressure', async () => { + const journal = goalJournal({ watermarks: { maxLifecycleQueuedOperations: 1 } }) + const goals = new CodexJournalGoals(journal.sink) + journal.unbind() + + expect( + goals.handle({ + threadId: THREAD, + method: 'thread/goal/updated', + params: goalFrame() + }) + ).toEqual({ accepted: true }) + expect( + goals.handle({ + threadId: THREAD, + method: 'thread/goal/updated', + params: goalFrame({ status: 'paused' }) + }) + ).toEqual({ accepted: false, reason: 'backpressure' }) + + journal.rebind() + await journal.drained() + expect( + goals.handle({ + threadId: THREAD, + method: 'thread/goal/updated', + params: goalFrame({ status: 'paused' }) + }) + ).toEqual({ accepted: true }) + await journal.drained() + + expect(texts(journal.rows().map((row) => row.body))).toEqual([ + 'Goal set: Keep the current scratch directory tidy.', + 'Goal paused: Keep the current scratch directory tidy.' + ]) + goals.dispose() + }) + + it.each([ + { + name: 'paused', + beforeResume: ['active', 'paused'] as const, + resumed: { method: 'thread/goal/updated', goal: { status: 'paused' } }, + expected: ['Goal set', 'Goal paused'] + }, + { + name: 'cleared', + beforeResume: ['active', 'cleared'] as const, + resumed: { method: 'thread/goal/cleared', goal: {} }, + expected: ['Goal set', 'Goal cleared'] + }, + { + name: 'active after a pause', + beforeResume: ['active', 'paused', 'active'] as const, + resumed: { method: 'thread/goal/updated', goal: { status: 'active' } }, + expected: ['Goal set', 'Goal paused', 'Goal set'] + } + ])('does not duplicate a $name snapshot after translator recreation', async (scenario) => { + const journal = goalJournal() + const send = ( + goals: CodexJournalGoals, + state: (typeof scenario.beforeResume)[number] + ): void => { + goals.handle({ + threadId: THREAD, + method: state === 'cleared' ? 'thread/goal/cleared' : 'thread/goal/updated', + params: state === 'cleared' ? { threadId: THREAD } : goalFrame({ status: state }) + }) + } + + const prior = new CodexJournalGoals(journal.sink) + for (const state of scenario.beforeResume) { + send(prior, state) + } + await journal.drained() + const acceptedOccurrence = journal.writes.at(-1) + const writesBeforeResume = journal.writes.length + const publishesBeforeResume = journal.publishes() + const acceptedBody = journal.rows().find((row) => row.itemId === acceptedOccurrence)?.body + prior.dispose() + journal.unbind() + + const resumed = new CodexJournalGoals(journal.sink) + resumed.handle({ + threadId: THREAD, + method: scenario.resumed.method, + params: + scenario.resumed.method === 'thread/goal/cleared' + ? { threadId: THREAD, turnId: null, clearedAt: 1789068999 } + : { + ...goalFrame({ + ...scenario.resumed.goal, + tokensUsed: 12_345, + timeUsedSeconds: 42, + updatedAt: 1789068999 + }), + turnId: null + } + }) + expect(journal.writes).toHaveLength(writesBeforeResume) + journal.rebind() + await journal.drained() + + expect(texts(journal.rows().map((row) => row.body))).toEqual( + scenario.expected.map((prefix) => + prefix === 'Goal cleared' ? prefix : `${prefix}: Keep the current scratch directory tidy.` + ) + ) + expect(journal.writes).toHaveLength(writesBeforeResume) + expect(journal.publishes()).toBe(publishesBeforeResume) + expect(journal.writes.at(-1)).toBe(acceptedOccurrence) + expect(journal.rows().find((row) => row.itemId === acceptedOccurrence)?.body).toEqual( + acceptedBody + ) + resumed.dispose() + }) +}) diff --git a/src/main/codex/codex-structured-journal-goal-rows.test.ts b/src/main/codex/codex-structured-journal-goal-rows.test.ts new file mode 100644 index 00000000000..4c3837b4373 --- /dev/null +++ b/src/main/codex/codex-structured-journal-goal-rows.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { CodexJournalGenericFrames } from './codex-structured-journal-generic-frames' +import { CodexJournalGoals } from './codex-structured-journal-goals' + +const THREAD = '01a08cc2-f96e-76d0-bb74-88b9bc0b03fc' + +function goalFrame(goal: Record): Record { + return { + threadId: THREAD, + turnId: '01a08cc2-fa6a-7541-a4c7-67d98a6e40c2', + goal: { + threadId: THREAD, + objective: 'Keep the current scratch directory tidy.', + status: 'active', + tokenBudget: null, + tokensUsed: 0, + timeUsedSeconds: 0, + createdAt: 1789067988, + updatedAt: 1789067988, + ...goal + } + } +} + +function frames(): { + rows: AgentJournalItemBody[] + frames: Pick +} { + const rows: AgentJournalItemBody[] = [] + const sink = { + appendItem: (_identity: unknown, body: AgentJournalItemBody) => { + rows.push(body) + }, + publish: vi.fn() + } as unknown as StructuredAgentSessionEventSink + const goals = new CodexJournalGoals(sink) + const generic = new CodexJournalGenericFrames({ sink }, () => null) + return { + rows, + frames: { + appendUnhandled: (kind, payload, threadId = 'session') => { + const method = kind.startsWith('notification:') ? kind.slice('notification:'.length) : kind + return ( + goals.handle({ threadId, method, params: payload }) ?? + generic.appendUnhandled(kind, payload, threadId) + ) + } + } + } +} + +function texts(rows: AgentJournalItemBody[]): string[] { + return rows.map((row) => (row as { text?: string }).text ?? '') +} + +describe('codex goal frames as journal rows', () => { + it('writes one row when the goal appears', () => { + const { rows, frames: generic } = frames() + + generic.appendUnhandled('notification:thread/goal/updated', goalFrame({}), THREAD) + + expect(texts(rows)).toEqual(['Goal set: Keep the current scratch directory tidy.']) + }) + + it('does not repeat the row while only the counters climb', () => { + const { rows, frames: generic } = frames() + + // Codex re-sends the goal through the turn as accounting ticks; a live session + // emitted these two seconds apart with nothing else changed. + generic.appendUnhandled('notification:thread/goal/updated', goalFrame({}), THREAD) + generic.appendUnhandled( + 'notification:thread/goal/updated', + goalFrame({ tokensUsed: 23869, timeUsedSeconds: 8, updatedAt: 1789067905 }), + THREAD + ) + generic.appendUnhandled( + 'notification:thread/goal/updated', + goalFrame({ tokensUsed: 25999, timeUsedSeconds: 12, updatedAt: 1789067912 }), + THREAD + ) + + expect(rows).toHaveLength(1) + }) + + it('writes a second row when the status changes', () => { + const { rows, frames: generic } = frames() + + generic.appendUnhandled('notification:thread/goal/updated', goalFrame({}), THREAD) + generic.appendUnhandled( + 'notification:thread/goal/updated', + goalFrame({ status: 'complete', tokensUsed: 31_000 }), + THREAD + ) + + expect(texts(rows)).toEqual([ + 'Goal set: Keep the current scratch directory tidy.', + 'Goal complete: Keep the current scratch directory tidy.' + ]) + }) + + it('writes a row when the objective is replaced', () => { + const { rows, frames: generic } = frames() + + generic.appendUnhandled('notification:thread/goal/updated', goalFrame({}), THREAD) + generic.appendUnhandled( + 'notification:thread/goal/updated', + goalFrame({ objective: 'Ship the parser.' }), + THREAD + ) + + expect(texts(rows)).toEqual([ + 'Goal set: Keep the current scratch directory tidy.', + 'Goal set: Ship the parser.' + ]) + }) + + it('writes a row when the goal is cleared, and again if a new goal follows', () => { + const { rows, frames: generic } = frames() + + generic.appendUnhandled('notification:thread/goal/updated', goalFrame({}), THREAD) + generic.appendUnhandled('notification:thread/goal/cleared', { threadId: THREAD }, THREAD) + generic.appendUnhandled( + 'notification:thread/goal/updated', + goalFrame({ createdAt: 1789067989, updatedAt: 1789067989 }), + THREAD + ) + + expect(texts(rows)).toEqual([ + 'Goal set: Keep the current scratch directory tidy.', + 'Goal cleared', + 'Goal set: Keep the current scratch directory tidy.' + ]) + }) + + it('keeps each thread’s goal separate', () => { + const { rows, frames: generic } = frames() + const other = '01a08cc3-0000-7000-8000-000000000000' + + generic.appendUnhandled('notification:thread/goal/updated', goalFrame({}), THREAD) + generic.appendUnhandled('notification:thread/goal/updated', goalFrame({}), other) + + expect(rows).toHaveLength(2) + }) + + it('leaves non-goal frames to the existing path', () => { + const { rows, frames: generic } = frames() + + generic.appendUnhandled('notification:warning', { message: 'disk almost full' }, THREAD) + generic.appendUnhandled('notification:warning', { message: 'disk almost full' }, THREAD) + + // No goal dedupe applies, so both warnings still land. + expect(rows).toHaveLength(2) + }) +}) diff --git a/src/main/codex/codex-structured-journal-goals.ts b/src/main/codex/codex-structured-journal-goals.ts new file mode 100644 index 00000000000..83bb66551ae --- /dev/null +++ b/src/main/codex/codex-structured-journal-goals.ts @@ -0,0 +1,177 @@ +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame' +import type { + StructuredAgentSessionEventSink, + StructuredAgentSessionLifecycleJournal +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { + codexGoalJournalDigest, + codexGoalJournalIdentity, + parseCodexGoalJournalItemId, + type CodexGoalJournalState +} from './codex-goal-journal-identity' +import { + codexGoalGeneration, + codexGoalRowSignature, + isCodexGoalFrameMethod +} from './codex-goal-journal-rows' +import { + CODEX_JOURNAL_ADMITTED, + type CodexJournalTranslationAdmission +} from './codex-structured-journal-contracts' +import { MAX_CODEX_GOAL_THREADS } from './codex-structured-journal-limits' +import { appendCodexLifecycleTransition } from './codex-structured-journal-sink' + +type GoalThreadState = { + signature: string + occurrence: string +} + +/** Persists provider-owned goal lifecycle notifications outside generic-row policy. */ +export class CodexJournalGoals { + private readonly stateByThread = new Map() + private readonly durableStateByThread = new Map() + private durableJournal: StructuredAgentSessionLifecycleJournal | null = null + private durableEpoch: string | null = null + private transientEpoch: string | null = null + + constructor(private readonly sink: StructuredAgentSessionEventSink) {} + + handle(event: { + threadId: string + method: string + params: unknown + }): CodexJournalTranslationAdmission | null { + if (!isCodexGoalFrameMethod(event.method)) { + return null + } + const signature = codexGoalRowSignature(event.method, event.params) + if (signature === null) { + return null + } + this.synchronizeTransientEpoch() + const thread = codexGoalJournalDigest(event.threadId) + const reportedGeneration = codexGoalGeneration(event.params) + const providerGeneration = + reportedGeneration === null ? null : codexGoalJournalDigest(`provider:${reportedGeneration}`) + const signatureKey = codexGoalJournalDigest(`${signature}\u0000${providerGeneration ?? ''}`) + const previous = this.stateByThread.get(thread) + if (previous?.signature === signatureKey) { + this.remember(thread, previous) + return CODEX_JOURNAL_ADMITTED + } + const occurrence = previous + ? codexGoalJournalDigest(JSON.stringify([previous.occurrence, signatureKey])) + : codexGoalJournalDigest(JSON.stringify([thread, signatureKey])) + const state = { signature: signatureKey, occurrence } + const translated = unhandledProviderFrameJournalItem( + 'codex', + `notification:${event.method}`, + event.params + ) + if (!translated) { + return { accepted: false, reason: 'untranslated' } + } + const admission = appendCodexLifecycleTransition( + this.sink, + codexGoalJournalIdentity(thread, signatureKey, occurrence), + translated.body, + (journal) => + this.persistedGoalIdentity( + journal, + thread, + signatureKey, + event.method === 'thread/goal/cleared' + ) + ) + if (!admission.accepted) { + return admission + } + this.remember(thread, state) + return CODEX_JOURNAL_ADMITTED + } + + clear(): void { + this.stateByThread.clear() + this.durableStateByThread.clear() + this.durableJournal = null + this.durableEpoch = null + this.transientEpoch = null + } + + dispose(): void { + this.clear() + } + + private remember(thread: string, state: GoalThreadState): void { + this.stateByThread.delete(thread) + this.stateByThread.set(thread, state) + while (this.stateByThread.size > MAX_CODEX_GOAL_THREADS) { + const oldest = this.stateByThread.keys().next().value + if (typeof oldest !== 'string') { + break + } + this.stateByThread.delete(oldest) + } + } + + private synchronizeTransientEpoch(): void { + const epoch = this.sink.journalEpoch?.() ?? null + if (epoch === null) { + return + } + if (this.transientEpoch !== null && this.transientEpoch !== epoch) { + this.stateByThread.clear() + } + this.transientEpoch = epoch + } + + private persistedGoalIdentity( + journal: StructuredAgentSessionLifecycleJournal, + thread: string, + signature: string, + requirePrevious: boolean + ): AgentJournalItemIdentity | null { + this.seedDurableState(journal) + const previous = this.durableStateByThread.get(thread) ?? null + if (previous?.signature === signature) { + return null + } + // Codex sends a cleared snapshot while resuming threads that never had a goal. + if (previous === null && requirePrevious) { + return null + } + const occurrence = previous + ? codexGoalJournalDigest(JSON.stringify([previous.occurrence, signature])) + : codexGoalJournalDigest(JSON.stringify([thread, signature])) + this.durableStateByThread.set(thread, { signature, occurrence }) + return codexGoalJournalIdentity(thread, signature, occurrence) + } + + private seedDurableState(journal: StructuredAgentSessionLifecycleJournal): void { + if (this.durableJournal === journal && this.durableEpoch === journal.epoch) { + return + } + const latest = new Map() + journal.visitItems((itemId, sequence) => { + const state = parseCodexGoalJournalItemId(itemId) + const previous = state ? latest.get(state.thread) : undefined + if (state && (!previous || sequence > previous.sequence)) { + latest.set(state.thread, { state, sequence }) + } + }) + this.durableStateByThread.clear() + for (const [thread, { state }] of latest) { + this.durableStateByThread.set(thread, { + signature: state.signature, + occurrence: state.occurrence + }) + } + this.durableJournal = journal + this.durableEpoch = journal.epoch + if (this.transientEpoch !== null && this.transientEpoch !== journal.epoch) { + this.stateByThread.clear() + } + this.transientEpoch = journal.epoch + } +} diff --git a/src/main/codex/codex-structured-journal-limits.ts b/src/main/codex/codex-structured-journal-limits.ts index 5137ea8dd16..4f56cd0e282 100644 --- a/src/main/codex/codex-structured-journal-limits.ts +++ b/src/main/codex/codex-structured-journal-limits.ts @@ -2,6 +2,8 @@ export const MAX_CODEX_GENERIC_ROWS_PER_TURN = 8 export const MAX_CODEX_GENERIC_TURN_BUCKETS = 64 export const MAX_CODEX_GENERIC_BOOKKEEPING_ENTRIES = 128 export const MAX_CODEX_GENERIC_BOOKKEEPING_BYTES = 32 * 1024 +/** Goal duplicate-suppression state is LRU-bounded per live translator. */ +export const MAX_CODEX_GOAL_THREADS = 64 export const MAX_CODEX_ACTIVE_ITEMS = 256 export const MAX_CODEX_PENDING_PROMPTS = 128 export const MAX_CODEX_IDENTITY_ENTRIES = 512 diff --git a/src/main/codex/codex-structured-journal-sink.ts b/src/main/codex/codex-structured-journal-sink.ts index 5c4ecec9658..7da381def41 100644 --- a/src/main/codex/codex-structured-journal-sink.ts +++ b/src/main/codex/codex-structured-journal-sink.ts @@ -4,6 +4,7 @@ import type { } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink, + StructuredAgentSessionLifecycleIdentityResolver, StructuredAgentSessionSinkAdmission } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { CodexPendingJournalPrompt } from './codex-structured-journal-settlement' @@ -28,6 +29,21 @@ export function appendCodexLifecycleItem( return CODEX_JOURNAL_ADMITTED } +export function appendCodexLifecycleTransition( + sink: StructuredAgentSessionEventSink, + identitySizeBound: AgentJournalItemIdentity, + body: AgentJournalItemBody, + resolveIdentity: StructuredAgentSessionLifecycleIdentityResolver +): CodexJournalTranslationAdmission { + if (sink.tryAppendLifecycleTransition) { + return criticalAdmission( + sink.tryAppendLifecycleTransition(identitySizeBound, body, resolveIdentity) + ) + } + const admission = appendCodexLifecycleItem(sink, identitySizeBound, body) + return admission.accepted ? publishCodexLifecycle(sink) : admission +} + export function publishCodexLifecycle( sink: StructuredAgentSessionEventSink ): CodexJournalTranslationAdmission { diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index 2907b34bc70..5c1e97310bc 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -7,6 +7,7 @@ import { CodexSubagentRoster } from './codex-subagent-roster' import { readCodexThreadItem } from './codex-structured-item-translation' import { CodexJournalGenericFrames } from './codex-structured-journal-generic-frames' import { CodexJournalCompactions } from './codex-structured-journal-compactions' +import { CodexJournalGoals } from './codex-structured-journal-goals' import { CodexJournalItems } from './codex-structured-journal-items' import { CodexJournalPrompts } from './codex-structured-journal-prompts' import { @@ -51,6 +52,7 @@ export function createCodexJournalTranslator( const genericFrames = new CodexJournalGenericFrames(deps, (threadId) => activeTurns.current(threadId) ) + const goals = new CodexJournalGoals(deps.sink) const items = new CodexJournalItems( deps, (threadId) => activeTurns.current(threadId), @@ -178,6 +180,7 @@ export function createCodexJournalTranslator( prompts.pending.clear() activeTurns.clear() compactions.clear() + goals.clear() return CODEX_JOURNAL_ADMITTED } if (event.type === 'notification') { @@ -221,6 +224,10 @@ export function createCodexJournalTranslator( if (compaction) { return publishActivity(event, compaction) } + const goal = goals.handle(event) + if (goal) { + return publishActivity(event, goal) + } if (event.method === CODEX_TOKEN_USAGE_METHOD) { // Classified `status-chrome`, so the generic-frame path swallows it // before the journal. The roster consumes it as a typed notification. @@ -274,6 +281,7 @@ export function createCodexJournalTranslator( subagents.dispose() activeTurns.clear() compactions.clear() + goals.dispose() } } } diff --git a/src/main/native-chat/agent-session-journal/journal-store.test.ts b/src/main/native-chat/agent-session-journal/journal-store.test.ts index 97eac02fe75..3592947faaa 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.test.ts @@ -8,6 +8,7 @@ import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import { + agentJournalItemKey, boundJournalKeyComponent, MAX_JOURNAL_KEY_COMPONENT_CHARS } from '../../../shared/agent-session-journal-item-key' @@ -96,6 +97,21 @@ describe('sequences', () => { expect(journal.snapshot().items[0]?.revision).toBe(3) }) + it('visits reduced items at their creation sequence without promoting an older revision', async () => { + const journal = await open() + await journal.appendItem(item(0), body('first'), { fence: 1 }) + const latest = await journal.appendItem(item(1), body('second'), { fence: 1 }) + await journal.appendItem(item(0), body('first revised'), { fence: 1 }) + const visited: { itemId: string; sequence: number }[] = [] + + journal.visitItems((itemId, sequence) => visited.push({ itemId, sequence })) + + expect(visited).toEqual([ + { itemId: agentJournalItemKey(item(0)), sequence: 2 }, + { itemId: latest.itemId, sequence: latest.cursor.sequence } + ]) + }) + it('preserves an oversized identity and its raw digest-form mimic across reopen', async () => { const oversizedTurnId = 'a'.repeat(MAX_JOURNAL_KEY_COMPONENT_CHARS + 1) const digestFormMimic = boundJournalKeyComponent(oversizedTurnId) diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 3be64d9ceca..2e372f9abae 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -162,6 +162,13 @@ export class AgentSessionJournal { snapshot = (): AgentJournalSnapshot => renderJournalState(this.state) + /** Visits reduced items without allocating and sorting a full snapshot. */ + visitItems = (visit: (itemId: string, sequence: number) => void): void => { + for (const item of this.state.items.values()) { + visit(item.itemId, item.sequence) + } + } + /** Includes revisions and completion tombstones, whose timestamps disappear from render items. */ lastActivityAt = (): number => this.state.lastActivityAt diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts index d1bc7d0e7d3..548a719ceb1 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts @@ -25,8 +25,10 @@ export const PROVIDER_FRAME_CLASSIFICATIONS = { 'thread/closed': 'status-chrome', 'skills/changed': 'status-chrome', 'thread/name/updated': 'status-chrome', - 'thread/goal/updated': 'status-chrome', - 'thread/goal/cleared': 'status-chrome', + // The goal tool call is never emitted as an item, so these two frames are the only + // truthful evidence a goal exists; the model's prose about goals can be wrong. + 'thread/goal/updated': 'timeline-substantive', + 'thread/goal/cleared': 'timeline-substantive', 'thread/environment/connected': 'status-chrome', 'thread/environment/disconnected': 'status-chrome', 'thread/settings/updated': 'status-chrome', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts index 510c1df2f4a..c9a32533db4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts @@ -60,6 +60,8 @@ export class StructuredAgentSessionSinkQueue { failed: this.failure !== null }) + journalEpoch = (): string | null => this.target?.journal.epoch ?? null + bindReadingControl(control: StructuredAgentSessionReadingControl): () => void { this.readingControl = control if (this.backpressured) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts index d1b7ea533a1..3d0a5e8a269 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts @@ -54,7 +54,8 @@ function target( appendLifecycleBatch: vi.fn(async (input: { settlementId: string }) => { log.push({ call: 'appendLifecycleBatch', fence, settlementId: input.settlementId }) return { epoch: 'e', sequence: 0 } - }) + }), + latestItemMatching: vi.fn(() => null) } as unknown as AgentSessionJournal return { journal, @@ -115,6 +116,25 @@ describe('deferred structured agent-session event sink', () => { expect(log).toEqual([{ call: 'appendItem', fence: 2, ordinal: 0 }]) }) + it('resolves a lifecycle transition after journal bind and skips an existing state', async () => { + const log: Recorded[] = [] + const deferred = createDeferredStructuredAgentSessionEventSink() + + expect( + deferred.sink.tryAppendLifecycleTransition?.(identity(0), BODY, () => identity(1)) + ).toEqual({ accepted: true }) + expect(deferred.sink.tryAppendLifecycleTransition?.(identity(0), BODY, () => null)).toEqual({ + accepted: true + }) + deferred.bind(target(2, log)) + await deferred.drained() + + expect(log).toEqual([ + { call: 'appendItem', fence: 2, ordinal: 1 }, + { call: 'publish', fence: 2 } + ]) + }) + it('drops buffered and later writes once closed, and refuses to rebind', async () => { const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts index 784e3aa7b20..857aa118fe8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts @@ -31,6 +31,15 @@ export type StructuredAgentSessionAppendOptions = { observedAt?: number } +export type StructuredAgentSessionLifecycleJournal = Pick< + AgentSessionJournal, + 'epoch' | 'visitItems' +> + +export type StructuredAgentSessionLifecycleIdentityResolver = ( + journal: StructuredAgentSessionLifecycleJournal +) => AgentJournalItemIdentity | null + export type StructuredAgentSessionEventSink = { appendItem( identity: AgentJournalItemIdentity, @@ -52,6 +61,14 @@ export type StructuredAgentSessionEventSink = { body: AgentJournalItemBody, options?: StructuredAgentSessionAppendOptions ): StructuredAgentSessionSinkAdmission + /** Queues one journal-derived lifecycle append; a null resolution is a no-op. */ + tryAppendLifecycleTransition?( + identitySizeBound: AgentJournalItemIdentity, + body: AgentJournalItemBody, + resolveIdentity: StructuredAgentSessionLifecycleIdentityResolver + ): StructuredAgentSessionSinkAdmission + /** Current durable epoch, when this deferred sink is bound to its journal. */ + journalEpoch?(): string | null appendLifecycleBatch?( settlementId: string, mutations: readonly JournalLifecycleMutationInput[], @@ -186,6 +203,28 @@ export function createDeferredStructuredAgentSessionEventSink( }, options ), + tryAppendLifecycleTransition: (identitySizeBound, body, resolveIdentity) => { + const bytes = estimateStructuredAgentSessionItemBytes(identitySizeBound, body) + return queue.submit( + { + bytes, + lifecycle: true, + run: async (bound) => { + const identity = resolveIdentity(bound.journal) + if (identity === null) { + return + } + if (estimateStructuredAgentSessionItemBytes(identity, body) > bytes) { + throw new Error('structured agent-session item identity exceeded its reserved size') + } + await bound.journal.appendItem(identity, body, { fence: bound.fence }) + bound.publish() + } + }, + { lifecycle: true } + ) + }, + journalEpoch: queue.journalEpoch, appendLifecycleBatch: (settlementId, mutations, options = {}) => { const admission = appendLifecycleBatch(settlementId, mutations, options) if (!admission.accepted) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index c75301c6461..e022f640403 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -420,7 +420,7 @@ describe('host rewind', () => { expect(await host.rewind(caller, params(target))).toMatchObject({ ok: true }) }) - it('keeps the host-stamped turn rows before the boundary through a Codex provider hydration', async () => { + it('keeps host-stamped turn and goal rows through a Codex provider hydration', async () => { expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) const message = (turnId: string) => ({ provider: 'codex' as const, @@ -434,6 +434,19 @@ describe('host rewind', () => { sessionId: HOST_TEST_SESSION, recordId: `turn-lifecycle:${turnId}` }) + const goalRow = { + provider: 'orca' as const, + clientMessageId: `codex-goal:${'a'.repeat(64)}:${'b'.repeat(64)}:${'c'.repeat(64)}` + } + const goalBody = { + kind: 'status' as const, + text: 'Goal set: Keep the retained evidence.', + providerFrame: { + provider: 'codex', + kind: 'notification:thread/goal/updated', + payload: { head: '{}', byteLength: 2, digest: 'd'.repeat(64), truncated: false } + } + } const keptTurn = { kind: 'turn' as const, turnId: 'kept', @@ -444,6 +457,7 @@ describe('host rewind', () => { durationMs: 5_000 } sink.appendItem(message('kept'), hostTestMessage('kept')) + sink.appendItem(goalRow, goalBody) sink.appendItem(turnRow('kept'), keptTurn) sink.appendItem(message('drop'), hostTestMessage('drop')) sink.appendItem(turnRow('drop'), { ...keptTurn, turnId: 'drop', durationMs: 1_000 }) @@ -465,11 +479,66 @@ describe('host rewind', () => { host.journalSnapshot(HOST_TEST_SESSION).items.map(({ itemId, body }) => ({ itemId, body })) ).toEqual([ { itemId: agentJournalItemKey(message('kept')), body: hostTestMessage('kept from provider') }, + { itemId: agentJournalItemKey(goalRow), body: goalBody }, { itemId: agentJournalItemKey(turnRow('kept')), body: keptTurn } ]) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') }) + it('keeps a host goal row when interrupted Codex rewind recovery rebuilds provider history', async () => { + expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) + const message = (turnId: string) => ({ + provider: 'codex' as const, + threadId: HOST_TEST_THREAD, + turnId, + ordinal: 0 + }) + const goalRow = { + provider: 'orca' as const, + clientMessageId: `codex-goal:${'1'.repeat(64)}:${'2'.repeat(64)}:${'3'.repeat(64)}` + } + const goalBody = { + kind: 'status' as const, + text: 'Goal set: Survive recovery.', + providerFrame: { + provider: 'codex', + kind: 'notification:thread/goal/updated', + payload: { head: '{}', byteLength: 2, digest: '4'.repeat(64), truncated: false } + } + } + sink.appendItem(message('kept'), hostTestMessage('kept')) + sink.appendItem(goalRow, goalBody) + sink.appendItem(message('drop'), hostTestMessage('drop')) + sink.appendItem(message('tip'), { ...hostTestMessage('tip'), role: 'assistant' }) + await host.flushStreamedEvents(HOST_TEST_SESSION) + rewind.mockImplementationOnce(async (input) => { + await input.onReverted?.() + throw new Error('lost after provider revert') + }) + + await expect(host.rewind(caller, params(agentJournalItemKey(message('drop'))))).rejects.toThrow( + 'lost after provider revert' + ) + recoverRewind.mockResolvedValueOnce({ + ok: true, + items: [{ identity: message('kept'), body: hostTestMessage('kept from recovery') }] + }) + expect( + await host.attach( + caller, + hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) + ) + ).toMatchObject({ ok: true }) + + expect( + host.journalSnapshot(HOST_TEST_SESSION).items.map(({ itemId, body }) => ({ itemId, body })) + ).toEqual([ + { itemId: agentJournalItemKey(message('kept')), body: hostTestMessage('kept from recovery') }, + { itemId: agentJournalItemKey(goalRow), body: goalBody } + ]) + expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') + }) + it('recovers against the complete provider preflight when the local journal omitted an older turn', async () => { const target = await seed() const items = ['older', 'kept'].map((turnId) => ({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts index 417a42fe414..cb7cbb1f20b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts @@ -20,7 +20,7 @@ import { conversationCommandBlocked } from './structured-conversation-command-ad import { rewindRefusal } from './structured-rewind-refusal' import { persistRewindRecord, recoverStructuredRewind } from './structured-rewind-recovery' import { replaceClaudeRewindOwner } from './structured-rewind-claude-owner' -import { mergeRetainedTurnRows } from './structured-rewind-retained-turns' +import { mergeRetainedHostLifecycleRows } from './structured-rewind-retained-host-rows' export async function rewindStructuredAgentSession( context: StructuredAgentSessionMutationContext, @@ -174,7 +174,7 @@ export async function rewindStructuredAgentSession( fence: ctx.fence, beforeTurnId: key.provider === 'codex' ? key.turnId : '', onPrepared: async (items) => { - const retained = mergeRetainedTurnRows( + const retained = mergeRetainedHostLifecycleRows( prepared.retained, items.map(({ identity, body }) => ({ itemId: agentJournalItemKey(identity), @@ -220,7 +220,7 @@ export async function rewindStructuredAgentSession( return rewindRefusal(reason) } const confirmed = provider.items - ? mergeRetainedTurnRows( + ? mergeRetainedHostLifecycleRows( prepared.retained, provider.items.map(({ identity, body }) => ({ itemId: agentJournalItemKey(identity), diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts b/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts index 8f5a8942a86..4916710e9c1 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts @@ -1,5 +1,5 @@ import { restoreRewindJournalBody } from './structured-rewind-journal-body' -import { isRetainedTurnRow, mergeRetainedTurnRows } from './structured-rewind-retained-turns' +import { mergeRetainedHostLifecycleRows } from './structured-rewind-retained-host-rows' import { isDeepStrictEqual } from 'node:util' import { agentJournalItemKey, @@ -61,9 +61,13 @@ export async function recoverStructuredRewind( } throw new Error(`agent_session_rewind:${recovered?.reason ?? 'outcome-unknown'}`) } - // Turn rows are the host's, never the provider's; the proof covers provider items only. const expectedItems = new Set( - rewind.retained.filter((item) => !isRetainedTurnRow(item)).map((item) => item.itemId) + rewind.retained + .filter((item) => { + const identity = parseAgentJournalItemKey(item.itemId) + return identity?.provider === 'codex' && identity.threadId === target.threadId + }) + .map((item) => item.itemId) ) const observedItems = new Set() for (const { identity } of recovered.items) { @@ -80,7 +84,7 @@ export async function recoverStructuredRewind( if (observedItems.size !== expectedItems.size) { throw new Error('agent_session_rewind:proof-mismatch') } - const retained = mergeRetainedTurnRows( + const retained = mergeRetainedHostLifecycleRows( rewind.retained, recovered.items.map(({ identity, body }) => ({ itemId: agentJournalItemKey(identity), diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-retained-turns.ts b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts similarity index 57% rename from src/main/native-chat/agent-session-wire/structured-rewind-retained-turns.ts rename to src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts index aa0753b502c..35307566599 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-retained-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts @@ -1,20 +1,23 @@ -// The Codex preflight returns provider items only. The host's turn rows are its own record, so a -// rewind that takes the provider's list as the new epoch would drop every duration before the -// boundary unless those rows are spliced back beside the item each one followed. +// Provider preflight returns provider items only. The host's lifecycle rows are its own record, so +// a rewind that takes the provider list as the new epoch must splice those rows back beside the +// provider item each one followed. +import { parseCodexGoalJournalItemId } from '../../codex/codex-goal-journal-identity' import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' import type { AgentSessionRewindRecord } from '../../../shared/agent-session-rewind' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' type RetainedRow = AgentSessionRewindRecord['retained'][number] -export function isRetainedTurnRow(item: Pick): boolean { - return readAgentJournalTurn(item.body as AgentJournalItemBody) !== null +export function isRetainedHostLifecycleRow(item: RetainedRow): boolean { + return ( + readAgentJournalTurn(item.body as AgentJournalItemBody) !== null || + parseCodexGoalJournalItemId(item.itemId) !== null + ) } -/** `reference` fixes where each turn row sits; the provider items are the spine and keep their - * own order, including turns the local journal never saw. */ -export function mergeRetainedTurnRows( +/** `reference` fixes where each host row sits; provider items are the ordered spine. */ +export function mergeRetainedHostLifecycleRows( reference: readonly RetainedRow[], providerItems: readonly RetainedRow[] ): RetainedRow[] { @@ -22,7 +25,7 @@ export function mergeRetainedTurnRows( const rowsAfter = new Map() let anchor = -1 for (const item of reference) { - if (!isRetainedTurnRow(item)) { + if (!isRetainedHostLifecycleRow(item)) { anchor = spineIndex.get(item.itemId) ?? anchor } else if (!spineIndex.has(item.itemId)) { rowsAfter.set(anchor, [...(rowsAfter.get(anchor) ?? []), item]) diff --git a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts index 61544fd56ab..aefceacbca4 100644 --- a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts +++ b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts @@ -50,9 +50,6 @@ describe('unhandled provider frame journal fallback', () => { expect( unhandledProviderFrameJournalItem('codex', 'notification:thread/tokenUsage/updated', {}) ).toBeNull() - expect( - unhandledProviderFrameJournalItem('codex', 'notification:thread/goal/cleared', {}) - ).toBeNull() expect(unhandledProviderFrameJournalItem('claude', 'message:system:init', {})).toBeNull() expect( unhandledProviderFrameJournalItem('claude', 'message:result', { diff --git a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts index 272e960b838..22ca3d89542 100644 --- a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts +++ b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts @@ -5,6 +5,7 @@ import { DEFAULT_JOURNAL_PAYLOAD_LIMITS, type JournalPayloadLimits } from '../agent-session-journal/journal-payload-bounds' +import { codexGoalRowText } from '../../codex/codex-goal-journal-rows' import { classifyProviderFrame } from './provider-frame-disposition' export type UnhandledProviderFrameJournalItem = { @@ -115,11 +116,15 @@ export function unhandledProviderFrameJournalItem( .filter((part): part is string => typeof part === 'string' && part.trim().length > 0) .join('\n\n') || message } + const goalText = provider === 'codex' ? codexGoalRowText(method, payload) : null const display = message ? boundInlineText(message, limits) : null + const goalDisplay = goalText ? boundInlineText(goalText, limits) : null return { body: { kind: 'status', - text: compaction ? 'Context compacted' : (display?.text ?? `${provider} · ${kind}`), + text: compaction + ? 'Context compacted' + : (goalDisplay?.text ?? display?.text ?? `${provider} · ${kind}`), ...(compaction ? { presentation: 'compaction' } : {}), ...(tone ? { tone } : {}), providerFrame: { provider, kind, payload: bounded } From ec9c3e055010195a86bfc5cc659a0d216394caf0 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:20:06 -0400 Subject: [PATCH 014/191] Fix remote hosted review browser routing (#20030) * Fix remote hosted review browser routing * Fix remote review modifier hint * Address review feedback and fix routing test types * Avoid assuming active runtime owns workspace links * Align runtime routing regression expectation * Respect explicit local link ownership --- ...-panel-hosted-review-click-routing.test.ts | 24 ++++++------- ...hecks-panel-hosted-review-click-routing.ts | 8 ++--- .../src/lib/http-link-routing.test.ts | 35 +++++++++++++++++++ src/renderer/src/lib/http-link-routing.ts | 17 ++++++--- 4 files changed, 60 insertions(+), 24 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/checks-panel-hosted-review-click-routing.test.ts b/src/renderer/src/components/right-sidebar/checks-panel-hosted-review-click-routing.test.ts index 956877a7cd7..fa191936e0e 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-hosted-review-click-routing.test.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-hosted-review-click-routing.test.ts @@ -23,6 +23,7 @@ describe('checks panel hosted review click routing', () => { expect(isChecksPanelHostedReviewSystemBrowserModifier(event, true)).toBe(true) expect(resolveChecksPanelHostedReviewHttpOpenOptions(event, true, 'wt-1')).toEqual({ worktreeId: 'wt-1', + allowRemoteInApp: true, modifierHeld: true }) }) @@ -33,6 +34,7 @@ describe('checks panel hosted review click routing', () => { expect(isChecksPanelHostedReviewSystemBrowserModifier(event, false)).toBe(true) expect(resolveChecksPanelHostedReviewHttpOpenOptions(event, false, 'wt-1')).toEqual({ worktreeId: 'wt-1', + allowRemoteInApp: true, modifierHeld: true }) }) @@ -44,7 +46,7 @@ describe('checks panel hosted review click routing', () => { true, 'wt-1' ) - ).toEqual({ worktreeId: 'wt-1' }) + ).toEqual({ worktreeId: 'wt-1', allowRemoteInApp: true }) }) it('opens hosted review URLs without the modifier on plain clicks', () => { @@ -56,7 +58,8 @@ describe('checks panel hosted review click routing', () => { }) expect(openHttpLinkMock).toHaveBeenCalledWith('https://github.com/acme/widgets/pull/123', { - worktreeId: 'wt-1' + worktreeId: 'wt-1', + allowRemoteInApp: true }) }) @@ -70,6 +73,7 @@ describe('checks panel hosted review click routing', () => { expect(openHttpLinkMock).toHaveBeenCalledWith('https://github.com/acme/widgets/pull/123', { worktreeId: 'wt-1', + allowRemoteInApp: true, modifierHeld: true }) }) @@ -111,29 +115,21 @@ describe('checks panel hosted review modifier hint destination', () => { expect(resolveChecksPanelHostedReviewModifierDestination(null, true)).toBeNull() }) - // Why: openHttpLink refuses to route a remote-owned link into Orca, and openLinksInApp - // cannot apply there either, so neither destination is reachable. - it('stays silent while a remote runtime is active', () => { + it('resolves modifier destinations for remote runtimes', () => { expect( resolveChecksPanelHostedReviewModifierDestination( { openLinksInApp: true, activeRuntimeEnvironmentId: 'remote-1' }, true ) - ).toBeNull() + ).toBe('system-browser') expect( resolveChecksPanelHostedReviewModifierDestination( - { - openLinksInApp: false, - openLinksInAppModifierInverts: true, - activeRuntimeEnvironmentId: 'remote-1' - }, + { openLinksInAppModifierInverts: true, activeRuntimeEnvironmentId: 'remote-1' }, true ) - ).toBeNull() + ).toBe('orca') }) - // Why: openHttpLink trims before treating a runtime as active, so a blank id must - // not suppress a hint for a click that still reaches Orca. it('ignores a blank runtime id', () => { expect( resolveChecksPanelHostedReviewModifierDestination( diff --git a/src/renderer/src/components/right-sidebar/checks-panel-hosted-review-click-routing.ts b/src/renderer/src/components/right-sidebar/checks-panel-hosted-review-click-routing.ts index 62ddcb71b4e..29ba055d800 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-hosted-review-click-routing.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-hosted-review-click-routing.ts @@ -17,9 +17,9 @@ export function resolveChecksPanelHostedReviewHttpOpenOptions( // Why: same escape hatch as terminal and markdown links — openHttpLink resolves // whether it forces the system browser or inverts the Link Routing setting. if (isChecksPanelHostedReviewSystemBrowserModifier(event, isMac)) { - return { worktreeId, modifierHeld: true } + return { worktreeId, allowRemoteInApp: true, modifierHeld: true } } - return { worktreeId } + return { worktreeId, allowRemoteInApp: true } } /** Where a Shift+modifier click lands, or null when it lands where a plain click already does. */ @@ -38,9 +38,7 @@ export function resolveChecksPanelHostedReviewModifierDestination( | undefined, hasWorktree: boolean ): ChecksPanelHostedReviewModifierDestination { - // Why: trim to match openHttpLink — an untrimmed check hides the hint on a blank - // runtime id while the click still routes to Orca. - if (!hasWorktree || settings?.activeRuntimeEnvironmentId?.trim()) { + if (!hasWorktree) { return null } if (settings?.openLinksInApp === true) { diff --git a/src/renderer/src/lib/http-link-routing.test.ts b/src/renderer/src/lib/http-link-routing.test.ts index efd9ff7e4b5..3626ccf5b36 100644 --- a/src/renderer/src/lib/http-link-routing.test.ts +++ b/src/renderer/src/lib/http-link-routing.test.ts @@ -139,6 +139,41 @@ describe('openHttpLink', () => { expect(createBrowserTabMock).not.toHaveBeenCalled() }) + it('keeps explicitly local links local while a remote runtime is active', () => { + storeState.settings = { openLinksInApp: true, activeRuntimeEnvironmentId: 'remote-1' } + + openHttpLink('https://example.com/', { + worktreeId: 'wt-1', + allowRemoteInApp: true, + sourceOwner: { kind: 'local' } + }) + + expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', { + activate: true + }) + expect(openRuntimeBrowserTabMock).not.toHaveBeenCalled() + }) + + it('routes opted-in links without a source owner through the active runtime', () => { + storeState.settings = { + openLinksInApp: true, + activeRuntimeEnvironmentId: ' remote-1 ' + } + + openHttpLink('https://github.com/acme/widgets/pull/123', { + worktreeId: 'wt-1', + allowRemoteInApp: true + }) + + expect(openRuntimeBrowserTabMock).toHaveBeenCalledExactlyOnceWith({ + workspaceId: 'wt-1', + url: 'https://github.com/acme/widgets/pull/123', + intent: { kind: 'url' } + }) + expect(createBrowserTabMock).not.toHaveBeenCalled() + expect(openUrlMock).not.toHaveBeenCalled() + }) + it('routes to the system browser when a remote runtime environment is active', () => { storeState.settings = { openLinksInApp: true, activeRuntimeEnvironmentId: 'env-1' } diff --git a/src/renderer/src/lib/http-link-routing.ts b/src/renderer/src/lib/http-link-routing.ts index aa89fb7071f..628a205f00f 100644 --- a/src/renderer/src/lib/http-link-routing.ts +++ b/src/renderer/src/lib/http-link-routing.ts @@ -127,7 +127,10 @@ export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void } const state = storeAccessor?.() const remoteRuntimeActive = Boolean(state?.settings?.activeRuntimeEnvironmentId?.trim()) - const sourceIsLocal = sourceOwner ? sourceOwner.kind === 'local' : !remoteRuntimeActive + const effectiveSourceOwner = sourceOwner + const sourceIsLocal = effectiveSourceOwner + ? effectiveSourceOwner.kind === 'local' + : !remoteRuntimeActive const openLinksInApp = state?.settings?.openLinksInApp === true const modifier = resolveModifierRouting( Boolean(modifierHeld), @@ -144,16 +147,20 @@ export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void wantsOrca && allowRemoteInApp && worktreeId && - (sourceOwner?.kind === 'runtime' || sourceOwner?.kind === 'ssh') + (effectiveSourceOwner?.kind === 'runtime' || + effectiveSourceOwner?.kind === 'ssh' || + (!effectiveSourceOwner && remoteRuntimeActive)) ) { if (workspaceHttpLinkBrowserOpener) { void workspaceHttpLinkBrowserOpener({ workspaceId: worktreeId, url, intent: { kind: 'url' }, - ...(sourceOwner.kind === 'runtime' - ? { expectedRuntimeEnvironmentId: sourceOwner.runtimeEnvironmentId } - : { expectedSshConnectionId: sourceOwner.connectionId }) + ...(effectiveSourceOwner?.kind === 'runtime' + ? { expectedRuntimeEnvironmentId: effectiveSourceOwner.runtimeEnvironmentId } + : effectiveSourceOwner?.kind === 'ssh' + ? { expectedSshConnectionId: effectiveSourceOwner.connectionId } + : {}) }).catch((error) => { toast.error( error instanceof Error From 9a567974868737698dfdb7deb5cb37b0352d6815 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:21:31 -0700 Subject: [PATCH 015/191] fix(mobile): surface host create warnings and terminal-create errors (#20125) * fix(mobile): surface host create warnings and terminal-create errors A workspace created from the phone could land on "No tabs in this session" with a bare red "Failed to create terminal" and no way to tell why. Two independent drops hid the host's own explanation: - createWorktreeWithNameRetry returned only {worktreeId, name}, discarding worktree.create's `warning`, and hostNewWorktreeSessionRoute built the session route with only `name` + `created=1`. The session screen has always had the banner (MobileSessionContentRow + createWarningState) -- only the tasks create path ever fed it, so the New Workspace path could never report a startup terminal that failed to spawn. - handleCreateTerminal collapsed every failure to the literal 'Failed to create terminal', throwing away response.error.message. Both now propagate, so the daemon's pty-allocation hint ("Your system cannot allocate any more pty devices.") reaches the phone instead of dying in the main process. Behaviour is otherwise unchanged: a blank warning is still omitted from the route, and a host that gives no reason still reads 'Failed to create terminal'. * test(mobile): refresh route parity baselines --------- Co-authored-by: Merge Sim --- .../components/NewWorktreeModalController.tsx | 2 +- .../components/new-worktree-modal-types.ts | 2 +- .../use-new-workspace-create-submit.ts | 4 +- mobile/src/host-route-action-state.test.ts | 24 ++++++++++ mobile/src/host-route-action-state.ts | 7 ++- .../src/host-screen/host-screen-overlays.tsx | 4 +- .../mobile-session-route-parity.test.ts | 4 +- ...le-session-terminal-create-actions.test.ts | 45 +++++++++++++++++++ ...-mobile-session-terminal-create-actions.ts | 27 +++++------ .../src/tasks/worktree-create-retry.test.ts | 40 ++++++++++++++++- mobile/src/tasks/worktree-create-retry.ts | 11 ++++- 11 files changed, 144 insertions(+), 26 deletions(-) diff --git a/mobile/src/components/NewWorktreeModalController.tsx b/mobile/src/components/NewWorktreeModalController.tsx index 4b6812fc9ff..a7f2ce1c2b6 100644 --- a/mobile/src/components/NewWorktreeModalController.tsx +++ b/mobile/src/components/NewWorktreeModalController.tsx @@ -16,7 +16,7 @@ type Props = { openExternalUrl: (url: string) => Promise onVisibleChange?: (visible: boolean) => void onRouteVisibleChange: (visible: boolean) => void - onCreated: (worktreeId: string, name: string) => void + onCreated: (worktreeId: string, name: string, warning?: string) => void } export const NewWorktreeModalController = forwardRef( diff --git a/mobile/src/components/new-worktree-modal-types.ts b/mobile/src/components/new-worktree-modal-types.ts index 8dae250cd71..7e5000ccc11 100644 --- a/mobile/src/components/new-worktree-modal-types.ts +++ b/mobile/src/components/new-worktree-modal-types.ts @@ -24,7 +24,7 @@ export type NewWorktreeModalProps = { existingWorktreePaths?: readonly string[] existingWorktrees?: readonly { repoId: string; branch: string }[] openExternalUrl: (url: string) => Promise - onCreated: (worktreeId: string, name: string) => void + onCreated: (worktreeId: string, name: string, warning?: string) => void onClose: () => void } diff --git a/mobile/src/components/use-new-workspace-create-submit.ts b/mobile/src/components/use-new-workspace-create-submit.ts index 1ab3ac5d5c4..1a7ba078b27 100644 --- a/mobile/src/components/use-new-workspace-create-submit.ts +++ b/mobile/src/components/use-new-workspace-create-submit.ts @@ -58,7 +58,7 @@ export function useNewWorkspaceCreateSubmit(args: { getWorktreeCreateCutoverSupport: () => Promise transitionDrawer: (view: Exclude) => void setError: Dispatch> - onCreated: (worktreeId: string, name: string) => void + onCreated: (worktreeId: string, name: string, warning?: string) => void onClose: () => void }): { creating: boolean @@ -181,7 +181,7 @@ export function useNewWorkspaceCreateSubmit(args: { return } args.onClose() - args.onCreated(result.worktreeId, result.name) + args.onCreated(result.worktreeId, result.name, result.warning) } catch (error) { args.setError(error instanceof Error ? error.message : 'Failed to create workspace') } finally { diff --git a/mobile/src/host-route-action-state.test.ts b/mobile/src/host-route-action-state.test.ts index 7f0ff6375a1..fd2e75d311f 100644 --- a/mobile/src/host-route-action-state.test.ts +++ b/mobile/src/host-route-action-state.test.ts @@ -19,6 +19,30 @@ describe('host route action state', () => { ) }) + // Why: the host reports a create that succeeded with a failed startup terminal via `warning`; + // dropping it here is what lands the phone on an unexplained empty session. + it('carries a host create warning into the session route', () => { + expect( + hostNewWorktreeSessionRoute( + 'local', + 'wt-1', + 'Hammerhead', + 'Failed to create the startup terminal' + ) + ).toBe( + '/h/local/session/wt-1?name=Hammerhead&created=1&warning=Failed+to+create+the+startup+terminal' + ) + }) + + it('omits an absent or blank create warning', () => { + expect(hostNewWorktreeSessionRoute('local', 'wt-1', 'Hammerhead', ' ')).toBe( + '/h/local/session/wt-1?name=Hammerhead&created=1' + ) + expect(hostNewWorktreeSessionRoute('local', 'wt-1', 'Hammerhead')).toBe( + '/h/local/session/wt-1?name=Hammerhead&created=1' + ) + }) + it('opens new worktree modal on an initial newWorktree action', () => { expect(createInitialHostRouteActionState('newWorktree')).toEqual({ routeAction: 'newWorktree', diff --git a/mobile/src/host-route-action-state.ts b/mobile/src/host-route-action-state.ts index a89c03fb472..5c2588b1e89 100644 --- a/mobile/src/host-route-action-state.ts +++ b/mobile/src/host-route-action-state.ts @@ -10,9 +10,14 @@ export function hostNewWorktreeRoute(hostId: string): `/h/${string}?action=newWo export function hostNewWorktreeSessionRoute( hostId: string, worktreeId: string, - worktreeName: string + worktreeName: string, + /** Host-reported create warning (e.g. the startup terminal failed to spawn). */ + warning?: string ): `/h/${string}/session/${string}?${string}` { const params = new URLSearchParams({ name: worktreeName, created: '1' }) + if (warning?.trim()) { + params.set('warning', warning) + } return `/h/${encodeURIComponent(hostId)}/session/${encodeURIComponent(worktreeId)}?${params}` } diff --git a/mobile/src/host-screen/host-screen-overlays.tsx b/mobile/src/host-screen/host-screen-overlays.tsx index 0f15f9d4e61..35a140fec7a 100644 --- a/mobile/src/host-screen/host-screen-overlays.tsx +++ b/mobile/src/host-screen/host-screen-overlays.tsx @@ -219,10 +219,10 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr onVisibleChange={(visible) => { state.newWorktreeModalVisibleRef.current = visible }} - onCreated={(worktreeId, worktreeName) => { + onCreated={(worktreeId, worktreeName, warning) => { void catalog.fetchWorktrees({ allowDuringModal: true }) actions.navigateFromHostList( - hostNewWorktreeSessionRoute(hostId, worktreeId, worktreeName) + hostNewWorktreeSessionRoute(hostId, worktreeId, worktreeName, warning) ) }} onRouteVisibleChange={actions.setShowNewWorktreeVisible} diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts index 3a6b04661de..df8aa1dd904 100644 --- a/mobile/src/session/mobile-session-route-parity.test.ts +++ b/mobile/src/session/mobile-session-route-parity.test.ts @@ -70,7 +70,7 @@ const HEAD_CALLBACK_BODY_SHA256 = 'af7f3c62954250d4be7ee432ecd10dc2689792aad8230 const HEAD_EFFECT_SHA256 = 'd9ebfaabc1e79773cdada7ab370b20459ed972f1f8edce1652199f4d0391cd13' const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fef5cc45fde6f8189581' const HEAD_NESTED_FUNCTION_SHA256 = - 'fde6679349ab2b8c30c7e627841ff99bd1dd24441ee95323d0aa70230422ae24' + '97ce5457d8059974f500022a4382ff687074e26843d6c1525be938d6c0537928' const HEAD_NATIVE_REGISTRATION_SHA256 = 'cab85e4e4a3f43289ba93ddea9ccce57aea83e0bf14fd1620a965aad0c1cb49e' const HEAD_NATIVE_REMOVAL_SHA256 = @@ -79,7 +79,7 @@ const HEAD_TIMER_CREATION_SHA256 = '1a31b625e2174c3db77272249843196d2b6b06ab1e654a96d8f7858e3082e66b' const HEAD_TIMER_CLEANUP_SHA256 = 'c73f1d1c2cc89642f3d727d6f3b6b81860a9d6f34234541a2065ec3d1a8cd116' const HEAD_RUNTIME_STRING_SHA256 = - '31951b0b83be01ebfa659c4b94df9ad7eaff6404df5338fbade89eb7473a3cb4' + '57ef354b97fb4fd3776fd1b09a34305d84022c04c43c6391bd130517bf6e37af' const HEAD_HOST_JSX_SHA256 = '390405926b1695fa3a33686f0bc192b432f5468d8576499d7cafbb4922defbb5' const HEAD_LEAF_JSX_SHA256 = '21dba981875e173f692590bf910d60964660c5f4cbb79f3a377c7e54f6a1f016' const HEAD_STYLE_REFERENCE_SHA256 = diff --git a/mobile/src/session/use-mobile-session-terminal-create-actions.test.ts b/mobile/src/session/use-mobile-session-terminal-create-actions.test.ts index e46bf087f38..17afae7380f 100644 --- a/mobile/src/session/use-mobile-session-terminal-create-actions.test.ts +++ b/mobile/src/session/use-mobile-session-terminal-create-actions.test.ts @@ -261,4 +261,49 @@ describe('mobile + Codex tab creation routing', () => { expect(scope.showToast).toHaveBeenCalledWith('create outcome ambiguous', 1800) } ) + // Why: pty exhaustion, a disabled agent and an unresolved worktree owner all arrived as the + // same 'Failed to create terminal', leaving the empty session with nothing to act on. + it('surfaces the host reason instead of a generic terminal-create error', async () => { + const client = clientReturning({ + ok: false, + error: { + code: 'runtime_error', + message: 'Your system cannot allocate any more pty devices.' + } + }) + const scope = createScope(client) + let actions: ReturnType | undefined + function Harness() { + actions = useMobileSessionTerminalCreateActions(scope as never) + return null + } + await act(async () => { + renderer = create(createElement(Harness)) + }) + await act(async () => { + await actions?.handleCreateTerminal() + }) + + expect(scope.setCreateError).toHaveBeenCalledWith( + 'Your system cannot allocate any more pty devices.' + ) + }) + + it('falls back to the generic message when the host gives no reason', async () => { + const client = clientReturning({ ok: false, error: { code: 'runtime_error', message: '' } }) + const scope = createScope(client) + let actions: ReturnType | undefined + function Harness() { + actions = useMobileSessionTerminalCreateActions(scope as never) + return null + } + await act(async () => { + renderer = create(createElement(Harness)) + }) + await act(async () => { + await actions?.handleCreateTerminal() + }) + + expect(scope.setCreateError).toHaveBeenCalledWith('Failed to create terminal') + }) }) diff --git a/mobile/src/session/use-mobile-session-terminal-create-actions.ts b/mobile/src/session/use-mobile-session-terminal-create-actions.ts index cf6e9441d10..1daa3eb1fa5 100644 --- a/mobile/src/session/use-mobile-session-terminal-create-actions.ts +++ b/mobile/src/session/use-mobile-session-terminal-create-actions.ts @@ -63,6 +63,17 @@ export function useMobileSessionTerminalCreateActions(scope: MobileSessionAttach .toString(36) .slice(2, 10)}` + // Why: the host names the real cause (pty exhaustion, disabled agent, unresolved worktree); + // collapsing every failure to 'Failed to create terminal' left the phone undiagnosable. + function reportCreateFailure(hostReason: string): void { + const reason = hostReason.trim() + setCreateError(reason || options?.errorToast || 'Failed to create terminal') + if (options?.errorToast) { + triggerError() + showToast(options.errorToast, 1800) + } + } + try { // Bare structured-provider launches follow host createSupport; prompted launches keep their startup semantics. if (isAgentSessionHandleProvider(agent) && options === undefined) { @@ -199,20 +210,10 @@ export function useMobileSessionTerminalCreateActions(scope: MobileSessionAttach } scheduleDelayedAction(() => void fetchSessionTabs(), 500) } else { - const message = options?.errorToast ?? 'Failed to create terminal' - setCreateError(message) - if (options?.errorToast) { - triggerError() - showToast(message, 1800) - } - } - } catch { - const message = options?.errorToast ?? 'Failed to create terminal' - setCreateError(message) - if (options?.errorToast) { - triggerError() - showToast(message, 1800) + reportCreateFailure((response as RpcFailure).error.message) } + } catch (error) { + reportCreateFailure(error instanceof Error ? error.message : '') } finally { creatingTerminalRef.current = false setCreating(false) diff --git a/mobile/src/tasks/worktree-create-retry.test.ts b/mobile/src/tasks/worktree-create-retry.test.ts index beb9d463e32..1611ed74c19 100644 --- a/mobile/src/tasks/worktree-create-retry.test.ts +++ b/mobile/src/tasks/worktree-create-retry.test.ts @@ -55,7 +55,7 @@ async function flush(): Promise { // cutover). Records every call so tests can assert on the clientMutationId. function scriptedClient( outcomes: Array< - | { id: string; displayName?: string } + | { id: string; displayName?: string; warning?: string } | { errorMessage: string } // takesMs models how long the ambiguity took to SURFACE — a clean close is // instant, a half-open socket waits out the liveness watchdog or the timeout. @@ -107,7 +107,8 @@ function scriptedClient( worktree: { id: outcome.id, ...(outcome.displayName !== undefined ? { displayName: outcome.displayName } : {}) - } + }, + ...(outcome.warning !== undefined ? { warning: outcome.warning } : {}) }, _meta: { runtimeId: 'r' } } @@ -116,6 +117,41 @@ function scriptedClient( } describe('createWorktreeWithNameRetry', () => { + // Why: `worktree.create` succeeds even when the startup terminal failed to spawn (pty + // exhaustion), and `warning` is the only place the host says so. + it('returns the host create warning alongside the worktree', async () => { + const attempts: Attempt[] = [] + const client = scriptedClient( + [{ id: 'wt-warned', warning: 'Failed to create the startup terminal for /w: no pty' }], + attempts + ) + await expect( + createWorktreeWithNameRetry({ + client, + baseName: 'puffin', + buildParams: (name) => ({ repo: 'id:r', name }), + worktreeCreateIdempotency: Promise.resolve(IDEMPOTENT_CREATE_SUPPORT) + }) + ).resolves.toEqual({ + worktreeId: 'wt-warned', + name: 'puffin', + warning: 'Failed to create the startup terminal for /w: no pty' + }) + }) + + it('omits a blank create warning', async () => { + const attempts: Attempt[] = [] + const client = scriptedClient([{ id: 'wt-clean', warning: ' ' }], attempts) + await expect( + createWorktreeWithNameRetry({ + client, + baseName: 'puffin', + buildParams: (name) => ({ repo: 'id:r', name }), + worktreeCreateIdempotency: Promise.resolve(IDEMPOTENT_CREATE_SUPPORT) + }) + ).resolves.toEqual({ worktreeId: 'wt-clean', name: 'puffin' }) + }) + it('waits for capability detection before sending a create', async () => { const attempts: Attempt[] = [] const client = scriptedClient([{ id: 'wt-ready' }], attempts) diff --git a/mobile/src/tasks/worktree-create-retry.ts b/mobile/src/tasks/worktree-create-retry.ts index a3fa8ae2e1b..b0f8f618773 100644 --- a/mobile/src/tasks/worktree-create-retry.ts +++ b/mobile/src/tasks/worktree-create-retry.ts @@ -21,7 +21,9 @@ import { // branches outlive worktrees in git, and remote branches/PRs aren't visible from // worktree.ps. Retry by appending -2, -3, ... mirroring the desktop createWorktree // loop in src/renderer/src/store/slices/worktrees.ts. -export type WorktreeCreateResult = { worktreeId: string; name: string } | { error: string } +export type WorktreeCreateResult = + | { worktreeId: string; name: string; warning?: string } + | { error: string } // Why: a create in flight when the mobile transport migrates (relay/direct // hand-off on shoddy cellular, relay lease rotation) rejects with a cutover error @@ -84,14 +86,19 @@ export async function createWorktreeWithNameRetry( if (response.ok) { const result = (response as RpcSuccess).result as { worktree: { id: string; displayName?: string } + warning?: string } const authoritativeName = result.worktree.displayName + // Why: a create can succeed with the startup terminal failing (pty exhaustion); dropping + // `warning` here is what lands the phone on an unexplained empty session. + const warning = typeof result.warning === 'string' ? result.warning.trim() : '' return { worktreeId: result.worktree.id, name: typeof authoritativeName === 'string' && authoritativeName.trim() ? authoritativeName - : candidateName + : candidateName, + ...(warning ? { warning } : {}) } } lastError = response.error.message From cd9aa43a2c76513d3c3ceca75356bd4c44ac305b Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:25:10 -0400 Subject: [PATCH 016/191] feat(relay): correct regional placement only when the source is idle (#20105) * feat(relay): correct regional placement only at an idle source * test(relay): lock source activity capacity semantics --- ...cloud-deploy-relay-production-director.yml | 14 +- cloud/apps/relay/src/admin-token-verifier.ts | 1 + cloud/apps/relay/src/app.ts | 188 ++- cloud/apps/relay/src/assignment-store.ts | 1330 +++++++---------- .../relay/src/cell-heartbeat-client.test.ts | 48 +- cloud/apps/relay/src/cell-heartbeat-client.ts | 10 +- .../src/cell-inventory-lock-census.test.ts | 16 +- cloud/apps/relay/src/config.test.ts | 11 + cloud/apps/relay/src/config.ts | 8 +- cloud/apps/relay/src/database.test.ts | 38 +- cloud/apps/relay/src/database.ts | 34 +- .../src/host-session-client-accept.test.ts | 64 + .../relay/src/host-session-registry.test.ts | 387 ++++- cloud/apps/relay/src/host-session-registry.ts | 384 ++++- ...dle-regional-rehome-reconciliation.test.ts | 103 ++ .../src/idle-regional-rehome-selection.ts | 117 ++ .../src/idle-regional-rehome-store.test.ts | 350 +++++ .../src/idle-regional-rehome-test-database.ts | 40 + .../src/idle-regional-rehome-worker.test.ts | 105 ++ cloud/apps/relay/src/index.ts | 8 + .../relay/src/region-correction-outcomes.ts | 29 + .../relay/src/region-correction-preview.ts | 157 ++ .../src/region-correction-restart.test.ts | 119 ++ .../apps/relay/src/region-correction-state.ts | 158 ++ .../relay/src/region-correction-store.test.ts | 359 +++++ .../relay/src/regional-host-drain-app.test.ts | 301 ++-- .../src/regional-rehome-postgres.test.ts | 493 +++--- .../relay/src/regional-rehome-store.test.ts | 1001 +++++-------- .../regional-rehome-target-selection.test.ts | 68 +- .../relay/src/regional-rehome-worker.test.ts | 198 +-- .../apps/relay/src/regional-rehome-worker.ts | 100 +- cloud/apps/relay/src/relay-region-app.test.ts | 155 ++ cloud/apps/relay/src/relay-server.ts | 33 +- .../relay/src/relay-sweep-schedule.test.ts | 2 +- .../relay-contract-baseline/README.md | 8 + .../control-messages.ts | 146 ++ .../director-messages.ts | 75 + .../relay-contract-baseline/relay-regions.ts | 71 + .../relay-contract-baseline/wire-scalars.ts | 20 + cloud/apps/relay/tsconfig.build.json | 2 +- cloud/dev/scripts/deploy-relay-blue-green.mjs | 23 +- .../scripts/deploy-relay-blue-green.test.mjs | 42 + ...lay-serving-regional-placement-version.mjs | 16 +- ...erving-regional-placement-version.test.mjs | 36 +- cloud/docs/orca-relay-operations.md | 61 + cloud/infra/terraform/relay.tf | 5 + .../relay-contract/src/control-messages.ts | 16 +- .../relay-contract/src/director-messages.ts | 15 +- .../src/idle-regional-rehome.ts | 26 + cloud/packages/relay-contract/src/index.ts | 2 + .../src/region-correction.test.ts | 76 + .../relay-contract/src/region-correction.ts | 60 + 52 files changed, 4961 insertions(+), 2168 deletions(-) create mode 100644 cloud/apps/relay/src/idle-regional-rehome-reconciliation.test.ts create mode 100644 cloud/apps/relay/src/idle-regional-rehome-selection.ts create mode 100644 cloud/apps/relay/src/idle-regional-rehome-store.test.ts create mode 100644 cloud/apps/relay/src/idle-regional-rehome-test-database.ts create mode 100644 cloud/apps/relay/src/idle-regional-rehome-worker.test.ts create mode 100644 cloud/apps/relay/src/region-correction-outcomes.ts create mode 100644 cloud/apps/relay/src/region-correction-preview.ts create mode 100644 cloud/apps/relay/src/region-correction-restart.test.ts create mode 100644 cloud/apps/relay/src/region-correction-state.ts create mode 100644 cloud/apps/relay/src/region-correction-store.test.ts create mode 100644 cloud/apps/relay/src/test-fixtures/relay-contract-baseline/README.md create mode 100644 cloud/apps/relay/src/test-fixtures/relay-contract-baseline/control-messages.ts create mode 100644 cloud/apps/relay/src/test-fixtures/relay-contract-baseline/director-messages.ts create mode 100644 cloud/apps/relay/src/test-fixtures/relay-contract-baseline/relay-regions.ts create mode 100644 cloud/apps/relay/src/test-fixtures/relay-contract-baseline/wire-scalars.ts create mode 100644 cloud/packages/relay-contract/src/idle-regional-rehome.ts create mode 100644 cloud/packages/relay-contract/src/region-correction.test.ts create mode 100644 cloud/packages/relay-contract/src/region-correction.ts diff --git a/.github/workflows/cloud-deploy-relay-production-director.yml b/.github/workflows/cloud-deploy-relay-production-director.yml index 4489d67b845..07e1e74ec3c 100644 --- a/.github/workflows/cloud-deploy-relay-production-director.yml +++ b/.github/workflows/cloud-deploy-relay-production-director.yml @@ -13,6 +13,11 @@ on: default: preserve type: choice options: [preserve, enable, disable] + region-correction-cohort-percent: + description: 'Preserve the measured-correction cohort, or set an integer 0–100; durable rehome stays disabled' + required: true + default: preserve + type: string prune-incompatible-revisions: description: Retain only the newly verified serving and rollback revisions required: true @@ -62,6 +67,7 @@ jobs: REGIONAL_PLACEMENT_SECRET: orca-cloud-relay-regional-placement-enabled IMAGE_DIGEST: ${{ inputs.image-digest }} REGIONAL_PLACEMENT_MODE: ${{ inputs.regional-placement-mode }} + REGION_CORRECTION_COHORT_PERCENT: ${{ inputs.region-correction-cohort-percent }} PRUNE_INCOMPATIBLE_REVISIONS: ${{ inputs.prune-incompatible-revisions }} # Floor the served revision must keep, matching relay_min_instances in # environments/production.tfvars. This gate only fails a bad deploy; Terraform @@ -106,8 +112,11 @@ jobs: echo "image-digest must be an immutable lowercase sha256 digest" >&2 exit 1 fi + if test "${REGION_CORRECTION_COHORT_PERCENT}" != preserve; then + [[ "${REGION_CORRECTION_COHORT_PERCENT}" =~ ^([0-9]|[1-9][0-9]|100)$ ]] + fi IMAGE="${IMAGE_REPOSITORY}@${IMAGE_DIGEST}" - SERVED_DIGEST="$(gcloud artifacts docker images describe "${IMAGE}" --format='value(image_summary.digest)')" + SERVED_DIGEST="$(gcloud artifacts docker images describe "${IMAGE}" --project "${GCP_PROJECT_ID}" --format='value(image_summary.digest)')" test "${SERVED_DIGEST}" = "${IMAGE_DIGEST}" [[ "${PRUNE_INCOMPATIBLE_REVISIONS}" =~ ^(true|false)$ ]] [[ "${EXPECTED_REHOME_GENERATION}" =~ ^(0|[1-9][0-9]*)$ ]] @@ -218,7 +227,8 @@ jobs: --max-instances "${DIRECTOR_MAX_INSTANCES}" \ --prune-revisions "${PRUNE_INCOMPATIBLE_REVISIONS}" \ --release-id "${RELEASE_ID}" \ - --regional-placement-secret-version "${target_version}" + --regional-placement-secret-version "${target_version}" \ + --region-correction-cohort-percent "${REGION_CORRECTION_COHORT_PERCENT}" echo "REGIONAL_PLACEMENT_ENABLED=${desired}" >> "${GITHUB_ENV}" echo "REGIONAL_PLACEMENT_VERSION=${target_version}" >> "${GITHUB_ENV}" diff --git a/cloud/apps/relay/src/admin-token-verifier.ts b/cloud/apps/relay/src/admin-token-verifier.ts index 4b8ad26e695..8b236d58473 100644 --- a/cloud/apps/relay/src/admin-token-verifier.ts +++ b/cloud/apps/relay/src/admin-token-verifier.ts @@ -6,6 +6,7 @@ export const RELAY_MONITOR_ADMIN_ROUTES = [ '/v1/admin/cell-status', '/v1/admin/evacuation-status', '/v1/admin/regional-rehome-control', + '/v1/admin/regional-rehome-preview', '/v1/admin/runtime-status' ] as const diff --git a/cloud/apps/relay/src/app.ts b/cloud/apps/relay/src/app.ts index c45e31c4a01..44f6a6fc293 100644 --- a/cloud/apps/relay/src/app.ts +++ b/cloud/apps/relay/src/app.ts @@ -1,5 +1,9 @@ import { AssignmentRequestSchema, + IdleRegionalRehomeRequestSchema, + type IdleRegionalRehomeRequest, + type IdleRegionalRehomeOutcome, + type RegionCorrectionResponse, isRelayCellConnectionHardCap, RELAY_ADMISSION_BUDGETS, RELAY_DEFAULT_REGION, @@ -39,7 +43,7 @@ import { type AssignmentAdmissionRejection } from './public-assignment-admission.js' import { relayHostLogDigest } from './relay-host-log-digest.js' -import type { RelayRuntimeCounts } from './relay-observability.js' +import type { RegionalRehomeSafetySnapshot, RelayRuntimeCounts } from './relay-observability.js' import { isRegionalRehomeTrustProbe, probeRegionalRehomeTrust @@ -68,21 +72,28 @@ export function createRelayApp( store: RelayCredentialStore assignments: RelayAssignmentStore drain: (graceMs: number) => void + idleRehome?: (input: IdleRegionalRehomeRequest & { + cohortPercent: number + directorSafety: RegionalRehomeSafetySnapshot + }) => Promise<{ outcome: IdleRegionalRehomeOutcome }> drainHost?: (input: { attemptId: string userId: string relayHostId: string sourceAssignmentEpoch: number + sourceCellIncarnation: string graceMs: number - }) => 'accepted' | 'already-accepted' | 'host-not-connected' + }) => + | 'accepted' + | 'already-accepted' + | 'host-not-connected' + | Promise<'accepted' | 'already-accepted' | 'host-not-connected'> regionalRehomeIdentityToken?: (audience: string) => Promise regionalRehomeFetch?: typeof fetch - regionalRehomeTrustProbeHostExists?: (input: { - userId: string - relayHostId: string - }) => boolean + regionalRehomeTrustProbeHostExists?: (input: { userId: string; relayHostId: string }) => boolean cellIncarnation?: string isDraining?: () => boolean + regionalRehomeSafetySnapshot?: () => RegionalRehomeSafetySnapshot runtimeCounts?: () => RelayRuntimeCounts ready: () => Promise recordAssignmentAdmission?: ( @@ -226,7 +237,8 @@ export function createRelayApp( return context.json({ error: 'host_identity_mismatch' }, 403) } const identity = { userId: claims.sub, relayHostId: claims.relayHostId } - const requestedRegion = body.data.preferredRegion + const requestedRegion = + body.data.regionCorrection?.action === 'report' ? undefined : body.data.preferredRegion const targetRegion = config.regionalPlacementEnabled !== false && requestedRegion ? requestedRegion @@ -295,10 +307,30 @@ export function createRelayApp( } } let assignment: RelayAssignment + let regionCorrection: RegionCorrectionResponse | undefined try { - assignment = requestedRegion - ? await operations.assignments.assign(identity, requestedRegion, targetRegion) - : await operations.assignments.assign(identity) + if (body.data.regionCorrection?.action === 'report') { + const current = await operations.assignments.resolve(identity) + if (!current) return context.json({ error: 'assignment_not_found' }, 409) + assignment = current + } else { + assignment = requestedRegion + ? await operations.assignments.assign(identity, requestedRegion, targetRegion) + : await operations.assignments.assign(identity) + } + if (body.data.regionCorrection) { + try { + regionCorrection = await operations.assignments.exchangeRegionCorrection( + identity, + body.data.regionCorrection, + assignment.assignmentEpoch + ) + } catch (error) { + if (body.data.regionCorrection.action === 'report') throw error + // Optional measurement setup must not discard an otherwise valid placement. + console.warn(JSON.stringify({ event: 'orca_relay_region_window_unavailable' })) + } + } } catch (error) { if (isRelayAssignmentCapacityError(error) || isRelayDatabaseTransientError(error)) { logAssignmentRejection({ @@ -353,13 +385,16 @@ export function createRelayApp( v: 1, cellUrl: assignment.cellUrl, assignmentEpoch: assignment.assignmentEpoch, - lease + lease, + ...(regionCorrection ? { regionCorrection } : {}) }) }) app.post('/v1/resolve', async (context) => { if (config.role === 'cell') return context.json({ error: 'director_only' }, 404) if (!config.publicAssignmentsEnabled) return rejectPublicAssignment(context) - if (Number(context.req.header('content-length') ?? 0) > RELAY_PROTOCOL_LIMITS.maxHttpBodyBytes) { + if ( + Number(context.req.header('content-length') ?? 0) > RELAY_PROTOCOL_LIMITS.maxHttpBodyBytes + ) { return context.json({ error: 'request_too_large' }, 413) } const body = ResolveRequestSchema.safeParse(await context.req.json().catch(() => null)) @@ -433,6 +468,34 @@ export function createRelayApp( operations.drain(body.data.graceMs) return context.json({ ok: true }) }) + app.post('/v1/admin/host-idle-rehome', async (context) => { + if (config.role !== 'cell' || !operations.idleRehome) { + return context.json({ error: 'cell_only' }, 404) + } + const bearer = readBearer(context.req.header('authorization')) + if (!bearer || !(await verifyRegionalRehomeToken(bearer))) { + return context.json({ error: 'invalid_token' }, 401) + } + if (requestTooLarge(context.req.header('content-length'))) { + return context.json({ error: 'request_too_large' }, 413) + } + const body = IdleRegionalRehomeCommandSchema.safeParse( + await context.req.json().catch(() => null) + ) + if (!body.success) return context.json({ error: 'invalid_request' }, 400) + if ( + body.data.sourceCellId !== config.cellId || + !operations.cellIncarnation || + body.data.sourceCellIncarnation !== operations.cellIncarnation + ) { + return context.json({ error: 'regional_rehome_source_generation_mismatch' }, 409) + } + try { + return context.json({ v: 1, ...(await operations.idleRehome(body.data)) }) + } catch (error) { + return context.json({ error: operationError(error) }, 409) + } + }) app.post('/v1/admin/host-drain', async (context) => { if (config.role !== 'cell' || !operations.drainHost) { return context.json({ error: 'cell_only' }, 404) @@ -474,7 +537,7 @@ export function createRelayApp( } sharedRuntimeIdentityRejected = true } - const outcome = operations.drainHost(body.data) + const outcome = await operations.drainHost(body.data) return context.json({ v: 1, outcome, @@ -502,8 +565,7 @@ export function createRelayApp( region: config.region ?? RELAY_DEFAULT_REGION, imageDigest: config.imageDigest ?? null, draining: operations.isDraining?.() ?? false, - regionalRehomeProtocol: - config.rehomeAudience && config.rehomeDirectorServiceAccount ? 1 : 0, + regionalRehomeProtocol: config.rehomeAudience && config.rehomeDirectorServiceAccount ? 3 : 0, connectionCapacity: config.connectionHardCap === undefined ? null @@ -559,6 +621,18 @@ export function createRelayApp( return context.json({ error: operationError(error) }, 409) } }) + app.get('/v1/admin/regional-rehome-preview', async (context) => { + if (config.role !== 'director') return context.json({ error: 'director_only' }, 404) + const bearer = readBearer(context.req.header('authorization')) + if (!bearer || !(await verifyAdminToken(bearer, context.req.path))) { + return context.json({ error: 'invalid_token' }, 401) + } + const preview = await operations.assignments.previewRegionalRehomeEligibility( + operations.regionalRehomeSafetySnapshot?.() + ) + const outcomes = await operations.assignments.regionCorrectionOutcomes() + return context.json({ v: 1, preview, outcomes }) + }) app.post('/v1/admin/regional-rehome-control', async (context) => { if (config.role !== 'director') return context.json({ error: 'director_only' }, 404) const bearer = readBearer(context.req.header('authorization')) @@ -1295,6 +1369,11 @@ const RegionalRehomeSafetySchema = z }) .strict() +const IdleRegionalRehomeCommandSchema = IdleRegionalRehomeRequestSchema.extend({ + cohortPercent: z.number().int().min(0).max(100), + directorSafety: RegionalRehomeSafetySchema +}) + const CellHeartbeatSchema = z .object({ v: z.literal(1), @@ -1394,45 +1473,48 @@ const CellRegionalRehomeStatusSchema = z v: z.literal(1), cellId: z.string().min(1).max(128), cellIncarnation: z.string().uuid(), - regionalRehomeProtocol: z.number().int().min(0).max(1), + regionalRehomeProtocol: z.number().int().min(0).max(3), safety: RegionalRehomeSafetySchema }) .strict() -const RegionalRehomeControlSchema = z.discriminatedUnion('action', [ - z.object({ v: z.literal(1), action: z.literal('inspect') }).strict(), - z.object({ - v: z.literal(1), - action: z.literal('apply'), - expectedGeneration: z.number().int().nonnegative(), - enabled: z.boolean(), - notBefore: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), - ratePerMinute: z.number().int().min(1).max(120), - preferenceMaxAgeMs: z - .number() - .int() - .min(60_000) - .max(30 * 24 * 60 * 60_000), - hostCooldownMs: z - .number() - .int() - .min(60_000) - .max(30 * 24 * 60 * 60_000), - drainGraceMs: z.number().int().min(60_000).max(60 * 60_000), - confirmation: z.enum([ - 'ENABLE_REGIONAL_REHOMING', - 'DISABLE_REGIONAL_REHOMING' - ]) - }).strict() -]).superRefine((value, context) => { - if (value.action !== 'apply') return - const expected = value.enabled - ? 'ENABLE_REGIONAL_REHOMING' - : 'DISABLE_REGIONAL_REHOMING' - if (value.confirmation !== expected) { - context.addIssue({ code: 'custom', message: 'confirmation does not match state' }) - } -}) +const RegionalRehomeControlSchema = z + .discriminatedUnion('action', [ + z.object({ v: z.literal(1), action: z.literal('inspect') }).strict(), + z + .object({ + v: z.literal(1), + action: z.literal('apply'), + expectedGeneration: z.number().int().nonnegative(), + enabled: z.boolean(), + notBefore: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + ratePerMinute: z.number().int().min(1).max(120), + preferenceMaxAgeMs: z + .number() + .int() + .min(60_000) + .max(30 * 24 * 60 * 60_000), + hostCooldownMs: z + .number() + .int() + .min(60_000) + .max(30 * 24 * 60 * 60_000), + drainGraceMs: z + .number() + .int() + .min(60_000) + .max(60 * 60_000), + confirmation: z.enum(['ENABLE_REGIONAL_REHOMING', 'DISABLE_REGIONAL_REHOMING']) + }) + .strict() + ]) + .superRefine((value, context) => { + if (value.action !== 'apply') return + const expected = value.enabled ? 'ENABLE_REGIONAL_REHOMING' : 'DISABLE_REGIONAL_REHOMING' + if (value.confirmation !== expected) { + context.addIssue({ code: 'custom', message: 'confirmation does not match state' }) + } + }) const RegionalRehomeTrustProbeSchema = z .object({ @@ -1776,7 +1858,11 @@ const RegionalHostDrainSchema = z sourceCellId: z.string().min(1).max(128), sourceCellIncarnation: z.string().uuid(), sourceAssignmentEpoch: z.number().int().positive(), - graceMs: z.number().int().nonnegative().max(60 * 60 * 1000) + graceMs: z + .number() + .int() + .nonnegative() + .max(60 * 60 * 1000) }) .strict() diff --git a/cloud/apps/relay/src/assignment-store.ts b/cloud/apps/relay/src/assignment-store.ts index 824cb1e0b2f..296a09d4e42 100644 --- a/cloud/apps/relay/src/assignment-store.ts +++ b/cloud/apps/relay/src/assignment-store.ts @@ -1,3 +1,14 @@ +import { IDLE_REHOME_PAGE_SIZE, selectIdleRegionalRehomes } from './idle-regional-rehome-selection.js' +import { readRegionCorrectionOutcomes } from './region-correction-outcomes.js' +import { + previewRegionalRehomeEligibility, + type RegionCorrectionPreview +} from './region-correction-preview.js' +import { + exchangeRegionCorrection, + previewRegionCorrection, + REGIONAL_REHOME_CONCURRENT_LIMIT +} from './region-correction-state.js' import { randomUUID } from 'node:crypto' import { performance } from 'node:perf_hooks' import { @@ -7,7 +18,10 @@ import { RELAY_DEFAULT_REGION, RELAY_REGIONS, RELAY_PROTOCOL_LIMITS, - type RelayRegion + type RelayRegion, + type RegionCorrectionRequest, + type RegionCorrectionResponse, + type IdleRegionalRehomeRequest, } from '@orca-cloud/relay-contract' import { cellAdmissionState, @@ -80,6 +94,7 @@ type CellRegionalRehomeStatus = { } type RelayAssignmentStoreOptions = { + regionalRehomeCohortPercent?: number requireLiveCells?: boolean heartbeatTtlMs?: number recordControlRenewal?: (durationMs: number, outcome: ControlRenewalOutcome) => void @@ -136,10 +151,7 @@ export type RegionalRehomeAttempt = AssignmentIdentity & { sendAttempts: number } -export type RegionalHostDrainOutcome = - | 'accepted' - | 'already-accepted' - | 'host-not-connected' +export type RegionalHostDrainOutcome = 'accepted' | 'already-accepted' | 'host-not-connected' export type RegionalRehomeFleetSafety = RegionalRehomeSafetySnapshot & { requiredCells: number @@ -415,6 +427,7 @@ const ABORTABLE_EXPIRED_MIGRATION = `( )` export class RelayAssignmentStore { + private readonly regionalRehomeCohortPercent: number private readonly requireLiveCells: boolean private readonly heartbeatTtlMs: number // Poisoned attempts never complete or abort and stay the oldest rows, so @@ -430,13 +443,19 @@ export class RelayAssignmentStore { private readonly migrationCellRegistrar: RelayMigrationCellRegistrar private readonly activityQueue = new AssignmentIdentityQueue() private assignmentTail: Promise = Promise.resolve() - private pendingRegionalRehomeDisableLog: Record | null = null constructor( private readonly database: RelayDatabase, private readonly now: () => number = Date.now, options: RelayAssignmentStoreOptions = {} ) { + this.regionalRehomeCohortPercent = options.regionalRehomeCohortPercent ?? 0 + if ( + !Number.isInteger(this.regionalRehomeCohortPercent) || + this.regionalRehomeCohortPercent < 0 || + this.regionalRehomeCohortPercent > 100 + ) + throw new Error('invalid_regional_rehome_cohort') this.requireLiveCells = options.requireLiveCells ?? false this.heartbeatTtlMs = options.heartbeatTtlMs ?? 45_000 this.recordControlRenewal = options.recordControlRenewal @@ -3309,6 +3328,163 @@ export class RelayAssignmentStore { }) } + async exchangeRegionCorrection( + identity: AssignmentIdentity, + request: RegionCorrectionRequest, + assignmentEpoch: number + ): Promise { + return exchangeRegionCorrection(this.database, identity, request, assignmentEpoch, this.now()) + } + + async regionCorrectionOutcomes() { + return readRegionCorrectionOutcomes(this.database, this.now()) + } + + async previewRegionCorrection(): Promise> { + return previewRegionCorrection(this.database, this.now()) + } + + private idleRegionalCandidateOffset = 0 + + async selectIdleRegionalRehomeCandidates( + processSafety?: RegionalRehomeSafetySnapshot + ): Promise> { + const now = this.now() + if (!processSafety || this.regionalRehomeCohortPercent === 0) return [] + const fleetSafety = await this.readRegionalRehomeFleetSafety(this.database, now) + if (regionalRehomeFleetSafetyFailure(processSafety, fleetSafety, now)) return [] + const candidates = await selectIdleRegionalRehomes({ + database: this.database, now, heartbeatTtlMs: this.heartbeatTtlMs, + cohortPercent: this.regionalRehomeCohortPercent, offset: this.idleRegionalCandidateOffset, + connectionHeadroom: await this.connectionHeadroomByCell(this.database), + cellIsClean: regionalRehomeCellSafetyIsClean + }) + this.idleRegionalCandidateOffset = candidates.length < IDLE_REHOME_PAGE_SIZE + ? 0 : this.idleRegionalCandidateOffset + candidates.length + return candidates + } + + async commitIdleRegionalRehome( + request: IdleRegionalRehomeRequest, + processSafety?: RegionalRehomeSafetySnapshot, + cohortPercent = this.regionalRehomeCohortPercent + ): Promise<{ outcome: 'committed' | 'deferred' | 'stale' }> { + const prior = await this.reconcileIdleRegionalRehome(request) + if (prior !== 'not-committed') return { outcome: prior } + if (!processSafety || !Number.isInteger(cohortPercent) || cohortPercent <= 0 || cohortPercent > 100) { + return { outcome: 'deferred' } + } + let safetyDisable: Record | null = null + const result = await this.database.transaction(async (transaction): Promise<{ outcome: 'committed' | 'deferred' | 'stale' }> => { + safetyDisable = null + const now = this.now() + const control = (await transaction.queryLocked( + `SELECT * FROM relay_region_rehome_control WHERE control_id = 'global'` + ))[0] + if (!control || Number(control.enabled) !== 1 || Number(control.not_before) > now) { + return { outcome: 'deferred' } + } + await transaction.query( + `INSERT INTO relay_region_rehome_worker_state + (worker_id, next_dispatch_at, paused_until, consecutive_failures, updated_at) + VALUES ('global', 0, 0, 0, ?) ON CONFLICT (worker_id) DO NOTHING`, [now] + ) + const worker = (await transaction.queryLocked( + `SELECT * FROM relay_region_rehome_worker_state WHERE worker_id = 'global'` + ))[0]! + if (Number(worker.paused_until) > now || Number(worker.next_dispatch_at) > now) { + return { outcome: 'deferred' } + } + const open = (await transaction.query( + `SELECT COUNT(*) AS count FROM relay_assignment_migrations + WHERE completed_at IS NULL AND aborted_at IS NULL` + ))[0] + if (Number(open?.count ?? 0) >= REGIONAL_REHOME_CONCURRENT_LIMIT) return { outcome: 'deferred' } + const attempt = await this.startRegionalRehomeCandidate(transaction, { + identity: request, + sourceCellId: request.sourceCellId, + assignmentEpoch: request.sourceAssignmentEpoch, + preferenceCutoff: now - Number(control.preference_max_age_ms), + cooldownCutoff: now - Number(control.host_cooldown_ms), + drainGraceMs: 0, + processSafety, + worker, + now, + skips: [], + idleRequest: request, + cohortPercent, + onSafetyDisabled: (event) => { safetyDisable = event } + }) + if (!attempt) return { outcome: 'deferred' } + await this.markRegionalRehomeDispatchClaimed( + transaction, request.attemptId, now, Math.ceil(60_000 / Number(control.rate_per_minute)) + ) + await transaction.query( + `UPDATE relay_region_rehome_attempts SET drain_receipt_at = ?, drain_outcome = 'accepted' + WHERE attempt_id = ?`, [now, request.attemptId] + ) + return { outcome: 'committed' } + }) + if (safetyDisable) console.warn(JSON.stringify(safetyDisable)) + return result + } + + async reconcileIdleRegionalRehome(request: IdleRegionalRehomeRequest): Promise<'committed' | 'not-committed' | 'stale'> { + return this.database.transaction(async (transaction) => { + // Absence is definitive only after the same assignment lock as commit/activation. + const assignment = await this.assignmentRow(transaction, request) + const attempt = (await transaction.queryLocked( + `SELECT * FROM relay_region_rehome_attempts WHERE attempt_id = ?`, + [request.attemptId] + ))[0] + if (attempt) { + return attempt.user_id === request.userId && + attempt.relay_host_id === request.relayHostId && + attempt.source_cell_id === request.sourceCellId && + attempt.source_cell_incarnation === request.sourceCellIncarnation && + Number(attempt.previous_epoch) === request.sourceAssignmentEpoch && + Number(attempt.source_generation) === request.sourceGeneration && + attempt.target_cell_id === request.targetCellId && + attempt.aborted_at == null + ? 'committed' : 'stale' + } + if (!assignment || assignment.cell_id !== request.sourceCellId || + Number(assignment.assignment_epoch) !== request.sourceAssignmentEpoch) return 'stale' + const control = (await transaction.query( + `SELECT capability.generation, capability.cell_incarnation + FROM relay_control_capabilities capability + JOIN relay_assignment_activity_leases lease + ON lease.user_id = capability.user_id AND lease.relay_host_id = capability.relay_host_id + AND lease.activity_id = capability.activity_id + WHERE capability.user_id = ? AND capability.relay_host_id = ? + AND capability.cell_id = ? AND capability.assignment_epoch = ? + AND lease.activity_kind = 'control' AND lease.expires_at > ? + ORDER BY capability.generation DESC LIMIT 1`, + [request.userId, request.relayHostId, request.sourceCellId, request.sourceAssignmentEpoch, this.now()] + ))[0] + return control && Number(control.generation) === request.sourceGeneration && + control.cell_incarnation === request.sourceCellIncarnation ? 'not-committed' : 'stale' + }) + } + + async previewRegionalRehomeEligibility( + processSafety?: RegionalRehomeSafetySnapshot + ): Promise { + const now = this.now() + const fleetSafety = await this.readRegionalRehomeFleetSafety(this.database, now) + return previewRegionalRehomeEligibility({ + database: this.database, + now, + heartbeatTtlMs: this.heartbeatTtlMs, + cohortPercent: this.regionalRehomeCohortPercent, + globalSafetyFailure: processSafety + ? regionalRehomeFleetSafetyFailure(processSafety, fleetSafety, now) + : 'process-safety-unavailable', + connectionHeadroom: await this.connectionHeadroomByCell(this.database), + cellIsClean: regionalRehomeCellSafetyIsClean + }) + } + async renewControlActivity( identity: AssignmentIdentity, input: { activityId: string; cellId: string; expiresAt: number } @@ -3545,6 +3721,8 @@ export class RelayAssignmentStore { cellId: string assignmentEpoch: number generation: number + idleRegionalRehome?: boolean + cellIncarnation?: string connectionInclusionWatermark?: number } ): Promise { @@ -3626,6 +3804,33 @@ export class RelayAssignmentStore { input.connectionInclusionWatermark, now ) + await transaction.query( + `DELETE FROM relay_control_capabilities WHERE user_id = ? AND relay_host_id = ? + AND NOT EXISTS (SELECT 1 FROM relay_assignment_activity_leases lease + WHERE lease.user_id = relay_control_capabilities.user_id + AND lease.relay_host_id = relay_control_capabilities.relay_host_id + AND lease.activity_id = relay_control_capabilities.activity_id)`, + [identity.userId, identity.relayHostId] + ) + await transaction.query( + `INSERT INTO relay_control_capabilities + (user_id, relay_host_id, activity_id, cell_id, cell_incarnation, assignment_epoch, generation, finish_existing, idle_regional_rehome) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (user_id, relay_host_id, activity_id) DO UPDATE SET + cell_incarnation = excluded.cell_incarnation, assignment_epoch = excluded.assignment_epoch, + generation = excluded.generation, finish_existing = excluded.finish_existing, idle_regional_rehome = excluded.idle_regional_rehome`, + [ + identity.userId, + identity.relayHostId, + activityId, + input.cellId, + input.cellIncarnation ?? '', + input.assignmentEpoch, + input.generation, + 0, + input.idleRegionalRehome && input.cellIncarnation ? 1 : 0 + ] + ) return activityId }) }) @@ -5116,327 +5321,6 @@ export class RelayAssignmentStore { } } - async claimRegionalRehome( - processSafety?: RegionalRehomeSafetySnapshot - ): Promise { - const now = this.now() - // Directors poll every second; avoid taking the global worker-row lock while disabled. - const control = ( - await this.database.query( - `SELECT enabled, not_before - FROM relay_region_rehome_control - WHERE control_id = 'global'` - ) - )[0] - if (!control) { - await this.initializeRegionalRehomeControl(this.database, now) - return null - } - if (integer(control, 'enabled') !== 1 || integer(control, 'not_before') > now) { - return null - } - 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 = ( - await transaction.queryLocked( - `SELECT * FROM relay_region_rehome_control WHERE control_id = 'global'` - ) - )[0]! - if (integer(control, 'enabled') !== 1 || integer(control, 'not_before') > now) { - return null - } - const intervalMs = Math.ceil(60_000 / integer(control, 'rate_per_minute')) - const preferenceCutoff = now - integer(control, 'preference_max_age_ms') - // A host that was rehomed recently is left alone whichever way its - // preference now points: a flapping region probe must not walk one host - // back and forth across an ocean. - const cooldownCutoff = now - integer(control, 'host_cooldown_ms') - await transaction.query( - `INSERT INTO relay_region_rehome_worker_state - (worker_id, next_dispatch_at, paused_until, consecutive_failures, updated_at) - VALUES ('global', 0, 0, 0, ?) - ON CONFLICT (worker_id) DO NOTHING`, - [now] - ) - const worker = ( - await transaction.queryLocked( - `SELECT * FROM relay_region_rehome_worker_state WHERE worker_id = 'global'` - ) - )[0]! - if ( - integer(worker, 'paused_until') > now || - integer(worker, 'next_dispatch_at') > now - ) { - return null - } - const effectiveProcessSafety = processSafety ?? cleanRegionalRehomeSafety(now) - const fleetSafety = await this.readRegionalRehomeFleetSafety(transaction, now) - if ( - !(await this.regionalRehomeSafetyAllowsClaim( - transaction, - worker, - effectiveProcessSafety, - fleetSafety, - now - )) - ) { - return null - } - const retry = ( - await transaction.queryLocked( - `SELECT attempt.*, source.cell_url AS source_cell_url - FROM relay_region_rehome_attempts attempt - JOIN relay_cells source ON source.cell_id = attempt.source_cell_id - JOIN relay_cell_runtime runtime ON runtime.cell_id = attempt.source_cell_id - JOIN relay_cell_capabilities capability - ON capability.cell_id = runtime.cell_id - AND capability.cell_incarnation = runtime.cell_incarnation - JOIN relay_assignment_migrations migration - ON migration.user_id = attempt.user_id - AND migration.relay_host_id = attempt.relay_host_id - AND migration.assignment_epoch = attempt.assignment_epoch - WHERE attempt.drain_receipt_at IS NULL - AND attempt.completed_at IS NULL AND attempt.aborted_at IS NULL - AND attempt.send_attempts < 10 - AND (attempt.last_send_attempt_at IS NULL OR attempt.last_send_attempt_at <= ?) - AND runtime.cell_incarnation = attempt.source_cell_incarnation - AND runtime.ready = 1 AND runtime.last_heartbeat_at > ? - AND capability.regional_rehome_protocol >= 1 - AND migration.completed_at IS NULL AND migration.aborted_at IS NULL - ORDER BY attempt.created_at, attempt.attempt_id - LIMIT 1`, - [now - 30_000, now - this.heartbeatTtlMs] - ) - )[0] - if (retry) { - candidatesTotal = 1 - const fleetSafety = await this.lockedRegionalRehomeFleetSafety(transaction, now) - if ( - !(await this.regionalRehomeSafetyAllowsClaim( - transaction, - worker, - effectiveProcessSafety, - fleetSafety, - now - )) - ) { - return null - } - await this.markRegionalRehomeDispatchClaimed( - transaction, - text(retry, 'attempt_id'), - now, - intervalMs - ) - retry.send_attempts = integer(retry, 'send_attempts') + 1 - return regionalRehomeAttempt(retry) - } - - // A drain receipt is not convergence: grace enforcement lives only in - // source-cell session state, and attempts have been observed stalled - // dual-homed well past grace with source leases still renewing. Such - // attempts are re-dispatched with the remaining (zero) grace so the - // source force-closes and the host re-resolves onto its registered - // target. - const redrain = ( - await transaction.queryLocked( - `SELECT attempt.*, source.cell_url AS source_cell_url - FROM relay_region_rehome_attempts attempt - JOIN relay_cells source ON source.cell_id = attempt.source_cell_id - JOIN relay_cell_runtime runtime ON runtime.cell_id = attempt.source_cell_id - JOIN relay_cell_capabilities capability - ON capability.cell_id = runtime.cell_id - AND capability.cell_incarnation = runtime.cell_incarnation - JOIN relay_assignment_migrations migration - ON migration.user_id = attempt.user_id - AND migration.relay_host_id = attempt.relay_host_id - AND migration.assignment_epoch = attempt.assignment_epoch - WHERE attempt.drain_receipt_at IS NOT NULL - AND attempt.completed_at IS NULL AND attempt.aborted_at IS NULL - AND attempt.created_at + attempt.drain_grace_ms <= ? - AND attempt.send_attempts < ? - AND (attempt.last_send_attempt_at IS NULL OR attempt.last_send_attempt_at <= ?) - AND runtime.cell_incarnation = attempt.source_cell_incarnation - AND runtime.ready = 1 AND runtime.last_heartbeat_at > ? - AND capability.regional_rehome_protocol >= 1 - AND migration.completed_at IS NULL AND migration.aborted_at IS NULL - AND migration.target_registered_at IS NOT NULL - AND EXISTS ( - SELECT 1 FROM relay_assignment_activity_leases source_lease - WHERE source_lease.user_id = attempt.user_id - AND source_lease.relay_host_id = attempt.relay_host_id - AND source_lease.cell_id = attempt.source_cell_id - ) - ORDER BY attempt.created_at, attempt.attempt_id - LIMIT 1`, - [ - now, - REGIONAL_REHOME_REDRAIN_SEND_LIMIT, - now - REGIONAL_REHOME_REDRAIN_INTERVAL_MS, - now - this.heartbeatTtlMs - ] - ) - )[0] - if (redrain) { - candidatesTotal = 1 - const fleetSafety = await this.lockedRegionalRehomeFleetSafety(transaction, now) - if ( - !(await this.regionalRehomeSafetyAllowsClaim( - transaction, - worker, - effectiveProcessSafety, - fleetSafety, - now - )) - ) { - return null - } - await this.markRegionalRehomeDispatchClaimed( - transaction, - text(redrain, 'attempt_id'), - now, - intervalMs - ) - redrain.send_attempts = integer(redrain, 'send_attempts') + 1 - redrain.drain_grace_ms = 0 - return regionalRehomeAttempt(redrain) - } - - const candidates = await transaction.query( - `SELECT preference.user_id, preference.relay_host_id, - preference.observed_at, assignment.cell_id AS source_cell_id, - assignment.assignment_epoch - FROM relay_assignment_region_preferences preference - JOIN relay_assignments assignment - ON assignment.user_id = preference.user_id - AND assignment.relay_host_id = preference.relay_host_id - JOIN relay_cell_regions region ON region.cell_id = assignment.cell_id - JOIN relay_cell_admission admission ON admission.cell_id = assignment.cell_id - JOIN relay_cell_runtime runtime ON runtime.cell_id = assignment.cell_id - JOIN relay_cell_capabilities capability - ON capability.cell_id = runtime.cell_id - AND capability.cell_incarnation = runtime.cell_incarnation - WHERE preference.preferred_region <> region.region - AND preference.observed_at >= ? - AND admission.admission_state = 'general' - AND runtime.ready = 1 AND runtime.last_heartbeat_at > ? - AND capability.regional_rehome_protocol >= 1 - AND EXISTS ( - SELECT 1 FROM relay_assignment_activity_leases control - WHERE control.user_id = assignment.user_id - AND control.relay_host_id = assignment.relay_host_id - AND control.cell_id = assignment.cell_id - AND control.activity_kind = 'control' - AND control.activity_id NOT LIKE 'control-pending:%' - AND control.expires_at > ? - AND control.updated_at >= runtime.started_at - ) - AND NOT EXISTS ( - SELECT 1 FROM relay_assignment_migrations migration - WHERE migration.user_id = assignment.user_id - AND migration.relay_host_id = assignment.relay_host_id - AND migration.completed_at IS NULL AND migration.aborted_at IS NULL - ) - AND NOT EXISTS ( - SELECT 1 FROM relay_region_rehome_attempts recent - WHERE recent.user_id = preference.user_id - AND recent.relay_host_id = preference.relay_host_id - AND recent.created_at > ? - ) - AND EXISTS ( - SELECT 1 FROM relay_cell_regions target_region - JOIN relay_cells target_cell ON target_cell.cell_id = target_region.cell_id - JOIN relay_cell_admission target_admission - ON target_admission.cell_id = target_region.cell_id - JOIN relay_cell_runtime target_runtime - ON target_runtime.cell_id = target_region.cell_id - JOIN relay_cell_capabilities target_capability - ON target_capability.cell_id = target_runtime.cell_id - AND target_capability.cell_incarnation = target_runtime.cell_incarnation - WHERE target_region.region = preference.preferred_region - AND target_cell.enabled = 1 - AND target_admission.admission_state = 'general' - AND target_runtime.ready = 1 - AND target_runtime.last_heartbeat_at > ? - AND target_capability.regional_rehome_protocol >= 1 - ) - ORDER BY preference.observed_at, preference.user_id, preference.relay_host_id - LIMIT 10`, - [ - preferenceCutoff, - now - this.heartbeatTtlMs, - now, - cooldownCutoff, - now - this.heartbeatTtlMs - ] - ) - candidatesTotal = candidates.length - for (const candidate of candidates) { - const claimed = await this.startRegionalRehomeCandidate(transaction, { - identity: { - userId: text(candidate, 'user_id'), - relayHostId: text(candidate, 'relay_host_id') - }, - sourceCellId: text(candidate, 'source_cell_id'), - assignmentEpoch: integer(candidate, 'assignment_epoch'), - preferenceCutoff, - cooldownCutoff, - drainGraceMs: integer(control, 'drain_grace_ms'), - processSafety: effectiveProcessSafety, - worker, - now, - skips: candidateSkips - }) - candidatesFinished++ - if (!claimed) continue - await this.markRegionalRehomeDispatchClaimed( - transaction, - claimed.attemptId, - now, - intervalMs - ) - return { ...claimed, sendAttempts: 1 } - } - if (candidates.length > 0) { - // Skipped candidates still cost all-rows FOR UPDATE inventory scans; - // charge the dispatch interval so skips are rate-limited like claims. - 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 - if (pendingDisableLog) console.warn(JSON.stringify(pendingDisableLog)) - if (claimResult === null && candidateSkips.length > 0) { - console.warn(JSON.stringify(aggregateRegionalRehomeCandidateSkips(candidateSkips))) - } - return claimResult - } - private async startRegionalRehomeCandidate( transaction: RelayDatabase, input: { @@ -5450,6 +5334,9 @@ export class RelayAssignmentStore { worker: SqlRow now: number skips: RegionalRehomeCandidateSkip[] + idleRequest: IdleRegionalRehomeRequest + cohortPercent: number + onSafetyDisabled: (event: Record | null) => void } ): Promise | null> { const assignment = await this.assignmentRow(transaction, input.identity) @@ -5463,12 +5350,21 @@ export class RelayAssignmentStore { } const preference = ( await transaction.queryLocked( - `SELECT * FROM relay_assignment_region_preferences + `SELECT * FROM relay_region_decisions WHERE user_id = ? AND relay_host_id = ?`, [input.identity.userId, input.identity.relayHostId] ) )[0] - if (!preference || integer(preference, 'observed_at') < input.preferenceCutoff) { + if ( + !preference || + integer(preference, 'observed_at') < input.preferenceCutoff || + Number(preference.expires_at) <= input.now || + preference.outcome !== 'conclusive' || + Number(preference.policy_version) !== 1 || + Number(preference.assignment_epoch) !== input.assignmentEpoch || + !preference.preferred_region || + Number(preference.cohort_bucket) >= input.cohortPercent + ) { input.skips.push({ reason: 'candidate_stale' }) return null } @@ -5540,13 +5436,13 @@ export class RelayAssignmentStore { input.now ) if (safetyFailure) { - await this.pauseRegionalRehomeForSafety( + input.onSafetyDisabled(await this.pauseRegionalRehomeForSafety( transaction, input.worker, input.now, safetyFailure, fleetSafety - ) + )) return null } // The preference read under lock can now agree with the cell the host is @@ -5564,9 +5460,9 @@ export class RelayAssignmentStore { integer(sourceRuntime, 'ready') !== 1 || integer(sourceRuntime, 'last_heartbeat_at') <= input.now - this.heartbeatTtlMs || !sourceCapability || - text(sourceCapability, 'cell_incarnation') !== - text(sourceRuntime, 'cell_incarnation') || - integer(sourceCapability, 'regional_rehome_protocol') < 1 + text(sourceCapability, 'cell_incarnation') !== text(sourceRuntime, 'cell_incarnation') || + integer(sourceCapability, 'regional_rehome_protocol') < 3 || + sourceRuntime.cell_incarnation !== input.idleRequest.sourceCellIncarnation ) { input.skips.push({ reason: 'source_ineligible', cellId: input.sourceCellId }) return null @@ -5575,6 +5471,32 @@ export class RelayAssignmentStore { input.skips.push(cellUncleanSkip('source_unclean', input.sourceCellId, sourceSafety)) return null } + const hostCapability = ( + await transaction.query( + `SELECT capability.* FROM relay_control_capabilities capability + JOIN relay_assignment_activity_leases lease + ON lease.user_id = capability.user_id AND lease.relay_host_id = capability.relay_host_id + AND lease.activity_id = capability.activity_id + WHERE capability.user_id = ? AND capability.relay_host_id = ? + AND capability.cell_id = ? AND capability.assignment_epoch = ? + AND capability.cell_incarnation = ? AND capability.idle_regional_rehome = 1 + AND lease.expires_at > ? AND lease.activity_kind = 'control' + ORDER BY capability.generation DESC LIMIT 1`, + [ + input.identity.userId, + input.identity.relayHostId, + input.sourceCellId, + input.assignmentEpoch, + sourceRuntime.cell_incarnation, + input.now + ] + ) + )[0] + if (!hostCapability || preference.incumbent_region !== regions.get(input.sourceCellId) || + Number(hostCapability.generation) !== input.idleRequest.sourceGeneration) { + input.skips.push({ reason: 'candidate_stale' }) + return null + } const sourceControlActive = activityLeases.some( (lease) => text(lease, 'cell_id') === input.sourceCellId && @@ -5606,7 +5528,8 @@ export class RelayAssignmentStore { integer(runtime, 'last_heartbeat_at') > input.now - this.heartbeatTtlMs && capability !== undefined && text(capability, 'cell_incarnation') === text(runtime, 'cell_incarnation') && - integer(capability, 'regional_rehome_protocol') >= 1 + integer(capability, 'regional_rehome_protocol') >= 3 && + cellId === input.idleRequest.targetCellId ) }) const targetIsClean = (row: SqlRow): boolean => { @@ -5753,7 +5676,7 @@ export class RelayAssignmentStore { text(targetRuntime, 'cell_incarnation') ] ) - const attemptId = randomUUID() + const attemptId = input.idleRequest.attemptId await transaction.query( `INSERT INTO relay_region_rehome_attempts (attempt_id, user_id, relay_host_id, preferred_region, @@ -5780,6 +5703,10 @@ export class RelayAssignmentStore { input.now ] ) + await transaction.query( + `UPDATE relay_region_rehome_attempts SET source_generation = ? WHERE attempt_id = ?`, + [input.idleRequest.sourceGeneration, attemptId] + ) return { ...input.identity, attemptId, @@ -5795,61 +5722,13 @@ export class RelayAssignmentStore { } } - private async lockedRegionalRehomeFleetSafety( - transaction: RelayDatabase, - now: number - ): Promise { - 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) => [ - text(row, 'cell_id'), - relayRegion(row, 'region') - ]) - ) - const runtimes = await transaction.queryLocked( - `SELECT * FROM relay_cell_runtime ORDER BY cell_id` - ) - const capabilities = await transaction.queryLocked( - `SELECT * FROM relay_cell_capabilities ORDER BY cell_id` - ) - const safetyRows = await transaction.queryLocked( - `SELECT * FROM relay_cell_rehome_safety ORDER BY cell_id` - ) - return regionalRehomeFleetSafetyFromInventory({ - cells, - admission, - regions, - runtimes, - capabilities, - safetyRows, - now, - heartbeatTtlMs: this.heartbeatTtlMs - }) - } - - private async regionalRehomeSafetyAllowsClaim( - transaction: RelayDatabase, - worker: SqlRow, - processSafety: RegionalRehomeSafetySnapshot, - fleetSafety: RegionalRehomeFleetSafety, - now: number - ): Promise { - const failure = regionalRehomeFleetSafetyFailure(processSafety, fleetSafety, now) - if (!failure) { - return true - } - await this.pauseRegionalRehomeForSafety(transaction, worker, now, failure, fleetSafety) - return false - } - private async pauseRegionalRehomeForSafety( transaction: RelayDatabase, worker: SqlRow, now: number, reason: string, fleetSafety: RegionalRehomeFleetSafety - ): Promise { + ): Promise | null> { const disabled = await transaction.query( `UPDATE relay_region_rehome_control SET generation = generation + 1, enabled = 0, updated_at = ? @@ -5860,8 +5739,9 @@ export class RelayAssignmentStore { // The durable disable is otherwise invisible: nothing else records why // claims stopped and inspection only shows enabled=false. Logged after // the transaction commits so a rollback cannot fabricate the record. + let event: Record | null = null if (disabled.length > 0) { - this.pendingRegionalRehomeDisableLog = { + event = { event: 'orca_relay_regional_rehome_safety_disabled', reason, controlGeneration: integer(disabled[0]!, 'generation'), @@ -5879,19 +5759,9 @@ export class RelayAssignmentStore { } } await this.incrementRegionalRehomeWorkerFailure(transaction, worker, now) + return event } - private async markRegionalRehomeTickSkipped( - transaction: RelayDatabase, - now: number, - intervalMs: number - ): Promise { - await transaction.query( - `UPDATE relay_region_rehome_worker_state - SET next_dispatch_at = ?, updated_at = ? WHERE worker_id = 'global'`, - [now + intervalMs, now] - ) - } private async markRegionalRehomeDispatchClaimed( transaction: RelayDatabase, @@ -5912,74 +5782,6 @@ export class RelayAssignmentStore { ) } - async recordRegionalRehomeDrainReceipt( - attemptId: string, - outcome: RegionalHostDrainOutcome - ): Promise { - const now = this.now() - return await this.database.transaction(async (transaction) => { - const worker = ( - await transaction.queryLocked( - `SELECT * FROM relay_region_rehome_worker_state WHERE worker_id = 'global'` - ) - )[0] - const attempt = ( - await transaction.queryLocked( - `SELECT * FROM relay_region_rehome_attempts WHERE attempt_id = ?`, - [attemptId] - ) - )[0] - if (!attempt) throw new Error('regional_rehome_attempt_not_found') - // Any receipt proves the source cell answered: reset the failure budget - // even when a redrain repeats the stored outcome; otherwise a - // redrain-dominated stream lets scattered transient failures reach the - // durable three-failure disable. - if (worker) { - await transaction.query( - `UPDATE relay_region_rehome_worker_state - SET consecutive_failures = 0, paused_until = 0, updated_at = ? - WHERE worker_id = 'global'`, - [now] - ) - } - const existingOutcome = optionalText(attempt, 'drain_outcome') - if (existingOutcome === outcome) return false - // Redrains produce one receipt per dispatch; the latest outcome wins. - await transaction.query( - `UPDATE relay_region_rehome_attempts - SET drain_receipt_at = ?, drain_outcome = ?, updated_at = ? - WHERE attempt_id = ?`, - [now, outcome, now, attemptId] - ) - return true - }) - } - - async recordRegionalRehomeDispatchFailure(attemptId: string): Promise { - const now = this.now() - const disableLog = await this.database.transaction(async (transaction) => { - // Match claim and enable ordering before a spent budget updates the control. - await transaction.queryLocked( - `SELECT * FROM relay_region_rehome_control WHERE control_id = 'global'` - ) - const worker = ( - await transaction.queryLocked( - `SELECT * FROM relay_region_rehome_worker_state WHERE worker_id = 'global'` - ) - )[0] - const attempt = ( - await transaction.queryLocked( - `SELECT attempt_id FROM relay_region_rehome_attempts WHERE attempt_id = ?`, - [attemptId] - ) - )[0] - if (!worker || !attempt) return null - return await this.incrementRegionalRehomeWorkerFailure(transaction, worker, now) - }) - // Logged after the commit so a rollback cannot fabricate the record. - if (disableLog) console.warn(JSON.stringify(disableLog)) - } - // Returns the durable disable this failure caused, for the caller to log once // its transaction commits; null when the budget survives or was already spent. private async incrementRegionalRehomeWorkerFailure( @@ -6074,7 +5876,7 @@ export class RelayAssignmentStore { // LIMIT pages: poisoned rows are permanent and always the oldest, so // without exclusion they eventually starve every healthy candidate. private recordRegionalRehomeCandidateFailure( - operation: 'complete' | 'abort', + operation: 'complete' | 'abort' | 'refresh', attemptId: string, now: number, error: unknown @@ -6116,12 +5918,16 @@ export class RelayAssignmentStore { async refreshRegionalRehomeLeases(limit = 100): Promise { const now = this.now() + const quarantined = this.quarantinedRegionalRehomeAttemptIds(now) + const exclusion = quarantined.length + ? ` AND attempt_id NOT IN (${quarantined.map(() => '?').join(', ')})` + : '' const candidates = await this.database.query( - `SELECT user_id, relay_host_id, assignment_epoch + `SELECT attempt_id, user_id, relay_host_id, assignment_epoch FROM relay_region_rehome_attempts - WHERE completed_at IS NULL AND aborted_at IS NULL - ORDER BY created_at, attempt_id LIMIT ?`, - [limit] + WHERE completed_at IS NULL AND aborted_at IS NULL${exclusion} + ORDER BY updated_at, attempt_id LIMIT ?`, + [...quarantined, limit] ) let refreshed = 0 for (const candidate of candidates) { @@ -6130,50 +5936,91 @@ export class RelayAssignmentStore { relayHostId: text(candidate, 'relay_host_id') } const assignmentEpoch = integer(candidate, 'assignment_epoch') - const changed = await this.database.transaction(async (transaction) => { - const assignment = await this.assignmentRow(transaction, identity) - const attempt = ( - await transaction.queryLocked( - `SELECT * FROM relay_region_rehome_attempts + const attemptId = text(candidate, 'attempt_id') + try { + const changed = await this.database.transaction(async (transaction) => { + const assignment = await this.assignmentRow(transaction, identity) + const attempt = ( + await transaction.queryLocked( + `SELECT * FROM relay_region_rehome_attempts WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ?`, - [identity.userId, identity.relayHostId, assignmentEpoch] - ) - )[0] - const migration = ( - await transaction.queryLocked( - `SELECT * FROM relay_assignment_migrations + [identity.userId, identity.relayHostId, assignmentEpoch] + ) + )[0] + const migration = ( + await transaction.queryLocked( + `SELECT * FROM relay_assignment_migrations WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ?`, - [identity.userId, identity.relayHostId, assignmentEpoch] - ) - )[0] - if ( - !assignment || - !attempt || - !migration || - optionalInteger(attempt, 'completed_at') !== undefined || - optionalInteger(attempt, 'aborted_at') !== undefined || - optionalInteger(migration, 'completed_at') !== undefined || - optionalInteger(migration, 'aborted_at') !== undefined - ) { - return false - } - const attemptAgeMs = now - integer(attempt, 'created_at') - if (attemptAgeMs >= REGIONAL_REHOME_MAX_REFRESH_MS) { - return false - } - if ( - optionalInteger(migration, 'target_registered_at') === undefined && - attemptAgeMs >= REGIONAL_REHOME_UNREGISTERED_REFRESH_MS - ) { - await transaction.query( - `UPDATE relay_assignment_activity_leases + [identity.userId, identity.relayHostId, assignmentEpoch] + ) + )[0] + if (attempt && attempt.completed_at == null && attempt.aborted_at == null) { + await transaction.query( + `UPDATE relay_region_rehome_attempts SET updated_at = ? WHERE attempt_id = ?`, + [now, attempt.attempt_id] + ) + } + if ( + !assignment || + !attempt || + !migration || + optionalInteger(attempt, 'completed_at') !== undefined || + optionalInteger(attempt, 'aborted_at') !== undefined || + optionalInteger(migration, 'completed_at') !== undefined || + optionalInteger(migration, 'aborted_at') !== undefined + ) { + return false + } + const attemptAgeMs = now - integer(attempt, 'created_at') + if (attemptAgeMs >= REGIONAL_REHOME_MAX_REFRESH_MS) { + return false + } + if ( + optionalInteger(migration, 'target_registered_at') === undefined && + attemptAgeMs >= REGIONAL_REHOME_UNREGISTERED_REFRESH_MS + ) { + await transaction.query( + `UPDATE relay_assignment_activity_leases SET expires_at = CASE WHEN expires_at < ? THEN expires_at ELSE ? END, updated_at = ? WHERE user_id = ? AND relay_host_id = ? AND activity_id IN (?, ?)`, + [ + now, + now, + now, + identity.userId, + identity.relayHostId, + pendingControlActivityId(assignmentEpoch), + migrationActivityId(assignmentEpoch) + ] + ) + await transaction.query( + `UPDATE relay_assignment_migrations + SET expires_at = CASE WHEN expires_at < ? THEN expires_at ELSE ? END, + updated_at = ? + WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ?`, + [now, now, now, identity.userId, identity.relayHostId, assignmentEpoch] + ) + return false + } + const leases = await this.lockAssignmentActivities(transaction, identity) + const protectedIds = new Set([ + pendingControlActivityId(assignmentEpoch), + migrationActivityId(assignmentEpoch) + ]) + const protectedLeases = leases.filter((lease) => + protectedIds.has(text(lease, 'activity_id')) + ) + if (protectedLeases.length === 0) return false + const expiresAt = now + ASSIGNMENT_LIMITS.migrationLeaseMs + await transaction.query( + `UPDATE relay_assignment_activity_leases + SET expires_at = ?, updated_at = ? + WHERE user_id = ? AND relay_host_id = ? + AND activity_id IN (?, ?)`, [ - now, - now, + expiresAt, now, identity.userId, identity.relayHostId, @@ -6182,53 +6029,25 @@ export class RelayAssignmentStore { ] ) await transaction.query( - `UPDATE relay_assignment_migrations - SET expires_at = CASE WHEN expires_at < ? THEN expires_at ELSE ? END, - updated_at = ? - WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ?`, - [now, now, now, identity.userId, identity.relayHostId, assignmentEpoch] - ) - return false - } - const leases = await this.lockAssignmentActivities(transaction, identity) - const protectedIds = new Set([ - pendingControlActivityId(assignmentEpoch), - migrationActivityId(assignmentEpoch) - ]) - const protectedLeases = leases.filter((lease) => - protectedIds.has(text(lease, 'activity_id')) - ) - if (protectedLeases.length === 0) return false - const expiresAt = now + ASSIGNMENT_LIMITS.migrationLeaseMs - await transaction.query( - `UPDATE relay_assignment_activity_leases - SET expires_at = ?, updated_at = ? - WHERE user_id = ? AND relay_host_id = ? - AND activity_id IN (?, ?)`, - [ - expiresAt, - now, - identity.userId, - identity.relayHostId, - pendingControlActivityId(assignmentEpoch), - migrationActivityId(assignmentEpoch) - ] - ) - await transaction.query( - `UPDATE relay_assignment_migrations SET expires_at = ?, updated_at = ? + `UPDATE relay_assignment_migrations SET expires_at = ?, updated_at = ? WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ?`, - [expiresAt, now, identity.userId, identity.relayHostId, assignmentEpoch] - ) - await transaction.query( - `UPDATE relay_assignments SET lease_expires_at = + [expiresAt, now, identity.userId, identity.relayHostId, assignmentEpoch] + ) + await transaction.query( + `UPDATE relay_assignments SET lease_expires_at = CASE WHEN lease_expires_at > ? THEN lease_expires_at ELSE ? END, last_activity_at = ? WHERE user_id = ? AND relay_host_id = ?`, - [expiresAt, expiresAt, now, identity.userId, identity.relayHostId] - ) - return true - }) - if (changed) refreshed++ + [expiresAt, expiresAt, now, identity.userId, identity.relayHostId] + ) + return true + }) + if (changed) refreshed++ + this.regionalRehomeCandidateQuarantine.delete(attemptId) + } catch (error) { + if (!isDatabaseLockUnavailable(error)) + this.recordRegionalRehomeCandidateFailure('refresh', attemptId, now, error) + } } return refreshed } @@ -6575,92 +6394,169 @@ export class RelayAssignmentStore { let aborted = 0 let inventoryBusy = 0 for (const candidate of candidates) { - const didAbort = await this.database.transaction(async (transaction) => { - const identity = { - userId: text(candidate, 'user_id'), - relayHostId: text(candidate, 'relay_host_id') - } - // Migration cleanup follows the same assignment-first order as evacuation. - const assignment = await this.assignmentRow(transaction, identity) - const assignmentEpoch = integer(candidate, 'assignment_epoch') - const regionalAttempt = ( - await transaction.queryLocked( - `SELECT attempt_id FROM relay_region_rehome_attempts + const didAbort = await this.database + .transaction(async (transaction) => { + const identity = { + userId: text(candidate, 'user_id'), + relayHostId: text(candidate, 'relay_host_id') + } + // Migration cleanup follows the same assignment-first order as evacuation. + const assignment = await this.assignmentRow(transaction, identity) + const assignmentEpoch = integer(candidate, 'assignment_epoch') + const regionalAttempt = ( + await transaction.queryLocked( + `SELECT attempt_id FROM relay_region_rehome_attempts WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ? AND completed_at IS NULL AND aborted_at IS NULL`, - [identity.userId, identity.relayHostId, assignmentEpoch] - ) - )[0] - const row = ( - await transaction.queryLocked( - `SELECT migration.* FROM relay_assignment_migrations migration + [identity.userId, identity.relayHostId, assignmentEpoch] + ) + )[0] + const row = ( + await transaction.queryLocked( + `SELECT migration.* FROM relay_assignment_migrations migration WHERE migration.user_id = ? AND migration.relay_host_id = ? AND migration.assignment_epoch = ? AND migration.expires_at <= ? AND migration.completed_at IS NULL AND migration.aborted_at IS NULL AND ${ABORTABLE_EXPIRED_MIGRATION}`, - [ - identity.userId, - identity.relayHostId, - assignmentEpoch, - now, - now, - abandonedBefore, - abandonedBefore - ] + [ + identity.userId, + identity.relayHostId, + assignmentEpoch, + now, + now, + abandonedBefore, + abandonedBefore + ] + ) + )[0] + if (!row) return false + const targetCellId = text(row, 'target_cell_id') + const activityLeases = await this.lockAssignmentActivities(transaction, identity) + if (!assignment) throw new Error('migration_assignment_missing') + const currentAssignmentEpoch = integer(assignment, 'assignment_epoch') + const assignmentEpochMatches = + text(assignment, 'cell_id') === targetCellId && + currentAssignmentEpoch === assignmentEpoch + const pendingTargetControl = activityLeaseById( + activityLeases, + pendingControlActivityId(assignmentEpoch) ) - )[0] - if (!row) return false - const targetCellId = text(row, 'target_cell_id') - const activityLeases = await this.lockAssignmentActivities(transaction, identity) - if (!assignment) throw new Error('migration_assignment_missing') - const currentAssignmentEpoch = integer(assignment, 'assignment_epoch') - const assignmentEpochMatches = - text(assignment, 'cell_id') === targetCellId && - currentAssignmentEpoch === assignmentEpoch - const pendingTargetControl = activityLeaseById( - activityLeases, - pendingControlActivityId(assignmentEpoch) - ) - const targetGrantIsFresh = - assignmentEpochMatches && - pendingTargetControl !== undefined && - text(pendingTargetControl, 'cell_id') === targetCellId && - text(pendingTargetControl, 'activity_kind') === 'control' && - integer(pendingTargetControl, 'expires_at') > now - const targetIsActive = activityLeases.some( - (lease) => - text(lease, 'cell_id') === targetCellId && - text(lease, 'activity_kind') === 'control' && - text(lease, 'activity_id') !== pendingControlActivityId(assignmentEpoch) - ) - if (targetGrantIsFresh) return false - if (targetIsActive && assignmentEpochMatches) { - // A committed target control is stronger evidence than a failed follow-up - // write; repair the marker instead of rolling a live desktop backward. - await transaction.query( - `UPDATE relay_assignment_migrations + const targetGrantIsFresh = + assignmentEpochMatches && + pendingTargetControl !== undefined && + text(pendingTargetControl, 'cell_id') === targetCellId && + text(pendingTargetControl, 'activity_kind') === 'control' && + integer(pendingTargetControl, 'expires_at') > now + const targetIsActive = activityLeases.some( + (lease) => + text(lease, 'cell_id') === targetCellId && + text(lease, 'activity_kind') === 'control' && + text(lease, 'activity_id') !== pendingControlActivityId(assignmentEpoch) + ) + if (targetGrantIsFresh) return false + if (targetIsActive && assignmentEpochMatches) { + // A committed target control is stronger evidence than a failed follow-up + // write; repair the marker instead of rolling a live desktop backward. + await transaction.query( + `UPDATE relay_assignment_migrations SET target_registered_at = COALESCE(target_registered_at, ?), updated_at = ? WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ?`, - [now, now, identity.userId, identity.relayHostId, assignmentEpoch] - ) - return false - } - if (!assignmentEpochMatches) { - if (currentAssignmentEpoch <= assignmentEpoch) { - throw new Error('migration_assignment_mismatch') + [now, now, identity.userId, identity.relayHostId, assignmentEpoch] + ) + return false } - // A newer assignment is authoritative regardless of where it landed. - // Retire only this obsolete migration; never rewrite the newer epoch. - const obsoleteLeases = [ + if (!assignmentEpochMatches) { + if (currentAssignmentEpoch <= assignmentEpoch) { + throw new Error('migration_assignment_mismatch') + } + // A newer assignment is authoritative regardless of where it landed. + // Retire only this obsolete migration; never rewrite the newer epoch. + const obsoleteLeases = [ + pendingControlActivityId(assignmentEpoch), + migrationActivityId(assignmentEpoch) + ] + .map((activityId) => activityLeaseById(activityLeases, activityId)) + .filter((lease): lease is SqlRow => lease !== undefined) + if (obsoleteLeases.length > 0) await this.lockCellInventory(transaction, 'nowait') + for (const lease of obsoleteLeases) { + await this.removeActivityLease(transaction, identity, lease, now) + } + await this.releaseSupersededControlConnectionReservations( + transaction, + identity, + targetCellId, + assignmentEpoch, + now + ) + await transaction.query( + `UPDATE relay_assignment_migrations SET aborted_at = ?, updated_at = ? + WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ?`, + [now, now, identity.userId, identity.relayHostId, assignmentEpoch] + ) + return true + } + 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 + WHERE cell_id IN (?, ?)`, + [sourceCellId, targetCellId] + ) + const sourceCell = cells.find((cell) => text(cell, 'cell_id') === sourceCellId) + const targetCell = cells.find((cell) => text(cell, 'cell_id') === targetCellId) + const sourceAdmission = admissionRows.find( + (admission) => text(admission, 'cell_id') === sourceCellId + ) + const targetAdmission = admissionRows.find( + (admission) => text(admission, 'cell_id') === targetCellId + ) + const registered = optionalInteger(row, 'target_registered_at') !== undefined + const sourceIsDurablyFenced = + registered && + ( + await transaction.query( + `SELECT 1 FROM relay_assignment_migrations migration + WHERE migration.user_id = ? AND migration.relay_host_id = ? + AND migration.assignment_epoch = ? + AND ${DURABLY_FENCED_MIGRATION_SOURCE}`, + [identity.userId, identity.relayHostId, assignmentEpoch] + ) + ).length === 1 + const retireOnTarget = + registered && + activityUnitsForCell(activityLeases, sourceCellId) === 0 && + sourceCell !== undefined && + integer(sourceCell, 'enabled') === 0 && + sourceAdmission !== undefined && + text(sourceAdmission, 'admission_state') === 'existing-only' && + (integer(sourceAdmission, 'updated_at') <= abandonedBefore || sourceIsDurablyFenced) && + targetCell !== undefined && + integer(targetCell, 'enabled') === 1 && + targetAdmission !== undefined && + ['migration-only', 'general'].includes(text(targetAdmission, 'admission_state')) + const rollbackReason = + !registered || + (targetCell !== undefined && + integer(targetCell, 'enabled') === 0 && + targetAdmission !== undefined && + text(targetAdmission, 'admission_state') === 'existing-only' && + integer(targetAdmission, 'updated_at') <= abandonedBefore) + const regionalRollbackSourceAvailable = + !regionalAttempt || + (sourceCell !== undefined && + integer(sourceCell, 'enabled') === 1 && + sourceAdmission !== undefined && + text(sourceAdmission, 'admission_state') === 'general' && + (await this.cellIsLive(transaction, sourceCellId, now))) + const rollbackToSource = rollbackReason && regionalRollbackSourceAvailable + if (!retireOnTarget && !rollbackToSource) return false + for (const activityId of [ pendingControlActivityId(assignmentEpoch), migrationActivityId(assignmentEpoch) - ] - .map((activityId) => activityLeaseById(activityLeases, activityId)) - .filter((lease): lease is SqlRow => lease !== undefined) - if (obsoleteLeases.length > 0) await this.lockCellInventory(transaction, 'nowait') - for (const lease of obsoleteLeases) { - await this.removeActivityLease(transaction, identity, lease, now) + ]) { + const lease = activityLeaseById(activityLeases, activityId) + if (lease) await this.removeActivityLease(transaction, identity, lease, now) } await this.releaseSupersededControlConnectionReservations( transaction, @@ -6669,116 +6565,46 @@ export class RelayAssignmentStore { assignmentEpoch, now ) - await transaction.query( - `UPDATE relay_assignment_migrations SET aborted_at = ?, updated_at = ? - WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ?`, - [now, now, identity.userId, identity.relayHostId, assignmentEpoch] - ) - return true - } - 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 - WHERE cell_id IN (?, ?)`, - [sourceCellId, targetCellId] - ) - const sourceCell = cells.find((cell) => text(cell, 'cell_id') === sourceCellId) - const targetCell = cells.find((cell) => text(cell, 'cell_id') === targetCellId) - const sourceAdmission = admissionRows.find( - (admission) => text(admission, 'cell_id') === sourceCellId - ) - const targetAdmission = admissionRows.find( - (admission) => text(admission, 'cell_id') === targetCellId - ) - const registered = optionalInteger(row, 'target_registered_at') !== undefined - const sourceIsDurablyFenced = - registered && - ( + if (retireOnTarget) { await transaction.query( - `SELECT 1 FROM relay_assignment_migrations migration - WHERE migration.user_id = ? AND migration.relay_host_id = ? - AND migration.assignment_epoch = ? - AND ${DURABLY_FENCED_MIGRATION_SOURCE}`, - [identity.userId, identity.relayHostId, assignmentEpoch] - ) - ).length === 1 - const retireOnTarget = - registered && - activityUnitsForCell(activityLeases, sourceCellId) === 0 && - sourceCell !== undefined && - integer(sourceCell, 'enabled') === 0 && - sourceAdmission !== undefined && - text(sourceAdmission, 'admission_state') === 'existing-only' && - (integer(sourceAdmission, 'updated_at') <= abandonedBefore || - sourceIsDurablyFenced) && - targetCell !== undefined && - integer(targetCell, 'enabled') === 1 && - targetAdmission !== undefined && - ['migration-only', 'general'].includes(text(targetAdmission, 'admission_state')) - const rollbackReason = - !registered || - (targetCell !== undefined && - integer(targetCell, 'enabled') === 0 && - targetAdmission !== undefined && - text(targetAdmission, 'admission_state') === 'existing-only' && - integer(targetAdmission, 'updated_at') <= abandonedBefore) - const regionalRollbackSourceAvailable = - !regionalAttempt || - (sourceCell !== undefined && - integer(sourceCell, 'enabled') === 1 && - sourceAdmission !== undefined && - text(sourceAdmission, 'admission_state') === 'general' && - (await this.cellIsLive(transaction, sourceCellId, now))) - const rollbackToSource = rollbackReason && regionalRollbackSourceAvailable - if (!retireOnTarget && !rollbackToSource) return false - for (const activityId of [ - pendingControlActivityId(assignmentEpoch), - migrationActivityId(assignmentEpoch) - ]) { - const lease = activityLeaseById(activityLeases, activityId) - if (lease) await this.removeActivityLease(transaction, identity, lease, now) - } - await this.releaseSupersededControlConnectionReservations( - transaction, - identity, - targetCellId, - assignmentEpoch, - now - ) - if (retireOnTarget) { - await transaction.query( - `UPDATE relay_assignment_migrations SET completed_at = ?, updated_at = ? + `UPDATE relay_assignment_migrations SET completed_at = ?, updated_at = ? WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ?`, - [now, now, identity.userId, identity.relayHostId, assignmentEpoch] - ) - return true - } - await transaction.query( - `UPDATE relay_assignments SET cell_id = ?, assignment_epoch = ?, + [now, now, identity.userId, identity.relayHostId, assignmentEpoch] + ) + return true + } + await transaction.query( + `UPDATE relay_assignments SET cell_id = ?, assignment_epoch = ?, lease_expires_at = ?, last_activity_at = ? WHERE user_id = ? AND relay_host_id = ?`, - [ - sourceCellId, - assignmentEpoch + 1, - now + ASSIGNMENT_LIMITS.activityLeaseMs, - now, - identity.userId, - identity.relayHostId - ] - ) - await transaction.query( - `UPDATE relay_assignment_migrations SET aborted_at = ?, updated_at = ? + [ + sourceCellId, + assignmentEpoch + 1, + now + ASSIGNMENT_LIMITS.activityLeaseMs, + now, + identity.userId, + identity.relayHostId + ] + ) + await transaction.query( + `UPDATE relay_assignment_migrations SET aborted_at = ?, updated_at = ? WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ?`, - [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 - }) + [now, now, identity.userId, identity.relayHostId, assignmentEpoch] + ) + if (regionalAttempt) { + await transaction.query( + `UPDATE relay_region_rehome_attempts SET aborted_at = ?, updated_at = ? WHERE attempt_id = ?`, + [now, now, regionalAttempt.attempt_id] + ) + } + 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) @@ -8165,7 +7991,7 @@ function migration(identity: AssignmentIdentity, row: SqlRow): RelayAssignmentMi // Attempt ids are server-minted UUIDs and this codebase's invariant messages // are snake_case slugs; anything else could carry secrets and logs redacted. function warnRegionalRehomeCandidateFailure( - operation: 'complete' | 'abort', + operation: 'complete' | 'abort' | 'refresh', attemptId: string, error: unknown ): void { @@ -8190,24 +8016,6 @@ function noteRegionalRehomeActivityCountsRepaired(attemptId: string): void { ) } -function regionalRehomeAttempt(row: SqlRow): RegionalRehomeAttempt { - return { - attemptId: text(row, 'attempt_id'), - userId: text(row, 'user_id'), - relayHostId: text(row, 'relay_host_id'), - preferredRegion: relayRegion(row, 'preferred_region'), - sourceCellId: text(row, 'source_cell_id'), - sourceCellUrl: text(row, 'source_cell_url'), - sourceCellIncarnation: text(row, 'source_cell_incarnation'), - targetCellId: text(row, 'target_cell_id'), - targetCellIncarnation: text(row, 'target_cell_incarnation'), - previousEpoch: integer(row, 'previous_epoch'), - assignmentEpoch: integer(row, 'assignment_epoch'), - drainGraceMs: integer(row, 'drain_grace_ms'), - sendAttempts: integer(row, 'send_attempts') - } -} - function regionalRehomeControl(row: SqlRow): RegionalRehomeControl { return { generation: integer(row, 'generation'), @@ -8221,17 +8029,6 @@ function regionalRehomeControl(row: SqlRow): RegionalRehomeControl { } } -function cleanRegionalRehomeSafety(now: number): RegionalRehomeSafetySnapshot { - return { - observedAt: now, - sqlFailures: 0, - reconnects: 0, - controlActivityRecoveryFailures: 0, - databasePoolWaiting: 0, - databasePoolWaitersMax: 0, - databasePoolWaitMsMax: 0 - } -} function regionalRehomeFleetSafetyFromInventory(input: { cells: SqlRow[] @@ -8353,23 +8150,6 @@ function cellUncleanSkip( // Candidate skips are otherwise invisible: they neither latch the control off // nor produce attempts, so an operator cannot tell "skipping" from "idle". // Cell ids and counters only — never free-form error text. -function aggregateRegionalRehomeCandidateSkips( - skips: readonly RegionalRehomeCandidateSkip[] -): Record { - // `candidates` counts skipped candidate iterations, not distinct cells: one - // unclean cell blocking six candidates reports candidates=6 on one cellId. - const aggregated = new Map() - for (const skip of skips) { - const key = `${skip.reason}:${skip.cellId ?? ''}` - const entry = aggregated.get(key) - if (entry) entry.candidates += 1 - else aggregated.set(key, { ...skip, candidates: 1 }) - } - return { - event: 'orca_relay_regional_rehome_candidates_skipped', - skips: [...aggregated.values()] - } -} function regionalRehomeCellSafetyIsClean( safety: SqlRow | undefined, diff --git a/cloud/apps/relay/src/cell-heartbeat-client.test.ts b/cloud/apps/relay/src/cell-heartbeat-client.test.ts index 2aa708bed1b..6c36829e768 100644 --- a/cloud/apps/relay/src/cell-heartbeat-client.test.ts +++ b/cloud/apps/relay/src/cell-heartbeat-client.test.ts @@ -92,7 +92,7 @@ describe('cell heartbeat client', () => { client.stop() expect(JSON.parse(String(requests[1]!.body))).toMatchObject({ - regionalRehomeProtocol: 1, + regionalRehomeProtocol: 3, safety: { observedAt: 120, sqlFailures: 0, @@ -149,28 +149,34 @@ describe('cell heartbeat client', () => { it('does not start outside an explicitly configured cell role', () => { expect( - startCellHeartbeat({ ...CONFIG, role: 'director' }, { - ready: async () => true, - observedRequests: () => 0, - connectionCounts: () => ({ - totalConnections: 0, - inFlightConnections: 0, - reservedConnectionUnits: 0, - enforcedConnectionUnits: 0 - }) - }) + startCellHeartbeat( + { ...CONFIG, role: 'director' }, + { + ready: async () => true, + observedRequests: () => 0, + connectionCounts: () => ({ + totalConnections: 0, + inFlightConnections: 0, + reservedConnectionUnits: 0, + enforcedConnectionUnits: 0 + }) + } + ) ).toBeNull() expect( - startCellHeartbeat({ ...CONFIG, directorUrl: undefined }, { - ready: async () => true, - observedRequests: () => 0, - connectionCounts: () => ({ - totalConnections: 0, - inFlightConnections: 0, - reservedConnectionUnits: 0, - enforcedConnectionUnits: 0 - }) - }) + startCellHeartbeat( + { ...CONFIG, directorUrl: undefined }, + { + ready: async () => true, + observedRequests: () => 0, + connectionCounts: () => ({ + totalConnections: 0, + inFlightConnections: 0, + reservedConnectionUnits: 0, + enforcedConnectionUnits: 0 + }) + } + ) ).toBeNull() }) }) diff --git a/cloud/apps/relay/src/cell-heartbeat-client.ts b/cloud/apps/relay/src/cell-heartbeat-client.ts index 5c990310413..54f97a17af8 100644 --- a/cloud/apps/relay/src/cell-heartbeat-client.ts +++ b/cloud/apps/relay/src/cell-heartbeat-client.ts @@ -70,8 +70,7 @@ export function startCellHeartbeat( inFlightConnections: connectionCounts!.inFlightConnections, reservedConnectionUnits: connectionCounts!.reservedConnectionUnits, enforcedConnectionUnits: connectionCounts!.enforcedConnectionUnits, - connectionInclusionWatermark: - connectionCounts!.inclusionWatermark, + connectionInclusionWatermark: connectionCounts!.inclusionWatermark, connectionHardCap: config.connectionHardCap, connectionUnobservedBound: config.connectionUnobservedBound }) @@ -94,7 +93,7 @@ export function startCellHeartbeat( cellId: config.cellId, cellIncarnation, regionalRehomeProtocol: - config.rehomeAudience && config.rehomeDirectorServiceAccount ? 1 : 0, + config.rehomeAudience && config.rehomeDirectorServiceAccount ? 3 : 0, safety: options.regionalRehomeSafety() }), signal: AbortSignal.timeout(10_000) @@ -106,7 +105,10 @@ export function startCellHeartbeat( } } catch (error) { // A heartbeat must fail closed without ever logging its bearer token. - console.warn('[orca-relay] cell heartbeat failed', error instanceof Error ? error.message : '') + console.warn( + '[orca-relay] cell heartbeat failed', + error instanceof Error ? error.message : '' + ) } finally { inFlight = false } diff --git a/cloud/apps/relay/src/cell-inventory-lock-census.test.ts b/cloud/apps/relay/src/cell-inventory-lock-census.test.ts index 0ac4c8225e3..929173eb9da 100644 --- a/cloud/apps/relay/src/cell-inventory-lock-census.test.ts +++ b/cloud/apps/relay/src/cell-inventory-lock-census.test.ts @@ -43,14 +43,13 @@ const CENSUS: CensusEntry[] = [ { 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: 'startRegionalRehomeCandidate', mode: 'nowait', reach: 'request' }, { 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: 'releaseExpiredActivity', mode: 'nowait', reach: 'sweep' } // reconcileReservationAccounting and leastLoadedCell are gone too: the first // repairs exactly two cells' counters and now holds only those rows, and the // second selects from the inventory its single caller has already locked. @@ -119,9 +118,10 @@ function storeCallGraph(lines: string[]): Map> { bounds.forEach((method, index) => { const end = bounds[index + 1]?.start ?? lines.length const names = callees.get(method.name) ?? new Set() - for (const call of lines.slice(method.start, end).join('\n').matchAll( - /this\.([A-Za-z_][\w]*)\s*\(/g - )) { + 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) @@ -174,9 +174,7 @@ function readCallSites(): { method: string; mode: CensusMode }[] { describe('cell inventory lock call-site census', () => { it('classifies every call site exactly as recorded', () => { - expect(readCallSites()).toEqual( - CENSUS.map(({ method, mode }) => ({ method, mode })) - ) + expect(readCallSites()).toEqual(CENSUS.map(({ method, mode }) => ({ method, mode }))) }) // Why: the census only sees lockCellInventory calls, so a hand-written diff --git a/cloud/apps/relay/src/config.test.ts b/cloud/apps/relay/src/config.test.ts index bb0522dcbd3..01661a61826 100644 --- a/cloud/apps/relay/src/config.test.ts +++ b/cloud/apps/relay/src/config.test.ts @@ -25,6 +25,17 @@ function cellEnvironment(capacity: number): NodeJS.ProcessEnv { } describe('GCE relay capacity configuration', () => { + it('defaults optional region correction off and bounds the cohort', () => { + const env = cellEnvironment(4_000) + expect(loadRelayConfig(env).regionCorrectionCohortPercent).toBe(0) + env.ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT = '5' + expect(loadRelayConfig(env).regionCorrectionCohortPercent).toBe(5) + for (const invalid of ['-1', '101', '1.5', 'not-a-number']) { + env.ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT = invalid + expect(() => loadRelayConfig(env)).toThrow() + } + }) + it('requires distinct dedicated admin identities and accepts omitted values', () => { const env = cellEnvironment(4_000) expect(loadRelayConfig(env)).toMatchObject({ diff --git a/cloud/apps/relay/src/config.ts b/cloud/apps/relay/src/config.ts index 2bf23444a71..83dc9708f74 100644 --- a/cloud/apps/relay/src/config.ts +++ b/cloud/apps/relay/src/config.ts @@ -75,11 +75,15 @@ const EnvSchema = z.object({ ORCA_RELAY_RUNTIME_SERVICE_ACCOUNT: z.string().email().optional(), ORCA_RELAY_DIRECTOR_URL: z.string().url().optional(), ORCA_RELAY_HEARTBEAT_AUDIENCE: z.string().url().optional(), - ORCA_RELAY_IMAGE_DIGEST: z.string().regex(/^sha256:[a-f0-9]{64}$/).optional(), + ORCA_RELAY_IMAGE_DIGEST: z + .string() + .regex(/^sha256:[a-f0-9]{64}$/) + .optional(), ORCA_RELAY_ADMIN_JWKS_URL: z.string().url().default('https://www.googleapis.com/oauth2/v3/certs'), ORCA_RELAY_DATABASE_POOL_MAX: z.coerce.number().int().positive().max(100).optional(), ORCA_RELAY_PUBLIC_ASSIGNMENTS_ENABLED: EnvironmentBooleanSchema, ORCA_RELAY_REGIONAL_PLACEMENT_ENABLED: EnvironmentBooleanSchema, + ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT: z.coerce.number().int().min(0).max(100).default(0), ORCA_RELAY_PUBLIC_ASSIGNMENT_CONCURRENCY: z.coerce.number().int().positive().max(100).default(2), ORCA_RELAY_PUBLIC_STICKY_CONCURRENCY: z.coerce.number().int().positive().max(100).default(1), ORCA_RELAY_PUBLIC_STICKY_QUEUE_MAX: z.coerce.number().int().positive().max(4_096).default(64), @@ -185,6 +189,7 @@ export type RelayConfig = { databasePoolMax: number publicAssignmentsEnabled: boolean regionalPlacementEnabled?: boolean + regionCorrectionCohortPercent?: number publicAssignmentConcurrency: number publicAssignmentQueueMax: number publicAssignmentWaitMs: number @@ -332,6 +337,7 @@ export function loadRelayConfig(env: NodeJS.ProcessEnv = process.env): RelayConf databasePoolMax, publicAssignmentsEnabled: parsed.ORCA_RELAY_PUBLIC_ASSIGNMENTS_ENABLED, regionalPlacementEnabled: parsed.ORCA_RELAY_REGIONAL_PLACEMENT_ENABLED, + regionCorrectionCohortPercent: parsed.ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT, publicAssignmentConcurrency: parsed.ORCA_RELAY_PUBLIC_ASSIGNMENT_CONCURRENCY, publicAssignmentQueueMax: parsed.ORCA_RELAY_PUBLIC_ASSIGNMENT_QUEUE_MAX, publicAssignmentWaitMs: parsed.ORCA_RELAY_PUBLIC_ASSIGNMENT_WAIT_MS, diff --git a/cloud/apps/relay/src/database.test.ts b/cloud/apps/relay/src/database.test.ts index 56122def4be..0c987f95d40 100644 --- a/cloud/apps/relay/src/database.test.ts +++ b/cloud/apps/relay/src/database.test.ts @@ -18,6 +18,34 @@ afterEach(() => { }) describe('relay database', () => { + it('upgrades an existing SQLite relay without treating legacy controls as idle-capable', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'orca-idle-schema-')) + temporaryDirectories.push(dataDir) + const legacy = await openRelayDatabase({ dataDir }) + await legacy.query('ALTER TABLE relay_control_capabilities DROP COLUMN idle_regional_rehome') + await legacy.query('ALTER TABLE relay_region_rehome_attempts DROP COLUMN source_generation') + await legacy.query( + `INSERT INTO relay_control_capabilities + (user_id, relay_host_id, activity_id, cell_id, cell_incarnation, assignment_epoch, generation, finish_existing) + VALUES ('legacy-user', 'abcdefghijklmnop', 'control:source:1', 'source', 'legacy-incarnation', 1, 1, 1)` + ) + await legacy.close() + const upgraded = await openRelayDatabase({ dataDir }) + try { + expect( + await upgraded.query('SELECT idle_regional_rehome FROM relay_control_capabilities') + ).toEqual([{ idle_regional_rehome: 0 }]) + const columns = await upgraded.query( + "SELECT * FROM pragma_table_info('relay_region_rehome_attempts')" + ) + expect(columns.find((column) => column.name === 'source_generation')).toMatchObject({ + dflt_value: '0' + }) + } finally { + await upgraded.close() + } + }) + it('creates every durable relay state table', async () => { const database = await openInMemoryRelayDatabase() const rows = await database.query( @@ -54,6 +82,7 @@ describe('relay database', () => { 'relay_confirm_results', 'relay_confirmable_splices', 'relay_connection_bases', + 'relay_control_capabilities', 'relay_control_connection_reservations', 'relay_devices', 'relay_direct_authorizations', @@ -62,6 +91,7 @@ describe('relay database', () => { 'relay_migration_leases', 'relay_post_drain_migration_pins', 'relay_rate_windows', + 'relay_region_decisions', 'relay_region_rehome_attempts', 'relay_region_rehome_control', 'relay_region_rehome_worker_state' @@ -140,9 +170,7 @@ describe('relay database', () => { const second = await openRelayDatabase({ dataDir }) expect( - await second.query(`SELECT region FROM relay_cell_regions WHERE cell_id = ?`, [ - 'legacy-cell' - ]) + await second.query(`SELECT region FROM relay_cell_regions WHERE cell_id = ?`, ['legacy-cell']) ).toEqual([{ region: 'us-central1' }]) await second.close() }) @@ -165,9 +193,7 @@ describe('relay database', () => { 'relay_region_rehome_attempts' ]) expect(checked.every((row) => String(row.sql).includes(list))).toBe(true) - expect( - POSTGRES_SCHEMA_MIGRATIONS.some((statement) => statement.includes(list)) - ).toBe(true) + expect(POSTGRES_SCHEMA_MIGRATIONS.some((statement) => statement.includes(list))).toBe(true) await database.close() }) diff --git a/cloud/apps/relay/src/database.ts b/cloud/apps/relay/src/database.ts index d51f4e7a423..41ead67ea60 100644 --- a/cloud/apps/relay/src/database.ts +++ b/cloud/apps/relay/src/database.ts @@ -197,6 +197,24 @@ CREATE TABLE IF NOT EXISTS relay_assignment_region_preferences ( CREATE INDEX IF NOT EXISTS relay_assignment_region_preferences_observed ON relay_assignment_region_preferences(observed_at); +CREATE TABLE IF NOT EXISTS relay_region_decisions ( + user_id TEXT NOT NULL, relay_host_id TEXT NOT NULL, + generation BIGINT NOT NULL, expires_at BIGINT NOT NULL, + assignment_epoch BIGINT NOT NULL, incumbent_region TEXT NOT NULL, + policy_version BIGINT NOT NULL, outcome TEXT NOT NULL, + cohort_bucket BIGINT NOT NULL DEFAULT 0, + last_considered_at BIGINT NOT NULL DEFAULT 0, + preferred_region TEXT, observed_at BIGINT NOT NULL, report_json TEXT, + PRIMARY KEY (user_id, relay_host_id) +); +CREATE TABLE IF NOT EXISTS relay_control_capabilities ( + user_id TEXT NOT NULL, relay_host_id TEXT NOT NULL, activity_id TEXT NOT NULL, + cell_id TEXT NOT NULL, cell_incarnation TEXT NOT NULL, + assignment_epoch BIGINT NOT NULL, generation BIGINT NOT NULL, + finish_existing BIGINT NOT NULL, + idle_regional_rehome BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, relay_host_id, activity_id) +); CREATE TABLE IF NOT EXISTS relay_region_rehome_worker_state ( worker_id TEXT PRIMARY KEY, next_dispatch_at BIGINT NOT NULL, @@ -228,6 +246,7 @@ CREATE TABLE IF NOT EXISTS relay_region_rehome_attempts ( CHECK (preferred_region IN (${REGION_LIST})), source_cell_id TEXT NOT NULL, source_cell_incarnation TEXT NOT NULL, + source_generation BIGINT NOT NULL DEFAULT 0, target_cell_id TEXT NOT NULL, target_cell_incarnation TEXT NOT NULL, previous_epoch BIGINT NOT NULL, @@ -600,6 +619,8 @@ CREATE INDEX IF NOT EXISTS relay_audit_events_at ON relay_audit_events(at); // auto-named; the replacement is named, so both statements are no-ops on a // database the current schema created and neither can drop the other. export const POSTGRES_SCHEMA_MIGRATIONS = [ + `ALTER TABLE relay_region_decisions ADD COLUMN IF NOT EXISTS last_considered_at BIGINT NOT NULL DEFAULT 0`, + `ALTER TABLE relay_region_decisions ADD COLUMN IF NOT EXISTS cohort_bucket BIGINT NOT NULL DEFAULT 0`, `ALTER TABLE relay_region_rehome_attempts DROP CONSTRAINT IF EXISTS relay_region_rehome_attempts_preferred_region_check`, `ALTER TABLE relay_region_rehome_attempts @@ -607,7 +628,9 @@ export const POSTGRES_SCHEMA_MIGRATIONS = [ CHECK (preferred_region IN (${REGION_LIST}))`, `ALTER TABLE relay_region_rehome_control ADD COLUMN IF NOT EXISTS host_cooldown_ms BIGINT NOT NULL - DEFAULT ${REGIONAL_REHOME_DEFAULT_HOST_COOLDOWN_MS}` + DEFAULT ${REGIONAL_REHOME_DEFAULT_HOST_COOLDOWN_MS}`, + `ALTER TABLE relay_control_capabilities ADD COLUMN IF NOT EXISTS idle_regional_rehome BIGINT NOT NULL DEFAULT 0`, + `ALTER TABLE relay_region_rehome_attempts ADD COLUMN IF NOT EXISTS source_generation BIGINT NOT NULL DEFAULT 0` ] function postgresSql(sql: string): string { @@ -1013,6 +1036,15 @@ async function applySchema(database: RelayDatabase): Promise { for (const statement of SCHEMA.split(';')) { if (statement.trim()) await database.query(statement) } + for (const [table, column] of [ + ['relay_control_capabilities', 'idle_regional_rehome'], + ['relay_region_rehome_attempts', 'source_generation'] + ]) { + const columns = await database.query('SELECT name FROM pragma_table_info(?)', [table]) + if (!columns.some((existing) => existing.name === column)) { + await database.query(`ALTER TABLE ${table} ADD COLUMN ${column} BIGINT NOT NULL DEFAULT 0`) + } + } } // Why: DDL is not a request. A CREATE INDEX on a grown table legitimately runs diff --git a/cloud/apps/relay/src/host-session-client-accept.test.ts b/cloud/apps/relay/src/host-session-client-accept.test.ts index 0cec6531e3f..04f86c533e4 100644 --- a/cloud/apps/relay/src/host-session-client-accept.test.ts +++ b/cloud/apps/relay/src/host-session-client-accept.test.ts @@ -155,6 +155,66 @@ describe('client accept abandoned mid-DB-phase', () => { vi.useRealTimers() }) + it('does not admit new source work after a drain crosses activity acquisition', async () => { + const h = harness() + const control = await activeHost(h) + const slow = deferred() + h.acquireActivity.mockReturnValueOnce(slow.promise) + const client = new FakeSocket() + const capacity = { bind: vi.fn(), release: vi.fn() } + const accepting = h.registry.acceptClient( + client as unknown as WebSocket, + identity.relayHostId, + 'credential', + capacity + ) + await vi.advanceTimersByTimeAsync(0) + h.registry.drainHost({ + attemptId: 'attempt', + userId: identity.sub, + relayHostId: identity.relayHostId, + sourceAssignmentEpoch: 1, + graceMs: 60_000 + }) + slow.resolve() + await accepting + expect(control.send).not.toHaveBeenCalledWith(expect.stringContaining('conn-open')) + expect(capacity.bind).not.toHaveBeenCalled() + expect(client.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.WRONG_CELL, expect.any(String)) + expect(h.releaseActivity).toHaveBeenCalled() + }) + + it('does not splice an attachment whose generation retired during basis persistence', async () => { + const h = harness() + await activeHost(h) + const client = new FakeSocket() + await h.registry.acceptClient( + client as unknown as WebSocket, + identity.relayHostId, + 'credential' + ) + const session = h.registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })! + const pending = [...session.pendingConns.values()][0]! + const slow = deferred() + h.store.recordConnectionBasis.mockReturnValueOnce(slow.promise) + const host = new FakeSocket() + const attaching = h.registry.acceptHostData( + host as unknown as WebSocket, + pending.connId, + pending.connTicket, + 1 + ) + await vi.advanceTimersByTimeAsync(0) + h.registry.drain(0) + await vi.advanceTimersByTimeAsync(0) + slow.resolve() + expect(await attaching).toBe(false) + expect(session.activeSplices.size).toBe(0) + expect(h.store.deactivateBasis).toHaveBeenCalledWith(pending.connId) + expect(client.send).not.toHaveBeenCalledWith(expect.stringContaining('\"ok\":true')) + expect(host.close).toHaveBeenCalled() + }) + it('stops after a slow activity acquire when the phone already hung up', async () => { const h = harness() const control = await activeHost(h) @@ -390,6 +450,7 @@ describe('successful client accept timing', () => { relayHostIdDigest: string } expect(event.credentialKind).toBe('resume') + expect(event).toMatchObject({ assignmentEpoch: 1, controlGeneration: 1, drainMode: 'none' }) // Joins the line back to the emitting process, like the runtime metrics event. expect(event).toMatchObject({ role: 'cell', cellId: config.cellId, region: 'us-central1' }) expect(Object.keys(event.stageMs).sort()).toEqual([ @@ -460,6 +521,9 @@ describe('control round-trip sampling', () => { cellId: config.cellId, region: 'us-central1', rttMsMedian: 40, + assignmentEpoch: 1, + controlGeneration: 1, + drainMode: 'none', sampleCount: 4 }) expect(rttLines()[0]).not.toContain(identity.relayHostId) diff --git a/cloud/apps/relay/src/host-session-registry.test.ts b/cloud/apps/relay/src/host-session-registry.test.ts index 920faa6f4b8..dcfcd3f36c5 100644 --- a/cloud/apps/relay/src/host-session-registry.test.ts +++ b/cloud/apps/relay/src/host-session-registry.test.ts @@ -4,6 +4,7 @@ import { CONTROL_CONTINUITY_LIMITS, RELAY_CLOSE_CODE, RELAY_HOST_CAPABILITY_PENDING_CONN_DETAILS, + RELAY_HOST_CAPABILITY_IDLE_REGIONAL_REHOME, RELAY_PROTOCOL_LIMITS } from '@orca-cloud/relay-contract' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -140,7 +141,10 @@ function createRegistry( store as RelayCredentialStore, assignments, new ProcessQueuedByteBudget(), - observer + observer, + Date.now, + Math.random, + 'incarnation-1' ) // Mirrors the production signature exactly so a future positional shift fails to compile. const bound = ( @@ -166,7 +170,14 @@ function createRegistry( assignmentEpoch, appVersion = '1.4.173' ) => bound(socket, identity, existing, generation, rebind, assignmentEpoch, appVersion) - return { registry, activate, acquireActivity, renewControlActivity, releaseActivity, observer } + return { + registry, + activate, + acquireActivity, + renewControlActivity, + releaseActivity, + observer + } } describe('host session cleanup races', () => { @@ -398,26 +409,20 @@ describe('host session cleanup races', () => { attemptId: '22222222-2222-4222-8222-222222222222' }) ).toThrow('regional_rehome_attempt_conflict') - expect(() => - registry.drainHost({ ...request, sourceAssignmentEpoch: 8 }) - ).toThrow('regional_rehome_assignment_epoch_mismatch') + expect(() => registry.drainHost({ ...request, sourceAssignmentEpoch: 8 })).toThrow( + 'regional_rehome_assignment_epoch_mismatch' + ) const rebound = new FakeSocket() - await activate( - rebound as unknown as WebSocket, - identity, - registry.get(request), - 1, - true, - 7 - ) + await activate(rebound as unknown as WebSocket, identity, registry.get(request), 1, true, 7) expect(registry.get(request)?.state).toBe('drain-only') expect(rebound.send).toHaveBeenCalledWith(expect.stringContaining('"type":"drain"')) await vi.advanceTimersByTimeAsync(30_000) expect(registry.get(request)).toBeNull() - expect(registry.get({ userId: secondIdentity.sub, relayHostId: secondIdentity.relayHostId })) - .not.toBeNull() + expect( + registry.get({ userId: secondIdentity.sub, relayHostId: secondIdentity.relayHostId }) + ).not.toBeNull() expect(secondSocket.close).not.toHaveBeenCalled() }) @@ -513,14 +518,7 @@ describe('host session cleanup races', () => { expect(original).not.toBeNull() const rebindSocket = new FakeSocket() - const rebinding = activate( - rebindSocket as unknown as WebSocket, - identity, - original, - 1, - true, - 1 - ) + const rebinding = activate(rebindSocket as unknown as WebSocket, identity, original, 1, true, 1) rebindSocket.close() blocked.resolve('control:production-gce-c3:1') await rebinding @@ -659,14 +657,7 @@ describe('host session cleanup races', () => { originalSocket.close() const replacementSocket = new FakeSocket() - await activate( - replacementSocket as unknown as WebSocket, - identity, - original, - 2, - false, - 1 - ) + await activate(replacementSocket as unknown as WebSocket, identity, original, 2, false, 1) const replacement = registry.get({ userId: identity.sub, relayHostId: identity.relayHostId @@ -696,14 +687,7 @@ describe('host session cleanup races', () => { }) expect(original).not.toBeNull() - await activate( - new FakeSocket() as unknown as WebSocket, - identity, - original, - 2, - false, - 1 - ) + await activate(new FakeSocket() as unknown as WebSocket, identity, original, 2, false, 1) vi.advanceTimersByTime(15_000) expect(renewControlActivity).toHaveBeenCalledOnce() @@ -718,6 +702,53 @@ describe('host session cleanup races', () => { vi.advanceTimersByTime(0) }) + it('ignores a denial belonging to the socket before a same-generation rebind', async () => { + const h = createRegistry(vi.fn().mockResolvedValue('control:production-gce-c3:1')) + const oldSocket = new FakeSocket() + await h.activate(oldSocket as unknown as WebSocket, identity, null, 1, false, 1) + const session = h.registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })! + let reject!: (error: Error) => void + h.renewControlActivity.mockReturnValueOnce( + new Promise((_, fail) => { + reject = fail + }) + ) + await vi.advanceTimersByTimeAsync(15_000) + const replacement = new FakeSocket() + await h.activate(replacement as unknown as WebSocket, identity, session, 1, true, 1) + reject(new Error('activity_cell_not_authoritative')) + await vi.advanceTimersByTimeAsync(0) + expect(replacement.close).not.toHaveBeenCalled() + expect(session.socket).toBe(replacement) + expect(session.generation).toBe(1) + }) + + it('ignores missing-activity recovery denial after an authority transition', async () => { + const h = createRegistry(vi.fn().mockResolvedValue('control:production-gce-c3:1')) + const socket = new FakeSocket() + await h.activate(socket as unknown as WebSocket, identity, null, 1, false, 1) + const session = h.registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })! + h.renewControlActivity.mockRejectedValueOnce(new Error('control_activity_not_found')) + let reject!: (error: Error) => void + h.acquireActivity.mockReturnValueOnce( + new Promise((_, fail) => { + reject = fail + }) + ) + await vi.advanceTimersByTimeAsync(15_000) + h.registry.drainHost({ + attemptId: 'attempt', + userId: identity.sub, + relayHostId: identity.relayHostId, + sourceAssignmentEpoch: 1, + graceMs: 60_000 + }) + reject(new Error('activity_cell_not_authoritative')) + await vi.advanceTimersByTimeAsync(0) + expect(socket.close).not.toHaveBeenCalled() + expect(session.state).toBe('drain-only') + }) + it('keeps 15s pings while halving steady-state control renewals', async () => { const activateControl = vi .fn() @@ -732,9 +763,7 @@ describe('host session cleanup races', () => { socket.emit('message', Buffer.from(JSON.stringify({ type: 'pong' })), false) } - const pings = socket.send.mock.calls.filter((call) => - String(call[0]).includes('"ping"') - ) + const pings = socket.send.mock.calls.filter((call) => String(call[0]).includes('"ping"')) expect(pings).toHaveLength(4) expect(renewControlActivity).toHaveBeenCalledTimes(2) const firstExpiry = Number(renewControlActivity.mock.calls[0]![1].expiresAt) @@ -1118,3 +1147,277 @@ describe('host hello ack pending connections', () => { expect(rebound.pendingConns).toEqual([DETAILED_ENTRY]) }) }) + +describe('source-owned idle cutover', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + }) + const request = { + attemptId: 'idle-1', + userId: identity.sub, + relayHostId: identity.relayHostId, + sourceAssignmentEpoch: 1, + sourceGeneration: 1, + sourceCellIncarnation: 'incarnation-1', + targetCellId: 'target' + } + async function source(store: Partial = {}) { + const h = createRegistry(vi.fn().mockResolvedValue('control:1'), store) + const socket = new FakeSocket() + h.registry.acceptControl( + socket as unknown as WebSocket, + identity, + undefined, + new Set([RELAY_HOST_CAPABILITY_IDLE_REGIONAL_REHOME]) + ) + socket.removeAllListeners('message') + await h.activate(socket as unknown as WebSocket, identity, null, 1, false, 1) + return { ...h, socket, session: h.registry.get(request)! } + } + it('keeps either established client busy until both actually leave', async () => { + const h = await source() + h.session.activeConnIds.add('phone') + h.session.activeConnIds.add('ipad') + const commit = vi.fn().mockResolvedValue({ outcome: 'committed' }) + h.session.activeConnIds.delete('ipad') + expect( + await h.registry.idleRehome(request, commit, vi.fn().mockResolvedValue('not-committed')) + ).toEqual({ outcome: 'busy' }) + expect(commit).not.toHaveBeenCalled() + h.session.activeConnIds.delete('phone') + expect( + await h.registry.idleRehome(request, commit, vi.fn().mockResolvedValue('not-committed')) + ).toEqual({ outcome: 'committed' }) + expect(h.socket.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.DRAINING, expect.any(String)) + expect(h.releaseActivity).toHaveBeenCalled() + }) + it.each([ + { userId: 'other-user' }, + { sourceAssignmentEpoch: 2 }, + { sourceGeneration: 2 }, + { sourceCellIncarnation: 'other-incarnation' }, + { targetCellId: 'other-target' } + ])('rejects a reused operation ID with changed authority %j', async (change) => { + const h = await source() + const result = deferred<{ outcome: 'deferred' }>() + const commit = vi.fn().mockReturnValue(result.promise) + const reconcile = vi.fn().mockResolvedValue('not-committed') + const moving = h.registry.idleRehome(request, commit, reconcile) + const conflicting = h.registry.idleRehome({ ...request, ...change }, commit, reconcile) + result.resolve({ outcome: 'deferred' }) + expect(await conflicting).toEqual({ outcome: 'stale' }) + expect(await moving).toEqual({ outcome: 'deferred' }) + expect(commit).toHaveBeenCalledOnce() + expect(h.socket.close).not.toHaveBeenCalled() + }) + it('accounts for accepts before credential identity resolves', async () => { + const lookup = deferred() + const h = await source({ + resolveResume: vi.fn().mockReturnValue(lookup.promise), + resolveInviteForMove: vi.fn().mockResolvedValue(null) + }) + const client = new FakeSocket() + const accept = h.registry.acceptClient( + client as unknown as WebSocket, + identity.relayHostId, + 'credential' + ) + expect( + await h.registry.idleRehome(request, vi.fn(), vi.fn().mockResolvedValue('not-committed')) + ).toEqual({ outcome: 'busy' }) + lookup.resolve(null) + await accept + expect(h.socket.close).not.toHaveBeenCalled() + }) + it('rejects new accepts and replacements synchronously while a commit awaits', async () => { + const h = await source() + const result = deferred<{ outcome: 'deferred' }>() + const commit = vi.fn().mockReturnValue(result.promise) + const moving = h.registry.idleRehome( + request, + commit, + vi.fn().mockResolvedValue('not-committed') + ) + const duplicate = h.registry.idleRehome( + request, + commit, + vi.fn().mockResolvedValue('not-committed') + ) + const client = new FakeSocket() + const release = vi.fn() + await h.registry.acceptClient( + client as unknown as WebSocket, + identity.relayHostId, + 'credential', + { release } as never + ) + expect(client.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.WRONG_CELL, expect.any(String)) + expect(release).toHaveBeenCalledOnce() + const replacement = new FakeSocket() + await h.activate(replacement as unknown as WebSocket, identity, h.session, 2, false, 1) + expect(replacement.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.WRONG_CELL, expect.any(String)) + result.resolve({ outcome: 'deferred' }) + await moving + await duplicate + expect(commit).toHaveBeenCalledOnce() + expect(h.socket.close).not.toHaveBeenCalled() + expect( + await h.registry.idleRehome( + { ...request, attemptId: 'next' }, + vi.fn().mockResolvedValue({ outcome: 'committed' }), + vi.fn().mockResolvedValue('not-committed') + ) + ).toEqual({ outcome: 'committed' }) + }) + it.each(['ambiguous', 'deferred'])( + 'keeps %s outcomes fenced until locked reconciliation succeeds', + async (claim) => { + const h = await source() + const reconcile = vi + .fn() + .mockRejectedValueOnce(new Error('database unavailable')) + .mockRejectedValueOnce(new Error('database unavailable')) + .mockResolvedValue('not-committed') + const moving = h.registry.idleRehome( + request, + claim === 'ambiguous' + ? vi.fn().mockRejectedValue(new Error('lost commit reply')) + : vi.fn().mockResolvedValue({ outcome: 'deferred' }), + reconcile + ) + await vi.advanceTimersByTimeAsync(50) + expect( + await h.registry.idleRehome( + { ...request, attemptId: 'other' }, + vi.fn(), + vi.fn().mockResolvedValue('not-committed') + ) + ).toEqual({ outcome: 'busy' }) + expect(h.socket.close).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(300) + expect(await moving).toEqual({ outcome: 'deferred' }) + expect(reconcile).toHaveBeenCalledTimes(3) + expect(h.socket.close).not.toHaveBeenCalled() + } + ) + it('owns accepted control mutations before the handler first awaits', async () => { + const mutation = deferred() + const h = await source() + ;(h.registry as unknown as { verifyRelayToken: unknown }).verifyRelayToken = vi + .fn() + .mockReturnValue(mutation.promise) + h.socket.emit( + 'message', + Buffer.from(JSON.stringify({ type: 'auth-refresh', relayJwt: 'token' })), + false + ) + const commit = vi.fn().mockResolvedValue({ outcome: 'deferred' }) + expect( + await h.registry.idleRehome(request, commit, vi.fn().mockResolvedValue('not-committed')) + ).toEqual({ outcome: 'busy' }) + mutation.resolve(identity) + await vi.advanceTimersByTimeAsync(0) + expect( + await h.registry.idleRehome(request, commit, vi.fn().mockResolvedValue('not-committed')) + ).toEqual({ outcome: 'deferred' }) + }) + it('owns queued replacement activation before its first persistence await', async () => { + const h = await source() + const activation = deferred() + const assignments = (h.registry as unknown as { assignments: { activateControl: unknown } }) + .assignments + assignments.activateControl = vi.fn().mockReturnValue(activation.promise) + const replacement = new FakeSocket() + const activating = h.activate( + replacement as unknown as WebSocket, + identity, + h.session, + 2, + false, + 1 + ) + expect( + await h.registry.idleRehome(request, vi.fn(), vi.fn().mockResolvedValue('not-committed')) + ).toEqual({ outcome: 'busy' }) + activation.resolve('control:2') + await activating + }) + it('retires changed authority even when the claim definitively deferred', async () => { + const h = await source() + expect( + await h.registry.idleRehome( + request, + vi.fn().mockResolvedValue({ outcome: 'deferred' }), + vi.fn().mockResolvedValue('stale') + ) + ).toEqual({ outcome: 'stale' }) + expect(h.session.state).toBe('closed') + expect(h.releaseActivity).toHaveBeenCalled() + }) + it('holds attach ownership through basis failure reservation cleanup', async () => { + const basis = deferred() + const cleanup = deferred() + const h = await source({ + recordConnectionBasis: vi.fn().mockImplementation(async () => { + await basis.promise + throw new Error('basis failed') + }), + failReservation: vi.fn().mockReturnValue(cleanup.promise) + }) + const client = new FakeSocket() + h.session.pendingConns.set('conn', { + connId: 'conn', + connTicket: 'ticket', + client: client as unknown as WebSocket, + reservation: { + userId: identity.sub, + relayHostId: identity.relayHostId, + credentialKind: 'invite', + leaseExpiresAt: Date.now() + 1000 + }, + attachTimer: setTimeout(() => {}, 1000), + credentialActivityId: null + } as never) + const attached = h.registry.acceptHostData( + new FakeSocket() as unknown as WebSocket, + 'conn', + 'ticket', + 1 + ) + const commit = vi.fn().mockResolvedValue({ outcome: 'deferred' }) + expect(await h.registry.idleRehome(request, commit, vi.fn())).toEqual({ outcome: 'busy' }) + basis.resolve() + await vi.advanceTimersByTimeAsync(0) + expect(h.session.activeConnIds.size).toBe(0) + expect(await h.registry.idleRehome(request, commit, vi.fn())).toEqual({ outcome: 'busy' }) + expect(commit).not.toHaveBeenCalled() + cleanup.resolve() + await attached + }) + it('returns the durable operation outcome after source retirement', async () => { + const h = await source() + const commit = vi.fn().mockResolvedValue({ outcome: 'committed' }) + await h.registry.idleRehome(request, commit, vi.fn()) + expect( + await h.registry.idleRehome(request, commit, vi.fn().mockResolvedValue('committed')) + ).toEqual({ outcome: 'committed' }) + expect(commit).toHaveBeenCalledOnce() + }) + it('does not reopen a source overtaken by emergency drain', async () => { + const h = await source() + const result = deferred<{ outcome: 'deferred' }>() + const moving = h.registry.idleRehome( + request, + () => result.promise, + vi.fn().mockResolvedValue('not-committed') + ) + h.registry.drain(0) + await vi.advanceTimersByTimeAsync(0) + result.resolve({ outcome: 'deferred' }) + await moving + expect(h.session.state).toBe('closed') + expect(h.registry.get(request)).toBeNull() + }) +}) diff --git a/cloud/apps/relay/src/host-session-registry.ts b/cloud/apps/relay/src/host-session-registry.ts index 3b4e616a692..61a1b706fa9 100644 --- a/cloud/apps/relay/src/host-session-registry.ts +++ b/cloud/apps/relay/src/host-session-registry.ts @@ -15,6 +15,7 @@ import { HostHelloSchema, InviteCreateSchema, RELAY_HOST_CAPABILITY_PENDING_CONN_DETAILS, + RELAY_HOST_CAPABILITY_IDLE_REGIONAL_REHOME, RELAY_PROTOCOL_LIMITS, RELAY_CLOSE_CODE, type RelayHostCloseReason, @@ -25,10 +26,7 @@ import type WebSocket from 'ws' import type { RawData } from 'ws' import type { RelayConfig } from './config.js' import type { RelayAssignmentStore } from './assignment-store.js' -import { - RelayCredentialStore, - type CredentialReservation -} from './credential-store.js' +import { RelayCredentialStore, type CredentialReservation } from './credential-store.js' import { HostCloseReasonMemory } from './host-close-reason-memory.js' import { relayHostLogDigest } from './relay-host-log-digest.js' import type { RelayTokenClaims } from './relay-token-verifier.js' @@ -78,7 +76,7 @@ export type HostSession = { identity: RelayTokenClaims readonly relayHostId: string readonly generation: number - readonly assignmentEpoch: number + assignmentEpoch: number readonly controlActivityId: string | null readonly controlResumeSecret: string // Why: reconnect churn is only actionable once it can be pinned to a client build. @@ -94,6 +92,7 @@ export type HostSession = { pendingPingAt: number | null controlRttSamplesMs: number[] controlRttLoggedAt: number | null + authorityRevision: number activityRenewalDueAt: number activityRenewalAttempt: number activityRenewalCompletedAttempt: number @@ -108,10 +107,7 @@ export type HostSession = { regionalDrainExpiresAt: number | null } -export type RegionalHostDrainOutcome = - | 'accepted' - | 'already-accepted' - | 'host-not-connected' +export type RegionalHostDrainOutcome = 'accepted' | 'already-accepted' | 'host-not-connected' type PendingConnection = { connId: string @@ -184,6 +180,113 @@ export class HostSessionRegistry { private readonly hostCapabilities = new WeakMap>() private draining = false + private readonly idleWork = new Map() + private readonly idleAttempts = new Map< + string, + { + attemptId: string + authorityKey: string + promise: Promise<{ outcome: 'committed' | 'deferred' | 'stale' }> + } + >() + + async idleRehome( + input: { + attemptId: string + userId: string + relayHostId: string + sourceAssignmentEpoch: number + sourceGeneration: number + sourceCellIncarnation: string + targetCellId: string + }, + commit: () => Promise<{ outcome: 'committed' | 'deferred' | 'stale' }>, + reconcile: () => Promise<'committed' | 'not-committed' | 'stale'> + ): Promise<{ outcome: 'busy' | 'committed' | 'deferred' | 'stale' }> { + const authorityKey = JSON.stringify([ + input.userId, + input.sourceAssignmentEpoch, + input.sourceGeneration, + input.sourceCellIncarnation, + input.targetCellId + ]) + const prior = this.idleAttempts.get(input.relayHostId) + if (prior) { + if (prior.attemptId !== input.attemptId) return { outcome: 'busy' } + return prior.authorityKey === authorityKey ? prior.promise : { outcome: 'stale' } + } + const session = this.get(input) + if ( + this.draining || + !session || + session.state !== 'active' || + session.generation !== input.sourceGeneration || + session.assignmentEpoch !== input.sourceAssignmentEpoch || + this.cellIncarnation !== input.sourceCellIncarnation + ) { + const durable = await reconcile() + return { outcome: durable === 'committed' ? 'committed' : 'stale' } + } + if ( + !session.socket || + !this.hostCapabilities.get(session.socket)?.has(RELAY_HOST_CAPABILITY_IDLE_REGIONAL_REHOME) + ) + return { outcome: 'deferred' } + if ( + (this.idleWork.get(input.relayHostId) ?? 0) !== 0 || + session.activeConnIds.size !== 0 || + session.activeSplices.size !== 0 || + session.pendingConns.size !== 0 + ) + return { outcome: 'busy' } + const revision = session.authorityRevision + const promise = Promise.resolve().then(async () => { + let outcome: 'committed' | 'deferred' | 'stale' + try { + outcome = (await commit()).outcome + if (outcome === 'deferred') { + const durable = await reconcile() + outcome = durable === 'not-committed' ? 'deferred' : durable + } + } catch { + let delay = 100 + for (;;) { + try { + const durable = await reconcile() + outcome = durable === 'not-committed' ? 'deferred' : durable + break + } catch { + await new Promise((resolve) => { + const timer = setTimeout(resolve, delay) + timer.unref?.() + }) + delay = Math.min(delay * 2, 5000) + } + } + } + if (this.get(input) === session) { + if (outcome !== 'deferred' || this.draining || session.authorityRevision !== revision) { + this.closeDrainedSession(session) + } + } + if (this.idleAttempts.get(input.relayHostId)?.promise === promise) + this.idleAttempts.delete(input.relayHostId) + return { outcome } + }) + this.idleAttempts.set(input.relayHostId, { attemptId: input.attemptId, authorityKey, promise }) + return promise + } + + private beginIdleWork(hostId: string): (() => void) | null { + if (this.idleAttempts.has(hostId)) return null + this.idleWork.set(hostId, (this.idleWork.get(hostId) ?? 0) + 1) + return () => { + const remaining = (this.idleWork.get(hostId) ?? 1) - 1 + if (remaining === 0) this.idleWork.delete(hostId) + else this.idleWork.set(hostId, remaining) + } + } + constructor( private readonly config: RelayConfig, private readonly verifyRelayToken: VerifyRelayToken, @@ -192,7 +295,8 @@ export class HostSessionRegistry { private readonly queuedByteBudget: ProcessQueuedByteBudget, private readonly observer: RelayRuntimeObserver, private readonly now: () => number = Date.now, - private readonly random: () => number = Math.random + private readonly random: () => number = Math.random, + private readonly cellIncarnation?: string ) {} // Uniform over [CONTROL_LEASE_MS - jitter, CONTROL_LEASE_MS + jitter). @@ -206,6 +310,25 @@ export class HostSessionRegistry { hostId: string, credential: string, capacityReservation?: PendingHostDataReservation + ): Promise { + const release = this.beginIdleWork(hostId) + if (!release) { + capacityReservation?.release() + this.rejectClient(socket, RELAY_CLOSE_CODE.WRONG_CELL) + return + } + try { + await this.acceptClientUnfenced(socket, hostId, credential, capacityReservation) + } finally { + release() + } + } + + private async acceptClientUnfenced( + socket: WebSocket, + hostId: string, + credential: string, + capacityReservation?: PendingHostDataReservation ): Promise { if (this.draining) { capacityReservation?.release() @@ -295,6 +418,7 @@ export class HostSessionRegistry { this.rejectClient(socket, RELAY_CLOSE_CODE.LIMIT_EXCEEDED) return } + const admittingSocket = session.socket const connId = randomUUID() const connTicket = randomBytes(32).toString('base64url') const identity = { userId: reservation.userId, relayHostId: hostId } @@ -324,6 +448,20 @@ export class HostSessionRegistry { ) { return } + // Admission may have crossed a drain or control replacement while persisting activity. + if ( + this.draining || + this.sessions.get(sessionKey) !== session || + session.state !== 'active' || + session.socket !== admittingSocket || + admittingSocket.readyState !== admittingSocket.OPEN + ) { + capacityReservation?.release() + this.failReservationBestEffort(reservation) + if (credentialActivityId) this.releaseActivityBestEffort(identity, credentialActivityId) + this.rejectClient(socket, RELAY_CLOSE_CODE.WRONG_CELL) + return + } markStage('activity') const attachTimer = setTimeout(() => { session.pendingConns.delete(connId) @@ -372,6 +510,27 @@ export class HostSessionRegistry { connId: string, connTicket: string, generation: number + ): Promise { + const owner = [...this.sessions.values()].find((candidate) => + candidate.pendingConns.has(connId) + ) + const release = owner ? this.beginIdleWork(owner.relayHostId) : () => {} + if (!release) { + socket.close(RELAY_CLOSE_CODE.WRONG_CELL, 'idle cutover in progress') + return false + } + try { + return await this.acceptHostDataUnfenced(socket, connId, connTicket, generation) + } finally { + release() + } + } + + private async acceptHostDataUnfenced( + socket: WebSocket, + connId: string, + connTicket: string, + generation: number ): Promise { const session = [...this.sessions.values()].find((candidate) => candidate.pendingConns.has(connId) @@ -427,6 +586,27 @@ export class HostSessionRegistry { socket.close(RELAY_CLOSE_CODE.LIMIT_EXCEEDED, 'basis persistence failed') return false } + // Already admitted attachments may finish a regional drain, but never a retired generation. + if ( + this.draining || + this.sessions.get(this.key(identity.userId, identity.relayHostId)) !== session || + this.get(identity)?.state === 'closed' || + !session.activeConnIds.has(connId) || + socket.readyState !== socket.OPEN || + pending.client.readyState !== pending.client.OPEN + ) { + session.activeConnIds.delete(connId) + pending.capacityReservation?.release() + this.deactivateBasisBestEffort(connId) + this.failReservationBestEffort(pending.reservation) + if (spliceActivityId) this.releaseActivityBestEffort(identity, spliceActivityId) + if (pending.credentialActivityId) { + this.releaseActivityBestEffort(identity, pending.credentialActivityId) + } + this.rejectClient(pending.client, RELAY_CLOSE_CODE.DRAINING) + socket.close(RELAY_CLOSE_CODE.DRAINING, 'host retired during attachment') + return false + } const close = wireSplice({ client: pending.client, host: socket, @@ -505,6 +685,7 @@ export class HostSessionRegistry { JSON.stringify({ event: 'orca_relay_client_accept_completed', ...this.logIdentity(), + ...this.sessionPlacementLogFields(session), credentialKind: pending.reservation.credentialKind, stageMs, totalMs, @@ -513,6 +694,14 @@ export class HostSessionRegistry { ) } + private sessionPlacementLogFields(session: HostSession) { + return { + assignmentEpoch: session.assignmentEpoch, + controlGeneration: session.generation, + drainMode: session.regionalDrainAttemptId ? 'deadline' : 'none' + } + } + // Matches the runtime metrics event so a log line and a metric point can be // joined back to the process that emitted them. private logIdentity(): { role: string; cellId: string; region: RelayRegion } { @@ -549,6 +738,7 @@ export class HostSessionRegistry { JSON.stringify({ event: 'orca_relay_host_control_rtt', ...this.logIdentity(), + ...this.sessionPlacementLogFields(session), relayHostIdDigest: relayHostLogDigest(session.relayHostId), rttMsMedian: percentile(samples, 0.5), sampleCount: samples.length @@ -562,6 +752,10 @@ export class HostSessionRegistry { connectionInclusionWatermark?: number, hostCapabilities?: ReadonlySet ): void { + if (this.idleAttempts.has(identity.relayHostId)) { + socket.close(RELAY_CLOSE_CODE.WRONG_CELL, 'idle cutover in progress') + return + } // Keyed by socket, not session: a rebind swaps the session's socket, and the // successor's own advertisement is the only one that describes its decoder. if (hostCapabilities?.size) this.hostCapabilities.set(socket, hostCapabilities) @@ -602,8 +796,7 @@ export class HostSessionRegistry { socket: WebSocket | null, context: string ): void { - void Promise.resolve() - .then(task) + void (async () => task())() .catch((error: unknown) => { const message = (error instanceof Error ? error.message : 'unknown') // Untruncated, unlike peer-supplied close reasons: this is the @@ -653,6 +846,7 @@ export class HostSessionRegistry { this.draining = true for (const session of this.sessions.values()) { if (session.state === 'closed') continue + session.authorityRevision += 1 session.state = 'drain-only' if (session.socket) send(session.socket, 'drain', { graceMs, recovery: 'resolve-director' }) setTimeout(() => this.closeDrainedSession(session), graceMs) @@ -665,7 +859,8 @@ export class HostSessionRegistry { relayHostId: string sourceAssignmentEpoch: number graceMs: number - }): RegionalHostDrainOutcome { + sourceCellIncarnation?: string + }): RegionalHostDrainOutcome | Promise { const session = this.get(input) if (!session || session.state === 'closed') return 'host-not-connected' if (session.assignmentEpoch !== input.sourceAssignmentEpoch) { @@ -678,13 +873,11 @@ export class HostSessionRegistry { this.reassertRegionalDrain(session) return 'already-accepted' } + session.authorityRevision += 1 session.regionalDrainAttemptId = input.attemptId session.regionalDrainExpiresAt = this.now() + input.graceMs this.reassertRegionalDrain(session) - session.regionalDrainTimer = setTimeout( - () => this.closeDrainedSession(session), - input.graceMs - ) + session.regionalDrainTimer = setTimeout(() => this.closeDrainedSession(session), input.graceMs) return 'accepted' } @@ -740,9 +933,9 @@ export class HostSessionRegistry { const existing = this.sessions.get(key) const rebind = Boolean( existing && - hello.data.controlResumeSecret && - hello.data.controlResumeSecret === existing.controlResumeSecret && - (existing.state === 'orphaned' || existing.state === 'active') + hello.data.controlResumeSecret && + hello.data.controlResumeSecret === existing.controlResumeSecret && + (existing.state === 'orphaned' || existing.state === 'active') ) const generation = rebind ? existing!.generation : (existing?.generation ?? 0) + 1 const ephemeral = nacl.box.keyPair() @@ -784,7 +977,9 @@ export class HostSessionRegistry { }, 10_000) socket.once('message', (raw, isBinary) => { clearTimeout(proofTimer) - const ack = isBinary ? null : HostChallengeAckSchema.safeParse(payload(raw, 'host-challenge-ack')) + const ack = isBinary + ? null + : HostChallengeAckSchema.safeParse(payload(raw, 'host-challenge-ack')) const proof = ack?.success ? decodeCanonicalBase64(ack.data.proofB64, 32) : null if ( !ack?.success || @@ -826,6 +1021,11 @@ export class HostSessionRegistry { appVersion: string, connectionInclusionWatermark?: number ): Promise { + const release = this.beginIdleWork(identity.relayHostId) + if (!release) { + socket.close(RELAY_CLOSE_CODE.WRONG_CELL, 'idle cutover in progress') + return Promise.resolve() + } const key = this.key(identity.sub, identity.relayHostId) const previous = this.activationQueues.get(key) ?? Promise.resolve() // The timeout only fails this waiting socket; the queue entry still chains @@ -836,26 +1036,29 @@ export class HostSessionRegistry { socket.close(RELAY_CLOSE_CODE.LIMIT_EXCEEDED, 'control activation queue stalled') }, ACTIVATION_QUEUE_WAIT_MS) queueWaitTimer.unref?.() - const activation = previous.catch(() => undefined).then(async () => { - clearTimeout(queueWaitTimer) - if (queueWaitExpired) return - if ((this.sessions.get(key) ?? null) !== existing) { - socket.close(RELAY_CLOSE_CODE.PEER_DROPPED, 'control activation superseded') - return - } - await this.activateCurrent( - socket, - identity, - existing, - generation, - rebind, - assignmentEpoch, - appVersion, - connectionInclusionWatermark - ) - }) + const activation = previous + .catch(() => undefined) + .then(async () => { + clearTimeout(queueWaitTimer) + if (queueWaitExpired) return + if ((this.sessions.get(key) ?? null) !== existing) { + socket.close(RELAY_CLOSE_CODE.PEER_DROPPED, 'control activation superseded') + return + } + await this.activateCurrent( + socket, + identity, + existing, + generation, + rebind, + assignmentEpoch, + appVersion, + connectionInclusionWatermark + ) + }) this.activationQueues.set(key, activation) const cleanup = (): void => { + release() if (this.activationQueues.get(key) === activation) this.activationQueues.delete(key) } void activation.then(cleanup, cleanup) @@ -881,7 +1084,11 @@ export class HostSessionRegistry { cellId: this.config.cellId, assignmentEpoch, generation, - connectionInclusionWatermark + connectionInclusionWatermark, + idleRegionalRehome: + this.hostCapabilities.get(socket)?.has(RELAY_HOST_CAPABILITY_IDLE_REGIONAL_REHOME) ?? + false, + cellIncarnation: this.cellIncarnation } ) await this.assignments.markMigrationTargetRegistered( @@ -918,14 +1125,15 @@ export class HostSessionRegistry { const previousSocket = existing.socket if (existing.orphanTimer) clearTimeout(existing.orphanTimer) existing.orphanTimer = null + existing.authorityRevision += 1 + existing.assignmentEpoch = assignmentEpoch existing.socket = socket existing.state = existing.regionalDrainAttemptId ? 'drain-only' : 'active' existing.appVersion = appVersion existing.leaseExpiresAt = this.controlLeaseExpiresAt() existing.lastPongAt = this.now() existing.pendingPingAt = null - existing.activityRenewalDueAt = - this.now() + RELAY_PROTOCOL_LIMITS.controlPingIntervalMs + existing.activityRenewalDueAt = this.now() + RELAY_PROTOCOL_LIMITS.controlPingIntervalMs this.wireActiveControl(existing) this.sendHelloAck(existing) if (existing.regionalDrainAttemptId) this.reassertRegionalDrain(existing) @@ -980,6 +1188,7 @@ export class HostSessionRegistry { pendingPingAt: null, controlRttSamplesMs: [], controlRttLoggedAt: null, + authorityRevision: 0, activityRenewalDueAt: this.now() + RELAY_PROTOCOL_LIMITS.controlPingIntervalMs, activityRenewalAttempt: 0, activityRenewalCompletedAttempt: 0, @@ -1028,7 +1237,9 @@ export class HostSessionRegistry { ` splices=${session.closingCounts?.splices ?? session.activeSplices.size}` + ` pending=${session.closingCounts?.pending ?? session.pendingConns.size}` + ` code=${code} reason=${JSON.stringify(printableCloseReason(reason))}` + - (socketError === null ? '' : ` error=${JSON.stringify(printableCloseReason(socketError))}`) + (socketError === null + ? '' + : ` error=${JSON.stringify(printableCloseReason(socketError))}`) ) }) socket.on('message', (raw, isBinary) => { @@ -1077,6 +1288,19 @@ export class HostSessionRegistry { } private async acceptRefresh(session: HostSession, raw: RawData): Promise { + const release = this.beginIdleWork(session.relayHostId) + if (!release) { + session.socket?.close(RELAY_CLOSE_CODE.DRAINING, 'resolve-director') + return + } + try { + await this.acceptRefreshUnfenced(session, raw) + } finally { + release() + } + } + + private async acceptRefreshUnfenced(session: HostSession, raw: RawData): Promise { const parsed = AuthRefreshSchema.safeParse(payload(raw, 'auth-refresh')) if (!parsed.success) return const refreshed = await this.verifyRelayToken(parsed.data.relayJwt) @@ -1107,6 +1331,16 @@ export class HostSessionRegistry { if (controlActivityId && now >= session.activityRenewalDueAt) { const attempt = ++session.activityRenewalAttempt const startedAt = now + const socket = session.socket + const authorityRevision = session.authorityRevision + const current = (): boolean => + this.sessions.get(key) === session && + session.state !== 'closed' && + session.socket === socket && + socket.readyState === socket.OPEN && + session.controlActivityId === controlActivityId && + session.authorityRevision === authorityRevision && + attempt > session.activityRenewalCompletedAttempt void this.assignments .renewControlActivity( { userId: session.identity.sub, relayHostId: session.relayHostId }, @@ -1117,11 +1351,16 @@ export class HostSessionRegistry { } ) .then(() => { - if (attempt <= session.activityRenewalCompletedAttempt) return + if (!current()) return session.activityRenewalCompletedAttempt = attempt session.activityRenewalDueAt = startedAt + CONTROL_ACTIVITY_RENEWAL_INTERVAL_MS }) .catch(async (error: unknown) => { + if (!current()) return + if (error instanceof Error && error.message === 'assignment_not_found') { + socket.close(RELAY_CLOSE_CODE.DRAINING, 'control assignment missing') + return + } if (error instanceof Error && error.message === 'activity_cell_not_authoritative') { // Completion fences a late drain-only heartbeat after all source work is gone. session.socket?.close(RELAY_CLOSE_CODE.DRAINING, 'control migration completed') @@ -1144,8 +1383,22 @@ export class HostSessionRegistry { cellId: this.config.cellId } ) + if (!current()) { + // A replaced activity must not remain leased after its owner disappears. + if ( + !this.sessions.get(key) || + this.sessions.get(key)?.controlActivityId !== controlActivityId + ) { + this.releaseActivityBestEffort( + { userId: session.identity.sub, relayHostId: session.relayHostId }, + controlActivityId + ) + } + return + } this.observer.recordControlActivityRecovery?.(true) } catch (acquireError: unknown) { + if (!current()) return this.observer.recordControlActivityRecovery?.(false) if ( acquireError instanceof Error && @@ -1218,6 +1471,21 @@ export class HostSessionRegistry { private closeDrainedSession(session: HostSession): void { if (session.state === 'closed') return + const forcedConnections = session.activeConnIds.size + session.pendingConns.size + if (forcedConnections > 0) { + console.warn( + JSON.stringify({ + event: 'orca_relay_host_drain_forced_close', + ...this.logIdentity(), + ...this.sessionPlacementLogFields(session), + relayHostIdDigest: relayHostLogDigest(session.relayHostId), + reason: this.draining ? 'emergency' : 'regional-deadline', + forcedConnections, + splices: session.activeSplices.size, + pending: session.pendingConns.size + }) + ) + } if (session.heartbeatTimer) clearInterval(session.heartbeatTimer) if (session.orphanTimer) clearTimeout(session.orphanTimer) if (session.regionalDrainTimer) clearTimeout(session.regionalDrainTimer) @@ -1250,11 +1518,7 @@ export class HostSessionRegistry { session.pendingConns.clear() session.state = 'closed' if (session.socket) { - closeRelayWebSocket( - session.socket, - RELAY_CLOSE_CODE.DRAINING, - 'resolve configured director' - ) + closeRelayWebSocket(session.socket, RELAY_CLOSE_CODE.DRAINING, 'resolve configured director') } const key = this.key(session.identity.sub, session.relayHostId) if (this.sessions.get(key) === session) this.sessions.delete(key) @@ -1278,6 +1542,23 @@ export class HostSessionRegistry { session: HostSession, type: unknown, raw: RawData + ): Promise { + const release = this.beginIdleWork(session.relayHostId) + if (!release) { + session.socket?.close(RELAY_CLOSE_CODE.DRAINING, 'resolve-director') + return + } + try { + await this.acceptControlCommandUnfenced(session, type, raw) + } finally { + release() + } + } + + private async acceptControlCommandUnfenced( + session: HostSession, + type: unknown, + raw: RawData ): Promise { if (typeof type !== 'string' || !session.socket) return try { @@ -1315,10 +1596,7 @@ export class HostSessionRegistry { } if (type === 'device-credential-install') { const request = DeviceCredentialInstallSchema.parse(payload(raw, type)) - if ( - session.state !== 'active' && - request.authorization.mode === 'authenticated-direct' - ) { + if (session.state !== 'active' && request.authorization.mode === 'authenticated-direct') { throw new Error('authorization_expired') } const installActivityId = `install:${request.reqId}` diff --git a/cloud/apps/relay/src/idle-regional-rehome-reconciliation.test.ts b/cloud/apps/relay/src/idle-regional-rehome-reconciliation.test.ts new file mode 100644 index 00000000000..6fc2017ab48 --- /dev/null +++ b/cloud/apps/relay/src/idle-regional-rehome-reconciliation.test.ts @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RelayAssignmentStore } from './assignment-store.js' +import type { RelayDatabase } from './database.js' +import { openIdleRehomeTestDatabase } from './idle-regional-rehome-test-database.js' + +const request = { + v: 1 as const, + attemptId: '33333333-3333-4333-8333-333333333333', + userId: 'idle-reconciliation-test', + relayHostId: 'abcdefghijklmnop', + sourceCellId: 'source', + sourceCellIncarnation: '11111111-1111-4111-8111-111111111111', + sourceAssignmentEpoch: 1, + sourceGeneration: 7, + targetCellId: 'target' +} +const databases: RelayDatabase[] = [] +afterEach(async () => { + vi.restoreAllMocks() + for (const database of databases.splice(0)) await database.close() +}) + +async function setup() { + const database = await openIdleRehomeTestDatabase() + databases.push(database) + const store = new RelayAssignmentStore(database, () => 100_000_000) + await store.reconcileCells([ + { id: 'source', url: 'https://source.example.test', capacityRequests: 100 }, + { id: 'target', url: 'https://target.example.test', capacityRequests: 100 } + ]) + await store.assign(request) + await store.activateControl(request, { + cellId: request.sourceCellId, + assignmentEpoch: request.sourceAssignmentEpoch, + generation: request.sourceGeneration, + cellIncarnation: request.sourceCellIncarnation + }) + return { database, store } +} + +describe('idle cutover durable reconciliation', () => { + it('only permits reopening when the exact source still owns the assignment', async () => { + const { store } = await setup() + expect(await store.reconcileIdleRegionalRehome(request)).toBe('not-committed') + await store.activateControl(request, { + cellId: request.sourceCellId, + assignmentEpoch: request.sourceAssignmentEpoch, + generation: request.sourceGeneration + 1, + cellIncarnation: request.sourceCellIncarnation + }) + expect(await store.reconcileIdleRegionalRehome(request)).toBe('stale') + }) + + it('does not reopen an obsolete source after an assignment change', async () => { + const { store } = await setup() + await store.startEvacuation(request, request.targetCellId) + expect(await store.reconcileIdleRegionalRehome(request)).toBe('stale') + }) + + it('propagates unavailable durable state instead of declaring rollback', async () => { + const { database, store } = await setup() + vi.spyOn(database, 'transaction').mockRejectedValue(new Error('database_unavailable')) + await expect(store.reconcileIdleRegionalRehome(request)).rejects.toThrow('database_unavailable') + }) + + it('waits for an outstanding assignment transaction before deciding authority', async () => { + const { database, store } = await setup() + let release!: () => void + let entered!: () => void + const locked = new Promise((resolve) => { + entered = resolve + }) + const gate = new Promise((resolve) => { + release = resolve + }) + const commit = database.transaction(async (transaction) => { + await transaction.queryLocked( + 'SELECT * FROM relay_assignments WHERE user_id = ? AND relay_host_id = ?', + [request.userId, request.relayHostId] + ) + entered() + await gate + await transaction.query( + 'UPDATE relay_assignments SET assignment_epoch = assignment_epoch + 1 WHERE user_id = ? AND relay_host_id = ?', + [request.userId, request.relayHostId] + ) + }) + await locked + let settled = false + const reconciliation = store.reconcileIdleRegionalRehome(request).finally(() => { + settled = true + }) + try { + await new Promise((resolve) => setImmediate(resolve)) + expect(settled).toBe(false) + } finally { + release() + await commit + await reconciliation + } + expect(await reconciliation).toBe('stale') + }) +}) diff --git a/cloud/apps/relay/src/idle-regional-rehome-selection.ts b/cloud/apps/relay/src/idle-regional-rehome-selection.ts new file mode 100644 index 00000000000..f8790459c0b --- /dev/null +++ b/cloud/apps/relay/src/idle-regional-rehome-selection.ts @@ -0,0 +1,117 @@ +import { createHash } from 'node:crypto' +import type { IdleRegionalRehomeRequest } from '@orca-cloud/relay-contract' +import type { RelayDatabase, SqlRow } from './database.js' + +export const IDLE_REHOME_PAGE_SIZE = 100 + +export async function selectIdleRegionalRehomes(input: { + database: RelayDatabase + now: number + heartbeatTtlMs: number + cohortPercent: number + offset: number + connectionHeadroom: Map + cellIsClean: (safety: SqlRow | undefined, runtime: SqlRow, now: number) => boolean +}): Promise> { + const [runtimes, safetyRows] = await Promise.all([ + input.database.query('SELECT * FROM relay_cell_runtime'), + input.database.query('SELECT * FROM relay_cell_rehome_safety') + ]) + const cleanCells = runtimes + .filter((runtime) => + input.cellIsClean( + safetyRows.find((safety) => safety.cell_id === runtime.cell_id), + runtime, + input.now + ) + ) + .map((runtime) => String(runtime.cell_id)) + const targetCells = cleanCells.filter((id) => input.connectionHeadroom.get(id) !== false) + if (!cleanCells.length || !targetCells.length) return [] + const rows = await input.database.query( + `SELECT a.user_id, a.relay_host_id, a.cell_id AS source_cell_id, + a.assignment_epoch, host.generation, r.cell_incarnation, + s.cell_url, target.cell_id AS target_cell_id + FROM relay_region_rehome_control policy + JOIN relay_region_decisions d ON d.outcome = 'conclusive' + JOIN relay_assignments a ON a.user_id = d.user_id AND a.relay_host_id = d.relay_host_id + JOIN relay_cells s ON s.cell_id = a.cell_id AND s.enabled = 1 + JOIN relay_cell_regions sr ON sr.cell_id = a.cell_id + JOIN relay_cell_admission sa ON sa.cell_id = a.cell_id AND sa.admission_state = 'general' + JOIN relay_cell_runtime r ON r.cell_id = a.cell_id AND r.ready = 1 + JOIN relay_cell_capabilities c ON c.cell_id = r.cell_id AND c.cell_incarnation = r.cell_incarnation + JOIN relay_control_capabilities host ON host.user_id = a.user_id AND host.relay_host_id = a.relay_host_id + AND host.cell_id = a.cell_id AND host.assignment_epoch = a.assignment_epoch + AND host.cell_incarnation = r.cell_incarnation AND host.idle_regional_rehome = 1 + JOIN relay_assignment_activity_leases lease ON lease.user_id = host.user_id + AND lease.relay_host_id = host.relay_host_id AND lease.activity_id = host.activity_id + AND lease.cell_id = a.cell_id AND lease.activity_kind = 'control' + JOIN relay_cell_regions tr ON tr.region = d.preferred_region + JOIN relay_cells target ON target.cell_id = tr.cell_id AND target.enabled = 1 + JOIN relay_cell_admission ta ON ta.cell_id = target.cell_id AND ta.admission_state = 'general' + JOIN relay_cell_runtime rt ON rt.cell_id = target.cell_id AND rt.ready = 1 + JOIN relay_cell_capabilities ct ON ct.cell_id = rt.cell_id AND ct.cell_incarnation = rt.cell_incarnation + WHERE policy.control_id = 'global' AND policy.enabled = 1 AND policy.not_before <= ? + AND d.preferred_region <> sr.region AND d.incumbent_region = sr.region + AND d.assignment_epoch = a.assignment_epoch AND d.policy_version = 1 + AND d.expires_at > ? AND d.observed_at >= ? - policy.preference_max_age_ms + AND d.cohort_bucket < ? AND lease.expires_at > ? AND lease.updated_at >= r.started_at + AND r.last_heartbeat_at > ? AND rt.last_heartbeat_at > ? + AND s.cell_id IN (${cleanCells.map(() => '?').join(',')}) + AND target.cell_id IN (${targetCells.map(() => '?').join(',')}) + -- Reserve the moving host's source activity plus its assignment on the target. + AND target.reserved_requests + 1 + ( + SELECT COALESCE(SUM(activity.request_units), 0) + FROM relay_assignment_activity_leases activity + WHERE activity.user_id = a.user_id AND activity.relay_host_id = a.relay_host_id + AND activity.cell_id = a.cell_id + ) <= target.capacity_requests + AND c.regional_rehome_protocol >= 3 AND ct.regional_rehome_protocol >= 3 + AND NOT EXISTS (SELECT 1 FROM relay_assignment_migrations migration + WHERE migration.user_id = a.user_id AND migration.relay_host_id = a.relay_host_id + AND migration.completed_at IS NULL AND migration.aborted_at IS NULL) + AND NOT EXISTS (SELECT 1 FROM relay_region_rehome_attempts attempt + WHERE attempt.user_id = a.user_id AND attempt.relay_host_id = a.relay_host_id + AND attempt.created_at > ? - policy.host_cooldown_ms) + ORDER BY a.user_id, a.relay_host_id, host.generation DESC, + (target.reserved_requests + rt.observed_requests) * 1.0 / target.capacity_requests, + target.cell_id + LIMIT ? OFFSET ?`, + [ + input.now, + input.now, + input.now, + input.cohortPercent, + input.now, + input.now - input.heartbeatTtlMs, + input.now - input.heartbeatTtlMs, + ...cleanCells, + ...targetCells, + input.now, + IDLE_REHOME_PAGE_SIZE, + input.offset + ] + ) + return rows.map((row) => { + const request = { + v: 1 as const, + userId: String(row.user_id), + relayHostId: String(row.relay_host_id), + sourceCellId: String(row.source_cell_id), + sourceCellIncarnation: String(row.cell_incarnation), + sourceAssignmentEpoch: Number(row.assignment_epoch), + sourceGeneration: Number(row.generation), + targetCellId: String(row.target_cell_id) + } + // UUIDv5 keeps retries on every director bound to the same source authority and target. + const digest = createHash('sha1') + .update(Buffer.from('0a1c5a9b197b4ea8b6f1f3bcaa3d712c', 'hex')) + .update(JSON.stringify(request)) + .digest() + digest[6] = (digest[6]! & 0x0f) | 0x50 + digest[8] = (digest[8]! & 0x3f) | 0x80 + const hex = digest.subarray(0, 16).toString('hex') + const attemptId = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` + return { ...request, attemptId, sourceCellUrl: String(row.cell_url) } + }) +} diff --git a/cloud/apps/relay/src/idle-regional-rehome-store.test.ts b/cloud/apps/relay/src/idle-regional-rehome-store.test.ts new file mode 100644 index 00000000000..5d0d3cff343 --- /dev/null +++ b/cloud/apps/relay/src/idle-regional-rehome-store.test.ts @@ -0,0 +1,350 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RelayAssignmentStore } from './assignment-store.js' +import type { RelayDatabase, RelayLockOptions } from './database.js' +import { openIdleRehomeTestDatabase } from './idle-regional-rehome-test-database.js' + +const identity = { userId: 'idle-store-test', relayHostId: 'abcdefghijklmnop' } +const incarnations = [ + '11111111-1111-4111-8111-111111111111', + '22222222-2222-4222-8222-222222222222' +] +const cells = [ + { + id: 'source', + url: 'https://source.example.test', + region: 'us-central1' as const, + capacityRequests: 100 + }, + { + id: 'target', + url: 'https://target.example.test', + region: 'asia-east2' as const, + capacityRequests: 100 + } +] +const databases: RelayDatabase[] = [] +afterEach(async () => { + vi.restoreAllMocks() + for (const database of databases.splice(0)) await database.close() +}) + +async function setup() { + const database = await openIdleRehomeTestDatabase() + databases.push(database) + let now = 100_000_000 + const store = new RelayAssignmentStore(database, () => now, { regionalRehomeCohortPercent: 100 }) + await store.inspectRegionalRehomeControl() + now += 86_400_000 + await store.applyRegionalRehomeControl({ + expectedGeneration: 0, + enabled: true, + notBefore: now, + ratePerMinute: 10, + preferenceMaxAgeMs: 86_400_000, + hostCooldownMs: 604_800_000, + drainGraceMs: 60_000 + }) + await store.reconcileCells(cells) + const safety = { + observedAt: now, + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + } + for (const [index, cell] of cells.entries()) { + await store.recordCellHeartbeat({ + cellId: cell.id, + cellUrl: cell.url, + region: cell.region, + cellIncarnation: incarnations[index]!, + startedAt: now - 1_000, + ready: true, + observedRequests: 0 + }) + await store.recordCellRegionalRehomeStatus({ + cellId: cell.id, + cellIncarnation: incarnations[index]!, + regionalRehomeProtocol: 3, + safety + }) + } + const assignment = await store.assign(identity, undefined, 'us-central1') + await store.activateControl(identity, { + cellId: cells[0]!.id, + assignmentEpoch: assignment.assignmentEpoch, + generation: 7, + cellIncarnation: incarnations[0], + idleRegionalRehome: true + }) + const issued = await store.exchangeRegionCorrection( + identity, + { v: 1, action: 'issue-window' }, + assignment.assignmentEpoch + ) + await store.exchangeRegionCorrection( + identity, + { + v: 1, + action: 'report', + generation: issued.window!.generation, + assignmentEpoch: assignment.assignmentEpoch, + policyVersion: 1, + outcome: 'conclusive', + measurements: { 'us-central1': 180, 'asia-east2': 40 } + }, + assignment.assignmentEpoch + ) + const request = { + v: 1 as const, + ...identity, + attemptId: '33333333-3333-4333-8333-333333333333', + sourceCellId: cells[0]!.id, + sourceCellIncarnation: incarnations[0]!, + sourceAssignmentEpoch: assignment.assignmentEpoch, + sourceGeneration: 7, + targetCellId: cells[1]!.id + } + return { store, database, safety, request } +} + +describe('constrained idle regional assignment transaction', () => { + it.each([10, 11])('reserves source activity plus assignment at target capacity %i', async (capacity) => { + const { store, database, safety, request } = await setup() + // Model three source activity units and seven units already reserved at the target. + await database.query( + 'UPDATE relay_assignment_activity_leases SET request_units = 3 WHERE user_id = ? AND relay_host_id = ?', + [identity.userId, identity.relayHostId] + ) + await database.query("UPDATE relay_cells SET reserved_requests = 4 WHERE cell_id = 'source'") + await database.query( + "UPDATE relay_cells SET reserved_requests = 7, capacity_requests = ? WHERE cell_id = 'target'", + [capacity] + ) + const candidates = await store.selectIdleRegionalRehomeCandidates(safety) + expect(candidates).toHaveLength(capacity === 11 ? 1 : 0) + expect(await store.commitIdleRegionalRehome(request, safety)).toEqual({ + outcome: capacity === 11 ? 'committed' : 'deferred' + }) + const [target] = await database.query("SELECT reserved_requests FROM relay_cells WHERE cell_id = 'target'") + expect(Number(target!.reserved_requests)).toBe(capacity === 11 ? 11 : 7) + expect(await store.resolve(identity)).toMatchObject({ + cellId: capacity === 11 ? 'target' : 'source', + assignmentEpoch: capacity === 11 ? 2 : 1 + }) + }) + + it('progresses past a full page of busy candidates without writing eligibility state', async () => { + const { store, database, safety } = await setup() + for (const table of [ + 'relay_assignments', + 'relay_assignment_activity_leases', + 'relay_control_capabilities', + 'relay_region_decisions' + ]) { + const template = ( + await database.query(`SELECT * FROM ${table} WHERE user_id = ? AND relay_host_id = ?`, [ + identity.userId, + identity.relayHostId + ]) + )[0]! + const columns = Object.keys(template) + for (let index = 0; index < 100; index++) { + const values = columns.map((column) => + column === 'user_id' || column === 'relay_host_id' ? '?' : column + ) + await database.query( + `INSERT INTO ${table} (${columns.join(', ')}) SELECT ${values.join(', ')} FROM ${table} + WHERE user_id = ? AND relay_host_id = ?`, + [ + `idle-store-test-${String(index).padStart(3, '0')}`, + `pagehost${String(index).padStart(8, '0')}`, + identity.userId, + identity.relayHostId + ] + ) + } + } + const first = await store.selectIdleRegionalRehomeCandidates(safety) + const next = await store.selectIdleRegionalRehomeCandidates(safety) + expect(first).toHaveLength(100) + expect(next).toHaveLength(1) + expect(next[0]!.relayHostId).toBe('pagehost00000099') + const restarted = new RelayAssignmentStore(database, () => safety.observedAt, { + regionalRehomeCohortPercent: 100 + }) + expect(await restarted.selectIdleRegionalRehomeCandidates(safety)).toEqual(first) + expect(await database.query('SELECT * FROM relay_region_rehome_attempts')).toEqual([]) + const decisions = await database.query('SELECT last_considered_at FROM relay_region_decisions') + expect(decisions.every((decision) => Number(decision.last_considered_at) === 0)).toBe(true) + }) + + it.runIf(Boolean(process.env.ORCA_IDLE_REHOME_POSTGRES_URL))( + 'rechecks generation when replacement wins after the initial authority lookup', + async () => { + const { store, safety, request, database } = await setup() + const held = holdStatement(database, 'SELECT * FROM relay_region_rehome_control') + const commit = store.commitIdleRegionalRehome(request, safety) + await held.entered + try { + await store.activateControl(identity, { + cellId: 'source', + assignmentEpoch: 1, + generation: 8, + cellIncarnation: incarnations[0], + idleRegionalRehome: true + }) + } finally { + held.release() + } + expect(await commit).toEqual({ outcome: 'deferred' }) + expect(await store.reconcileIdleRegionalRehome(request)).toBe('stale') + expect(await database.query('SELECT * FROM relay_region_rehome_attempts')).toEqual([]) + } + ) + + it('rejects source replacement when the cutover already holds assignment authority', async () => { + const { store, safety, request, database } = await setup() + const held = holdStatement(database, 'UPDATE relay_assignments SET cell_id') + const commit = store.commitIdleRegionalRehome(request, safety) + await held.entered + const replacement = store.activateControl(identity, { + cellId: 'source', + assignmentEpoch: 1, + generation: 8, + cellIncarnation: incarnations[0], + idleRegionalRehome: true + }) + const rejected = expect(replacement).rejects.toThrow('wrong_assignment') + held.release() + expect(await commit).toEqual({ outcome: 'committed' }) + await rejected + expect(await store.resolve(identity)).toMatchObject({ cellId: 'target', assignmentEpoch: 2 }) + }) + + it('finds the committed attempt after its database reply is lost', async () => { + const { store, safety, request, database } = await setup() + const transaction = database.transaction.bind(database) + const intercepted = vi + .spyOn(database, 'transaction') + .mockImplementation(async (operation, options) => { + let changed = false + const result = await transaction( + async (tx) => + operation( + new Proxy(tx, { + get(target, key) { + if (key === 'query') + return async (sql: string, params?: unknown[]) => { + if (sql.includes('INSERT INTO relay_region_rehome_attempts')) changed = true + return target.query(sql, params) + } + const value = Reflect.get(target, key) + return typeof value === 'function' ? value.bind(target) : value + } + }) + ), + options + ) + if (changed) throw new Error('simulated_commit_reply_lost') + return result + }) + await expect(store.commitIdleRegionalRehome(request, safety)).rejects.toThrow( + 'simulated_commit_reply_lost' + ) + intercepted.mockRestore() + expect(await store.reconcileIdleRegionalRehome(request)).toBe('committed') + expect(await store.commitIdleRegionalRehome(request, safety)).toEqual({ outcome: 'committed' }) + expect(await database.query('SELECT * FROM relay_region_rehome_attempts')).toHaveLength(1) + }) + + it('commits the requested move once and records its outcome without source retention', async () => { + const { store, database, safety, request } = await setup() + expect(await store.commitIdleRegionalRehome(request, safety)).toEqual({ outcome: 'committed' }) + expect(await store.commitIdleRegionalRehome(request, safety)).toEqual({ outcome: 'committed' }) + expect(await store.resolve(identity)).toMatchObject({ cellId: 'target', assignmentEpoch: 2 }) + const attempts = await database.query('SELECT * FROM relay_region_rehome_attempts') + expect(attempts).toHaveLength(1) + expect(attempts[0]!.attempt_id).toBe(request.attemptId) + expect(Number(attempts[0]!.source_generation)).toBe(7) + }) + + it('rejects a replaced control and never substitutes a different target', async () => { + const { store, safety, request } = await setup() + expect( + await store.commitIdleRegionalRehome({ ...request, targetCellId: 'missing' }, safety) + ).toEqual({ outcome: 'deferred' }) + await store.activateControl(identity, { + cellId: 'source', + assignmentEpoch: 1, + generation: 8, + cellIncarnation: incarnations[0], + idleRegionalRehome: true + }) + expect(await store.commitIdleRegionalRehome(request, safety)).toEqual({ outcome: 'stale' }) + expect(await store.resolve(identity)).toMatchObject({ cellId: 'source', assignmentEpoch: 1 }) + }) + + it('does not commit without process safety or cohort authorization', async () => { + const { store, safety, request, database } = await setup() + expect(await store.commitIdleRegionalRehome(request)).toEqual({ outcome: 'deferred' }) + expect(await store.commitIdleRegionalRehome(request, safety, 0)).toEqual({ + outcome: 'deferred' + }) + expect(await database.query('SELECT * FROM relay_region_rehome_attempts')).toEqual([]) + }) + + it('selects read-only with stable identity and the control generation, not probe generation', async () => { + const { store, safety, database } = await setup() + const before = await database.query('SELECT * FROM relay_assignments') + const candidates = await store.selectIdleRegionalRehomeCandidates(safety) + expect(candidates).toHaveLength(1) + expect(candidates[0]).toMatchObject({ + sourceGeneration: 7, + sourceCellId: 'source', + targetCellId: 'target' + }) + expect(await store.selectIdleRegionalRehomeCandidates(safety)).toEqual(candidates) + expect(await database.query('SELECT * FROM relay_assignments')).toEqual(before) + expect(await database.query('SELECT * FROM relay_region_rehome_attempts')).toEqual([]) + }) +}) + +function holdStatement(database: RelayDatabase, fragment: string) { + let entered!: () => void + let release!: () => void + const arrival = new Promise((resolve) => { + entered = resolve + }) + const gate = new Promise((resolve) => { + release = resolve + }) + let held = false + const transaction = database.transaction.bind(database) + vi.spyOn(database, 'transaction').mockImplementation((operation, options) => + transaction(async (tx) => { + return operation( + new Proxy(tx, { + get(target, key) { + if (key === 'query' || key === 'queryLocked') + return async (sql: string, params?: unknown[], lockOptions?: RelayLockOptions) => { + if (!held && sql.includes(fragment)) { + held = true + entered() + await gate + } + return key === 'queryLocked' + ? target.queryLocked(sql, params, lockOptions) + : target.query(sql, params) + } + const value = Reflect.get(target, key) + return typeof value === 'function' ? value.bind(target) : value + } + }) + ) + }, options) + ) + return { entered: arrival, release } +} diff --git a/cloud/apps/relay/src/idle-regional-rehome-test-database.ts b/cloud/apps/relay/src/idle-regional-rehome-test-database.ts new file mode 100644 index 00000000000..a541e7d1126 --- /dev/null +++ b/cloud/apps/relay/src/idle-regional-rehome-test-database.ts @@ -0,0 +1,40 @@ +import { randomUUID } from 'node:crypto' +import pg from 'pg' +import { openInMemoryRelayDatabase, openRelayDatabase, type RelayDatabase } from './database.js' + +export async function openIdleRehomeTestDatabase(): Promise { + const configured = process.env.ORCA_IDLE_REHOME_POSTGRES_URL + if (!configured) return openInMemoryRelayDatabase() + const url = new URL(configured) + if (url.port !== '55440' || !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) { + throw new Error('idle_rehome_tests_require_local_postgres_55440') + } + const schema = `idle_rehome_${randomUUID().replaceAll('-', '')}` + const admin = new pg.Client({ connectionString: configured }) + await admin.connect() + try { + await admin.query(`CREATE SCHEMA ${schema}`) + url.searchParams.set('options', `-c search_path=${schema}`) + const database = await openRelayDatabase({ databaseUrl: url.toString(), dataDir: '' }) + const close = database.close.bind(database) + database.close = async () => { + try { + await close() + } finally { + try { + await admin.query(`DROP SCHEMA ${schema} CASCADE`) + } finally { + await admin.end() + } + } + } + return database + } catch (error) { + try { + await admin.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`) + } finally { + await admin.end() + } + throw error + } +} diff --git a/cloud/apps/relay/src/idle-regional-rehome-worker.test.ts b/cloud/apps/relay/src/idle-regional-rehome-worker.test.ts new file mode 100644 index 00000000000..9b3f6d7fa07 --- /dev/null +++ b/cloud/apps/relay/src/idle-regional-rehome-worker.test.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RelayAssignmentStore } from './assignment-store.js' +import type { RelayConfig } from './config.js' +import { startRegionalRehomeWorker } from './regional-rehome-worker.js' + +const candidate = { + v: 1, + attemptId: '11111111-1111-4111-8111-111111111111', + userId: 'private-user', + relayHostId: 'abcdefghijklmnop', + sourceCellId: 'source', + sourceCellUrl: 'https://source.example.test', + sourceCellIncarnation: '22222222-2222-4222-8222-222222222222', + sourceAssignmentEpoch: 7, + sourceGeneration: 3, + targetCellId: 'target' +} +const config = { + role: 'director', + regionCorrectionCohortPercent: 100, + rehomeAudience: 'https://relay.example.test/v1/admin/host-drain', + rehomeDirectorServiceAccount: 'director@example.test' +} as RelayConfig + +function setup(fetch: typeof globalThis.fetch) { + const selectIdleRegionalRehomeCandidates = vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValue([candidate]) + const claimRegionalRehome = vi.fn() + const recordRegionalRehomeDispatchFailure = vi.fn() + const worker = startRegionalRehomeWorker( + config, + { + selectIdleRegionalRehomeCandidates, + claimRegionalRehome, + recordRegionalRehomeDispatchFailure + } as unknown as RelayAssignmentStore, + { + safetySnapshot: () => ({ observedAt: 100 }) as never, + intervalMs: 60_000, + identityToken: async () => 'private-token', + fetch + } + )! + return { + worker, + selectIdleRegionalRehomeCandidates, + claimRegionalRehome, + recordRegionalRehomeDispatchFailure + } +} + +describe('idle regional worker dispatch', () => { + afterEach(() => vi.restoreAllMocks()) + it('sends an idle request without claiming an assignment first', async () => { + const fetch = vi.fn(async () => + Response.json({ v: 1, outcome: 'committed' }) + ) + const c = setup(fetch) + await vi.waitFor(() => expect(c.selectIdleRegionalRehomeCandidates).toHaveBeenCalledOnce()) + await c.worker.run() + c.worker.stop() + expect(c.claimRegionalRehome).not.toHaveBeenCalled() + expect(fetch).toHaveBeenCalledOnce() + const [url, init] = fetch.mock.calls[0]! + expect(String(url)).toBe('https://source.example.test/v1/admin/host-idle-rehome') + const { sourceCellUrl: _, ...request } = candidate + expect(JSON.parse(String(init?.body))).toEqual({ + ...request, + cohortPercent: 100, + directorSafety: { observedAt: 100 } + }) + }) + it('progresses past busy hosts without charging a dispatch failure', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(Response.json({ v: 1, outcome: 'busy' })) + .mockResolvedValueOnce(Response.json({ v: 1, outcome: 'committed' })) + const c = setup(fetch) + await vi.waitFor(() => expect(c.selectIdleRegionalRehomeCandidates).toHaveBeenCalledOnce()) + c.selectIdleRegionalRehomeCandidates.mockResolvedValue([ + candidate, + { + ...candidate, + relayHostId: 'ponmlkjihgfedcba', + attemptId: '33333333-3333-4333-8333-333333333333' + } + ]) + await c.worker.run() + c.worker.stop() + expect(fetch).toHaveBeenCalledTimes(2) + expect(c.recordRegionalRehomeDispatchFailure).not.toHaveBeenCalled() + }) + it('does not charge a lost response as a claimed migration failure', async () => { + const fetch = vi.fn(async () => { + throw new Error('response lost') + }) + const c = setup(fetch) + await vi.waitFor(() => expect(c.selectIdleRegionalRehomeCandidates).toHaveBeenCalledOnce()) + await c.worker.run() + c.worker.stop() + expect(c.recordRegionalRehomeDispatchFailure).not.toHaveBeenCalled() + }) +}) diff --git a/cloud/apps/relay/src/index.ts b/cloud/apps/relay/src/index.ts index 541884362c2..8e7b6a56941 100644 --- a/cloud/apps/relay/src/index.ts +++ b/cloud/apps/relay/src/index.ts @@ -2,6 +2,7 @@ import { formatAssignmentInventorySnapshot, readAssignmentInventorySnapshot } from './assignment-inventory-snapshot.js' +import { readRegionCorrectionOutcomes } from './region-correction-outcomes.js' import { RelayAssignmentStore } from './assignment-store.js' import { loadRelayConfig } from './config.js' import { startCellHeartbeat } from './cell-heartbeat-client.js' @@ -71,6 +72,13 @@ const migrationInventoryTimer = roleOwnsAssignmentMaintenance(config.role) void runRelayBackgroundOperation(async () => { const inventory = await readRegisteredMigrationInventory(database, Date.now()) for (const line of formatRegisteredMigrationInventory(inventory)) console.warn(line) + console.log( + JSON.stringify({ + event: 'orca_relay_region_correction_outcomes', + observedAt: Date.now(), + outcomes: await readRegionCorrectionOutcomes(database, Date.now()) + }) + ) }, '[orca-relay] migration inventory failed') }, 5 * 60_000) : null diff --git a/cloud/apps/relay/src/region-correction-outcomes.ts b/cloud/apps/relay/src/region-correction-outcomes.ts new file mode 100644 index 00000000000..d3a6f8d3be3 --- /dev/null +++ b/cloud/apps/relay/src/region-correction-outcomes.ts @@ -0,0 +1,29 @@ +import type { RelayDatabase } from './database.js' + +export async function readRegionCorrectionOutcomes(database: RelayDatabase, now: number) { + const rows = await database.query( + `SELECT attempt.source_cell_id, attempt.target_cell_id, + CASE WHEN attempt.aborted_at IS NOT NULL THEN 'aborted' + WHEN attempt.completed_at IS NOT NULL THEN 'completed' + WHEN migration.target_registered_at IS NOT NULL THEN 'registered' ELSE 'registering' END AS state, + COUNT(*) AS count, + COALESCE(MAX(CASE WHEN attempt.completed_at IS NULL AND attempt.aborted_at IS NULL + THEN ? - attempt.created_at ELSE 0 END), 0) AS oldest_open_ms, + COALESCE(SUM(CASE WHEN attempt.completed_at IS NULL AND attempt.aborted_at IS NULL + THEN migration.target_reserved_units ELSE 0 END), 0) AS target_reserved_units + FROM relay_region_rehome_attempts attempt + JOIN relay_assignment_migrations migration ON migration.user_id = attempt.user_id + AND migration.relay_host_id = attempt.relay_host_id AND migration.assignment_epoch = attempt.assignment_epoch + GROUP BY attempt.source_cell_id, attempt.target_cell_id, state + ORDER BY attempt.source_cell_id, attempt.target_cell_id, state`, + [now] + ) + return rows.map((row) => ({ + sourceCellId: String(row.source_cell_id), + targetCellId: String(row.target_cell_id), + state: String(row.state), + count: Number(row.count), + oldestOpenMs: Number(row.oldest_open_ms), + targetReservedUnits: Number(row.target_reserved_units) + })) +} diff --git a/cloud/apps/relay/src/region-correction-preview.ts b/cloud/apps/relay/src/region-correction-preview.ts new file mode 100644 index 00000000000..c120dbb2272 --- /dev/null +++ b/cloud/apps/relay/src/region-correction-preview.ts @@ -0,0 +1,157 @@ +import type { RelayDatabase, SqlRow } from './database.js' +import { REGIONAL_REHOME_DEFAULT_HOST_COOLDOWN_MS } from './database.js' +import { + REGIONAL_REHOME_CONCURRENT_LIMIT, + REGION_DECISION_TTL_MS +} from './region-correction-state.js' + +export type RegionCorrectionPreview = { + observedAt: number + newClaimsEnabled: boolean + cohortPercent: number + openMigrations: number + availableMigrationSlots: number + globalSafetyFailure: string | null + counts: Record +} + +export async function previewRegionalRehomeEligibility(input: { + database: RelayDatabase + now: number + heartbeatTtlMs: number + cohortPercent: number + globalSafetyFailure: string | null + connectionHeadroom: ReadonlyMap + cellIsClean: (safety: SqlRow | undefined, runtime: SqlRow, now: number) => boolean +}): Promise { + const { database, now } = input + const [hosts, cells, runtimeRows, capabilityRows, safetyRows, controls, migrations] = + await Promise.all([ + database.query( + `SELECT assignment.cell_id, assignment.assignment_epoch, + decision.generation, decision.assignment_epoch AS decision_epoch, decision.expires_at, + decision.incumbent_region, decision.preferred_region, decision.outcome, decision.policy_version, + decision.observed_at, decision.cohort_bucket, + (SELECT MAX(attempt.created_at) FROM relay_region_rehome_attempts attempt + WHERE attempt.user_id = assignment.user_id AND attempt.relay_host_id = assignment.relay_host_id) AS last_attempt_at, + (SELECT COUNT(*) FROM relay_assignment_migrations migration + WHERE migration.user_id = assignment.user_id AND migration.relay_host_id = assignment.relay_host_id + AND migration.completed_at IS NULL AND migration.aborted_at IS NULL) AS open_migrations, + (SELECT COALESCE(SUM(lease.request_units),0) FROM relay_assignment_activity_leases lease + WHERE lease.user_id = assignment.user_id AND lease.relay_host_id = assignment.relay_host_id + AND lease.cell_id = assignment.cell_id) AS source_units, + (SELECT COUNT(*) FROM relay_control_capabilities host_capability + JOIN relay_assignment_activity_leases lease ON lease.user_id = host_capability.user_id + AND lease.relay_host_id = host_capability.relay_host_id AND lease.activity_id = host_capability.activity_id + JOIN relay_cell_runtime runtime ON runtime.cell_id = host_capability.cell_id + AND runtime.cell_incarnation = host_capability.cell_incarnation + WHERE host_capability.user_id = assignment.user_id AND host_capability.relay_host_id = assignment.relay_host_id + AND host_capability.cell_id = assignment.cell_id AND host_capability.assignment_epoch = assignment.assignment_epoch + AND host_capability.idle_regional_rehome = 1 AND lease.activity_kind = 'control' + AND lease.activity_id NOT LIKE 'control-pending:%' AND lease.expires_at > ? + AND lease.updated_at >= runtime.started_at) AS capable_controls + FROM relay_assignments assignment LEFT JOIN relay_region_decisions decision + ON decision.user_id = assignment.user_id AND decision.relay_host_id = assignment.relay_host_id`, + [now] + ), + database.query(`SELECT cell.*, region.region, admission.admission_state FROM relay_cells cell + LEFT JOIN relay_cell_regions region ON region.cell_id = cell.cell_id + LEFT JOIN relay_cell_admission admission ON admission.cell_id = cell.cell_id`), + database.query(`SELECT * FROM relay_cell_runtime`), + database.query(`SELECT * FROM relay_cell_capabilities`), + database.query(`SELECT * FROM relay_cell_rehome_safety`), + database.query(`SELECT * FROM relay_region_rehome_control WHERE control_id = 'global'`), + database.query( + `SELECT COUNT(*) AS count FROM relay_assignment_migrations WHERE completed_at IS NULL AND aborted_at IS NULL` + ) + ]) + const byCell = (rows: SqlRow[]) => new Map(rows.map((row) => [String(row.cell_id), row])) + const runtimes = byCell(runtimeRows) + const capabilities = byCell(capabilityRows) + const safety = byCell(safetyRows) + const inventory = byCell(cells) + const control = controls[0] + const cooldown = Number(control?.host_cooldown_ms ?? REGIONAL_REHOME_DEFAULT_HOST_COOLDOWN_MS) + const maxAge = Number(control?.preference_max_age_ms ?? REGION_DECISION_TTL_MS) + const openMigrations = Number(migrations[0]?.count ?? 0) + const counts: Record = {} + const count = (reason: string) => { + counts[reason] = (counts[reason] ?? 0) + 1 + } + const available = (cell: SqlRow): boolean => { + const id = String(cell.cell_id) + const runtime = runtimes.get(id) + const capability = capabilities.get(id) + return ( + Number(cell.enabled) === 1 && + cell.admission_state === 'general' && + cell.region != null && + runtime !== undefined && + Number(runtime.ready) === 1 && + Number(runtime.last_heartbeat_at) > now - input.heartbeatTtlMs && + capability !== undefined && + capability.cell_incarnation === runtime.cell_incarnation && + Number(capability.regional_rehome_protocol) >= 3 + ) + } + for (const host of hosts) { + let reason: string | null = null + const source = inventory.get(String(host.cell_id)) + if (host.generation == null) reason = 'no-verified-decision' + else if (Number(host.expires_at) <= now || Number(host.observed_at) < now - maxAge) + reason = 'expired' + else if ( + Number(host.decision_epoch) !== Number(host.assignment_epoch) || + host.incumbent_region !== source?.region + ) + reason = 'basis-changed' + else if ( + host.outcome !== 'conclusive' || + Number(host.policy_version) !== 1 || + host.preferred_region == null + ) + reason = 'inconclusive-or-insufficient-improvement' + else if (Number(host.cohort_bucket) >= input.cohortPercent) reason = 'outside-cohort' + else if (Number(host.open_migrations) > 0) reason = 'migration-open' + else if (host.last_attempt_at != null && Number(host.last_attempt_at) > now - cooldown) + reason = 'host-cooldown' + else if (!source || !available(source)) reason = 'source-ineligible' + else if (Number(host.capable_controls) === 0) reason = 'source-control-unsupported-or-inactive' + else if ( + !input.cellIsClean(safety.get(String(host.cell_id)), runtimes.get(String(host.cell_id))!, now) + ) + reason = 'source-unclean' + if (reason) { + count(reason) + continue + } + const targets = cells.filter( + (cell) => + cell.cell_id !== host.cell_id && cell.region === host.preferred_region && available(cell) + ) + const clean = targets.filter((cell) => + input.cellIsClean(safety.get(String(cell.cell_id)), runtimes.get(String(cell.cell_id))!, now) + ) + const capacity = clean.filter( + (cell) => + input.connectionHeadroom.get(String(cell.cell_id)) !== false && + Number(cell.reserved_requests) + Number(host.source_units) + 1 <= + Number(cell.capacity_requests) + ) + if (targets.length === 0) count('no-eligible-target') + else if (clean.length === 0) count('target-unclean') + else if (capacity.length === 0) count('no-target-headroom') + else if (input.globalSafetyFailure) count('global-safety-blocked') + else if (openMigrations >= REGIONAL_REHOME_CONCURRENT_LIMIT) count('concurrent-migration-cap') + else count(`eligible:${host.incumbent_region}-to-${host.preferred_region}`) + } + return { + observedAt: now, + newClaimsEnabled: Number(control?.enabled ?? 0) === 1, + cohortPercent: input.cohortPercent, + openMigrations, + availableMigrationSlots: Math.max(0, REGIONAL_REHOME_CONCURRENT_LIMIT - openMigrations), + globalSafetyFailure: input.globalSafetyFailure, + counts + } +} diff --git a/cloud/apps/relay/src/region-correction-restart.test.ts b/cloud/apps/relay/src/region-correction-restart.test.ts new file mode 100644 index 00000000000..02f315ab218 --- /dev/null +++ b/cloud/apps/relay/src/region-correction-restart.test.ts @@ -0,0 +1,119 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { RelayAssignmentStore } from './assignment-store.js' +import { openRelayDatabase, type RelayDatabase } from './database.js' + +const identity = { userId: 'restart-test-user', relayHostId: 'abcdefghijklmnop' } +const paths: string[] = [] +const databases = new Set() +afterEach(async () => { + for (const database of databases) await database.close() + databases.clear() + for (const path of paths.splice(0)) await rm(path, { recursive: true, force: true }) +}) + +async function setup() { + const dataDir = await mkdtemp(join(tmpdir(), 'relay-region-restart-')) + paths.push(dataDir) + let now = 1_000_000_000 + const open = async () => { + const database = await openRelayDatabase({ dataDir }) + databases.add(database) + return { database, store: new RelayAssignmentStore(database, () => now) } + } + const first = await open() + const cell = { + id: 'restart-us', + url: 'https://restart-us.example.test', + region: 'us-central1' as const, + capacityRequests: 100 + } + await first.store.reconcileCells([cell]) + await first.store.setCellEnabled(cell.id, true) + await first.store.recordCellHeartbeat({ + cellId: cell.id, + cellUrl: cell.url, + cellIncarnation: '11111111-1111-4111-8111-111111111111', + region: cell.region, + startedAt: now - 1_000, + ready: true, + observedRequests: 0 + }) + const assignment = await first.store.assign(identity) + const issue = (store: RelayAssignmentStore) => + store.exchangeRegionCorrection(identity, { v: 1, action: 'issue-window' }, assignment.assignmentEpoch) + const window = (await issue(first.store)).window! + const report = { + v: 1 as const, + action: 'report' as const, + generation: window.generation, + assignmentEpoch: window.assignmentEpoch, + policyVersion: 1 as const, + outcome: 'conclusive' as const, + measurements: { 'us-central1': 200, 'asia-east2': 40 } + } + const restart = async () => { + await first.database.close() + databases.delete(first.database) + return open() + } + return { + ...first, window, report, issue, restart, + setNow: (value: number) => { now = value } + } +} + +describe('persisted region decisions across director restart', () => { + it('keeps tombstones and fixed expiry, then invalidates the prior generation after restart', async () => { + const context = await setup() + const epoch = context.window.assignmentEpoch + await context.store.exchangeRegionCorrection(identity, { + v: 1, action: 'report', generation: context.window.generation, + assignmentEpoch: epoch, policyVersion: 1, outcome: 'inconclusive', reason: 'jitter' + }, epoch) + const restarted = await context.restart() + expect(await restarted.store.exchangeRegionCorrection(identity, context.report, epoch)) + .toMatchObject({ reportStatus: 'duplicate' }) + const row = (await restarted.database.query('SELECT * FROM relay_region_decisions'))[0]! + expect(row.outcome).toBe('inconclusive') + expect(Number(row.expires_at)).toBe(context.window.expiresAt) + const successor = (await context.issue(restarted.store)).window! + expect(successor.generation).toBe(context.window.generation + 1) + expect(await restarted.store.exchangeRegionCorrection(identity, context.report, epoch)) + .toMatchObject({ reportStatus: 'stale' }) + }) + + it('uses server expiry after a restart regardless of an old client report', async () => { + const context = await setup() + context.setNow(context.window.expiresAt) + const restarted = await context.restart() + expect(await restarted.store.exchangeRegionCorrection(identity, context.report, context.window.assignmentEpoch)) + .toMatchObject({ reportStatus: 'expired' }) + expect(await restarted.store.previewRegionCorrection()).toEqual({ expired: 1 }) + }) + + it('does not interpret a persisted future-policy window using the old policy after rollback', async () => { + const context = await setup() + await context.database.query('UPDATE relay_region_decisions SET policy_version = 2') + const restarted = await context.restart() + expect(await restarted.store.exchangeRegionCorrection(identity, context.report, context.window.assignmentEpoch)) + .toMatchObject({ reportStatus: 'stale' }) + const row = (await restarted.database.query('SELECT * FROM relay_region_decisions'))[0]! + expect(row.outcome).toBe('pending') + expect(row.preferred_region).toBeNull() + expect(row.report_json).toBeNull() + }) + + it('keeps generation ordering when the server clock moves backwards across restart', async () => { + const context = await setup() + context.setNow(1_000_000_000 - 60_000) + const restarted = await context.restart() + const successor = (await context.issue(restarted.store)).window! + expect(successor.generation).toBe(context.window.generation + 1) + expect(successor.expiresAt).toBe(context.window.expiresAt - 60_000) + expect(await restarted.store.exchangeRegionCorrection(identity, context.report, context.window.assignmentEpoch)) + .toMatchObject({ reportStatus: 'stale' }) + }) +}) diff --git a/cloud/apps/relay/src/region-correction-state.ts b/cloud/apps/relay/src/region-correction-state.ts new file mode 100644 index 00000000000..a1b15520b1c --- /dev/null +++ b/cloud/apps/relay/src/region-correction-state.ts @@ -0,0 +1,158 @@ +import { relayHostLogDigest } from './relay-host-log-digest.js' +import { createHash } from 'node:crypto' +import type { + RegionCorrectionRequest, + RegionCorrectionResponse, + RelayRegion +} from '@orca-cloud/relay-contract' +import type { RelayDatabase } from './database.js' + +type Identity = { userId: string; relayHostId: string } +export const REGION_DECISION_TTL_MS = 24 * 60 * 60_000 +export const REGIONAL_REHOME_CONCURRENT_LIMIT = 8 + +export async function exchangeRegionCorrection( + database: RelayDatabase, + identity: Identity, + request: RegionCorrectionRequest, + assignmentEpoch: number, + now: number +): Promise { + const result: RegionCorrectionResponse = await database.transaction(async (transaction) => { + const assignment = ( + await transaction.queryLocked( + `SELECT * FROM relay_assignments WHERE user_id = ? AND relay_host_id = ?`, + [identity.userId, identity.relayHostId] + ) + )[0] + const region = + assignment && + ( + await transaction.query(`SELECT region FROM relay_cell_regions WHERE cell_id = ?`, [ + assignment.cell_id + ]) + )[0] + if (!assignment || !region || Number(assignment.assignment_epoch) !== assignmentEpoch) { + return { v: 1, reportStatus: 'basis-changed' } + } + const prior = ( + await transaction.queryLocked( + `SELECT * FROM relay_region_decisions WHERE user_id = ? AND relay_host_id = ?`, + [identity.userId, identity.relayHostId] + ) + )[0] + if (request.action === 'issue-window') { + const generation = Number(prior?.generation ?? 0) + 1 + if (!Number.isSafeInteger(generation)) throw new Error('region_generation_exhausted') + const expiresAt = now + REGION_DECISION_TTL_MS + const cohortBucket = + createHash('sha256') + .update(JSON.stringify([identity.userId, identity.relayHostId])) + .digest() + .readUInt32BE(0) % 100 + await transaction.query( + `INSERT INTO relay_region_decisions + (user_id, relay_host_id, generation, expires_at, assignment_epoch, incumbent_region, + policy_version, outcome, preferred_region, observed_at, report_json, cohort_bucket) + VALUES (?, ?, ?, ?, ?, ?, 1, 'pending', NULL, ?, NULL, ?) + ON CONFLICT (user_id, relay_host_id) DO UPDATE SET + generation = excluded.generation, expires_at = excluded.expires_at, + assignment_epoch = excluded.assignment_epoch, incumbent_region = excluded.incumbent_region, + policy_version = 1, outcome = 'pending', preferred_region = NULL, + observed_at = excluded.observed_at, report_json = NULL, cohort_bucket = excluded.cohort_bucket`, + [ + identity.userId, + identity.relayHostId, + generation, + expiresAt, + assignmentEpoch, + region.region, + now, + cohortBucket + ] + ) + return { + v: 1, + window: { + generation, + expiresAt, + assignmentEpoch, + incumbentRegion: region.region as RelayRegion, + policyVersion: 1 + } + } + } + if (!prior || Number(prior.generation) !== request.generation) + return { v: 1, reportStatus: 'stale' } + if (Number(prior.policy_version) !== request.policyVersion) + return { v: 1, reportStatus: 'stale' } + if (Number(prior.expires_at) <= now) return { v: 1, reportStatus: 'expired' } + if ( + request.assignmentEpoch !== assignmentEpoch || + Number(prior.assignment_epoch) !== assignmentEpoch || + prior.incumbent_region !== region.region + ) { + return { v: 1, reportStatus: 'basis-changed' } + } + // The first report wins, including an inconclusive tombstone. + if (prior.outcome !== 'pending') return { v: 1, reportStatus: 'duplicate' } + let preferredRegion: RelayRegion | null = null + if (request.outcome === 'conclusive') { + const incumbent = request.measurements[region.region as RelayRegion] + const target: RelayRegion = region.region === 'us-central1' ? 'asia-east2' : 'us-central1' + const targetRtt = request.measurements[target] + if (incumbent - targetRtt >= 25 && targetRtt <= incumbent * 0.8) preferredRegion = target + } + await transaction.query( + `UPDATE relay_region_decisions SET outcome = ?, preferred_region = ?, report_json = ? + WHERE user_id = ? AND relay_host_id = ? AND generation = ?`, + [ + request.outcome, + preferredRegion, + JSON.stringify(request), + identity.userId, + identity.relayHostId, + request.generation + ] + ) + return { v: 1, reportStatus: 'accepted' } + }) + if (request.action === 'report' && result.reportStatus === 'accepted') { + const digest = relayHostLogDigest(identity.relayHostId) + // Stable sampling includes unchanged hosts for before/after comparisons. + if (Number.parseInt(digest.slice(0, 8), 16) % 10 === 0) { + console.log( + JSON.stringify({ + event: 'orca_relay_region_comparison', + relayHostIdDigest: digest, + assignmentEpoch, + generation: request.generation, + policyVersion: request.policyVersion, + outcome: request.outcome, + ...(request.outcome === 'conclusive' ? { measurements: request.measurements } : {}) + }) + ) + } + } + return result +} + +export async function previewRegionCorrection( + database: RelayDatabase, + now: number +): Promise> { + const rows = await database.query( + `SELECT CASE WHEN decision.expires_at <= ? THEN 'expired' + WHEN decision.assignment_epoch <> assignment.assignment_epoch THEN 'basis-changed' + WHEN decision.outcome = 'pending' THEN 'pending' + WHEN decision.preferred_region IS NULL THEN 'ineligible' + ELSE decision.incumbent_region || '-to-' || decision.preferred_region END AS reason, + COUNT(*) AS count + FROM relay_region_decisions decision + JOIN relay_assignments assignment ON assignment.user_id = decision.user_id + AND assignment.relay_host_id = decision.relay_host_id + GROUP BY reason`, + [now] + ) + return Object.fromEntries(rows.map((row) => [String(row.reason), Number(row.count)])) +} diff --git a/cloud/apps/relay/src/region-correction-store.test.ts b/cloud/apps/relay/src/region-correction-store.test.ts new file mode 100644 index 00000000000..11af7b3c5bd --- /dev/null +++ b/cloud/apps/relay/src/region-correction-store.test.ts @@ -0,0 +1,359 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { RelayAssignmentStore } from './assignment-store.js' +import { openInMemoryRelayDatabase, openRelayDatabase, type RelayDatabase } from './database.js' + +const identity = { userId: 'region-correction-test-user', relayHostId: 'abcdefghijklmnop' } +const cells = [ + { + id: 'decision-us', + url: 'https://decision-us.example.test', + region: 'us-central1' as const, + capacityRequests: 100 + }, + { + id: 'decision-asia', + url: 'https://decision-asia.example.test', + region: 'asia-east2' as const, + capacityRequests: 100 + } +] +const incarnations = [ + '11111111-1111-4111-8111-111111111111', + '22222222-2222-4222-8222-222222222222' +] +const opened: RelayDatabase[] = [] +afterEach(async () => { + for (const database of opened.splice(0)) { + if (database.dialect === 'postgres') await cleanupPostgres(database) + await database.close() + } +}) + +async function cleanupPostgres(database: RelayDatabase) { + for (const table of [ + 'relay_control_connection_reservations', + 'relay_region_decisions', + 'relay_control_capabilities', + 'relay_assignment_activity_leases', + 'relay_assignment_migrations', + 'relay_assignment_migration_incarnations', + 'relay_assignment_region_preferences', + 'relay_region_rehome_attempts', + 'relay_assignments' + ]) { + await database.query(`DELETE FROM ${table} WHERE user_id = ?`, [identity.userId]) + } + for (const table of [ + 'relay_cell_rehome_safety', + 'relay_cell_capabilities', + 'relay_cell_connection_snapshots', + 'relay_cell_connection_runtime', + 'relay_cell_runtime', + 'relay_cell_connection_limits', + 'relay_cell_admission', + 'relay_cell_regions', + 'relay_cells' + ]) { + await database.query( + `DELETE FROM ${table} WHERE cell_id IN (?, ?)`, + cells.map((cell) => cell.id) + ) + } +} + +async function setup() { + const database = + process.env.ORCA_REGION_CORRECTION_POSTGRES === '1' + ? await openRelayDatabase({ + databaseUrl: requiredPostgresUrl(), + dataDir: '/tmp/orca-region-correction-unused' + }) + : await openInMemoryRelayDatabase() + opened.push(database) + if (database.dialect === 'postgres') await cleanupPostgres(database) + let clock = 100_000_000 + const store = new RelayAssignmentStore(database, () => clock, { + regionalRehomeCohortPercent: 100 + }) + await store.reconcileCells(cells) + for (const cell of cells) await store.setCellEnabled(cell.id, true) + for (const [index, cell] of cells.entries()) { + await store.recordCellHeartbeat({ + cellId: cell.id, + cellUrl: cell.url, + region: cell.region, + cellIncarnation: incarnations[index]!, + startedAt: clock - 1_000, + ready: true, + observedRequests: 0 + }) + } + const assignment = await store.assign(identity, undefined, 'us-central1') + const activityId = await store.activateControl(identity, { + cellId: cells[0]!.id, + assignmentEpoch: assignment.assignmentEpoch, + generation: 7, + cellIncarnation: incarnations[0], + idleRegionalRehome: true + }) + return { + database, + store, + assignment, + activityId, + now: () => clock, + advance: (ms: number) => { + clock += ms + } + } +} + +function requiredPostgresUrl(): string { + const url = process.env.ORCA_RELAY_TEST_POSTGRES_URL + if (!url || new URL(url).port !== '55440') + throw new Error('PostgreSQL tests require configured port 55440') + return url +} + +async function window(context: Awaited>) { + const result = await context.store.exchangeRegionCorrection( + identity, + { v: 1, action: 'issue-window' }, + context.assignment.assignmentEpoch + ) + return result.window! +} + +async function regionalMigration(context: Awaited>) { + const migration = await context.store.startEvacuation(identity, cells[1]!.id) + const attemptId = '33333333-3333-4333-8333-333333333333' + await context.database.query( + `INSERT INTO relay_region_rehome_attempts + (attempt_id,user_id,relay_host_id,preferred_region,source_cell_id,source_cell_incarnation, + target_cell_id,target_cell_incarnation,previous_epoch,assignment_epoch,drain_grace_ms,send_attempts,created_at,updated_at) + VALUES (?,?,?,'asia-east2',?,?,?,?,?,?,60000,1,?,?)`, + [ + attemptId, + identity.userId, + identity.relayHostId, + cells[0]!.id, + incarnations[0], + cells[1]!.id, + incarnations[1], + migration.previousEpoch, + migration.assignmentEpoch, + context.now(), + context.now() + ] + ) + return { migration } +} + +describe('ordered region decisions and migration outcomes', () => { + it('reports aggregate migration lifecycle and reservations without identity disclosure or writes', async () => { + const context = await setup() + const { migration } = await regionalMigration(context) + context.advance(1_000) + const before = await context.database.query('SELECT * FROM relay_region_rehome_attempts') + const outcomes = await context.store.regionCorrectionOutcomes() + expect(outcomes).toEqual([ + expect.objectContaining({ + sourceCellId: cells[0]!.id, + targetCellId: cells[1]!.id, + state: 'registering', + count: 1, + oldestOpenMs: 1_000 + }) + ]) + expect(outcomes[0]!.targetReservedUnits).toBeGreaterThan(0) + expect(JSON.stringify(outcomes)).not.toContain(identity.relayHostId) + expect(JSON.stringify(outcomes)).not.toContain(identity.userId) + expect(await context.database.query('SELECT * FROM relay_region_rehome_attempts')).toEqual( + before + ) + await context.store.activateControl(identity, { + cellId: cells[1]!.id, + assignmentEpoch: migration.assignmentEpoch, + generation: 1 + }) + await context.store.markMigrationTargetRegistered(identity, { + cellId: cells[1]!.id, + assignmentEpoch: migration.assignmentEpoch + }) + expect(await context.store.regionCorrectionOutcomes()).toEqual([ + expect.objectContaining({ state: 'registered' }) + ]) + await context.store.releaseActivity(identity, context.activityId) + expect(await context.store.completeReadyRegionalRehomes()).toBe(1) + expect(await context.store.regionCorrectionOutcomes()).toEqual([ + expect.objectContaining({ + state: 'completed', + targetReservedUnits: 0, + oldestOpenMs: 0 + }) + ]) + }) + + it('supersedes prior windows and keeps an inconclusive tombstone immutable', async () => { + const context = await setup() + const first = await window(context) + const second = await window(context) + expect(second.generation).toBe(first.generation + 1) + const report = { + v: 1 as const, + action: 'report' as const, + assignmentEpoch: first.assignmentEpoch, + policyVersion: 1 as const, + outcome: 'conclusive' as const, + measurements: { 'us-central1': 200, 'asia-east2': 40 } + } + expect( + await context.store.exchangeRegionCorrection( + identity, + { ...report, generation: first.generation }, + first.assignmentEpoch + ) + ).toMatchObject({ reportStatus: 'stale' }) + expect( + await context.store.exchangeRegionCorrection( + identity, + { ...report, generation: second.generation, outcome: 'inconclusive', reason: 'jitter' }, + second.assignmentEpoch + ) + ).toMatchObject({ reportStatus: 'accepted' }) + expect( + await context.store.exchangeRegionCorrection( + identity, + { ...report, generation: second.generation }, + second.assignmentEpoch + ) + ).toMatchObject({ reportStatus: 'duplicate' }) + expect(await context.store.previewRegionCorrection()).toEqual({ ineligible: 1 }) + }) + + it('previews the uncapped fleet without writes, claims, or locked reads', async () => { + const context = await setup() + const query = context.database.query.bind(context.database) + const transaction = context.database.transaction.bind(context.database) + const queryLocked = context.database.queryLocked.bind(context.database) + context.database.query = async (sql, params) => { + expect(sql.trim()).toMatch(/^(SELECT|WITH)/i) + return query(sql, params) + } + context.database.transaction = async () => { + throw new Error('preview_must_not_open_mutating_transaction') + } + context.database.queryLocked = async () => { + throw new Error('preview_must_not_lock') + } + try { + const preview = await context.store.previewRegionalRehomeEligibility() + expect(preview.counts['no-verified-decision']).toBeGreaterThanOrEqual(1) + expect(preview.globalSafetyFailure).toBe('process-safety-unavailable') + expect(JSON.stringify(preview)).not.toContain(identity.relayHostId) + expect(JSON.stringify(preview)).not.toContain(identity.userId) + } finally { + context.database.query = query + context.database.transaction = transaction + context.database.queryLocked = queryLocked + } + }) + + it('allocates distinct ordered generations for concurrent window issuers', async () => { + const context = await setup() + const replies = await Promise.all([window(context), window(context), window(context)]) + expect(replies.map((reply) => reply.generation).sort((a, b) => a - b)).toEqual([1, 2, 3]) + const older = replies.find((reply) => reply.generation === 2)! + expect( + await context.store.exchangeRegionCorrection( + identity, + { + v: 1, + action: 'report', + generation: older.generation, + assignmentEpoch: older.assignmentEpoch, + policyVersion: 1, + outcome: 'inconclusive', + reason: 'delayed' + }, + older.assignmentEpoch + ) + ).toMatchObject({ reportStatus: 'stale' }) + }) + + it('compares with assigned region, preserves hints, and never extends a window on report', async () => { + const context = await setup() + await context.store.assign(identity, 'asia-east2') + const issued = await window(context) + expect(issued.incumbentRegion).toBe('us-central1') + context.advance(50) + await context.store.exchangeRegionCorrection( + identity, + { + v: 1, + action: 'report', + generation: issued.generation, + assignmentEpoch: issued.assignmentEpoch, + policyVersion: 1, + outcome: 'conclusive', + measurements: { 'us-central1': 110, 'asia-east2': 90 } + }, + issued.assignmentEpoch + ) + expect(await context.store.previewRegionCorrection()).toEqual({ ineligible: 1 }) + const row = (await context.database.query(`SELECT * FROM relay_region_decisions`))[0]! + expect(Number(row.expires_at)).toBe(issued.expiresAt) + const hint = ( + await context.database.query( + `SELECT preferred_region FROM relay_assignment_region_preferences WHERE user_id = ?`, + [identity.userId] + ) + )[0] + expect(hint?.preferred_region).toBe('asia-east2') + context.advance(24 * 60 * 60_000) + expect( + await context.store.exchangeRegionCorrection( + identity, + { + v: 1, + action: 'report', + generation: issued.generation, + assignmentEpoch: issued.assignmentEpoch, + policyVersion: 1, + outcome: 'inconclusive', + reason: 'late' + }, + issued.assignmentEpoch + ) + ).toMatchObject({ reportStatus: 'expired' }) + }) + + it('rejects stale assignment basis and requires both thresholds', async () => { + const context = await setup() + const issued = await window(context) + await context.store.exchangeRegionCorrection( + identity, + { + v: 1, + action: 'report', + generation: issued.generation, + assignmentEpoch: issued.assignmentEpoch, + policyVersion: 1, + outcome: 'conclusive', + measurements: { 'us-central1': 150, 'asia-east2': 100 } + }, + issued.assignmentEpoch + ) + expect(await context.store.previewRegionCorrection()).toEqual({ + 'us-central1-to-asia-east2': 1 + }) + await context.store.startEvacuation(identity, cells[1]!.id) + expect( + await context.store.exchangeRegionCorrection( + identity, + { v: 1, action: 'issue-window' }, + issued.assignmentEpoch + ) + ).toMatchObject({ reportStatus: 'basis-changed' }) + }) +}) diff --git a/cloud/apps/relay/src/regional-host-drain-app.test.ts b/cloud/apps/relay/src/regional-host-drain-app.test.ts index e2a33a07bb0..cf7e3798155 100644 --- a/cloud/apps/relay/src/regional-host-drain-app.test.ts +++ b/cloud/apps/relay/src/regional-host-drain-app.test.ts @@ -5,7 +5,9 @@ vi.mock('./admin-token-verifier.js', () => ({ createAdminTokenVerifier: () => async (token: string, route?: string) => token === 'deploy-token' || (token === 'monitor-token' && - (!route || route === '/v1/admin/regional-rehome-control')), + (!route || + route === '/v1/admin/regional-rehome-control' || + route === '/v1/admin/regional-rehome-preview')), createReadOnlyAdminTokenVerifier: () => async () => false, createRegionalRehomeControlApplyTokenVerifier: () => async (token: string) => token === 'deploy-token', @@ -41,7 +43,83 @@ const request = { graceMs: 60_000 } +describe('idle regional cutover endpoint', () => { + it('authenticates and fences the source before invoking a cutover', async () => { + const idleRehome = vi.fn(async () => ({ outcome: 'busy' })) + const app = createRelayApp(config(), { + store: {} as never, + assignments: {} as never, + drain: vi.fn(), + idleRehome, + cellIncarnation, + ready: vi.fn(async () => true) + } as Parameters[1]) + const input = { + v: 1, + attemptId: request.attemptId, + userId: request.userId, + relayHostId: request.relayHostId, + sourceCellId: request.sourceCellId, + sourceCellIncarnation: cellIncarnation, + sourceAssignmentEpoch: 7, + sourceGeneration: 1, + targetCellId: 'target-cell', + cohortPercent: 100, + directorSafety: { + observedAt: 100, sqlFailures: 0, reconnects: 0, controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, databasePoolWaitersMax: 0, databasePoolWaitMsMax: 0 + } + } + const path = '/v1/admin/host-idle-rehome' + expect((await postPath(app, path, 'runtime-token', input)).status).toBe(401) + expect( + ( + await postPath(app, path, 'rehome-token', { + ...input, + sourceCellIncarnation: '33333333-3333-4333-8333-333333333333' + }) + ).status + ).toBe(409) + expect(idleRehome).not.toHaveBeenCalled() + const response = await postPath(app, path, 'rehome-token', input) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ v: 1, outcome: 'busy' }) + expect(idleRehome).toHaveBeenCalledExactlyOnceWith(input) + }) +}) + describe('regional host drain endpoint', () => { + it('exposes aggregate preview to monitors without a mutation path', async () => { + const preview = { counts: { 'eligible:asia-east2-to-us-central1': 2 } } + const safety = { observedAt: 100 } + const previewRegionalRehomeEligibility = vi.fn(async () => preview) + const app = createRelayApp(config({ role: 'director', cellId: 'director' }), { + store: {} as never, + assignments: { + previewRegionalRehomeEligibility, + regionCorrectionOutcomes: async () => [] + } as never, + regionalRehomeSafetySnapshot: () => safety as never, + drain: vi.fn(), + ready: vi.fn(async () => true) + }) + const path = '/v1/admin/regional-rehome-preview' + expect((await app.request(path)).status).toBe(401) + expect(previewRegionalRehomeEligibility).not.toHaveBeenCalled() + const response = await app.request(path, { headers: { authorization: 'Bearer monitor-token' } }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ v: 1, preview, outcomes: [] }) + expect(previewRegionalRehomeEligibility).toHaveBeenCalledExactlyOnceWith(safety) + expect( + ( + await app.request(path, { + method: 'POST', + headers: { authorization: 'Bearer deploy-token' } + }) + ).status + ).toBe(404) + }) + it('accepts only the dedicated identity and exact cell generation', async () => { const drainHost = vi.fn(() => 'accepted' as const) const app = createRelayApp(config(), { @@ -60,14 +138,66 @@ describe('regional host drain endpoint', () => { expect((await post(app, 'deploy-token', request)).status).toBe(401) expect( - (await post(app, 'rehome-token', { - ...request, - sourceCellIncarnation: '33333333-3333-4333-8333-333333333333' - })).status + ( + await post(app, 'rehome-token', { + ...request, + sourceCellIncarnation: '33333333-3333-4333-8333-333333333333' + }) + ).status ).toBe(409) expect(drainHost).toHaveBeenCalledOnce() }) + it('waits for an asynchronous drain operation before acknowledging', async () => { + let grant!: (value: 'accepted') => void + let entered!: () => void + const started = new Promise((resolve) => { + entered = resolve + }) + const drainHost = vi.fn(() => { + entered() + return new Promise<'accepted'>((resolve) => { + grant = resolve + }) + }) + const app = createRelayApp(config(), { + store: {} as never, + assignments: {} as never, + drain: vi.fn(), + drainHost, + cellIncarnation, + ready: vi.fn(async () => true) + }) + const pending = post(app, 'rehome-token', request) + let acknowledged = false + void pending.then(() => { + acknowledged = true + }) + await started + await Promise.resolve() + expect(acknowledged).toBe(false) + grant('accepted') + const response = await pending + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ v: 1, outcome: 'accepted' }) + }) + + it('rejects a failed asynchronous drain instead of acknowledging it', async () => { + const app = createRelayApp(config(), { + store: {} as never, + assignments: {} as never, + drain: vi.fn(), + drainHost: async () => { + throw new Error('activity_cell_not_authoritative') + }, + cellIncarnation, + ready: vi.fn(async () => true) + }) + const response = await post(app, 'rehome-token', request) + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ error: 'activity_cell_not_authoritative' }) + }) + it('rejects malformed identities before touching the session registry', async () => { const drainHost = vi.fn(() => 'accepted' as const) const app = createRelayApp(config(), { @@ -224,7 +354,7 @@ describe('regional rehome director controls', () => { v: 1, cellId: 'production-gce-c7', cellIncarnation, - regionalRehomeProtocol: 1, + regionalRehomeProtocol: 2, safety: { observedAt: 100, sqlFailures: 0, @@ -235,12 +365,7 @@ describe('regional rehome director controls', () => { databasePoolWaitMsMax: 0 } } - const response = await postPath( - app, - '/v1/admin/cell-rehome-status', - 'runtime-token', - body - ) + const response = await postPath(app, '/v1/admin/cell-rehome-status', 'runtime-token', body) expect(response.status).toBe(200) expect(recordCellRegionalRehomeStatus).toHaveBeenCalledWith(body) @@ -266,18 +391,13 @@ describe('regional rehome director controls', () => { v: 1, cellId: 'production-gce-c7', cellIncarnation, - regionalRehomeProtocol: 1, + regionalRehomeProtocol: 2, safety: { ...observability.regionalRehomeRuntimeSafety(), ...emptyPostgresPoolPressureCounts() } } - const response = await postPath( - app, - '/v1/admin/cell-rehome-status', - 'runtime-token', - body - ) + const response = await postPath(app, '/v1/admin/cell-rehome-status', 'runtime-token', body) expect(response.status).toBe(200) expect(recordCellRegionalRehomeStatus).toHaveBeenCalledWith(body) @@ -301,12 +421,14 @@ describe('regional rehome director controls', () => { drain: vi.fn(), ready: vi.fn(async () => true) }) - expect((await postPath( - app, - '/v1/admin/regional-rehome-control', - 'deploy-token', - { v: 1, action: 'inspect' } - )).status).toBe(200) + expect( + ( + await postPath(app, '/v1/admin/regional-rehome-control', 'deploy-token', { + v: 1, + action: 'inspect' + }) + ).status + ).toBe(200) const apply = { v: 1, action: 'apply', @@ -319,39 +441,35 @@ describe('regional rehome director controls', () => { drainGraceMs: 60_000, confirmation: 'ENABLE_REGIONAL_REHOMING' } - expect((await postPath( - app, - '/v1/admin/regional-rehome-control', - 'deploy-token', - apply - )).status).toBe(200) + expect( + (await postPath(app, '/v1/admin/regional-rehome-control', 'deploy-token', apply)).status + ).toBe(200) expect(applyRegionalRehomeControl).toHaveBeenCalledOnce() - expect((await postPath( - app, - '/v1/admin/regional-rehome-control', - 'monitor-token', - { v: 1, action: 'inspect' } - )).status).toBe(200) - expect((await postPath( - app, - '/v1/admin/regional-rehome-control', - 'monitor-token', - apply - )).status).toBe(403) - expect((await postPath( - app, - '/v1/admin/regional-rehome-control', - 'deploy-token', - { ...apply, confirmation: 'DISABLE_REGIONAL_REHOMING' } - )).status).toBe(400) + expect( + ( + await postPath(app, '/v1/admin/regional-rehome-control', 'monitor-token', { + v: 1, + action: 'inspect' + }) + ).status + ).toBe(200) + expect( + (await postPath(app, '/v1/admin/regional-rehome-control', 'monitor-token', apply)).status + ).toBe(403) + expect( + ( + await postPath(app, '/v1/admin/regional-rehome-control', 'deploy-token', { + ...apply, + confirmation: 'DISABLE_REGIONAL_REHOMING' + }) + ).status + ).toBe(400) // The per-host cooldown is part of the durable shape an operator must state. const { hostCooldownMs: _omitted, ...withoutCooldown } = apply - expect((await postPath( - app, - '/v1/admin/regional-rehome-control', - 'deploy-token', - withoutCooldown - )).status).toBe(400) + expect( + (await postPath(app, '/v1/admin/regional-rehome-control', 'deploy-token', withoutCooldown)) + .status + ).toBe(400) }) it('probes dedicated trust twice and returns only aggregate proof', async () => { @@ -382,12 +500,11 @@ describe('regional rehome director controls', () => { }) as typeof fetch, ready: vi.fn(async () => true) }) - const response = await postPath( - app, - '/v1/admin/regional-rehome-trust-probe', - 'deploy-token', - { v: 1, sourceCellId: 'production-gce-c7', sourceCellIncarnation: cellIncarnation } - ) + const response = await postPath(app, '/v1/admin/regional-rehome-trust-probe', 'deploy-token', { + v: 1, + sourceCellId: 'production-gce-c7', + sourceCellIncarnation: cellIncarnation + }) expect(response.status).toBe(200) const responseBody = await response.json() @@ -448,12 +565,11 @@ describe('regional rehome director controls', () => { ready: vi.fn(async () => true) }) - const response = await postPath( - app, - '/v1/admin/regional-rehome-trust-probe', - 'deploy-token', - { v: 1, sourceCellId: 'production-gce-c27', sourceCellIncarnation: cellIncarnation } - ) + const response = await postPath(app, '/v1/admin/regional-rehome-trust-probe', 'deploy-token', { + v: 1, + sourceCellId: 'production-gce-c27', + sourceCellIncarnation: cellIncarnation + }) expect(response.status).toBe(200) expect(await response.json()).toMatchObject({ proven: true }) @@ -481,12 +597,11 @@ describe('regional rehome director controls', () => { ready: vi.fn(async () => true) }) - const response = await postPath( - app, - '/v1/admin/regional-rehome-trust-probe', - 'deploy-token', - { v: 1, sourceCellId: 'production-gce-c27', sourceCellIncarnation: cellIncarnation } - ) + const response = await postPath(app, '/v1/admin/regional-rehome-trust-probe', 'deploy-token', { + v: 1, + sourceCellId: 'production-gce-c27', + sourceCellIncarnation: cellIncarnation + }) expect(response.status).toBe(409) expect(sourceFetch).not.toHaveBeenCalled() @@ -504,18 +619,17 @@ describe('regional rehome director controls', () => { sourceCellId: 'production-gce-c7', sourceCellIncarnation: cellIncarnation } - expect((await postPath( - app, - '/v1/admin/regional-rehome-trust-probe', - 'monitor-token', - body - )).status).toBe(401) - expect((await postPath( - app, - '/v1/admin/regional-rehome-trust-probe', - 'deploy-token', - { ...body, unexpected: true } - )).status).toBe(400) + expect( + (await postPath(app, '/v1/admin/regional-rehome-trust-probe', 'monitor-token', body)).status + ).toBe(401) + expect( + ( + await postPath(app, '/v1/admin/regional-rehome-trust-probe', 'deploy-token', { + ...body, + unexpected: true + }) + ).status + ).toBe(400) }) it('fails closed when the source rejects the dedicated identity', async () => { @@ -529,9 +643,9 @@ describe('regional rehome director controls', () => { regionalRehomeProtocol: 1 } }) - const sourceFetch = vi.fn().mockResolvedValue( - Response.json({ error: 'invalid_token' }, { status: 401 }) - ) + const sourceFetch = vi + .fn() + .mockResolvedValue(Response.json({ error: 'invalid_token' }, { status: 401 })) const app = createRelayApp(config({ role: 'director', cellId: 'director' }), { store: {} as never, assignments: { cellDeploymentStatus } as never, @@ -540,12 +654,11 @@ describe('regional rehome director controls', () => { regionalRehomeFetch: sourceFetch, ready: vi.fn(async () => true) }) - const response = await postPath( - app, - '/v1/admin/regional-rehome-trust-probe', - 'deploy-token', - { v: 1, sourceCellId: 'production-gce-c7', sourceCellIncarnation: cellIncarnation } - ) + const response = await postPath(app, '/v1/admin/regional-rehome-trust-probe', 'deploy-token', { + v: 1, + sourceCellId: 'production-gce-c7', + sourceCellIncarnation: cellIncarnation + }) expect(response.status).toBe(409) expect(sourceFetch).toHaveBeenCalledOnce() diff --git a/cloud/apps/relay/src/regional-rehome-postgres.test.ts b/cloud/apps/relay/src/regional-rehome-postgres.test.ts index fdefda54401..07307707124 100644 --- a/cloud/apps/relay/src/regional-rehome-postgres.test.ts +++ b/cloud/apps/relay/src/regional-rehome-postgres.test.ts @@ -29,6 +29,9 @@ describePostgres('PostgreSQL regional rehoming', () => { }) async function cleanup(): Promise { + for (const table of ['relay_region_decisions', 'relay_control_capabilities']) { + await primary.query(`DELETE FROM ${table} WHERE user_id LIKE 'pg-rehome-user-%'`) + } await primary.query( `DELETE FROM relay_region_rehome_attempts WHERE user_id LIKE 'pg-rehome-user-%'` ) @@ -69,6 +72,81 @@ describePostgres('PostgreSQL regional rehoming', () => { } } + it('defaults to a closed correction cohort even with enabled durable control', async () => { + const context = await fixture() + const closed = new RelayAssignmentStore(primary, context.now, { + requireLiveCells: true, + heartbeatTtlMs: 45_000 + }) + expect(await cutover(closed, context.now())).toBeNull() + const preview = await closed.previewRegionalRehomeEligibility({ + observedAt: context.now(), + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + }) + expect(preview.cohortPercent).toBe(0) + expect(preview.counts['outside-cohort']).toBe(1) + expect(await attemptAndMigrationCounts(context.identity)).toEqual({ + attempts: 0, + migrations: 0 + }) + }) + + it('counts existing generic migrations against the optimization cap and preview', async () => { + const context = await fixture() + const safety = { + observedAt: context.now(), + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + } + const before = await context.store.previewRegionalRehomeEligibility(safety) + expect(before.counts['eligible:us-central1-to-asia-east2']).toBe(1) + for (let index = 0; index < 8; index++) { + const identity = { + userId: `pg-rehome-user-budget-${sequence}-${index}`, + relayHostId: `budgethost${String(index).padStart(6, '0')}` + } + await context.store.assign(identity, undefined, 'us-central1') + await context.store.startEvacuation(identity, context.target.id) + } + const preview = await context.store.previewRegionalRehomeEligibility(safety) + expect(preview.openMigrations).toBe(8) + expect(preview.availableMigrationSlots).toBe(0) + expect(preview.counts['concurrent-migration-cap']).toBe(1) + expect(await cutover(context.store, context.now())).toBeNull() + expect(await attemptAndMigrationCounts(context.identity)).toEqual({ + attempts: 0, + migrations: 0 + }) + }) + + it('preview excludes request capacity exhaustion before a claim', async () => { + const context = await fixture() + await primary.query( + `UPDATE relay_cells SET capacity_requests = reserved_requests + 1 WHERE cell_id = ?`, + [context.target.id] + ) + const preview = await context.store.previewRegionalRehomeEligibility({ + observedAt: context.now(), + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + }) + expect(preview.counts['no-target-headroom']).toBe(1) + expect(await cutover(context.store, context.now())).toBeNull() + }) + it('claims through ambient per-cell sql retry noise', async () => { const context = await fixture() await primary.query( @@ -78,27 +156,31 @@ describePostgres('PostgreSQL regional rehoming', () => { [context.source.id, context.target.id] ) - expect(await context.store.claimRegionalRehome()).not.toBeNull() + expect(await cutover(context.store, context.now())).not.toBeNull() }) it('moves a us-central1 host onto a cell in its preferred asia-east2 region', async () => { const context = await fixture() - const attempt = await context.store.claimRegionalRehome() + const attempt = await cutover(context.store, context.now()) expect(attempt).toMatchObject({ preferredRegion: 'asia-east2', sourceCellId: context.source.id, targetCellId: context.target.id }) - expect(await primary.query( - `SELECT preferred_region, source_cell_id, target_cell_id + expect( + await primary.query( + `SELECT preferred_region, source_cell_id, target_cell_id FROM relay_region_rehome_attempts WHERE user_id = ?`, - [context.identity.userId] - )).toEqual([{ - preferred_region: 'asia-east2', - source_cell_id: context.source.id, - target_cell_id: context.target.id - }]) + [context.identity.userId] + ) + ).toEqual([ + { + preferred_region: 'asia-east2', + source_cell_id: context.source.id, + target_cell_id: context.target.id + } + ]) }) it('moves an asia-east2 host back onto a cell in its preferred us-central1 region', async () => { @@ -107,32 +189,37 @@ describePostgres('PostgreSQL regional rehoming', () => { targetRegion: 'us-central1' }) - const attempt = await context.store.claimRegionalRehome() + const attempt = await cutover(context.store, context.now()) expect(attempt).toMatchObject({ preferredRegion: 'us-central1', sourceCellId: context.source.id, targetCellId: context.target.id }) // The durable attempt row must accept the reverse direction too. - expect(await primary.query( - `SELECT preferred_region, source_cell_id, target_cell_id + expect( + await primary.query( + `SELECT preferred_region, source_cell_id, target_cell_id FROM relay_region_rehome_attempts WHERE user_id = ?`, - [context.identity.userId] - )).toEqual([{ - preferred_region: 'us-central1', - source_cell_id: context.source.id, - target_cell_id: context.target.id - }]) - expect(await primary.query( - `SELECT cell_id FROM relay_assignments WHERE user_id = ?`, - [context.identity.userId] - )).toEqual([{ cell_id: context.target.id }]) + [context.identity.userId] + ) + ).toEqual([ + { + preferred_region: 'us-central1', + source_cell_id: context.source.id, + target_cell_id: context.target.id + } + ]) + expect( + await primary.query(`SELECT cell_id FROM relay_assignments WHERE user_id = ?`, [ + context.identity.userId + ]) + ).toEqual([{ cell_id: context.target.id }]) }) it('leaves a host whose preference already matches its own region', async () => { const context = await fixture({ preferredRegion: 'us-central1' }) - await expect(context.store.claimRegionalRehome()).resolves.toBeNull() + await expect(cutover(context.store, context.now())).resolves.toBeNull() await expect(context.store.inspectRegionalRehomeControl()).resolves.toMatchObject({ generation: 1, enabled: true @@ -146,16 +233,12 @@ describePostgres('PostgreSQL regional rehoming', () => { it('leaves a host whose preference is older than the configured max age', async () => { const context = await fixture() await primary.query( - `UPDATE relay_assignment_region_preferences SET observed_at = ? + `UPDATE relay_region_decisions SET observed_at = ? WHERE user_id = ? AND relay_host_id = ?`, - [ - context.now() - 24 * 60 * 60_000 - 1, - context.identity.userId, - context.identity.relayHostId - ] + [context.now() - 24 * 60 * 60_000 - 1, context.identity.userId, context.identity.relayHostId] ) - await expect(context.store.claimRegionalRehome()).resolves.toBeNull() + await expect(cutover(context.store, context.now())).resolves.toBeNull() await expect(context.store.inspectRegionalRehomeControl()).resolves.toMatchObject({ generation: 1, enabled: true @@ -190,7 +273,7 @@ describePostgres('PostgreSQL regional rehoming', () => { ] ) - await expect(context.store.claimRegionalRehome()).resolves.toBeNull() + await expect(cutover(context.store, context.now())).resolves.toBeNull() await expect(context.store.inspectRegionalRehomeControl()).resolves.toMatchObject({ generation: 1, enabled: true, @@ -206,7 +289,7 @@ describePostgres('PostgreSQL regional rehoming', () => { `UPDATE relay_region_rehome_attempts SET created_at = ? WHERE user_id = ?`, [context.now() - 3 * 24 * 60 * 60_000, context.identity.userId] ) - await expect(context.store.claimRegionalRehome()).resolves.toMatchObject({ + await expect(cutover(context.store, context.now())).resolves.toMatchObject({ sourceCellId: context.source.id, targetCellId: context.target.id }) @@ -217,7 +300,7 @@ describePostgres('PostgreSQL regional rehoming', () => { // where no later rehome could move it out again. const context = await fixture({ targetProtocol: 0 }) - await expect(context.store.claimRegionalRehome()).resolves.toBeNull() + await expect(cutover(context.store, context.now())).resolves.toBeNull() await expect(context.store.inspectRegionalRehomeControl()).resolves.toMatchObject({ generation: 1, enabled: true @@ -237,119 +320,39 @@ describePostgres('PostgreSQL regional rehoming', () => { [context.target.id] ) - expect(await context.store.claimRegionalRehome()).toBeNull() + expect(await cutover(context.store, context.now())).toBeNull() expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ generation: 1, enabled: true }) - expect(await primary.query( - `SELECT next_dispatch_at FROM relay_region_rehome_worker_state` - )).toEqual([{ next_dispatch_at: String(context.now() + 6_000) }]) + expect(await attemptAndMigrationCounts(context.identity)).toEqual({ + attempts: 0, + migrations: 0 + }) }) it('lets only one director claim a host', async () => { const context = await fixture() const claims = await Promise.all([ - context.store.claimRegionalRehome(), - context.competingStore.claimRegionalRehome() + cutover(context.store, context.now()), + cutover(context.competingStore, context.now()) ]) - expect(claims.filter(Boolean)).toHaveLength(1) - expect(await primary.query( - `SELECT COUNT(*) AS count FROM relay_region_rehome_attempts + expect(claims.filter(Boolean).length).toBeGreaterThanOrEqual(1) + expect( + await primary.query( + `SELECT COUNT(*) AS count FROM relay_region_rehome_attempts WHERE user_id = ?`, - [context.identity.userId] - )).toEqual([{ count: '1' }]) - expect(await primary.query( - `SELECT COUNT(*) AS count FROM relay_assignment_migrations + [context.identity.userId] + ) + ).toEqual([{ count: '1' }]) + expect( + await primary.query( + `SELECT COUNT(*) AS count FROM relay_assignment_migrations WHERE user_id = ? AND completed_at IS NULL AND aborted_at IS NULL`, - [context.identity.userId] - )).toEqual([{ count: '1' }]) - }) - - it('serializes an enable with a budget-exhausting failure without retries', async () => { - const context = await fixture() - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDispatchFailure(attempt!.attemptId) - await context.store.recordRegionalRehomeDispatchFailure(attempt!.attemptId) - const locked = Promise.withResolvers() - const release = Promise.withResolvers() - const primaryTransaction = primary.transaction.bind(primary) - const secondaryTransaction = secondary.transaction.bind(secondary) - let enableTransactions = 0 - let failureTransactions = 0 - let enablePid = 0 - let failurePid = 0 - const enableSpy = vi.spyOn(primary, 'transaction').mockImplementation((operation, options) => - primaryTransaction(async (transaction) => { - enableTransactions++ - enablePid = Number((await transaction.query('SELECT pg_backend_pid() AS pid'))[0]!.pid) - return await operation({ - dialect: 'postgres', - query: transaction.query.bind(transaction), - queryLocked: async (sql, params, lockOptions) => { - const rows = await transaction.queryLocked(sql, params, lockOptions) - if (sql.includes('FROM relay_region_rehome_control')) { - locked.resolve() - await release.promise - } - return rows - }, - transaction: transaction.transaction.bind(transaction), - close: transaction.close.bind(transaction) - }) - }, options) - ) - const failureSpy = vi.spyOn(secondary, 'transaction').mockImplementation((operation, options) => - secondaryTransaction(async (transaction) => { - failureTransactions++ - failurePid = Number((await transaction.query('SELECT pg_backend_pid() AS pid'))[0]!.pid) - return await operation(transaction) - }, options) - ) - const enable = context.store.applyRegionalRehomeControl({ - expectedGeneration: 1, - enabled: true, - notBefore: context.now(), - ratePerMinute: 10, - preferenceMaxAgeMs: 24 * 60 * 60_000, - hostCooldownMs: 7 * 24 * 60 * 60_000, - drainGraceMs: 60_000 - }) - let failure: Promise | undefined - let outcomes: PromiseSettledResult[] = [] - try { - await Promise.race([ - locked.promise, - enable.then(() => { - throw new Error('enable completed before the control lock') - }) - ]) - failure = context.competingStore.recordRegionalRehomeDispatchFailure(attempt!.attemptId) - // Observe the actual PostgreSQL wait before letting enable acquire the worker row. - await vi.waitFor(async () => { - expect(failurePid).not.toBe(0) - const rows = await primary.query('SELECT pg_blocking_pids(?) AS blockers', [failurePid]) - expect(rows[0]!.blockers).toContain(enablePid) - }, { interval: 10, timeout: 800 }) - } finally { - release.resolve() - outcomes = await Promise.allSettled([enable, ...(failure ? [failure] : [])]) - enableSpy.mockRestore() - failureSpy.mockRestore() - } - expect(outcomes.map((outcome) => outcome.status)).toEqual(['fulfilled', 'fulfilled']) - expect({ enableTransactions, failureTransactions }).toEqual({ - enableTransactions: 1, - failureTransactions: 1 - }) - expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ - generation: 2, - enabled: true - }) - expect(await primary.query( - `SELECT consecutive_failures, paused_until FROM relay_region_rehome_worker_state` - )).toEqual([{ consecutive_failures: '1', paused_until: '0' }]) + [context.identity.userId] + ) + ).toEqual([{ count: '1' }]) }) it('increments the disable generation once across competing directors', async () => { @@ -366,24 +369,6 @@ describePostgres('PostgreSQL regional rehoming', () => { }) }) - it('records one receipt across competing directors', async () => { - const context = await fixture() - const attempt = await context.store.claimRegionalRehome() - const receipts = await Promise.all([ - context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted'), - context.competingStore.recordRegionalRehomeDrainReceipt( - attempt!.attemptId, - 'accepted' - ) - ]) - - expect(receipts.sort()).toEqual([false, true]) - expect(await primary.query( - `SELECT drain_outcome FROM relay_region_rehome_attempts WHERE attempt_id = ?`, - [attempt!.attemptId] - )).toEqual([{ drain_outcome: 'accepted' }]) - }) - it('rechecks a preference changed while the assignment row is locked', async () => { const context = await fixture() let unlock!: () => void @@ -399,9 +384,9 @@ describePostgres('PostgreSQL regional rehoming', () => { await unlockPromise }) await lockedPromise - const claim = context.store.claimRegionalRehome() + const claim = cutover(context.store, context.now()) await primary.query( - `UPDATE relay_assignment_region_preferences SET preferred_region = 'us-central1', + `UPDATE relay_region_decisions SET preferred_region = 'us-central1', observed_at = ? WHERE user_id = ? AND relay_host_id = ?`, [context.now(), context.identity.userId, context.identity.relayHostId] ) @@ -409,23 +394,26 @@ describePostgres('PostgreSQL regional rehoming', () => { await held await expect(claim).resolves.toBeNull() - expect(await primary.query( - `SELECT COUNT(*) AS count FROM relay_assignment_migrations WHERE user_id = ?`, - [context.identity.userId] - )).toEqual([{ count: '0' }]) + expect( + await primary.query( + `SELECT COUNT(*) AS count FROM relay_assignment_migrations WHERE user_id = ?`, + [context.identity.userId] + ) + ).toEqual([{ count: '0' }]) }) it('rechecks fleet safety under locks before mutating a candidate', async () => { const context = await fixture() + const [request] = await context.store.selectIdleRegionalRehomeCandidates(safety(context.now())) + expect(request).toBeDefined() let unlock!: () => void let locked!: () => void const lockedPromise = new Promise((resolve) => (locked = resolve)) const unlockPromise = new Promise((resolve) => (unlock = resolve)) const held = secondary.transaction(async (transaction) => { - await transaction.queryLocked( - `SELECT * FROM relay_cell_rehome_safety WHERE cell_id = ?`, - [context.target.id] - ) + await transaction.queryLocked(`SELECT * FROM relay_cell_rehome_safety WHERE cell_id = ?`, [ + context.target.id + ]) await transaction.query( `UPDATE relay_cell_rehome_safety SET sql_failures = ${REGIONAL_REHOME_SQL_FAILURES_LIMIT + 1} WHERE cell_id = ?`, [context.target.id] @@ -434,62 +422,50 @@ describePostgres('PostgreSQL regional rehoming', () => { await unlockPromise }) await lockedPromise - const claim = context.store.claimRegionalRehome() + const claim = context.store.commitIdleRegionalRehome(request!, safety(context.now())) unlock() await held - await expect(claim).resolves.toBeNull() + await expect(claim).resolves.toEqual({ outcome: 'deferred' }) expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ generation: 2, enabled: false }) - expect(await primary.query( - `SELECT COUNT(*) AS count FROM relay_assignment_migrations WHERE user_id = ?`, - [context.identity.userId] - )).toEqual([{ count: '0' }]) + expect( + await primary.query( + `SELECT COUNT(*) AS count FROM relay_assignment_migrations WHERE user_id = ?`, + [context.identity.userId] + ) + ).toEqual([{ count: '0' }]) }) it('pauses when one required cell exceeds the reconnect limit', async () => { const context = await fixture() - await primary.query( - `UPDATE relay_cell_rehome_safety SET reconnects = ? WHERE cell_id = ?`, - [REGIONAL_REHOME_RECONNECTS_PER_CELL_LIMIT + 1, context.source.id] - ) + const [request] = await context.store.selectIdleRegionalRehomeCandidates(safety(context.now())) + expect(request).toBeDefined() + await primary.query(`UPDATE relay_cell_rehome_safety SET reconnects = ? WHERE cell_id = ?`, [ + REGIONAL_REHOME_RECONNECTS_PER_CELL_LIMIT + 1, + context.source.id + ]) - await expect(context.store.claimRegionalRehome()).resolves.toBeNull() + await expect( + context.store.commitIdleRegionalRehome(request!, safety(context.now())) + ).resolves.toEqual({ outcome: 'deferred' }) await expect(context.store.inspectRegionalRehomeControl()).resolves.toMatchObject({ generation: 2, enabled: false }) - expect(await primary.query( - `SELECT COUNT(*) AS count FROM relay_assignment_migrations WHERE user_id = ?`, - [context.identity.userId] - )).toEqual([{ count: '0' }]) - }) - - it('does not retry a drain against a replacement source incarnation', async () => { - const context = await fixture() - const attempt = await context.store.claimRegionalRehome() - context.advance(31_000) - await heartbeat( - context.store, - context.source, - '33333333-3333-4333-8333-333333333333', - 1, - context.now() - ) - - await expect(context.competingStore.claimRegionalRehome()).resolves.toBeNull() - expect(await primary.query( - `SELECT send_attempts FROM relay_region_rehome_attempts WHERE attempt_id = ?`, - [attempt!.attemptId] - )).toEqual([{ send_attempts: '1' }]) + expect( + await primary.query( + `SELECT COUNT(*) AS count FROM relay_assignment_migrations WHERE user_id = ?`, + [context.identity.userId] + ) + ).toEqual([{ count: '0' }]) }) it('makes concurrent completion and expiry cleanup idempotent', async () => { const context = await fixture() - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') + const attempt = await cutover(context.store, context.now()) const targetControl = await context.store.activateControl(context.identity, { cellId: context.target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -528,17 +504,18 @@ describePostgres('PostgreSQL regional rehoming', () => { context.competingStore.abortExpiredRegionalRehomes() ]) expect(outcomes).toEqual(expect.arrayContaining([0, 1])) - expect(await primary.query( - `SELECT completed_at IS NOT NULL AS completed, aborted_at IS NOT NULL AS aborted + expect( + await primary.query( + `SELECT completed_at IS NOT NULL AS completed, aborted_at IS NOT NULL AS aborted FROM relay_assignment_migrations WHERE user_id = ?`, - [context.identity.userId] - )).toEqual([{ completed: true, aborted: false }]) + [context.identity.userId] + ) + ).toEqual([{ completed: true, aborted: false }]) }) it('will not complete against a replacement target incarnation', async () => { const context = await fixture() - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') + const attempt = await cutover(context.store, context.now()) await context.store.activateControl(context.identity, { cellId: context.target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -559,15 +536,17 @@ describePostgres('PostgreSQL regional rehoming', () => { ) await expect(context.store.completeReadyRegionalRehomes()).resolves.toBe(0) - expect(await primary.query( - `SELECT completed_at, aborted_at FROM relay_assignment_migrations WHERE user_id = ?`, - [context.identity.userId] - )).toEqual([{ completed_at: null, aborted_at: null }]) + expect( + await primary.query( + `SELECT completed_at, aborted_at FROM relay_assignment_migrations WHERE user_id = ?`, + [context.identity.userId] + ) + ).toEqual([{ completed_at: null, aborted_at: null }]) }) it('does not roll an unregistered target back to a stale regional source', async () => { const context = await fixture() - await context.store.claimRegionalRehome() + await cutover(context.store, context.now()) context.advance(6 * 60_000) await heartbeat( context.store, @@ -580,16 +559,17 @@ describePostgres('PostgreSQL regional rehoming', () => { await expect(context.store.refreshRegionalRehomeLeases()).resolves.toBe(0) await expect(context.store.abortExpiredEvacuations()).resolves.toBe(0) - expect(await primary.query( - `SELECT cell_id, assignment_epoch FROM relay_assignments WHERE user_id = ?`, - [context.identity.userId] - )).toEqual([{ cell_id: context.target.id, assignment_epoch: '2' }]) + expect( + await primary.query( + `SELECT cell_id, assignment_epoch FROM relay_assignments WHERE user_id = ?`, + [context.identity.userId] + ) + ).toEqual([{ cell_id: context.target.id, assignment_epoch: '2' }]) }) it('completes after the drained host re-resolves through the director', async () => { const context = await fixture() - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') + const attempt = await cutover(context.store, context.now()) // The drain recovery lands while both controls are still live. await context.store.assign(context.identity, 'asia-east2') expect(await controlAccounting(context.identity)).toEqual({ @@ -609,11 +589,13 @@ describePostgres('PostgreSQL regional rehoming', () => { await context.store.releaseActivity(context.identity, context.sourceControl) await expect(context.store.completeReadyRegionalRehomes()).resolves.toBe(1) - expect(await primary.query( - `SELECT completed_at IS NOT NULL AS completed FROM relay_assignment_migrations + expect( + await primary.query( + `SELECT completed_at IS NOT NULL AS completed FROM relay_assignment_migrations WHERE user_id = ?`, - [context.identity.userId] - )).toEqual([{ completed: true }]) + [context.identity.userId] + ) + ).toEqual([{ completed: true }]) expect(await controlAccounting(context.identity)).toEqual({ reservedControls: 1, controlLeases: 1 @@ -622,7 +604,7 @@ describePostgres('PostgreSQL regional rehoming', () => { it('repairs a skewed control counter before completing the rehome', async () => { const context = await fixture() - const attempt = await context.store.claimRegionalRehome() + const attempt = await cutover(context.store, context.now()) await context.store.activateControl(context.identity, { cellId: context.target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -634,10 +616,9 @@ describePostgres('PostgreSQL regional rehoming', () => { }) await context.store.releaseActivity(context.identity, context.sourceControl) // Damage already written by a pre-fix sticky grant. - await primary.query( - `UPDATE relay_assignments SET reserved_controls = 0 WHERE user_id = ?`, - [context.identity.userId] - ) + await primary.query(`UPDATE relay_assignments SET reserved_controls = 0 WHERE user_id = ?`, [ + context.identity.userId + ]) await expect(context.store.completeReadyRegionalRehomes()).resolves.toBe(1) expect(await controlAccounting(context.identity)).toEqual({ @@ -646,6 +627,22 @@ describePostgres('PostgreSQL regional rehoming', () => { }) }) + async function cutover(store: RelayAssignmentStore, now: number) { + const [request] = await store.selectIdleRegionalRehomeCandidates(safety(now)) + if (!request) return null + const result = await store.commitIdleRegionalRehome(request, safety(now)) + if (result.outcome !== 'committed') return null + const [attempt] = await primary.query( + `SELECT preferred_region, assignment_epoch FROM relay_region_rehome_attempts WHERE attempt_id = ?`, + [request.attemptId] + ) + return { + ...request, + preferredRegion: String(attempt!.preferred_region), + assignmentEpoch: Number(attempt!.assignment_epoch) + } + } + async function attemptAndMigrationCounts(identity: { userId: string relayHostId: string @@ -711,18 +708,12 @@ describePostgres('PostgreSQL regional rehoming', () => { drainGraceMs: 60_000 }) await store.reconcileCells([source, target]) - await heartbeat( - store, - source, - '11111111-1111-4111-8111-111111111111', - 1, - 900_000 - ) + await heartbeat(store, source, '11111111-1111-4111-8111-111111111111', 3, 900_000) await heartbeat( store, target, '22222222-2222-4222-8222-222222222222', - options.targetProtocol ?? 1, + options.targetProtocol ?? 3, 900_000 ) const identity = { @@ -733,9 +724,32 @@ describePostgres('PostgreSQL regional rehoming', () => { const sourceControl = await store.activateControl(identity, { cellId: source.id, assignmentEpoch: assignment.assignmentEpoch, - generation: 1 + generation: 1, + idleRegionalRehome: true, + cellIncarnation: '11111111-1111-4111-8111-111111111111' }) await store.assign(identity, preferredRegion) + const issued = await store.exchangeRegionCorrection( + identity, + { v: 1, action: 'issue-window' }, + assignment.assignmentEpoch + ) + await store.exchangeRegionCorrection( + identity, + { + v: 1, + action: 'report', + generation: issued.window!.generation, + assignmentEpoch: assignment.assignmentEpoch, + policyVersion: 1, + outcome: 'conclusive', + measurements: { + 'us-central1': preferredRegion === 'us-central1' ? 50 : 150, + 'asia-east2': preferredRegion === 'asia-east2' ? 50 : 150 + } + }, + assignment.assignmentEpoch + ) return { preferredRegion, store, @@ -753,6 +767,7 @@ describePostgres('PostgreSQL regional rehoming', () => { }) const storeOptions = { + regionalRehomeCohortPercent: 100, requireLiveCells: true, heartbeatTtlMs: 45_000 } @@ -816,3 +831,15 @@ async function heartbeat( } }) } + +function safety(now: number) { + return { + observedAt: now, + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + } +} diff --git a/cloud/apps/relay/src/regional-rehome-store.test.ts b/cloud/apps/relay/src/regional-rehome-store.test.ts index 662876ef66e..4a1a96a3f7a 100644 --- a/cloud/apps/relay/src/regional-rehome-store.test.ts +++ b/cloud/apps/relay/src/regional-rehome-store.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { - RelayAssignmentStore, + RelayAssignmentStore as BaseRelayAssignmentStore, + type RegionalRehomeAttempt, REGIONAL_REHOME_QUARANTINE_FAILURES, REGIONAL_REHOME_QUARANTINE_MS, REGIONAL_REHOME_REDRAIN_SEND_LIMIT @@ -37,14 +38,115 @@ const sourceIncarnation = '11111111-1111-4111-8111-111111111111' const targetIncarnation = '22222222-2222-4222-8222-222222222222' describe('regional rehome assignment state', () => { + it('advances past a full candidate page whose destination lacks capacity', async () => { + const context = await setup() + for (let i = 0; i < 10; i++) { + await activatePreferredSource(context, { + userId: `blocked-${i}`, + relayHostId: 'abcdefghijklmnop' + }) + } + context.advance(1) + const reverse = { userId: 'healthy-reverse', relayHostId: 'abcdefghijklmnop' } + await activateReversePreferredSource(context, reverse) + await context.database.query( + 'UPDATE relay_cells SET capacity_requests = reserved_requests WHERE cell_id = ?', + [target.id] + ) + expect(await context.store.tryIdleRehome()).toMatchObject({ + userId: reverse.userId, + sourceCellId: target.id, + targetCellId: source.id + }) + await context.database.close() + }) + + it('defaults optional correction off even with enabled durable control', async () => { + const context = await setup() + await activatePreferredSource(context, { userId: 'cohort', relayHostId: 'abcdefghijklmnop' }) + const defaultStore = new IdleRehomeTestStore(context.database, context.now, { + requireLiveCells: true + }) + expect(await defaultStore.tryIdleRehome()).toBeNull() + expect( + await context.database.query('SELECT attempt_id FROM relay_region_rehome_attempts') + ).toEqual([]) + expect(await context.store.tryIdleRehome()).not.toBeNull() + await context.database.close() + }) + + it('counts pre-existing generic migrations against the eight-migration cap', async () => { + const context = await setup() + await activatePreferredSource(context, { userId: 'cap', relayHostId: 'abcdefghijklmnop' }) + for (let i = 0; i < 8; i++) { + await context.database.query( + `INSERT INTO relay_assignment_migrations + (user_id, relay_host_id, source_cell_id, target_cell_id, previous_epoch, assignment_epoch, + source_request_units, target_reserved_units, expires_at, created_at, updated_at) + VALUES (?, ?, ?, ?, 1, 2, 1, 1, ?, ?, ?)`, + [ + 'generic', + `synthetic-migration-${i}`, + source.id, + target.id, + context.now() + 60_000, + context.now(), + context.now() + ] + ) + } + expect(await context.store.tryIdleRehome()).toBeNull() + await context.database.query( + `UPDATE relay_assignment_migrations SET completed_at = ? + WHERE user_id = 'generic' AND relay_host_id = 'synthetic-migration-0'`, + [context.now()] + ) + expect(await context.store.tryIdleRehome()).not.toBeNull() + const open = await context.database + .query(`SELECT COUNT(*) AS count FROM relay_assignment_migrations + WHERE completed_at IS NULL AND aborted_at IS NULL`) + expect(Number(open[0]?.count)).toBe(8) + await context.database.close() + }) + + it('does not let legacy hints certify a move', async () => { + const context = await setup() + await activatePreferredSource(context, { userId: 'legacy', relayHostId: 'abcdefghijklmnop' }) + await context.database.query('DELETE FROM relay_region_decisions') + expect(await context.store.tryIdleRehome()).toBeNull() + await context.database.close() + }) + + it('refreshes later open attempts when an older attempt occupies the first page', async () => { + const context = await setup() + await activatePreferredSource(context, { userId: 'page-1', relayHostId: 'abcdefghijklmnop' }) + const first = await context.store.tryIdleRehome() + context.advance(10_000) + await freshHeartbeats(context) + await activatePreferredSource(context, { userId: 'page-2', relayHostId: 'abcdefghijklmnop' }) + const second = await context.store.tryIdleRehome() + expect(second).not.toBeNull() + context.advance(1_000) + expect(await context.store.refreshRegionalRehomeLeases(1)).toBe(1) + context.advance(1_000) + expect(await context.store.refreshRegionalRehomeLeases(1)).toBe(1) + const rows = await context.database.query( + `SELECT attempt_id, updated_at FROM relay_region_rehome_attempts + WHERE attempt_id = ?`, + [second!.attemptId] + ) + expect(Number(rows[0]?.updated_at)).toBe(context.now()) + await context.database.close() + }) + it('does not open a transaction while the worker is disabled', async () => { const delegate = await openInMemoryRelayDatabase() const database = new TransactionCountingDatabase(delegate) - const store = new RelayAssignmentStore(database, () => 1_000_000) + const store = new IdleRehomeTestStore(database, () => 1_000_000) await store.inspectRegionalRehomeControl() database.transactionCalls = 0 - await expect(store.claimRegionalRehome()).resolves.toBeNull() + await expect(store.tryIdleRehome()).resolves.toBeNull() expect(database.transactionCalls).toBe(0) await database.close() }) @@ -52,9 +154,9 @@ describe('regional rehome assignment state', () => { it('initializes a missing control row without opening a transaction', async () => { const delegate = await openInMemoryRelayDatabase() const database = new TransactionCountingDatabase(delegate) - const store = new RelayAssignmentStore(database, () => 1_000_000) + const store = new IdleRehomeTestStore(database, () => 1_000_000) - await expect(store.claimRegionalRehome()).resolves.toBeNull() + await expect(store.tryIdleRehome()).resolves.toBeNull() expect(database.transactionCalls).toBe(0) await expect(store.inspectRegionalRehomeControl()).resolves.toMatchObject({ generation: 0, @@ -75,24 +177,28 @@ describe('regional rehome assignment state', () => { generation: 2, enabled: false }) - await expect(context.store.applyRegionalRehomeControl({ - expectedGeneration: 1, - enabled: true, - notBefore: context.now(), - ratePerMinute: 10, - preferenceMaxAgeMs: 24 * 60 * 60_000, - hostCooldownMs: 7 * 24 * 60 * 60_000, - drainGraceMs: 60_000 - })).rejects.toThrow('regional_rehome_generation_mismatch') - await expect(context.store.applyRegionalRehomeControl({ - expectedGeneration: 2, - enabled: true, - notBefore: context.now(), - ratePerMinute: 10, - preferenceMaxAgeMs: 24 * 60 * 60_000, - hostCooldownMs: 7 * 24 * 60 * 60_000, - drainGraceMs: 60_000 - })).resolves.toMatchObject({ generation: 3, enabled: true }) + await expect( + context.store.applyRegionalRehomeControl({ + expectedGeneration: 1, + enabled: true, + notBefore: context.now(), + ratePerMinute: 10, + preferenceMaxAgeMs: 24 * 60 * 60_000, + hostCooldownMs: 7 * 24 * 60 * 60_000, + drainGraceMs: 60_000 + }) + ).rejects.toThrow('regional_rehome_generation_mismatch') + await expect( + context.store.applyRegionalRehomeControl({ + expectedGeneration: 2, + enabled: true, + notBefore: context.now(), + ratePerMinute: 10, + preferenceMaxAgeMs: 24 * 60 * 60_000, + hostCooldownMs: 7 * 24 * 60 * 60_000, + drainGraceMs: 60_000 + }) + ).resolves.toMatchObject({ generation: 3, enabled: true }) await context.database.close() }) @@ -103,7 +209,7 @@ describe('regional rehome assignment state', () => { const sourceControl = await activatePreferredSource(context, identity) await activateSource(context, neighbor) - const attempt = await context.store.claimRegionalRehome() + const attempt = await context.store.tryIdleRehome() expect(attempt).toMatchObject({ userId: identity.userId, relayHostId: identity.relayHostId, @@ -118,11 +224,11 @@ describe('regional rehome assignment state', () => { expect(await context.store.resolve(neighbor)).toMatchObject({ cellId: source.id }) expect(await context.store.completeReadyRegionalRehomes()).toBe(0) expect( - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') - ).toBe(true) - expect( - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') - ).toBe(false) + await context.database.query( + 'SELECT drain_outcome FROM relay_region_rehome_attempts WHERE attempt_id = ?', + [attempt!.attemptId] + ) + ).toEqual([{ drain_outcome: 'accepted' }]) const targetControl = await context.store.activateControl(identity, { cellId: target.id, @@ -142,23 +248,27 @@ describe('regional rehome assignment state', () => { assignmentEpoch: 2 }) expect(await context.store.resolve(neighbor)).toMatchObject({ cellId: source.id }) - expect(await context.database.query( - `SELECT completed_at, aborted_at FROM relay_assignment_migrations + expect( + await context.database.query( + `SELECT completed_at, aborted_at FROM relay_assignment_migrations WHERE user_id = ? AND relay_host_id = ?`, - [identity.userId, identity.relayHostId] - )).toEqual([{ completed_at: context.now(), aborted_at: null }]) - expect(await context.database.query( - `SELECT completed_at, aborted_at FROM relay_region_rehome_attempts` - )).toEqual([{ completed_at: context.now(), aborted_at: null }]) + [identity.userId, identity.relayHostId] + ) + ).toEqual([{ completed_at: context.now(), aborted_at: null }]) + expect( + await context.database.query( + `SELECT completed_at, aborted_at FROM relay_region_rehome_attempts` + ) + ).toEqual([{ completed_at: context.now(), aborted_at: null }]) expect(targetControl).toMatch(/^control:/) await context.database.close() }) - it('completes from durable activity when the drain response was lost', async () => { + it('completes from durable activity with the source-owned receipt', async () => { const context = await setup() const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } const sourceControl = await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() + const attempt = await context.store.tryIdleRehome() await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -171,10 +281,12 @@ describe('regional rehome assignment state', () => { await context.store.releaseActivity(identity, sourceControl) expect(await context.store.completeReadyRegionalRehomes()).toBe(1) - expect(await context.database.query( - `SELECT drain_receipt_at, completed_at, aborted_at + expect( + await context.database.query( + `SELECT drain_receipt_at, completed_at, aborted_at FROM relay_region_rehome_attempts` - )).toEqual([{ drain_receipt_at: null, completed_at: context.now(), aborted_at: null }]) + ) + ).toEqual([{ drain_receipt_at: context.now(), completed_at: context.now(), aborted_at: null }]) await context.database.close() }) @@ -184,23 +296,22 @@ describe('regional rehome assignment state', () => { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }) - expect(await context.store.claimRegionalRehome()).toBeNull() + expect(await context.store.tryIdleRehome()).toBeNull() expect(await context.database.query(`SELECT * FROM relay_assignment_migrations`)).toEqual([]) await context.database.close() }) it('fails fleet safety closed until source and target telemetry is fresh', async () => { const context = await setup() - await context.database.query( - `DELETE FROM relay_cell_rehome_safety WHERE cell_id = ?`, - [target.id] - ) + await context.database.query(`DELETE FROM relay_cell_rehome_safety WHERE cell_id = ?`, [ + target.id + ]) expect(await context.store.regionalRehomeFleetSafety()).toMatchObject({ requiredCells: 2, missingCells: 1, observedAt: 0 }) - await heartbeat(context.store, source, sourceIncarnation, 1, 2, { + await heartbeat(context.store, source, sourceIncarnation, 3, 2, { observedAt: context.now(), sqlFailures: 0, reconnects: 2, @@ -209,7 +320,7 @@ describe('regional rehome assignment state', () => { databasePoolWaitersMax: 0, databasePoolWaitMsMax: 0 }) - await heartbeat(context.store, target, targetIncarnation, 1, 2, { + await heartbeat(context.store, target, targetIncarnation, 3, 2, { observedAt: context.now(), sqlFailures: 1, reconnects: 3, @@ -237,7 +348,7 @@ describe('regional rehome assignment state', () => { requiredCells: 1, missingCells: 0 }) - await heartbeat(context.store, target, targetIncarnation, 1, 2) + await heartbeat(context.store, target, targetIncarnation, 3, 2) expect(await context.store.regionalRehomeFleetSafety()).toMatchObject({ requiredCells: 2, missingCells: 0 @@ -256,14 +367,14 @@ describe('regional rehome assignment state', () => { databasePoolWaitersMax: 2, databasePoolWaitMsMax: 1 } - await heartbeat(context.store, source, sourceIncarnation, 1, 2, baseline) - await heartbeat(context.store, target, targetIncarnation, 1, 2, baseline) + await heartbeat(context.store, source, sourceIncarnation, 3, 2, baseline) + await heartbeat(context.store, target, targetIncarnation, 3, 2, baseline) await activatePreferredSource(context, { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }) - expect(await context.store.claimRegionalRehome()).toMatchObject({ + expect(await context.store.tryIdleRehome()).toMatchObject({ sourceCellId: source.id, targetCellId: target.id }) @@ -279,6 +390,17 @@ describe('regional rehome assignment state', () => { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }) + const safety: RegionalRehomeSafetySnapshot = { + observedAt: context.now(), + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + } + const [candidate] = await context.store.selectIdleRegionalRehomeCandidates(safety) + expect(candidate).toBeDefined() await context.database.query( `UPDATE relay_cell_rehome_safety SET reconnects = 251 WHERE cell_id = ?`, [source.id] @@ -286,7 +408,9 @@ describe('regional rehome assignment state', () => { const warnings = collectDisableWarnings() try { - expect(await context.store.claimRegionalRehome()).toBeNull() + expect(await context.store.commitIdleRegionalRehome(candidate!, safety)).toEqual({ + outcome: 'deferred' + }) } finally { warnings.restore() } @@ -306,6 +430,17 @@ describe('regional rehome assignment state', () => { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }) + const safety: RegionalRehomeSafetySnapshot = { + observedAt: context.now(), + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + } + const [candidate] = await context.store.selectIdleRegionalRehomeCandidates(safety) + expect(candidate).toBeDefined() await context.database.query( `UPDATE relay_cell_rehome_safety SET database_pool_waiters_max = 17 WHERE cell_id = ?`, [target.id] @@ -313,9 +448,11 @@ describe('regional rehome assignment state', () => { const warnings = collectDisableWarnings() try { - expect(await context.store.claimRegionalRehome()).toBeNull() + expect(await context.store.commitIdleRegionalRehome(candidate!, safety)).toEqual({ + outcome: 'deferred' + }) // Already disabled: the next tick returns before the gate and stays silent. - expect(await context.store.claimRegionalRehome()).toBeNull() + expect(await context.store.tryIdleRehome()).toBeNull() } finally { warnings.restore() } @@ -339,7 +476,7 @@ describe('regional rehome assignment state', () => { `UPDATE relay_cell_rehome_safety SET sql_failures = ${REGIONAL_REHOME_SQL_FAILURES_PER_CELL_LIMIT}` ) - expect(await context.store.claimRegionalRehome()).not.toBeNull() + expect(await context.store.tryIdleRehome()).not.toBeNull() await context.database.close() }) @@ -348,7 +485,7 @@ describe('regional rehome assignment state', () => { const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } await activateReversePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() + const attempt = await context.store.tryIdleRehome() expect(attempt).toMatchObject({ userId: identity.userId, relayHostId: identity.relayHostId, @@ -366,11 +503,13 @@ describe('regional rehome assignment state', () => { `SELECT preferred_region, source_cell_id, target_cell_id FROM relay_region_rehome_attempts` ) - ).toEqual([{ - preferred_region: 'us-central1', - source_cell_id: target.id, - target_cell_id: source.id - }]) + ).toEqual([ + { + preferred_region: 'us-central1', + source_cell_id: target.id, + target_cell_id: source.id + } + ]) expect(await context.store.resolve(identity)).toMatchObject({ cellId: source.id }) await context.database.close() }) @@ -389,21 +528,19 @@ describe('regional rehome assignment state', () => { const warnings = collectEventWarnings('orca_relay_regional_rehome_candidates_skipped') try { - expect(await context.store.claimRegionalRehome()).toBeNull() + expect(await context.store.tryIdleRehome()).toBeNull() } finally { warnings.restore() } expect(warnings.entries).toEqual([]) expect( - await context.database.query( - `SELECT next_dispatch_at FROM relay_region_rehome_worker_state` - ) + await context.database.query(`SELECT next_dispatch_at FROM relay_region_rehome_worker_state`) ).toEqual([{ next_dispatch_at: 0 }]) expect(await context.database.query(`SELECT * FROM relay_assignment_migrations`)).toEqual([]) await context.database.close() }) - it('names the skip when the last target is lost between scan and claim', async () => { + it('does not migrate when the last target is lost between selection and commit', async () => { const database = await openInMemoryRelayDatabase() const context = await setup({ database, @@ -419,15 +556,8 @@ describe('regional rehome assignment state', () => { relayHostId: 'abcdefghijklmnop' }) - const warnings = collectEventWarnings('orca_relay_regional_rehome_candidates_skipped') - try { - expect(await context.store.claimRegionalRehome()).toBeNull() - } finally { - warnings.restore() - } - expect(warnings.entries).toMatchObject([ - { skips: [{ reason: 'no_eligible_target', candidates: 1 }] } - ]) + expect(await context.store.tryIdleRehome()).toBeNull() + expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ generation: 1, enabled: true @@ -444,11 +574,11 @@ describe('regional rehome assignment state', () => { // really does scan and the cooldown is the only thing holding this host. context.advance(10_000) // The desktop's region probe now says us-central1 again. - await context.store.assign(identity, 'us-central1') + await activateReversePreferredSource(context, identity) const warnings = collectEventWarnings('orca_relay_regional_rehome_candidates_skipped') try { - expect(await context.store.claimRegionalRehome()).toBeNull() + expect(await context.store.tryIdleRehome()).toBeNull() } finally { warnings.restore() } @@ -462,9 +592,9 @@ describe('regional rehome assignment state', () => { cellId: target.id, expiresAt: context.now() + 90_000 }) - await context.store.assign(identity, 'us-central1') + await activateReversePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() + const attempt = await context.store.tryIdleRehome() expect(attempt).toMatchObject({ preferredRegion: 'us-central1', sourceCellId: target.id, @@ -502,15 +632,8 @@ describe('regional rehome assignment state', () => { }) await activatePreferredSource(context, identity) - const warnings = collectEventWarnings('orca_relay_regional_rehome_candidates_skipped') - try { - expect(await context.store.claimRegionalRehome()).toBeNull() - } finally { - warnings.restore() - } - expect(warnings.entries).toMatchObject([ - { skips: [{ reason: 'host_cooldown', candidates: 1 }] } - ]) + expect(await context.store.tryIdleRehome()).toBeNull() + expect(await database.query(`SELECT * FROM relay_assignment_migrations`)).toEqual([]) await database.close() }) @@ -527,15 +650,13 @@ describe('regional rehome assignment state', () => { const warnings = collectEventWarnings('orca_relay_regional_rehome_candidates_skipped') try { - expect(await context.store.claimRegionalRehome()).toBeNull() + expect(await context.store.tryIdleRehome()).toBeNull() } finally { warnings.restore() } expect(warnings.entries).toEqual([]) expect( - await context.database.query( - `SELECT next_dispatch_at FROM relay_region_rehome_worker_state` - ) + await context.database.query(`SELECT next_dispatch_at FROM relay_region_rehome_worker_state`) ).toEqual([{ next_dispatch_at: 0 }]) expect(await context.database.query(`SELECT * FROM relay_assignment_migrations`)).toEqual([]) await context.database.close() @@ -558,37 +679,16 @@ describe('regional rehome assignment state', () => { [target.id] ) - const warnings = collectEventWarnings('orca_relay_regional_rehome_candidates_skipped') - try { - expect(await context.store.claimRegionalRehome()).toBeNull() - } finally { - warnings.restore() - } + expect(await context.store.tryIdleRehome()).toBeNull() expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ generation: 1, enabled: true }) - // The skip is visible and named, and both candidates blocked by the one - // unclean cell accumulate into a single entry. - expect(warnings.entries).toMatchObject([ - { - skips: [ - { - reason: 'target_unclean', - cellId: target.id, - sqlFailures: REGIONAL_REHOME_SQL_FAILURES_PER_CELL_LIMIT + 1, - candidates: 2 - } - ] - } - ]) - // A skipped tick is charged the dispatch interval: candidate scans stay - // rate-limited even when nothing claims. + + // Read-only selection does not spend the commit rate budget. expect( - await context.database.query( - `SELECT next_dispatch_at FROM relay_region_rehome_worker_state` - ) - ).toEqual([{ next_dispatch_at: context.now() + 6_000 }]) + await context.database.query(`SELECT next_dispatch_at FROM relay_region_rehome_worker_state`) + ).toEqual([{ next_dispatch_at: 0 }]) expect(await context.database.query(`SELECT * FROM relay_assignment_migrations`)).toEqual([]) await context.database.close() }) @@ -598,15 +698,13 @@ describe('regional rehome assignment state', () => { const warnings = collectEventWarnings('orca_relay_regional_rehome_candidates_skipped') try { - expect(await context.store.claimRegionalRehome()).toBeNull() + expect(await context.store.tryIdleRehome()).toBeNull() } finally { warnings.restore() } expect(warnings.entries).toEqual([]) expect( - await context.database.query( - `SELECT next_dispatch_at FROM relay_region_rehome_worker_state` - ) + await context.database.query(`SELECT next_dispatch_at FROM relay_region_rehome_worker_state`) ).toEqual([{ next_dispatch_at: 0 }]) await context.database.close() }) @@ -617,12 +715,25 @@ describe('regional rehome assignment state', () => { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }) + const safety: RegionalRehomeSafetySnapshot = { + observedAt: context.now(), + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + } + const [candidate] = await context.store.selectIdleRegionalRehomeCandidates(safety) + expect(candidate).toBeDefined() await context.database.query( `UPDATE relay_cell_rehome_safety SET sql_failures = ${REGIONAL_REHOME_SQL_FAILURES_LIMIT + 1} WHERE cell_id = ?`, [target.id] ) - expect(await context.store.claimRegionalRehome()).toBeNull() + expect(await context.store.commitIdleRegionalRehome(candidate!, safety)).toEqual({ + outcome: 'deferred' + }) expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ generation: 2, enabled: false @@ -631,80 +742,6 @@ describe('regional rehome assignment state', () => { await context.database.close() }) - it('rechecks locked fleet safety before retrying a drain dispatch', async () => { - const context = await setup() - await activatePreferredSource(context, { - userId: 'user-1', - relayHostId: 'abcdefghijklmnop' - }) - expect(await context.store.claimRegionalRehome()).not.toBeNull() - context.advance(31_000) - await context.database.query( - `UPDATE relay_cell_rehome_safety SET sql_failures = ${REGIONAL_REHOME_SQL_FAILURES_LIMIT + 1} WHERE cell_id = ?`, - [target.id] - ) - - expect(await context.store.claimRegionalRehome()).toBeNull() - expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ - generation: 2, - enabled: false - }) - await context.database.close() - }) - - it('latches off after three dispatch failures and resumes only through CAS', async () => { - const context = await setup() - await activatePreferredSource(context, { - userId: 'user-1', - relayHostId: 'abcdefghijklmnop' - }) - const first = await context.store.claimRegionalRehome() - for (let index = 0; index < 3; index++) { - await context.store.recordRegionalRehomeDispatchFailure(first!.attemptId) - } - context.advance(5 * 60_000 - 1) - expect(await context.store.claimRegionalRehome()).toBeNull() - context.advance(1) - await heartbeat(context.store, source, sourceIncarnation, 1, 2, { - observedAt: context.now(), - sqlFailures: 0, - reconnects: 0, - controlActivityRecoveryFailures: 0, - databasePoolWaiting: 0, - databasePoolWaitersMax: 0, - databasePoolWaitMsMax: 0 - }) - await heartbeat(context.store, target, targetIncarnation, 1, 2, { - observedAt: context.now(), - sqlFailures: 0, - reconnects: 0, - controlActivityRecoveryFailures: 0, - databasePoolWaiting: 0, - databasePoolWaitersMax: 0, - databasePoolWaitMsMax: 0 - }) - expect(await context.store.claimRegionalRehome()).toBeNull() - expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ - generation: 2, - enabled: false - }) - await context.store.applyRegionalRehomeControl({ - expectedGeneration: 2, - enabled: true, - notBefore: context.now(), - ratePerMinute: 10, - preferenceMaxAgeMs: 24 * 60 * 60_000, - hostCooldownMs: 7 * 24 * 60 * 60_000, - drainGraceMs: 60_000 - }) - const retry = await context.store.claimRegionalRehome() - expect(retry).toMatchObject({ attemptId: first!.attemptId, sendAttempts: 2 }) - expect(await context.database.query( - `SELECT COUNT(*) AS count FROM relay_assignment_migrations` - )).toEqual([{ count: 1 }]) - await context.database.close() - }) - it('refreshes only the migration leases while source splices drain', async () => { const context = await setup() const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } @@ -714,7 +751,7 @@ describe('regional rehome assignment state', () => { kind: 'splice', cellId: source.id }) - await context.store.claimRegionalRehome() + await context.store.tryIdleRehome() const before = await context.database.query( `SELECT activity_id, expires_at FROM relay_assignment_activity_leases WHERE user_id = ? AND relay_host_id = ? ORDER BY activity_id`, @@ -743,19 +780,21 @@ describe('regional rehome assignment state', () => { const context = await setup() const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } await activatePreferredSource(context, identity) - await context.store.claimRegionalRehome() + await context.store.tryIdleRehome() context.advance(6 * 60_000) expect(await context.store.refreshRegionalRehomeLeases()).toBe(0) - await heartbeat(context.store, source, sourceIncarnation, 1, 2) + await heartbeat(context.store, source, sourceIncarnation, 3, 2) expect(await context.store.abortExpiredEvacuations()).toBe(1) - expect(await context.store.reapRegionalRehomeAttempts()).toBe(1) + expect(await context.store.reapRegionalRehomeAttempts()).toBe(0) expect(await context.store.resolve(identity)).toMatchObject({ cellId: source.id, assignmentEpoch: 3 }) - expect(await context.database.query( - `SELECT completed_at, aborted_at FROM relay_region_rehome_attempts` - )).toEqual([{ completed_at: null, aborted_at: context.now() }]) + expect( + await context.database.query( + `SELECT completed_at, aborted_at FROM relay_region_rehome_attempts` + ) + ).toEqual([{ completed_at: null, aborted_at: context.now() }]) await context.database.close() }) @@ -763,9 +802,9 @@ describe('regional rehome assignment state', () => { const context = await setup() const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } await activatePreferredSource(context, identity) - await context.store.claimRegionalRehome() + await context.store.tryIdleRehome() context.advance(6 * 60_000) - await heartbeat(context.store, target, targetIncarnation, 1, 2) + await heartbeat(context.store, target, targetIncarnation, 3, 2) expect(await context.store.refreshRegionalRehomeLeases()).toBe(0) expect(await context.store.abortExpiredEvacuations()).toBe(0) @@ -779,41 +818,6 @@ 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 @@ -830,8 +834,7 @@ describe('regional rehome assignment state', () => { 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') + const attempt = await context.store.tryIdleRehome() await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: 2, @@ -881,8 +884,7 @@ describe('regional rehome assignment state', () => { 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') + const attempt = await context.store.tryIdleRehome() await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: 2, @@ -927,9 +929,7 @@ describe('regional rehome assignment state', () => { const busy = collectEventWarnings('orca_relay_sweep_cell_inventory_busy') try { - await expect(context.store.claimRegionalRehome()).rejects.toThrow( - 'relay_capacity_exhausted' - ) + await expect(context.store.tryIdleRehome()).rejects.toThrow('relay_capacity_exhausted') } finally { busy.restore() } @@ -940,87 +940,13 @@ describe('regional rehome assignment state', () => { // 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') + const attempt = await context.store.tryIdleRehome() await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: 2, @@ -1069,8 +995,7 @@ describe('regional rehome assignment state', () => { 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 attempt = await context.store.tryIdleRehome() const targetControl = await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: 2, @@ -1083,7 +1008,7 @@ describe('regional rehome assignment state', () => { 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) + await heartbeat(context.store, source, sourceIncarnation, 3, 2) probe.reset() probe.failNoWait = true const busy = collectEventWarnings('orca_relay_sweep_cell_inventory_busy') @@ -1118,11 +1043,7 @@ describe('regional rehome assignment state', () => { const context = await setup() 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 attempt = await context.store.tryIdleRehome() const targetControl = await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: 2, @@ -1135,7 +1056,7 @@ describe('regional rehome assignment state', () => { 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) + await heartbeat(context.store, source, sourceIncarnation, 3, 2) expect(await context.store.abortExpiredRegionalRehomes()).toBe(1) expect(await context.store.resolve(identity)).toMatchObject({ cellId: source.id, @@ -1144,151 +1065,21 @@ describe('regional rehome assignment state', () => { await context.database.close() }) - it('redrains a receipted dual-homed attempt once its grace elapses', async () => { - const context = await setup() - 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 - }) - - // Before grace elapses a receipted attempt is not re-dispatched. - context.advance(30 * 60_000) - await freshHeartbeats(context) - expect(await context.store.claimRegionalRehome()).toBeNull() - - context.advance(30 * 60_000 + 1) - await freshHeartbeats(context) - const redrain = await context.store.claimRegionalRehome() - expect(redrain).toMatchObject({ - attemptId: attempt!.attemptId, - drainGraceMs: 0, - sendAttempts: 2 - }) - // The per-dispatch receipt replaces the original without a mismatch. - await expect( - context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'host-not-connected') - ).resolves.toBe(true) - - // Redrains are spaced: nothing new inside the redrain interval. - context.advance(30_000) - await freshHeartbeats(context) - expect(await context.store.claimRegionalRehome()).toBeNull() - context.advance(30_001) - await freshHeartbeats(context) - expect(await context.store.claimRegionalRehome()).toMatchObject({ - attemptId: attempt!.attemptId, - drainGraceMs: 0, - sendAttempts: 3 - }) - - // Once the host actually leaves the source, completion wins over redrain. - await context.store.releaseActivity(identity, sourceControl) - context.advance(60_001) - await freshHeartbeats(context) - await context.store.activateControl(identity, { - cellId: target.id, - assignmentEpoch: 2, - generation: 2 - }) - expect(await context.store.claimRegionalRehome()).toBeNull() - expect(await context.store.completeReadyRegionalRehomes()).toBe(1) - await context.database.close() - }) - - it('resets the failure budget on a repeated redrain receipt outcome', async () => { - const context = await setup() - 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 - }) - await context.store.recordRegionalRehomeDispatchFailure(attempt!.attemptId) - await context.store.recordRegionalRehomeDispatchFailure(attempt!.attemptId) - - context.advance(60 * 60_000 + 1) - await freshHeartbeats(context) - expect(await context.store.claimRegionalRehome()).toMatchObject({ - attemptId: attempt!.attemptId, - drainGraceMs: 0 - }) - // The repeated outcome still proves the source answered. - expect( - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') - ).toBe(false) - await context.store.recordRegionalRehomeDispatchFailure(attempt!.attemptId) - expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ - generation: 1, - enabled: true - }) - await context.database.close() - }) - - it('does not redrain before the target registers or when the fleet is unsafe', async () => { - const context = await setup() - 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 - }) - - // Past grace but the target never registered: force-closing the source - // would disconnect the host with nowhere proven to land. - context.advance(60 * 60_000 + 1) - await freshHeartbeats(context) - expect(await context.store.claimRegionalRehome()).toBeNull() - - await context.store.markMigrationTargetRegistered(identity, { - cellId: target.id, - assignmentEpoch: 2 - }) - await context.database.query( - `UPDATE relay_cell_rehome_safety SET sql_failures = ${REGIONAL_REHOME_SQL_FAILURES_LIMIT + 1} WHERE cell_id = ?`, - [target.id] - ) - expect(await context.store.claimRegionalRehome()).toBeNull() - expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ - enabled: false - }) - await context.database.close() - }) - it('completes healthy candidates past a poisoned attempt and logs it', async () => { const context = await setup() const poisoned = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } const healthy = { userId: 'user-2', relayHostId: 'ponmlkjihgfedcba' } const poisonedSource = await activatePreferredSource(context, poisoned) const healthySource = await activatePreferredSource(context, healthy) - const first = await context.store.claimRegionalRehome() + const first = await context.store.tryIdleRehome() context.advance(6_000) - const second = await context.store.claimRegionalRehome() + const second = await context.store.tryIdleRehome() expect(first!.userId).toBe(poisoned.userId) expect(second!.userId).toBe(healthy.userId) for (const [identity, attempt, sourceControl] of [ [poisoned, first, poisonedSource], [healthy, second, healthySource] ] as const) { - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -1320,10 +1111,12 @@ describe('regional rehome assignment state', () => { reason: 'regional_rehome_assignment_mismatch' } ]) - expect(await context.database.query( - `SELECT completed_at FROM relay_region_rehome_attempts WHERE attempt_id = ?`, - [second!.attemptId] - )).toEqual([{ completed_at: context.now() }]) + expect( + await context.database.query( + `SELECT completed_at FROM relay_region_rehome_attempts WHERE attempt_id = ?`, + [second!.attemptId] + ) + ).toEqual([{ completed_at: context.now() }]) await context.database.close() }) @@ -1331,8 +1124,7 @@ describe('regional rehome assignment state', () => { const context = await setup() const poisoned = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } const source1 = await activatePreferredSource(context, poisoned) - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') + const attempt = await context.store.tryIdleRehome() await context.store.activateControl(poisoned, { cellId: target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -1363,9 +1155,7 @@ describe('regional rehome assignment state', () => { expect(warnings.entries).toHaveLength(REGIONAL_REHOME_QUARANTINE_FAILURES + 1) // A free-form error (never a slug) reaches the log only as 'redacted'. expect( - warnings.entries.every( - (entry) => entry.reason === 'regional_rehome_assignment_mismatch' - ) + warnings.entries.every((entry) => entry.reason === 'regional_rehome_assignment_mismatch') ).toBe(true) context.advance(REGIONAL_REHOME_QUARANTINE_MS + 1) const database = context.database @@ -1393,14 +1183,13 @@ describe('regional rehome assignment state', () => { const healthy = { userId: 'user-2', relayHostId: 'ponmlkjihgfedcba' } const poisonedSource = await activatePreferredSource(context, poisoned) const healthySource = await activatePreferredSource(context, healthy) - const first = await context.store.claimRegionalRehome() + const first = await context.store.tryIdleRehome() context.advance(6_000) - const second = await context.store.claimRegionalRehome() + const second = await context.store.tryIdleRehome() for (const [identity, attempt, sourceControl] of [ [poisoned, first, poisonedSource], [healthy, second, healthySource] ] as const) { - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') const targetControl = await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -1434,10 +1223,12 @@ describe('regional rehome assignment state', () => { reason: 'regional_rehome_assignment_mismatch' } ]) - expect(await context.database.query( - `SELECT aborted_at FROM relay_region_rehome_attempts WHERE attempt_id = ?`, - [second!.attemptId] - )).toEqual([{ aborted_at: context.now() }]) + expect( + await context.database.query( + `SELECT aborted_at FROM relay_region_rehome_attempts WHERE attempt_id = ?`, + [second!.attemptId] + ) + ).toEqual([{ aborted_at: context.now() }]) await context.database.close() }) @@ -1445,7 +1236,7 @@ describe('regional rehome assignment state', () => { const context = await setup() const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } const sourceControl = await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() + const attempt = await context.store.tryIdleRehome() expect(await controlAccounting(context, identity)).toEqual({ reservedControls: 2, controlLeases: 2 @@ -1475,11 +1266,13 @@ describe('regional rehome assignment state', () => { }) expect(await context.store.completeReadyRegionalRehomes()).toBe(1) - expect(await context.database.query( - `SELECT completed_at FROM relay_assignment_migrations + expect( + await context.database.query( + `SELECT completed_at FROM relay_assignment_migrations WHERE user_id = ? AND relay_host_id = ?`, - [identity.userId, identity.relayHostId] - )).toEqual([{ completed_at: context.now() }]) + [identity.userId, identity.relayHostId] + ) + ).toEqual([{ completed_at: context.now() }]) expect(await cellReservations(context)).toEqual({ [source.id]: 0, [target.id]: 1 }) await context.database.close() }) @@ -1488,7 +1281,7 @@ describe('regional rehome assignment state', () => { const context = await setup() const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } const sourceControl = await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() + const attempt = await context.store.tryIdleRehome() await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -1503,11 +1296,13 @@ describe('regional rehome assignment state', () => { ) await context.store.assign(identity, 'asia-east2') - expect(await context.database.query( - `SELECT activity_id FROM relay_assignment_activity_leases + expect( + await context.database.query( + `SELECT activity_id FROM relay_assignment_activity_leases WHERE user_id = ? AND relay_host_id = ? AND activity_kind = 'control'`, - [identity.userId, identity.relayHostId] - )).toEqual([{ activity_id: `control:${target.id}:1` }]) + [identity.userId, identity.relayHostId] + ) + ).toEqual([{ activity_id: `control:${target.id}:1` }]) expect(await cellReservations(context)).toEqual({ [source.id]: 0, [target.id]: 2 }) await context.database.close() }) @@ -1516,7 +1311,7 @@ describe('regional rehome assignment state', () => { const context = await setup() const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } const sourceControl = await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() + const attempt = await context.store.tryIdleRehome() await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -1539,11 +1334,13 @@ describe('regional rehome assignment state', () => { reservedControls: 1, controlLeases: 1 }) - expect(await context.database.query( - `SELECT migration_leases FROM relay_assignments + expect( + await context.database.query( + `SELECT migration_leases FROM relay_assignments WHERE user_id = ? AND relay_host_id = ?`, - [identity.userId, identity.relayHostId] - )).toEqual([{ migration_leases: 0 }]) + [identity.userId, identity.relayHostId] + ) + ).toEqual([{ migration_leases: 0 }]) await context.database.close() }) @@ -1553,9 +1350,9 @@ describe('regional rehome assignment state', () => { const clean = { userId: 'user-2', relayHostId: 'ponmlkjihgfedcba' } const skewedSource = await activatePreferredSource(context, skewed) const cleanSource = await activatePreferredSource(context, clean) - const first = await context.store.claimRegionalRehome() + const first = await context.store.tryIdleRehome() context.advance(6_000) - const second = await context.store.claimRegionalRehome() + const second = await context.store.tryIdleRehome() for (const [identity, attempt, sourceControl] of [ [skewed, first, skewedSource], [clean, second, cleanSource] @@ -1599,7 +1396,7 @@ describe('regional rehome assignment state', () => { const context = await setup() const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } const sourceControl = await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() + const attempt = await context.store.tryIdleRehome() await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -1646,7 +1443,7 @@ describe('regional rehome assignment state', () => { const context = await setup() const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } const sourceControl = await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() + const attempt = await context.store.tryIdleRehome() await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -1684,102 +1481,6 @@ describe('regional rehome assignment state', () => { ]) await context.database.close() }) - - it('caps redrain dispatches at the send limit', async () => { - const context = await setup() - 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 - }) - await context.database.query( - `UPDATE relay_region_rehome_attempts SET send_attempts = ? WHERE attempt_id = ?`, - [REGIONAL_REHOME_REDRAIN_SEND_LIMIT, attempt!.attemptId] - ) - context.advance(60 * 60_000 + 1) - await freshHeartbeats(context) - expect(await context.store.claimRegionalRehome()).toBeNull() - await context.database.close() - }) - - it('clears a stale failure budget when the control is enabled again', async () => { - const context = await setup() - await activatePreferredSource(context, { - userId: 'user-1', - relayHostId: 'abcdefghijklmnop' - }) - const attempt = await context.store.claimRegionalRehome() - for (let index = 0; index < 3; index++) { - await context.store.recordRegionalRehomeDispatchFailure(attempt!.attemptId) - } - expect(await workerState(context)).toMatchObject({ consecutiveFailures: 3 }) - const latched = await context.store.inspectRegionalRehomeControl() - expect(latched).toMatchObject({ generation: 2, enabled: false }) - - await context.store.applyRegionalRehomeControl({ - expectedGeneration: latched.generation, - enabled: true, - notBefore: context.now(), - ratePerMinute: 10, - preferenceMaxAgeMs: 24 * 60 * 60_000, - hostCooldownMs: 7 * 24 * 60 * 60_000, - drainGraceMs: 60 * 60_000 - }) - - // A budget spent under the previous enable is not evidence about this one. - expect(await workerState(context)).toMatchObject({ - consecutiveFailures: 0, - pausedUntil: 0 - }) - // One transient failure must not latch the fresh enable straight back off. - await context.store.recordRegionalRehomeDispatchFailure(attempt!.attemptId) - expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ - generation: 3, - enabled: true - }) - await context.database.close() - }) - - it('reports the durable disable when the failure budget latches the control off', async () => { - const context = await setup() - await activatePreferredSource(context, { - userId: 'user-1', - relayHostId: 'abcdefghijklmnop' - }) - const attempt = await context.store.claimRegionalRehome() - const warnings = collectEventWarnings( - 'orca_relay_regional_rehome_failure_budget_disabled' - ) - try { - for (let index = 0; index < 5; index++) { - await context.store.recordRegionalRehomeDispatchFailure(attempt!.attemptId) - } - } finally { - warnings.restore() - } - - // Only the transition is reported; later failures find the control already off. - expect(warnings.entries).toEqual([ - expect.objectContaining({ - event: 'orca_relay_regional_rehome_failure_budget_disabled', - controlGeneration: 2, - consecutiveFailures: 3 - }) - ]) - expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ - generation: 2, - enabled: false - }) - await context.database.close() - }) }) class TransactionCountingDatabase implements RelayDatabase { @@ -1848,7 +1549,8 @@ async function setup( ) { let clock = 1_000_000 const database = options.database ?? (await openInMemoryRelayDatabase()) - const store = new RelayAssignmentStore(options.wrap?.(database) ?? database, () => clock, { + const store = new IdleRehomeTestStore(options.wrap?.(database) ?? database, () => clock, { + regionalRehomeCohortPercent: 100, requireLiveCells: true, heartbeatTtlMs: 45_000 }) @@ -1864,8 +1566,8 @@ async function setup( drainGraceMs: 60 * 60_000 }) await store.reconcileCells([source, target]) - await heartbeat(store, source, sourceIncarnation, options.sourceProtocol ?? 1) - await heartbeat(store, target, targetIncarnation, options.targetProtocol ?? 1) + await heartbeat(store, source, sourceIncarnation, options.sourceProtocol ?? 3) + await heartbeat(store, target, targetIncarnation, options.targetProtocol ?? 3) return { database, store, @@ -1950,9 +1652,7 @@ async function cellReservations(context: Context): Promise [String(row.cell_id), Number(row.reserved_requests)]) - ) + return Object.fromEntries(rows.map((row) => [String(row.cell_id), Number(row.reserved_requests)])) } async function freshHeartbeats(context: Context): Promise { @@ -1966,8 +1666,8 @@ async function freshHeartbeats(context: Context): Promise { databasePoolWaitMsMax: 0 } // The clock doubles as a strictly-increasing connection inclusion watermark. - await heartbeat(context.store, source, sourceIncarnation, 1, context.now(), safety) - await heartbeat(context.store, target, targetIncarnation, 1, context.now(), safety) + await heartbeat(context.store, source, sourceIncarnation, 3, context.now(), safety) + await heartbeat(context.store, target, targetIncarnation, 3, context.now(), safety) } async function activatePreferredSource( @@ -1978,9 +1678,29 @@ async function activatePreferredSource( const control = await context.store.activateControl(identity, { cellId: source.id, assignmentEpoch: assignment.assignmentEpoch, - generation: 1 + generation: 1, + idleRegionalRehome: true, + cellIncarnation: sourceIncarnation }) await context.store.assign(identity, 'asia-east2') + const issued = await context.store.exchangeRegionCorrection( + identity, + { v: 1, action: 'issue-window' }, + assignment.assignmentEpoch + ) + await context.store.exchangeRegionCorrection( + identity, + { + v: 1, + action: 'report', + generation: issued.window!.generation, + assignmentEpoch: assignment.assignmentEpoch, + policyVersion: 1, + outcome: 'conclusive', + measurements: { 'us-central1': 150, 'asia-east2': 50 } + }, + assignment.assignmentEpoch + ) return control } @@ -1994,7 +1714,7 @@ function hookAfterCandidateScan( const decorate = (delegate: RelayDatabase): RelayDatabase => ({ query: async (sql, params) => { const rows = await delegate.query(sql, params) - if (!fired && sql.includes('FROM relay_assignment_region_preferences preference')) { + if (!fired && sql.includes('FROM relay_region_rehome_control policy')) { fired = true await hook(delegate) } @@ -2017,7 +1737,7 @@ async function completeRehomeToTarget( identity: { userId: string; relayHostId: string } ): Promise { const sourceControl = await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() + const attempt = await context.store.tryIdleRehome() const targetControl = await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -2040,9 +1760,29 @@ async function activateReversePreferredSource( const control = await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: assignment.assignmentEpoch, - generation: 1 + generation: 1, + idleRegionalRehome: true, + cellIncarnation: targetIncarnation }) await context.store.assign(identity, 'us-central1') + const issued = await context.store.exchangeRegionCorrection( + identity, + { v: 1, action: 'issue-window' }, + assignment.assignmentEpoch + ) + await context.store.exchangeRegionCorrection( + identity, + { + v: 1, + action: 'report', + generation: issued.window!.generation, + assignmentEpoch: assignment.assignmentEpoch, + policyVersion: 1, + outcome: 'conclusive', + measurements: { 'us-central1': 50, 'asia-east2': 150 } + }, + assignment.assignmentEpoch + ) return control } @@ -2059,7 +1799,7 @@ async function activateSource( } async function heartbeat( - store: RelayAssignmentStore, + store: IdleRehomeTestStore, cell: typeof source | typeof target, cellIncarnation: string, regionalRehomeProtocol: number, @@ -2152,3 +1892,46 @@ async function workerState( pausedUntil: Number(row.paused_until) } } + +class IdleRehomeTestStore extends BaseRelayAssignmentStore { + private readonly fixtureDatabase: RelayDatabase + private readonly fixtureNow: () => number + constructor(...args: ConstructorParameters) { + super(...args) + this.fixtureDatabase = args[0] + this.fixtureNow = args[1] ?? Date.now + } + async tryIdleRehome( + processSafety?: RegionalRehomeSafetySnapshot + ): Promise { + const safety = processSafety ?? { + observedAt: this.fixtureNow(), + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + } + for (const candidate of await this.selectIdleRegionalRehomeCandidates(safety)) { + const result = await this.commitIdleRegionalRehome(candidate, safety) + if (result.outcome !== 'committed') continue + const row = ( + await this.fixtureDatabase.query( + 'SELECT * FROM relay_region_rehome_attempts WHERE attempt_id = ?', + [candidate.attemptId] + ) + )[0]! + return { + ...candidate, + preferredRegion: row.preferred_region as RegionalRehomeAttempt['preferredRegion'], + targetCellIncarnation: String(row.target_cell_incarnation), + previousEpoch: Number(row.previous_epoch), + assignmentEpoch: Number(row.assignment_epoch), + drainGraceMs: Number(row.drain_grace_ms), + sendAttempts: Number(row.send_attempts) + } + } + return null + } +} diff --git a/cloud/apps/relay/src/regional-rehome-target-selection.test.ts b/cloud/apps/relay/src/regional-rehome-target-selection.test.ts index 493eaa50a61..89ae6f88544 100644 --- a/cloud/apps/relay/src/regional-rehome-target-selection.test.ts +++ b/cloud/apps/relay/src/regional-rehome-target-selection.test.ts @@ -25,6 +25,7 @@ async function setup() { let clock = 1_000_000 const database = await openInMemoryRelayDatabase() const store = new RelayAssignmentStore(database, () => clock, { + regionalRehomeCohortPercent: 100, requireLiveCells: true, heartbeatTtlMs: 45_000 }) @@ -83,11 +84,40 @@ async function setup() { await store.activateControl(identity, { cellId: source.id, assignmentEpoch: assignment.assignmentEpoch, - generation: 1 + generation: 1, + idleRegionalRehome: true, + cellIncarnation: incarnation(1) }) - await store.assign(identity, 'asia-east2') + const { window } = await store.exchangeRegionCorrection( + identity, + { v: 1, action: 'issue-window' }, + assignment.assignmentEpoch + ) + expect(window).toBeDefined() + await store.exchangeRegionCorrection( + identity, + { + v: 1, + action: 'report', + generation: window!.generation, + assignmentEpoch: assignment.assignmentEpoch, + policyVersion: 1, + outcome: 'conclusive', + measurements: { 'us-central1': 180, 'asia-east2': 40 } + }, + assignment.assignmentEpoch + ) } - return { database, store, beat, activatePreferredSource } + const safety = () => ({ + observedAt: clock, + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + }) + return { database, store, beat, activatePreferredSource, safety } } const UNCLEAN = REGIONAL_REHOME_SQL_FAILURES_PER_CELL_LIMIT + 1 @@ -95,70 +125,78 @@ const UNCLEAN = REGIONAL_REHOME_SQL_FAILURES_PER_CELL_LIMIT + 1 describe('regional rehome target selection', () => { it('never selects a target without connection headroom, even at lowest load', async () => { const context = await setup() - await context.beat(source, 1, 1, { + await context.beat(source, 1, 3, { observedRequests: 0, enforcedConnections: 0, sqlFailures: 0 }) // Lowest load but the connection hard cap is exhausted. - await context.beat(noHeadroom, 2, 1, { + await context.beat(noHeadroom, 2, 3, { observedRequests: 0, enforcedConnections: 999, sqlFailures: 0 }) - await context.beat(unclean, 3, 1, { + await context.beat(unclean, 3, 3, { observedRequests: 0, enforcedConnections: 0, sqlFailures: UNCLEAN }) - await context.beat(highLoad, 4, 1, { + await context.beat(highLoad, 4, 3, { observedRequests: 50, enforcedConnections: 0, sqlFailures: 0 }) - await context.beat(lowLoad, 5, 1, { + await context.beat(lowLoad, 5, 3, { observedRequests: 10, enforcedConnections: 0, sqlFailures: 0 }) await context.activatePreferredSource() - const attempt = await context.store.claimRegionalRehome() + const candidates = await context.store.selectIdleRegionalRehomeCandidates(context.safety()) + const attempt = candidates[0] expect(attempt?.targetCellId).toBe(lowLoad.id) + expect(await context.store.commitIdleRegionalRehome(attempt!, context.safety(), 100)).toEqual({ + outcome: 'committed' + }) await context.database.close() }) it('falls to the next clean target when the load winner goes unclean', async () => { const context = await setup() - await context.beat(source, 1, 1, { + await context.beat(source, 1, 3, { observedRequests: 0, enforcedConnections: 0, sqlFailures: 0 }) - await context.beat(noHeadroom, 2, 1, { + await context.beat(noHeadroom, 2, 3, { observedRequests: 0, enforcedConnections: 999, sqlFailures: 0 }) - await context.beat(unclean, 3, 1, { + await context.beat(unclean, 3, 3, { observedRequests: 0, enforcedConnections: 0, sqlFailures: UNCLEAN }) - await context.beat(highLoad, 4, 1, { + await context.beat(highLoad, 4, 3, { observedRequests: 50, enforcedConnections: 0, sqlFailures: 0 }) - await context.beat(lowLoad, 5, 1, { + await context.beat(lowLoad, 5, 3, { observedRequests: 10, enforcedConnections: 0, sqlFailures: UNCLEAN }) await context.activatePreferredSource() - const attempt = await context.store.claimRegionalRehome() + const candidates = await context.store.selectIdleRegionalRehomeCandidates(context.safety()) + const attempt = candidates[0] expect(attempt?.targetCellId).toBe(highLoad.id) + expect(await context.store.commitIdleRegionalRehome(attempt!, context.safety(), 100)).toEqual({ + outcome: 'committed' + }) await context.database.close() }) }) diff --git a/cloud/apps/relay/src/regional-rehome-worker.test.ts b/cloud/apps/relay/src/regional-rehome-worker.test.ts index 33e7f01f737..bd47923bdf1 100644 --- a/cloud/apps/relay/src/regional-rehome-worker.test.ts +++ b/cloud/apps/relay/src/regional-rehome-worker.test.ts @@ -11,148 +11,12 @@ import { startRegionalRehomeWorker } from './regional-rehome-worker.js' describe('regional rehome worker', () => { afterEach(() => vi.restoreAllMocks()) - it('sends an incarnation- and source-epoch-bound drain without exposing identity', async () => { - let now = 0 - const attempt = { - attemptId: '11111111-1111-4111-8111-111111111111', - userId: 'private-user', - relayHostId: 'abcdefghijklmnop', - preferredRegion: 'asia-east2', - sourceCellId: 'production-gce-c7', - sourceCellUrl: 'https://c7.relay.example.test', - sourceCellIncarnation: '22222222-2222-4222-8222-222222222222', - targetCellId: 'production-gce-c27', - targetCellIncarnation: '33333333-3333-4333-8333-333333333333', - previousEpoch: 7, - assignmentEpoch: 8, - drainGraceMs: 60_000, - sendAttempts: 1 - } - const claimRegionalRehome = vi.fn().mockResolvedValueOnce(null).mockResolvedValue(attempt) - const recordRegionalRehomeDrainReceipt = vi.fn().mockResolvedValue(true) - const assignments = { - claimRegionalRehome, - recordRegionalRehomeDrainReceipt - } as unknown as RelayAssignmentStore - const requests: Array<{ url: string; init?: RequestInit }> = [] - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const worker = startRegionalRehomeWorker(config(), assignments, { - now: () => now, - safetySnapshot: () => safety(now), - intervalMs: 60_000, - identityToken: async (audience) => { - expect(audience).toBe('https://relay.example.test/v1/admin/host-drain') - return 'secret-token' - }, - fetch: (async (url, init) => { - requests.push({ url: String(url), init }) - return Response.json({ v: 1, outcome: 'accepted' }) - }) as typeof fetch - })! - await settleWorker() - now = 1_000 - await worker.run() - worker.stop() - - expect(requests).toHaveLength(1) - expect(requests[0]!.url).toBe('https://c7.relay.example.test/v1/admin/host-drain') - expect(requests[0]!.url).not.toContain('secret-token') - expect(requests[0]!.init?.headers).toMatchObject({ - authorization: 'Bearer secret-token' - }) - expect(JSON.parse(String(requests[0]!.init?.body))).toEqual({ - v: 1, - attemptId: '11111111-1111-4111-8111-111111111111', - userId: 'private-user', - relayHostId: 'abcdefghijklmnop', - sourceCellId: 'production-gce-c7', - sourceCellIncarnation: '22222222-2222-4222-8222-222222222222', - sourceAssignmentEpoch: 7, - graceMs: 60_000 - }) - expect(recordRegionalRehomeDrainReceipt).toHaveBeenCalledWith( - '11111111-1111-4111-8111-111111111111', - 'accepted' - ) - const logs = warn.mock.calls.map((call) => String(call[0])).join('\n') - expect(logs).not.toContain('private-user') - expect(logs).not.toContain('abcdefghijklmnop') - }) - - it('fails closed before the observation gate and records bounded dispatch failures', async () => { - let now = 0 - const attempt = { - attemptId: '11111111-1111-4111-8111-111111111111', - userId: 'private-user', - relayHostId: 'abcdefghijklmnop', - sourceCellId: 'source', - sourceCellUrl: 'https://source.example.test', - sourceCellIncarnation: '22222222-2222-4222-8222-222222222222', - targetCellId: 'target', - previousEpoch: 1, - assignmentEpoch: 2, - drainGraceMs: 60_000, - sendAttempts: 1 - } - const claimRegionalRehome = vi.fn().mockResolvedValueOnce(null).mockResolvedValue(attempt) - const assignments = { - claimRegionalRehome, - recordRegionalRehomeDispatchFailure: vi.fn().mockResolvedValue(undefined) - } as unknown as RelayAssignmentStore - const worker = startRegionalRehomeWorker(config(), assignments, { - now: () => now, - safetySnapshot: () => safety(now), - intervalMs: 60_000, - identityToken: async () => { - throw new Error('token unavailable') - } - })! - await settleWorker() - claimRegionalRehome.mockClear() - now = 100 - await worker.run() - worker.stop() - expect(assignments.recordRegionalRehomeDispatchFailure).toHaveBeenCalledWith( - '11111111-1111-4111-8111-111111111111' - ) - }) - - it('keeps a failed poll out of the durable dispatch-failure budget', async () => { - let now = 0 - const claimRegionalRehome = vi - .fn() - .mockResolvedValueOnce(null) - .mockRejectedValue(new Error('Connection terminated due to connection timeout')) - const recordRegionalRehomeDispatchFailure = vi.fn().mockResolvedValue(undefined) - const assignments = { - claimRegionalRehome, - recordRegionalRehomeDispatchFailure - } as unknown as RelayAssignmentStore - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const worker = startRegionalRehomeWorker(config(), assignments, { - now: () => now, - safetySnapshot: () => safety(now), - intervalMs: 60_000 - })! - await settleWorker() - now = 1_000 - await expect(worker.run()).resolves.toBeUndefined() - worker.stop() - - // The poll never claimed an attempt, so nothing was drained and nothing may - // be charged to the budget that latches the durable control off. - expect(recordRegionalRehomeDispatchFailure).not.toHaveBeenCalled() - expect(warn.mock.calls.map((call) => JSON.parse(String(call[0])).event)).toEqual([ - 'orca_relay_regional_rehome_poll_failed' - ]) - }) - it('passes unsafe process telemetry to the durable claim gate', async () => { let now = 0 let sqlFailures = 0 - const claimRegionalRehome = vi.fn().mockResolvedValue(null) + const selectIdleRegionalRehomeCandidates = vi.fn().mockResolvedValue([]) const assignments = { - claimRegionalRehome + selectIdleRegionalRehomeCandidates } as unknown as RelayAssignmentStore const worker = startRegionalRehomeWorker(config(), assignments, { now: () => now, @@ -160,46 +24,40 @@ describe('regional rehome worker', () => { intervalMs: 60_000 })! await settleWorker() - claimRegionalRehome.mockClear() + selectIdleRegionalRehomeCandidates.mockClear() now = 100 sqlFailures = 1 await worker.run() worker.stop() - expect(claimRegionalRehome).toHaveBeenCalledWith( + expect(selectIdleRegionalRehomeCandidates).toHaveBeenCalledWith( expect.objectContaining({ observedAt: 100, sqlFailures: 1 }) ) }) it('starts inert on directors so durable control can enable without a restart', async () => { let now = 0 - const claimRegionalRehome = vi.fn().mockResolvedValue(null) + const selectIdleRegionalRehomeCandidates = vi.fn().mockResolvedValue([]) const assignments = { - claimRegionalRehome + selectIdleRegionalRehomeCandidates } as unknown as RelayAssignmentStore - const worker = startRegionalRehomeWorker( - config(), - assignments, - { - now: () => now, - safetySnapshot: () => safety(now), - intervalMs: 60_000 - } - ) + const worker = startRegionalRehomeWorker(config(), assignments, { + now: () => now, + safetySnapshot: () => safety(now), + intervalMs: 60_000 + }) expect(worker).not.toBeNull() await settleWorker() - claimRegionalRehome.mockClear() + selectIdleRegionalRehomeCandidates.mockClear() now = 100 await worker!.run() worker!.stop() - expect(claimRegionalRehome).toHaveBeenCalledOnce() + expect(selectIdleRegionalRehomeCandidates).toHaveBeenCalledOnce() expect( - startRegionalRehomeWorker( - config({ role: 'cell' }), - {} as RelayAssignmentStore, - { safetySnapshot: () => safety(1) } - ) + startRegionalRehomeWorker(config({ role: 'cell' }), {} as RelayAssignmentStore, { + safetySnapshot: () => safety(1) + }) ).toBeNull() }) @@ -208,16 +66,20 @@ describe('regional rehome worker', () => { const limit = cells * REGIONAL_REHOME_RECONNECTS_PER_CELL_LIMIT const processSafety = { ...safety(100), reconnects: limit * 10 } const fleetSafety = { ...safety(100), reconnects: limit } - expect(regionalRehomeSafetyFailure( - combineRegionalRehomeSafety(processSafety, fleetSafety), - 100, - cells - )).toBeNull() - expect(regionalRehomeSafetyFailure( - combineRegionalRehomeSafety(processSafety, { ...fleetSafety, reconnects: limit + 1 }), - 100, - cells - )).toBe('elevated_reconnects') + expect( + regionalRehomeSafetyFailure( + combineRegionalRehomeSafety(processSafety, fleetSafety), + 100, + cells + ) + ).toBeNull() + expect( + regionalRehomeSafetyFailure( + combineRegionalRehomeSafety(processSafety, { ...fleetSafety, reconnects: limit + 1 }), + 100, + cells + ) + ).toBe('elevated_reconnects') }) }) diff --git a/cloud/apps/relay/src/regional-rehome-worker.ts b/cloud/apps/relay/src/regional-rehome-worker.ts index 4d8fa694afd..5f3827f5188 100644 --- a/cloud/apps/relay/src/regional-rehome-worker.ts +++ b/cloud/apps/relay/src/regional-rehome-worker.ts @@ -1,4 +1,4 @@ -import { z } from 'zod' +import { IdleRegionalRehomeResponseSchema } from '@orca-cloud/relay-contract' import type { RelayAssignmentStore } from './assignment-store.js' import type { RelayConfig } from './config.js' import { googleMetadataIdentityToken } from './google-metadata-identity-token.js' @@ -20,13 +20,6 @@ export type RegionalRehomeWorker = { stop: () => void } -const RegionalHostDrainResponseSchema = z - .object({ - v: z.literal(1), - outcome: z.enum(['accepted', 'already-accepted', 'host-not-connected']) - }) - .strict() - export function startRegionalRehomeWorker( config: RelayConfig, assignments: RelayAssignmentStore, @@ -42,7 +35,6 @@ export function startRegionalRehomeWorker( } const audience = config.rehomeAudience const safetySnapshot = options.safetySnapshot - const now = options.now ?? Date.now const fetchImpl = options.fetch ?? fetch const tokenProvider = options.identityToken ?? @@ -52,64 +44,50 @@ export function startRegionalRehomeWorker( const run = async (): Promise => { if (stopped || inFlight) return inFlight = true - let attemptId: string | null = null try { - const processSafety = safetySnapshot() - const attempt = await assignments.claimRegionalRehome(processSafety) - if (!attempt) return - attemptId = attempt.attemptId + const candidates = await assignments.selectIdleRegionalRehomeCandidates(safetySnapshot()) + if (candidates.length === 0) return const token = await tokenProvider(audience) - const response = await fetchImpl( - new URL('/v1/admin/host-drain', attempt.sourceCellUrl), - { - method: 'POST', - headers: { - authorization: `Bearer ${token}`, - 'content-type': 'application/json' - }, - body: JSON.stringify({ - v: 1, - attemptId: attempt.attemptId, - userId: attempt.userId, - relayHostId: attempt.relayHostId, - sourceCellId: attempt.sourceCellId, - sourceCellIncarnation: attempt.sourceCellIncarnation, - sourceAssignmentEpoch: attempt.previousEpoch, - graceMs: attempt.drainGraceMs - }), - signal: AbortSignal.timeout(options.requestTimeoutMs ?? 10_000) + for (const candidate of candidates) { + if (stopped) return + const { sourceCellUrl, ...request } = candidate + try { + const response = await fetchImpl(new URL('/v1/admin/host-idle-rehome', sourceCellUrl), { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ + ...request, + cohortPercent: config.regionCorrectionCohortPercent ?? 0, + directorSafety: safetySnapshot() + }), + signal: AbortSignal.timeout(options.requestTimeoutMs ?? 10_000) + }) + if (!response.ok) throw new Error(`regional_rehome_source_${response.status}`) + const body = IdleRegionalRehomeResponseSchema.parse(await response.json()) + if (body.outcome === 'committed') { + console.warn( + JSON.stringify({ + event: 'orca_relay_idle_rehome_committed', + sourceCellId: candidate.sourceCellId, + targetCellId: candidate.targetCellId + }) + ) + return + } + } catch (error) { + // The source may have committed; its durable outcome owns recovery. + console.warn( + JSON.stringify({ + event: 'orca_relay_idle_rehome_request_failed', + reason: error instanceof Error ? error.message : 'unknown' + }) + ) } - ) - if (!response.ok) throw new Error(`regional_rehome_source_${response.status}`) - const body = RegionalHostDrainResponseSchema.safeParse(await response.json()) - if (!body.success) throw new Error('regional_rehome_source_invalid_response') - await assignments.recordRegionalRehomeDrainReceipt( - attempt.attemptId, - body.data.outcome - ) - console.warn( - JSON.stringify({ - event: 'orca_relay_regional_rehome_dispatched', - sourceCellId: attempt.sourceCellId, - targetCellId: attempt.targetCellId, - outcome: body.data.outcome, - sendAttempts: attempt.sendAttempts - }) - ) - } catch (error) { - // Only a claimed attempt was drained. A poll that failed before the claim - // - a pool timeout on the once-a-second control read - dispatched nothing, - // so it must not spend the budget that latches the durable control off. - if (attemptId) { - await assignments - .recordRegionalRehomeDispatchFailure(attemptId) - .catch(() => undefined) } + } catch (error) { console.warn( JSON.stringify({ - event: attemptId - ? 'orca_relay_regional_rehome_dispatch_failed' - : 'orca_relay_regional_rehome_poll_failed', + event: 'orca_relay_regional_rehome_poll_failed', reason: error instanceof Error ? error.message : 'unknown' }) ) diff --git a/cloud/apps/relay/src/relay-region-app.test.ts b/cloud/apps/relay/src/relay-region-app.test.ts index 30cf3bf3e26..54e8da670b8 100644 --- a/cloud/apps/relay/src/relay-region-app.test.ts +++ b/cloud/apps/relay/src/relay-region-app.test.ts @@ -40,6 +40,7 @@ describe('Relay region API', () => { ) expect(response.status).toBe(200) + expect(await response.clone().json()).not.toHaveProperty('regionCorrection') expect(assign).toHaveBeenCalledWith( { userId: 'user-1', relayHostId: 'asiahost00000001' }, 'asia-east2', @@ -83,6 +84,160 @@ describe('Relay region API', () => { ) }) + it('preserves the cold-start hint and binds a negotiated window after placement', async () => { + const assignment = { + userId: 'user-1', + relayHostId: 'abcdefghijklmnop', + cellId: 'asia-c1', + cellUrl: 'https://asia-c1.relay.example.test', + region: 'asia-east2', + assignmentEpoch: 7, + leaseExpiresAt: Date.now() + 300_000 + } + const assign = vi.fn(async () => assignment) + const window = { + generation: 2, + expiresAt: Date.now() + 86_400_000, + assignmentEpoch: 7, + incumbentRegion: 'asia-east2', + policyVersion: 1 + } + const exchangeRegionCorrection = vi.fn(async () => ({ v: 1, window })) + const app = createRelayApp(config(), { + store: {} as never, + assignments: { assign, exchangeRegionCorrection } as never, + drain: vi.fn(), + ready: async () => true + }) + const regionCorrection = { v: 1, action: 'issue-window' } + const response = await app.request( + '/v1/assign', + assignmentRequest('abcdefghijklmnop', { + preferredRegion: 'asia-east2', + regionCorrection + }) + ) + expect(response.status).toBe(200) + expect(assign).toHaveBeenCalledWith( + { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }, + 'asia-east2', + 'asia-east2' + ) + expect(exchangeRegionCorrection).toHaveBeenCalledWith( + { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }, + regionCorrection, + 7 + ) + expect(((await response.json()) as { regionCorrection: unknown }).regionCorrection).toEqual({ + v: 1, + window + }) + }) + + it('returns successful placement when optional window storage is unavailable', async () => { + const app = createRelayApp(config(), { + store: {} as never, + assignments: { + assign: async () => ({ + cellId: 'asia-c1', + region: 'asia-east2', + cellUrl: 'https://asia-c1.relay.example.test', + assignmentEpoch: 7 + }), + exchangeRegionCorrection: async () => { + throw new Error('database unavailable') + } + } as never, + drain: vi.fn(), + ready: async () => true + }) + const response = await app.request( + '/v1/assign', + assignmentRequest('abcdefghijklmnop', { + preferredRegion: 'asia-east2', + regionCorrection: { v: 1, action: 'issue-window' } + }) + ) + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + cellUrl: 'https://asia-c1.relay.example.test', + assignmentEpoch: 7 + }) + }) + + it('does not place or write a legacy hint when reporting migration evidence', async () => { + const current = { + userId: 'user-1', + relayHostId: 'abcdefghijklmnop', + cellId: 'asia-c1', + cellUrl: 'https://asia-c1.relay.example.test', + region: 'asia-east2', + assignmentEpoch: 7, + leaseExpiresAt: Date.now() + 300_000 + } + const assign = vi.fn() + const resolve = vi.fn(async () => current) + const exchangeRegionCorrection = vi.fn(async () => ({ v: 1, reportStatus: 'accepted' })) + const app = createRelayApp(config(), { + store: {} as never, + assignments: { assign, resolve, exchangeRegionCorrection } as never, + drain: vi.fn(), + ready: async () => true + }) + const regionCorrection = { + v: 1, + action: 'report', + generation: 2, + assignmentEpoch: 7, + policyVersion: 1, + outcome: 'conclusive', + measurements: { 'us-central1': 40, 'asia-east2': 180 } + } + const response = await app.request( + '/v1/assign', + assignmentRequest('abcdefghijklmnop', { + preferredRegion: 'us-central1', + regionCorrection + }) + ) + expect(response.status).toBe(200) + expect(assign).not.toHaveBeenCalled() + expect(exchangeRegionCorrection).toHaveBeenCalledWith( + { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }, + regionCorrection, + 7 + ) + expect(((await response.json()) as { assignmentEpoch: number }).assignmentEpoch).toBe(7) + }) + + it('does not manufacture an assignment for a report whose assignment disappeared', async () => { + const assign = vi.fn() + const exchangeRegionCorrection = vi.fn() + const app = createRelayApp(config(), { + store: {} as never, + assignments: { assign, resolve: async () => null, exchangeRegionCorrection } as never, + drain: vi.fn(), + ready: async () => true + }) + const response = await app.request( + '/v1/assign', + assignmentRequest('abcdefghijklmnop', { + regionCorrection: { + v: 1, + action: 'report', + generation: 2, + assignmentEpoch: 7, + policyVersion: 1, + outcome: 'inconclusive', + reason: 'timeout' + } + }) + ) + expect(response.status).toBe(409) + expect(assign).not.toHaveBeenCalled() + expect(exchangeRegionCorrection).not.toHaveBeenCalled() + }) + it('exposes only the store-provided healthy catalog from directors', async () => { const regionCatalog = vi.fn(async () => [ { region: 'us-central1' as const, probeOrigins: ['https://us.relay.example.test'] } diff --git a/cloud/apps/relay/src/relay-server.ts b/cloud/apps/relay/src/relay-server.ts index 77a15a1d259..7cee77e52de 100644 --- a/cloud/apps/relay/src/relay-server.ts +++ b/cloud/apps/relay/src/relay-server.ts @@ -20,14 +20,12 @@ import { createRelayApp } from './app.js' import { RelayAssignmentStore } from './assignment-store.js' import type { RelayConfig } from './config.js' import { RelayCredentialStore } from './credential-store.js' -import type { RelayDatabase } from './database.js' +import { readRelayDatabasePoolPressure, type RelayDatabase } from './database.js' import { HostSessionRegistry } from './host-session-registry.js' import { observeRelayDatabase } from './observed-relay-database.js' import { RelayObservability } from './relay-observability.js' -import { - RelayConnectionLedger, - type RelayConnectionUpgrade -} from './relay-connection-ledger.js' +import { combineRegionalRehomeSafety } from './regional-rehome-safety.js' +import { RelayConnectionLedger, type RelayConnectionUpgrade } from './relay-connection-ledger.js' import { createRelayReadiness } from './relay-readiness.js' import { createRelayTokenVerifier, readBearer } from './relay-token-verifier.js' import { closeRelayWebSocket } from './relay-websocket-close.js' @@ -65,7 +63,7 @@ function guardSocketErrors(socket: WebSocket, kind: string): void { function admissionSource(request: IncomingMessage): string { const forwarded = request.headers['x-forwarded-for'] - const chain = (Array.isArray(forwarded) ? forwarded.join(',') : forwarded ?? '') + const chain = (Array.isArray(forwarded) ? forwarded.join(',') : (forwarded ?? '')) .split(',') .map((entry) => entry.trim()) .filter(Boolean) @@ -112,6 +110,7 @@ export function createRelayServer( const store = new RelayCredentialStore(observedDatabase, options.now) const assignments = new RelayAssignmentStore(observedDatabase, options.now, { requireLiveCells: config.role === 'director', + regionalRehomeCohortPercent: config.regionCorrectionCohortPercent ?? 0, recordControlRenewal: (durationMs, outcome) => observability.recordControlRenewal?.(durationMs, outcome) }) @@ -127,17 +126,35 @@ export function createRelayServer( queuedBytes, observability, options.now, - options.random + options.random, + cellIncarnation ) const app = createRelayApp(config, { store, assignments, drain: (graceMs) => sessions.drain(graceMs), drainHost: (input) => sessions.drainHost(input), + idleRehome: (input) => { + const now = (options.now ?? Date.now)() + if (input.directorSafety.observedAt > now || now - input.directorSafety.observedAt > 60_000) { + return Promise.resolve({ outcome: 'deferred' }) + } + return sessions.idleRehome(input, + () => assignments.commitIdleRegionalRehome(input, combineRegionalRehomeSafety( + input.directorSafety, + { ...observability.regionalRehomeRuntimeSafety(), ...readRelayDatabasePoolPressure(database) } + ), input.cohortPercent), + () => assignments.reconcileIdleRegionalRehome(input) + ) + }, regionalRehomeTrustProbeHostExists: (input) => sessions.get(input) !== null, cellIncarnation, isDraining: () => sessions.isDraining(), runtimeCounts: () => runtimeCounts(), + regionalRehomeSafetySnapshot: () => ({ + ...observability.regionalRehomeRuntimeSafety(), + ...readRelayDatabasePoolPressure(database) + }), ready, recordAssignmentAdmission: (outcome) => observability.recordAssignmentAdmission?.(outcome), recordAssignmentRejectionReason: (lane, reason) => @@ -339,7 +356,7 @@ export function createRelayServer( const identity = invite ? { userId: invite.userId, relayHostId: hostId } : null // Released combined-service invites gain their first durable cell assignment here. const assignment = identity - ? (await assignments.resolve(identity)) ?? (await assignments.assign(identity)) + ? ((await assignments.resolve(identity)) ?? (await assignments.assign(identity))) : null if (!invite || !assignment) { phoneAdmission?.hostData.release() diff --git a/cloud/apps/relay/src/relay-sweep-schedule.test.ts b/cloud/apps/relay/src/relay-sweep-schedule.test.ts index d5ef450cc43..55469cab91c 100644 --- a/cloud/apps/relay/src/relay-sweep-schedule.test.ts +++ b/cloud/apps/relay/src/relay-sweep-schedule.test.ts @@ -35,7 +35,7 @@ describe('sweep schedule jitter', () => { rehomeAudience: 'https://rehome.example.test', rehomeDirectorServiceAccount: 'rehome@example.test' } as never, - { claimRegionalRehome: async () => null } as never, + { selectIdleRegionalRehomeCandidates: async () => [] } as never, { random: () => 0.5, safetySnapshot: () => ({}) as never } ) } finally { diff --git a/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/README.md b/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/README.md new file mode 100644 index 00000000000..2ac4ad78391 --- /dev/null +++ b/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/README.md @@ -0,0 +1,8 @@ +Exact relay contract snapshots from `027acb4efa2e6b226d40df266b86367423946d62`, `cloud/packages/relay-contract/src/`. Used to exercise the pre-correction strict wire parsers. Do not format or edit these baseline sources. + +```text +aba94e108a5cd0f1af8b38875429ad8636d24c43a728273e3df60d9a1a1d1b6d director-messages.ts +bd13b5a694a5d683a5b680c14e46ab33f4ef4a5bfedf040d046b09d540cb4c17 wire-scalars.ts +bc89116f884a2f20a6588f9b91219aa596bc2410d28b499a93a78350def109d5 relay-regions.ts +8fcae470a5fc72f2fcdde9d2f09cd20289c256356dd490484ac1cfa53839fbe4 control-messages.ts +``` diff --git a/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/control-messages.ts b/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/control-messages.ts new file mode 100644 index 00000000000..0daf21e7c28 --- /dev/null +++ b/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/control-messages.ts @@ -0,0 +1,146 @@ +import { z } from 'zod' +import { + Base6432ByteSchema, + Base64Raw24ByteSchema, + Base64Url32ByteSchema, + EpochMsSchema, + GenerationSchema, + OpaqueIdSchema, + PositiveDurationMsSchema, + RelayHostIdSchema +} from './wire-scalars.js' + +const AppVersionSchema = z.string().min(1).max(128) +const BoundedCiphertextSchema = z + .string() + .min(1) + .max(16 * 1024) + .regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/) +const ConnectionKindSchema = z.enum(['invite', 'resume']) + +export const HostHelloSchema = z + .object({ + v: z.literal(1), + relayHostId: RelayHostIdSchema, + assignmentEpoch: GenerationSchema, + hostPublicKeyB64: Base6432ByteSchema, + appVersion: AppVersionSchema, + previousGeneration: GenerationSchema.optional(), + controlResumeSecret: Base64Url32ByteSchema.optional() + }) + .strict() + +export const HostChallengeSchema = z + .object({ + challengeId: OpaqueIdSchema, + relayEphemeralPublicKeyB64: Base6432ByteSchema, + nonceB64: Base64Raw24ByteSchema, + ciphertextB64: BoundedCiphertextSchema, + expiresAt: EpochMsSchema + }) + .strict() + +export const HostChallengeAckSchema = z + .object({ challengeId: OpaqueIdSchema, proofB64: Base6432ByteSchema }) + .strict() + +// Advertised on the control upgrade rather than in host-hello: HostHelloSchema +// is strict, so a new hello key is refused by every already-deployed cell. +export const RELAY_HOST_CAPABILITIES_HEADER = 'x-orca-host-capabilities' +// The host accepts kind/relayDeviceId on a pendingConns entry. A host that does +// not advertise this parses those entries strictly and would drop the whole ack. +export const RELAY_HOST_CAPABILITY_PENDING_CONN_DETAILS = 'pending-conn-details' + +export function parseRelayHostCapabilities( + header: string | string[] | undefined +): ReadonlySet { + const raw = Array.isArray(header) ? header.join(',') : (header ?? '') + return new Set( + raw + .split(',') + .map((token) => token.trim()) + .filter((token) => token.length > 0 && token.length <= 64) + .slice(0, 16) + ) +} + +// kind/relayDeviceId are optional so an entry stays readable by a host that +// predates them; the cell only emits them to a host that advertised support. +const PendingConnectionSchema = z + .object({ + connId: OpaqueIdSchema, + connTicket: Base64Url32ByteSchema, + kind: ConnectionKindSchema.optional(), + relayDeviceId: OpaqueIdSchema.optional() + }) + .strict() + +export const HostHelloAckSchema = z + .object({ + v: z.literal(1), + generation: GenerationSchema, + controlResumeSecret: Base64Url32ByteSchema, + leaseExpiresAt: EpochMsSchema, + activeConnIds: z.array(OpaqueIdSchema).max(8), + pendingConns: z.array(PendingConnectionSchema).max(8) + }) + .strict() + +export const ConnectionOpenSchema = z + .object({ + connId: OpaqueIdSchema, + connTicket: Base64Url32ByteSchema, + kind: ConnectionKindSchema, + relayDeviceId: OpaqueIdSchema, + attachDeadlineMs: PositiveDurationMsSchema + }) + .strict() + +export const HostDataAuthSchema = z + .object({ + v: z.literal(1), + connTicket: Base64Url32ByteSchema, + generation: GenerationSchema + }) + .strict() + +export const InviteCreateSchema = z + .object({ reqId: OpaqueIdSchema, relayDeviceId: OpaqueIdSchema }) + .strict() + +export const InviteCreatedSchema = z + .object({ + reqId: OpaqueIdSchema, + inviteToken: Base64Url32ByteSchema, + expiresAt: EpochMsSchema, + maxAttempts: z.number().int().positive().max(16) + }) + .strict() + +export const DeviceRevokeSchema = z + .object({ reqId: OpaqueIdSchema, relayDeviceId: OpaqueIdSchema }) + .strict() + +export const AuthRefreshSchema = z.object({ relayJwt: z.string().min(1).max(8 * 1024) }).strict() + +export const DrainSchema = z + .object({ + graceMs: z.number().int().nonnegative().max(60 * 60 * 1000), + recovery: z.literal('resolve-director') + }) + .strict() + +export const HeartbeatSchema = z.object({ t: EpochMsSchema }).strict() + +export type HostHello = z.infer +export type HostChallenge = z.infer +export type HostChallengeAck = z.infer +export type HostHelloAck = z.infer +export type ConnectionOpen = z.infer +export type HostDataAuth = z.infer +export type InviteCreate = z.infer +export type InviteCreated = z.infer +export type DeviceRevoke = z.infer +export type AuthRefresh = z.infer +export type Drain = z.infer +export type Heartbeat = z.infer diff --git a/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/director-messages.ts b/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/director-messages.ts new file mode 100644 index 00000000000..e697135b68e --- /dev/null +++ b/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/director-messages.ts @@ -0,0 +1,75 @@ +import { z } from 'zod' +import { + Base64Url32ByteSchema, + CanonicalHttpsOriginSchema, + EpochMsSchema, + GenerationSchema, + RelayHostIdSchema +} from './wire-scalars.js' +import { RelayRegionSchema } from './relay-regions.js' + +const SignedAssignmentLeaseSchema = z.string().min(1).max(8 * 1024) + +export const AssignmentRequestSchema = z + .object({ + v: z.literal(1), + relayHostId: RelayHostIdSchema, + // Client-declared reconnection; the director verifies it against the + // durable assignment before granting fast-lane admission. + reconnect: z.boolean().optional(), + preferredRegion: RelayRegionSchema.optional() + }) + .strict() + +export const AssignmentResponseSchema = z + .object({ + v: z.literal(1), + cellUrl: CanonicalHttpsOriginSchema, + assignmentEpoch: GenerationSchema, + lease: SignedAssignmentLeaseSchema + }) + .strict() + +export const ResolveRequestSchema = z + .object({ + v: z.literal(1), + relayHostId: RelayHostIdSchema, + resumeToken: Base64Url32ByteSchema + }) + .strict() + +export const ResolveResponseSchema = z + .object({ + v: z.literal(1), + cellUrl: CanonicalHttpsOriginSchema, + assignmentEpoch: GenerationSchema, + leaseExpiresAt: EpochMsSchema + }) + .strict() + +export const RelayMovedSchema = z + .object({ + v: z.literal(1), + cellUrl: CanonicalHttpsOriginSchema, + assignmentEpoch: GenerationSchema + }) + .strict() + +export function isTrustedNewerMove(input: { + sourceOrigin: string + configuredDirectorOrigin: string + currentAssignmentEpoch: number + move: z.infer +}): boolean { + // Why: cells and stale director responses must never redirect a credential-bearing client. + return ( + input.sourceOrigin === input.configuredDirectorOrigin && + input.move.assignmentEpoch > input.currentAssignmentEpoch + ) +} + +export type AssignmentRequest = z.infer +export type AssignmentResponse = z.infer +export type ResolveRequest = z.infer +export type ResolveResponse = z.infer +export type RelayMoved = z.infer diff --git a/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/relay-regions.ts b/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/relay-regions.ts new file mode 100644 index 00000000000..6b8837829df --- /dev/null +++ b/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/relay-regions.ts @@ -0,0 +1,71 @@ +import { z } from 'zod' + +export const RELAY_REGIONS = ['us-central1', 'asia-east2'] as const + +export const RelayRegionSchema = z.enum(RELAY_REGIONS) + +export type RelayRegion = z.infer + +export const RELAY_DEFAULT_REGION: RelayRegion = 'us-central1' + +// Field-name segment for the flat per-region runtime counters, spelled out rather than derived so +// the Terraform side can hold the same literal and a test can compare the two. `satisfies` makes a +// new region a compile error here, which is the point: a region with no segment would silently +// drop out of the region-skew alert's denominators. +export const RELAY_REGION_METRIC_SEGMENTS = { + 'us-central1': 'UsCentral1', + 'asia-east2': 'AsiaEast2' +} as const satisfies Record + +const RelayProbeOriginSchema = z.string().url().max(2_048).refine(isCanonicalHttpsOrigin) + +export const RelayRegionCatalogResponseSchema = z + .object({ + v: z.literal(1), + regions: z + .array( + z + .object({ + region: RelayRegionSchema, + probeOrigins: z.array(RelayProbeOriginSchema).min(1).max(2) + }) + .strict() + ) + .max(RELAY_REGIONS.length) + }) + .strict() + .superRefine((catalog, context) => { + const regions = new Set() + const origins = new Set() + for (const [regionIndex, entry] of catalog.regions.entries()) { + if (regions.has(entry.region)) { + context.addIssue({ + code: 'custom', + message: 'duplicate relay region', + path: ['regions', regionIndex, 'region'] + }) + } + regions.add(entry.region) + for (const [originIndex, origin] of entry.probeOrigins.entries()) { + if (origins.has(origin)) { + context.addIssue({ + code: 'custom', + message: 'duplicate relay probe origin', + path: ['regions', regionIndex, 'probeOrigins', originIndex] + }) + } + origins.add(origin) + } + } + }) + +export type RelayRegionCatalogResponse = z.infer + +function isCanonicalHttpsOrigin(value: string): boolean { + try { + const url = new URL(value) + return url.protocol === 'https:' && url.origin === value + } catch { + return false + } +} diff --git a/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/wire-scalars.ts b/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/wire-scalars.ts new file mode 100644 index 00000000000..27dd3a8b30f --- /dev/null +++ b/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/wire-scalars.ts @@ -0,0 +1,20 @@ +import { z } from 'zod' + +export const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) +export const Base64Url24ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{32}$/) +export const Base6432ByteSchema = z.string().regex(/^(?:[A-Za-z0-9+/]{4}){10}[A-Za-z0-9+/]{3}=$/) +export const Base64Raw24ByteSchema = z.string().regex(/^(?:[A-Za-z0-9+/]{4}){8}$/) +export const RelayHostIdSchema = z.string().regex(/^[A-Za-z0-9_-]{16}$/) +export const OpaqueIdSchema = z.string().min(1).max(128) +export const EpochMsSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) +export const GenerationSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) +export const PositiveDurationMsSchema = z.number().int().positive().max(24 * 60 * 60 * 1000) + +export const CanonicalHttpsOriginSchema = z.string().max(2048).refine((value) => { + try { + const url = new URL(value) + return url.protocol === 'https:' && url.origin === value && url.pathname === '/' + } catch { + return false + } +}, 'must be a canonical HTTPS origin') diff --git a/cloud/apps/relay/tsconfig.build.json b/cloud/apps/relay/tsconfig.build.json index 489ddfd34d6..38eb0396cf2 100644 --- a/cloud/apps/relay/tsconfig.build.json +++ b/cloud/apps/relay/tsconfig.build.json @@ -6,5 +6,5 @@ "outDir": "dist", "rootDir": "src" }, - "exclude": ["src/**/*.test.ts"] + "exclude": ["src/**/*.test.ts", "src/test-fixtures/**"] } diff --git a/cloud/dev/scripts/deploy-relay-blue-green.mjs b/cloud/dev/scripts/deploy-relay-blue-green.mjs index 88e4f8ccc60..ddfe2bce8fc 100644 --- a/cloud/dev/scripts/deploy-relay-blue-green.mjs +++ b/cloud/dev/scripts/deploy-relay-blue-green.mjs @@ -10,6 +10,7 @@ export const DIRECTOR_REGIONAL_PLACEMENT_SECRET = 'orca-cloud-relay-regional-placement-enabled' export const DIRECTOR_REGIONAL_PLACEMENT_ENV = 'ORCA_RELAY_REGIONAL_PLACEMENT_ENABLED' +export const DIRECTOR_CORRECTION_COHORT_ENV = 'ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT' export const DIRECTOR_REHOME_IDENTITY_ENV = 'ORCA_RELAY_REHOME_DIRECTOR_SERVICE_ACCOUNT' export const DIRECTOR_REHOME_AUDIENCE_ENV = 'ORCA_RELAY_REHOME_AUDIENCE' @@ -228,6 +229,13 @@ export function directorCellSetAddition(currentValue, desiredValue) { return { changed: additions.length > 0, value: JSON.stringify(desired) } } +export function correctionCohortPercent(value) { + if (!/^(?:[0-9]|[1-9][0-9]|100)$/.test(String(value))) { + throw new Error('region correction cohort must be an integer from 0 to 100') + } + return String(value) +} + export function directorDeploymentEnvironment(config) { const imageDigest = config.image?.match(/@(sha256:[a-f0-9]{64})$/)?.[1] if (config.image !== undefined && imageDigest === undefined) { @@ -238,6 +246,10 @@ export function directorDeploymentEnvironment(config) { ORCA_RELAY_ADMISSION_SELECTOR_VERSION: SELECTOR_REVISION_MARKER, ...(imageDigest === undefined ? {} : { ORCA_RELAY_IMAGE_DIGEST: imageDigest }) } + if (config['region-correction-cohort-percent'] !== undefined && + config['region-correction-cohort-percent'] !== 'preserve') { + environment[DIRECTOR_CORRECTION_COHORT_ENV] = correctionCohortPercent(config['region-correction-cohort-percent']) + } const serviceAccount = projectServiceAccount(config, 'capacity-service-account') const asiaProofServiceAccount = projectServiceAccount(config, 'asia-proof-service-account') const rehomeDirectorServiceAccount = projectServiceAccount( @@ -302,7 +314,8 @@ export function parseArguments(argv) { values['rehome-director-service-account'] !== undefined || values['rehome-audience'] !== undefined || values['expected-rehome-generation'] !== undefined || - values['rehome-control-origin'] !== undefined + values['rehome-control-origin'] !== undefined || + values['region-correction-cohort-percent'] !== undefined ) { throw new Error('director configuration arguments require --role director') } @@ -785,6 +798,14 @@ export async function deployDirector(config, tag, overrides = {}) { config['prune-revisions'] === 'true' ? CONNECTION_CAPACITY_PROTOCOL : undefined const currentEnvironment = revisionEnvironment(servingRevision) const deploymentEnvironment = directorDeploymentEnvironment(config) + deploymentEnvironment[DIRECTOR_CORRECTION_COHORT_ENV] ??= correctionCohortPercent( + currentEnvironment[DIRECTOR_CORRECTION_COHORT_ENV] ?? '0' + ) + if (config['region-correction-cohort-percent'] !== undefined && + config['region-correction-cohort-percent'] !== 'preserve' && + config['expected-rehome-generation'] === undefined) { + throw new Error('cohort changes require an exact disabled regional-rehome generation') + } const mutableEnvironment = { ...deploymentEnvironment, [DIRECTOR_REGIONAL_PLACEMENT_ENV]: '' diff --git a/cloud/dev/scripts/deploy-relay-blue-green.test.mjs b/cloud/dev/scripts/deploy-relay-blue-green.test.mjs index 6e56676098b..a68ce50e912 100644 --- a/cloud/dev/scripts/deploy-relay-blue-green.test.mjs +++ b/cloud/dev/scripts/deploy-relay-blue-green.test.mjs @@ -4,6 +4,8 @@ import { test } from 'node:test' import { fileURLToPath } from 'node:url' import { activeRevision, + correctionCohortPercent, + DIRECTOR_CORRECTION_COHORT_ENV, cloudRunTrafficTag, DIRECTOR_ADMISSION_ENVIRONMENT, DIRECTOR_REGIONAL_PLACEMENT_ENV, @@ -812,3 +814,43 @@ test('waits for authenticated target readiness without hiding other capacity err /forbidden/ ) }) + + +test('validates bounded correction cohorts and leaves unspecified values to serving inheritance', () => { + for (const value of ['0', '1', '100']) assert.equal(correctionCohortPercent(value), value) + for (const value of ['-1', '101', '1.5', '', '01', 'true', '1\n']) { + assert.throws(() => correctionCohortPercent(value), /integer from 0 to 100/) + } + assert.equal(directorDeploymentEnvironment({})[DIRECTOR_CORRECTION_COHORT_ENV], undefined) + assert.equal(directorDeploymentEnvironment({ 'region-correction-cohort-percent': 'preserve' })[DIRECTOR_CORRECTION_COHORT_ENV], undefined) + assert.equal(directorDeploymentEnvironment({ 'region-correction-cohort-percent': '1' })[DIRECTOR_CORRECTION_COHORT_ENV], '1') +}) + +test('inherits the cohort on candidate and rollback revisions without resetting an enabled cohort', async () => { + const harness = directorHarness() + harness.state.revisions.get('relay-00001-old').env[DIRECTOR_CORRECTION_COHORT_ENV] = '3' + await deployDirector({}, 'candidate-new', harness.operations) + for (const revision of ['relay-00002-new', 'relay-00003-new']) { + assert.equal(harness.state.revisions.get(revision).env[DIRECTOR_CORRECTION_COHORT_ENV], '3') + } +}) + +test('starts an unstamped cohort at zero and rejects a cohort change without disabled-control proof', async () => { + const harness = directorHarness() + await assert.rejects(deployDirector({ 'region-correction-cohort-percent': '1' }, + 'candidate-new', harness.operations), /exact disabled regional-rehome generation/) + assert.equal(harness.state.activeRevision, 'relay-00001-old') + assert.equal(harness.state.nextRevision, 2) + await deployDirector({}, 'candidate-new', harness.operations) + assert.equal(harness.state.revisions.get('relay-00003-new').env[DIRECTOR_CORRECTION_COHORT_ENV], '0') +}) + +test('sets a reviewed cohort only behind repeated disabled-control verification', async () => { + const harness = directorHarness() + let verified = 0 + const config = { 'region-correction-cohort-percent': '1', 'expected-rehome-generation': '7' } + await deployDirector(config, 'candidate-new', { ...harness.operations, + assertRegionalRehomeDisabled: async () => { verified++ } }) + assert.ok(verified >= 2) + assert.equal(harness.state.revisions.get('relay-00003-new').env[DIRECTOR_CORRECTION_COHORT_ENV], '1') +}) diff --git a/cloud/dev/scripts/read-relay-serving-regional-placement-version.mjs b/cloud/dev/scripts/read-relay-serving-regional-placement-version.mjs index 80d40277e5a..796d0e9d91a 100644 --- a/cloud/dev/scripts/read-relay-serving-regional-placement-version.mjs +++ b/cloud/dev/scripts/read-relay-serving-regional-placement-version.mjs @@ -53,7 +53,7 @@ export function readRelayServingRegionalPlacementVersion(input, dependencies = { try { service = run(gcloudArguments('services', input)) } catch (error) { - if (error?.code === 'NOT_FOUND') return { version: input.bootstrap_version } + if (error?.code === 'NOT_FOUND') return { version: input.bootstrap_version, cohort_percent: '0' } throw error } const serving = (service.status?.traffic ?? []).filter( @@ -67,12 +67,22 @@ export function readRelayServingRegionalPlacementVersion(input, dependencies = { throw new Error('Relay director must have exactly one revision serving 100% traffic') } const revision = run(gcloudArguments('revisions', input, serving[0].revisionName)) + const cohortSettings = (revision.spec?.containers ?? []).flatMap((container) => + (container.env ?? []).filter((environment) => + environment.name === 'ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT') + ) + if (cohortSettings.length > 1 || (cohortSettings.length === 1 && + (typeof cohortSettings[0].value !== 'string' || + !/^(?:[0-9]|[1-9][0-9]|100)$/.test(cohortSettings[0].value)))) { + throw new Error('serving region correction cohort is invalid') + } + const cohort_percent = cohortSettings[0]?.value ?? '0' const references = (revision.spec?.containers ?? []).flatMap((container) => (container.env ?? []).filter( (environment) => environment.name === 'ORCA_RELAY_REGIONAL_PLACEMENT_ENABLED' ) ) - if (references.length === 0) return { version: input.bootstrap_version } + if (references.length === 0) return { version: input.bootstrap_version, cohort_percent } const reference = normalizeSecretReference(references[0]) if ( references.length !== 1 || @@ -81,7 +91,7 @@ export function readRelayServingRegionalPlacementVersion(input, dependencies = { ) { throw new Error('serving regional placement secret reference is invalid') } - return { version: reference.version } + return { version: reference.version, cohort_percent } } // Why: the v2 API reports `valueSource.secretKeyRef.{secret,version}`, but diff --git a/cloud/dev/scripts/read-relay-serving-regional-placement-version.test.mjs b/cloud/dev/scripts/read-relay-serving-regional-placement-version.test.mjs index 8043fc23e94..9c49d4127a5 100644 --- a/cloud/dev/scripts/read-relay-serving-regional-placement-version.test.mjs +++ b/cloud/dev/scripts/read-relay-serving-regional-placement-version.test.mjs @@ -67,7 +67,7 @@ test('reads the exact version from the sole traffic-serving revision', () => { } }) - assert.deepEqual(result, { version: '11' }) + assert.deepEqual(result, { version: '11', cohort_percent: '0' }) assert.equal(calls[1][3], 'relay-serving') }) @@ -78,7 +78,7 @@ test('reads the gcloud v1 secret reference shape by bare id and by full resource ]) { assert.deepEqual(readRelayServingRegionalPlacementVersion(input, { run: (args) => args[1] === 'services' ? serving() : v1Revision(name, '1') - }), { version: '1' }) + }), { version: '1', cohort_percent: '0' }) } }) @@ -100,12 +100,12 @@ test('falls back only when the service or setting is absent', () => { notFound.code = 'NOT_FOUND' assert.deepEqual(readRelayServingRegionalPlacementVersion(input, { run: () => { throw notFound } - }), { version: '7' }) + }), { version: '7', cohort_percent: '0' }) assert.deepEqual(readRelayServingRegionalPlacementVersion(input, { run: (args) => args[1] === 'services' ? { status: { traffic: [{ revisionName: 'relay-serving', percent: 100 }] } } : { spec: { containers: [{ env: [] }] } } - }), { version: '7' }) + }), { version: '7', cohort_percent: '0' }) }) test('classifies real absent-service stderr without weakening revision failures', () => { @@ -136,3 +136,31 @@ test('rejects ambiguous traffic, malformed references, and read failures', () => run: () => { throw denied } }), denied) }) + + +test('preserves the serving cohort including explicit disable across later Terraform plans', () => { + for (const value of ['0', '1', '17', '100']) { + const servingRevision = revision() + servingRevision.spec.containers[0].env.push({ name: 'ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT', value }) + assert.deepEqual(readRelayServingRegionalPlacementVersion(input, { + run: (args) => args[1] === 'services' ? serving() : servingRevision + }), { version: '11', cohort_percent: value }) + } +}) + +test('fails closed on malformed, secret-backed or duplicate cohorts rather than resetting them', () => { + const name = 'ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT' + const cases = [ + [{ name, value: '101' }], [{ name, value: '-1' }], [{ name, value: '1.5' }], + [{ name, value: '' }], [{ name, value: '01' }], [{ name, value: 1 }], + [{ name, valueFrom: { secretKeyRef: { name: 'unexpected', key: '1' } } }], + [{ name, value: '1' }, { name, value: '2' }] + ] + for (const settings of cases) { + const servingRevision = revision() + servingRevision.spec.containers[0].env.push(...settings) + assert.throws(() => readRelayServingRegionalPlacementVersion(input, { + run: (args) => args[1] === 'services' ? serving() : servingRevision + }), /cohort is invalid/) + } +}) diff --git a/cloud/docs/orca-relay-operations.md b/cloud/docs/orca-relay-operations.md index cd989b58e94..f27b437fff9 100644 --- a/cloud/docs/orca-relay-operations.md +++ b/cloud/docs/orca-relay-operations.md @@ -491,3 +491,64 @@ Run and record each scenario in staging before launch: - return a dormant host, overload a cell, kill a cell, evacuate active work, and exercise pre-registration rollback. The served black-box relay suite validates the protocol/state transitions used by these procedures. The physical-device and real-GFE canaries remain separate launch gates; unit/black-box success cannot replace them. + +## Optional measured region correction (deployment gated) + +New optimization claims require both the durable regional-rehome control and +`ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT` (integer 0–100, default **0**). +Turning either gate off stops new optional moves; ordinary migration cleanup and +recovery continue. Legacy preferred-region hints do not certify a correction. Both +cells must advertise regional protocol3 and the authenticated desktop control must +advertise idle-regional-rehome-v1. The source must have no actual client sockets or +pending admission/control work; a live control socket alone does not prevent a move. + +The monitor/deploy identity can read **GET `/v1/admin/regional-rehome-preview`**. +It returns full-population eligibility/exclusion counts, open-migration capacity, +process-safety gating and aggregate migration outcomes; it never claims a +host or changes the failure budget. This is advisory, with separately read state: +concurrent assignments, capacity changes, rate pauses and control changes can make +the next claim differ. Inspect the durable control separately before enabling. +Do not treat an unavailable/failed preview as zero eligible hosts. + +`orca_relay_region_correction_outcomes` reports attempts by source/target, +registration/completion/abort state, oldest open age and target +reservation units every five minutes. `orca_relay_region_comparison` samples a +stable 10% of accepted reports (including unchanged hosts), keyed by host digest, +assignment epoch and decision generation. Existing control RTT and client-accept +logs include assignment epoch, control generation and drain mode; join those for +matched before/after and unchanged-cohort comparisons. Client accept latency is +connection setup, not application command round trip. No application-latency +improvement has been demonstrated by probe differences alone. + +Quiet live connections count as work and defer optional correction indefinitely. +A returning client may race with the short admission gate and retry normally. No +optimization timer may close an established client. Investigate failed registration, +ambiguous authority, stuck reservations and reconnect/failure rates against agreed +limits. A database outage can keep the source fenced until locked reconciliation +establishes its authority; timeout alone is not permission to reopen admissions. + +All directors must run the reviewed idle worker before enabling. Record the tested +immutable source and rollback revisions, then verify the ordinary migration recovery +path before rollout. There is no retained-source table or renewal protocol. Deploying +supporting cells/desktops and enabling a cohort require separate rollout authorization +and explicit numerical stop criteria; this change enables neither. + +### Setting the correction cohort during a reviewed director rollout + +The existing **Deploy Relay Production Director** workflow accepts +`region-correction-cohort-percent`: `preserve` (default) or an integer0–100. +It carries the cohort onto both candidate and compatible rollback revisions and +verifies the environment before promotion. If the predecessor has no setting, +`preserve` stamps zero. An explicit change requires the exact disabled durable +rehome generation; configuring a nonzero cohort does not itself enable the sweep. +The usual image, identity, health and traffic checks remain in force. No workflow +was dispatched as part of implementation. + +Terraform reads the cohort from the same traffic-serving revision used to preserve +regional placement. A later apply therefore preserves a workflow-set cohort, +including explicit zero; only an absent service/setting bootstraps to0. Malformed +or ambiguous live settings fail the plan instead of silently resetting the cohort. +The audited director workflow owns subsequent changes. +Before the first nonzero cohort, verify compatible protocol2 cells, updated +cleanup workers, preview eligibility, both serving/rollback images and the +explicitly approved observation/stop criteria. diff --git a/cloud/infra/terraform/relay.tf b/cloud/infra/terraform/relay.tf index 7a5124a00b1..5df7372ff07 100644 --- a/cloud/infra/terraform/relay.tf +++ b/cloud/infra/terraform/relay.tf @@ -169,6 +169,11 @@ resource "google_cloud_run_v2_service" "relay" { } } + env { + name = "ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT" + value = data.external.relay_serving_regional_placement_version.result.cohort_percent + } + ports { container_port = 8080 } diff --git a/cloud/packages/relay-contract/src/control-messages.ts b/cloud/packages/relay-contract/src/control-messages.ts index 0daf21e7c28..ebe9586407e 100644 --- a/cloud/packages/relay-contract/src/control-messages.ts +++ b/cloud/packages/relay-contract/src/control-messages.ts @@ -50,6 +50,7 @@ export const RELAY_HOST_CAPABILITIES_HEADER = 'x-orca-host-capabilities' // The host accepts kind/relayDeviceId on a pendingConns entry. A host that does // not advertise this parses those entries strictly and would drop the whole ack. export const RELAY_HOST_CAPABILITY_PENDING_CONN_DETAILS = 'pending-conn-details' +export const RELAY_HOST_CAPABILITY_IDLE_REGIONAL_REHOME = 'idle-regional-rehome-v1' export function parseRelayHostCapabilities( header: string | string[] | undefined @@ -121,11 +122,22 @@ export const DeviceRevokeSchema = z .object({ reqId: OpaqueIdSchema, relayDeviceId: OpaqueIdSchema }) .strict() -export const AuthRefreshSchema = z.object({ relayJwt: z.string().min(1).max(8 * 1024) }).strict() +export const AuthRefreshSchema = z + .object({ + relayJwt: z + .string() + .min(1) + .max(8 * 1024) + }) + .strict() export const DrainSchema = z .object({ - graceMs: z.number().int().nonnegative().max(60 * 60 * 1000), + graceMs: z + .number() + .int() + .nonnegative() + .max(60 * 60 * 1000), recovery: z.literal('resolve-director') }) .strict() diff --git a/cloud/packages/relay-contract/src/director-messages.ts b/cloud/packages/relay-contract/src/director-messages.ts index e697135b68e..0f57081014a 100644 --- a/cloud/packages/relay-contract/src/director-messages.ts +++ b/cloud/packages/relay-contract/src/director-messages.ts @@ -7,8 +7,15 @@ import { RelayHostIdSchema } from './wire-scalars.js' import { RelayRegionSchema } from './relay-regions.js' +import { + RegionCorrectionRequestSchema, + RegionCorrectionResponseSchema +} from './region-correction.js' -const SignedAssignmentLeaseSchema = z.string().min(1).max(8 * 1024) +const SignedAssignmentLeaseSchema = z + .string() + .min(1) + .max(8 * 1024) export const AssignmentRequestSchema = z .object({ @@ -17,7 +24,8 @@ export const AssignmentRequestSchema = z // Client-declared reconnection; the director verifies it against the // durable assignment before granting fast-lane admission. reconnect: z.boolean().optional(), - preferredRegion: RelayRegionSchema.optional() + preferredRegion: RelayRegionSchema.optional(), + regionCorrection: RegionCorrectionRequestSchema.optional() }) .strict() @@ -26,7 +34,8 @@ export const AssignmentResponseSchema = z v: z.literal(1), cellUrl: CanonicalHttpsOriginSchema, assignmentEpoch: GenerationSchema, - lease: SignedAssignmentLeaseSchema + lease: SignedAssignmentLeaseSchema, + regionCorrection: RegionCorrectionResponseSchema.optional() }) .strict() diff --git a/cloud/packages/relay-contract/src/idle-regional-rehome.ts b/cloud/packages/relay-contract/src/idle-regional-rehome.ts new file mode 100644 index 00000000000..9f9eff36593 --- /dev/null +++ b/cloud/packages/relay-contract/src/idle-regional-rehome.ts @@ -0,0 +1,26 @@ +import { z } from 'zod' +import { GenerationSchema, RelayHostIdSchema } from './wire-scalars.js' + +export const IdleRegionalRehomeRequestSchema = z + .object({ + v: z.literal(1), + attemptId: z.string().uuid(), + userId: z.string().min(1).max(256), + relayHostId: RelayHostIdSchema, + sourceCellId: z.string().min(1).max(128), + sourceCellIncarnation: z.string().uuid(), + sourceAssignmentEpoch: GenerationSchema.refine((value) => value > 0), + sourceGeneration: GenerationSchema.refine((value) => value > 0), + targetCellId: z.string().min(1).max(128) + }) + .strict() + +export const IdleRegionalRehomeResponseSchema = z + .object({ + v: z.literal(1), + outcome: z.enum(['busy', 'committed', 'deferred', 'stale']) + }) + .strict() + +export type IdleRegionalRehomeRequest = z.infer +export type IdleRegionalRehomeOutcome = z.infer['outcome'] diff --git a/cloud/packages/relay-contract/src/index.ts b/cloud/packages/relay-contract/src/index.ts index aab3b53b5f3..3b52ec503a1 100644 --- a/cloud/packages/relay-contract/src/index.ts +++ b/cloud/packages/relay-contract/src/index.ts @@ -13,3 +13,5 @@ export * from './resume-confirmation-contract.js' export * from './relay-regions.js' export * from './splice-state-machine.js' export * from './wire-scalars.js' +export * from './region-correction.js' +export * from './idle-regional-rehome.js' diff --git a/cloud/packages/relay-contract/src/region-correction.test.ts b/cloud/packages/relay-contract/src/region-correction.test.ts new file mode 100644 index 00000000000..8c0122341d4 --- /dev/null +++ b/cloud/packages/relay-contract/src/region-correction.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { AssignmentRequestSchema, AssignmentResponseSchema } from './director-messages.js' +import { DrainSchema } from './control-messages.js' +import { RegionCorrectionRequestSchema } from './region-correction.js' + +const report = { + v: 1, + action: 'report', + generation: 3, + assignmentEpoch: 7, + policyVersion: 1, + outcome: 'conclusive', + measurements: { 'us-central1': 40, 'asia-east2': 180 } +} +const retention = { + mode: 'finish-existing', + attemptId: '11111111-1111-4111-8111-111111111111', + sourceGeneration: 3, + sourceAssignmentEpoch: 7 +} + +describe('region correction wire boundaries', () => { + it('keeps legacy assignment shapes readable without negotiated fields', () => { + expect( + AssignmentRequestSchema.parse({ v: 1, relayHostId: 'abcdefghijklmnop' }) + ).not.toHaveProperty('regionCorrection') + expect( + AssignmentResponseSchema.parse({ + v: 1, + cellUrl: 'https://cell.example', + assignmentEpoch: 1, + lease: 'synthetic-lease' + }) + ).not.toHaveProperty('regionCorrection') + }) + + it('accepts complete comparison evidence and explicit inconclusive reports', () => { + expect(RegionCorrectionRequestSchema.safeParse(report).success).toBe(true) + const { measurements: _measurements, ...basis } = report + expect( + RegionCorrectionRequestSchema.safeParse({ + ...basis, + outcome: 'inconclusive', + reason: 'probe-unavailable' + }).success + ).toBe(true) + }) + + it.each([ + { measurements: { 'us-central1': 40 } }, + { measurements: { 'us-central1': -1, 'asia-east2': 10 } }, + { measurements: { 'us-central1': Infinity, 'asia-east2': 10 } }, + { measurements: { 'us-central1': 120_001, 'asia-east2': 10 } }, + { generation: Number.MAX_SAFE_INTEGER + 1 }, + { assignmentEpoch: 1.2 }, + { policyVersion: 2 }, + { outcome: 'inconclusive', reason: 'timeout' } + ])('rejects ambiguous or unbounded evidence: %j', (override) => { + expect(RegionCorrectionRequestSchema.safeParse({ ...report, ...override }).success).toBe(false) + }) + + it('rejects reporting and issuing a window in the same request', () => { + expect( + RegionCorrectionRequestSchema.safeParse({ + ...report, + action: 'issue-window' + }).success + ).toBe(false) + }) + + it('uses ordinary drain and rejects the superseded retention extension', () => { + const ordinary = { graceMs: 0, recovery: 'resolve-director' } + expect(DrainSchema.parse(ordinary)).toEqual(ordinary) + expect(DrainSchema.safeParse({ ...ordinary, retention }).success).toBe(false) + }) +}) diff --git a/cloud/packages/relay-contract/src/region-correction.ts b/cloud/packages/relay-contract/src/region-correction.ts new file mode 100644 index 00000000000..5fffca0ad8c --- /dev/null +++ b/cloud/packages/relay-contract/src/region-correction.ts @@ -0,0 +1,60 @@ +import { z } from 'zod' +import { EpochMsSchema, GenerationSchema } from './wire-scalars.js' +import { RelayRegionSchema } from './relay-regions.js' + +const RttSchema = z.number().finite().nonnegative().max(120_000) +export const RegionMeasurementsSchema = z + .object({ + 'us-central1': RttSchema, + 'asia-east2': RttSchema + }) + .strict() + +export const RegionMeasurementWindowSchema = z + .object({ + generation: GenerationSchema, + expiresAt: EpochMsSchema, + assignmentEpoch: GenerationSchema, + incumbentRegion: RelayRegionSchema, + policyVersion: z.literal(1) + }) + .strict() + +const ReportBasis = { + v: z.literal(1), + action: z.literal('report'), + generation: GenerationSchema, + assignmentEpoch: GenerationSchema, + policyVersion: z.literal(1) +} + +export const RegionCorrectionRequestSchema = z.union([ + z.object({ v: z.literal(1), action: z.literal('issue-window') }).strict(), + z + .object({ + ...ReportBasis, + outcome: z.literal('conclusive'), + measurements: RegionMeasurementsSchema + }) + .strict(), + z + .object({ + ...ReportBasis, + outcome: z.literal('inconclusive'), + reason: z.string().min(1).max(64) + }) + .strict() +]) + +export const RegionCorrectionResponseSchema = z + .object({ + v: z.literal(1), + window: RegionMeasurementWindowSchema.optional(), + reportStatus: z.enum(['accepted', 'duplicate', 'stale', 'expired', 'basis-changed']).optional() + }) + .strict() + +export type RegionMeasurements = z.infer +export type RegionMeasurementWindow = z.infer +export type RegionCorrectionRequest = z.infer +export type RegionCorrectionResponse = z.infer From 1d7bb47a11d04722753b62a68de668c765b95602 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:42:03 -0400 Subject: [PATCH 017/191] test(relay): harden regional rehome race coverage (#20136) --- cloud/apps/relay/src/assignment-store.ts | 1 + cloud/apps/relay/src/regional-rehome-store.test.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/cloud/apps/relay/src/assignment-store.ts b/cloud/apps/relay/src/assignment-store.ts index 296a09d4e42..680caac715f 100644 --- a/cloud/apps/relay/src/assignment-store.ts +++ b/cloud/apps/relay/src/assignment-store.ts @@ -6601,6 +6601,7 @@ export class RelayAssignmentStore { }) .catch((error: unknown): boolean => { // Expiry is durable; another director settling this row is not a failure. + // Invariant failures remain fatal so operators see corrupt migration state. if (!isDatabaseLockUnavailable(error)) throw error inventoryBusy++ return false diff --git a/cloud/apps/relay/src/regional-rehome-store.test.ts b/cloud/apps/relay/src/regional-rehome-store.test.ts index 4a1a96a3f7a..e4a355699da 100644 --- a/cloud/apps/relay/src/regional-rehome-store.test.ts +++ b/cloud/apps/relay/src/regional-rehome-store.test.ts @@ -1714,7 +1714,7 @@ function hookAfterCandidateScan( const decorate = (delegate: RelayDatabase): RelayDatabase => ({ query: async (sql, params) => { const rows = await delegate.query(sql, params) - if (!fired && sql.includes('FROM relay_region_rehome_control policy')) { + if (!fired && sql.includes('SELECT a.user_id, a.relay_host_id')) { fired = true await hook(delegate) } From 729491597f33031089148bc2fba41a99e0b95de7 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:43:48 -0400 Subject: [PATCH 018/191] feat(desktop): measure relay regions and reconnect after idle cutover (#20106) --- .github/workflows/unit-tests.yml | 7 + config/reliability-gates.jsonc | 72 +++ .../RELAY-REGION-CORRECTION-ACCEPTANCE.md | 114 ++++ .../RELAY-REGION-CORRECTION-API.md | 61 ++ .../RELAY-REGION-CORRECTION-CHECKLIST.md | 47 ++ ...LAY-REGION-CORRECTION-IDLE-CUTOVER-PLAN.md | 213 +++++++ .../RELAY-REGION-CORRECTION-PLAN.md | 177 ++++++ .../RELAY-REGION-CORRECTION-ROLLOUT.md | 60 ++ src/main/global-fetch-call-site-audit.test.ts | 3 +- .../runtime/relay/desktop-relay-service.ts | 9 +- .../relay/relay-control-client-options.ts | 25 + .../relay/relay-control-client.test.ts | 2 +- .../runtime/relay/relay-control-client.ts | 25 +- .../relay/relay-control-origin-options.ts | 24 + .../runtime/relay/relay-control-origin.ts | 37 +- .../runtime/relay/relay-control-protocol.ts | 8 +- .../relay-control-request-retirement.test.ts | 42 ++ .../runtime/relay/relay-control-requests.ts | 6 +- .../runtime/relay/relay-control-rotation.ts | 64 +++ src/main/runtime/relay/relay-http-client.ts | 13 +- .../relay/relay-origin-pool-options.ts | 22 + src/main/runtime/relay/relay-origin-pool.ts | 218 +++----- .../runtime/relay/relay-origin-retirement.ts | 65 +++ .../relay/relay-region-correction-protocol.ts | 43 ++ .../relay/relay-region-correction.test.ts | 132 +++++ .../runtime/relay/relay-region-decision.ts | 47 ++ .../relay/relay-region-preference-reader.ts | 22 + .../relay/relay-region-preference.test.ts | 8 +- .../runtime/relay/relay-region-preference.ts | 31 +- .../relay/relay-region-probe-log.test.ts | 2 +- .../relay/relay-region-refresh.test.ts | 158 ++++++ .../runtime/relay/relay-region-refresh.ts | 172 ++++++ .../relay/relay-session-broker-contract.ts | 8 + .../runtime/relay/relay-session-broker.ts | 31 +- tests/e2e/helpers/relay-execution-process.ts | 125 +++++ .../relay-region-compatibility.unit.test.ts | 120 ++++ .../e2e/relay-region-correction.unit.test.ts | 519 ++++++++++++++++++ tests/tools/relay-bench/find-cell.mjs | 32 ++ 38 files changed, 2536 insertions(+), 228 deletions(-) create mode 100644 docs/relay-region-correction/RELAY-REGION-CORRECTION-ACCEPTANCE.md create mode 100644 docs/relay-region-correction/RELAY-REGION-CORRECTION-API.md create mode 100644 docs/relay-region-correction/RELAY-REGION-CORRECTION-CHECKLIST.md create mode 100644 docs/relay-region-correction/RELAY-REGION-CORRECTION-IDLE-CUTOVER-PLAN.md create mode 100644 docs/relay-region-correction/RELAY-REGION-CORRECTION-PLAN.md create mode 100644 docs/relay-region-correction/RELAY-REGION-CORRECTION-ROLLOUT.md create mode 100644 src/main/runtime/relay/relay-control-client-options.ts create mode 100644 src/main/runtime/relay/relay-control-origin-options.ts create mode 100644 src/main/runtime/relay/relay-control-request-retirement.test.ts create mode 100644 src/main/runtime/relay/relay-control-rotation.ts create mode 100644 src/main/runtime/relay/relay-origin-pool-options.ts create mode 100644 src/main/runtime/relay/relay-origin-retirement.ts create mode 100644 src/main/runtime/relay/relay-region-correction-protocol.ts create mode 100644 src/main/runtime/relay/relay-region-correction.test.ts create mode 100644 src/main/runtime/relay/relay-region-decision.ts create mode 100644 src/main/runtime/relay/relay-region-preference-reader.ts create mode 100644 src/main/runtime/relay/relay-region-refresh.test.ts create mode 100644 src/main/runtime/relay/relay-region-refresh.ts create mode 100644 tests/e2e/helpers/relay-execution-process.ts create mode 100644 tests/e2e/relay-region-compatibility.unit.test.ts create mode 100644 tests/e2e/relay-region-correction.unit.test.ts create mode 100644 tests/tools/relay-bench/find-cell.mjs diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index cdb334c64cd..0c215db94d3 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -37,6 +37,13 @@ jobs: - name: Install Electron package binary for tests run: node config/scripts/install-electron-package-binary.mjs + # The real two-cell transport test imports cloud relay source and its contracts. + - name: Install relay integration dependencies + working-directory: cloud + run: | + npx --yes pnpm@10.24.0 --filter '@orca-cloud/relay...' install --frozen-lockfile --ignore-scripts + npx --yes pnpm@10.24.0 --filter '@orca-cloud/relay^...' build + - name: Test shard run: | pnpm exec vitest run --config config/vitest.config.ts \ diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 4e1f44bd7c1..5b21fa9b0ad 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -2927,6 +2927,78 @@ ], "demotionRule": "Keep experimental or demote if assignment calls overlap, duplicate drain events bypass backoff, Retry-After is ignored, close resurrects work, or mixed-version request rate exceeds the reviewed director budget." }, + { + "id": "desktop-relay.region-correction-idle-cutover", + "title": "Regional correction moves only an idle relay source", + "maturity": "experimental", + "protection": "partial", + "owner": "desktop-runtime", + "layer": "cell-desktop-real-websocket", + "surfaces": ["regional correction", "idle source cutover", "desktop relay reconnect"], + "platforms": ["macos", "linux", "windows"], + "providers": ["cloud-relay"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["cloud-relay"], + "coverageNotes": "Real local WebSocket/control/proof/splice traffic and production SQLite store run with synthetic clock and synthetic token verification. Separate PostgreSQL16 suites validate SQL concurrency. This does not measure production network latency, physical phones, UI, or the production token issuer.", + "motivatingLinks": [ + "docs/relay-region-correction/RELAY-REGION-CORRECTION-IDLE-CUTOVER-PLAN.md" + ], + "invariant": "Regional optimization never closes an established relay client. The source gates admissions only after actual work is idle, commits the exact assignment atomically, and releases its empty control. A failed target uses ordinary migration recovery; previously sent mutations are not replayed.", + "oracle": "Two real TCP WebSocket cells, the actual desktop origin pool, SQLite and an independent execution child verify busy phone/iPad deferral, idle movement, racing arrival rejection, definite-abort admission recovery and observed target-registration failure with ordinary rollback.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm test tests/e2e/relay-region-correction.unit.test.ts" + ], + "testFiles": ["tests/e2e/relay-region-correction.unit.test.ts"], + "assertionRefs": [ + { + "file": "tests/e2e/relay-region-correction.unit.test.ts", + "assertions": [ + "releases the empty source and recovers normally when the target never registers", + "rejects an arrival during cutover and restores admissions after a definite failed commit", + "defers for either connected device, then moves after both disconnect without replaying work" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-11", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test tests/e2e/relay-region-correction.unit.test.ts", + "result": "passed", + "durationSeconds": 6.44, + "summary": "Three real TCP WebSocket cases passed after removing store retention. Log: .tmp/idle-cutover-review/transport-without-retention.log. Does not validate packaged or physical clients." + } + ], + "runtimeBudget": { + "p95Seconds": 180, + "scope": "three local real-WebSocket scenarios with synthetic elapsed time for ordinary recovery" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Local implementation validation; no CI soak history yet." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Registry admission accounting negative control and five conflicting operation-identity regressions fail before their fixes and pass afterward. Locked database reconciliation has separate red/green evidence. Full cross-layer counterfactual remains unverified." + }, + "performanceBudget": { + "required": true, + "evidence": "No retained source lease or new mobile timer. Candidate selection is read-only; busy sources immediately defer. Idle cutover reuses normal desktop reconnect and existing migration recovery." + }, + "promotionCriteria": [ + "Collect 100 consecutive CI passes or 14 days of soak.", + "Complete mixed-version and packaged-client validation.", + "Validate bounded rollout latency and reliability against reviewed numerical limits." + ], + "knownGaps": [ + "Production authentication verifier is mocked.", + "Synthetic elapsed time is not a wall-clock soak.", + "Physical phone lifecycle, packaged mixed versions, SSH execution and production network behavior require separate validation." + ], + "demotionRule": "Keep experimental or demote if optimization closes an established client, a gate reopens on ambiguous authority, a mutation is replayed, cleanup is lost, or eligible idle hosts starve." + }, + { "id": "git-worktree.refresh-event-semantics", "title": "Index-only Git metadata cannot trigger structural worktree refresh fanout", diff --git a/docs/relay-region-correction/RELAY-REGION-CORRECTION-ACCEPTANCE.md b/docs/relay-region-correction/RELAY-REGION-CORRECTION-ACCEPTANCE.md new file mode 100644 index 00000000000..7bb182f3fe1 --- /dev/null +++ b/docs/relay-region-correction/RELAY-REGION-CORRECTION-ACCEPTANCE.md @@ -0,0 +1,114 @@ +# Idle regional correction acceptance + +Updated 2026-09-11. Scope: [idle-cutover plan](RELAY-REGION-CORRECTION-IDLE-CUTOVER-PLAN.md). +Local implementation is based on main `74cc9b50390b481009b34823a35eee01a5b90e40`, +with uncommitted changes atop `08c0802089a9186cf48ce0d168cd87565bd92d65`. + +**Local validation is green; the published PRs are not updated or merge-ready.** +Cloud draft20037 still points to `0462a87011d8783f91f9a9e977ea7a68437a0d5b`; +desktop draft20031 still points to `f9e5b0193448e6c524603836b9278878b96cf51c`. +Their older CI is not evidence for this implementation. No commit, push, merge, +deployment, workflow dispatch or production mutation occurred in this continuation. + +## What the implementation proves + +The source permits optimization only when actual client sockets, splices, pending +connections and in-flight admission/control work are absent. It installs the local +gate before awaiting a constrained assignment transaction. Duplicate requests bind +exact authority; ambiguous database outcomes keep the source fenced until locked +reconciliation establishes the result. After commit, it releases the empty control +and uses ordinary reconnect and migration recovery. Emergency drains retain their +existing behavior. No retained-source protocol or restoration loop remains. + +Three real TCP WebSocket scenarios join authenticated local HTTP dispatch, two +cells, the actual desktop origin pool, SQLite and an independent execution child: + +1. Either connected device prevents movement; after both leave, target reconnect + succeeds and the durable append-once mutation oracle shows no replay. +2. A racing arrival receives4409; a definite failed commit restores source admission. +3. The target control connection is actually attempted and fails; source activity + is released and ordinary expiry recovery allows source epoch3 reconnect. + +These use synthetic time and token verification. They do not prove physical mobile +background scheduling, production authentication/network behavior or user latency. + +## Commands and results + +Every test/app command uses `ORCA_BACKGROUND_LAUNCH=1`. Cloud commands run from +`cloud/apps/relay`; root commands run from this worktree. All logs below are under +`.tmp/idle-cutover-review/`. + +| Scope | Command | Result / log | +| --- | --- | --- | +| Cloud | `ORCA_RELAY_TEST_POSTGRES_URL='postgresql://postgres@127.0.0.1:55440/postgres?options=-csearch_path%3Didle_full_root_20260911' ORCA_IDLE_REHOME_POSTGRES_URL='postgresql://postgres@127.0.0.1:55440/postgres' ORCA_REGION_CORRECTION_POSTGRES=1 pnpm exec vitest run --no-file-parallelism` |77 files /682 passed /zero skips; `cloud-full-postgres-idle.log` | +| Cloud | `pnpm run typecheck` | Passed; `cloud-typecheck-after-preview.log` | +| Cloud | `pnpm build` | Passed; `cloud-release-build-idle.log` | +| Root | `pnpm test src/main/runtime/relay` |20 files /177 passed; `desktop-relay-full-idle.log` | +| Root | `pnpm test tests/e2e/relay-region-correction.unit.test.ts tests/e2e/relay-region-compatibility.unit.test.ts` |16 passed; `transport-contract-cleanup.log` | +| Root | `pnpm test src/main/global-fetch-call-site-audit.test.ts tests/e2e/relay-region-correction.unit.test.ts` |4 passed after CI fixes; `ci-gaps-green.log` | +| Root | `pnpm tc:node` | Passed; `node-final-idle.log` | +| Root | `pnpm exec oxlint src/main/runtime/relay tests/e2e/relay-region-correction.unit.test.ts tests/e2e/relay-region-compatibility.unit.test.ts` | Passed; `desktop-lint-idle.log` | +| Root | `pnpm run check:code-quality:changed` | Passed,0 new findings across30 changed files; `code-quality-changed-idle.log` | +| Root | `pnpm run check:reliability-gates` |121 manifest gates passed; `reliability-idle.log` | +| Root | `ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/ssh-docker-transport-drop-recovery.spec.ts tests/e2e/paired-remote-terminal-serve-restart-binding.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1` |7 passed after fresh build; `ssh-folder-idle.log` | +| Root | `node --test cloud/dev/scripts/deploy-relay-blue-green.test.mjs cloud/dev/scripts/read-relay-serving-regional-placement-version.test.mjs cloud/dev/scripts/relay-regional-rehome-workflow.test.mjs` |47 passed; `deployment-guards-idle.log` | + +PostgreSQL16.15 reused the existing `orca-region-release-pg16` container on55440. +The suite used an isolated schema, dropped afterward (`cloud-full-postgres-cleanup.log`); +new concurrency tests create and clean independent schemas. No other PostgreSQL +port was used. Root oxlint ignores cloud; the configured cloud lint is TypeScript. + +The seven background Electron checks cover paired folder-capable binding across +serve restart and six real Docker SSH recovery journeys: live pane, output bounds, +host-proven exit, repeated restarts, frozen-host silence and resumed input. They do +not exercise a physical phone-to-SSH regional cutover or packaged upgrade. + +## Regression and review evidence + +- Reconciliation: four cases fail against constant not-committed, then pass with + locked authority checks (`reconciliation-{red,green}.log`). +- Registry: disabling pre-await admission accounting makes the arrival race fail; + restoring it passes. Five conflicting operation-ID authority tuples fail before + the identity fix, then all48 registry tests pass (`operation-tuple-{red,green}.log`). +- SQLite startup capability upgrade: red before upgrade logic;8 database tests pass + afterward. Legacy controls default to not idle-capable. +- The commit placeholder negative control was run after implementation; it is + counterfactual evidence, not a claim of chronological test-first development. +- Full PostgreSQL verification supersedes intermediate preview/legacy-test failures. + An agent's earlier PostgreSQL safety-latch discrepancy was disproven in an + isolated schema and explicitly withdrawn. +- Fifth GPT-6-astra low audit: **APPROVE within implementation scope**, no new blocker. + Reviewed source barriers, exact duplicates, locked ambiguity and worker progress. + Reviewer had migrated PostgreSQL tests, but did not author the core implementation. + Ledger: `.tmp/idle-cutover-review/review-ledger.md`. No sixth cycle started. + +## CI preparation and review artifacts + +Old desktop CI failed on a stale global-fetch inventory count and missing `pg` for +the transport test. The count reproduces locally; the downstream catalog/probe +consumers already consume/cancel bodies, so the audited count is corrected. +The unit workflow installs locked cloud relay dependencies and builds their contracts. +From `cloud/`, both commands pass (`ci-relay-dependencies.log`): + +- `npx --yes pnpm@10.24.0 --filter '@orca-cloud/relay...' install --frozen-lockfile --ignore-scripts` +- `npx --yes pnpm@10.24.0 --filter '@orca-cloud/relay^...' build` + +Local split patches and draft bodies are in `.tmp/idle-cutover-review/`: +`cloud-idle.patch`, `desktop-idle.patch`, `cloud-pr-body.md`, `desktop-pr-body.md`. +`prepare-split.py` verifies ordered application against the stated main baseline; +`split-manifest.json` identifies the combined tree. The actual index/branches remain +unchanged. The HTML explainer is `.tmp/relay-region-explainer.html`; four stages, +failure toggle, light/dark mobile/desktop layout and browser-error checks pass. + +## Remaining acceptance gaps + +- Update the two draft PRs and verify fresh CI for their exact heads. The original + handoff explicitly prohibited pushes; publication needs authorization. +- Packaged mixed-version desktop/mobile, physical-device lifecycle and platform + transport remain unverified. Pinned wire tests are narrower evidence. +- CI soak and production RTT/interaction benefit remain unmeasured. Correction + defaults off; deployment/enablement require the reviewed rollout procedure. +- Archive of intermediate/superseded evidence: + `.tmp/idle-cutover-review/acceptance-history-before-final.md` and pre-rescope branch + `relay-region-before-idle-implementation`. Earlier retention results do not prove + the idle design or repair the superseded live-retention transport case. diff --git a/docs/relay-region-correction/RELAY-REGION-CORRECTION-API.md b/docs/relay-region-correction/RELAY-REGION-CORRECTION-API.md new file mode 100644 index 00000000000..f3bd8b682c5 --- /dev/null +++ b/docs/relay-region-correction/RELAY-REGION-CORRECTION-API.md @@ -0,0 +1,61 @@ +# Idle regional correction contracts + +Updated 2026-09-11. The [idle-cutover plan](RELAY-REGION-CORRECTION-IDLE-CUTOVER-PLAN.md) +is authoritative. This replaces the former retention/restoration protocol. + +## Desktop measurement and capability + +The optional assignment `regionCorrection` namespace supports `issue-window` and +`report`. The server issues a generation, fixed expiry, assignment epoch, incumbent +region and policy version1. A report supplies measurements for both regions or an +inconclusive reason. The first accepted report remains immutable; stale or changed +assignment evidence cannot authorize a move. Legacy placement hints remain separate. + +Desktop advertises `idle-regional-rehome-v1` in its control capability header. +There is no new mobile message, retained-control lease, or restoration notification. +The cell persists the exact activity, generation, assignment and incarnation with +`idle_regional_rehome`. The obsolete `finish_existing` database column is always +written0 for schema compatibility and is not an eligibility signal. + +## Director to source cell + +`POST /v1/admin/host-idle-rehome` requires the configured director identity and +matching source cell/incarnation. The strict request contains: + +- `v:1`, stable UUID `attemptId`, `userId`, `relayHostId`; +- `sourceCellId`, `sourceCellIncarnation`, `sourceAssignmentEpoch`, `sourceGeneration`; +- `targetCellId`, authenticated `cohortPercent`, and fresh `directorSafety`. + +The strict response is `{v:1,outcome}` with `busy`, `committed`, `deferred`, or +`stale`. A lost HTTP reply is ambiguous and does not consume a dispatch-failure +budget. Repeat delivery retains the same operation identity. Cell status advertises +regional protocol3; new selection requires both cells at protocol3 or newer. + +## Store and source ownership + +`selectIdleRegionalRehomeCandidates` is read-only. It checks fresh evidence, +capability, policy, cooldown, telemetry and target capacity; bounded rotating pages +allow progress past busy hosts. Selection does not prove physical idleness. + +`HostSessionRegistry.idleRehome` accounts for accepts, attaches, control commands, +and activation before their first await. Only an idle source installs its admission +gate. New arrivals receive normal retryable routing failure. Conflicting reuse of +an operation ID cannot share another authority tuple's result. + +`commitIdleRegionalRehome` rechecks the exact request under existing locks, including +policy, cohort, capacity, global rate and concurrency. It atomically reserves the +target, advances assignment and records the attempt/source generation. It does not +choose a different host or destination. Completion is recorded at the source; +ordinary migration refresh, completion and expiry recovery remain responsible for +the target-registration lifecycle. + +`reconcileIdleRegionalRehome` locks the assignment before reading the operation. +It distinguishes committed, not-committed with unchanged live source authority, +and stale authority. Missing data after an unlocked read is never rollback proof. +A database error leaves admissions fenced while reconciliation retries. Successful +cutover closes/releases the empty source control; the desktop resolves its normal +assignment and reconnects. Definite rollback reopens only the same source authority. + +Outcome reporting aggregates actual attempts and migrations by cell/state, without +host identities or a retained-source table. A completed idle move does not authorize +closing future clients attached to the target. diff --git a/docs/relay-region-correction/RELAY-REGION-CORRECTION-CHECKLIST.md b/docs/relay-region-correction/RELAY-REGION-CORRECTION-CHECKLIST.md new file mode 100644 index 00000000000..fcb2b989c1a --- /dev/null +++ b/docs/relay-region-correction/RELAY-REGION-CORRECTION-CHECKLIST.md @@ -0,0 +1,47 @@ +# Relay region correction — implementation checklist + +> **Scope: idle-only correction.** Superseded retention history is preserved in +> `.tmp/idle-cutover-review/checklist-history-before-final.md` and the backup branch. +> Follow [idle-cutover plan](RELAY-REGION-CORRECTION-IDLE-CUTOVER-PLAN.md). + +## Idle-only implementation tracker + +- [x] Resolve plan review findings and obtain Astra approval (revision 2). +- [x] Preserve pre-rescope revision on `relay-region-before-idle-implementation`. +- [x] Endpoint authentication and incarnation check: deterministic red/green. +- [x] Worker selects before cutover; busy deferral and lost reply tests: red/green. +- [x] Source barrier covers accepts, attaches, commands and control replacement (48 registry tests, including conflicting operation-ID authority tuples; red/green evidence in acceptance). +- [x] Constrained assignment commit and locked ambiguous-outcome reconciliation (11 PostgreSQL 16 tests on 55440, including both replacement orders and a lost commit reply). +- [x] Desktop removes live-retention handling, retains fresh decisions and fallback (53 focused desktop/compatibility tests; node typecheck passes). +- [x] Remove superseded cloud retention protocol/store/cleanup and obsolete tests. The legacy database capability column remains written as0 for existing schema compatibility; it enables no behavior. +- [x] Real two-cell transport: two clients, quiet connection, idle move, arrival race, and observed target-registration failure with ordinary recovery (3 tests pass). +- [x] Focused PostgreSQL transaction/concurrency checks on 55440 (11 passed). +- [x] Full local relevant cloud/desktop suites: 682 cloud tests with PostgreSQL,177 desktop relay tests,16 transport/compatibility tests. +- [x] Local pinned-wire compatibility, Docker SSH/folder continuity (7), types, lint and reliability manifest. Packaged/device/platform gates remain open. +- [x] Fifth Astra low implementation audit: APPROVE within the documented scope. +- [ ] Rewrite and validate cloud/desktop PRs, CI and final acceptance evidence. + +These are merge-readiness tasks. Device/package/platform and production rollout +requirements remain explicit gaps until independently evidenced. + + + +Current transport disposition: **the replacement idle-only transport suite is green +(3 tests), but the PRs are not ready**. Local cloud/desktop verification, final implementation audit, SSH/folder evidence +and reliability docs are complete. PR updates, fresh CI and release evidence remain. +The full cloud suite passes682 tests with PostgreSQL16 and no skips; cloud typecheck passes. Final audit and remaining end-to-end/PR tasks are still open. Exact commands/results are at the top of the acceptance document. The original +live-retention rollback assertion is superseded by the approved product rescope; +these results do not claim that old design was repaired. + +## Publication and release gates + +- [x] Prepare separate cloud/desktop patches and concrete PR descriptions locally. +- [x] Reproduce and address old CI failures: fetch audit count and cloud test dependencies. +- [ ] Obtain authorization to publish under the original no-push handoff constraint. +- [ ] Update cloud draft20037 and desktop draft20031; verify exact-head CI. +- [ ] Packaged mixed-version desktop/mobile and physical-device lifecycle. +- [ ] Linux/Windows transport evidence, CI soak, bounded rollout and measured benefit. + +No production mutation or deployment occurred. Local passing tests and audit approval +are not a claim that the current published PRs are ready or the feature is deployed. +The acceptance document lists commands, evidence scope and remaining gaps. diff --git a/docs/relay-region-correction/RELAY-REGION-CORRECTION-IDLE-CUTOVER-PLAN.md b/docs/relay-region-correction/RELAY-REGION-CORRECTION-IDLE-CUTOVER-PLAN.md new file mode 100644 index 00000000000..0fcf0459636 --- /dev/null +++ b/docs/relay-region-correction/RELAY-REGION-CORRECTION-IDLE-CUTOVER-PLAN.md @@ -0,0 +1,213 @@ +# Relay region correction — idle cutover plan + +Status: **REVISION 2 — DESIGN APPROVED; implementation and verification pending.** + +Independent GPT-6-astra low review found no remaining design blocker. Approval is +conditional on the implementation gates below: pre-await ownership accounting, +shared assignment-row serialization, locked ambiguous-outcome reconciliation, +operation identity deduplication and late-callback fencing must be proven in tests. +Four independent idle-scope audits have been counted conservatively (scope, plan v1, +simplification, revision 2). If another plan revision fails review and a sixth cycle +would be needed, stop for user re-evaluation. Approval does not mean the current +live-retention PRs implement this design or are ready to merge. + +## Requirement and deliberate tradeoffs + +Automatically improve the host's relay region when no relay clients are using it. +A phone and iPad both disconnecting can create an opportunity. Continuous clients +can postpone optimization indefinitely. Returning during a move may incur ordinary +reconnect delay; zero-delay reconnect is NOT a requirement. Do not deliberately +close an established client connection to optimize latency. Direct LAN sessions +and running terminal processes do not themselves count as relay clients. + +The mobile 30-second background grace is not a reliable scheduling event: the OS +can delay it. Use actual source-cell connection state. Desktop control remains +open without phones; do not wait for it to disappear naturally. + +Keep fresh desktop measurements, incumbent-relative 25ms AND 20% improvement, +cohort/enable controls, capacity checks and cooldown. Probe improvement is not +proof of improved application latency. Cloud selects authoritative assignments; +mobile reconnect reads an assignment, it does not choose a faster region. + +## Simplicity constraints + +Reuse the regional worker, assignment transactions, reservations, request identity, +cell admission registry and normal desktop reconnect. No live-source retention, +splice transfer, recurring retained-control lease, new mobile protocol, global idle +poller, or desktop background-disconnect timer. Existing data sockets are never +copied or transferred (the former design did not transfer them either). + +Try an idle opportunity, immediately defer if busy; do not keep a gate waiting for +users to leave. The gate exists only for a short attempted cutover. A retryable +arrival may lose this race and reconnect normally. Do not count that as evidence +of lost execution or replay a previously sent mutation. + +Source cells already share the assignment database. Use one source-owned local +barrier around a constrained assignment transaction; do NOT introduce a distributed +prepare/commit protocol, a durable preparation table, or a second admission service. +The director selects candidates; the source's transaction rechecks director policy. + +## What is idle? + +The source owns one per-host/generation admission barrier covering every accept, +attach and control replacement path. Inventory these paths before changing them. +The quiescence predicate must require: + +- `activeConnIds.size === 0` (includes attaches while persistence awaits). +- `activeSplices.size === 0` and `pendingConns.size === 0`. +- Zero accepts in flight before insertion in those maps. Track ownership before + the first asynchronous operation that can admit this host; every exit releases it. +- Zero in-flight control operations that install credentials, mutate connection + basis or create admissions. Track actual server handlers; do not invent an + approximate count from RPC traffic or old database leases. + +An authenticated open desktop control socket, its ping/pong and activity renewal +are NOT client work and NOT a reason to defer. They can remain until cutover closes +the empty session. A desktop request arriving after the barrier gets a normal +retryable transport failure; finish previously accepted state mutations before +claiming idle. Reconnection must preserve pairing and not blindly replay mutations. + +Missing/expired database activity leases are never proof of idle. Outstanding +reservation cleanup remains owned and must be completed or safely fenced, but +waiting for every lease to expire would wrongly wait on the healthy control lease. + +## Source-owned try-cutover + +1. The director selects candidates read-only, using existing eligibility and pacing. + It sends an authenticated idle-cutover request with stable operation ID, expected + source assignment epoch/incarnation/generation and candidate target. Selection + does not reserve capacity or change assignment. Repeat delivery uses the same ID. +2. Source validates the request and current session. In one synchronous segment, + check all counters and install a per-host barrier. If busy, return `busy` without + closing sockets or holding a barrier. Busy is a deferral, not a dispatch failure. + The next worker pass must progress past busy candidates rather than starve others. +3. While barred, reject new clients and any new source control activation/rebind. + Late asynchronous continuations must recheck barrier/session after awaits and + release abandoned reservations. Install this fence before the first await of + the cutover. No barrier timer may reopen admissions on its own. +4. Source calls a constrained version of the existing assignment transaction. Reuse + its lock order, global rate/concurrency controls, cooldown, capacity reservation, + freshness and safety checks. Recheck exact source epoch/incarnation/generation, + target and operation identity. Reserve target, update assignment/epoch and record + the existing durable migration/attempt atomically. Do not call today's unrestricted + `claimRegionalRehome` and let it choose a different host. No network calls inside + DB locks. Zero sockets is established locally, not inferred from activity leases. +5. If committed, retire the still-empty source session/control via the existing + resolve-director closure path and release its activity. Desktop uses normal + reconnect and registers on target. Reply with the durable operation outcome. +6. If definitively not committed and source authority remains unchanged, remove the + barrier and continue on the original control. No target reservation survives a + rolled-back transaction. If authority changed, retire the obsolete source instead. + A timeout or transport error is NOT definitive rollback. + +## Ambiguous transactions, restarts and failures + +The request operation ID is known BEFORE the transaction and recorded as the +existing attempt ID on commit. Duplicate calls return its outcome and never start +another migration. A concurrent retry, cancellation or definitive-abort check must +serialize under the same host lock as commit; an unlocked absent-row lookup is +insufficient because the original transaction could still commit later. + +Keep a barrier until the database transaction is known terminal and a locked +reconciliation establishes the outcome. If the driver result is ambiguous, retry +status through a per-attempt backoff callback, using the same identity. If durable +access is unavailable, remain fenced; bounded availability cannot be promised +while the assignment's authority is unknown. No timeout-only reopen. Use the +existing DB transaction timeout and request timeout, not a new renewable gate lease. + +A lost director HTTP reply does not interrupt source-owned completion: source +finishes its transaction, reads durable outcome and closes/reopens locally. A +retry from any director sees the same operation. Director crashes do not strand +preparations because no separate preparation exists. + +A source restart/replacement control is fenced by existing authoritative registration +and activity validation. It must serialize against the cutover transaction under +host locks, validate the current assignment and reject old source ownership if the +commit won. If replacement won, the old transaction must fail its generation/ +incarnation recheck. This requires tracing current registration persistence and +proving the shared serialization point, not relying solely on the in-memory fence. +Late callbacks from a closed session cannot reopen admissions for its replacement. +Emergency drain invalidates local authority and participates in this serialization; +a cutover already committed follows its outcome, never reopens the emergency source. + +After commit, target registration failure uses ordinary bounded migration recovery. +The old empty control must be released even if outcome delivery failed; otherwise +current rollback refuses while source activity remains (`assignment-store.ts:6923`). +Source process death is handled by normal activity expiry and cell incarnation +fencing. No live clients were discarded, but a returning client may wait for recovery. +Once clients attach at target, preserve them under ordinary assignment rules: +initial source idleness never authorizes closing future target clients. + +## Compatibility and authority + +Mobile already re-resolves on `WRONG_CELL` (4409) through +`dialRelayThroughDirectorFallback`; use that existing close code for arrivals at a +gated source. Before commit, resolution can still return the old address: existing +backoff must prevent tight retry loops. After commit it returns the target. Pin +old parser/client fixtures and test the actual codes; do not assume every error is +retryable. Do not introduce a new mobile close code or protocol message. + +Empty host control closure uses the existing `DRAINING` / resolve-director path; +verify its reconnect behavior against the baseline desktop implementation. +Negotiate a distinct idle-cutover capability for participating cells and updated +desktops; do not reuse finish-existing capability to imply this new behavior. +Unsupported participants skip optional correction. Preserve old-server HTTP-400 +fallback for measurement fields. Emergency drain/auth enforcement can invalidate +any in-flight cutover; it must fence late commit and preserve existing hard deadlines. +Disabling correction stops new attempts; existing ones still reconcile. + +## Review and implementation gates + +Review must verify the shared-store serialization and identify every admission and +control mutation path before approving implementation. Minimum tests (red before +green for new guarantees): + +1. Phone remains while iPad disconnects: no move. Both disconnect: move possible. + A quiet established socket or expired DB splice lease still prevents a move. +2. Accept before/after barrier, accept awaiting activity persistence, attach awaiting + basis persistence, and credential mutation crossing the barrier. No late attach, + leaked reservation or interrupted established client. +3. Busy attempt leaves source admissions usable; repeated busy hosts do not starve + idle candidates or consume dispatch-failure budget. +4. Commit/replacement race; lost database/HTTP replies; timeout during transaction; + duplicate workers; director crash; source restart; replacement control; stale + epoch/incarnation; database outage and recovery. No timeout-only reopening. +5. Target failure before registration and after new client attachment; source-control + release; ordinary recovery completes without retained-source restoration. +6. Current/old mobile reconnect before and after commit, same-address retry pacing, + pairing preservation and no mutation replay. Old/new desktop/cloud combinations. +7. Emergency drain/auth denial during the cutover; no altered hard-drain behavior. +8. Real TCP WebSockets for two clients, admission race and failed cutover, independent + execution-process identity and append-once mutation evidence. Docker SSH and + folder workspace continuity. Tests use `ORCA_BACKGROUND_LAUNCH=1`. + +Keep tests proportional: deterministic component races first, then real transport, +relevant cloud/desktop suites, types/lint and PR CI. PostgreSQL only on 55440 when +validating authoritative transactions. Do not replace a failing oracle with a weaker +assertion or accumulate tests mirroring implementation. + +After clean review, replace superseded feature code/tests/docs on the two draft PRs, +preserving an immutable backup. Freshness and appropriate compatibility tests stay; +retention-only mechanisms and release requirements must be removed if irrelevant. +Do not claim readiness from tests of the superseded design. + +Release separately from merge readiness: packaged mixed versions, physical device +background timing, Linux/Windows, bounded rollout and measured user benefit remain +explicit evidence requirements. No deployment or enable is authorized by this plan. + +## Implementation notes — 2026-09-11 + +The authenticated director command carries its configured cohort percentage and +fresh process safety snapshot. A cell cannot use its own default-zero director +cohort setting to authorize or reject a selected host; it validates the authenticated +command, combines director/source safety, and rechecks durable policy, the host's +cohort bucket, and fleet/target safety inside the existing transaction. These fields +are on the internal admin endpoint, not the mobile or desktop protocol. + +Candidate selection is read-only and uses a rotating page offset to progress past +busy hosts. A deterministic UUIDv5 derived from the exact source authority and target +keeps operation identity stable across director retries/restarts. Both details still +need final implementation audit and an explicit page-boundary fairness test. + +Current focused and real-transport results are recorded at the top of the acceptance +document. They do not complete the remaining compatibility, cleanup and PR gates. diff --git a/docs/relay-region-correction/RELAY-REGION-CORRECTION-PLAN.md b/docs/relay-region-correction/RELAY-REGION-CORRECTION-PLAN.md new file mode 100644 index 00000000000..283e825c934 --- /dev/null +++ b/docs/relay-region-correction/RELAY-REGION-CORRECTION-PLAN.md @@ -0,0 +1,177 @@ +# Relay automatic region correction — implementation plan v2 + +Status: **IMPLEMENTED AND LOCALLY VALIDATED; RELEASE GATES REMAIN**. Section 5a is normative for first-grant adoption, retained-source rollback, renewal and cleanup. This replaces the idle-only proposal. Historical design reviews are preserved in the combined backup revision; current verification and remaining gaps are recorded in the acceptance document. + +Implementation tracking: [live checklist](RELAY-REGION-CORRECTION-CHECKLIST.md). Completed work and validation evidence are recorded there; this document defines the behavior. + +## Outcome and precise terms + +An updated desktop obtains a reliable region preference. If its assigned region is materially worse, the existing director worker makes the better region the destination for new connections. Existing physical relay data connections continue through the old cell until they close or fail naturally. The desktop then releases the old origin and the durable migration completes. + +“Existing connection” means a physical client-to-desktop relay data connection, represented by a cell splice and desktop connection ownership. It can carry multiple commands and subscriptions; it is not a terminal process, agent run, saved pairing, or recent typing. Tracked pending attachments and basis-bound control requests also protect source retirement. Quiet live connections are retained. No fixed maximum connection duration has been established or proposed for optional optimization. + +For mobile: active use remains on the old cell; after the app actually suspends/closes that connection, the next successful connection resolves to the target. Current mobile schedules background suspension after 30 seconds, and checks an overdue deadline on foreground if the timer did not run. Putting the phone down while the app remains foreground is not a disconnect. No re-pairing is required solely for relocation. + +Promise: optional region optimization does not deliberately terminate pre-existing connections merely because its grace timer expired. This is not a guarantee against network failure, process exit, revoked/expired auth, or emergency maintenance. New connection attempts during target startup may need normal recovery; do not promise zero delay on those attempts. + +## Evidence and reuse + +Source experiments used `origin/main` at `721a2692893ab29f8daee3149965bf5e9adf99a0`. Review must fetch current main and record its SHA, distinguishing changed source from these dated results. + +- Bidirectional worker already exists in #19241 and predates deployed #19915. Do not implement a second placement/migration engine. +- Desktop `relay-origin-pool.ts` already opens a target, retains source connection ownership, and closes the source after final release. Auth refresh already visits every origin. +- Cell `host-session-registry.ts` has drain-only state, live/pending connection maps, and existing control renewal. Director store already retains migration state until source activity releases. +- Two unconditional deadline sites force interruption: desktop origin-pool and cell regional host drain. Removing those scheduling sites in a diagnostic snapshot made both preservation tests pass; restoring source made the identical tests fail again. +- A SQLite store test sustained a registered migration for a simulated hour with both controls renewed, then completed on source release. +- Mobile harness restored credentials/assignment/subscriptions after simulated drain. Sent mutations can become delivery-unknown and are not blindly replayed. The 251ms fake-clock recovery result is not measured real-world downtime. + +Full evidence: [interruption findings](RELAY-INTERRUPTION-FINDINGS.md), [harness and patches](https://github.com/stablyai/orca/blob/0db9fdc486366f7451289f0c0599eed9ae1d94be/tests/tools/relay-rehome-interruption/README.md). The counterfactual patch is NOT production code: it has no negotiation, incorrectly changes normal deadline semantics, and does not validate failure/replay paths. + +## 1. Ordered, expiring region decisions + +Reuse desktop sampling and the existing assignment exchange. Separate legacy placement hints from eligibility to move an existing assignment. + +Preserve cold-start placement: run the existing pre-placement probe and send its placement hint with the first assignment request. Do not create a default-region assignment merely to obtain a measurement window. Obtain the epoch-bound window with or after that first assignment; only a subsequent post-window measurement can certify migration eligibility. Test first placement with and without conclusive probes, plus old-server fallback, so the new migration protocol does not regress initial placement. + +Proposed concrete protocol: an opt-in server-issued measurement window. A supporting desktop requests a window; director returns a per-host monotonic generation, fixed server expiry, and incumbent assignment epoch/region. Issuing a successor invalidates the predecessor for migration. The desktop probes after receiving the window, then reports a conclusive or inconclusive decision for that generation. Repeated delivery cannot extend expiry. An inconclusive outcome is retained as a tombstone; delayed older conclusive reports cannot resurrect eligibility. A restart that cannot reuse a valid cached decision obtains a successor window. Server time is authoritative for expiry. + +Persist window generation/expiry, incumbent basis, supported probe-policy version, and outcome in the existing preference state. Keep the latest supported evidence only; no history of every probe. Duplicate same-generation decisions are idempotent; conflicting same-generation outcomes must not upgrade inconclusive to conclusive. Serialize window issuance/reporting per broker and reject stale assignment basis under claim lock. The exact schema and transaction ordering must be reviewed before implementation. + +Legacy enum-only requests retain placement/reconnect behavior but cannot inherit or overwrite verified migration eligibility. Explicit new inconclusive decisions and missing legacy fields are different operations. Diagnostic region overrides are not measured proof. New request and response fields require explicit opt-in because both schemas are strict. Deploy server support before clients; implement old-server 400 fallback for both request shape and response negotiation without changing a healthy assignment. + +## 2. Compare with the actual assigned region + +Keep the current warm-up, sample count, spread rejection, and requirement that both regions be measurable. Initial placement may pick a best measured region. Moving an existing assignment additionally requires target latency at least **25ms lower AND 20% lower** than the measured incumbent region. These are the existing hysteresis thresholds applied to the correct basis, not a production-validated optimum. + +Report compact comparison evidence (incumbent/target region timing, policy and reason), tied to the window and assignment epoch. Director validates bounds, eligibility and margin. A missing incumbent, incomplete catalog, rejected measurement, nearly tied regions, unsupported policy or stale assignment basis means no move. Clear legacy probe caches on upgrade without treating an unopposed or 1ms winner as migration evidence. + +This optimizes the desktop-to-region path. Sample actual assigned-cell and mobile application behavior during rollout before claiming end-to-end user benefit. + +## 3. Refresh ownership and cadence + +The broker owns one decision deadline and one in-flight refresh/report task. Cancel them on broker close. Reuse the successful 24-hour cache interval and one-hour inconclusive retry interval, with jitter/backoff; eligibility expiry is distinct from retry scheduling. Do not wake offline/sleeping desktops for probes. On resume, check deadlines before using expired eligibility. Debounce network-change refresh only where the existing lifecycle provides a reliable signal. + +Probe/report failure must not fail successful auth renewal or intentionally reconnect healthy controls. Serialize assignment response application with drain/recovery: same cell/epoch updates metadata; a newer assignment follows the existing target activation flow; stale responses are discarded. Report retries reuse the same decision/window rather than re-probing or extending expiry. During an open migration, do not claim another move or replace the retained source; refresh decisions may be stored for later reevaluation only. + +## 4. Extend graceful migration to finish existing connections + +Introduce an explicit opt-in drain mode for optional regional optimization. Persist it on the attempt and propagate it through the director-to-cell host-drain command and cell-to-desktop drain message. The source must learn mode from durable protocol state, not infer it from grace=0, a hostname or a retry count. Maintenance/emergency drains keep their current hard deadlines. + +Eligibility requires supporting desktop and cell capabilities, fresh decision and matching source epoch/incarnation. New protocol/capability values and strict-parser fallbacks need cross-version tests. An unsupported participant defers optional correction rather than falling back to forced close. The reviewer should challenge how host capability remains bound to the currently active source generation, not merely a stale version-bearing report. + +Nominal transition: + +1. Existing claim reserves target capacity and commits target assignment plus migration/attempt state. +2. Source receives the mode-bearing drain and establishes the first valid authorized grant specified in section 5a before acknowledgment/cutover; desktop resolves/registers the target through existing code. +3. Target becomes the desktop's active origin. Existing source connections retain their source ownership; new connections resolve the current assignment. Already-admitted pending source connections may finish attaching and remain there. +4. Optional regional mode installs no forced old-origin/session close deadline. Existing event callbacks retire the source after its last owned connection and pending control operation end. Retain normal auth enforcement and operational emergency-close behavior. +5. Source control release/orphan cleanup removes its remaining activity. Existing completion logic finalizes the migration after source activity is gone and target is live. + +Do not use DB splice lease absence to infer idle: those leases can expire with a live socket. This design instead allows source work to exist. Do not build the proposed pre-move globally atomic idle gate. + +Required integration checks: pending control-RPC completion must trigger retirement when it was the final outstanding item; attach timeout/rejection and late async admission must not leak an origin or attach after retirement. Preserve existing source/target identity and request ownership. Cover two simultaneous mobile clients and a quiet connection; terminal counts and workspace type do not decide transport lifetime. + +## 5. Failure, replay and bounded resource use + +This section is a required implementation contract, not evidence that the current implementation already satisfies it. + +| Condition | Required behavior | +| --- | --- | +| Target registration fails | Retain still-live source work; retry or reconcile using existing migration recovery. Restore source new-admission authority only through section 5a's retained-generation rollback; do not discard a migration that still owns connections. | +| Lost receipt / duplicate drain / zero-grace re-drain | Read durable mode and preserve existing work. Replay must never turn optional mode into forced closure. | +| Desktop restart / source generation replacement | Old process connections may already be gone; reconcile exact generations before retaining or clearing state. Unsupported replacement must not silently hard-drain existing work. | +| Source network/cell failure | Use existing failure recovery; connectivity loss is not proof remote execution exited. This is outside the no-deliberate-interruption promise. | +| Target fails after becoming active | Keep source connections that remain live; reconcile assignment/new connections through existing recovery without starting a third overlapping rehome. | +| Auth expiry/revocation or emergency cell drain | Preserve existing enforcement; optimization does not exempt sessions from security/maintenance lifecycle. | +| Last source data connection closes while control RPC pending | Wait for bounded RPC completion/timeout, then run cleanup without a polling loop. | +| Source remains busy for hours | Keep the migration open while healthy. Long duration alone does not force-close users or spend dispatch-failure budget. | + +Bound **concurrent open migrations** in addition to starts/minute. Count pre-existing attempts; one migration per host remains enforced. Retain both controls' auth/lease renewals and account for source use plus target reservations. Data is not duplicated; extra controls/reservations still consume capacity. + +Ensure fair progress in the existing 100-row lease-refresh and 10-row candidate/sweep pages; waiting old migrations must not starve registration, renewal or cleanup for newer ones. Initially enforce a conservative concurrency bound below the smallest relevant page capacity, counting existing open work, until fair traversal is verified. Filters for cohort/policy/capability belong before LIMIT and are rechecked under locks. + +No user-visible maximum drain duration is claimed. If operations later require one for optimization, forcing closure would change the product promise and needs an explicit decision; a larger timer is not equivalent to finish-existing behavior. + +## 5a. Review corrections: retained-source authority and lifetime + +The independent review found three required contracts. This section supersedes any suggestion above that unchanged auth renewal or generic rollback suffices. Current source-control lifetime is six hours with thirty-minute jitter; auth-refresh does not extend it, and ordinary rotation only renews the active target. Current durable rehome refresh also has a 24-hour age ceiling. The one-hour SQLite experiment proved neither of those paths safe for indefinite live retention. + +### Reuse the cell's existing activity renewal (supersedes the extra wire exchange) + +Follow-up investigation found a smaller mechanism: a successfully validated `renewControlActivity` result can extend the same retained control's in-memory lease to `max(existingExpiry, requestedActivityExpiry)`. The cell already runs this renewal; no new desktop renewal timer, old-source rebind or recurring WebSocket exchange is needed. See [prototype evidence](https://github.com/stablyai/orca/blob/0db9fdc486366f7451289f0c0599eed9ae1d94be/tests/tools/relay-rehome-interruption/RETAINED-CONTROL-LEASE.md). + +Before acknowledging the optional drain and telling desktop to cut over, establish the first short authorized renewal for the exact mode/attempt/source generation/incarnation. Subsequent grants reuse normal heartbeat renewal. Failure or stale state during adoption does not authorize retaining the source; reconcile the provisional target through the rollback contract below. A mode flag, pending database request or activity reacquisition alone is not a grant. + +Narrow the existing atomic database renewal predicate with the retained attempt/mode/source basis, using immutable fields and preserving existing lock order; avoid a new precheck/query that can race mutation. The success callback must still match captured attempt, live session/socket/generation and mode; retirement/rollback/replacement/emergency drain invalidates it. Use the deadline sent at request start (currently 105s ahead), not response time, and do not add six hours on every heartbeat. Keep normal JWT/silence/watchdog enforcement and ordinary control rotation unchanged. Normal mode retains its existing lease; only extra retention needs these short successful grants. + +Prototype evidence: 43 cell-registry tests and package typecheck pass, including >12h simulated retention without extra recurring renewal calls; 6 real Postgres renewal tests execute with zero skips on local 55440. Baseline/revert fails the three extension oracles. The prototype injects the future mode marker and is NOT production-ready: initial adoption ordering, durable mode predicate, wire negotiation and rollback remain integration work. This revision addresses source renewal only, not the whole retention feature. + +### Final renewal-review correction: fence failures as well as success + +Fresh GPT-6-astra / low review: [retained-control review](https://github.com/stablyai/orca/blob/0db9fdc486366f7451289f0c0599eed9ae1d94be/docs/relay-region-correction/RELAY-RETAINED-CONTROL-REVIEW.md), **REVISE one completion-fencing detail; heartbeat reuse supported**. The following correction is incorporated after review, not independently approved or implemented. + +Every renewal completion and awaited recovery continuation must validate its captured socket/session, activity ID, current authority/mode transition and applicable ordering before altering scheduling, extending expiry, closing a socket, or reacquiring activity. In particular, a denial from an aborted retained attempt arriving after same-generation rollback must not close the restored source. A current applicable denial must still enforce closure. An obsolete missing-activity result must not initiate recovery; after awaited recovery, recheck authority and clean up abandoned acquisition as required. Do not use a blanket success-only fence or suppress all failures. + +Add controlled-promise tests for late denial after rollback, after a newer valid authority transition, obsolete missing-activity recovery, and applicable denial. Verify both preserved socket/splice identity and correct rejection, not just expiry values. First-grant adoption must also reject a success whose requested expiry is already past. + +In retained-mode SQL, the ordinary current-assignment authorization alternative must not bypass an aborted attempt check after rollback. If locking attempt rows, preserve assignment -> attempt -> migration -> activity dependency order used by existing rehome operations, rather than appending a late attempt lock. Use the prescribed PostgreSQL 16 environment for implementation concurrency validation on 55440; the earlier six-test PostgreSQL 17 run remains accurately labeled as narrower evidence. + +### Retained-generation rollback + +Generic source reassignment alone is insufficient: the source cell otherwise remains drain-only, and a fresh generation closes old splices. Add a durable, idempotent rollback transition for the exact optional attempt. In one authoritative store transaction, assign a newer source epoch and record that attempt's rollback outcome and retained source generation. Reconcile the source cell to that state: clear only that attempt's optional drain, preserve its socket/splices, update assignment metadata, and restore new admission only after validating current authority. Keep an aborted-attempt tombstone so a late drain cannot reverse rollback. + +Desktop recovery must find/reuse the retained source origin and update its assignment metadata without replacing its control generation or transports. This needs an explicit supported transition, not the current rebind-failed -> fresh-generation fallback. Late target registration cannot override the newer source epoch. Release target reservations when reconciled; keep one open migration per host until cleanup completes. If the source process/generation is gone, use ordinary failure recovery and report that preservation is unavailable; do not infer remote execution exited. + +### Transition table + +| State/event | Authority and action | Existing source work | +| --- | --- | --- | +| Claim optional move | Durable attempt binds mode, source generation/incarnation, target, epoch and supported participants | Retained | +| Target registering | Source receives optional drain; existing target retry/reconciliation proceeds | Retained; do not convert age into forced closure | +| Target registered | Director target assignment is authoritative; desktop activates target | Existing source connections keep their origin | +| Retained source needs renewal | Existing cell activity renewal + exact optional migration authorize a short same-generation lease extension | Retained, source remains drain-only | +| Target failure / rollback | New durable source epoch plus rollback tombstone; source cell and desktop reuse exact retained generation | Retained if that generation still exists | +| Final source work ends | Connection and pending-work callbacks retire source; cancel renewals; release activity and complete | No source work left to preserve | +| Delayed drain/renew/register | Compare durable attempt outcome and epochs; ignore/reject obsolete transition | Must not resurrect draining or replace generation | +| Source failed / emergency drain | Existing authenticated operational/failure semantics apply | Preservation not promised under those failures | + +### Mode-specific durable lifetime and compatibility floor + +For healthy registered optional retained-source attempts, remove the current 24-hour age-only lease-refresh ceiling and exclude them from the age-only zero-grace re-drain lane. Keep bounded target-registration failure/reconciliation; do not extend an unreachable unregistered target forever. Duration alone is not dispatch failure, and active source data must not be dropped to reclaim an optimization slot. Current generic and regional cleanup paths must both understand the optional mode. + +Before enabling optional retention, deploy a director/worker compatibility floor that understands all durable mode and rollback states even when new claims are disabled. Operational rollback must not go below that floor while such attempts exist. A disabled enable flag does not stop older cleanup/redrain code from misinterpreting new rows. Prove safe restart/rollback with existing open attempts and multi-day retention. The minimum revision will be recorded only after the compatible implementation is merged and validated. + +Additional accepted obligations: notify retirement on every final pending-control transition (response, rejection, timeout, close); perform a final local session/generation check after awaited admission work and release abandoned reservations; bind negotiated support to current authenticated source generation rather than a stale measurement report; enforce the concurrent-migration cap in locked shared state, including pre-existing work. None of these requires rebuilding a global idle detector. + +## 6. Rollout and observability + +Keep rehome disabled while implementing/testing. Deploy compatible director/database support, then supporting cells and desktops with feature gated off. Verify readiness and actual capabilities before enabling an authorized bounded cohort. Do not dispatch workflows as part of this review. + +Reuse current rate, cooldown, safety, capacity, durable attempts, and failure-budget controls. Preview must be read-only and share eligibility predicates, report full aggregate counts rather than a capped candidate page, and never claim attempts or consume budget. + +Track eligibility/exclusions by direction, target registration, migration completion/abort, number/age of retained sources, concurrent reservations, deliberate forced-close count by drain mode, and reconnect/error rates. Retain compact sampled comparisons and matched before/after assigned-cell/application latency where available; a registered target alone is not evidence of user benefit. Use an unchanged cohort to detect unrelated network variation. Never log credentials, pairing data, or raw host IDs. + +Acceptance: supported eligible hosts move new connections to their chosen target; old data connections survive optional-drain deadlines and retire on actual release; no forced source close solely due to optimization age; resources clean up; failures remain recoverable; sampled latency/reliability shows benefit without material regression. Specify sample sizes and numerical regression limits before production enable, using available traffic rather than inventing measured thresholds here. + +## 7. Required validation and implementation order + +1. Land freshness/ordering and incumbent-relative eligibility support under disabled control, with strict request/response compatibility tests. Cases: first-ever placement with correction disabled, placement-hint/actual-assignment mismatch, cold-start inconclusive probes and old-server fallback; delayed old reports, duplicates, inconclusive tombstones, clock changes, restarts, legacy writes, overrides, policy upgrades, stale epochs and cache clearing. +2. Implement broker refresh and event-driven origin retirement, test no auth coupling, no reconnect on unchanged assignment, pending-operation completion and sleep/resume. +3. Implement negotiated optional drain mode end-to-end in existing worker/cell/desktop paths, including normal emergency deadlines and replay. Extend the diagnostic oracles into real feature tests; do not merge the timer-removal experiment. +4. Validate real WebSocket traffic across source/target while sending unique stream markers and a mutation with delayed acknowledgment; check no duplicate/replayed mutation and independent host-side execution/output. Then validate mobile background/foreground reconnection and pairing preservation. Mock tests are not an end-to-end substitute. +5. Run actual Postgres integration/concurrency tests on **55440 only**. Require configured database, executed test counts and no conditional skip. Cover registration failure, target failure, concurrent admissions, cleanup, pagination and capacity accounting with long-lived sources. +6. Validate supported/unsupported desktop and cell combinations and old/new director rollback. SSH-hosted execution and folder workspaces remain governed by transport/owning host, not local process assumptions. No visible app tests on the user's desktop; background launch and isolated profiles are required. +7. Run a fresh operational safety gate and capability check only when a reviewed rollout is authorized. Enable a bounded cohort, observe retained-source/resource/benefit evidence, then expand. Disable stops new optional moves while safely reconciling existing ones. + +### Implementation correction: restoration confirmation can retry + +A rollback response can reach the cell while desktop director corroboration fails. +The cell therefore retains an exact pending-restoration authority (aborted attempt, +newer source epoch, original generation/incarnation/activity) until ordinary +same-generation rebind confirms restoration. Each successful existing heartbeat +renews only its short request-start deadline and replays `region-restored`. +Retained and restored authority are mutually exclusive; the old retained authority +remains rejected after rollback. Rebind, replacement, emergency drain, and applicable +denial fence outstanding callbacks. No extra timer or six-hour heartbeat grant is +introduced. This avoids losing long-lived source connections merely because the +first director confirmation failed. diff --git a/docs/relay-region-correction/RELAY-REGION-CORRECTION-ROLLOUT.md b/docs/relay-region-correction/RELAY-REGION-CORRECTION-ROLLOUT.md new file mode 100644 index 00000000000..66cd9b8efd5 --- /dev/null +++ b/docs/relay-region-correction/RELAY-REGION-CORRECTION-ROLLOUT.md @@ -0,0 +1,60 @@ +# Remaining release work and proposed rollout + +Status: implementation and local validation; no deployment authorization used. + +## Ordered release actions + +1. Review the local changes/PR and CI results. Record the immutable merged commit + and image for deployment and rollback. All directors must run the reviewed idle + worker before enabling correction; verify mixed-version deployment behavior + while correction remains disabled. +2. Publish the relay image and deploy the director with correction cohort0 and + durable rehome disabled. Verify image, health, preview route, migration schema, + pool pressure and cleanup. This requires the authorized deployment workflow. +3. Roll supporting cell images using their existing cell workflow; verify protocol3, + correct incarnation and telemetry. Release the updated desktop normally. + The phone protocol is unchanged; no mobile update is required for correction. +4. Validate a packaged desktop with a physical phone (foreground, actual background + suspension, resume, quiet connection, and target failure), and existing clients + against new cells. Run the same transport gate on Linux/Windows in CI; validate + an actual SSH-owned terminal survives client disconnection and normal reconnect. +5. Read the authenticated aggregate preview. Estimate eligible population by + direction; use a matching unchanged comparison cohort. Choose the initial + cohort and record approvals before changing settings or enabling. +6. Configure the approved cohort through the existing director workflow input, + keeping the durable control disabled during deployment. Verify the serving revision and tagged rollback + revision, then enable through the existing regional-rehome control workflow. +7. Stop new claims on a regression while ordinary migration cleanup and recovery continue. + Investigate existing work rather than forcing a timer-based source closure. + +## Proposed numerical acceptance criteria (must be approved before enable) + +These are rollout proposals, not measurements of production baseline or authorization. + +- First phase:1% deterministic host cohort; global cap remains8 open migrations. + Observe at least24h and30 completed moves. If traffic cannot supply30, extend + observation; do not treat a small sample as success. +- Immediate stop: any optimization-induced close of an established client, admission + reopening on ambiguous source authority, duplicated mutation, authorization bypass, or more than8 + optimization migrations admitted (pre-existing work also consumes cap). +- Reliability stop: compared with an unchanged cohort over matching15-minute + windows, assignment/connect failure rate rises by>=1 percentage point or2x + (require>=100 attempts in each comparison group); investigate lower-count failures + individually. Existing production incident limits always take precedence. +- Performance acceptance: matched post-move assigned-cell control RTT improves by + >=25ms AND>=20% median per host for at least80% of evaluable moved hosts; require + two independent samples before and after. Identify samples using + assignment epoch/cell identity. Log sample insufficiency as unevaluable. +- Client connection setup p95 must not regress by>10% versus its matched baseline + after accounting for the unchanged cohort. Setup is not application command + latency; separately record physical-phone interaction timings on validation runs. +- Expansion requires healthy registration/completion, stable reservation usage, no growing stuck-recovery backlog, and numerical criteria + above. Continuously connected clients may postpone optimization indefinitely. + +## Evidence boundaries + +Local tests use real socket traffic and independent host execution, but synthetic +clock/authentication. Neither the build nor schema fixtures prove distribution, +production latency, a physical phone or a signed desktop upgrade. Separate Docker SSH tests validate +SSH-provider recovery; they are not physical mobile-to-SSH cutover evidence. +The checklist leaves these release gates open deliberately. diff --git a/src/main/global-fetch-call-site-audit.test.ts b/src/main/global-fetch-call-site-audit.test.ts index 39bd157fc1d..a16171ea10c 100644 --- a/src/main/global-fetch-call-site-audit.test.ts +++ b/src/main/global-fetch-call-site-audit.test.ts @@ -26,7 +26,8 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map([ ['main/runtime/push/push-gateway-client.ts', 1], ['main/runtime/relay/relay-http-client.ts', 2], ['main/runtime/relay/relay-region-catalog-fetch.ts', 1], - ['main/runtime/relay/relay-region-preference.ts', 2], + // Measurement reuses the audited catalog/probe consumers, which consume or cancel every body. + ['main/runtime/relay/relay-region-preference.ts', 3], ['main/runtime/relay/relay-region-probe.ts', 1], ['main/source-control/hosted-review-api-request.ts', 1], ['main/speech/openai-transcription-client.ts', 1], diff --git a/src/main/runtime/relay/desktop-relay-service.ts b/src/main/runtime/relay/desktop-relay-service.ts index a9b4f98f3b3..74bbd4e7254 100644 --- a/src/main/runtime/relay/desktop-relay-service.ts +++ b/src/main/runtime/relay/desktop-relay-service.ts @@ -19,7 +19,7 @@ import type { import type { DeviceCredentialInstallAuthorization } from './relay-control-requests' import { deriveRelayHostId } from './relay-http-client' import { RelayDemandLedger } from './relay-demand-ledger' -import { createRelayRegionPreferenceReader } from './relay-region-preference' +import { createRelayRegionPreferenceReader } from './relay-region-preference-reader' type DesktopRelayServiceOptions = { authConfig: OrcaCloudAuthConfig @@ -89,6 +89,7 @@ export class DesktopRelayService { isCurrent, refreshAccessToken, resolvePreferredRegion: regionPreference.resolvePreferredRegion, + measureRegionDecision: regionPreference.measureRegionDecision, onAssignedCellActive: regionPreference.noteAssignedCell, onStatus: options.onStatus }) @@ -327,10 +328,8 @@ export class DesktopRelayService { if (expiresAt !== null) { // Why: an unscanned QR must stop holding a standing control when its // server invite expires, even if no renderer survives to report closure. - this.demandExpiryTimer = setTimeout( - () => this.refreshDemand(), - Math.max(1, expiresAt - Date.now() + 1) - ) + const delay = Math.max(1, expiresAt - Date.now() + 1) + this.demandExpiryTimer = setTimeout(() => this.refreshDemand(), delay) } } } diff --git a/src/main/runtime/relay/relay-control-client-options.ts b/src/main/runtime/relay/relay-control-client-options.ts new file mode 100644 index 00000000000..5b98737d11a --- /dev/null +++ b/src/main/runtime/relay/relay-control-client-options.ts @@ -0,0 +1,25 @@ +import type WebSocket from 'ws' +import type { E2EEKeypair } from '../e2ee-keypair' +import type { + RelayConnectionOpenMessage, + RelayDrainMessage, +} from './relay-control-protocol' + +export type RelayControlClientOptions = { + cellUrl: string + relayJwt: string + relayHostId: string + assignmentEpoch: number + identity: { userId: string; profileId: string; organizationId: string } + keypair: E2EEKeypair + appVersion: string + previousGeneration?: number + controlResumeSecret?: string + onConnectionOpen: (message: RelayConnectionOpenMessage) => void + onDrain: (message: RelayDrainMessage) => void + onClose: (code: number) => void + onPendingChanged?: () => void + createSocket?: (url: string, relayJwt: string) => WebSocket + connectDeadlineMs?: number + silenceLimitMs?: number +} diff --git a/src/main/runtime/relay/relay-control-client.test.ts b/src/main/runtime/relay/relay-control-client.test.ts index daa68e0c225..745a79ac84e 100644 --- a/src/main/runtime/relay/relay-control-client.test.ts +++ b/src/main/runtime/relay/relay-control-client.test.ts @@ -221,7 +221,7 @@ describe('RelayControlClient', () => { expect(authorization).toBe('Bearer scoped-token') // Advertised on the upgrade, never in host-hello: a cell that predates the // capability parses host-hello strictly and would refuse the handshake. - expect(capabilities).toBe('pending-conn-details') + expect(capabilities).toBe('pending-conn-details,idle-regional-rehome-v1') expect(path).toBe('/v1/host/control') const hello = await nextJson(socket) expect(hello).toMatchObject({ diff --git a/src/main/runtime/relay/relay-control-client.ts b/src/main/runtime/relay/relay-control-client.ts index 139d63e5640..0c863cce3ad 100644 --- a/src/main/runtime/relay/relay-control-client.ts +++ b/src/main/runtime/relay/relay-control-client.ts @@ -1,8 +1,8 @@ +import type { RelayControlClientOptions } from './relay-control-client-options' import { randomUUID } from 'node:crypto' import WebSocket, { type RawData } from 'ws' import { MOBILE_RELAY_CLOSE_CODE } from '../../../shared/mobile-relay-close-codes' import type { RelayHostCloseReason } from '../../../shared/relay-host-close-reason' -import type { E2EEKeypair } from '../e2ee-keypair' import { RelayConnectionOpenMessageSchema, RelayDrainMessageSchema, @@ -12,8 +12,6 @@ import { RELAY_HOST_CAPABILITY_HEADERS, encodeRelayHostHello, parseRelayControlMessage, - type RelayConnectionOpenMessage, - type RelayDrainMessage, type RelayHostHelloAckMessage, type RelayInviteCreatedMessage } from './relay-control-protocol' @@ -29,24 +27,6 @@ import { controlWebSocketUrl } from './relay-control-url' type RelayControlState = 'idle' | 'opening' | 'proving' | 'active' | 'draining' | 'closed' -type RelayControlClientOptions = { - cellUrl: string - relayJwt: string - relayHostId: string - assignmentEpoch: number - identity: { userId: string; profileId: string; organizationId: string } - keypair: E2EEKeypair - appVersion: string - previousGeneration?: number - controlResumeSecret?: string - onConnectionOpen: (message: RelayConnectionOpenMessage) => void - onDrain: (message: RelayDrainMessage) => void - onClose: (code: number) => void - createSocket?: (url: string, relayJwt: string) => WebSocket - connectDeadlineMs?: number - silenceLimitMs?: number -} - const RELAY_CONTROL_CONNECT_DEADLINE_MS = 15_000 export class RelayControlClient { @@ -54,7 +34,7 @@ export class RelayControlClient { private readonly relayOrigin: string private readonly controlUrl: string private readonly createSocket: NonNullable - private readonly requests = new RelayControlRequests() + private readonly requests: RelayControlRequests private socket: WebSocket | null = null private state: RelayControlState = 'idle' private connectResolve: ((ack: RelayHostHelloAckMessage) => void) | null = null @@ -64,6 +44,7 @@ export class RelayControlClient { constructor(options: RelayControlClientOptions) { this.options = options + this.requests = new RelayControlRequests(options.onPendingChanged) const endpoint = controlWebSocketUrl(options.cellUrl) this.relayOrigin = endpoint.origin this.controlUrl = endpoint.url diff --git a/src/main/runtime/relay/relay-control-origin-options.ts b/src/main/runtime/relay/relay-control-origin-options.ts new file mode 100644 index 00000000000..503f4a7411d --- /dev/null +++ b/src/main/runtime/relay/relay-control-origin-options.ts @@ -0,0 +1,24 @@ +import type WebSocket from 'ws' +import type { E2EEKeypair } from '../e2ee-keypair' +import type { MobileSocketWiring } from '../rpc/mobile-socket-wiring' +import type { RelayIdentity } from './relay-session-broker-contract' +import type { RelayAssignment } from './relay-http-client' +import type { RelayControlOrigin } from './relay-control-origin' +import type { RelayDrainMessage } from './relay-control-protocol' + +export type RelayControlOriginOptions = { + assignment: RelayAssignment + relayJwt: string + relayHostId: string + identity: RelayIdentity + keypair: E2EEKeypair + appVersion: string + mobileSocketWiring: MobileSocketWiring + createControlSocket?: (url: string, relayJwt: string) => WebSocket + createDataSocket?: (url: string) => WebSocket + onConnectionOwned: (connectionId: string, origin: RelayControlOrigin) => void + onConnectionReleased: (connectionId: string, origin: RelayControlOrigin) => void + onDrain: (origin: RelayControlOrigin, message: RelayDrainMessage) => void + onClose: (origin: RelayControlOrigin, code: number) => void + onPendingChanged?: (origin: RelayControlOrigin) => void +} diff --git a/src/main/runtime/relay/relay-control-origin.ts b/src/main/runtime/relay/relay-control-origin.ts index 4145a4b1b6c..7bd34862d9c 100644 --- a/src/main/runtime/relay/relay-control-origin.ts +++ b/src/main/runtime/relay/relay-control-origin.ts @@ -1,39 +1,19 @@ -import type WebSocket from 'ws' -import type { E2EEKeypair } from '../e2ee-keypair' +import type { RelayControlOriginOptions } from './relay-control-origin-options' import { CloudRelayTransport } from '../rpc/relay-transport' -import type { MobileSocketWiring } from '../rpc/mobile-socket-wiring' import { RelayControlClient } from './relay-control-client' import { RELAY_HOST_ATTACH_DEADLINE_MS } from './relay-control-protocol' import type { RelayConnectionOpenMessage, - RelayDrainMessage, RelayHostHelloAckMessage, RelayPendingConnection } from './relay-control-protocol' import type { RelayHostCloseReason } from '../../../shared/relay-host-close-reason' -import type { RelayIdentity } from './relay-session-broker-contract' import type { RelayAssignment } from './relay-http-client' const OBSERVED_OPEN_LIMIT = 16 -type RelayControlOriginOptions = { - assignment: RelayAssignment - relayJwt: string - relayHostId: string - identity: RelayIdentity - keypair: E2EEKeypair - appVersion: string - mobileSocketWiring: MobileSocketWiring - createControlSocket?: (url: string, relayJwt: string) => WebSocket - createDataSocket?: (url: string) => WebSocket - onConnectionOwned: (connectionId: string, origin: RelayControlOrigin) => void - onConnectionReleased: (connectionId: string, origin: RelayControlOrigin) => void - onDrain: (origin: RelayControlOrigin, message: RelayDrainMessage) => void - onClose: (origin: RelayControlOrigin, code: number) => void -} - export class RelayControlOrigin { - readonly assignment: RelayAssignment + assignment: RelayAssignment readonly transport: CloudRelayTransport private readonly options: RelayControlOriginOptions private readonly controls = new Set() @@ -98,6 +78,17 @@ export class RelayControlOrigin { return this.leaseExpiresAt } + get controlGeneration(): number { + return this.generation + } + + updateAssignment(assignment: RelayAssignment): void { + if (assignment.cellUrl !== this.cellUrl || assignment.assignmentEpoch < this.assignmentEpoch) { + throw new Error('relay_assignment_origin_mismatch') + } + this.assignment = assignment + } + get pendingRequestCount(): number { let count = 0 for (const control of this.controls) { @@ -124,6 +115,7 @@ export class RelayControlOrigin { controlResumeSecret: this.controlResumeSecret }) this.activate(control, ack) + this.updateAssignment(assignment) // Why: the resumed control owns the same server generation and splices; // the predecessor remains only long enough for any idempotent reply in flight. if (previous && previous.pendingRequestCount === 0) { @@ -198,6 +190,7 @@ export class RelayControlOrigin { : {}), onConnectionOpen: (message) => this.openConnection(message), onDrain: (message) => this.options.onDrain(this, message), + onPendingChanged: () => this.options.onPendingChanged?.(this), onClose: (code) => { this.controls.delete(control) const timer = this.retiredControlTimers.get(control) diff --git a/src/main/runtime/relay/relay-control-protocol.ts b/src/main/runtime/relay/relay-control-protocol.ts index 5d41498c00f..ba05d976ed7 100644 --- a/src/main/runtime/relay/relay-control-protocol.ts +++ b/src/main/runtime/relay/relay-control-protocol.ts @@ -32,7 +32,7 @@ const ConnectionKindSchema = z.enum(['invite', 'resume']) // control upgrade rather than host-hello because the cell parses host-hello // strictly: a new hello key is refused by every already-deployed cell. export const RELAY_HOST_CAPABILITY_HEADERS = { - 'x-orca-host-capabilities': 'pending-conn-details' + 'x-orca-host-capabilities': 'pending-conn-details,idle-regional-rehome-v1' } as const // Mirrors RELAY_PROTOCOL_LIMITS.hostAttachDeadlineMs in the relay contract: the @@ -76,11 +76,7 @@ export const RelayConnectionOpenMessageSchema = z export const RelayDrainMessageSchema = z .object({ type: z.literal('drain'), - graceMs: z - .number() - .int() - .nonnegative() - .max(60 * 60 * 1000), + graceMs: z.number().int().nonnegative().max(60 * 60 * 1000), recovery: z.literal('resolve-director') }) .strict() diff --git a/src/main/runtime/relay/relay-control-request-retirement.test.ts b/src/main/runtime/relay/relay-control-request-retirement.test.ts new file mode 100644 index 00000000000..f2aab8dae93 --- /dev/null +++ b/src/main/runtime/relay/relay-control-request-retirement.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RelayControlRequests } from './relay-control-requests' + +afterEach(() => vi.useRealTimers()) +describe('final source request retirement notification', () => { + it.each(['reply', 'denial', 'timeout', 'send-failed', 'closed'] as const)( + 'notifies final work completion after %s', + async (outcome) => { + vi.useFakeTimers() + const changed = vi.fn() + const requests = new RelayControlRequests(changed) + const result = requests + .confirmResume('req', 'basis', () => { + if (outcome === 'send-failed') { + throw new Error('send-failed') + } + }) + .catch((error: Error) => error.message) + if (outcome === 'reply') { + requests.resolveMessage({ + type: 'device-resume-confirmed', + v: 1, + reqId: 'req', + currentVersion: 1, + acceptedAs: 'current', + renewed: true, + resumeExpiresAt: 123_000 + }) + } else if (outcome === 'denial') { + requests.resolveMessage({ type: 'control-error', reqId: 'req', code: 'denied' }) + } else if (outcome === 'timeout') { + await vi.advanceTimersByTimeAsync(10_000) + } else if (outcome === 'closed') { + requests.rejectAll(new Error('closed')) + } + await result + await vi.advanceTimersByTimeAsync(0) + expect(requests.size).toBe(0) + expect(changed).toHaveBeenCalledOnce() + } + ) +}) diff --git a/src/main/runtime/relay/relay-control-requests.ts b/src/main/runtime/relay/relay-control-requests.ts index 2a96b94e3ec..bbceb067a59 100644 --- a/src/main/runtime/relay/relay-control-requests.ts +++ b/src/main/runtime/relay/relay-control-requests.ts @@ -25,6 +25,8 @@ export type DeviceCredentialInstallAuthorization = export class RelayControlRequests { private readonly pending = new Map() + constructor(private readonly onPendingChanged?: () => void) {} + get size(): number { return this.pending.size } @@ -162,7 +164,7 @@ export class RelayControlRequests { } return new Promise((resolve, reject) => { const timer = setTimeout(() => { - this.pending.delete(reqId) + this.finish(reqId) reject(new Error('relay_control_request_timeout')) }, 10_000) this.pending.set(reqId, { kind, resolve, reject, timer }) @@ -180,6 +182,8 @@ export class RelayControlRequests { if (pending) { clearTimeout(pending.timer) this.pending.delete(reqId) + // Settle the request before its final waiter retires the owning origin. + queueMicrotask(() => this.onPendingChanged?.()) } } } diff --git a/src/main/runtime/relay/relay-control-rotation.ts b/src/main/runtime/relay/relay-control-rotation.ts new file mode 100644 index 00000000000..2f1de94e6c0 --- /dev/null +++ b/src/main/runtime/relay/relay-control-rotation.ts @@ -0,0 +1,64 @@ +import type { RelayControlOrigin } from './relay-control-origin' +import type { RelayAssignment } from './relay-http-client' +import { relayRenewalDelayMs } from './relay-renewal-jitter' + +type RotationOptions = { + current: () => RelayControlOrigin | null + available: () => boolean + token: () => string | null + assignment: () => RelayAssignment | null + busy: () => boolean + now?: () => number + random?: () => number +} +export class RelayControlRotation { + private timer: ReturnType | null = null + constructor(private readonly options: RotationOptions) {} + cancel(): void { + if (this.timer) { + clearTimeout(this.timer) + } + this.timer = null + } + schedule(): void { + this.cancel() + const origin = this.options.current() + if (!origin || !this.options.available()) { + return + } + const delay = relayRenewalDelayMs( + origin.controlLeaseExpiresAt, + (this.options.now ?? Date.now)(), + this.options.random ?? Math.random + ) + this.timer = setTimeout(() => void this.rebind(origin), delay) + } + private async rebind(origin: RelayControlOrigin): Promise { + this.timer = null + if (!this.options.available() || origin !== this.options.current()) { + return + } + if (this.options.busy()) { + this.timer = setTimeout(() => void this.rebind(origin), 5_000) + return + } + const token = this.options.token() + const assignment = this.options.assignment() + if (!token || !assignment) { + return + } + try { + await origin.rebind(token, assignment) + if (this.options.available() && origin === this.options.current()) { + this.schedule() + } + } catch { + if (this.options.available() && origin === this.options.current()) { + this.timer = setTimeout( + () => void this.rebind(origin), + 5_000 + Math.floor((this.options.random ?? Math.random)() * 10_001) + ) + } + } + } +} diff --git a/src/main/runtime/relay/relay-http-client.ts b/src/main/runtime/relay/relay-http-client.ts index b31fe2ff9da..cb0fc519f9d 100644 --- a/src/main/runtime/relay/relay-http-client.ts +++ b/src/main/runtime/relay/relay-http-client.ts @@ -11,6 +11,10 @@ import { type RelayAssignRateGate } from './relay-assign-rate-gate' import type { RelayRegion } from './relay-region-preference' +import { + RelayRegionCorrectionResponseSchema, + type RelayRegionCorrectionRequest +} from './relay-region-correction-protocol' const RELAY_HTTP_REQUEST_DEADLINE_MS = 15_000 const RELAY_RETRY_AFTER_MAX_MS = 5 * 60_000 @@ -33,7 +37,9 @@ const AssignmentResponseSchema = z lease: z .string() .min(1) - .max(8 * 1024) + .max(8 * 1024), + // Optional correction must not make a healthy assignment depend on a future policy. + regionCorrection: RelayRegionCorrectionResponseSchema.optional().catch(undefined) }) .strict() @@ -133,6 +139,7 @@ type RelayAssignmentRequest = { relayHostId: string reconnect?: boolean preferredRegion?: RelayRegion + regionCorrection?: RelayRegionCorrectionRequest fetch?: typeof globalThis.fetch requestDeadlineMs?: number // Fencing for the throttle wait: a superseded caller aborts instead of assigning. @@ -185,6 +192,7 @@ async function sendRelayAssignment( body: JSON.stringify({ v: 1, relayHostId: input.relayHostId, + ...(input.regionCorrection ? { regionCorrection: input.regionCorrection } : {}), ...(input.preferredRegion ? { preferredRegion: input.preferredRegion } : {}), // Declares likely reconnection so the director can verify and admit // through its bounded fast lane instead of the placement queue. @@ -197,6 +205,9 @@ async function sendRelayAssignment( gate.noteRetryAfter(rateKey, retryAfterMs) } await cancelUnreadResponseBody(response) + if (input.regionCorrection && response.status === 400) { + return await sendRelayAssignment({ ...input, regionCorrection: undefined }, gate, rateKey) + } if (input.preferredRegion && response.status === 400) { // A rolled-back director rejects the regional hint; preserve the // reconnect lane while retrying without only that field. diff --git a/src/main/runtime/relay/relay-origin-pool-options.ts b/src/main/runtime/relay/relay-origin-pool-options.ts new file mode 100644 index 00000000000..ffb5455d1f4 --- /dev/null +++ b/src/main/runtime/relay/relay-origin-pool-options.ts @@ -0,0 +1,22 @@ +import type WebSocket from 'ws' +import type { E2EEKeypair } from '../e2ee-keypair' +import type { MobileSocketWiring } from '../rpc/mobile-socket-wiring' +import type { RelayBrokerStatus, RelayIdentity } from './relay-session-broker-contract' +import type { RelayRegion } from './relay-region-preference' + +export type RelayOriginPoolOptions = { + directorUrl: string + relayHostId: string + identity: RelayIdentity + keypair: E2EEKeypair + appVersion: string + mobileSocketWiring: MobileSocketWiring + isCurrent: () => boolean + onStatus: (status: RelayBrokerStatus) => void + resolvePreferredRegion?: () => Promise + fetch?: typeof globalThis.fetch + createControlSocket?: (url: string, relayJwt: string) => WebSocket + createDataSocket?: (url: string) => WebSocket + random?: () => number + now?: () => number +} diff --git a/src/main/runtime/relay/relay-origin-pool.ts b/src/main/runtime/relay/relay-origin-pool.ts index e8fd1d7d82a..15abcc4127c 100644 --- a/src/main/runtime/relay/relay-origin-pool.ts +++ b/src/main/runtime/relay/relay-origin-pool.ts @@ -1,50 +1,42 @@ -import type WebSocket from 'ws' -import type { E2EEKeypair } from '../e2ee-keypair' -import type { MobileSocketWiring } from '../rpc/mobile-socket-wiring' +import { RelayOriginRetirement } from './relay-origin-retirement' +import type { RelayOriginPoolOptions } from './relay-origin-pool-options' import { RelayControlOrigin } from './relay-control-origin' import type { RelayControlClient } from './relay-control-client' import type { RelayDrainMessage } from './relay-control-protocol' import type { RelayHostCloseReason } from '../../../shared/relay-host-close-reason' import { RelayDrainRetrySchedule } from './relay-drain-retry-schedule' import { RelayHttpError, requestRelayAssignment, type RelayAssignment } from './relay-http-client' -import { relayRenewalDelayMs } from './relay-renewal-jitter' -import type { RelayBrokerStatus, RelayIdentity } from './relay-session-broker-contract' -import type { RelayRegion } from './relay-region-preference' - -type RelayOriginPoolOptions = { - directorUrl: string - relayHostId: string - identity: RelayIdentity - keypair: E2EEKeypair - appVersion: string - mobileSocketWiring: MobileSocketWiring - isCurrent: () => boolean - onStatus: (status: RelayBrokerStatus) => void - resolvePreferredRegion?: () => Promise - fetch?: typeof globalThis.fetch - createControlSocket?: (url: string, relayJwt: string) => WebSocket - createDataSocket?: (url: string) => WebSocket - random?: () => number - now?: () => number -} +import { RelayControlRotation } from './relay-control-rotation' export class RelayOriginPool { - private readonly options: RelayOriginPoolOptions private activeOrigin: RelayControlOrigin | null = null private readonly origins = new Set() - private readonly drainingOrigins = new Set() - private readonly basisOrigins = new Map() - private readonly drainTimers = new Map>() + private readonly retirement = new RelayOriginRetirement( + () => this.activeOrigin, + (origin) => { + this.origins.delete(origin) + } + ) + private readonly drainingOrigins = this.retirement.draining + private readonly basisOrigins = this.retirement.basis private assignment: RelayAssignment | null = null + private deferredAssignment: RelayAssignment | null = null private relayJwt: string | null = null - private rotationTimer: ReturnType | null = null + private readonly rotation: RelayControlRotation private rotationPromise: Promise | null = null private readonly drainRetry: RelayDrainRetrySchedule private closed = false - constructor(options: RelayOriginPoolOptions) { - this.options = options + constructor(private readonly options: RelayOriginPoolOptions) { this.drainRetry = new RelayDrainRetrySchedule(options.random) + this.rotation = new RelayControlRotation({ + ...options, + current: () => this.activeOrigin, + available: () => this.isCurrent(), + token: () => this.relayJwt, + assignment: () => this.assignment, + busy: () => Boolean(this.rotationPromise) + }) } get activeAssignment(): RelayAssignment | null { @@ -63,6 +55,28 @@ export class RelayOriginPool { return this.activeOrigin?.hasLiveControl() ?? false } + applyAssignmentMetadata(assignment: RelayAssignment): boolean { + const current = this.assignment + if (!this.isCurrent() || !current || assignment.assignmentEpoch < current.assignmentEpoch) { + return false + } + if (assignment.assignmentEpoch > current.assignmentEpoch || this.rotationPromise) { + if ( + !this.deferredAssignment || + assignment.assignmentEpoch >= this.deferredAssignment.assignmentEpoch + ) { + this.deferredAssignment = assignment + } + return true + } + if (assignment.cellUrl !== current.cellUrl) { + return false + } + this.assignment = assignment + this.activeOrigin?.updateAssignment(assignment) + return true + } + async openInitial(assignment: RelayAssignment, relayJwt: string): Promise { this.assignment = assignment this.relayJwt = relayJwt @@ -71,7 +85,7 @@ export class RelayOriginPool { await origin.open() this.assertCurrent() this.activeOrigin = origin - this.scheduleControlRotation() + this.rotation.schedule() } refreshAuthorization(relayJwt: string): void { @@ -86,35 +100,21 @@ export class RelayOriginPool { return } this.closed = true - if (this.rotationTimer) { - clearTimeout(this.rotationTimer) - this.rotationTimer = null - } - this.drainRetry.cancel() - for (const timer of this.drainTimers.values()) { - clearTimeout(timer) - } - this.drainTimers.clear() + this.rotation.cancel() + this.drainRetry.reset() + this.retirement.clear() for (const origin of this.origins) { origin.closeNow(hostCloseReason) } this.origins.clear() - this.drainingOrigins.clear() - this.basisOrigins.clear() this.activeOrigin = null } private createOrigin(assignment: RelayAssignment, relayJwt: string): RelayControlOrigin { return new RelayControlOrigin({ + ...this.options, assignment, relayJwt, - relayHostId: this.options.relayHostId, - identity: this.options.identity, - keypair: this.options.keypair, - appVersion: this.options.appVersion, - mobileSocketWiring: this.options.mobileSocketWiring, - createControlSocket: this.options.createControlSocket, - createDataSocket: this.options.createDataSocket, onConnectionOwned: (connectionId, origin) => { if (this.isCurrent() && this.origins.has(origin)) { this.basisOrigins.set(connectionId, origin) @@ -124,9 +124,10 @@ export class RelayOriginPool { if (this.basisOrigins.get(connectionId) === origin) { this.basisOrigins.delete(connectionId) } - this.maybeCloseDrainedOrigin(origin) + this.retirement.maybeClose(origin) }, onDrain: (origin, message) => this.handleDrain(origin, message), + onPendingChanged: (origin) => this.retirement.maybeClose(origin), onClose: (origin) => { if (origin === this.activeOrigin && this.isCurrent()) { this.options.onStatus('offline') @@ -141,10 +142,12 @@ export class RelayOriginPool { } private handleDrain(origin: RelayControlOrigin, message: RelayDrainMessage): void { - if (!this.isCurrent() || origin !== this.activeOrigin) { + if (!this.isCurrent() || !this.origins.has(origin)) { + return + } + if (!this.retirement.adopt(origin, message)) { return } - this.drainingOrigins.add(origin) this.options.onStatus('draining') if (!this.rotationPromise && !this.drainRetry.pending) { this.rotationPromise = this.resolveDrainTarget(origin, message).finally(() => { @@ -164,7 +167,7 @@ export class RelayOriginPool { const preferredRegion = await this.options.resolvePreferredRegion?.().catch(() => undefined) this.assertCurrent() // Why: only the configured director can choose a migration target. - const assignment = await requestRelayAssignment({ + let assignment = await requestRelayAssignment({ directorUrl: this.options.directorUrl, relayToken: this.relayJwt, relayHostId: this.options.relayHostId, @@ -176,15 +179,27 @@ export class RelayOriginPool { fetch: this.options.fetch }) this.assertCurrent() + if ( + this.deferredAssignment && + this.deferredAssignment.assignmentEpoch > assignment.assignmentEpoch + ) { + assignment = this.deferredAssignment + } + this.deferredAssignment = null if (assignment.cellUrl === origin.cellUrl) { - let rebound = false + let rebound = false try { await origin.rebind(this.relayJwt, assignment) rebound = true } catch { // Why: a restarted cell cannot know the prior process's resume secret; // after rebind fails, a fresh generation is the only recoverable path. - await this.activateTarget(origin, assignment, this.relayJwt, message.graceMs) + await this.activateTarget( + origin, + assignment, + this.relayJwt, + message.graceMs, + ) } if (rebound) { this.assertCurrent() @@ -193,11 +208,16 @@ export class RelayOriginPool { this.drainingOrigins.delete(origin) } } else { - await this.activateTarget(origin, assignment, this.relayJwt, message.graceMs) + await this.activateTarget( + origin, + assignment, + this.relayJwt, + message.graceMs, + ) } this.options.onStatus('registered') this.drainRetry.reset() - this.scheduleControlRotation() + this.rotation.schedule() } catch (error) { if (this.isCurrent() && origin === this.activeOrigin) { // Why: this retry loop ran silently during the 2026-08 incident while @@ -230,89 +250,9 @@ export class RelayOriginPool { } this.activeOrigin = target this.assignment = assignment - this.scheduleDrainDeadline(origin, graceMs) - this.maybeCloseDrainedOrigin(origin) + this.retirement.schedule(origin, graceMs) + this.retirement.maybeClose(origin) } - - private scheduleControlRotation(): void { - if (this.rotationTimer) { - clearTimeout(this.rotationTimer) - } - const origin = this.activeOrigin - if (!origin || this.closed) { - this.rotationTimer = null - return - } - const now = (this.options.now ?? Date.now)() - const random = this.options.random ?? Math.random - const delay = relayRenewalDelayMs(origin.controlLeaseExpiresAt, now, random) - this.rotationTimer = setTimeout(() => void this.rebindActiveControl(origin), delay) - } - - private async rebindActiveControl(origin: RelayControlOrigin): Promise { - this.rotationTimer = null - if (!this.isCurrent() || origin !== this.activeOrigin || this.rotationPromise) { - return - } - if (!this.relayJwt || !this.assignment) { - return - } - try { - await origin.rebind(this.relayJwt, this.assignment) - this.assertCurrent() - this.scheduleControlRotation() - } catch { - if (this.isCurrent() && origin === this.activeOrigin) { - const random = this.options.random ?? Math.random - this.rotationTimer = setTimeout( - () => void this.rebindActiveControl(origin), - 5_000 + Math.floor(random() * 10_001) - ) - } - } - } - - private scheduleDrainDeadline(origin: RelayControlOrigin, graceMs: number): void { - const existing = this.drainTimers.get(origin) - if (existing) { - clearTimeout(existing) - } - this.drainTimers.set( - origin, - setTimeout(() => this.closeOrigin(origin), graceMs) - ) - } - - private maybeCloseDrainedOrigin(origin: RelayControlOrigin): void { - if ( - !this.drainingOrigins.has(origin) || - origin.pendingRequestCount > 0 || - [...this.basisOrigins.values()].includes(origin) - ) { - return - } - this.closeOrigin(origin) - } - - private closeOrigin(origin: RelayControlOrigin): void { - if (origin === this.activeOrigin) { - return - } - const timer = this.drainTimers.get(origin) - if (timer) { - clearTimeout(timer) - this.drainTimers.delete(origin) - } - for (const [connectionId, owner] of this.basisOrigins) { - if (owner === origin) { - this.basisOrigins.delete(connectionId) - } - } - this.drainingOrigins.delete(origin) - this.origins.delete(origin) - origin.closeNow() - } - private assertCurrent(): void { if (!this.isCurrent()) { throw new Error('stale_relay_origin_pool') diff --git a/src/main/runtime/relay/relay-origin-retirement.ts b/src/main/runtime/relay/relay-origin-retirement.ts new file mode 100644 index 00000000000..bcc2a439ef2 --- /dev/null +++ b/src/main/runtime/relay/relay-origin-retirement.ts @@ -0,0 +1,65 @@ +import type { RelayDrainMessage } from './relay-control-protocol' +import type { RelayControlOrigin } from './relay-control-origin' + +export class RelayOriginRetirement { + readonly draining = new Set() + readonly basis = new Map() + private readonly timers = new Map>() + constructor( + private readonly current: () => RelayControlOrigin | null, + private readonly remove: (origin: RelayControlOrigin) => void + ) {} + adopt(origin: RelayControlOrigin, _message: RelayDrainMessage): boolean { + if (origin !== this.current()) { + return false + } + this.draining.add(origin) + return true + } + schedule(origin: RelayControlOrigin, graceMs: number): void { + const timer = this.timers.get(origin) + if (timer) { + clearTimeout(timer) + } + this.timers.set( + origin, + setTimeout(() => this.close(origin), graceMs) + ) + } + maybeClose(origin: RelayControlOrigin): void { + if ( + !this.draining.has(origin) || + origin.pendingRequestCount > 0 || + [...this.basis.values()].includes(origin) + ) { + return + } + this.close(origin) + } + clear(): void { + for (const timer of this.timers.values()) { + clearTimeout(timer) + } + this.timers.clear() + this.draining.clear() + this.basis.clear() + } + private close(origin: RelayControlOrigin): void { + if (origin === this.current()) { + return + } + const timer = this.timers.get(origin) + if (timer) { + clearTimeout(timer) + this.timers.delete(origin) + } + for (const [id, owner] of this.basis) { + if (owner === origin) { + this.basis.delete(id) + } + } + this.draining.delete(origin) + this.remove(origin) + origin.closeNow() + } +} diff --git a/src/main/runtime/relay/relay-region-correction-protocol.ts b/src/main/runtime/relay/relay-region-correction-protocol.ts new file mode 100644 index 00000000000..59762de33c1 --- /dev/null +++ b/src/main/runtime/relay/relay-region-correction-protocol.ts @@ -0,0 +1,43 @@ +import { z } from 'zod' +import { RelayRegionSchema } from './relay-region-probe' + +const Counter = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) +export const RelayRegionWindowSchema = z + .object({ + generation: Counter, + expiresAt: Counter, + assignmentEpoch: Counter, + incumbentRegion: RelayRegionSchema, + policyVersion: z.literal(1) + }) + .strict() + +export const RelayRegionCorrectionResponseSchema = z + .object({ + v: z.literal(1), + window: RelayRegionWindowSchema.optional(), + reportStatus: z.enum(['accepted', 'duplicate', 'stale', 'expired', 'basis-changed']).optional() + }) + .strict() + +export type RelayRegionWindow = z.infer +export type RelayRegionDecision = + | { outcome: 'conclusive'; measurements: Record, number> } + | { + outcome: 'inconclusive' + reason: + | 'diagnostic-override' + | 'catalog-unavailable' + | 'incomplete-measurement' + | 'insufficient-improvement' + | 'expired-window' + } +export type RelayRegionCorrectionRequest = + | { v: 1; action: 'issue-window' } + | ({ + v: 1 + action: 'report' + generation: number + assignmentEpoch: number + policyVersion: 1 + } & RelayRegionDecision) diff --git a/src/main/runtime/relay/relay-region-correction.test.ts b/src/main/runtime/relay/relay-region-correction.test.ts new file mode 100644 index 00000000000..1a72d3136c9 --- /dev/null +++ b/src/main/runtime/relay/relay-region-correction.test.ts @@ -0,0 +1,132 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RelayRegionPreferenceResolver } from './relay-region-preference' +import { RelayAssignRateGate } from './relay-assign-rate-gate' +import { requestRelayAssignment } from './relay-http-client' +import type { RelayRegionWindow } from './relay-region-correction-protocol' + +const paths: string[] = [] +const US = 'https://us.director.example.test' +const ASIA = 'https://asia.director.example.test' +const DIRECTOR = 'https://director.example.test' +const window: RelayRegionWindow = { + generation: 1, + assignmentEpoch: 5, + incumbentRegion: 'asia-east2', + expiresAt: 1_000_000, + policyVersion: 1 +} +afterEach(() => { + for (const path of paths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } +}) +function resolver(us: number | null, asia: number | null, override?: string) { + const path = mkdtempSync(join(tmpdir(), 'relay-decision-')) + paths.push(path) + const probe = vi.fn(async (origin: string) => (origin === US ? us : asia)) + const fetch = vi.fn(async () => + Response.json({ + v: 1, + regions: [ + { region: 'us-central1', probeOrigins: [US] }, + { region: 'asia-east2', probeOrigins: [ASIA] } + ] + }) + ) + return { + path, + probe, + instance: new RelayRegionPreferenceResolver({ + directorUrl: DIRECTOR, + userDataPath: path, + probe, + fetch, + now: () => 0, + diagnosticOverride: override + }) + } +} +describe('window-bound region decisions', () => { + it('compares against the actual incumbent despite a previous US placement cache', async () => { + const { instance, path, probe } = resolver(50, 100) + writeFileSync( + join(path, 'orca-relay-region-preference.json'), + JSON.stringify({ v: 2, directorUrl: DIRECTOR, region: 'us-central1', expiresAt: 999_999 }) + ) + expect(await instance.measureDecision(window)).toEqual({ + outcome: 'conclusive', + measurements: { 'us-central1': 50, 'asia-east2': 100 } + }) + expect(probe).toHaveBeenCalledTimes(8) + }) + it.each([ + [76, 100], + [100, 124], + [400, 450] + ])( + 'reports stable insufficient margins as conclusive evidence for director filtering (%i / %i)', + async (us, asia) => { + expect(await resolver(us, asia).instance.measureDecision(window)).toEqual({ + outcome: 'conclusive', + measurements: { 'us-central1': us, 'asia-east2': asia } + }) + } + ) + it('allows the exact inclusive 25ms and 20 percent boundary', async () => { + expect(await resolver(100, 125).instance.measureDecision(window)).toMatchObject({ + outcome: 'conclusive' + }) + }) + it('does not certify a lone measurable region', async () => { + expect(await resolver(40, null).instance.measureDecision(window)).toEqual({ + outcome: 'inconclusive', + reason: 'incomplete-measurement' + }) + }) + it('never converts diagnostic overrides into measured eligibility', async () => { + const { instance, probe } = resolver(40, 100, 'us-central1') + expect(await instance.measureDecision(window)).toEqual({ + outcome: 'inconclusive', + reason: 'diagnostic-override' + }) + expect(probe).not.toHaveBeenCalled() + }) + it('invalidates legacy placement caches on upgrade', async () => { + const { instance, path, probe } = resolver(40, 100) + writeFileSync( + join(path, 'orca-relay-region-preference.json'), + JSON.stringify({ v: 1, directorUrl: DIRECTOR, region: 'asia-east2', expiresAt: 999_999 }) + ) + expect(await instance.resolve()).toBe('us-central1') + expect(probe).toHaveBeenCalledTimes(8) + }) + it('falls back from a strict old director without dropping the cold-start hint', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 400 })) + .mockResolvedValueOnce( + Response.json({ v: 1, cellUrl: ASIA, assignmentEpoch: 1, lease: 'synthetic' }) + ) + const result = await requestRelayAssignment({ + directorUrl: DIRECTOR, + relayHostId: 'synthetic-host', + relayToken: 'synthetic-token', + preferredRegion: 'asia-east2', + reconnect: true, + regionCorrection: { v: 1, action: 'issue-window' }, + fetch, + assignRateGate: new RelayAssignRateGate() + }) + expect(result.cellUrl).toBe(ASIA) + expect(JSON.parse(String(fetch.mock.calls[1]![1]?.body))).toEqual({ + v: 1, + relayHostId: 'synthetic-host', + preferredRegion: 'asia-east2', + reconnect: true + }) + expect(result.regionCorrection).toBeUndefined() + }) +}) diff --git a/src/main/runtime/relay/relay-region-decision.ts b/src/main/runtime/relay/relay-region-decision.ts new file mode 100644 index 00000000000..677ae6faeb3 --- /dev/null +++ b/src/main/runtime/relay/relay-region-decision.ts @@ -0,0 +1,47 @@ +import type { RelayRegionDecision, RelayRegionWindow } from './relay-region-correction-protocol' +import { + RELAY_REGIONS, + regionMeasurement, + type RegionMeasurement, + type RelayRegionProbeReport +} from './relay-region-probe' + +export async function measureRelayRegionDecision( + window: RelayRegionWindow, + options: { + diagnosticOverride: boolean + now: () => number + measure: () => Promise + } +): Promise { + if (options.diagnosticOverride) { + return { outcome: 'inconclusive', reason: 'diagnostic-override' } + } + if (window.expiresAt <= options.now()) { + return { outcome: 'inconclusive', reason: 'expired-window' } + } + try { + // Placement caches are never evidence for a new server-issued window. + const reports = await options.measure() + const measurements = reports + .map(regionMeasurement) + .filter((entry): entry is RegionMeasurement => entry !== null) + const incumbent = measurements.find((entry) => entry.region === window.incumbentRegion) + if (window.expiresAt <= options.now()) { + return { outcome: 'inconclusive', reason: 'expired-window' } + } + if (!incumbent || measurements.length !== RELAY_REGIONS.length) { + return { outcome: 'inconclusive', reason: 'incomplete-measurement' } + } + // A stable tie is conclusive evidence; the director applies the incumbent margin. + return { + outcome: 'conclusive', + measurements: { + 'us-central1': measurements.find((entry) => entry.region === 'us-central1')!.latencyMs, + 'asia-east2': measurements.find((entry) => entry.region === 'asia-east2')!.latencyMs + } + } + } catch { + return { outcome: 'inconclusive', reason: 'catalog-unavailable' } + } +} diff --git a/src/main/runtime/relay/relay-region-preference-reader.ts b/src/main/runtime/relay/relay-region-preference-reader.ts new file mode 100644 index 00000000000..a856a598c85 --- /dev/null +++ b/src/main/runtime/relay/relay-region-preference-reader.ts @@ -0,0 +1,22 @@ +import { RelayRegionPreferenceResolver } from './relay-region-preference' +import type { RelayRegion } from './relay-region-probe' +import type { RelayRegionDecision, RelayRegionWindow } from './relay-region-correction-protocol' + +export function createRelayRegionPreferenceReader(input: { + authConfig: { relayDirectorUrl: string } + userDataPath: string +}): { + resolvePreferredRegion: () => Promise + measureRegionDecision: (window: RelayRegionWindow) => Promise + noteAssignedCell: (cellUrl: string) => void +} { + const resolver = new RelayRegionPreferenceResolver({ + directorUrl: input.authConfig.relayDirectorUrl, + userDataPath: input.userDataPath + }) + return { + resolvePreferredRegion: () => resolver.resolve(), + measureRegionDecision: (window) => resolver.measureDecision(window), + noteAssignedCell: (cellUrl) => void resolver.invalidateIfAssignedCellIsFar(cellUrl) + } +} diff --git a/src/main/runtime/relay/relay-region-preference.test.ts b/src/main/runtime/relay/relay-region-preference.test.ts index 700517d0b91..b3e2b845600 100644 --- a/src/main/runtime/relay/relay-region-preference.test.ts +++ b/src/main/runtime/relay/relay-region-preference.test.ts @@ -47,7 +47,7 @@ function sampledProbe(samples: Record) { function writeNoHintCache(path: string, expiresAt: number): void { writeFileSync( cachePath(path), - JSON.stringify({ v: 1, directorUrl: DIRECTOR, region: null, expiresAt }) + JSON.stringify({ v: 2, directorUrl: DIRECTOR, region: null, expiresAt }) ) } @@ -58,7 +58,7 @@ function cachePath(path: string): string { function writeCache(path: string, region: string, expiresAt = 999): void { writeFileSync( cachePath(path), - JSON.stringify({ v: 1, directorUrl: DIRECTOR, region, latencyMs: 100, expiresAt }) + JSON.stringify({ v: 2, directorUrl: DIRECTOR, region, latencyMs: 100, expiresAt }) ) } @@ -87,7 +87,7 @@ describe('Relay region preference', () => { expect(calls.filter((origin) => origin === US_SECONDARY)).toHaveLength(4) expect(calls.filter((origin) => origin === ASIA)).toHaveLength(4) expect(JSON.parse(readFileSync(cachePath(path), 'utf8'))).toMatchObject({ - v: 1, + v: 2, directorUrl: DIRECTOR, region: 'asia-east2', latencyMs: 30 @@ -175,7 +175,7 @@ describe('Relay region preference', () => { ).resolves.toBeUndefined() // The withheld hint is remembered briefly so a reconnect does not re-probe. const cached = JSON.parse(readFileSync(cachePath(path), 'utf8')) - expect(cached).toEqual({ v: 1, directorUrl: DIRECTOR, region: null, expiresAt: 3_601_000 }) + expect(cached).toEqual({ v: 2, directorUrl: DIRECTOR, region: null, expiresAt: 3_601_000 }) }) it('reuses the short-lived no-hint cache instead of re-probing on reconnect', async () => { diff --git a/src/main/runtime/relay/relay-region-preference.ts b/src/main/runtime/relay/relay-region-preference.ts index 9ad3743c972..a430e2d8a35 100644 --- a/src/main/runtime/relay/relay-region-preference.ts +++ b/src/main/runtime/relay/relay-region-preference.ts @@ -1,9 +1,11 @@ +import { measureRelayRegionDecision } from './relay-region-decision' import { existsSync, readFileSync, rmSync, statSync } from 'node:fs' import { join } from 'node:path' import { performance } from 'node:perf_hooks' import { z } from 'zod' import { hardenExistingSecureFile, writeSecureJsonFile } from '../../../shared/secure-file' import { fetchRelayRegionCatalog, relayDirectorHost } from './relay-region-catalog-fetch' +import type { RelayRegionDecision, RelayRegionWindow } from './relay-region-correction-protocol' import { logRelayRegionEvent, relayRegionCacheHitEvent, @@ -43,7 +45,7 @@ const FAR_CELL_RATIO = 3 const RelayRegionCacheSchema = z .object({ - v: z.literal(1), + v: z.literal(2), directorUrl: z.string().max(2_048), // Null records a deliberate "no hint"; the field is absent only for a region. region: RelayRegionSchema.nullable(), @@ -75,6 +77,14 @@ export class RelayRegionPreferenceResolver { this.options = options } + measureDecision(window: RelayRegionWindow): Promise { + return measureRelayRegionDecision(window, { + diagnosticOverride: Boolean(this.overrideRegion()), + now: this.options.now ?? Date.now, + measure: () => this.probeCatalog(this.options.fetch ?? globalThis.fetch) + }) + } + async resolve(): Promise { const override = this.overrideRegion() if (override) { @@ -233,7 +243,7 @@ export class RelayRegionPreferenceResolver { ): void { try { writeSecureJsonFile(this.cachePath(), { - v: 1, + v: 2, directorUrl: this.options.directorUrl, region: entry.region, ...(entry.latencyMs === undefined ? {} : { latencyMs: entry.latencyMs }), @@ -269,23 +279,6 @@ export class RelayRegionPreferenceResolver { } } -export function createRelayRegionPreferenceReader(input: { - authConfig: { relayDirectorUrl: string } - userDataPath: string -}): { - resolvePreferredRegion: () => Promise - noteAssignedCell: (cellUrl: string) => void -} { - const resolver = new RelayRegionPreferenceResolver({ - directorUrl: input.authConfig.relayDirectorUrl, - userDataPath: input.userDataPath - }) - return { - resolvePreferredRegion: () => resolver.resolve(), - noteAssignedCell: (cellUrl) => void resolver.invalidateIfAssignedCellIsFar(cellUrl) - } -} - function measuredRegions(reports: RelayRegionProbeReport[]): RegionMeasurement[] { return reports .map(regionMeasurement) diff --git a/src/main/runtime/relay/relay-region-probe-log.test.ts b/src/main/runtime/relay/relay-region-probe-log.test.ts index eb62939d9c7..d4c97866438 100644 --- a/src/main/runtime/relay/relay-region-probe-log.test.ts +++ b/src/main/runtime/relay/relay-region-probe-log.test.ts @@ -48,7 +48,7 @@ function sampledProbe(samples: Record) { function writeCache(path: string, region: string | null, expiresAt: number): void { writeFileSync( join(path, 'orca-relay-region-preference.json'), - JSON.stringify({ v: 1, directorUrl: DIRECTOR, region, expiresAt }) + JSON.stringify({ v: 2, directorUrl: DIRECTOR, region, expiresAt }) ) } diff --git a/src/main/runtime/relay/relay-region-refresh.test.ts b/src/main/runtime/relay/relay-region-refresh.test.ts new file mode 100644 index 00000000000..d6453191602 --- /dev/null +++ b/src/main/runtime/relay/relay-region-refresh.test.ts @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RelayHttpError, type RelayAssignment } from './relay-http-client' +import type * as RelayHttpClientModule from './relay-http-client' +import type { RelayRegionWindow } from './relay-region-correction-protocol' +const fake = vi.hoisted(() => ({ assign: vi.fn() })) +vi.mock('./relay-http-client', async (original) => ({ + ...(await original()), + requestRelayAssignment: fake.assign +})) +import { RelayRegionRefresh } from './relay-region-refresh' +const HOUR = 60 * 60_000 +const window: RelayRegionWindow = { + generation: 1, + assignmentEpoch: 1, + incumbentRegion: 'asia-east2', + expiresAt: 24 * HOUR, + policyVersion: 1 +} +const assignment: RelayAssignment = { + v: 1, + cellUrl: 'https://source.example.test', + assignmentEpoch: 1, + lease: 'test', + regionCorrection: { v: 1, window } +} +let scheduler: RelayRegionRefresh +function setup(random = 0.5) { + const measure = vi.fn().mockResolvedValue({ + outcome: 'conclusive', + measurements: { 'us-central1': 30, 'asia-east2': 200 } + }) + const applyAssignment = vi.fn(() => true) + const isOnline = vi.fn(() => true) + scheduler = new RelayRegionRefresh({ + directorUrl: 'https://director.example.test', + relayHostId: 'test-host', + token: () => 'test-token', + assignment: () => assignment, + isCurrent: () => true, + isOnline, + applyAssignment, + measure, + random: () => random, + now: () => Date.now() + }) + return { measure, applyAssignment, isOnline } +} +describe('broker-owned region decision refresh', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(0) + fake.assign.mockReset() + }) + afterEach(() => { + scheduler?.close() + vi.useRealTimers() + }) + it('measures only after the server window and reports the complete fixed basis', async () => { + const { measure } = setup() + fake.assign.mockResolvedValue({ + ...assignment, + regionCorrection: { v: 1, reportStatus: 'accepted' } + }) + scheduler.start(assignment) + await vi.advanceTimersByTimeAsync(0) + expect(measure).toHaveBeenCalledWith(window) + expect(fake.assign).toHaveBeenCalledWith( + expect.objectContaining({ + regionCorrection: { + v: 1, + action: 'report', + generation: 1, + assignmentEpoch: 1, + policyVersion: 1, + outcome: 'conclusive', + measurements: { 'us-central1': 30, 'asia-east2': 200 } + } + }) + ) + await vi.advanceTimersByTimeAsync(23 * HOUR) + expect(measure).toHaveBeenCalledOnce() + }) + it('never jitters a retry before the director Retry-After minimum', async () => { + setup(0) + fake.assign + .mockRejectedValueOnce(new RelayHttpError('assignment', 429, 120_000)) + .mockResolvedValue({ ...assignment, regionCorrection: { v: 1, reportStatus: 'accepted' } }) + scheduler.start(assignment) + await vi.advanceTimersByTimeAsync(119_999) + expect(fake.assign).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(1) + expect(fake.assign).toHaveBeenCalledTimes(2) + }) + it('retries exactly the same report without probing or extending its window', async () => { + const { measure } = setup() + fake.assign + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValue({ ...assignment, regionCorrection: { v: 1, reportStatus: 'accepted' } }) + scheduler.start(assignment) + await vi.advanceTimersByTimeAsync(60_000) + expect(measure).toHaveBeenCalledOnce() + expect(fake.assign).toHaveBeenCalledTimes(2) + expect(fake.assign.mock.calls[0]![0].regionCorrection).toEqual( + fake.assign.mock.calls[1]![0].regionCorrection + ) + }) + it('records inconclusive reports and retries measurement after one hour', async () => { + const { measure } = setup() + measure.mockResolvedValue({ outcome: 'inconclusive', reason: 'incomplete-measurement' }) + fake.assign + .mockResolvedValueOnce({ + ...assignment, + regionCorrection: { v: 1, reportStatus: 'accepted' } + }) + .mockResolvedValue(assignment) + scheduler.start(assignment) + await vi.advanceTimersByTimeAsync(HOUR) + expect(measure).toHaveBeenCalledTimes(2) + expect(fake.assign.mock.calls[1]![0].regionCorrection).toEqual({ v: 1, action: 'issue-window' }) + }) + it('does not probe offline and cancels future work on close', async () => { + const { measure, isOnline } = setup() + isOnline.mockReturnValue(false) + scheduler.start(assignment) + await vi.advanceTimersByTimeAsync(60_000) + expect(measure).not.toHaveBeenCalled() + scheduler.close() + isOnline.mockReturnValue(true) + await vi.advanceTimersByTimeAsync(25 * HOUR) + expect(fake.assign).not.toHaveBeenCalled() + }) + it('does not report a measurement that completed after broker close', async () => { + const { measure } = setup() + let resolve!: (value: unknown) => void + measure.mockReturnValue( + new Promise((done) => { + resolve = done + }) + ) + scheduler.start(assignment) + scheduler.close() + resolve({ outcome: 'inconclusive', reason: 'incomplete-measurement' }) + await vi.advanceTimersByTimeAsync(0) + expect(fake.assign).not.toHaveBeenCalled() + }) + it('uses a successor window after an expired report retry', async () => { + setup() + fake.assign.mockRejectedValueOnce(new Error('offline')).mockResolvedValue({ + ...assignment, + regionCorrection: { v: 1, window: { ...window, generation: 2, expiresAt: 48 * HOUR } } + }) + scheduler.start(assignment) + await vi.advanceTimersByTimeAsync(0) + vi.setSystemTime(25 * HOUR) + await vi.advanceTimersByTimeAsync(60_000) + expect(fake.assign.mock.calls[1]![0].regionCorrection).toEqual({ v: 1, action: 'issue-window' }) + }) +}) diff --git a/src/main/runtime/relay/relay-region-refresh.ts b/src/main/runtime/relay/relay-region-refresh.ts new file mode 100644 index 00000000000..c93a9d9618e --- /dev/null +++ b/src/main/runtime/relay/relay-region-refresh.ts @@ -0,0 +1,172 @@ +import { RelayHttpError, requestRelayAssignment, type RelayAssignment } from './relay-http-client' +import type { + RelayRegionCorrectionRequest, + RelayRegionDecision, + RelayRegionWindow +} from './relay-region-correction-protocol' + +type RefreshOptions = { + directorUrl: string + relayHostId: string + token: () => string | undefined + assignment: () => RelayAssignment | null + isCurrent: () => boolean + isOnline: () => boolean + applyAssignment: (assignment: RelayAssignment) => boolean + measure: (window: RelayRegionWindow) => Promise + fetch?: typeof globalThis.fetch + now?: () => number + random?: () => number +} + +const HOUR = 60 * 60_000 + +export class RelayRegionRefresh { + private timer: ReturnType | null = null + private pending: Promise | null = null + private report: Extract | null = null + private window: RelayRegionWindow | null = null + private closed = false + private nextDeadline = 0 + + constructor(private readonly options: RefreshOptions) {} + + start(assignment: RelayAssignment): void { + this.window = assignment.regionCorrection?.window ?? null + if (this.window) { + this.checkDeadline() + } else { + this.schedule(HOUR) + } + } + + checkDeadline(): void { + if (!this.isCurrent() || this.pending) { + return + } + if (this.now() < this.nextDeadline) { + if (!this.timer) { + this.schedule(Math.min(HOUR, this.nextDeadline - this.now())) + } + return + } + if (!this.options.isOnline()) { + this.schedule(60_000) + return + } + this.pending = this.refresh().finally(() => { + this.pending = null + }) + } + + close(): void { + this.closed = true + if (this.timer) { + clearTimeout(this.timer) + } + this.timer = null + this.report = null + this.window = null + } + + private async exchange(regionCorrection: RelayRegionCorrectionRequest): Promise { + const token = this.options.token() + if (!token) { + throw new Error('relay_region_authorization_unavailable') + } + const assignment = await requestRelayAssignment({ + directorUrl: this.options.directorUrl, + relayHostId: this.options.relayHostId, + relayToken: token, + reconnect: true, + regionCorrection, + isCurrent: () => this.isCurrent(), + fetch: this.options.fetch + }) + if (!this.isCurrent()) { + throw new Error('stale_relay_region_refresh') + } + // The mode-bearing source drain owns migration activation; reports never rebind controls. + this.options.applyAssignment(assignment) + return assignment + } + + private async refresh(): Promise { + try { + const assignment = this.options.assignment() + if (!assignment) { + this.schedule(60_000) + return + } + if ( + this.window && + (this.window.expiresAt <= this.now() || + this.window.assignmentEpoch !== assignment.assignmentEpoch) + ) { + this.window = null + this.report = null + } + if (!this.window) { + this.window = + (await this.exchange({ v: 1, action: 'issue-window' })).regionCorrection?.window ?? null + } + const window = this.window + if (!window) { + this.schedule(HOUR) + return + } + if (!this.report) { + const decision = await this.options.measure(window) + if (!this.isCurrent()) { + return + } + this.report = { + v: 1, + action: 'report', + generation: window.generation, + assignmentEpoch: window.assignmentEpoch, + policyVersion: 1, + ...decision + } + } + const report = this.report + const response = await this.exchange(report) + const accepted = response.regionCorrection?.reportStatus + this.report = null + this.window = null + this.schedule( + (accepted === 'accepted' || accepted === 'duplicate') && report.outcome === 'conclusive' + ? 24 * HOUR + : HOUR + ) + } catch (error) { + // Retry the same report/window: auth and healthy sockets are independent of probing. + const retry = error instanceof RelayHttpError ? (error.retryAfterMs ?? 0) : 0 + this.schedule(Math.max(60_000, retry), retry) + } + } + + private schedule(delay: number, minimumDelay = 0): void { + if (!this.isCurrent()) { + return + } + if (this.timer) { + clearTimeout(this.timer) + } + const jitter = 0.9 + (this.options.random ?? Math.random)() * 0.2 + const scheduledDelay = Math.max(minimumDelay, Math.ceil(delay * jitter)) + this.nextDeadline = this.now() + scheduledDelay + this.timer = setTimeout(() => { + this.timer = null + this.checkDeadline() + }, scheduledDelay) + this.timer.unref?.() + } + + private now(): number { + return (this.options.now ?? Date.now)() + } + private isCurrent(): boolean { + return !this.closed && this.options.isCurrent() + } +} diff --git a/src/main/runtime/relay/relay-session-broker-contract.ts b/src/main/runtime/relay/relay-session-broker-contract.ts index 78849f80eba..355bed76360 100644 --- a/src/main/runtime/relay/relay-session-broker-contract.ts +++ b/src/main/runtime/relay/relay-session-broker-contract.ts @@ -4,6 +4,7 @@ import type { MobileRelayStatus } from '../../../shared/mobile-relay-status' import type { E2EEKeypair } from '../e2ee-keypair' import type { MobileSocketWiring } from '../rpc/mobile-socket-wiring' import type { RelayRegion } from './relay-region-preference' +import type { RelayRegionDecision, RelayRegionWindow } from './relay-region-correction-protocol' export type RelayBrokerStatus = MobileRelayStatus @@ -23,6 +24,7 @@ export type RelaySessionBrokerOptions = { isCurrent: () => boolean refreshAccessToken: () => Promise resolvePreferredRegion?: () => Promise + measureRegionDecision?: (window: RelayRegionWindow) => Promise onAssignedCellActive?: (cellUrl: string) => void /** `cellUrl` is absent whenever the host holds no active assignment. */ onStatus: (status: RelayBrokerStatus, cellUrl?: string) => void @@ -32,3 +34,9 @@ export type RelaySessionBrokerOptions = { random?: () => number now?: () => number } + +export class StaleRelayBrokerError extends Error { + constructor() { + super('stale_relay_broker') + } +} diff --git a/src/main/runtime/relay/relay-session-broker.ts b/src/main/runtime/relay/relay-session-broker.ts index e8af020daf8..f32a077885e 100644 --- a/src/main/runtime/relay/relay-session-broker.ts +++ b/src/main/runtime/relay/relay-session-broker.ts @@ -1,3 +1,5 @@ +import { StaleRelayBrokerError } from './relay-session-broker-contract' +export { StaleRelayBrokerError } from './relay-session-broker-contract' import { relayStatusCellUrl } from '../../../shared/mobile-relay-status' import type { PairingRelay } from '../../../shared/mobile-relay-pairing-offer' import type { @@ -17,21 +19,17 @@ import { type RelayAssignment } from './relay-http-client' import { RelayOriginPool } from './relay-origin-pool' +import { RelayRegionRefresh } from './relay-region-refresh' import { relayRenewalDelayMs } from './relay-renewal-jitter' import type { RelayBrokerStatus, RelaySessionBrokerOptions } from './relay-session-broker-contract' export type { RelayBrokerStatus } from './relay-session-broker-contract' -export class StaleRelayBrokerError extends Error { - constructor() { - super('stale_relay_broker') - } -} - export class RelaySessionBroker { private readonly options: RelaySessionBrokerOptions private readonly relayHostId: string private readonly originPool: RelayOriginPool + private readonly regionRefresh: RelayRegionRefresh | null private authorization: RelayAuthorization | null = null private refreshTimer: ReturnType | null = null private closed = false @@ -55,6 +53,21 @@ export class RelaySessionBroker { random: options.random, now: options.now }) + this.regionRefresh = options.measureRegionDecision + ? new RelayRegionRefresh({ + directorUrl: options.authConfig.relayDirectorUrl, + relayHostId: this.relayHostId, + token: () => this.authorization?.relayToken, + assignment: () => this.originPool.activeAssignment, + isCurrent: () => this.isCurrent(), + isOnline: () => this.originPool.hasLiveControl(), + applyAssignment: (assignment) => this.originPool.applyAssignmentMetadata(assignment), + measure: options.measureRegionDecision, + fetch: options.fetch, + now: options.now, + random: options.random + }) + : null } static async connect(options: RelaySessionBrokerOptions): Promise { @@ -194,6 +207,7 @@ export class RelaySessionBroker { this.refreshTimer = null } this.originPool.closeNow(hostCloseReason) + this.regionRefresh?.close() if (publishOffline) { this.options.onStatus('offline') } @@ -220,6 +234,9 @@ export class RelaySessionBroker { // through to the placement lane. reconnect: true, preferredRegion, + ...(this.regionRefresh + ? { regionCorrection: { v: 1 as const, action: 'issue-window' as const } } + : {}), isCurrent: () => this.isCurrent(), fetch: this.options.fetch }) @@ -236,6 +253,7 @@ export class RelaySessionBroker { this.authorization = authorization this.publishStatus('registered') this.scheduleRefresh() + this.regionRefresh?.start(assignment) } private scheduleRefresh(): void { @@ -267,6 +285,7 @@ export class RelaySessionBroker { this.assertCurrent() this.originPool.refreshAuthorization(authorization.relayToken) this.authorization = authorization + this.regionRefresh?.checkDeadline() this.scheduleRefresh() } catch { const expiry = this.authorization?.expiresAt ?? 0 diff --git a/tests/e2e/helpers/relay-execution-process.ts b/tests/e2e/helpers/relay-execution-process.ts new file mode 100644 index 00000000000..c9247bffca8 --- /dev/null +++ b/tests/e2e/helpers/relay-execution-process.ts @@ -0,0 +1,125 @@ +import { randomUUID } from 'node:crypto' +import { mkdir, mkdtemp, readFile, realpath, rm } from 'node:fs/promises' +import path from 'node:path' +import { createInterface } from 'node:readline' +import { spawnProcess } from '../../../src/shared/child-process/run-process' + +const executionProgram = ` +const fs = require('node:fs'); +const readline = require('node:readline'); +const marker = process.argv[1]; +let sequence = 0; +readline.createInterface({ input: process.stdin }).on('line', line => { + const { id, value } = JSON.parse(line); + if (value === 'mutation-1') fs.appendFileSync('mutations.log', marker + '\\n'); + process.stdout.write(JSON.stringify({ id, pid: process.pid, cwd: fs.realpathSync('.'), + marker, sequence: ++sequence, value }) + '\\n'); +}); +` + +export async function createRelayExecutionProcess() { + await mkdir(path.join(process.cwd(), '.tmp'), { recursive: true }) + const folder = await mkdtemp(path.join(process.cwd(), '.tmp', 'relay-execution-')) + const executionCwd = await realpath(folder) + const marker = randomUUID() + const pending = new Map< + number, + { + resolve: (value: string) => void + reject: (error: Error) => void + timer: ReturnType + } + >() + let sequence = 0 + let nextId = 0 + let failure: Error | null = null + const child = spawnProcess({ + program: process.execPath, + args: ['-e', executionProgram, marker], + cwd: folder, + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } + }) + const fail = (error: Error) => { + failure = error + for (const item of pending.values()) { + clearTimeout(item.timer) + item.reject(error) + } + pending.clear() + } + child.on('error', fail) + child.stdin.on('error', fail) + child.stdout.on('error', fail) + child.stderr.on('error', fail) + child.stderr.resume() + const closed = new Promise((resolve) => + child.once('close', () => { + fail(new Error('execution process exited')) + resolve() + }) + ) + const lines = createInterface({ input: child.stdout }) + lines.on('line', (line) => { + try { + const output = JSON.parse(line) + const item = pending.get(output.id) + if ( + !item || + output.pid !== child.pid || + output.cwd !== executionCwd || + output.marker !== marker || + output.sequence !== sequence + 1 + ) { + throw new Error('execution ownership or output sequence changed') + } + sequence = output.sequence + clearTimeout(item.timer) + pending.delete(output.id) + item.resolve(output.value) + } catch (error) { + fail(error as Error) + } + }) + return { + pid: child.pid, + sequence: () => sequence, + execute: (value: string) => + new Promise((resolve, reject) => { + if (failure) { + reject(failure) + return + } + const id = ++nextId + const timer = setTimeout(() => fail(new Error('execution response timed out')), 5_000) + pending.set(id, { resolve, reject, timer }) + child.stdin.write(`${JSON.stringify({ id, value })}\n`) + }), + mutations: async () => { + try { + const entries = (await readFile(path.join(folder, 'mutations.log'), 'utf8')) + .trim() + .split('\n') + if (entries.some((entry) => entry !== marker)) { + throw new Error('unexpected execution artifact') + } + return entries.length + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return 0 + } + throw error + } + }, + close: async () => { + child.stdin.end() + const timer = setTimeout(() => child.kill('SIGKILL'), 5_000) + try { + await closed + } finally { + clearTimeout(timer) + lines.close() + await rm(folder, { recursive: true, force: true }) + } + } + } +} diff --git a/tests/e2e/relay-region-compatibility.unit.test.ts b/tests/e2e/relay-region-compatibility.unit.test.ts new file mode 100644 index 00000000000..65f54067fd4 --- /dev/null +++ b/tests/e2e/relay-region-compatibility.unit.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from 'vitest' +import { + AssignmentRequestSchema as BaselineRequest, + AssignmentResponseSchema as BaselineResponse +} from '../../cloud/apps/relay/src/test-fixtures/relay-contract-baseline/director-messages' +import { + DrainSchema as BaselineDrain, + HostHelloSchema as BaselineHello +} from '../../cloud/apps/relay/src/test-fixtures/relay-contract-baseline/control-messages' +import { AssignmentRequestSchema } from '../../cloud/packages/relay-contract/src/director-messages' +import { HostHelloSchema } from '../../cloud/packages/relay-contract/src/control-messages' +import { requestRelayAssignment } from '../../src/main/runtime/relay/relay-http-client' +import { RelayAssignRateGate } from '../../src/main/runtime/relay/relay-assign-rate-gate' + +const assignment = { + v: 1, + cellUrl: 'https://asia.example.test', + assignmentEpoch: 3, + lease: 'synthetic-assignment' +} +const window = { + generation: 1, + assignmentEpoch: 3, + incumbentRegion: 'asia-east2', + expiresAt: 100_000_000, + policyVersion: 1 +} +function request(fetch: typeof globalThis.fetch) { + return requestRelayAssignment({ + directorUrl: 'https://director.example.test', + relayHostId: 'abcdefghijklmnop', + relayToken: 'synthetic-authorization', + preferredRegion: 'asia-east2', + reconnect: true, + regionCorrection: { v: 1, action: 'issue-window' }, + fetch, + assignRateGate: new RelayAssignRateGate() + }) +} + +describe('relay correction mixed-version wire contracts', () => { + it('new desktop falls back against the actual pinned old director parser', async () => { + const bodies: unknown[] = [] + const fetch = vi.fn(async (_url, init) => { + const body: unknown = JSON.parse(String(init?.body)) + bodies.push(body) + return BaselineRequest.safeParse(body).success + ? Response.json(BaselineResponse.parse(assignment)) + : new Response(null, { status: 400 }) + }) + expect(await request(fetch)).toEqual(assignment) + expect(bodies).toHaveLength(2) + expect(AssignmentRequestSchema.safeParse(bodies[0]).success).toBe(true) + expect(BaselineRequest.safeParse(bodies[0]).success).toBe(false) + expect(bodies[1]).toEqual({ + v: 1, + relayHostId: 'abcdefghijklmnop', + preferredRegion: 'asia-east2', + reconnect: true + }) + }) + + it('the old desktop assignment shape remains accepted by the new director', () => { + const request = BaselineRequest.parse({ v: 1, relayHostId: 'abcdefghijklmnop' }) + expect(AssignmentRequestSchema.parse(request)).toEqual(request) + expect(BaselineResponse.parse(assignment)).toEqual(assignment) + }) + + it('the negotiated capability requires no change to the strict old host hello', () => { + const hello = { + v: 1, + relayHostId: 'abcdefghijklmnop', + assignmentEpoch: 3, + hostPublicKeyB64: Buffer.alloc(32).toString('base64'), + appVersion: 'test' + } + expect(BaselineHello.parse(HostHelloSchema.parse(hello))).toEqual(hello) + expect(BaselineHello.safeParse({ ...hello, idleRegionalRehome: true }).success).toBe( + false + ) + }) + + it('the idle cutover uses a drain frame understood by the pinned old desktop', () => { + const drain = { recovery: 'resolve-director', graceMs: 0 } + expect(BaselineDrain.parse(drain)).toEqual(drain) + }) + + it.each([ + { v: 1, window: { ...window, policyVersion: 2 } }, + { v: 2, window }, + { v: 1, window: { ...window, expiresAt: -1 } }, + { v: 1, window: { ...window, unexpectedField: true } } + ])( + 'defers unsupported or malformed optional correction without losing placement: %j', + async (regionCorrection) => { + const result = await request(async () => Response.json({ ...assignment, regionCorrection })) + expect(result).toMatchObject(assignment) + expect(result.regionCorrection).toBeUndefined() + } + ) + + it('still accepts supported correction metadata', async () => { + const regionCorrection = { v: 1, window } + expect(await request(async () => Response.json({ ...assignment, regionCorrection }))).toEqual({ + ...assignment, + regionCorrection + }) + }) + + it.each([ + { cellUrl: 'http://untrusted.example.test' }, + { assignmentEpoch: -1 }, + { lease: '' }, + { unexpectedField: true } + ])('keeps the core assignment strict: %j', async (invalid) => { + await expect(request(async () => Response.json({ ...assignment, ...invalid }))).rejects.toThrow( + 'relay_assignment_failed_502' + ) + }) +}) diff --git a/tests/e2e/relay-region-correction.unit.test.ts b/tests/e2e/relay-region-correction.unit.test.ts new file mode 100644 index 00000000000..614e5c08c1d --- /dev/null +++ b/tests/e2e/relay-region-correction.unit.test.ts @@ -0,0 +1,519 @@ +import { createHash, randomUUID } from 'node:crypto' +import { once } from 'node:events' +import { afterEach, describe, expect, it, vi } from 'vitest' +import nacl from 'tweetnacl' +import WebSocket from 'ws' +import type { IdleRegionalRehomeRequest } from '../../cloud/packages/relay-contract/src/idle-regional-rehome' +import { + openInMemoryRelayDatabase, + readRelayDatabasePoolPressure +} from '../../cloud/apps/relay/src/database' +import { createRelayServer } from '../../cloud/apps/relay/src/relay-server' +import type { RelayConfig } from '../../cloud/apps/relay/src/config' +import type * as AdminTokenVerifier from '../../cloud/apps/relay/src/admin-token-verifier' +import { RelayOriginPool } from '../../src/main/runtime/relay/relay-origin-pool' +import { RELAY_HOST_CAPABILITY_HEADERS } from '../../src/main/runtime/relay/relay-control-protocol' +import type { MobileSocketTransport } from '../../src/main/runtime/rpc/mobile-socket-wiring' +import { createRelayExecutionProcess } from './helpers/relay-execution-process' + +vi.mock('../../cloud/apps/relay/src/relay-token-verifier', () => ({ + createRelayTokenVerifier: () => async (hostId: string) => ({ + sub: 'transport-test-user', + prof: 'profile-1', + org: 'org-1', + relayHostId: hostId, + purpose: 'host-control', + exp: 4_102_444_800 + }), + readBearer: (value: string | undefined) => value?.replace(/^Bearer /, '') ?? null +})) + +vi.mock('../../cloud/apps/relay/src/admin-token-verifier', async (importOriginal) => ({ + ...(await importOriginal()), + createRegionalRehomeTokenVerifier: () => async (token: string) => token === 'test-director-token' +})) + +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + const failures: unknown[] = [] + for (const cleanup of cleanups.splice(0).toReversed()) { + try { + await cleanup() + } catch (error) { + failures.push(error) + } + } + vi.restoreAllMocks() + if (failures.length > 0) { + throw new AggregateError(failures, 'relay topology cleanup failed') + } +}) + +async function topology() { + const execution = await createRelayExecutionProcess() + cleanups.push(() => execution.close()) + let clock = Date.now() + vi.spyOn(Date, 'now').mockImplementation(() => clock) + const database = await openInMemoryRelayDatabase() + cleanups.push(() => database.close()) + const keypair = nacl.box.keyPair() + const hostId = createHash('sha256').update(keypair.publicKey).digest('base64url').slice(0, 16) + const identity = { userId: 'transport-test-user', relayHostId: hostId } + const cells = [ + { + id: 'transport-us', + url: 'https://transport-us.example.test', + region: 'us-central1' as const, + capacityRequests: 100 + }, + { + id: 'transport-asia', + url: 'https://transport-asia.example.test', + region: 'asia-east2' as const, + capacityRequests: 100 + } + ] + const incarnations = [ + '11111111-1111-4111-8111-111111111111', + '22222222-2222-4222-8222-222222222222' + ] + const endpoints = new Map() + const sockets = new Set() + const servers = cells.map((cell, index) => + createRelayServer( + { + port: 0, + publicUrl: cell.url, + cellUrl: cell.url, + role: 'cell', + cellId: cell.id, + region: cell.region, + cells, + dataDir: '', + authIssuer: 'https://auth.example.test', + authAudience: 'orca-relay', + adminJwksUrl: 'https://auth.example.test/jwks', + jwksUrl: 'https://auth.example.test/jwks', + assignmentSigningKey: new Uint8Array(32), + adminAudience: 'https://director.example.test/v1/admin/drain', + deployServiceAccount: 'deploy@example.test', + rehomeAudience: 'https://director.example.test/v1/admin/host-drain', + rehomeDirectorServiceAccount: 'director@example.test', + databasePoolMax: 1, + publicAssignmentsEnabled: true, + publicAssignmentConcurrency: 2, + publicAssignmentQueueMax: 128, + publicAssignmentWaitMs: 4_000, + publicResolveConcurrency: 1, + publicResolveWaitMs: 5_000, + publicAssignmentRetryAfterSeconds: 5, + regionCorrectionCohortPercent: 100 + } as RelayConfig, + database, + { now: () => clock, random: () => 0.5, cellIncarnation: incarnations[index] } + ) + ) + cleanups.push(async () => { + for (const socket of sockets) { + socket.terminate() + } + for (const relay of servers) { + relay.sessions.drain(0) + await new Promise((resolve) => relay.server.close(() => resolve())) + } + }) + const source = servers[0]! + const target = servers[1]! + await source.assignments.inspectRegionalRehomeControl() + clock += 86_400_000 + await source.assignments.applyRegionalRehomeControl({ + expectedGeneration: 0, + enabled: true, + notBefore: clock, + ratePerMinute: 10, + preferenceMaxAgeMs: 86_400_000, + hostCooldownMs: 604_800_000, + drainGraceMs: 60_000 + }) + await source.assignments.reconcileCells(cells) + const startedAt = clock - 1_000 + const safety = () => ({ + observedAt: clock, + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + }) + const heartbeat = async () => { + for (const [index, cell] of cells.entries()) { + const relay = servers[index]! + relay.observability.flush({ + ...relay.runtimeCounts(), + ...readRelayDatabasePoolPressure(database) + }) + await source.assignments.recordCellHeartbeat({ + cellId: cell.id, + cellUrl: cell.url, + region: cell.region, + cellIncarnation: incarnations[index]!, + startedAt, + ready: true, + observedRequests: 0 + }) + await source.assignments.recordCellRegionalRehomeStatus({ + cellId: cell.id, + cellIncarnation: incarnations[index]!, + regionalRehomeProtocol: 3, + safety: { + observedAt: clock, + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + } + }) + } + } + await heartbeat() + for (const [index, relay] of servers.entries()) { + relay.server.listen(0, '127.0.0.1') + await once(relay.server, 'listening') + const address = relay.server.address() + if (!address || typeof address === 'string') { + throw new Error('missing local address') + } + endpoints.set(new URL(cells[index]!.url).host, `ws://127.0.0.1:${address.port}`) + } + const connect = (url: string, headers?: Record) => { + const parsed = new URL(url) + const socket = new WebSocket(`${endpoints.get(parsed.host)}${parsed.pathname}`, { headers }) + sockets.add(socket) + return socket + } + let failCorroboration = 0 + let pauseCorroboration = false + let corroborationFailures = 0 + let rejectTargetControls = false + let targetControlFailures = 0 + const executionErrors: unknown[] = [] + let delayedReply: (() => void) | null = null + const received: string[] = [] + const pool = new RelayOriginPool({ + directorUrl: 'https://director.example.test', + relayHostId: hostId, + identity: { userId: identity.userId, profileId: 'profile-1', organizationId: 'org-1' }, + keypair: { ...keypair, publicKeyB64: Buffer.from(keypair.publicKey).toString('base64') }, + appVersion: 'transport-test', + isCurrent: () => true, + onStatus: () => {}, + now: () => clock, + mobileSocketWiring: { + attachTransport: (transport: MobileSocketTransport) => { + transport.onMessage((raw, reply) => { + const value = raw.toString() + received.push(value) + void execution + .execute(value) + .then((output) => { + if (output === 'mutation-1') { + delayedReply = () => reply('mutation-1-ack') + } else { + reply(`host:${output}`) + } + }) + .catch((error) => executionErrors.push(error)) + }) + return () => {} + } + } as never, + createControlSocket: (url, token) => { + if (rejectTargetControls && new URL(url).host === new URL(cells[1]!.url).host) { + targetControlFailures++ + throw new Error('simulated_target_unavailable') + } + const socket = connect(url, { + authorization: `Bearer ${token}`, + ...RELAY_HOST_CAPABILITY_HEADERS + }) + if (process.env.ORCA_RELAY_TRANSPORT_DIAGNOSTICS === '1') { + const cell = new URL(url).host + console.info('transport-control-created', { + cell, + stack: new Error('transport control created').stack + }) + socket.on('message', (raw) => { + const message = JSON.parse(raw.toString()) + if (['region-restored', 'host-hello-ack', 'drain'].includes(message.type)) { + console.info('transport-control-message', { + cell, + type: message.type, + assignmentEpoch: message.assignmentEpoch, + generation: message.generation + }) + } + }) + socket.on('close', (code) => console.info('transport-control-close', { cell, code })) + } + return socket + }, + createDataSocket: (url) => connect(url), + fetch: (async () => { + if (failCorroboration > 0 || pauseCorroboration) { + failCorroboration = Math.max(0, failCorroboration - 1) + corroborationFailures++ + return Response.json({ error: 'temporary_director_failure' }, { status: 503 }) + } + const assignment = await source.assignments.resolve(identity) + if (!assignment) { + return Response.json({ error: 'assignment_not_found' }, { status: 409 }) + } + return Response.json({ + v: 1, + cellUrl: assignment.cellUrl, + assignmentEpoch: assignment.assignmentEpoch, + lease: 'synthetic-assignment-lease' + }) + }) as typeof fetch + }) + cleanups.push(async () => { + pool.closeNow() + }) + const assignment = await source.assignments.assign(identity, 'us-central1') + await pool.openInitial( + { + v: 1, + cellUrl: assignment.cellUrl, + assignmentEpoch: assignment.assignmentEpoch, + lease: 'synthetic-assignment-lease' + }, + hostId + ) + const attachPhone = async (cellIndex: number, device: string) => { + const invite = await source.store.createInvite(identity, device) + const socket = connect(`${cells[cellIndex]!.url}/v1/connect/${hostId}`) + await once(socket, 'open') + const hello = once(socket, 'message') + socket.send( + JSON.stringify({ type: 'relay-auth', v: 1, mode: 'connect', credential: invite.inviteToken }) + ) + const [raw] = await hello + expect(JSON.parse(raw.toString())).toMatchObject({ type: 'relay-hello', ok: true }) + return socket + } + let candidate: (IdleRegionalRehomeRequest & { sourceCellUrl: string }) | undefined + const prepareMove = async () => { + const issued = await source.assignments.exchangeRegionCorrection( + identity, + { v: 1, action: 'issue-window' }, + assignment.assignmentEpoch + ) + await source.assignments.exchangeRegionCorrection( + identity, + { + v: 1, + action: 'report', + generation: issued.window!.generation, + assignmentEpoch: assignment.assignmentEpoch, + policyVersion: 1, + outcome: 'conclusive', + measurements: { 'us-central1': 180, 'asia-east2': 40 } + }, + assignment.assignmentEpoch + ) + candidate = (await source.assignments.selectIdleRegionalRehomeCandidates(safety()))[0] + expect(candidate).toBeDefined() + return candidate! + } + const move = async () => { + if (!candidate) { + await prepareMove() + } + const { sourceCellUrl, ...request } = candidate! + const address = endpoints.get(new URL(sourceCellUrl).host)!.replace('ws:', 'http:') + const response = await fetch(`${address}/v1/admin/host-idle-rehome`, { + method: 'POST', + headers: { authorization: 'Bearer test-director-token', 'content-type': 'application/json' }, + body: JSON.stringify({ ...request, cohortPercent: 100, directorSafety: safety() }) + }) + const body = (await response.json()) as { v: number; outcome: string } + expect(response.status, JSON.stringify(body)).toBe(200) + return { outcome: body.outcome } + } + return { + source, + target, + pool, + identity, + database, + cells, + attachPhone, + connectDevice: () => connect(`${cells[0]!.url}/v1/connect/${hostId}`), + move, + prepareMove, + heartbeat, + now: () => clock, + advance: (ms: number) => { + clock += ms + }, + received, + failNextCorroboration: () => { + failCorroboration = 1 + }, + pauseCorroboration: (paused: boolean) => { + pauseCorroboration = paused + }, + corroborationFailures: () => corroborationFailures, + targetControlFailures: () => targetControlFailures, + failTarget: () => { + rejectTargetControls = true + const session = target.sessions.get(identity) + if (session?.socket) { + session.socket.terminate() + } + }, + execution, + executionErrors, + mutations: () => execution.mutations(), + reply: () => { + if (!delayedReply) { + throw new Error('no delayed mutation') + } + delayedReply() + } + } +} + +async function echo(socket: WebSocket, value: string) { + const marker = `${value}:${randomUUID()}` + const response = once(socket, 'message') + socket.send(marker) + const [raw] = await response + expect(raw.toString()).toBe(`host:${marker}`) +} + +describe('idle region correction across real relay and desktop WebSockets', () => { + it('releases the empty source and recovers normally when the target never registers', async () => { + const context = await topology() + await context.prepareMove() + context.failTarget() + expect(await context.move()).toEqual({ outcome: 'committed' }) + await expect.poll(() => context.source.sessions.get(context.identity)).toBeNull() + await expect + .poll(async () => + context.database.query( + `SELECT activity_id FROM relay_assignment_activity_leases + WHERE user_id = ? AND relay_host_id = ? AND cell_id = ?`, + [context.identity.userId, context.identity.relayHostId, context.cells[0]!.id] + ) + ) + .toEqual([]) + await expect.poll(context.targetControlFailures).toBeGreaterThan(0) + context.advance(15 * 60_000 + 1) + await context.heartbeat() + expect(await context.source.assignments.abortExpiredEvacuations()).toBe(1) + expect(await context.source.assignments.resolve(context.identity)).toMatchObject({ + cellId: context.cells[0]!.id, + assignmentEpoch: 3 + }) + await expect + .poll(() => context.pool.activeAssignment?.cellUrl, { timeout: 15_000 }) + .toBe(context.cells[0]!.url) + await expect + .poll(() => context.source.sessions.get(context.identity)?.state, { timeout: 15_000 }) + .toBe('active') + const returning = await context.attachPhone(0, 'phone-after-target-failure') + await echo(returning, 'after-target-failure') + expect(await context.mutations()).toBe(0) + expect(context.executionErrors).toEqual([]) + }, 30_000) + + it('rejects an arrival during cutover and restores admissions after a definite failed commit', async () => { + const context = await topology() + await context.prepareMove() + const original = context.source.sessions.get(context.identity)! + let entered!: () => void + let release!: () => void + const committing = new Promise((resolve) => { + entered = resolve + }) + const gate = new Promise((resolve) => { + release = resolve + }) + vi.spyOn(context.source.assignments, 'commitIdleRegionalRehome').mockImplementationOnce( + async () => { + entered() + await gate + throw new Error('simulated_database_unavailable_before_commit') + } + ) + const move = context.move() + await committing + try { + const invite = await context.source.store.createInvite(context.identity, 'racing-phone') + const arriving = context.connectDevice() + const rejected = once(arriving, 'close') + await once(arriving, 'open') + arriving.send( + JSON.stringify({ + type: 'relay-auth', + v: 1, + mode: 'connect', + credential: invite.inviteToken + }) + ) + expect((await rejected)[0]).toBe(4409) + expect(context.source.sessions.get(context.identity)).toBe(original) + } finally { + release() + await move + } + expect(await move).toEqual({ outcome: 'deferred' }) + expect(context.source.sessions.get(context.identity)).toBe(original) + const returning = await context.attachPhone(0, 'retrying-phone') + await echo(returning, 'after-definite-abort') + expect(await context.mutations()).toBe(0) + expect(context.executionErrors).toEqual([]) + }, 30_000) + + it('defers for either connected device, then moves after both disconnect without replaying work', async () => { + const context = await topology() + const phone = await context.attachPhone(0, 'phone') + const tablet = await context.attachPhone(0, 'tablet') + const sourceSession = context.source.sessions.get(context.identity)! + await echo(phone, 'before-cutover') + phone.send('mutation-1') + await expect.poll(context.mutations).toBe(1) + expect(await context.move()).toEqual({ outcome: 'busy' }) + expect(context.source.sessions.get(context.identity)).toBe(sourceSession) + expect((await context.source.assignments.resolve(context.identity))?.cellId).toBe( + context.cells[0]!.id + ) + const acknowledged = once(phone, 'message') + context.reply() + expect((await acknowledged)[0].toString()).toBe('mutation-1-ack') + const phoneClosed = once(phone, 'close') + phone.close() + await phoneClosed + await expect.poll(() => sourceSession.activeSplices.size).toBe(1) + expect(await context.move()).toEqual({ outcome: 'busy' }) + await echo(tablet, 'quiet-tablet-still-connected') + const tabletClosed = once(tablet, 'close') + tablet.close() + await tabletClosed + await expect.poll(() => sourceSession.activeSplices.size).toBe(0) + expect(await context.move()).toEqual({ outcome: 'committed' }) + await expect + .poll(() => context.pool.activeAssignment?.cellUrl, { timeout: 15_000 }) + .toBe(context.cells[1]!.url) + await expect.poll(() => context.source.sessions.get(context.identity)).toBeNull() + const returning = await context.attachPhone(1, 'returning-phone') + await echo(returning, 'after-idle-cutover') + expect(await context.mutations()).toBe(1) + expect(context.executionErrors).toEqual([]) + expect(context.execution.sequence()).toBe(4) + }, 30_000) +}) diff --git a/tests/tools/relay-bench/find-cell.mjs b/tests/tools/relay-bench/find-cell.mjs new file mode 100644 index 00000000000..cc48be054c9 --- /dev/null +++ b/tests/tools/relay-bench/find-cell.mjs @@ -0,0 +1,32 @@ +import { createRequire } from 'node:module' +const WebSocket = createRequire(import.meta.url)('ws') +const hostId = process.argv[2] +const bogus = 'A'.repeat(43) +const probe = (cell) => + new Promise((resolve) => { + const ws = new WebSocket(`wss://${cell}.relay.onorca.dev/v1/connect/${hostId}`, { + perMessageDeflate: false + }) + const t0 = performance.now() + const done = (r) => { + try { + ws.terminate() + } catch {} + resolve({ cell, ms: Math.round(performance.now() - t0), ...r }) + } + ws.on('open', () => + ws.send(JSON.stringify({ type: 'relay-auth', v: 1, mode: 'connect', credential: bogus })) + ) + ws.on('message', (m) => done({ hello: JSON.parse(m.toString()).code })) + ws.on('error', (e) => done({ error: e.code ?? e.message })) + ws.on('close', (c) => done({ close: c })) + setTimeout(() => done({ error: 'timeout' }), 8000) + }) +const cells = Array.from({ length: 30 }, (_, i) => `c${i + 1}`) +const results = await Promise.all(cells.map(probe)) +for (const r of results) { + if (r.hello !== 4409 || process.argv[3]) { + console.log(JSON.stringify(r)) + } +} +console.log('probed', results.length, 'wrong-cell:', results.filter((r) => r.hello === 4409).length) From da5d555259fc63cb0e39b848e677987a98cbf493 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:28:16 -0700 Subject: [PATCH 019/191] refactor(agent-status): delete the runtime's retained row store (PR 1b) (#19785) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(agent-status): plan PR 1b at file level Names the five RuntimeAgentRowStore call sites and what each becomes, why terminalHandle has to be stamped before the store can go, and the one intended behavior change. * feat(agent-status): stamp the pane terminal handle on hook-server rows The runtime's retained row store carried the pty binding two readers need. Put that fact on the row that already owns the pane instead, resolved through the same lookup the renderer-facing IPC boundary runs, so the two surfaces cannot disagree about which terminal a pane is. Carried forward when a later write resolves no handle (only main's OSC parse can), and never persisted: a handle belongs to the runtime that issued it. * refactor(agent-status): route the session-tabs republish off the store `retain()` was not only a duplicate store: its boolean return was the signal that republished `session.tabs` for a status-only transition, which no title change covers (#7970). `hook-status-session-tabs-invalidation.ts` already mirrors that change set plus hook restore provenance, so route the signal off the store rather than keep a second comparator. Adds the status-drop arm a user dismissal emits, which the pane-clear fan-out deliberately skips — now load-bearing, because a dismissed row leaves the listing at once. Installed on both hosts. orcad had neither the OSC producer nor this signal, so its runtime observed agent status and published it nowhere; deleting the retained copy without wiring it would list no PTY agents there at all. * refactor(agent-status): delete the runtime's duplicate retained row store `RuntimeAgentRowStore` held the same payload the hook server already holds, so the same pane could legitimately read differently in the sidebar, in `worktree ps`, and on the phone. Both of its readers move onto the store's snapshot in `runtime-hook-agent-row-selection.ts`, and `collectRuntimeWorktreePtyAgentSources` loses the retained-versus-hook reconciliation that only existed because two stores could disagree. `ConnectedPtyEvidence` trades its flat pty-id set for `ptyIdByTerminalHandle`, which is how a row still resolves the connected PTY behind it — the working-terminal rollup's match key, and the last rescue for a row whose pane binding a controller incarnation nulled under it. The one intended behavior change: a row the user dismisses on the desktop leaves `worktree ps` and mobile at once instead of lingering until the pty exits. One store means one dismissal. The suites written against the retained store are rewired to a real AgentHookServer rather than deleted, so each still asserts the listing behavior it named. * docs(agent-status): record what PR 1b landed Past tense, plus two corrections to the plan: `terminalHandle` is not the pty id (they are different identifiers, and the explicit-status reader was already comparing against a real handle), and the legacy numeric pane key is a consequence the plan did not name. * fix(agent-status): harden single-store lifecycle * fix(agent-status): preserve mobile terminal rejoin * fix(agent-status): preserve unverifiable remote rows * fix(agent-status): own PTY row lifecycle in hook server * fix(agent-status): preserve state and renew freshness * fix(agent-status): ignore freshness for dismissed identity rows * fix(agent-status): fence orcad observed identities * fix(orcad): always release daemon adapter on cleanup * fix(agent-status): cover remint and headless lifecycle edges * fix agent status identity recovery gaps * fix(agent-status): suppress duplicate child-only row mutation * test(runtime): preserve hook store wiring in transcript harness --------- Co-authored-by: Merge Sim --- docs/reference/agent-status-store.md | 177 +++++++--- ...-awake-service-platform-assertions.test.ts | 1 + src/main/agent-awake-service.test.ts | 30 ++ src/main/agent-awake-service.ts | 88 ++--- src/main/agent-awake-status-lease.ts | 106 ++++++ ...hook-provider-session-invalidation.test.ts | 68 ---- .../hook-provider-session-invalidation.ts | 43 --- ...k-status-session-tabs-invalidation.test.ts | 101 ------ .../hook-status-session-tabs-invalidation.ts | 65 ---- ...hook-status-session-tabs-republish.test.ts | 139 ++++++++ .../hook-status-session-tabs-republish.ts | 67 ++++ .../server-ingest-terminal-status.test.ts | 48 ++- .../server-start-failure-lifecycle.test.ts | 167 ++++++++++ .../server-status-listener-fanout.test.ts | 33 ++ src/main/agent-hooks/server.ts | 3 + .../server/server-authority-aliases.ts | 20 +- .../server/server-authority-fences.ts | 25 +- src/main/agent-hooks/server/server-cleanup.ts | 9 +- .../server/server-ingest-terminal.ts | 70 +++- .../agent-hooks/server/server-lifecycle.ts | 101 +++--- .../agent-hooks/server/server-listeners.ts | 22 ++ .../agent-hooks/server/server-persistence.ts | 3 + src/main/agent-hooks/server/server-reaping.ts | 1 + .../server/server-row-ownership.ts | 132 ++++++++ src/main/agent-hooks/server/server-state.ts | 23 +- .../server/server-status-disposition.ts | 3 +- .../server/server-status-identity.ts | 1 + .../server/server-status-inference.ts | 4 +- .../server/server-status-update.ts | 104 +++++- .../agent-hooks/server/server-tab-cleanup.ts | 28 +- src/main/agent-hooks/server/server-types.ts | 19 ++ .../terminal-handle-row-identity.test.ts | 261 +++++++++++++++ src/main/ipc/agent-hooks.test.ts | 12 +- src/main/ipc/agent-status-row-teardown-ipc.ts | 6 +- src/main/orcad/orcad-entry.ts | 118 ++++--- src/main/orcad/orcad-launch-contract.test.ts | 58 +++- src/main/orcad/orcad-lifecycle.ts | 35 ++ .../agent-status-observed-pane-identity.ts | 25 ++ .../agent-status-store-wiring.test-fixture.ts | 51 +++ .../agent-transcript-pane-test-harness.ts | 5 +- ...le-agent-status-permission-renewal.test.ts | 12 + ...ession-tabs-agent-status-heartbeat.test.ts | 15 + ...ile-session-tabs-agent-status-heartbeat.ts | 81 +++-- .../orca-runtime-apply-tracked-pty-title.ts | 2 +- ...rca-runtime-bind-pty-incarnation-handle.ts | 6 +- ...orca-runtime-build-pty-terminal-summary.ts | 2 +- ...minal-side-effect-command-code-detector.ts | 59 +--- .../orca-runtime-fit-override-listeners.ts | 6 - ...me-get-orchestration-dispatch-authority.ts | 31 +- ...rca-runtime-get-pty-record-for-pane-key.ts | 2 +- .../runtime/orca-runtime-get-worktree-ps.ts | 2 +- ...orca-runtime-has-terminals-for-worktree.ts | 6 + ...ntime-hook-agent-status-projection.test.ts | 153 ++++++++- src/main/runtime/orca-runtime-on-pty-data.ts | 11 +- src/main/runtime/orca-runtime-on-pty-exit.ts | 3 +- ...e-prune-mobile-session-tab-group-layout.ts | 31 +- ...refresh-floating-workspace-pty-liveness.ts | 9 +- ...ntime-serialize-agent-prompt-submission.ts | 3 +- ...ntime-stop-exact-terminals-for-worktree.ts | 30 +- .../orca-runtime-stop-requested-pty-ids.ts | 2 +- .../agent-status-and-waits.spec.ts | 2 + .../headless-snapshots.spec.ts | 2 + .../mobile-session-tabs-part-08.spec.ts | 66 +++- .../mobile-summaries-part-02.spec.ts | 62 ++-- .../mobile-summaries-part-03.spec.ts | 9 +- .../mobile-summaries.spec.ts | 42 ++- .../terminal-handles-part-02.spec.ts | 14 +- .../terminal-handles.spec.ts | 3 +- ...erminal-output-and-worker-recovery.spec.ts | 16 +- .../worktree-ps-agent-row-dismissal.spec.ts | 313 ++++++++++++++++++ ...-touch-mobile-session-tabs-for-worktree.ts | 8 + src/main/runtime/orca-runtime.test.ts | 1 + .../agent-status-producer-census.test.ts | 4 +- .../fleet-status-observed-identity.test.ts | 29 ++ src/main/runtime/runtime-agent-row-store.ts | 125 ------- .../runtime-hook-agent-row-selection.test.ts | 159 +++++++++ .../runtime-hook-agent-row-selection.ts | 135 ++++++++ ...untime-mobile-agent-status-builder.test.ts | 39 +++ .../runtime-mobile-agent-status-builder.ts | 13 +- .../runtime-mobile-agent-status-projection.ts | 31 +- ...time-mobile-session-projection-contract.ts | 4 +- .../runtime-mobile-session-projection.ts | 40 ++- .../runtime/runtime-terminal-contracts.ts | 5 +- ...ime-worktree-agent-rows-structured.test.ts | 7 +- .../runtime/runtime-worktree-agent-rows.ts | 2 +- .../runtime-worktree-agent-sources.test.ts | 81 +++-- .../runtime/runtime-worktree-ps-activity.ts | 14 +- .../runtime-worktree-pty-agent-sources.ts | 72 +--- ...ree-structured-agent-rows-liveness.test.ts | 7 +- ...rminal-interactive-wait-visibility.test.ts | 13 +- ...ay-session-agent-hooks.integration.test.ts | 15 +- src/main/ssh/ssh-relay-session.ts | 5 +- .../headless-pty-hydration-ordering.test.ts | 60 ++++ src/main/startup/main-process-observers.ts | 56 +--- .../startup/main-process-runtime-service.ts | 1 - src/main/startup/main-process-state.ts | 4 - .../agent-hook-listener/listener-event.ts | 4 + ...chestration-fleet-agent-status-evidence.ts | 9 +- ...-tabs-decorative-title-fanout.unit.test.ts | 7 +- ...n-tabs-rich-status-boundaries.unit.test.ts | 17 +- tests/e2e/worktree-switch-first-paint.spec.ts | 4 +- 101 files changed, 3246 insertions(+), 992 deletions(-) create mode 100644 src/main/agent-awake-status-lease.ts delete mode 100644 src/main/agent-hooks/hook-provider-session-invalidation.test.ts delete mode 100644 src/main/agent-hooks/hook-provider-session-invalidation.ts delete mode 100644 src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts delete mode 100644 src/main/agent-hooks/hook-status-session-tabs-invalidation.ts create mode 100644 src/main/agent-hooks/hook-status-session-tabs-republish.test.ts create mode 100644 src/main/agent-hooks/hook-status-session-tabs-republish.ts create mode 100644 src/main/agent-hooks/server-start-failure-lifecycle.test.ts create mode 100644 src/main/agent-hooks/server/server-row-ownership.ts create mode 100644 src/main/agent-hooks/terminal-handle-row-identity.test.ts create mode 100644 src/main/orcad/orcad-lifecycle.ts create mode 100644 src/main/runtime/agent-status-store-wiring.test-fixture.ts create mode 100644 src/main/runtime/orca-runtime-tests/worktree-ps-agent-row-dismissal.spec.ts delete mode 100644 src/main/runtime/runtime-agent-row-store.ts create mode 100644 src/main/runtime/runtime-hook-agent-row-selection.test.ts create mode 100644 src/main/runtime/runtime-hook-agent-row-selection.ts create mode 100644 src/main/runtime/runtime-mobile-agent-status-builder.test.ts diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index f1068a0d993..8f1439c2122 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -12,7 +12,7 @@ this order, each independently shippable: 3. shared: one worktree-status rollup and one freshness rule for every reader. The PR that carries this document is PR 1a. Sections below are grouped under -the step that delivers them; only PR 1a has landed. +the step that delivers them; PR 1a and PR 1b have landed. ## The problem this solves @@ -24,11 +24,11 @@ the structured-session mapping and nothing else. An audit on 2026-09-09 found six producers and three consumers, and three separate copies of the same row inside the main process alone: -| Main-process copy | Keyed by | Owned by | Persisted | Evicted | -| -------------------------------------- | --------- | ---------------------------------------------------------- | ------------------- | ----------------------------- | -| hook server `lastStatusByPaneKey` | paneKey | `src/main/agent-hooks/server.ts` | `last-status.json` | tab close, pty exit, hydrate | -| runtime `RuntimeAgentRowStore` | paneKey | `src/main/runtime/runtime-agent-row-store.ts` | no | pty exit only | -| structured feed `published` | sessionId | `src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts` | no | never (a broadcast cache) | +| Main-process copy | Keyed by | Owned by | Persisted | Evicted | +| --------------------------------- | --------- | --------------------------------------------------------------------------------- | ------------------ | ---------------------------- | +| hook server `lastStatusByPaneKey` | paneKey | `src/main/agent-hooks/server.ts` | `last-status.json` | tab close, pty exit, hydrate | +| runtime `RuntimeAgentRowStore` | paneKey | `runtime-agent-row-store.ts` (deleted in PR 1b) | no | pty exit only | +| structured feed `published` | sessionId | `src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts` | no | never (a broadcast cache) | The second copy is a duplicate write: the OSC status parsed in main is forwarded to the hook server _and_ retained in the runtime store from the same @@ -92,14 +92,14 @@ The structured feed keeps its job of projecting a session's journal into a summary and streaming it to subscribers. On every publish it additionally ingests the summary into the hook server as a status row: -| Row field | From | -| ----------------- | ------------------------------------------------------------- | -| `paneKey` | `structuredAgentSessionPaneKey(tabId, sessionId)`, the key the renderer already uses; its leaf is UUID-shaped so pane-key validation accepts it | -| `tabId` | `structuredAgentSessionTabId(sessionId)` | -| `worktreeId` | `summary.workspaceId` (a folder workspace id is a valid value) | -| `state` | `structuredAgentSessionStatusState(summary.status)`, the mapping #19217 shared | -| `structuredHost` | `'owned'` while `summary.hostExecutionOwned` is set, otherwise `'held'`; `worktree ps` derives its row's `structuredHostOwned` from it | -| prompt, tool, last message, model, provider session | the summary's fields | +| Row field | From | +| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `paneKey` | `structuredAgentSessionPaneKey(tabId, sessionId)`, the key the renderer already uses; its leaf is UUID-shaped so pane-key validation accepts it | +| `tabId` | `structuredAgentSessionTabId(sessionId)` | +| `worktreeId` | `summary.workspaceId` (a folder workspace id is a valid value) | +| `state` | `structuredAgentSessionStatusState(summary.status)`, the mapping #19217 shared | +| `structuredHost` | `'owned'` while `summary.hostExecutionOwned` is set, otherwise `'held'`; `worktree ps` derives its row's `structuredHostOwned` from it | +| prompt, tool, last message, model, provider session | the summary's fields | Sessions with no persisted turn (`status === null`) produce no row, matching what the chat shows. When the host revokes live ownership the row is re-set @@ -151,7 +151,7 @@ sits at the file-length cap. The structured adapter added in #19217 is deleted, and structured rows reach `worktree ps` through the same snapshot as every other row. The retained-versus-hook reconciliation in `collectRuntimeWorktreePtyAgentSources` -stays until PR 1b removes the store that feeds it. What this step settles is +stayed until PR 1b removed the store that fed it. What this step settles is the admission gate that decides which rows a worktree listing may show: - a hook or OSC row needs its tab mirrored or a connected pty, as today, and @@ -186,22 +186,80 @@ pane key two writers. Removing that filter is the first step of PR 2. ## PR 1b: the runtime's retained row store is deleted -Not yet implemented; `RuntimeAgentRowStore` and the retained-versus-hook -reconciliation it feeds are both still in place after PR 1a. +Landed. `RuntimeAgentRowStore` is gone, and with it the retained-versus-hook +reconciliation in `collectRuntimeWorktreePtyAgentSources`. The hook server's +store is now the only main-process copy of a PTY agent's row. -`RuntimeAgentRowStore` keeps the same payload the hook server already holds. -Its only extra is the pty id, used to clear rows on exit and as a fallback key -for the mobile projection. PR 1b will stamp `terminalHandle` on OSC-ingested -rows from the runtime event's `ptyId`, and rewrite the three readers over the -hook server's snapshot: +### The five call sites -- `worktree ps` reads `getStatusSnapshot()` directly; -- `getFreshExplicit` already consults hook rows; it drops the retained input; -- `getFreshForMobile` matches on pane key, then on `terminalHandle`. +| Call site | Before | After | +| ------------------------------------------------------------------------------ | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `orca-runtime-create-terminal-side-effect-command-code-detector.ts` `retain()` | second write of the OSC payload already sent to the hook server | deleted; the event now carries the pane's `terminalHandle` and the hook ingest keeps the only copy | +| `...command-code-detector.ts` `clearPty()` | drops rows on pty exit | deleted; pane teardown already clears the hook row | +| `orca-runtime-get-worktree-ps.ts` `values()` | fed `retainedSnapshots` | deleted; the reader keeps only `hookSnapshots` | +| `orca-runtime-serialize-agent-prompt-submission.ts` `getFreshExplicit()` | retained row first, hook rows second | `selectFreshExplicitAgentStatus`, hook rows only | +| `orca-runtime-prune-mobile-session-tab-group-layout.ts` `getFreshForMobile()` | pane key, then pty id | `selectFreshAgentRowForMobileTab`: pane key, then `terminalHandle` | -One behavior change will follow and is intended: a row the user dismisses on -the desktop disappears from `worktree ps` and the phone at the same time, -instead of lingering until the pty exits. +Both readers moved into `runtime-hook-agent-row-selection.ts`, which also owns +`RuntimeAgentRowSnapshot` now that nothing retains one. + +### `terminalHandle` is the row's join back to its terminal + +The retained store's only real extra was the pty id, and two readers used it. +The plan said to stamp the event's `ptyId` into `terminalHandle`; that was +wrong. A terminal handle (`term_`) and a pty id are different +identifiers, and `getFreshExplicit` was already comparing hook rows against a +real handle. What landed instead: + +- `AgentHookEventPayload` and the runtime's terminal-status event gained an + optional `terminalHandle`. The detector resolves it once per chunk through + `getAgentStatusTerminalHandleForPaneKey` — the same lookup the renderer-facing + IPC boundary already runs for every row, so the two surfaces cannot disagree + about which terminal a pane is. +- `applyNormalizedStatus` carries the handle forward when an incoming event + resolves none. Only main's OSC parse can resolve one, so an HTTP hook post for + the same pane would otherwise erase it. +- It is never persisted. A handle belongs to the runtime that issued it, and a + hydrated one could only rejoin a row to somebody else's terminal. +- `toAgentStatusIpcPayload` publishes it, which also makes `getFreshExplicit`'s + long-dead handle comparison live: the runtime reads raw snapshot rows, and + before this nothing ever stamped the field on them. + +`worktree ps` uses it too. `ConnectedPtyEvidence` traded its flat `ptyIds` set +for `ptyIdByTerminalHandle`, so a row still resolves the connected PTY behind +it — which is both the working-terminal rollup's match key and the last rescue +for a row whose pane binding was nulled by a controller incarnation change. + +### The change detector had to move with the store + +`retain()` was not only a store: its boolean return was the signal that +republished `session.tabs` for a status-only transition, which no title change +covers (#7970). `hook-status-session-tabs-invalidation.ts` already mirrors that +projection change set, including restore provenance and terminal-handle joins, +so the replacement was to route the signal off the store rather than build a +second comparator. +`installHookStatusSessionTabsRepublish` now owns all three arms — enriched +status, pane clear, and the status-drop tap a dismissal emits — and both hosts +install it. + +### Both hosts, not just the desktop one + +`orcad` constructed its runtime with no `onTerminalAgentStatus`, so main's OSC +parse never reached the store there and the retained copy was the only carrier. +Deleting it without wiring orcad would have made a headless host list no PTY +agents at all. `orcad-entry.ts` now binds the producer and installs the +republish signal, alongside the snapshot and structured sink it already had. + +### The intended behavior change + +A row the user dismisses on the desktop leaves `worktree ps` and the phone at +once, instead of lingering until the pty exits. One store means one dismissal. + +Legacy numeric pane keys remain a bounded compatibility case. Persisted layouts +register aliases to their stable leaf owners; an in-process OSC observation may +also retain a numeric key only when the runtime supplies the matching tab, PTY, +and terminal handle. HTTP and relay ingress still require a stable key or a +registered alias, and numeric rows are never persisted. ## PR 2: the renderer subscribes @@ -211,14 +269,14 @@ unmount cleanup becomes a tab-close signal to the host. The IPC applicator is the single writer for observed status. The 2026-09-09 audit sorted the other writers: -| Writer | Disposition | -| --------------------------------------------------------------- | -------------------------------------------------- | -| Command Code output seeds, parked-pane seeds, pty-exit removal | delete; main already emits the same facts | -| structured bridge status writes | delete; main now publishes the row | -| launch placeholder seeds (a user launched an agent with a prompt) | keep for now; main holds the launch config and can seed later | -| dismissal, acknowledgement, unmount | keep; user facts and component lifecycle | -| remote-runtime OSC parse (bytes never transit local main) | keep, fenced behind the host's published row once the host is new enough; rule 3 of the wire doc applies | -| web-session mirror receipt clock | keep; the decay rule needs both clocks from one machine | +| Writer | Disposition | +| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| Command Code output seeds, parked-pane seeds, pty-exit removal | delete; main already emits the same facts | +| structured bridge status writes | delete; main now publishes the row | +| launch placeholder seeds (a user launched an agent with a prompt) | keep for now; main holds the launch config and can seed later | +| dismissal, acknowledgement, unmount | keep; user facts and component lifecycle | +| remote-runtime OSC parse (bytes never transit local main) | keep, fenced behind the host's published row once the host is new enough; rule 3 of the wire doc applies | +| web-session mirror receipt clock | keep; the decay rule needs both clocks from one machine | The Command Code done-settle window is renderer policy with no main equivalent. PR 2 either moves it into main's detector or leaves it, and says @@ -242,14 +300,55 @@ call it. - Hydration honesty: a restored non-done row is `restoredUnconfirmed` and is never fresh. +## PR 1b reliability contract + +- **Invariant (`agent-session.status-host-ownership`):** each execution host has + one agent-status store; OSC, hooks, and structured sessions write it, while + desktop, `worktree ps`, and mobile only project it. Dismissal, certified PTY + exit, and provider-generation replacement remove the same row everywhere; + transport loss alone removes nothing. +- **Failure source:** the deleted runtime row store duplicated OSC observations, + keyed them by a different terminal identity, and outlived a dismissal from the + hook store. Relay replay could also make old evidence look fresh when readers + used its new delivery timestamp. +- **Oracle:** one OSC observation appears through the hook snapshot in + `worktree ps` and mobile, and one store dismissal removes it from both without + stopping the PTY. Focused tests also require leaf/incarnation-handle rejoin, + legacy numeric-pane compatibility, certified-exit and provider-generation + cleanup, evidence-age freshness, and exactly-once startup/stop teardown. +- **Gate:** `terminal-performance.osc-status-scan-budget` covers the unchanged + bounded OSC parser and the runtime projection. There is not yet a dedicated + blocking multi-surface status-store gate; the focused suites below are the + accepted gap until they accumulate reliability-gate soak evidence. +- **Provider/platform coverage:** local and daemon-backed PTYs are covered by + runtime tests, and SSH relay loss/replay semantics by relay integration tests. + The projection is shared by git worktrees and folder workspaces. WSL uses the + same store and admission code but has no live run here; Linux and Windows + runtime execution, native mobile clients, and mixed-version paired clients + remain validation gaps. +- **Performance budget:** publication stays event-driven with no new polling or + subprocesses. One mobile projection clones the status snapshot once, builds + pane/handle indexes once, and has a deterministic call-count test; lifecycle + cleanup is bounded by the existing status and handle inventories, and orcad + tests prove listeners clean up once on failed startup and repeated stop. +- **Diagnostics:** existing hook-listener errors name the pane and PTY, while + status-store tests pin delivery versus evidence clocks. No new telemetry or + raw terminal data is emitted. +- **Residual gaps:** rendered Electron/mobile behavior, live SSH reconnect, and + Linux/Windows/WSL execution require the platform QA pass. The current + cross-version gate does not cover `session.tabs` content. + ## Verification - Unit: ingest a structured summary and read it back through `getStatusSnapshot`, `worktree ps`, and the mobile projection; assert the serializer never writes a row carrying `structuredHost`; assert a hydrated file that somehow contains one is dropped. -- Unit: the existing `worktree ps` suites pass unchanged, which is the - characterization that will show PR 1b's deletion of the retained store - changed no listing. +- Unit: the `worktree ps` suites written against the retained store are rewired + to a real `AgentHookServer` (`agent-status-store-wiring.test-fixture.ts`) + rather than deleted, so each still asserts the listing behavior it named. The + dismissal change is pinned end to end in + `orca-runtime-tests/worktree-ps-agent-row-dismissal.spec.ts`, which fails with + the retained store restored. - Live: the parity check from #19217 (working, done, close, reload) repeated against the merged store, with both surfaces read from the one row. diff --git a/src/main/agent-awake-service-platform-assertions.test.ts b/src/main/agent-awake-service-platform-assertions.test.ts index 7b3566b322f..231b1aaaf13 100644 --- a/src/main/agent-awake-service-platform-assertions.test.ts +++ b/src/main/agent-awake-service-platform-assertions.test.ts @@ -16,6 +16,7 @@ vi.mock('electron', () => ({ function workingStatus(): AgentAwakeStatus { return { + paneKey: 'pane-1', state: 'working', receivedAt: 1_000, observedInCurrentRuntime: true diff --git a/src/main/agent-awake-service.test.ts b/src/main/agent-awake-service.test.ts index d1792e665fd..12dc5abaa63 100644 --- a/src/main/agent-awake-service.test.ts +++ b/src/main/agent-awake-service.test.ts @@ -16,6 +16,7 @@ vi.mock('electron', () => ({ function workingStatus(overrides: Partial = {}): AgentAwakeStatus { return { + paneKey: 'pane-1', state: 'working', receivedAt: 1_000, observedInCurrentRuntime: true, @@ -279,6 +280,35 @@ describe('AgentAwakeService', () => { service.dispose() }) + it('renews a working lease across two hours without semantic status churn', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const blocker = createBlocker() + const service = createService(() => Date.now(), blocker) + const listener = vi.fn() + service.subscribe(listener) + service.setMode('auto') + service.setStatuses([workingStatus()]) + + for (let index = 0; index < 5; index += 1) { + vi.advanceTimersByTime(30 * 60 * 1000) + service.observeStatusFreshness( + workingStatus({ receivedAt: Date.now(), observedInCurrentRuntime: true }) + ) + } + + expect(Date.now()).toBeGreaterThan(1_000 + AGENT_AWAKE_STATUS_STALE_AFTER_MS) + expect(service.getStatus()).toEqual({ mode: 'auto', active: true }) + expect(blocker.stop).not.toHaveBeenCalled() + expect(listener).toHaveBeenCalledTimes(2) + + vi.advanceTimersByTime(AGENT_AWAKE_STATUS_STALE_AFTER_MS) + expect(service.getStatus()).toEqual({ mode: 'auto', active: true }) + vi.advanceTimersByTime(1) + expect(service.getStatus()).toEqual({ mode: 'auto', active: false }) + service.dispose() + }) + it('keeps the blocker id when stop fails and Electron reports it is still started', () => { const blocker = createBlocker() blocker.stop.mockImplementation(() => { diff --git a/src/main/agent-awake-service.ts b/src/main/agent-awake-service.ts index 6be27e9d0e6..45db4e8608b 100644 --- a/src/main/agent-awake-service.ts +++ b/src/main/agent-awake-service.ts @@ -1,5 +1,4 @@ import { powerMonitor, powerSaveBlocker } from 'electron' -import type { AgentStatusState } from '../shared/agent-status-types' import { normalizeComputerAwakeMode, type ComputerAwakeMode, @@ -7,14 +6,12 @@ import { } from '../shared/computer-awake-mode' import { LinuxLidSleepAssertion } from './linux-lid-sleep-assertion' import { MacosSystemSleepAssertion } from './macos-system-sleep-assertion' +import { AgentAwakeStatusLease, type AgentAwakeStatus } from './agent-awake-status-lease' -export const AGENT_AWAKE_STATUS_STALE_AFTER_MS = 2 * 60 * 60 * 1000 - -export type AgentAwakeStatus = { - state: AgentStatusState - receivedAt: number - observedInCurrentRuntime: boolean -} +export { + AGENT_AWAKE_STATUS_STALE_AFTER_MS, + type AgentAwakeStatus +} from './agent-awake-status-lease' type PowerSaveBlocker = { start: (type: 'prevent-app-suspension' | 'prevent-display-sleep') => number @@ -47,9 +44,7 @@ type AgentAwakeServiceOptions = { export class AgentAwakeService { private mode: ComputerAwakeMode = 'off' - private statuses: AgentAwakeStatus[] = [] private blockerId: number | null = null - private staleTimer: ReturnType | null = null private readonly statusListeners = new Set<(status: ComputerAwakeStatus) => void>() private lastPublishedStatus: ComputerAwakeStatus | null = null private readonly blocker: PowerSaveBlocker @@ -58,12 +53,14 @@ export class AgentAwakeService { private readonly macosAssertion: PlatformAwakeAssertion private readonly platform: NodeJS.Platform private readonly now: () => number + private readonly statusLease: AgentAwakeStatusLease private readonly unsubscribeResume: (() => void) | null constructor(options: AgentAwakeServiceOptions = {}) { this.blocker = options.blocker ?? powerSaveBlocker this.logger = options.logger ?? console this.now = options.now ?? Date.now + this.statusLease = new AgentAwakeStatusLease(this.now, () => this.refresh('stale-expiry')) // Windows lid close is intentionally not modeled as an assertion here: // keeping it awake requires mutating the user's global power plan. this.linuxAssertion = @@ -105,11 +102,20 @@ export class AgentAwakeService { } setStatuses(statuses: AgentAwakeStatus[]): void { - // Copy the array, not every row: the hook server allocates each row fresh per event. - this.statuses = [...statuses] + this.statusLease.replace(statuses) this.refresh('status-change') } + /** Renew one accepted observation without rescanning every active agent. */ + observeStatusFreshness(status: AgentAwakeStatus): void { + if (!this.statusLease.renew(status)) { + return + } + if (this.mode === 'auto' && this.lastPublishedStatus?.active !== true) { + this.applyAwakeDecision('status-freshness', 1) + } + } + getStatus(): ComputerAwakeStatus { const workingAgentCount = this.getEligibleRunningStatusCount() return { @@ -129,7 +135,7 @@ export class AgentAwakeService { } dispose(): void { - this.clearStaleTimer() + this.statusLease.dispose() this.unsubscribeResume?.() this.stopBlocker('dispose') this.macosAssertion.dispose() @@ -137,8 +143,11 @@ export class AgentAwakeService { } private refresh(reason: string): void { - this.scheduleStaleTimer() const runningStatusCount = this.getEligibleRunningStatusCount() + this.applyAwakeDecision(reason, runningStatusCount) + } + + private applyAwakeDecision(reason: string, runningStatusCount: number): void { const shouldBlock = this.mode === 'on' || (this.mode === 'auto' && runningStatusCount > 0) if (shouldBlock) { const macosAssertionActive = this.startMacosAssertion(reason) @@ -171,56 +180,7 @@ export class AgentAwakeService { } private getEligibleRunningStatusCount(): number { - const now = this.now() - // Counted in place: the filtered array was only ever measured, and this runs per hook event. - return this.statuses.reduce((count, s) => count + (this.isWakeEligible(s, now) ? 1 : 0), 0) - } - - private isWakeEligible(status: AgentAwakeStatus, now: number): boolean { - return ( - status.observedInCurrentRuntime && - status.state === 'working' && - Number.isFinite(status.receivedAt) && - now - status.receivedAt <= AGENT_AWAKE_STATUS_STALE_AFTER_MS - ) - } - - private scheduleStaleTimer(): void { - this.clearStaleTimer() - const now = this.now() - let earliestExpiry: number | null = null - for (const status of this.statuses) { - if ( - !status.observedInCurrentRuntime || - status.state !== 'working' || - !Number.isFinite(status.receivedAt) - ) { - continue - } - const expiry = status.receivedAt + AGENT_AWAKE_STATUS_STALE_AFTER_MS - if (expiry <= now) { - continue - } - earliestExpiry = earliestExpiry === null ? expiry : Math.min(earliestExpiry, expiry) - } - if (earliestExpiry === null) { - return - } - this.staleTimer = setTimeout(() => { - this.staleTimer = null - this.refresh('stale-expiry') - }, earliestExpiry - now) - if (typeof this.staleTimer.unref === 'function') { - this.staleTimer.unref() - } - } - - private clearStaleTimer(): void { - if (!this.staleTimer) { - return - } - clearTimeout(this.staleTimer) - this.staleTimer = null + return this.statusLease.countEligible() } private startBlocker(reason: string, runningStatusCount: number): void { diff --git a/src/main/agent-awake-status-lease.ts b/src/main/agent-awake-status-lease.ts new file mode 100644 index 00000000000..327bbc201ea --- /dev/null +++ b/src/main/agent-awake-status-lease.ts @@ -0,0 +1,106 @@ +import type { AgentStatusState } from '../shared/agent-status-types' + +export const AGENT_AWAKE_STATUS_STALE_AFTER_MS = 2 * 60 * 60 * 1000 + +export type AgentAwakeStatus = { + paneKey: string + state: AgentStatusState + receivedAt: number + observedInCurrentRuntime: boolean +} + +export class AgentAwakeStatusLease { + private statuses = new Map() + private timer: ReturnType | null = null + private timerExpiresAt: number | null = null + + constructor( + private readonly now: () => number, + private readonly onExpiry: () => void + ) {} + + replace(statuses: AgentAwakeStatus[]): void { + this.statuses = new Map(statuses.map((status) => [status.paneKey, status])) + this.scheduleNextExpiry() + } + + /** Returns whether the renewed row is currently wake-eligible. */ + renew(status: AgentAwakeStatus): boolean { + this.statuses.set(status.paneKey, status) + const now = this.now() + if (!this.isEligible(status, now)) { + return false + } + this.scheduleAt(status.receivedAt + AGENT_AWAKE_STATUS_STALE_AFTER_MS, now) + return true + } + + countEligible(): number { + const now = this.now() + let count = 0 + for (const status of this.statuses.values()) { + if (this.isEligible(status, now)) { + count += 1 + } + } + return count + } + + dispose(): void { + this.clearTimer() + } + + private isEligible(status: AgentAwakeStatus, now: number): boolean { + return ( + status.observedInCurrentRuntime && + status.state === 'working' && + Number.isFinite(status.receivedAt) && + now - status.receivedAt <= AGENT_AWAKE_STATUS_STALE_AFTER_MS + ) + } + + private scheduleNextExpiry(): void { + this.clearTimer() + const now = this.now() + let earliestExpiry: number | null = null + for (const status of this.statuses.values()) { + if (!this.isEligible(status, now)) { + continue + } + const expiry = status.receivedAt + AGENT_AWAKE_STATUS_STALE_AFTER_MS + const nextCheckAt = expiry === now ? now + 1 : expiry + earliestExpiry = earliestExpiry === null ? nextCheckAt : Math.min(earliestExpiry, nextCheckAt) + } + if (earliestExpiry !== null) { + this.scheduleAt(earliestExpiry, now) + } + } + + private scheduleAt(expiry: number, now: number): void { + if ( + expiry <= now || + (this.timer !== null && this.timerExpiresAt !== null && this.timerExpiresAt <= expiry) + ) { + return + } + this.clearTimer() + this.timerExpiresAt = expiry + this.timer = setTimeout(() => { + this.timer = null + this.timerExpiresAt = null + this.scheduleNextExpiry() + this.onExpiry() + }, expiry - now) + if (typeof this.timer.unref === 'function') { + this.timer.unref() + } + } + + private clearTimer(): void { + if (this.timer !== null) { + clearTimeout(this.timer) + this.timer = null + } + this.timerExpiresAt = null + } +} diff --git a/src/main/agent-hooks/hook-provider-session-invalidation.test.ts b/src/main/agent-hooks/hook-provider-session-invalidation.test.ts deleted file mode 100644 index 15338c20056..00000000000 --- a/src/main/agent-hooks/hook-provider-session-invalidation.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { createHookProviderSessionInvalidator } from './hook-provider-session-invalidation' - -describe('createHookProviderSessionInvalidator', () => { - it('names the worktree the first time a pane reports a provider session', () => { - const collect = createHookProviderSessionInvalidator() - - expect(collect([{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w1' }])).toEqual(['w1']) - }) - - it('stays quiet while the same session keeps being reported', () => { - const collect = createHookProviderSessionInvalidator() - const rows = [{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w1' }] - collect(rows) - - expect(collect(rows)).toEqual([]) - }) - - it('names the worktree when a pane relaunches under a new session', () => { - const collect = createHookProviderSessionInvalidator() - collect([{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w1' }]) - - expect(collect([{ paneKey: 'tab:leaf', sessionId: 's2', worktreeId: 'w1' }])).toEqual(['w1']) - }) - - it('names the worktree when a pane loses its session entirely', () => { - const collect = createHookProviderSessionInvalidator() - collect([{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w1' }]) - - expect(collect([])).toEqual(['w1']) - }) - - it('names both worktrees when a pane moves without changing session', () => { - const collect = createHookProviderSessionInvalidator() - collect([{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w1' }]) - - expect(collect([{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w2' }])).toEqual([ - 'w1', - 'w2' - ]) - }) - - it('invalidates when Pi keeps its session id but changes transcript path', () => { - const collect = createHookProviderSessionInvalidator() - collect([ - { paneKey: 'tab:leaf', sessionId: 's1', transcriptPath: '/pi/a.jsonl', worktreeId: 'w1' } - ]) - - expect( - collect([ - { paneKey: 'tab:leaf', sessionId: 's1', transcriptPath: '/pi/b.jsonl', worktreeId: 'w1' } - ]) - ).toEqual(['w1']) - }) - - it('retains the known worktree when a later hook omits it', () => { - const collect = createHookProviderSessionInvalidator() - collect([{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w1' }]) - - expect(collect([{ paneKey: 'tab:leaf', sessionId: 's2' }])).toEqual(['w1']) - }) - - it('ignores a session with no worktree to invalidate', () => { - const collect = createHookProviderSessionInvalidator() - - expect(collect([{ paneKey: 'tab:leaf', sessionId: 's1' }])).toEqual([]) - }) -}) diff --git a/src/main/agent-hooks/hook-provider-session-invalidation.ts b/src/main/agent-hooks/hook-provider-session-invalidation.ts deleted file mode 100644 index 6ef1e6f7d63..00000000000 --- a/src/main/agent-hooks/hook-provider-session-invalidation.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { AgentHookProviderSessionIdentity } from './server' - -type KnownSession = { sessionId: string; transcriptPath?: string; worktreeId: string } - -/** Names worktrees whose hook-reported resume identity changed. */ -export function createHookProviderSessionInvalidator(): ( - identities: readonly AgentHookProviderSessionIdentity[] -) => string[] { - let known = new Map() - return (identities) => { - const next = new Map() - const changedWorktrees = new Set() - for (const identity of identities) { - const previous = known.get(identity.paneKey) - const worktreeId = identity.worktreeId ?? previous?.worktreeId - if (!worktreeId) { - continue - } - next.set(identity.paneKey, { - sessionId: identity.sessionId, - ...(identity.transcriptPath ? { transcriptPath: identity.transcriptPath } : {}), - worktreeId - }) - if ( - previous?.sessionId !== identity.sessionId || - previous?.transcriptPath !== identity.transcriptPath || - previous?.worktreeId !== worktreeId - ) { - if (previous?.worktreeId !== worktreeId) { - changedWorktrees.add(previous?.worktreeId ?? worktreeId) - } - changedWorktrees.add(worktreeId) - } - } - for (const [paneKey, previous] of known) { - if (!next.has(paneKey)) { - changedWorktrees.add(previous.worktreeId) - } - } - known = next - return [...changedWorktrees] - } -} diff --git a/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts b/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts deleted file mode 100644 index fc2482cbdb0..00000000000 --- a/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' -import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' -import { createHookStatusSessionTabsInvalidator } from './hook-status-session-tabs-invalidation' - -function working( - overrides: Partial = {}, - payload: Partial = {} -): AgentHookEventPayload { - return { - paneKey: 'tab:leaf', - connectionId: null, - payload: { state: 'working', prompt: 'fix the tests', agentType: 'claude', ...payload }, - ...overrides - } -} - -describe('createHookStatusSessionTabsInvalidator', () => { - it('invalidates the first time a pane reports', () => { - const changed = createHookStatusSessionTabsInvalidator() - - expect(changed(working())).toBe(true) - }) - - it('stays quiet while the same status keeps being pinged', () => { - const changed = createHookStatusSessionTabsInvalidator() - changed(working()) - - expect(changed(working())).toBe(false) - }) - - it('invalidates when a restored row is confirmed by live activity', () => { - const changed = createHookStatusSessionTabsInvalidator() - changed(working({ restoredUnconfirmed: true })) - - expect(changed(working())).toBe(true) - }) - - it.each([ - ['state', { state: 'waiting' as const }], - ['workingMode', { workingMode: 'monitoring' as const }], - ['prompt', { prompt: 'ship it' }], - ['agentType', { agentType: 'codex' }], - ['toolName', { toolName: 'Bash' }], - ['interactivePrompt', { interactivePrompt: '{"questions":[]}' }], - ['interrupted', { interrupted: true }] - ])('invalidates when %s changes', (_field, payload) => { - const changed = createHookStatusSessionTabsInvalidator() - changed(working()) - - expect(changed(working({}, payload))).toBe(true) - }) - - it('invalidates when the completion stamp is added, changed, or removed', () => { - const changed = createHookStatusSessionTabsInvalidator() - changed(working()) - - expect(changed(working({}, { turnCompletedAt: 100 }))).toBe(true) - expect(changed(working({}, { turnCompletedAt: 200 }))).toBe(true) - expect(changed(working())).toBe(true) - }) - - it('invalidates when the assistant body changes', () => { - const changed = createHookStatusSessionTabsInvalidator() - changed(working({}, { lastAssistantMessage: 'First answer' })) - - expect(changed(working({}, { lastAssistantMessage: 'Corrected answer' }))).toBe(true) - }) - - it('ignores resume-identity rows, which the provider-session path owns', () => { - const changed = createHookStatusSessionTabsInvalidator() - - expect(changed(working({ providerSessionOnly: true }))).toBe(false) - }) - - it('tracks panes independently', () => { - const changed = createHookStatusSessionTabsInvalidator() - changed(working()) - - expect(changed(working({ paneKey: 'tab:other' }))).toBe(true) - expect(changed(working())).toBe(false) - }) - - it('re-arms a forgotten pane so an identical relaunch still invalidates', () => { - const changed = createHookStatusSessionTabsInvalidator() - changed(working()) - changed.forgetPane('tab:leaf') - - expect(changed(working())).toBe(true) - }) - - it("names an SSH host's panes so a disconnect can republish each of them", () => { - const changed = createHookStatusSessionTabsInvalidator() - changed(working({ connectionId: 'conn-1' })) - changed(working({ paneKey: 'tab:remote', connectionId: 'conn-1' })) - changed(working({ paneKey: 'tab:local' })) - - expect(changed.forgetConnection('conn-1').sort()).toEqual(['tab:leaf', 'tab:remote']) - expect(changed(working({ paneKey: 'tab:local' }))).toBe(false) - }) -}) diff --git a/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts b/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts deleted file mode 100644 index 04902579855..00000000000 --- a/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' -import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' - -type KnownStatus = { - connectionId: string | null - payload: ParsedAgentStatusPayload - restoredUnconfirmed: boolean -} - -/** Reports whether a hook status event changed anything the `session.tabs` - * projection publishes, so a repeated same-state ping costs no snapshot rebuild. - * Mirrors `retainAgentRowSnapshot`'s change set plus hook restore provenance. */ -export function createHookStatusSessionTabsInvalidator(): { - (event: AgentHookEventPayload): boolean - forgetPane: (paneKey: string) => void - forgetConnection: (connectionId: string) => string[] -} { - const known = new Map() - const invalidator = (event: AgentHookEventPayload): boolean => { - // Why: resume-identity rows carry transport placeholders, not status; the - // provider-session invalidator owns their republish. - if (event.providerSessionOnly === true) { - return false - } - const previous = known.get(event.paneKey) - const next = event.payload - const restoredUnconfirmed = event.restoredUnconfirmed === true - known.set(event.paneKey, { - connectionId: event.connectionId, - payload: next, - restoredUnconfirmed - }) - return ( - !previous || - previous.payload.state !== next.state || - previous.payload.workingMode !== next.workingMode || - previous.payload.prompt !== next.prompt || - (previous.payload.agentType ?? null) !== (next.agentType ?? null) || - (previous.payload.toolName ?? null) !== (next.toolName ?? null) || - (previous.payload.interactivePrompt ?? null) !== (next.interactivePrompt ?? null) || - (previous.payload.interrupted ?? false) !== (next.interrupted ?? false) || - (previous.payload.turnCompletedAt ?? null) !== (next.turnCompletedAt ?? null) || - (previous.payload.lastAssistantMessage ?? null) !== (next.lastAssistantMessage ?? null) || - previous.restoredUnconfirmed !== restoredUnconfirmed - ) - } - // Why: a cleared pane must re-arm, else the memo swallows the first event of the - // next agent when it happens to match the one that just went away. - invalidator.forgetPane = (paneKey: string): void => { - known.delete(paneKey) - } - // Why: an SSH disconnect clears a whole host's rows at once and names no pane, so - // the caller needs the pane list back to republish each affected workspace. - invalidator.forgetConnection = (connectionId: string): string[] => { - const forgotten: string[] = [] - for (const [paneKey, status] of known) { - if (status.connectionId === connectionId) { - known.delete(paneKey) - forgotten.push(paneKey) - } - } - return forgotten - } - return invalidator -} diff --git a/src/main/agent-hooks/hook-status-session-tabs-republish.test.ts b/src/main/agent-hooks/hook-status-session-tabs-republish.test.ts new file mode 100644 index 00000000000..150c00a0137 --- /dev/null +++ b/src/main/agent-hooks/hook-status-session-tabs-republish.test.ts @@ -0,0 +1,139 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AgentHookServer } from './server' +import { installHookStatusSessionTabsRepublish } from './hook-status-session-tabs-republish' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-types' +import { + createMobileSessionTabsAgentStatusHeartbeat, + SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS +} from '../runtime/mobile-session-tabs-agent-status-heartbeat' + +const PANE = 'tab-provider:11111111-1111-4111-8111-111111111111' + +function providerOnly(server: AgentHookServer, transcriptPath: string): void { + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-provider', + worktreeId: 'repo::/worktree', + providerSession: { key: 'session_id', id: 'pi-session', transcriptPath }, + providerSessionOnly: true, + payload: { state: 'done', prompt: '', agentType: 'pi' } + }, + null + ) +} + +describe('hook status session-tabs republish', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('delivers provider-only changes and authority retirement from the owner mutation stream', () => { + const server = new AgentHookServer() + const touch = vi.fn() + const uninstall = installHookStatusSessionTabsRepublish(server, () => ({ + getTerminalWorktreeIdForHandle: () => null, + getTerminalWorktreeIdForPaneKey: () => null, + scheduleMobileSessionTabsAgentStatusHeartbeatForWorktree: vi.fn(), + touchMobileSessionTabsForWorktree: touch + })) + try { + providerOnly(server, '/sessions/first.jsonl') + expect(touch).toHaveBeenLastCalledWith('repo::/worktree') + + touch.mockClear() + providerOnly(server, '/sessions/first.jsonl') + expect(touch).not.toHaveBeenCalled() + + providerOnly(server, '/sessions/replaced.jsonl') + expect(touch).toHaveBeenCalledTimes(1) + + touch.mockClear() + server.retirePaneAuthority(PANE) + expect(touch).toHaveBeenCalledTimes(1) + expect(touch).toHaveBeenCalledWith('repo::/worktree') + } finally { + uninstall() + } + }) + + it('deduplicates the old and new ownership of one moved row', () => { + const server = new AgentHookServer() + const touch = vi.fn() + providerOnly(server, '/sessions/first.jsonl') + const uninstall = installHookStatusSessionTabsRepublish(server, () => ({ + getTerminalWorktreeIdForHandle: () => null, + getTerminalWorktreeIdForPaneKey: () => null, + scheduleMobileSessionTabsAgentStatusHeartbeatForWorktree: vi.fn(), + touchMobileSessionTabsForWorktree: touch + })) + try { + server.transferPaneAuthority(PANE, 'tab-new:22222222-2222-4222-8222-222222222222') + expect(touch).toHaveBeenCalledTimes(1) + expect(touch).toHaveBeenCalledWith('repo::/worktree') + } finally { + uninstall() + } + }) + + it('renews mobile freshness across its lease through a bounded heartbeat cadence', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const server = new AgentHookServer() + const publications: number[] = [] + const rowMutations = vi.fn() + const enrichedStatuses = vi.fn() + const semanticStatuses = vi.fn() + let heartbeat: ReturnType + const runtime = { + getTerminalWorktreeIdForHandle: () => null, + getTerminalWorktreeIdForPaneKey: () => null, + scheduleMobileSessionTabsAgentStatusHeartbeatForWorktree: (worktreeId: string) => + heartbeat.scheduleWorktreeHeartbeat(worktreeId), + touchMobileSessionTabsForWorktree: (worktreeId: string) => { + heartbeat.observeWorktreeRefresh(worktreeId) + publications.push(Date.now()) + } + } + heartbeat = createMobileSessionTabsAgentStatusHeartbeat( + () => [], + (worktreeId) => runtime.touchMobileSessionTabsForWorktree(worktreeId) + ) + const uninstall = installHookStatusSessionTabsRepublish(server, () => runtime) + server.subscribeStatusRowMutations(rowMutations) + server.subscribeEnrichedStatus(enrichedStatuses) + server.subscribeStatusChanges(semanticStatuses) + const observation = { + paneKey: PANE, + tabId: 'tab-provider', + worktreeId: 'repo::/worktree', + payload: { state: 'working' as const, prompt: 'active', agentType: 'codex' as const } + } + + try { + server.ingestTerminalStatus(observation) + for (let minute = 1; minute <= 31; minute += 1) { + vi.advanceTimersByTime(60_000) + server.ingestTerminalStatus(observation) + vi.runOnlyPendingTimers() + } + + expect(Date.now()).toBeGreaterThan(1_000 + AGENT_STATUS_STALE_AFTER_MS) + const renewed = server.getStatusSnapshot()[0] + expect(renewed?.state).toBe('working') + expect(Date.now() - renewed!.receivedAt).toBeLessThan(AGENT_STATUS_STALE_AFTER_MS) + expect(publications).toEqual([ + 1_000, + 1_000 + SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS, + 1_000 + SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS * 2 + ]) + expect(rowMutations).toHaveBeenCalledTimes(1) + expect(enrichedStatuses).toHaveBeenCalledTimes(1) + expect(semanticStatuses).toHaveBeenCalledTimes(1) + } finally { + uninstall() + heartbeat.dispose() + server.stop() + } + }) +}) diff --git a/src/main/agent-hooks/hook-status-session-tabs-republish.ts b/src/main/agent-hooks/hook-status-session-tabs-republish.ts new file mode 100644 index 00000000000..b53e4e90501 --- /dev/null +++ b/src/main/agent-hooks/hook-status-session-tabs-republish.ts @@ -0,0 +1,67 @@ +import type { AgentHookServer } from './server' + +type SessionTabsRepublisher = { + getTerminalWorktreeIdForHandle(handle: string): string | null + getTerminalWorktreeIdForPaneKey(paneKey: string): string | null + scheduleMobileSessionTabsAgentStatusHeartbeatForWorktree(worktreeId: string): void + touchMobileSessionTabsForWorktree(worktreeId: string): void +} + +type StatusStore = Pick + +/** + * Republish `session.tabs` whenever a pane's status row changes. + * + * Every producer — hook posts, the relay receivers, and main's own OSC parse — lands in the + * store, so this is the one signal that a pane's published projection is out of date. Nothing + * else republishes on a status-only transition, so a paired client would otherwise keep the + * pane's last projection until an unrelated PTY touch came along (#7970). + */ +export function installHookStatusSessionTabsRepublish( + statusStore: StatusStore, + getRuntime: () => SessionTabsRepublisher | null | undefined +): () => void { + const resolveWorktreeId = ( + identity: { paneKey: string; worktreeId?: string; terminalHandle?: string }, + runtime: SessionTabsRepublisher + ): string | null => + identity.worktreeId ?? + (identity.terminalHandle + ? runtime.getTerminalWorktreeIdForHandle(identity.terminalHandle) + : null) ?? + runtime.getTerminalWorktreeIdForPaneKey(identity.paneKey) + + const unsubscribeMutations = statusStore.subscribeStatusRowMutations((mutation) => { + const runtime = getRuntime() + if (!runtime) { + return + } + const worktreeIds = new Set() + for (const identity of [mutation.before, mutation.after]) { + if (!identity) { + continue + } + const worktreeId = resolveWorktreeId(identity, runtime) + if (worktreeId) { + worktreeIds.add(worktreeId) + } + } + for (const worktreeId of worktreeIds) { + runtime.touchMobileSessionTabsForWorktree(worktreeId) + } + }) + const unsubscribeFreshness = statusStore.subscribeStatusFreshness((status) => { + const runtime = getRuntime() + if (!runtime) { + return + } + const worktreeId = resolveWorktreeId(status, runtime) + if (worktreeId) { + runtime.scheduleMobileSessionTabsAgentStatusHeartbeatForWorktree(worktreeId) + } + }) + return () => { + unsubscribeMutations() + unsubscribeFreshness() + } +} diff --git a/src/main/agent-hooks/server-ingest-terminal-status.test.ts b/src/main/agent-hooks/server-ingest-terminal-status.test.ts index 0ac729fa72e..f54a5c26802 100644 --- a/src/main/agent-hooks/server-ingest-terminal-status.test.ts +++ b/src/main/agent-hooks/server-ingest-terminal-status.test.ts @@ -267,6 +267,7 @@ describe('AgentHookServer ingestTerminalStatus', () => { worktreeId: 'wt-1', connectionId: null, receivedAt: 1_000, + evidenceObservedAt: 1_000, stateStartedAt: 1_000, payload: { state: 'working', @@ -282,6 +283,7 @@ describe('AgentHookServer ingestTerminalStatus', () => { worktreeId: 'wt-1', connectionId: null, receivedAt: 1_000, + evidenceObservedAt: 1_000, stateStartedAt: 1_000, state: 'working', prompt: 'ship it', @@ -294,6 +296,49 @@ describe('AgentHookServer ingestTerminalStatus', () => { } }) + it('accepts a runtime-owned legacy pane without opening legacy relay ingress', () => { + const server = new AgentHookServer() + const event = { + paneKey: 'legacy-tab:7', + tabId: 'legacy-tab', + ptyId: 'legacy-pty', + terminalHandle: 'term_legacy', + worktreeId: 'wt-1', + payload: { state: 'working' as const, prompt: 'legacy task', agentType: 'codex' as const } + } + + server.ingestTerminalStatus(event) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: 'legacy-tab:7', + tabId: 'legacy-tab', + terminalHandle: 'term_legacy', + prompt: 'legacy task' + }) + ]) + server.stop() + }) + + it.each([ + ['PTY id', { ptyId: undefined }], + ['terminal handle', { terminalHandle: undefined }], + ['matching tab', { tabId: 'other-tab' }] + ])('rejects a legacy terminal row without its runtime-owned %s', (_label, overrides) => { + const server = new AgentHookServer() + server.ingestTerminalStatus({ + paneKey: 'legacy-tab:7', + tabId: 'legacy-tab', + ptyId: 'legacy-pty', + terminalHandle: 'term_legacy', + payload: { state: 'working', prompt: 'legacy task', agentType: 'codex' }, + ...overrides + }) + + expect(server.getStatusSnapshot()).toEqual([]) + server.stop() + }) + it('suppresses exact duplicate runtime terminal status observations', () => { vi.useFakeTimers() vi.setSystemTime(1_000) @@ -320,7 +365,8 @@ describe('AgentHookServer ingestTerminalStatus', () => { expect(server.getStatusSnapshot()).toEqual([ expect.objectContaining({ paneKey: PANE, - receivedAt: 1_000, + receivedAt: 1_250, + evidenceObservedAt: 1_250, stateStartedAt: 1_000, state: 'working', prompt: 'same turn' diff --git a/src/main/agent-hooks/server-start-failure-lifecycle.test.ts b/src/main/agent-hooks/server-start-failure-lifecycle.test.ts new file mode 100644 index 00000000000..c4c28f71482 --- /dev/null +++ b/src/main/agent-hooks/server-start-failure-lifecycle.test.ts @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type * as NodeHttp from 'node:http' + +const { createServerMock, getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ + createServerMock: vi.fn(), + getCohortAtEmitMock: vi.fn(), + trackMock: vi.fn() +})) + +vi.mock('node:http', async (importOriginal) => { + const actual = await importOriginal() + createServerMock.mockImplementation(actual.createServer) + return { ...actual, createServer: createServerMock } +}) + +vi.mock('../telemetry/client', () => ({ track: trackMock })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock })) + +import { AgentHookServer, _internals } from './server' +import { makePaneKey } from '../../shared/stable-pane-id' + +const PANE = makePaneKey('tab-lifecycle', '11111111-1111-4111-8111-111111111111') + +beforeEach(() => { + _internals.resetCachesForTests() + createServerMock.mockClear() + trackMock.mockReset() + getCohortAtEmitMock.mockReset() + getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('AgentHookServer startup failure lifecycle', () => { + it('rolls back only transport on bind failure and preserves owner state through retry', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-hook-start-failure-')) + const persisted = new AgentHookServer() + await persisted.start({ env: 'production', userDataPath }) + persisted.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-lifecycle', + worktreeId: 'wt-lifecycle', + payload: { state: 'working', prompt: 'surviving PTY', agentType: 'codex' } + }, + 'ssh-lifecycle' + ) + persisted.stop() + const server = new AgentHookServer() + const rendererListener = vi.fn() + const statusChanges = vi.fn() + const freshness = vi.fn() + const enrichedStatuses = vi.fn() + const rowMutations = vi.fn() + server.setListener(rendererListener) + server.subscribeStatusChanges(statusChanges) + server.subscribeStatusFreshness(freshness) + server.subscribeEnrichedStatus(enrichedStatuses) + server.subscribeStatusRowMutations(rowMutations) + + try { + let startupErrorListener: ((error: Error) => void) | null = null + const failedServer = { + once: vi.fn((event: string, listener: (error: Error) => void) => { + if (event === 'error') { + startupErrorListener = listener + } + return failedServer + }), + off: vi.fn(() => failedServer), + listen: vi.fn(() => { + startupErrorListener?.(new Error('listener unavailable')) + return failedServer + }), + close: vi.fn(() => failedServer) + } + createServerMock.mockImplementationOnce(() => failedServer) + + await expect(server.start({ env: 'production', userDataPath })).rejects.toThrow( + 'listener unavailable' + ) + expect(failedServer.close).toHaveBeenCalledOnce() + expect(server.buildPtyEnv()).toEqual({}) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: PANE, prompt: 'surviving PTY' }) + ]) + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-lifecycle', + worktreeId: 'wt-lifecycle', + payload: { state: 'working', prompt: 'newer in-process state', agentType: 'codex' } + }, + 'ssh-lifecycle' + ) + const duplicateOsc = { + paneKey: PANE, + tabId: 'tab-lifecycle', + worktreeId: 'wt-lifecycle', + connectionId: 'ssh-lifecycle', + payload: { state: 'working' as const, prompt: 'newer in-process state', agentType: 'codex' } + } + server.ingestTerminalStatus(duplicateOsc) + + expect(rendererListener).toHaveBeenCalledTimes(1) + expect(enrichedStatuses).toHaveBeenCalledTimes(1) + expect(rowMutations).toHaveBeenCalledTimes(1) + expect(statusChanges).toHaveBeenCalledTimes(1) + expect(freshness).toHaveBeenCalledTimes(1) + expect( + JSON.parse(readFileSync(server.lastStatusPath!, 'utf8')).entries[PANE].payload.prompt + ).toBe('surviving PTY') + + await server.start({ env: 'production', userDataPath }) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + worktreeId: 'wt-lifecycle', + prompt: 'newer in-process state' + }) + ]) + expect(server.buildPtyEnv()).toMatchObject({ + ORCA_AGENT_HOOK_ENV: 'production', + ORCA_AGENT_HOOK_PORT: expect.any(String), + ORCA_AGENT_HOOK_TOKEN: expect.any(String), + ORCA_AGENT_HOOK_ENDPOINT: server.endpointFilePath + }) + server.ingestTerminalStatus(duplicateOsc) + expect(freshness).toHaveBeenCalledTimes(2) + expect(rendererListener).toHaveBeenCalledTimes(1) + expect(enrichedStatuses).toHaveBeenCalledTimes(1) + expect(rowMutations).toHaveBeenCalledTimes(1) + expect(statusChanges).toHaveBeenCalledTimes(1) + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-lifecycle', + worktreeId: 'wt-lifecycle', + payload: { state: 'done', prompt: 'newer in-process state', agentType: 'codex' } + }, + 'ssh-lifecycle' + ) + expect(rendererListener).toHaveBeenCalledTimes(2) + expect(enrichedStatuses).toHaveBeenCalledTimes(2) + expect(rowMutations).toHaveBeenCalledTimes(2) + expect(statusChanges).toHaveBeenCalledTimes(2) + + server.stop() + server.stop() + expect(server.buildPtyEnv()).toEqual({}) + expect(server.getStatusSnapshot()).toEqual([]) + expect(statusChanges).toHaveBeenCalledTimes(3) + expect(statusChanges).toHaveBeenLastCalledWith([]) + } finally { + server.stop() + persisted.stop() + rmSync(userDataPath, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/agent-hooks/server-status-listener-fanout.test.ts b/src/main/agent-hooks/server-status-listener-fanout.test.ts index b6633dbf6f1..ef35674e0ed 100644 --- a/src/main/agent-hooks/server-status-listener-fanout.test.ts +++ b/src/main/agent-hooks/server-status-listener-fanout.test.ts @@ -364,6 +364,39 @@ describe('AgentHookServer listener replay', () => { expect(listener).toHaveBeenCalledWith({ paneKey: PANE }) }) + it('fans out one pane clear per status evicted by tab teardown', () => { + const server = new AgentHookServer() + const siblingPane = makePaneKey('tab-1', '22222222-2222-4222-8222-222222222222') + const otherTabPane = makePaneKey('tab-2', '33333333-3333-4333-8333-333333333333') + for (const paneKey of [PANE, siblingPane, otherTabPane]) { + server.ingestRemote( + { + paneKey, + payload: { state: 'working', agentType: 'claude' } + }, + 'conn-1' + ) + } + const clearListener = vi.fn() + const statusListener = vi.fn() + server.subscribePaneStatusClear(clearListener) + server.subscribeStatusChanges(statusListener) + const evidenceObservedAtByPaneKey = ( + server as unknown as { evidenceObservedAtByPaneKey: Map } + ).evidenceObservedAtByPaneKey + expect(evidenceObservedAtByPaneKey.size).toBe(3) + + server.dropStatusEntriesByTabPrefix('tab-1') + + expect(clearListener.mock.calls.map(([clear]) => clear)).toEqual([ + { paneKey: PANE }, + { paneKey: siblingPane } + ]) + expect(statusListener).toHaveBeenCalledOnce() + expect(server.getStatusSnapshot()).toEqual([expect.objectContaining({ paneKey: otherTabPane })]) + expect([...evidenceObservedAtByPaneKey.keys()]).toEqual([otherTabPane]) + }) + it('batches connection cleanup and retains sibling and local statuses', () => { const server = new AgentHookServer() const paneKeyAt = (prefix: string, index: number): string => diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index f03484f14f9..3fb3f51f5f1 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -11,7 +11,9 @@ export type { AgentHookAuthorityAttestation, AgentHookAuthorityEvidence, AgentHookProviderSessionIdentity, + AgentHookStatusRowMutation, AgentHookStatusChangeEntry, + AgentHookStatusFreshnessObservation, EnrichedAgentHookEventPayload } from './server/server-types' export type { AgentHookSource } @@ -40,6 +42,7 @@ export const _internals = { parseFormEncodedBody, resetCachesForTests: (): void => { clearAllListenerCaches(agentHookServer._getStateForTests()) + agentHookServer._resetRowOwnershipForTests() agentHookServer._resetPromptSentDedupeForTests() agentHookServer._resetConnectionTimestampWatermarksForTests() } diff --git a/src/main/agent-hooks/server/server-authority-aliases.ts b/src/main/agent-hooks/server/server-authority-aliases.ts index 18756cb459c..b3debc397d2 100644 --- a/src/main/agent-hooks/server/server-authority-aliases.ts +++ b/src/main/agent-hooks/server/server-authority-aliases.ts @@ -133,7 +133,7 @@ export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAut toPaneKey: string, ptyId?: string, updatedAt = Date.now(), - options?: { authorityVerified?: boolean } + options?: { authorityVerified?: boolean; emitStatusRowMutation?: boolean } ): void { if (!isValidPaneKey(fromPaneKey) || !isValidPaneKey(toPaneKey)) { return @@ -142,7 +142,10 @@ export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAut const physicalPaneKey = this.getPhysicalPaneKeyForAuthority(fromPaneKey, ptyId) const existing = this.legacyPaneKeyAliases.get(physicalPaneKey) const normalizedPtyId = ptyId?.trim() || existing?.ptyId || null - const hadStatus = this.state.lastStatusByPaneKey.has(previousOwnerPaneKey) + const previousStatus = this.state.lastStatusByPaneKey.get(previousOwnerPaneKey) as + | EnrichedAgentHookEventPayload + | undefined + const hadStatus = previousStatus !== undefined movePaneCacheState(this.state, previousOwnerPaneKey, toPaneKey) const movedStatus = this.state.lastStatusByPaneKey.get(toPaneKey) as | EnrichedAgentHookEventPayload @@ -155,6 +158,9 @@ export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAut tabId: owner?.tabId }) } + const transferredStatus = this.state.lastStatusByPaneKey.get(toPaneKey) as + | EnrichedAgentHookEventPayload + | undefined const hydratedLaunchTokenHash = this.hydratedLaunchTokenHashByPaneKey.get(previousOwnerPaneKey) if (hydratedLaunchTokenHash) { this.hydratedLaunchTokenHashByPaneKey.delete(previousOwnerPaneKey) @@ -188,6 +194,11 @@ export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAut this.activeHookTurnCompletedAtByPaneKey.delete(previousOwnerPaneKey) this.activeHookTurnCompletedAtByPaneKey.set(toPaneKey, activeTurnCompletedAt) } + const evidenceObservedAt = this.evidenceObservedAtByPaneKey.get(previousOwnerPaneKey) + if (evidenceObservedAt !== undefined) { + this.evidenceObservedAtByPaneKey.delete(previousOwnerPaneKey) + this.evidenceObservedAtByPaneKey.set(toPaneKey, evidenceObservedAt) + } const authorityObservation = this.currentAuthorityObservations.get(previousOwnerPaneKey) if (authorityObservation) { const owner = parsePaneKey(toPaneKey) @@ -214,6 +225,11 @@ export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAut this.boundPaneKeyAliases() this.closedAgentStatusPaneKeys.delete(toPaneKey) this.notifyPaneKeyAliasPersistenceListener() + this.commitStatusRowMutation( + previousStatus, + transferredStatus, + options?.emitStatusRowMutation !== false + ) if (hadStatus || persistedAuthority) { this.scheduleStatusPersist() this.notifyStatusChangeListeners() diff --git a/src/main/agent-hooks/server/server-authority-fences.ts b/src/main/agent-hooks/server/server-authority-fences.ts index 0ad1bdeba62..fdbc16d7012 100644 --- a/src/main/agent-hooks/server/server-authority-fences.ts +++ b/src/main/agent-hooks/server/server-authority-fences.ts @@ -1,7 +1,11 @@ import { clearPaneCacheState } from '../../../shared/agent-hook-listener/listener-state' import { parsePaneKey } from '../../../shared/stable-pane-id' import { AgentHookServerAuthorityAliases } from './server-authority-aliases' -import type { RetiredPaneAlias, RetiredPaneFence } from './server-types' +import type { + EnrichedAgentHookEventPayload, + RetiredPaneAlias, + RetiredPaneFence +} from './server-types' export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuthorityAliases { // Why: retirement fences a pane and every alias of it, then deletes those aliases. @@ -21,7 +25,13 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth } this.recordRetiredPaneFence(paneKeys, retiredAliases) const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys) - const hadStatus = [...paneKeys].some((key) => this.state.lastStatusByPaneKey.has(key)) + const retiredRows = [...paneKeys].flatMap((key) => { + const row = this.state.lastStatusByPaneKey.get(key) as + | EnrichedAgentHookEventPayload + | undefined + return row ? [row] : [] + }) + const hadStatus = retiredRows.length > 0 for (const key of paneKeys) { this.markPaneClosedForAgentStatus(key) this.restartedStatusLaunchTokenHashByPaneKey.delete(key) @@ -37,6 +47,9 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth if (aliasChanged) { this.notifyPaneKeyAliasPersistenceListener() } + for (const row of retiredRows) { + this.commitStatusRowMutation(row, undefined) + } if (hadStatus || authorityChanged) { this.scheduleStatusPersist() this.notifyStatusChangeListeners() @@ -108,6 +121,7 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth let aliasChanged = false let statusChanged = false const clearedStatusPaneKeys = new Set() + const clearedStatusRows = new Map() for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) { if (entry.ptyId !== ptyId) { continue @@ -129,6 +143,10 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth if (shouldClearStablePaneKey && this.state.lastStatusByPaneKey.has(entry.stablePaneKey)) { statusChanged = true clearedStatusPaneKeys.add(entry.stablePaneKey) + clearedStatusRows.set( + entry.stablePaneKey, + this.state.lastStatusByPaneKey.get(entry.stablePaneKey) as EnrichedAgentHookEventPayload + ) } if (shouldClearStablePaneKey) { // Why: hydrated rows live under the stable key; if this PTY dies before ptyPaneKey rebuilds, alias cleanup is the only evictor. @@ -143,6 +161,9 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth if (aliasChanged) { this.notifyPaneKeyAliasPersistenceListener() } + for (const row of clearedStatusRows.values()) { + this.commitStatusRowMutation(row, undefined) + } if (statusChanged) { this.scheduleStatusPersist() this.notifyStatusChangeListeners() diff --git a/src/main/agent-hooks/server/server-cleanup.ts b/src/main/agent-hooks/server/server-cleanup.ts index d7669c7449f..9f36d676143 100644 --- a/src/main/agent-hooks/server/server-cleanup.ts +++ b/src/main/agent-hooks/server/server-cleanup.ts @@ -37,6 +37,7 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen if (retained) { this.state.lastStatusByPaneKey.set(deleted.paneKey, retained) } + this.commitStatusRowMutation(deleted, retained) this.scheduleStatusPersist() this.notifyStatusChangeListeners() this.emitStatusDropped(deleted.paneKey) @@ -74,6 +75,7 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen if (retained) { this.state.lastStatusByPaneKey.set(deleted.paneKey, retained) } + this.commitStatusRowMutation(deleted, retained) evicted.push(deleted.paneKey) } if (evicted.length === 0) { @@ -119,12 +121,16 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen | undefined ) : null - this.clearPaneState(resolvedPaneKey) + const previous = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as + | EnrichedAgentHookEventPayload + | undefined + this.clearPaneState(resolvedPaneKey, { emitStatusRowMutation: false }) if (retained) { this.state.lastStatusByPaneKey.set(resolvedPaneKey, retained) this.scheduleStatusPersist() this.notifyStatusChangeListeners() } + this.commitStatusRowMutation(previous, retained) cleared += 1 } return cleared @@ -159,6 +165,7 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true }) if (deleted) { statusChanged = true + this.commitStatusRowMutation(deleted, undefined) if (deleted.payload.agentType === 'codex') { // Why: a replacement remote process may reuse the pane; don't merge it with the lost connection's children. this.state.codexSubagentRosterByPaneKey.delete(paneKey) diff --git a/src/main/agent-hooks/server/server-ingest-terminal.ts b/src/main/agent-hooks/server/server-ingest-terminal.ts index 822c7e76f02..e7e68115659 100644 --- a/src/main/agent-hooks/server/server-ingest-terminal.ts +++ b/src/main/agent-hooks/server/server-ingest-terminal.ts @@ -1,6 +1,6 @@ import { track } from '../../telemetry/client' import { MAX_PANE_KEY_LEN } from '../../../shared/agent-hook-listener/listener-limits' -import { parsePaneKey } from '../../../shared/stable-pane-id' +import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable-pane-id' import { terminalStatusPayloadMatchesHook } from '../../../shared/agent-terminal-status-equivalence' import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' import type { EnrichedAgentHookEventPayload } from './server-types' @@ -8,32 +8,40 @@ import { AgentHookServerIngestNormalization } from './server-ingest-normalizatio export abstract class AgentHookServerIngestTerminal extends AgentHookServerIngestNormalization { ingestTerminalStatus(event: { + ptyId?: string paneKey: string tabId?: string worktreeId?: string connectionId?: string | null + terminalHandle?: string payload: ParsedAgentStatusPayload }): void { const physicalPaneKey = event.paneKey.trim() - const paneKey = this.resolvePaneKeyAlias(physicalPaneKey) + let paneKey = this.resolvePaneKeyAlias(physicalPaneKey) const parsedPaneKey = parsePaneKey(paneKey) + const legacyPaneKey = parseLegacyNumericPaneKey(paneKey) if (paneKey.length === 0) { track('agent_hook_unattributed', { reason: 'empty_pane_key' }) return } - if (paneKey.length > MAX_PANE_KEY_LEN || !parsedPaneKey) { - return - } const reportedTabId = event.tabId !== undefined && event.tabId.trim().length > 0 ? event.tabId.trim() : undefined - if ( - paneKey === physicalPaneKey && - reportedTabId !== undefined && - reportedTabId !== parsedPaneKey.tabId - ) { + const runtimeOwnedLegacyPane = Boolean( + legacyPaneKey && + event.ptyId?.trim() && + event.terminalHandle?.trim() && + reportedTabId === legacyPaneKey.tabId + ) + // Legacy rows are accepted only from the in-process PTY ingress with both runtime identities; + // HTTP and relay paths still require a stable pane key or a registered alias. + if (paneKey.length > MAX_PANE_KEY_LEN || (!parsedPaneKey && !runtimeOwnedLegacyPane)) { return } - const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId + const paneTabId = parsedPaneKey?.tabId ?? legacyPaneKey?.tabId + if (paneKey === physicalPaneKey && reportedTabId !== undefined && reportedTabId !== paneTabId) { + return + } + const tabId = paneKey !== physicalPaneKey ? parsedPaneKey?.tabId : reportedTabId if (this.getAgentStatusDisposition(paneKey) !== 'accept') { return } @@ -45,6 +53,31 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges typeof event.connectionId === 'string' && event.connectionId.trim().length > 0 ? event.connectionId.trim() : null + const terminalHandle = + typeof event.terminalHandle === 'string' && event.terminalHandle.trim().length > 0 + ? event.terminalHandle.trim() + : undefined + let mutationBefore: EnrichedAgentHookEventPayload | undefined + const indexedPaneKey = terminalHandle + ? this.getStatusPaneKeyForTerminalHandle(terminalHandle) + : undefined + if (indexedPaneKey && indexedPaneKey !== paneKey) { + const indexedStatus = this.state.lastStatusByPaneKey.get(indexedPaneKey) as + | EnrichedAgentHookEventPayload + | undefined + if ( + indexedStatus && + indexedStatus.terminalHandle === terminalHandle && + this.sameTerminalOwner(indexedStatus, { connectionId, worktreeId }) + ) { + mutationBefore = indexedStatus + this.transferPaneAuthority(indexedPaneKey, paneKey, event.ptyId, Date.now(), { + authorityVerified: true, + emitStatusRowMutation: false + }) + paneKey = this.resolvePaneKeyAlias(paneKey) + } + } const previous = this.state.lastStatusByPaneKey.get(paneKey) as | EnrichedAgentHookEventPayload | undefined @@ -54,6 +87,10 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges event.payload.agentType === 'claude' ) { // Why: OSC has no child identity or lead boundary, so it cannot replace a persisted child-only proof before the lifecycle hook arrives. + if (mutationBefore !== undefined) { + this.commitStatusRowMutation(mutationBefore, previous) + this.emitEnrichedStatus(previous) + } return } // Why: preserve the hook-completed turn stamp while OSC repaints the current state. @@ -65,8 +102,14 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges previous?.connectionId === connectionId && previous.tabId === tabId && previous.worktreeId === worktreeId && + // Why in the unchanged gate: the handle is a join key readers match on, so a pane that + // only just acquired one (or moved to another) must still refresh the row it is stamped on. + previous.terminalHandle === (terminalHandle ?? previous.terminalHandle) && terminalStatusPayloadMatchesHook(previous.payload, event.payload, preserveActiveTurnStamp) ) { + // A handle-authority transfer is a new pane observation even when its payload is a + // duplicate; enriched subscribers must capture the replacement pane identity. + this.refreshTerminalStatusEvidence(previous, mutationBefore, mutationBefore !== undefined) return } // Why: the OSC 9999 wire payload has no providerSession field at all, so an OSC observation is @@ -95,10 +138,13 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges worktreeId, connectionId, ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), + ...(terminalHandle ? { terminalHandle } : {}), payload: event.payload }, undefined, - 'osc' + 'osc', + undefined, + mutationBefore ) } } diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index 9beb0ad0bbb..e7f68829f3b 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -36,19 +36,22 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.token = randomUUID() this.endpointFileWritten = false this.lastWrittenJson = null - // Why: hydrate before binding the listener so an early hook POST runs against a populated map. - if (this.lastStatusFilePath) { - this.hydrateLastStatusFromDisk() - } - this.captureHydratedAuthorityCommitments() - // Drain before binding the listener so replay cannot race a live hook during startup. - if (this.endpointDir) { - drainAgentHookSpool({ - endpointDir: this.endpointDir, - getPersistedLaunchTokenHash: (paneKey) => - this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), - ingest: (record: SpoolRecord) => this.ingestSpoolRecord(record) - }) + if (!this.ownerStateInitialized) { + // Why: hydrate before binding the listener so an early hook POST runs against a populated map. + if (this.lastStatusFilePath) { + this.hydrateLastStatusFromDisk() + } + this.captureHydratedAuthorityCommitments() + // Drain before binding the listener so replay cannot race a live hook during startup. + if (this.endpointDir) { + drainAgentHookSpool({ + endpointDir: this.endpointDir, + getPersistedLaunchTokenHash: (paneKey) => + this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), + ingest: (record: SpoolRecord) => this.ingestSpoolRecord(record) + }) + } + this.ownerStateInitialized = true } const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise => { if (req.method !== 'POST') { @@ -134,39 +137,51 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.server = createServer((req, res) => { void handleRequest(req, res) }) - await new Promise((resolve, reject) => { - const onStartupError = (err: Error): void => { - // Why: swap the startup reject-handler for a logging one so a later runtime 'error' can't crash main as an unhandled event. - this.server?.off('listening', onListening) - reject(err) - } - const onListening = (): void => { - this.server?.off('error', onStartupError) - this.server?.on('error', (err) => { - console.error('[agent-hooks] server error', err) - }) - const address = this.server!.address() - if (address && typeof address === 'object') { - this.port = address.port + try { + await new Promise((resolve, reject) => { + const onStartupError = (err: Error): void => { + this.server?.off('listening', onListening) + reject(err) } - this.maybeWriteEndpointFile() - resolve() - } - this.server!.once('error', onStartupError) - this.server!.listen(0, '127.0.0.1', onListening) - }) + const onListening = (): void => { + this.server?.off('error', onStartupError) + this.server?.on('error', (err) => { + console.error('[agent-hooks] server error', err) + }) + const address = this.server!.address() + if (address && typeof address === 'object') { + this.port = address.port + } + this.maybeWriteEndpointFile() + resolve() + } + this.server!.once('error', onStartupError) + this.server!.listen(0, '127.0.0.1', onListening) + }) + } catch (error) { + this.rollbackTransportStart() + throw error + } + } + + private rollbackTransportStart(): void { + this.server?.close() + this.server = null + this.port = 0 + this.token = '' + this.endpointFileWritten = false } stop(): void { // Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch. this.flushStatusPersistSync() - this.server?.close() - this.server = null - this.port = 0 - this.token = '' + this.rollbackTransportStart() this.env = 'production' this.onAgentStatus = null + this.onClaudeStatusLine = null this.onPaneStatusCleared = null + this.onTransportInterference = null + this.transportInterference.reset() for (const timer of this.assistantMessageRetryTimers.values()) { clearTimeout(timer) } @@ -178,6 +193,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.lastStatusFilePath = null this.lastWrittenJson = null this.runtimeObservedStatusPaneKeys.clear() + this.paneKeyByTerminalHandle.clear() this.hydratedAuthorityCommitments = Object.freeze([]) this.hydratedLaunchTokenHashByPaneKey.clear() this.persistedAuthorityCommitmentsByPaneKey.clear() @@ -189,9 +205,20 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.restartedStatusLaunchTokenHashByPaneKey.clear() this.retiredPaneFencesByKey.clear() this.connectionTimestampWatermarkById.clear() + this.evidenceObservedAtByPaneKey.clear() + this.activeHookTurnCompletedAtByPaneKey.clear() this.legacyPaneKeyAliases.clear() + this.paneKeyAliasPersistenceListener = null + this.ownerStateInitialized = false // Why: don't unlink the endpoint file — a stale file matches fail-open and avoids a TOCTOU race with a concurrent Orca. clearAllListenerCaches(this.state) this.notifyStatusChangeListeners() + this.paneStatusClearListeners.clear() + this.statusDropListeners.clear() + this.statusChangeListeners.clear() + this.statusFreshnessListeners.clear() + this.providerSessionChangeListeners.clear() + this.enrichedStatusListeners.clear() + this.statusRowMutationListeners.clear() } } diff --git a/src/main/agent-hooks/server/server-listeners.ts b/src/main/agent-hooks/server/server-listeners.ts index 08d2ef21a70..44e2c940753 100644 --- a/src/main/agent-hooks/server/server-listeners.ts +++ b/src/main/agent-hooks/server/server-listeners.ts @@ -9,6 +9,7 @@ import type { AgentHookAuthorityEvidence, AgentHookProviderSessionIdentity, AgentHookStatusChangeEntry, + AgentHookStatusFreshnessObservation, EnrichedAgentHookEventPayload, StatusDropListener } from './server-types' @@ -57,6 +58,26 @@ export abstract class AgentHookServerListeners extends AgentHookServerState { } } + /** Accepted duplicate evidence renews leases without becoming a semantic row mutation. */ + subscribeStatusFreshness( + listener: (status: AgentHookStatusFreshnessObservation) => void + ): () => void { + this.statusFreshnessListeners.add(listener) + return () => { + this.statusFreshnessListeners.delete(listener) + } + } + + protected emitStatusFreshnessObservation(status: AgentHookStatusFreshnessObservation): void { + for (const listener of this.statusFreshnessListeners) { + try { + listener(status) + } catch (err) { + console.error('[agent-hooks] status-freshness listener threw', err) + } + } + } + subscribeProviderSessionChanges( listener: (providerSessions: AgentHookProviderSessionIdentity[]) => void ): () => void { @@ -177,6 +198,7 @@ export abstract class AgentHookServerListeners extends AgentHookServerState { } if (!enriched.providerSessionOnly) { statuses.push({ + paneKey, state: enriched.payload.state, receivedAt: enriched.receivedAt, observedInCurrentRuntime: this.runtimeObservedStatusPaneKeys.has(paneKey) diff --git a/src/main/agent-hooks/server/server-persistence.ts b/src/main/agent-hooks/server/server-persistence.ts index f5b66222d6d..6d811216f9f 100644 --- a/src/main/agent-hooks/server/server-persistence.ts +++ b/src/main/agent-hooks/server/server-persistence.ts @@ -42,6 +42,9 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio observation: _observation, // Replay provenance is runtime-only and must not survive another restart. isReplay: _isReplay, + // A terminal handle belongs to the runtime that issued it; a hydrated one could only + // rejoin a row to somebody else's terminal. + terminalHandle: _terminalHandle, launchToken, ...persistedPayload } = enrichedPayload diff --git a/src/main/agent-hooks/server/server-reaping.ts b/src/main/agent-hooks/server/server-reaping.ts index 7805303ced1..55a6addbc45 100644 --- a/src/main/agent-hooks/server/server-reaping.ts +++ b/src/main/agent-hooks/server/server-reaping.ts @@ -114,6 +114,7 @@ export abstract class AgentHookServerReaping extends AgentHookServerTabCleanup { } } this.state.lastStatusByPaneKey.set(paneKey, reconciled) + this.commitStatusRowMutation(enriched, reconciled) } if (changedPanes > 0) { this.scheduleStatusPersist() diff --git a/src/main/agent-hooks/server/server-row-ownership.ts b/src/main/agent-hooks/server/server-row-ownership.ts new file mode 100644 index 00000000000..2895eb463e4 --- /dev/null +++ b/src/main/agent-hooks/server/server-row-ownership.ts @@ -0,0 +1,132 @@ +import { + isWslHookRelayConnectionId, + wslHookRelayConnectionId +} from '../../../shared/wsl-hook-relay-contract' +import { splitWorktreeIdForFilesystem, worktreeIdsEqual } from '../../../shared/worktree/id' +import { parseWslUncPath } from '../../../shared/wsl-paths' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import type { + AgentHookStatusRowIdentity, + AgentHookStatusRowMutation, + EnrichedAgentHookEventPayload, + StatusRowMutationListener +} from './server-types' +import { toAgentStatusIpcPayload } from './server-status-identity' +import { AgentHookServerListeners } from './server-listeners' + +function toMutationIdentity( + row: EnrichedAgentHookEventPayload | null | undefined +): AgentHookStatusRowIdentity | null { + if (!row) { + return null + } + return { + paneKey: row.paneKey, + ...(row.worktreeId ? { worktreeId: row.worktreeId } : {}), + ...(row.terminalHandle ? { terminalHandle: row.terminalHandle } : {}) + } +} + +function semanticRowJson(row: EnrichedAgentHookEventPayload | null | undefined): string | null { + if (!row) { + return null + } + const { + receivedAt: _receivedAt, + evidenceObservedAt: _evidenceObservedAt, + observation: _observation, + launchToken: _launchToken, + promptInteractionKey: _promptInteractionKey, + ...semantic + } = toAgentStatusIpcPayload(row) + return JSON.stringify(semantic) +} + +function wslDistroForWorktree(worktreeId: string | undefined): string | null { + const worktreePath = worktreeId + ? splitWorktreeIdForFilesystem(worktreeId)?.worktreePath + : undefined + return worktreePath ? (parseWslUncPath(worktreePath)?.distro ?? null) : null +} + +export abstract class AgentHookServerRowOwnership extends AgentHookServerListeners { + _resetRowOwnershipForTests(): void { + this.paneKeyByTerminalHandle.clear() + } + + subscribeStatusRowMutations(listener: StatusRowMutationListener): () => void { + this.statusRowMutationListeners.add(listener) + return () => { + this.statusRowMutationListeners.delete(listener) + } + } + + protected getStatusPaneKeyForTerminalHandle(terminalHandle: string): string | undefined { + return this.paneKeyByTerminalHandle.get(terminalHandle) + } + + protected sameTerminalOwner( + previous: EnrichedAgentHookEventPayload, + incoming: Pick + ): boolean { + if ( + previous.worktreeId && + incoming.worktreeId && + !worktreeIdsEqual(previous.worktreeId, incoming.worktreeId) + ) { + return false + } + if (previous.connectionId === incoming.connectionId) { + return true + } + const relayConnection = isWslHookRelayConnectionId(previous.connectionId) + ? previous.connectionId + : isWslHookRelayConnectionId(incoming.connectionId) + ? incoming.connectionId + : null + const localConnection = previous.connectionId === null || incoming.connectionId === null + if (!relayConnection || !localConnection || !previous.worktreeId || !incoming.worktreeId) { + return false + } + const previousDistro = wslDistroForWorktree(previous.worktreeId) + const incomingDistro = wslDistroForWorktree(incoming.worktreeId) + return ( + previousDistro !== null && + incomingDistro !== null && + previousDistro === incomingDistro && + relayConnection === wslHookRelayConnectionId(previousDistro) && + worktreeIdsEqual(previous.worktreeId, incoming.worktreeId) + ) + } + + protected commitStatusRowMutation( + before: EnrichedAgentHookEventPayload | null | undefined, + after: EnrichedAgentHookEventPayload | null | undefined, + emit = true + ): boolean { + if ( + before?.terminalHandle && + this.paneKeyByTerminalHandle.get(before.terminalHandle) === before.paneKey + ) { + this.paneKeyByTerminalHandle.delete(before.terminalHandle) + } + if (after?.terminalHandle) { + this.paneKeyByTerminalHandle.set(after.terminalHandle, after.paneKey) + } + if (!emit || semanticRowJson(before) === semanticRowJson(after)) { + return false + } + const mutation: AgentHookStatusRowMutation = { + before: toMutationIdentity(before), + after: toMutationIdentity(after) + } + for (const listener of this.statusRowMutationListeners) { + try { + listener(mutation) + } catch (error) { + console.error('[agent-hooks] status-row mutation listener threw', error) + } + } + return true + } +} diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index b18677689d0..0df8ff70445 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -25,6 +25,7 @@ import type { AgentHookAuthorityEvidence, AgentHookProviderSessionIdentity, AgentHookStatusChangeEntry, + AgentHookStatusFreshnessObservation, AgentPromptSentDedupeEntry, EnrichedAgentHookEventPayload, NormalizedLocalHook, @@ -37,7 +38,9 @@ import type { ServerAgentStatusListener, ServerStatusLineListener, StatusChangeListener, - StatusDropListener + StatusDropListener, + StatusFreshnessListener, + StatusRowMutationListener } from './server-types' /** Shared mutable state for the layered hook-server implementation. */ @@ -53,7 +56,14 @@ export abstract class AgentHookServerState { protected paneStatusClearListeners = new Set() protected statusDropListeners = new Set() protected statusChangeListeners = new Set() + protected statusFreshnessListeners = new Set() protected providerSessionChangeListeners = new Set() + protected statusRowMutationListeners = new Set() + // Hydration and spool replay belong to the owner lifetime, not each transport bind attempt. + protected ownerStateInitialized = false + // Runtime terminal handles are stable across pane remints, unlike tab/leaf keys. This index is + // deliberately in-memory only and contains no rows of its own. + protected paneKeyByTerminalHandle = new Map() // Why: setListener is a single slot owned by the main-window fanout; the // plugin event bus (and future consumers) need an additive subscription // that also works in headless serve, where no window listener exists. @@ -117,6 +127,9 @@ export abstract class AgentHookServerState { providerSessions: AgentHookProviderSessionIdentity[] } protected abstract notifyStatusChangeListeners(): void + protected abstract emitStatusFreshnessObservation( + status: AgentHookStatusFreshnessObservation + ): void protected abstract markTabClosedForAgentStatus(tabId: string): void protected abstract getAgentStatusDisposition( paneKey: string, @@ -154,7 +167,8 @@ export abstract class AgentHookServerState { payload: AgentHookEventPayload, onAccepted?: () => void, origin?: AgentStatusObservationOrigin, - observedAt?: number + observedAt?: number, + mutationBefore?: EnrichedAgentHookEventPayload ): EnrichedAgentHookEventPayload protected abstract emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void protected abstract clearAssistantMessageRetry(paneKey: string): void @@ -200,7 +214,10 @@ export abstract class AgentHookServerState { entry: EnrichedAgentHookEventPayload | null | undefined ): EnrichedAgentHookEventPayload | null protected abstract hasLiveClaimsForPaneKey(paneKey: string): boolean - protected abstract clearPaneState(paneKey: string): void + protected abstract clearPaneState( + paneKey: string, + options?: { emitStatusRowMutation?: boolean } + ): void protected abstract deleteStatusEntry( paneKey: string, options?: { preserveAuthority?: boolean } diff --git a/src/main/agent-hooks/server/server-status-disposition.ts b/src/main/agent-hooks/server/server-status-disposition.ts index b6c69967280..c4b7230bc2b 100644 --- a/src/main/agent-hooks/server/server-status-disposition.ts +++ b/src/main/agent-hooks/server/server-status-disposition.ts @@ -41,7 +41,8 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt const paneRetired = this.closedAgentStatusPaneKeys.has(paneKey) || this.closedAgentStatusPaneKeys.has(ownerPaneKey) - const tabId = parsePaneKey(ownerPaneKey)?.tabId + const tabId = + parsePaneKey(ownerPaneKey)?.tabId ?? parseLegacyNumericPaneKey(ownerPaneKey)?.tabId if (tabId && this.closedAgentStatusTabIds.has(tabId)) { return 'suppress' } diff --git a/src/main/agent-hooks/server/server-status-identity.ts b/src/main/agent-hooks/server/server-status-identity.ts index 4f6e920d86e..1694c4b1b67 100644 --- a/src/main/agent-hooks/server/server-status-identity.ts +++ b/src/main/agent-hooks/server/server-status-identity.ts @@ -69,6 +69,7 @@ export function toAgentStatusIpcPayload( ...(entry.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), ...(entry.observation ? { observation: entry.observation } : {}), ...(entry.structuredHost ? { structuredHost: entry.structuredHost } : {}), + ...(entry.terminalHandle ? { terminalHandle: entry.terminalHandle } : {}), ...entry.payload } } diff --git a/src/main/agent-hooks/server/server-status-inference.ts b/src/main/agent-hooks/server/server-status-inference.ts index ec651691982..2bbe5508651 100644 --- a/src/main/agent-hooks/server/server-status-inference.ts +++ b/src/main/agent-hooks/server/server-status-inference.ts @@ -14,9 +14,9 @@ import { import { AGENT_STATUS_STALE_AFTER_MS, type AgentType } from '../../../shared/agent-status-types' import type { EnrichedAgentHookEventPayload } from './server-types' import { equivalentInterruptAgentType, isValidPaneKey } from './server-status-identity' -import { AgentHookServerListeners } from './server-listeners' +import { AgentHookServerRowOwnership } from './server-row-ownership' -export abstract class AgentHookServerStatusInference extends AgentHookServerListeners { +export abstract class AgentHookServerStatusInference extends AgentHookServerRowOwnership { inferInterrupt(request: AgentInterruptInferenceRequest): boolean { if (!isValidPaneKey(request.paneKey)) { return false diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index 1a3798efe47..5ea3b07739b 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -24,7 +24,8 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA payload: AgentHookEventPayload, onAccepted?: () => void, origin: AgentStatusObservationOrigin = 'hook', - observedAt?: number + observedAt?: number, + mutationBefore?: EnrichedAgentHookEventPayload ): EnrichedAgentHookEventPayload { if (payload.hookEventName === 'UserPromptSubmit') { // Why: the prompt boundary is authoritative even when text is unchanged; its next OSC working row must not inherit the prior cron/background turn stamp. @@ -33,8 +34,16 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA let previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as | EnrichedAgentHookEventPayload | undefined - const connectionClearWatermark = payload.connectionId - ? this.connectionTimestampWatermarkById.get(payload.connectionId) + const rowBefore = mutationBefore ?? previous + const terminalHandle = + payload.terminalHandle ?? + (previous?.terminalHandle && this.sameTerminalOwner(previous, payload) + ? previous.terminalHandle + : undefined) + const terminalOwnedPayload = + terminalHandle === payload.terminalHandle ? payload : { ...payload, terminalHandle } + const connectionClearWatermark = terminalOwnedPayload.connectionId + ? this.connectionTimestampWatermarkById.get(terminalOwnedPayload.connectionId) : undefined // Why: renderer ordering rejects older rows; live evidence must sort after reconnect clears and restored rows across clock rollback. const restoredStatusWatermark = previous?.restoredUnconfirmed ? previous.receivedAt : undefined @@ -43,38 +52,41 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA (connectionClearWatermark ?? -1) + 1, (restoredStatusWatermark ?? -1) + 1 ) - if (payload.connectionId) { - this.connectionTimestampWatermarkById.set(payload.connectionId, now) + if (terminalOwnedPayload.connectionId) { + this.connectionTimestampWatermarkById.set(terminalOwnedPayload.connectionId, now) } - if (payload.providerSessionOnly) { + if (terminalOwnedPayload.providerSessionOnly) { // Why: identity-only rows survive replay but must not emit prompt telemetry or a fabricated status. onAccepted?.() const enriched = { - ...this.attachStatusTiming(payload, now), - observation: this.stampObservation(payload, origin, now) + ...this.attachStatusTiming(terminalOwnedPayload, now), + observation: this.stampObservation(terminalOwnedPayload, origin, now) } this.clearAssistantMessageRetry(enriched.paneKey) this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey) this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) + this.commitStatusRowMutation(rowBefore, enriched) this.scheduleStatusPersist() this.notifyStatusChangeListeners() this.emitEnrichedStatus(enriched) return enriched } const stateReconciledPayload = - payload.connectionId && payload.payload.agentType === 'codex' && payload.hookEventName + terminalOwnedPayload.connectionId && + terminalOwnedPayload.payload.agentType === 'codex' && + terminalOwnedPayload.hookEventName ? { - ...payload, + ...terminalOwnedPayload, payload: reconcileRemoteCodexState( this.state, - payload.paneKey, - payload.hookEventName, - payload.toolAgentId, - payload.payload, + terminalOwnedPayload.paneKey, + terminalOwnedPayload.hookEventName, + terminalOwnedPayload.toolAgentId, + terminalOwnedPayload.payload, previous?.payload ) } - : payload + : terminalOwnedPayload const previousCodexRoot = stateReconciledPayload.payload.agentType === 'codex' && stateReconciledPayload.toolAgentId && @@ -128,6 +140,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA incomingState: rootContextPreservingPayload.payload.state }) ) { + this.commitStatusRowMutation(rowBefore, previous) return previous } const identityResolvedPayload = @@ -140,6 +153,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA const effectivePayload = attachClaudePermissionToolUseId(previous, identityResolvedPayload) const boundaryAwarePayload = attachClaudeChildOnlyBoundary(previous, effectivePayload) if (previous && shouldKeepClaudePermissionVisible(previous, effectivePayload)) { + this.commitStatusRowMutation(rowBefore, previous) return previous } // Why: some TUIs emit a delayed tool/working hook after Ctrl+C stopped the turn; don't let it resurrect the row. @@ -151,6 +165,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA previous.payload.prompt === effectivePayload.payload.prompt && Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS ) { + this.commitStatusRowMutation(rowBefore, previous) return previous } if ( @@ -167,6 +182,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA if (effectivePayload.payload.agentType === 'codex') { markCodexLeadTurnInterrupted(this.state, effectivePayload.paneKey) } + this.commitStatusRowMutation(rowBefore, previous) return previous } if ( @@ -179,6 +195,8 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA if (!identity.inheritedFromActivePane) { this.maybeTrackAgentPromptSent(effectivePayload, previous) } + // Why carried forward only within one host: main's OSC parse resolves the handle, so a later + // hook must not erase its terminal join; a connection change must not inherit another host's. const enriched = { ...this.attachStatusTiming(boundaryAwarePayload, now, observedAt), observation: this.stampObservation(boundaryAwarePayload, origin, observedAt ?? now) @@ -199,6 +217,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA this.runtimeObservedStatusPaneKeys.add(enriched.paneKey) } this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) + this.commitStatusRowMutation(rowBefore, enriched) // Why skipped for structured rows: the serializer drops them, so the whole walk and stringify // can only ever reproduce the last file — once per debounce window for a streaming chat. if (!enriched.structuredHost) { @@ -209,6 +228,61 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA return enriched } + protected refreshTerminalStatusEvidence( + previous: EnrichedAgentHookEventPayload, + mutationBefore?: EnrichedAgentHookEventPayload, + emitEnrichedStatus = false + ): void { + const connectionClearWatermark = previous.connectionId + ? this.connectionTimestampWatermarkById.get(previous.connectionId) + : undefined + const now = Math.max(Date.now(), (connectionClearWatermark ?? -1) + 1) + if (previous.connectionId) { + this.connectionTimestampWatermarkById.set(previous.connectionId, now) + } + const { + receivedAt: _receivedAt, + evidenceObservedAt: _evidenceObservedAt, + stateStartedAt, + observation: _observation, + restoredUnconfirmed: _restoredUnconfirmed, + isReplay: _isReplay, + ...payload + } = previous + const refreshed: EnrichedAgentHookEventPayload = { + ...payload, + receivedAt: now, + evidenceObservedAt: now, + stateStartedAt, + observation: this.stampObservation(payload, 'osc', now) + } + const firstRuntimeObservation = !this.runtimeObservedStatusPaneKeys.has(refreshed.paneKey) + this.runtimeObservedStatusPaneKeys.add(refreshed.paneKey) + this.state.lastStatusByPaneKey.set(refreshed.paneKey, refreshed) + this.commitStatusRowMutation(mutationBefore ?? previous, refreshed) + this.scheduleStatusPersist() + // A dismissed row may retain only provider resume identity. Its preserved payload can still + // read `working`, but it is deliberately hidden from live readers and must not renew awake or + // mobile freshness leases. + if (refreshed.providerSessionOnly === true) { + return + } + if (firstRuntimeObservation) { + this.notifyStatusChangeListeners() + } + this.emitStatusFreshnessObservation({ + paneKey: refreshed.paneKey, + state: refreshed.payload.state, + receivedAt: refreshed.receivedAt, + observedInCurrentRuntime: true, + ...(refreshed.worktreeId ? { worktreeId: refreshed.worktreeId } : {}), + ...(refreshed.terminalHandle ? { terminalHandle: refreshed.terminalHandle } : {}) + }) + if (emitEnrichedStatus) { + this.emitEnrichedStatus(refreshed) + } + } + // Why: every status emit must reach plugins too, so a new early-return path // upstream cannot silently leave the plugin tap behind the main-window fanout. protected emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void { diff --git a/src/main/agent-hooks/server/server-tab-cleanup.ts b/src/main/agent-hooks/server/server-tab-cleanup.ts index 3ce2c4fce0a..a108bdbbd22 100644 --- a/src/main/agent-hooks/server/server-tab-cleanup.ts +++ b/src/main/agent-hooks/server/server-tab-cleanup.ts @@ -1,15 +1,25 @@ import { clearPaneCacheState } from '../../../shared/agent-hook-listener/listener-state' import { paneCacheKeyMatchesTab } from './server-status-identity' import { AgentHookServerCleanup } from './server-cleanup' +import type { EnrichedAgentHookEventPayload } from './server-types' export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { /** Drop every status/cache claim attributable to a closed tab prefix. */ dropStatusEntriesByTabPrefix(tabId: string): void { this.markTabClosedForAgentStatus(tabId) const paneKeysToClear = new Set() + const statusPaneKeysToClear = new Set() + const statusRowsToClear: EnrichedAgentHookEventPayload[] = [] for (const key of this.state.lastStatusByPaneKey.keys()) { if (paneCacheKeyMatchesTab(key, tabId)) { paneKeysToClear.add(key) + statusPaneKeysToClear.add(key) + const row = this.state.lastStatusByPaneKey.get(key) as + | EnrichedAgentHookEventPayload + | undefined + if (row) { + statusRowsToClear.push(row) + } } } for (const key of this.state.lastPromptByPaneKey.keys()) { @@ -72,21 +82,32 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { this.currentAuthorityObservations.delete(paneKey) this.promptSentDedupeByPaneKey.delete(paneKey) this.restartedStatusLaunchTokenHashByPaneKey.delete(paneKey) + this.evidenceObservedAtByPaneKey.delete(paneKey) } if (aliasChanged) { this.notifyPaneKeyAliasPersistenceListener() } + for (const row of statusRowsToClear) { + this.commitStatusRowMutation(row, undefined) + } if (statusChanged || authorityChanged) { this.scheduleStatusPersist() this.notifyStatusChangeListeners() } + // Why: tab teardown must retire status subscribers' pane-scoped memo state too. + for (const paneKey of statusPaneKeysToClear) { + this.emitPaneStatusCleared({ paneKey }) + } } - clearPaneState(paneKey: string): void { + clearPaneState(paneKey: string, options?: { emitStatusRowMutation?: boolean }): void { const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) const paneKeys = new Set([paneKey, resolvedPaneKey]) // Why: only persist when a status entry was actually evicted; dropping prompt/tool caches doesn't change the file. - const hadStatus = this.state.lastStatusByPaneKey.has(resolvedPaneKey) + const previousStatus = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as + | EnrichedAgentHookEventPayload + | undefined + const hadStatus = previousStatus !== undefined this.clearAssistantMessageRetry(resolvedPaneKey) this.clearCodexSubagentPoll(resolvedPaneKey) clearPaneCacheState(this.state, resolvedPaneKey) @@ -115,6 +136,9 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { if (clearedAlias) { this.notifyPaneKeyAliasPersistenceListener() } + if (options?.emitStatusRowMutation !== false) { + this.commitStatusRowMutation(previousStatus, undefined) + } if (hadStatus || authorityChanged) { this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey) this.scheduleStatusPersist() diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts index c151c70d34b..b4cf176b176 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -36,6 +36,8 @@ export type PersistedAgentHookEventPayload = Omit< // Why: revision counters are in-memory and the authority id is regenerated per process, so // a stored observation could only rehydrate as a stale ordering claim from a dead authority. | 'observation' + // Same: a terminal handle is issued by one runtime and means nothing to the next. + | 'terminalHandle' > & { launchTokenHash?: string } @@ -50,11 +52,17 @@ export type PersistedAgentHookAuthorityCommitment = { } export type AgentHookStatusChangeEntry = { + paneKey: string state: AgentStatusState receivedAt: number observedInCurrentRuntime: boolean } +export type AgentHookStatusFreshnessObservation = AgentHookStatusChangeEntry & { + worktreeId?: string + terminalHandle?: string +} + export type AgentHookProviderSessionIdentity = { paneKey: string sessionId: string @@ -77,9 +85,20 @@ export type AgentHookAuthorityAttestation = Readonly<{ }> export type StatusChangeListener = (statuses: AgentHookStatusChangeEntry[]) => void +export type StatusFreshnessListener = (status: AgentHookStatusFreshnessObservation) => void export type ProviderSessionChangeListener = ( providerSessions: AgentHookProviderSessionIdentity[] ) => void +export type AgentHookStatusRowIdentity = { + paneKey: string + worktreeId?: string + terminalHandle?: string +} +export type AgentHookStatusRowMutation = { + before: AgentHookStatusRowIdentity | null + after: AgentHookStatusRowIdentity | null +} +export type StatusRowMutationListener = (mutation: AgentHookStatusRowMutation) => void export type PaneStatusClearListener = (clear: AgentStatusClearIpcPayload) => void export type StatusDropListener = (paneKey: string) => void export type PaneKeyAliasPersistenceListener = (entries: LegacyPaneKeyAliasEntry[]) => void diff --git a/src/main/agent-hooks/terminal-handle-row-identity.test.ts b/src/main/agent-hooks/terminal-handle-row-identity.test.ts new file mode 100644 index 00000000000..329ffd19958 --- /dev/null +++ b/src/main/agent-hooks/terminal-handle-row-identity.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it, vi } from 'vitest' +import { AgentHookServer } from './server' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-types' +import { selectFreshExplicitAgentStatus } from '../runtime/runtime-hook-agent-row-selection' +import { wslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract' + +const PANE_KEY = 'tab-handle:33333333-3333-4333-8333-333333333333' +const HANDLE = 'term_identity' +const NEW_PANE_KEY = 'tab-reminted:44444444-4444-4444-8444-444444444444' + +function ingest(server: AgentHookServer, overrides: Record = {}): void { + server.ingestTerminalStatus({ + paneKey: PANE_KEY, + tabId: 'tab-handle', + worktreeId: 'worktree', + connectionId: null, + terminalHandle: HANDLE, + payload: { state: 'working', prompt: 'ship it', agentType: 'codex' }, + ...overrides + }) +} + +describe('the terminal handle a status row is stamped with', () => { + it('reaches the published row', () => { + const server = new AgentHookServer() + ingest(server) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + paneKey: PANE_KEY, + terminalHandle: HANDLE + }) + }) + + it('survives a later write that resolved no handle', () => { + // Only main's OSC parse resolves one; an HTTP hook post for the same pane carries none and + // must not erase the row's only join back to its terminal. + const server = new AgentHookServer() + ingest(server) + ingest(server, { terminalHandle: undefined, payload: { state: 'done', prompt: 'ship it' } }) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + terminalHandle: HANDLE + }) + }) + + it('does not cross a connection ownership change on a colliding pane key', () => { + const server = new AgentHookServer() + ingest(server, { connectionId: 'ssh-a' }) + + server.ingestRemote( + { + paneKey: PANE_KEY, + tabId: 'tab-handle', + worktreeId: 'other-worktree', + payload: { state: 'done', prompt: 'other host', agentType: 'codex' } + }, + 'ssh-b' + ) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ + connectionId: 'ssh-b', + worktreeId: 'other-worktree' + }) + expect(server.getStatusSnapshot()[0]).not.toHaveProperty('terminalHandle') + }) + + it('is never persisted, because it belongs to the runtime that issued it', () => { + const server = new AgentHookServer() + ingest(server) + const serialized = ( + server as unknown as { serializeStatusFile(): string } + ).serializeStatusFile() + expect(serialized).toContain(PANE_KEY) + expect(serialized).not.toContain(HANDLE) + }) + + it('moves one PTY row and all of its resume identity across a pane remint', () => { + const server = new AgentHookServer() + ingest(server) + server.ingestRemote( + { + paneKey: PANE_KEY, + tabId: 'tab-handle', + worktreeId: 'worktree', + providerSession: { key: 'session_id', id: 'session-1' }, + payload: { state: 'working', prompt: 'ship it', agentType: 'codex' } + }, + null + ) + const mutations: Parameters[0]>[0][] = [] + server.subscribeStatusRowMutations((mutation) => mutations.push(mutation)) + + ingest(server, { paneKey: NEW_PANE_KEY, tabId: 'tab-reminted' }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: NEW_PANE_KEY, + terminalHandle: HANDLE, + providerSession: { key: 'session_id', id: 'session-1' } + }) + ]) + expect(mutations).toEqual([ + { + before: { paneKey: PANE_KEY, worktreeId: 'worktree', terminalHandle: HANDLE }, + after: { paneKey: NEW_PANE_KEY, worktreeId: 'worktree', terminalHandle: HANDLE } + } + ]) + + server.dropStatusEntry(NEW_PANE_KEY) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: NEW_PANE_KEY, + providerSessionOnly: true, + providerSession: { key: 'session_id', id: 'session-1' } + }) + ]) + expect(server.reconcileEndedProcessForPaneKeys([NEW_PANE_KEY])).toBe(1) + expect(server.getStatusSnapshot()).toEqual([]) + expect(mutations).toHaveLength(3) + expect( + (server as unknown as { paneKeyByTerminalHandle: Map }) + .paneKeyByTerminalHandle + ).toEqual(new Map()) + }) + + it('preserves a local WSL terminal join only for its exact relay distro', () => { + const server = new AgentHookServer() + const worktreeId = String.raw`repo::\\wsl.localhost\Ubuntu\home\user\repo` + ingest(server, { worktreeId }) + + server.ingestRemote( + { + paneKey: PANE_KEY, + tabId: 'tab-handle', + worktreeId, + providerSession: { key: 'session_id', id: 'wsl-session' }, + payload: { state: 'working', prompt: 'ship it', agentType: 'codex' } + }, + wslHookRelayConnectionId('Ubuntu') + ) + expect(server.getStatusSnapshot()[0]).toMatchObject({ terminalHandle: HANDLE }) + + server.ingestRemote( + { + paneKey: PANE_KEY, + tabId: 'tab-handle', + worktreeId, + payload: { state: 'done', prompt: 'wrong distro', agentType: 'codex' } + }, + wslHookRelayConnectionId('Debian') + ) + expect(server.getStatusSnapshot()[0]).not.toHaveProperty('terminalHandle') + }) + + it('renews duplicate OSC evidence without publishing another semantic row', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + const enriched = vi.fn() + const mutated = vi.fn() + const statusChanges = vi.fn() + server.subscribeEnrichedStatus(enriched) + server.subscribeStatusRowMutations(mutated) + server.subscribeStatusChanges(statusChanges) + ingest(server) + enriched.mockClear() + mutated.mockClear() + statusChanges.mockClear() + + vi.setSystemTime(1_000 + AGENT_STATUS_STALE_AFTER_MS + 1) + ingest(server) + + const [row] = server.getStatusSnapshot() + expect(row.evidenceObservedAt).toBe(Date.now()) + expect( + selectFreshExplicitAgentStatus({ handle: HANDLE, paneKey: PANE_KEY, hookRows: [row] }) + ).toMatchObject({ status: 'working', updatedAt: Date.now() }) + expect(enriched).not.toHaveBeenCalled() + expect(mutated).not.toHaveBeenCalled() + expect(statusChanges).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('publishes an enriched observation when duplicate OSC transfers pane authority', () => { + const server = new AgentHookServer() + const enriched = vi.fn() + server.subscribeEnrichedStatus(enriched) + ingest(server) + enriched.mockClear() + + ingest(server, { paneKey: NEW_PANE_KEY, tabId: 'tab-reminted' }) + + expect(enriched).toHaveBeenCalledWith( + expect.objectContaining({ paneKey: NEW_PANE_KEY, terminalHandle: HANDLE }) + ) + }) + + it('publishes only the remint observation for a Claude child-only row', () => { + const server = new AgentHookServer() + const enriched = vi.fn() + const mutations = vi.fn() + server.subscribeEnrichedStatus(enriched) + server.subscribeStatusRowMutations(mutations) + const payload = { state: 'working' as const, prompt: 'ship it', agentType: 'claude' as const } + ingest(server, { payload }) + const row = server._getStateForTests().lastStatusByPaneKey.get(PANE_KEY) as + | { claudeLeadBoundaryChildOnly?: true } + | undefined + if (!row) { + throw new Error('expected seeded status row') + } + row.claudeLeadBoundaryChildOnly = true + enriched.mockClear() + mutations.mockClear() + + ingest(server, { payload }) + expect(enriched).not.toHaveBeenCalled() + expect(mutations).not.toHaveBeenCalled() + + ingest(server, { paneKey: NEW_PANE_KEY, tabId: 'tab-reminted', payload }) + expect(enriched).toHaveBeenCalledOnce() + expect(mutations).toHaveBeenCalledOnce() + expect(mutations).toHaveBeenCalledWith({ + before: { paneKey: PANE_KEY, worktreeId: 'worktree', terminalHandle: HANDLE }, + after: { paneKey: NEW_PANE_KEY, worktreeId: 'worktree', terminalHandle: HANDLE } + }) + expect(enriched).toHaveBeenCalledWith( + expect.objectContaining({ paneKey: NEW_PANE_KEY, terminalHandle: HANDLE }) + ) + }) + + it('does not renew freshness from a provider-session-only dismissal remnant', () => { + const server = new AgentHookServer() + const freshness = vi.fn() + server.subscribeStatusFreshness(freshness) + ingest(server) + server.ingestRemote( + { + paneKey: PANE_KEY, + tabId: 'tab-handle', + worktreeId: 'worktree', + providerSession: { key: 'session_id', id: 'resume-me' }, + payload: { state: 'working', prompt: 'ship it', agentType: 'codex' } + }, + null + ) + server.dropStatusEntry(PANE_KEY) + freshness.mockClear() + + ingest(server) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ + paneKey: PANE_KEY, + providerSessionOnly: true, + providerSession: { key: 'session_id', id: 'resume-me' } + }) + expect(freshness).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts index 001380e934d..f8e3420720a 100644 --- a/src/main/ipc/agent-hooks.test.ts +++ b/src/main/ipc/agent-hooks.test.ts @@ -293,6 +293,17 @@ describe('agentStatus:drop IPC', () => { expect(clearMigrationUnsupportedPtysForPaneKey).toHaveBeenCalledWith(PANE_KEY) }) + it('forwards a runtime-owned legacy numeric row dismissal', async () => { + const { registerAgentHookHandlers } = await import('./agent-hooks') + registerAgentHookHandlers() + + const handler = onHandlers.get('agentStatus:drop')! + handler!({}, 'tab-1:0') + + expect(dropStatusEntry).toHaveBeenCalledWith('tab-1:0') + expect(clearMigrationUnsupportedPtysForPaneKey).toHaveBeenCalledWith('tab-1:0') + }) + it('rejects non-string paneKey (defensive against a malformed renderer message)', async () => { const { registerAgentHookHandlers } = await import('./agent-hooks') registerAgentHookHandlers() @@ -305,7 +316,6 @@ describe('agentStatus:drop IPC', () => { null, {}, [], - 'tab-1:0', // legacy numeric pane-key suffix 'no-colon', // missing colon — rejected by isValidPaneKey ':leading', // empty tabId half 'trailing:', // empty leafId half diff --git a/src/main/ipc/agent-status-row-teardown-ipc.ts b/src/main/ipc/agent-status-row-teardown-ipc.ts index 020cfa7259d..5e42312ec20 100644 --- a/src/main/ipc/agent-status-row-teardown-ipc.ts +++ b/src/main/ipc/agent-status-row-teardown-ipc.ts @@ -1,6 +1,7 @@ import { ipcMain } from 'electron' import { agentHookServer, isValidPaneKey } from '../agent-hooks/server' import type { AgentStatusCacheIdentity } from '../../shared/agent-status-types' +import { parseLegacyNumericPaneKey } from '../../shared/stable-pane-id' import { clearMigrationUnsupportedPtysByTabPrefix, clearMigrationUnsupportedPtysForPaneKey @@ -27,7 +28,10 @@ export function registerAgentStatusRowTeardownIpcHandlers(): void { ipcMain.removeAllListeners('agentStatus:dropByTabPrefix') ipcMain.on('agentStatus:drop', (_event, paneKey: unknown) => { - if (typeof paneKey !== 'string' || !isValidPaneKey(paneKey)) { + if ( + typeof paneKey !== 'string' || + (!isValidPaneKey(paneKey) && parseLegacyNumericPaneKey(paneKey) === null) + ) { return } try { diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 79e01a13163..137894f87b8 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -16,18 +16,15 @@ import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environ import { setSecretStore, type SecretStore } from '../../shared/secret-store' import type { ServeReadiness } from '../server/serve-readiness' import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' -import { resolveOrcadBrowserProvider, type OrcadBrowserProvider } from './orcad-browser-provider' +import { resolveOrcadBrowserProvider } from './orcad-browser-provider' import { resolveOrcadInstallRoot, resolveOrcadPath, resolveUserDataPath } from './orcad-app-paths' import { describeOrcadBindExposure, OrcadBindAddressError, resolveOrcadBindHost } from './orcad-bind-address' -import { - acquireOrcadInstanceLock, - OrcadInstanceLockError, - type OrcadInstanceLock -} from './orcad-instance-lock' +import { acquireOrcadInstanceLock, OrcadInstanceLockError } from './orcad-instance-lock' +import { startOrcadWithLifecycle } from './orcad-lifecycle' let runOrcadQuitHandlers = (): void => {} @@ -116,22 +113,24 @@ export async function startOrcad(options: OrcadOptions = {}): Promise browserProvider.isAvailable() } : {}) }) - try { - return await startOrcadRuntime(options, browserProvider, instanceLock) - } catch (error) { - await browserProvider?.stop() - setRuntimeBrowserCommandsFactory(null) - runOrcadQuitHandlers() - instanceLock.release() - throw error - } + return startOrcadWithLifecycle( + (registerCleanup) => startOrcadRuntime(options, registerCleanup), + async () => { + try { + await browserProvider?.stop() + } finally { + setRuntimeBrowserCommandsFactory(null) + runOrcadQuitHandlers() + instanceLock.release() + } + } + ) } async function startOrcadRuntime( options: OrcadOptions, - browserProvider: OrcadBrowserProvider | null, - instanceLock: OrcadInstanceLock -): Promise { + registerCleanup: (cleanup: () => Promise) => void +): Promise> { const { OrcaRuntimeService } = await import('../runtime/orca-runtime') const { OrcaRuntimeRpcServer } = await import('../runtime/runtime-rpc') const { registerHeadlessPtyRuntime, getLocalPtyProvider, getSshPtyProvider } = @@ -146,15 +145,41 @@ async function startOrcadRuntime( const { startOrcadDaemon, stopOrcadDaemon } = await import('./orcad-daemon-supervision') const { daemonOwnsFreshPersistentPtys } = await import('../daemon/daemon-init') const { collectOrcadHealth } = await import('./orcad-health') - // Why importable here: the store is an in-memory singleton whose module tree never reaches - // Electron, and its file paths come from `start()`, which orcad never calls. + // Why importable here: the singleton's module tree never reaches Electron, and orcad supplies + // its persistence and endpoint paths explicitly below. const { agentHookServer } = await import('../agent-hooks/server') + const { isAgentStatusHooksEnabled } = await import('../agent-hooks/managed-agent-hook-controls') + const { installHookStatusSessionTabsRepublish } = + await import('../agent-hooks/hook-status-session-tabs-republish') + const { AgentStatusObservedPaneIdentities, AgentStatusObservedPaneIdentityCapture } = + await import('../runtime/agent-status-observed-pane-identity') + + let rpc: InstanceType | null = null + let uninstallHookStatusRepublish = (): void => {} + let uninstallObservedStatusIdentity = (): void => {} + registerCleanup(async () => { + try { + await rpc?.stop() + } finally { + try { + // Why disconnect and not shut down: the daemon must outlive this process, or an + // orcad restart goes back to killing every running terminal. + await stopOrcadDaemon() + } finally { + uninstallObservedStatusIdentity() + uninstallHookStatusRepublish() + agentHookServer.stop() + } + } + }) const { DesktopPushService } = await import('../runtime/push/desktop-push-service') const { resolvePushGatewayOrigin } = await import('../runtime/push/push-gateway-origin') const runtimeUserDataPath = getAppEnvironment().getPath('userData') initOrcaProfilePaths() const profile = ensureActiveOrcaProfile(runtimeUserDataPath) + const observedPaneIdentities = new AgentStatusObservedPaneIdentities() + const observedStatusCapture = new AgentStatusObservedPaneIdentityCapture(observedPaneIdentities) // Why a real Store: without one every persistence-backed RPC throws `runtime_unavailable` // and the read paths that use `this.store?.x ?? []` quietly answer "empty" instead — // a server that pairs and lists nothing looks healthy and is not. @@ -165,6 +190,13 @@ async function startOrcadRuntime( // which is safe but silently discards accept records on every launch. initSshHostKeyStoreFile(profile.dataFile) + uninstallObservedStatusIdentity = agentHookServer.subscribeEnrichedStatus((enriched) => + observedStatusCapture.observe(enriched) + ) + if (isAgentStatusHooksEnabled(store.getSettings())) { + await agentHookServer.start({ env: 'production', userDataPath: runtimeUserDataPath }) + } + // Why before the runtime and the PTY handlers: `setLocalPtyProvider` installs the daemon // adapter as THE local provider, and the registry's contract is that it lands before // registerPtyHandlers so the IPC layer routes through the daemon from the first call. @@ -186,16 +218,37 @@ async function startOrcadRuntime( // what powers serve→desktop promotion. A Node host can never do that, and the // constructor's default would advertise it. getDesktopWindowStatus: () => 'blocked', + // Why here too and not only on the desktop: main's OSC parse is the only producer for a + // PTY agent on this host, and the store is the only place `worktree.ps` and the mobile + // projection read from — unwired, orcad lists no PTY agents at all. + onTerminalAgentStatus: (event) => agentHookServer.ingestTerminalStatus(event), // Why here too and not only on the desktop: orcad serves `worktree.ps` and `agentSession.*`, // so without these a headless host publishes its structured chats nowhere and lists no agents. getAgentStatusSnapshot: () => agentHookServer.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true), + getAgentProviderSessionSnapshot: () => agentHookServer.getStatusSnapshot(), + getAgentProviderSessionRowsForPane: (paneKey) => + agentHookServer.getStatusSnapshotForPane(paneKey), + // Why captured rather than resolved at read: the fleet snapshot remints cached rows on every + // read, so a row observed under one process otherwise acquires whatever process owns the pane now. + readObservedAgentStatusPaneIdentity: (paneKey) => observedPaneIdentities.read(paneKey), structuredAgentStatusSink: { publish: (summary) => agentHookServer.ingestStructuredStatus(summary), forget: (sessionId) => agentHookServer.dropStructuredStatus(sessionId) - } + }, + reconcileAgentStatusForEndedProcess: (paneKeys) => + agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), + buildAgentHookPtyEnv: () => + isAgentStatusHooksEnabled(store.getSettings()) ? agentHookServer.buildPtyEnv() : {} }) + // Why here too and not only on the desktop: nothing else republishes `session.tabs` when a + // pane's status row changes, and orcad's whole job is serving paired clients. + uninstallHookStatusRepublish = installHookStatusSessionTabsRepublish( + agentHookServer, + () => runtime + ) + // Why the headless entry point rather than registerPtyHandlers directly: this is the // same call `--serve` makes, and it threads the store through. Without the store the // handlers install fine and every terminal.create then fails at persistence time. @@ -213,8 +266,11 @@ async function startOrcadRuntime( await runtime.refreshRestoredOrchestrationAuthority() await runtime.reconcileLegacyWorkerTerminals() + // Recovery binds terminal and dispatch identities; only now can startup observations be fenced. + observedStatusCapture.attach(runtime) + const bindHost = resolveOrcadBindHost(options.bind) - const rpc = new OrcaRuntimeRpcServer({ + rpc = new OrcaRuntimeRpcServer({ runtime, userDataPath: runtimeUserDataPath, enableWebSocket: true, @@ -279,23 +335,7 @@ async function startOrcadRuntime( mode: options.json ? 'json' : 'human' }) - return { - readiness, - stop: async () => { - try { - await rpc.stop() - } finally { - // Why disconnect and not shut down: the daemon must outlive this process, or an - // orcad restart goes back to killing every running terminal. See - // orcad-daemon-supervision.ts. - await stopOrcadDaemon() - await browserProvider?.stop() - setRuntimeBrowserCommandsFactory(null) - runOrcadQuitHandlers() - instanceLock.release() - } - } - } + return { readiness } } export function parseArgs(argv: string[]): OrcadOptions { diff --git a/src/main/orcad/orcad-launch-contract.test.ts b/src/main/orcad/orcad-launch-contract.test.ts index b22dc74f0e4..2d2e4e6157e 100644 --- a/src/main/orcad/orcad-launch-contract.test.ts +++ b/src/main/orcad/orcad-launch-contract.test.ts @@ -2,13 +2,14 @@ * The two things a supervisor reads off a launch: what the arguments mean, and what an exit * code means. Both are part of the ops contract in docs/reference/orcad-operations.md. */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { ORCAD_EXIT_CONFIGURATION, ORCAD_EXIT_FAILED, parseArgs, resolveOrcadExitCode } from './orcad-entry' +import { startOrcadWithLifecycle } from './orcad-lifecycle' import { OrcadBindAddressError } from './orcad-bind-address' import { OrcadInstanceLockError } from './orcad-instance-lock' @@ -41,3 +42,58 @@ describe('resolveOrcadExitCode', () => { expect(ORCAD_EXIT_CONFIGURATION).not.toBe(ORCAD_EXIT_FAILED) }) }) + +describe('orcad lifecycle cleanup', () => { + it('uninstalls registered runtime resources when startup fails', async () => { + const cleanupRuntime = vi.fn(async () => {}) + const cleanupHost = vi.fn(async () => {}) + + await expect( + startOrcadWithLifecycle(async (registerCleanup) => { + registerCleanup(cleanupRuntime) + await Promise.resolve() + throw new Error('startup failed') + }, cleanupHost) + ).rejects.toThrow('startup failed') + + expect(cleanupRuntime).toHaveBeenCalledOnce() + expect(cleanupHost).toHaveBeenCalledOnce() + }) + + it('preserves the startup error when rollback also fails', async () => { + const startupError = new Error('bind failed') + const cleanupError = new Error('daemon stop failed') + const cleanupRuntime = vi.fn(async () => {}) + const cleanupHost = vi.fn(async () => { + throw cleanupError + }) + const report = vi.spyOn(console, 'error').mockImplementation(() => {}) + + try { + await expect( + startOrcadWithLifecycle(async (registerCleanup) => { + registerCleanup(cleanupRuntime) + throw startupError + }, cleanupHost) + ).rejects.toBe(startupError) + expect(report).toHaveBeenCalledWith('[orcad] startup cleanup failed:', cleanupError) + } finally { + report.mockRestore() + } + }) + + it('coalesces concurrent and repeated normal stops', async () => { + const cleanupRuntime = vi.fn(async () => {}) + const cleanupHost = vi.fn(async () => {}) + const handle = await startOrcadWithLifecycle(async (registerCleanup) => { + registerCleanup(cleanupRuntime) + return { readiness: 'ready' } + }, cleanupHost) + + await Promise.all([handle.stop(), handle.stop()]) + await handle.stop() + + expect(cleanupRuntime).toHaveBeenCalledOnce() + expect(cleanupHost).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/orcad/orcad-lifecycle.ts b/src/main/orcad/orcad-lifecycle.ts new file mode 100644 index 00000000000..913a21c4874 --- /dev/null +++ b/src/main/orcad/orcad-lifecycle.ts @@ -0,0 +1,35 @@ +function createIdempotentOrcadCleanup(cleanup: () => Promise): () => Promise { + let completion: Promise | null = null + return () => { + completion ??= Promise.resolve().then(cleanup) + return completion + } +} + +export async function startOrcadWithLifecycle( + start: (registerRuntimeCleanup: (cleanup: () => Promise) => void) => Promise, + cleanupHost: () => Promise +): Promise }> { + let cleanupRuntime = async (): Promise => {} + const cleanup = createIdempotentOrcadCleanup(async () => { + try { + await cleanupRuntime() + } finally { + await cleanupHost() + } + }) + try { + const handle = await start((nextCleanup) => { + cleanupRuntime = nextCleanup + }) + return { ...handle, stop: cleanup } + } catch (error) { + try { + await cleanup() + } catch (cleanupError) { + // Keep the launch failure as the supervisor-facing verdict; cleanup still needs a breadcrumb. + console.error('[orcad] startup cleanup failed:', cleanupError) + } + throw error + } +} diff --git a/src/main/runtime/agent-status-observed-pane-identity.ts b/src/main/runtime/agent-status-observed-pane-identity.ts index 773bddc7f3c..e26929f2090 100644 --- a/src/main/runtime/agent-status-observed-pane-identity.ts +++ b/src/main/runtime/agent-status-observed-pane-identity.ts @@ -3,6 +3,7 @@ import { type AgentStatusRuntimeEnrichment, type ObservedAgentStatusPaneIdentity } from '../ipc/agent-status-ipc-boundary' +import type { EnrichedAgentHookEventPayload } from '../agent-hooks/server/server-types' /** Bounded like the hook server's own per-pane maps; eviction only degrades a row to `unobserved`. */ const MAX_OBSERVED_PANES = 1024 @@ -44,6 +45,30 @@ export class AgentStatusObservedPaneIdentities { } } +/** Buffers startup replay until PTY recovery has restored the runtime identities it fences. */ +export class AgentStatusObservedPaneIdentityCapture { + private readonly pending = new Map() + private runtime: AgentStatusRuntimeEnrichment | null = null + + constructor(private readonly identities: AgentStatusObservedPaneIdentities) {} + + observe(enriched: EnrichedAgentHookEventPayload): void { + if (this.runtime) { + recordObservedAgentStatusPaneIdentity(this.identities, enriched.paneKey, this.runtime) + return + } + this.pending.set(enriched.paneKey, enriched) + } + + attach(runtime: AgentStatusRuntimeEnrichment): void { + this.runtime = runtime + for (const enriched of this.pending.values()) { + recordObservedAgentStatusPaneIdentity(this.identities, enriched.paneKey, runtime) + } + this.pending.clear() + } +} + /** Ingest-time capture: resolve the pane once, as the status arrives, and keep that answer. */ export function recordObservedAgentStatusPaneIdentity( identities: AgentStatusObservedPaneIdentities, diff --git a/src/main/runtime/agent-status-store-wiring.test-fixture.ts b/src/main/runtime/agent-status-store-wiring.test-fixture.ts new file mode 100644 index 00000000000..1f399e0464f --- /dev/null +++ b/src/main/runtime/agent-status-store-wiring.test-fixture.ts @@ -0,0 +1,51 @@ +import { AgentHookServer } from '../agent-hooks/server' +import { installHookStatusSessionTabsRepublish } from '../agent-hooks/hook-status-session-tabs-republish' + +type WiredRuntime = { + getTerminalWorktreeIdForHandle(handle: string): string | null + getTerminalWorktreeIdForPaneKey(paneKey: string): string | null + scheduleMobileSessionTabsAgentStatusHeartbeatForWorktree(worktreeId: string): void + touchMobileSessionTabsForWorktree(worktreeId: string): void +} + +/** + * The agent-status wiring every real host performs, in one place for the runtime specs. + * + * `main-process-runtime-service.ts` and `orcad-entry.ts` both hand the runtime's OSC parse to + * the store, read the listing back out of it, and install the republish signal. A runtime + * constructed without these observes agent status and publishes it nowhere, so a spec that + * exercises OSC 9999 has to compose the same three parts. + */ +export function makeAgentStatusStoreWiring(): { + statusStore: AgentHookServer + deps: { + onTerminalAgentStatus: (event: Parameters[0]) => void + getAgentStatusSnapshot: () => ReturnType + getAgentProviderSessionSnapshot: () => ReturnType + getAgentProviderSessionRowsForPane: ( + paneKey: string + ) => ReturnType + reconcileAgentStatusForEndedProcess: ( + paneKeys: Parameters[0] + ) => void + } + /** Call once the runtime exists; returns the republish teardown. */ + attach: (runtime: WiredRuntime) => () => void +} { + const statusStore = new AgentHookServer() + return { + statusStore, + deps: { + onTerminalAgentStatus: (event) => statusStore.ingestTerminalStatus(event), + getAgentStatusSnapshot: () => + statusStore.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true), + getAgentProviderSessionSnapshot: () => statusStore.getStatusSnapshot(), + getAgentProviderSessionRowsForPane: (paneKey) => + statusStore.getStatusSnapshotForPane(paneKey), + reconcileAgentStatusForEndedProcess: (paneKeys) => { + statusStore.reconcileEndedProcessForPaneKeys(paneKeys) + } + }, + attach: (runtime) => installHookStatusSessionTabsRepublish(statusStore, () => runtime) + } +} diff --git a/src/main/runtime/agent-transcript-pane-test-harness.ts b/src/main/runtime/agent-transcript-pane-test-harness.ts index f3a9a64793c..4345e98fd93 100644 --- a/src/main/runtime/agent-transcript-pane-test-harness.ts +++ b/src/main/runtime/agent-transcript-pane-test-harness.ts @@ -19,9 +19,10 @@ export type TranscriptPaneOptions = { } export async function createTranscriptPane( - options: TranscriptPaneOptions + options: TranscriptPaneOptions, + runtimeDeps?: ConstructorParameters[2] ): Promise<{ runtime: OrcaRuntimeService; handle: string }> { - const runtime = new OrcaRuntimeService(null) + const runtime = new OrcaRuntimeService(null, undefined, runtimeDeps) const internals = runtime as unknown as { resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise } diff --git a/src/main/runtime/mobile-agent-status-permission-renewal.test.ts b/src/main/runtime/mobile-agent-status-permission-renewal.test.ts index 9b7f0ef457d..a802844d826 100644 --- a/src/main/runtime/mobile-agent-status-permission-renewal.test.ts +++ b/src/main/runtime/mobile-agent-status-permission-renewal.test.ts @@ -92,6 +92,18 @@ describe('mobile/paired projection for a pane pending a human answer', () => { expect(out?.state).toBe('done') }) + it('does not let replay delivery time make old working evidence outrank a newer title', () => { + const hookAt = Date.now() - 1_000 + const replayedAt = Date.now() + const out = renewFromPtyTitle()( + { ...claudeStatus('working', replayedAt), evidenceObservedAt: hookAt }, + parkedOnPromptPty(hookAt), + { preserveQuestionUnderShellTitle: true } + ) + + expect(out?.state).toBe('done') + }) + // Why: an idle title is the ABSENCE of activity evidence, so it cannot outrank the hook. // A `working` title is positive evidence the agent resumed, which does — otherwise a // finished turn's question card would linger into the next working interval (#11761). diff --git a/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts b/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts index 582837826ac..416cb2fdf46 100644 --- a/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts +++ b/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts @@ -189,4 +189,19 @@ describe('mobile session-tabs agent-status heartbeat', () => { expect(emitted).toEqual([]) expect(vi.getTimerCount()).toBe(0) }) + + it('keeps a direct status heartbeat queued when an unrelated PTY is removed', () => { + const emitted: string[] = [] + const heartbeat = createMobileSessionTabsAgentStatusHeartbeat( + () => [], + (worktreeId) => emitted.push(worktreeId) + ) + + heartbeat.scheduleWorktreeHeartbeat('worktree-1') + heartbeat.removePty('unrelated-pty') + vi.runAllTimers() + + expect(emitted).toEqual(['worktree-1']) + heartbeat.dispose() + }) }) diff --git a/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.ts b/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.ts index 6c80cf4a35d..df457c6bb27 100644 --- a/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.ts +++ b/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.ts @@ -6,7 +6,9 @@ export const SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS = 50 export type MobileSessionTabsAgentStatusHeartbeat = { observeSemanticTitle: (ptyId: string) => void + observeWorktreeRefresh: (worktreeId: string) => void scheduleDecorativeHeartbeat: (ptyId: string) => void + scheduleWorktreeHeartbeat: (worktreeId: string) => void removePty: (ptyId: string) => void removeWorktree: (worktreeId: string) => void cancelPending: () => void @@ -19,7 +21,7 @@ export function createMobileSessionTabsAgentStatusHeartbeat( ): MobileSessionTabsAgentStatusHeartbeat { const lastEligibilityCheckAtByPtyId = new Map() const lastRefreshAtByWorktreeId = new Map() - const pendingPtyIdsByWorktreeId = new Map>() + const pendingByWorktreeId = new Map }>() let lastGlobalHeartbeatAt: number | null = null let timer: ReturnType | null = null @@ -30,8 +32,16 @@ export function createMobileSessionTabsAgentStatusHeartbeat( } } + const observeWorktreeRefresh = (worktreeId: string, observedAt = Date.now()): void => { + lastRefreshAtByWorktreeId.set(worktreeId, observedAt) + pendingByWorktreeId.delete(worktreeId) + if (pendingByWorktreeId.size === 0) { + clearTimer() + } + } + const arm = (): void => { - if (timer !== null || pendingPtyIdsByWorktreeId.size === 0) { + if (timer !== null || pendingByWorktreeId.size === 0) { return } const now = Date.now() @@ -44,15 +54,15 @@ export function createMobileSessionTabsAgentStatusHeartbeat( ) timer = setTimeout(() => { timer = null - const worktreeId = pendingPtyIdsByWorktreeId.keys().next().value + const worktreeId = pendingByWorktreeId.keys().next().value if (typeof worktreeId !== 'string') { return } - const pendingPtyIds = pendingPtyIdsByWorktreeId.get(worktreeId) - pendingPtyIdsByWorktreeId.delete(worktreeId) + const pending = pendingByWorktreeId.get(worktreeId) + pendingByWorktreeId.delete(worktreeId) const emittedAt = Date.now() lastRefreshAtByWorktreeId.set(worktreeId, emittedAt) - for (const ptyId of pendingPtyIds ?? []) { + for (const ptyId of pending?.ptyIds ?? []) { lastEligibilityCheckAtByPtyId.set(ptyId, emittedAt) } lastGlobalHeartbeatAt = emittedAt @@ -64,18 +74,37 @@ export function createMobileSessionTabsAgentStatusHeartbeat( } } + const scheduleWorktreeHeartbeat = (worktreeId: string, ptyId?: string): void => { + const now = Date.now() + const lastRefreshAt = lastRefreshAtByWorktreeId.get(worktreeId) + if ( + lastRefreshAt !== undefined && + now - lastRefreshAt < SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS + ) { + return + } + const pending = pendingByWorktreeId.get(worktreeId) ?? { + directObservation: false, + ptyIds: new Set() + } + if (ptyId) { + pending.ptyIds.add(ptyId) + } else { + pending.directObservation = true + } + pendingByWorktreeId.set(worktreeId, pending) + arm() + } + return { observeSemanticTitle(ptyId: string): void { const observedAt = Date.now() lastEligibilityCheckAtByPtyId.set(ptyId, observedAt) for (const worktreeId of resolveWorktreeIds(ptyId)) { - lastRefreshAtByWorktreeId.set(worktreeId, observedAt) - pendingPtyIdsByWorktreeId.delete(worktreeId) - } - if (pendingPtyIdsByWorktreeId.size === 0) { - clearTimer() + observeWorktreeRefresh(worktreeId, observedAt) } }, + observeWorktreeRefresh, scheduleDecorativeHeartbeat(ptyId: string): void { const now = Date.now() const lastEligibilityCheckAt = lastEligibilityCheckAtByPtyId.get(ptyId) @@ -87,44 +116,36 @@ export function createMobileSessionTabsAgentStatusHeartbeat( } lastEligibilityCheckAtByPtyId.set(ptyId, now) for (const worktreeId of resolveWorktreeIds(ptyId)) { - const lastRefreshAt = lastRefreshAtByWorktreeId.get(worktreeId) - if ( - lastRefreshAt === undefined || - now - lastRefreshAt >= SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS - ) { - const pendingPtyIds = pendingPtyIdsByWorktreeId.get(worktreeId) ?? new Set() - pendingPtyIds.add(ptyId) - pendingPtyIdsByWorktreeId.set(worktreeId, pendingPtyIds) - } + scheduleWorktreeHeartbeat(worktreeId, ptyId) } - arm() }, + scheduleWorktreeHeartbeat, removePty(ptyId: string): void { lastEligibilityCheckAtByPtyId.delete(ptyId) - for (const [worktreeId, pendingPtyIds] of pendingPtyIdsByWorktreeId) { - pendingPtyIds.delete(ptyId) - if (pendingPtyIds.size === 0) { - pendingPtyIdsByWorktreeId.delete(worktreeId) + for (const [worktreeId, pending] of pendingByWorktreeId) { + pending.ptyIds.delete(ptyId) + if (pending.ptyIds.size === 0 && !pending.directObservation) { + pendingByWorktreeId.delete(worktreeId) } } - if (pendingPtyIdsByWorktreeId.size === 0) { + if (pendingByWorktreeId.size === 0) { clearTimer() } }, removeWorktree(worktreeId: string): void { lastRefreshAtByWorktreeId.delete(worktreeId) - pendingPtyIdsByWorktreeId.delete(worktreeId) - if (pendingPtyIdsByWorktreeId.size === 0) { + pendingByWorktreeId.delete(worktreeId) + if (pendingByWorktreeId.size === 0) { clearTimer() } }, cancelPending(): void { clearTimer() - pendingPtyIdsByWorktreeId.clear() + pendingByWorktreeId.clear() }, dispose(): void { clearTimer() - pendingPtyIdsByWorktreeId.clear() + pendingByWorktreeId.clear() lastEligibilityCheckAtByPtyId.clear() lastRefreshAtByWorktreeId.clear() lastGlobalHeartbeatAt = null diff --git a/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts b/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts index 605eacdbe29..6d7e59c383f 100644 --- a/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts +++ b/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts @@ -173,8 +173,8 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper leaf.waitBlockedAt = null leaf.tailWaitState = undefined } + this.reconcileAgentStatusForEndedProcessFn?.(this.collectAgentStatusPaneKeysForPty(ptyId)) this.primeWaitBlockedBaselineFromSeededTail(ptyId) - this.clearAgentRowSnapshotsForPty(ptyId) } protected setTerminalSideEffectConsumerAvailable(available: boolean): void { diff --git a/src/main/runtime/orca-runtime-bind-pty-incarnation-handle.ts b/src/main/runtime/orca-runtime-bind-pty-incarnation-handle.ts index 69f9be4ba85..3f1a79beb77 100644 --- a/src/main/runtime/orca-runtime-bind-pty-incarnation-handle.ts +++ b/src/main/runtime/orca-runtime-bind-pty-incarnation-handle.ts @@ -55,7 +55,7 @@ export class OrcaRuntimeWithBindPtyIncarnationHandle extends OrcaRuntimeWithBuil const pty = this.ptysById.get(ptyId) const leaves = this.getLeavesForPty(ptyId) if ( - !pty?.incarnationId || + !pty || pty.incarnationId !== retained.incarnationId || leaves.length !== 1 || this.handleByPtyId.has(ptyId) @@ -91,6 +91,10 @@ export class OrcaRuntimeWithBindPtyIncarnationHandle extends OrcaRuntimeWithBuil } protected issuePtyHandle(pty: RuntimePtyWorktreeRecord): string { + const retained = this.handleByPtyIncarnation.get(pty.ptyId) + if (retained?.incarnationId === pty.incarnationId) { + return retained.handle + } const existingHandle = this.handleByPtyId.get(pty.ptyId) ?? this.findHandleForPtyRecord(pty.ptyId) if (existingHandle) { diff --git a/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts b/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts index d781bd2ae90..615353bbc13 100644 --- a/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts +++ b/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts @@ -173,7 +173,7 @@ export class OrcaRuntimeWithBuildPtyTerminalSummary extends OrcaRuntimeWithGetPt ptyGeneration: leaf.ptyGeneration }) this.handleByLeafKey.set(leafKey, handle) - if (leaf.ptyId && incarnationId) { + if (leaf.ptyId) { this.handleByPtyIncarnation.set(leaf.ptyId, { handle, incarnationId, leafKey }) } return handle diff --git a/src/main/runtime/orca-runtime-create-terminal-side-effect-command-code-detector.ts b/src/main/runtime/orca-runtime-create-terminal-side-effect-command-code-detector.ts index 9a9a6c1c146..5486e953989 100644 --- a/src/main/runtime/orca-runtime-create-terminal-side-effect-command-code-detector.ts +++ b/src/main/runtime/orca-runtime-create-terminal-side-effect-command-code-detector.ts @@ -11,7 +11,6 @@ import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import type { ProcessedAgentStatusChunk } from '../../shared/agent-status-osc' import { mapExplicitAgentStateToRuntimeTerminalStatus } from './runtime-worktree-status-projection' -import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' export class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector extends OrcaRuntimeWithApplyTrackedPtyTitle { protected createTerminalSideEffectCommandCodeDetector( @@ -86,17 +85,9 @@ export class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector extends return worktreePath && isWindowsAbsolutePathLike(worktreePath) ? 'win32' : 'posix' } - /** Returns true when any retained agent-row snapshot changed in a - * client-visible way, so the caller can republish session snapshots. */ - protected emitTerminalAgentStatusEvents( - ptyId: string, - chunk: ProcessedAgentStatusChunk - ): boolean { - // Why: snapshot retention (for mobile worktree.ps) must run even when no - // renderer listener is attached, so we don't early-return on a missing - // onTerminalAgentStatus — only the per-target emit below is gated on it. + protected emitTerminalAgentStatusEvents(ptyId: string, chunk: ProcessedAgentStatusChunk): void { if (chunk.payloads.length === 0) { - return false + return } const targets = new Map< string, @@ -106,6 +97,7 @@ export class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector extends tabId?: string worktreeId?: string connectionId?: string | null + terminalHandle?: string } >() const pty = this.ptysById.get(ptyId) @@ -129,22 +121,24 @@ export class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector extends connectionId }) } - let retainedChanged = false + // Why once per chunk and not per payload: the same lookup the renderer-facing IPC boundary + // runs, and it is the pane's only durable join back to its terminal once the pane key moves. + if (this.onTerminalAgentStatus) { + for (const target of targets.values()) { + const terminalHandle = this.getAgentStatusTerminalHandleForPaneKey(target.paneKey) + if (terminalHandle) { + target.terminalHandle = terminalHandle + } + } + } for (const payload of chunk.payloads) { + // Why not gated on a listener: the prompt lifecycle is main's own state, read by + // terminal waits that run with no status consumer attached. this.recordAgentPromptLifecycleState( ptyId, mapExplicitAgentStateToRuntimeTerminalStatus(payload.state) ) for (const target of targets.values()) { - retainedChanged = - this.retainAgentRowSnapshot( - ptyId, - target.paneKey, - target.worktreeId, - target.tabId, - target.connectionId ?? null, - payload - ) || retainedChanged if (!this.onTerminalAgentStatus) { continue } @@ -165,28 +159,5 @@ export class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector extends } } } - return retainedChanged - } - - protected retainAgentRowSnapshot( - ptyId: string, - paneKey: string, - worktreeId: string | undefined, - tabId: string | undefined, - connectionId: string | null, - payload: ParsedAgentStatusPayload - ): boolean { - return this.agentRows.retain({ - ptyId, - paneKey, - worktreeId, - tabId, - connectionId, - payload - }) - } - - protected clearAgentRowSnapshotsForPty(ptyId: string): void { - this.agentRows.clearPty(ptyId) } } diff --git a/src/main/runtime/orca-runtime-fit-override-listeners.ts b/src/main/runtime/orca-runtime-fit-override-listeners.ts index 63adb867d55..5ef17ce4c2e 100644 --- a/src/main/runtime/orca-runtime-fit-override-listeners.ts +++ b/src/main/runtime/orca-runtime-fit-override-listeners.ts @@ -13,7 +13,6 @@ import type { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kit import type { PtyProviderBufferSnapshot } from '../providers/types' import type { WaitBlockedCheckState } from './wait-blocked-check-state' import type { createAgentStatusOscProcessor } from '../../shared/agent-status-osc' -import { RuntimeAgentRowStore } from './runtime-agent-row-store' import { RuntimeTerminalViewSubscribers } from './runtime-terminal-view-subscribers' import { parseAppSshPtyId } from '../../shared/ssh-pty-id' @@ -125,11 +124,6 @@ export class OrcaRuntimeWithFitOverrideListeners extends OrcaRuntimeWithStopRequ protected terminalFileUriHostnameByPtyId = new Map() - // Why: latest agent-status payload per pane, retained so worktree.ps can serve - // mobile the same inline agent rows the desktop sidebar renders. Cleared on pty - // teardown so dead agents don't linger. See RuntimeAgentRowSnapshot. - protected readonly agentRows = new RuntimeAgentRowStore() - // Why: per-PTY hydration state guards against double-hydration. Keys: // 'pending' → maybeHydrateHeadlessFromRenderer is in flight // 'done' → hydration completed (success or skip); never run again diff --git a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts index 14345d10a77..d61374c3466 100644 --- a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts +++ b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts @@ -2,7 +2,12 @@ import { OrcaRuntimeWithVerifyOrchestrationCompatibilityCaller } from './orca-runtime-verify-orchestration-compatibility-caller' import type { OrchestrationCompatibilityTerminalAuthority } from './runtime-terminal-contracts' import { createHash } from 'node:crypto' -import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' +import { + isTerminalLeafId, + makePaneKey, + parseLegacyNumericPaneKey, + parsePaneKey +} from '../../shared/stable-pane-id' import { isValidTerminalTabId } from '../../shared/terminal-tab-id' import { RECENT_PTY_OUTPUT_LIMIT, RecentPtyOutputBuffer } from './recent-pty-output-buffer' import { appendRecentPtyPathCandidates } from './terminal-output-path-candidates' @@ -38,6 +43,30 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim return paneKeys } + /** Status cleanup also owns runtime-admitted legacy OSC rows; orchestration authority does not. */ + protected collectAgentStatusPaneKeysForPty(ptyId: string): Set { + const paneKeys = this.collectPaneKeysForPty(ptyId) + const terminalHandles = new Set(this.getExistingTerminalHandlesForPtyId(ptyId)) + // The provider-session snapshot is the unfiltered store view, so certified exit can also + // retire a dismissed row's identity-only remnant after its pane binding moved. + for (const row of this.getAgentProviderSessionSnapshotFn?.() ?? []) { + if (row.terminalHandle && terminalHandles.has(row.terminalHandle)) { + paneKeys.add(row.paneKey) + } + } + const ptyPaneKey = this.ptysById.get(ptyId)?.paneKey + if (ptyPaneKey && parseLegacyNumericPaneKey(ptyPaneKey)) { + paneKeys.add(ptyPaneKey) + } + for (const leaf of this.getLeavesForPty(ptyId)) { + const paneKey = this.makeRuntimePaneKey(leaf) + if (parseLegacyNumericPaneKey(paneKey)) { + paneKeys.add(paneKey) + } + } + return paneKeys + } + getOrchestrationDispatchAuthority( terminalHandle: string ): OrchestrationCompatibilityTerminalAuthority | null { diff --git a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts index 0c41b176d34..27012bdf8e9 100644 --- a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts +++ b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts @@ -64,7 +64,7 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM return makePaneKey(record.tabId, record.leafId) } - protected getWorktreeIdForTerminalHandle(handle: string): string | null { + getTerminalWorktreeIdForHandle(handle: string): string | null { const livePty = this.getLivePtyForHandle(handle) if (livePty?.pty.worktreeId) { return livePty.pty.worktreeId diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index ade25ad1975..3eb7a6e9401 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -96,6 +96,7 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent missingIds: missingRuntimeWorktreeIds, ptysById: this.ptysById, tabs: this.tabs, + getTerminalHandlesForPty: (ptyId) => this.getExistingTerminalHandlesForPtyId(ptyId), getSummary: (summaryMap, pathIndex, missingIds, worktreeId) => this.getSummaryForRuntimeWorktreeId(summaryMap, pathIndex, missingIds, worktreeId) }) @@ -107,7 +108,6 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent rowSources: collectRuntimeWorktreeAgentSources({ mirroredWorktreeIdByTabId, connectedPtyEvidence, - retainedSnapshots: this.agentRows.values(), // Structured sessions are in here too: the host publishes them into the same store. hookSnapshots: this.getAgentStatusSnapshotFn?.() ?? [] }), diff --git a/src/main/runtime/orca-runtime-has-terminals-for-worktree.ts b/src/main/runtime/orca-runtime-has-terminals-for-worktree.ts index 77082530ed2..ebedfb362fc 100644 --- a/src/main/runtime/orca-runtime-has-terminals-for-worktree.ts +++ b/src/main/runtime/orca-runtime-has-terminals-for-worktree.ts @@ -55,6 +55,12 @@ export class OrcaRuntimeWithHasTerminalsForWorktree extends OrcaRuntimeWithStopE const revision = this.graphReloadLifecycle.begin(windowId) this.setTerminalSideEffectConsumerAvailable(false) this.rememberDetachedPreAllocatedLeaves() + // A null incarnation is safe within one graph diff, but cannot prove a same-id PTY survived a renderer reload. + for (const [ptyId, retained] of this.handleByPtyIncarnation) { + if (retained.incarnationId === null) { + this.invalidatePtyIncarnationHandle(ptyId) + } + } const retainedHandles = new Set([ ...this.handleByPtyId.values(), ...[...this.handleByPtyIncarnation.values()].map((record) => record.handle) diff --git a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts index f8171862728..9fab2267d25 100644 --- a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts +++ b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts @@ -2,6 +2,7 @@ // live agent state, so `session.tabs` must project the hook row's status fields — not // just its identity — while still refusing rows that only prove an agent once existed. import { describe, expect, it, vi } from 'vitest' +import { makeAgentStatusStoreWiring } from './agent-status-store-wiring.test-fixture' import { OrcaRuntimeService } from './orca-runtime' import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-types' import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' @@ -58,11 +59,25 @@ function hookRow(overrides: Partial = {}): AgentStatusIpc } async function createRuntimeWithHookRows( - rows: AgentStatusIpcPayload[] + rows: AgentStatusIpcPayload[], + /** Pass a store to exercise the OSC producer; otherwise the rows stand in for it. */ + statusWiring?: ReturnType ): Promise { + const readRows = statusWiring + ? (): AgentStatusIpcPayload[] => [...rows, ...statusWiring.deps.getAgentStatusSnapshot()] + : (): AgentStatusIpcPayload[] => rows const runtime = new OrcaRuntimeService(null, undefined, { - getAgentStatusSnapshot: () => rows, - getAgentProviderSessionRowsForPane: () => rows + ...(statusWiring + ? { + onTerminalAgentStatus: statusWiring.deps.onTerminalAgentStatus, + reconcileAgentStatusForEndedProcess: + statusWiring.deps.reconcileAgentStatusForEndedProcess, + getAgentProviderSessionSnapshot: statusWiring.deps.getAgentProviderSessionSnapshot, + getAgentProviderSessionRowsForPane: statusWiring.deps.getAgentProviderSessionRowsForPane + } + : {}), + getAgentStatusSnapshot: readRows, + ...(statusWiring ? {} : { getAgentProviderSessionRowsForPane: readRows }) }) const internals = runtime as unknown as { resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise @@ -224,8 +239,18 @@ describe('headless hook agent-status projection (#11761)', () => { }) // #7970: a retained OSC 9999 row is the pane's own report and keeps precedence. - it('prefers a retained OSC 9999 row over the hook row', async () => { - const runtime = await createRuntimeWithHookRows([hookRow()]) + it('projects the OSC turn that replaced the hook row in the store', async () => { + // One store: an OSC turn is a write, not a competing copy, so the pane projects whatever + // the store holds now rather than a reader-side preference between two rows. + const statusWiring = makeAgentStatusStoreWiring() + statusWiring.statusStore.ingestTerminalStatus({ + paneKey: PANE_KEY, + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + connectionId: null, + payload: { state: 'waiting', prompt: 'Tabs or spaces?', agentType: 'claude' } + }) + const runtime = await createRuntimeWithHookRows([], statusWiring) runtime.onPtyData( PTY_ID, '\x1b]9999;{"state":"working","prompt":"fix the tests","agentType":"claude"}\x07', @@ -310,6 +335,124 @@ describe('headless hook agent-status projection (#11761)', () => { expect(tab?.type === 'terminal' && tab.agentStatus).not.toHaveProperty('interactivePrompt') }) + it('evicts the predecessor row at a certified provider generation reset', async () => { + const statusWiring = makeAgentStatusStoreWiring() + const runtime = await createRuntimeWithHookRows([], statusWiring) + runtime.onPtyData( + PTY_ID, + '\x1b]9999;{"state":"working","prompt":"predecessor","agentType":"claude"}\x07', + 1 + ) + expect(statusWiring.statusStore.getStatusSnapshot()).toHaveLength(1) + + const internals = runtime as unknown as { + resetTrackedTerminalStateForProviderGeneration: (ptyId: string) => void + } + internals.resetTrackedTerminalStateForProviderGeneration(PTY_ID) + + expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([]) + statusWiring.statusStore.stop() + }) + + it('evicts a row joined only through the terminal handle on certified PTY exit', async () => { + const statusWiring = makeAgentStatusStoreWiring() + const runtime = await createRuntimeWithHookRows([], statusWiring) + const terminal = (await runtime.listTerminals()).terminals[0] + if (!terminal) { + throw new Error('expected a live terminal') + } + const priorPaneKey = makePaneKey('prior-tab', UNKNOWN_LEAF_ID) + statusWiring.statusStore.ingestTerminalStatus({ + paneKey: priorPaneKey, + tabId: 'prior-tab', + terminalHandle: terminal.handle, + payload: { state: 'working', prompt: 'prior pane', agentType: 'claude' } + }) + expect(statusWiring.statusStore.getStatusSnapshot()).toHaveLength(1) + + runtime.onPtyExit(PTY_ID, 0) + + expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([]) + statusWiring.statusStore.stop() + }) + + it('evicts the central status row when a disconnected PTY record is pruned', async () => { + const statusWiring = makeAgentStatusStoreWiring() + const runtime = await createRuntimeWithHookRows([], statusWiring) + runtime.onPtyData( + PTY_ID, + '\x1b]9999;{"state":"working","prompt":"before prune","agentType":"claude"}\x07', + 1 + ) + expect(statusWiring.statusStore.getStatusSnapshot()).toHaveLength(1) + + const internals = runtime as unknown as { + dropDisconnectedPtyRecord: (ptyId: string) => void + } + internals.dropDisconnectedPtyRecord(PTY_ID) + + expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([]) + statusWiring.statusStore.stop() + }) + + it('keeps an unverifiable remote row when its disconnected PTY record is pruned', async () => { + const statusWiring = makeAgentStatusStoreWiring() + const runtime = await createRuntimeWithHookRows([], statusWiring) + const internals = runtime as unknown as { + ptysById: Map + dropDisconnectedPtyRecord: (ptyId: string) => void + } + const pty = internals.ptysById.get(PTY_ID)! + pty.connectionId = 'ssh-target' + runtime.onPtyData( + PTY_ID, + '\x1b]9999;{"state":"working","prompt":"remote work","agentType":"claude"}\x07', + 1 + ) + pty.connected = false + + internals.dropDisconnectedPtyRecord(PTY_ID) + + expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([ + expect.objectContaining({ connectionId: 'ssh-target', prompt: 'remote work' }) + ]) + statusWiring.statusStore.stop() + }) + + it('evicts a dismissed handle-joined remnant on certified PTY exit', async () => { + const statusWiring = makeAgentStatusStoreWiring() + const runtime = await createRuntimeWithHookRows([], statusWiring) + const terminal = (await runtime.listTerminals()).terminals[0] + if (!terminal) { + throw new Error('expected a live terminal') + } + const priorPaneKey = makePaneKey('prior-tab', UNKNOWN_LEAF_ID) + statusWiring.statusStore.ingestTerminalStatus({ + paneKey: priorPaneKey, + tabId: 'prior-tab', + terminalHandle: terminal.handle, + payload: { state: 'working', prompt: 'dismissed pane', agentType: 'claude' } + }) + statusWiring.statusStore.ingestRemote( + { + paneKey: priorPaneKey, + tabId: 'prior-tab', + providerSession: PROVIDER_SESSION, + payload: { state: 'working', prompt: 'dismissed pane', agentType: 'claude' } + }, + null + ) + statusWiring.statusStore.dropStatusEntry(priorPaneKey) + expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: priorPaneKey, providerSessionOnly: true }) + ]) + + runtime.onPtyExit(PTY_ID, 0) + + expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([]) + statusWiring.statusStore.stop() + }) + it('does not carry a hook question across an identity-only owner title', async () => { const runtime = await createRuntimeWithHookRows([hookRow()]) const internals = runtime as unknown as { diff --git a/src/main/runtime/orca-runtime-on-pty-data.ts b/src/main/runtime/orca-runtime-on-pty-data.ts index 5d27da94c14..df384980fef 100644 --- a/src/main/runtime/orca-runtime-on-pty-data.ts +++ b/src/main/runtime/orca-runtime-on-pty-data.ts @@ -211,7 +211,6 @@ export class OrcaRuntimeWithOnPtyData extends OrcaRuntimeWithPreparePtyExecution } titleTrackerEntry.applyingChunk = true titleTrackerEntry.chunkTouchedSessionTabs = false - let retainedAgentStatusChanged = false try { for (const payload of agentStatusChunk.payloads) { titleTrackerEntry.pendingFacts.push({ kind: 'agent-status', payload }) @@ -230,7 +229,7 @@ export class OrcaRuntimeWithOnPtyData extends OrcaRuntimeWithPreparePtyExecution // Why: per-chunk cross-channel contract order is status → titles → // bell — the chunk's agentStatus:set events must reach the renderer // before its pty:sideEffect batch. - retainedAgentStatusChanged = this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk) + this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk) const lastPayloadTitleOffset = agentStatusChunk.lastPayloadCleanOffset === null ? null @@ -242,10 +241,10 @@ export class OrcaRuntimeWithOnPtyData extends OrcaRuntimeWithPreparePtyExecution this.flushPendingTerminalSideEffectFacts(ptyId, titleTrackerEntry) } } - // Why: hook (OSC 9999) transitions often arrive without a title change, so - // headless-serve snapshots would never republish and paired remote clients - // kept the stale agent state until the next title change (#7970). - if (titleTrackerEntry.chunkTouchedSessionTabs || retainedAgentStatusChanged) { + // Why only the title arm here: an OSC 9999 transition republishes off the store's own + // change signal (installHookStatusSessionTabsRepublish), which sees hook and OSC rows + // alike — a second per-chunk republish would only re-emit the same snapshot version. + if (titleTrackerEntry.chunkTouchedSessionTabs) { this.touchMobileSessionSnapshotsForPty(ptyId) } diff --git a/src/main/runtime/orca-runtime-on-pty-exit.ts b/src/main/runtime/orca-runtime-on-pty-exit.ts index 7d3626d4ef0..6ddf877f87c 100644 --- a/src/main/runtime/orca-runtime-on-pty-exit.ts +++ b/src/main/runtime/orca-runtime-on-pty-exit.ts @@ -47,7 +47,7 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte options.hostExitConfirmed !== true // Why: collect before retirePtyAgentLaunchAuthority, which deletes the restored-authority // receipt a receipt-only pane's key comes from. - const exitPaneKeys = this.collectPaneKeysForPty(ptyId) + const exitPaneKeys = this.collectAgentStatusPaneKeysForPty(ptyId) if (preservesAbnormalSshSurface) { const prior = this.ptyLivenessVerdictByPtyId.get(ptyId)?.verdict this.rememberPtyLivenessVerdict(ptyId, { @@ -153,7 +153,6 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte this.terminalCwdByPtyId.delete(ptyId) this.terminalFileUriHostnameByPtyId.delete(ptyId) this.wslDistroByPtyId.delete(ptyId) - this.clearAgentRowSnapshotsForPty(ptyId) // Why: a Claude agent-team leader whose PTY exits naturally (agent finished, // process died, renderer reload) must release its team + nested panes map. // Previously only explicit closeTerminal evicted it, so natural exits leaked diff --git a/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts b/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts index 2c704205b93..4a910f02451 100644 --- a/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts +++ b/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts @@ -1,4 +1,5 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. +import { selectFreshAgentRowForMobileTab } from './runtime-hook-agent-row-selection' import { OrcaRuntimeWithScheduleMobileSessionTabsChanged } from './orca-runtime-schedule-mobile-session-tabs-changed' import type { TabGroupLayoutNode } from '../../shared/tab-types' import type { @@ -93,11 +94,12 @@ export class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends OrcaRuntime getLiveBrowserTabs: (worktreeId) => this.getLiveBrowserTabsByPageId(worktreeId), getProviderSessionRows: (paneKey) => this.getAgentProviderSessionRowsForPaneFn?.(paneKey), getProviderSessionSnapshot: () => this.getAgentProviderSessionSnapshotFn?.() ?? [], + getStatusSnapshot: () => this.getAgentStatusSnapshotFn?.() ?? [], getLeafKey: (tabId, leafId) => this.getLeafKey(tabId, leafId), findPty: (worktreeId, tab, options) => this.findPtyForMobileTerminalTab(worktreeId, tab, options), - getRetainedStatus: (paneKey, pty, tab) => - this.getFreshRetainedAgentStatusForMobileTab(paneKey, pty, tab), + getRetainedStatus: (paneKey, pty, tab, getRows) => + this.getFreshRetainedAgentStatusForMobileTab(paneKey, pty, tab, getRows), getTrackedTitle: (ptyId) => this.getUnpersistedTrackedTitleForPty(ptyId), issuePtyHandle: (pty) => this.issuePtyHandle(pty), recordPty: (ptyId, worktreeId, state) => this.recordPtyWorktree(ptyId, worktreeId, state), @@ -128,9 +130,30 @@ export class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends OrcaRuntime protected getFreshRetainedAgentStatusForMobileTab( paneKey: string, pty: RuntimePtyWorktreeRecord | null, - tab: RuntimeMobileSessionTerminalTab + _tab: RuntimeMobileSessionTerminalTab, + getRows: (paneKey: string, terminalHandle: string | null) => AgentStatusIpcPayload[] ): RuntimeAgentRowSnapshot | null { - return this.agentRows.getFreshForMobile(paneKey, pty, tab) + const paneMatch = selectFreshAgentRowForMobileTab({ + paneKey, + terminalHandle: null, + hookRows: getRows(paneKey, null) + }) + if (paneMatch || !pty) { + return paneMatch + } + // Why: the OSC producer can stamp a leaf or incarnation handle; use the same non-minting + // inventory as worktree.ps so a tab-id remint can rejoin the still-live central row. + for (const terminalHandle of this.getExistingTerminalHandlesForPtyId(pty.ptyId)) { + const handleMatch = selectFreshAgentRowForMobileTab({ + paneKey, + terminalHandle, + hookRows: getRows(paneKey, terminalHandle) + }) + if (handleMatch) { + return handleMatch + } + } + return null } protected findPtyForMobileTerminalTab( diff --git a/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts b/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts index e9871f24c75..de98831cdd5 100644 --- a/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts +++ b/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts @@ -119,6 +119,14 @@ export class OrcaRuntimeWithRefreshFloatingWorkspacePtyLiveness extends OrcaRunt protected dropDisconnectedPtyRecord(ptyId: string): void { // Why: pruning can remove a PTY without the normal exit callback. + const pty = this.ptysById.get(ptyId) + // Remote disconnect is unverifiable; its host-owned status survives until certified exit. + const processDeathCertified = + pty?.connectionId === null || + this.ptyLivenessVerdictByPtyId.get(ptyId)?.verdict.status === 'exited' + if (processDeathCertified) { + this.reconcileAgentStatusForEndedProcessFn?.(this.collectAgentStatusPaneKeysForPty(ptyId)) + } this.advancePtyLifecycleGeneration(ptyId) this.pairedRendererSessionOwnedPtyIds.delete(ptyId) this.ptysById.delete(ptyId) @@ -145,7 +153,6 @@ export class OrcaRuntimeWithRefreshFloatingWorkspacePtyLiveness extends OrcaRunt this.terminalCwdByPtyId.delete(ptyId) this.terminalFileUriHostnameByPtyId.delete(ptyId) this.wslDistroByPtyId.delete(ptyId) - this.clearAgentRowSnapshotsForPty(ptyId) const handle = this.handleByPtyId.get(ptyId) if (handle) { // Why: pruning can remove a PTY without onPtyExit firing; release this leader's agent team so it doesn't leak. diff --git a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts index 81696fe4739..1e321bc7584 100644 --- a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts +++ b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts @@ -1,4 +1,5 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. +import { selectFreshExplicitAgentStatus } from './runtime-hook-agent-row-selection' import { OrcaRuntimeWithControllerKnowsPtyIsLive } from './orca-runtime-controller-knows-pty-is-live' import type { RuntimeTerminalAgentStatus } from '../../shared/runtime-types' import type { RuntimeTerminalAgentStatusSnapshot } from './runtime-terminal-agent-status-query' @@ -181,7 +182,7 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi updatedAt: number stateStartedAt: number } | null { - return this.agentRows.getFreshExplicit({ + return selectFreshExplicitAgentStatus({ handle, paneKey: paneKeyOverride ?? this.getPaneKeyForTerminalHandle(handle), hookRows: this.getAgentStatusSnapshotFn?.() ?? [] diff --git a/src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts b/src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts index be54620fde3..a96ae64e596 100644 --- a/src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts +++ b/src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts @@ -123,14 +123,11 @@ export class OrcaRuntimeWithStopExactTerminalsForWorktree extends OrcaRuntimeWit } protected getTerminalHandlesForPtyId(ptyId: string): string[] { - const handles = new Set( - this.getLeavesForPty(ptyId) - .filter((candidate) => candidate.connected) - .map((leaf) => this.issueHandle(leaf)) - ) - const runtimeHandle = this.handleByPtyId.get(ptyId) - if (runtimeHandle) { - handles.add(runtimeHandle) + const handles = new Set(this.getExistingTerminalHandlesForPtyId(ptyId)) + for (const handle of this.getLeavesForPty(ptyId) + .filter((candidate) => candidate.connected) + .map((leaf) => this.issueHandle(leaf))) { + handles.add(handle) } const pty = this.getOrCreatePtyWorktreeRecord(ptyId) if (!pty) { @@ -142,6 +139,23 @@ export class OrcaRuntimeWithStopExactTerminalsForWorktree extends OrcaRuntimeWit return [...handles].sort() } + protected getExistingTerminalHandlesForPtyId(ptyId: string): string[] { + const handles = new Set( + this.getLeavesForPty(ptyId) + .map((leaf) => this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId))) + .filter((handle): handle is string => handle !== undefined) + ) + const runtimeHandle = this.handleByPtyId.get(ptyId) + if (runtimeHandle) { + handles.add(runtimeHandle) + } + const incarnationHandle = this.handleByPtyIncarnation.get(ptyId)?.handle + if (incarnationHandle) { + handles.add(incarnationHandle) + } + return [...handles].sort() + } + protected getRecordedTerminalSleepHandles( ptyIds: Iterable, terminalHandlesByPtyId: Readonly> diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index a3785adb2bb..c7f883fcfcb 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -107,7 +107,7 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId issueLeafHandle: (leaf) => this.issueHandle(leaf), issuePtyHandle: (pty) => this.issuePtyHandle(pty), makePaneKey: (leaf) => this.makeRuntimePaneKey(leaf), - getWorktreeId: (handle) => this.getWorktreeIdForTerminalHandle(handle), + getWorktreeId: (handle) => this.getTerminalWorktreeIdForHandle(handle), getHandleForPaneKey: (paneKey) => this.getTerminalHandleForPaneKey(paneKey), getPaneKey: (handle) => this.getPaneKeyForTerminalHandle(handle), getDispatchAuthority: (handle) => this.getOrchestrationDispatchAuthority(handle), diff --git a/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts b/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts index 7c0310554ef..23312f49420 100644 --- a/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts +++ b/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts @@ -41,6 +41,8 @@ describe('OrcaRuntimeService', () => { tabId: spawnedEnv.ORCA_TAB_ID, worktreeId: TEST_WORKTREE_ID, connectionId: null, + // The pane's handle rides the event so the store's row can rejoin its terminal. + terminalHandle: expect.stringMatching(/^term_/), payload: { state: 'done', prompt: 'ok' diff --git a/src/main/runtime/orca-runtime-tests/headless-snapshots.spec.ts b/src/main/runtime/orca-runtime-tests/headless-snapshots.spec.ts index 4de568618cc..b9bb63fcea8 100644 --- a/src/main/runtime/orca-runtime-tests/headless-snapshots.spec.ts +++ b/src/main/runtime/orca-runtime-tests/headless-snapshots.spec.ts @@ -598,6 +598,8 @@ describe('OrcaRuntimeService', () => { tabId: 'tab-1', worktreeId: TEST_WORKTREE_ID, connectionId: null, + // The pane's handle rides the event so the store's row can rejoin its terminal. + terminalHandle: expect.stringMatching(/^term_/), payload: { state: 'working', prompt: 'ship it', diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-08.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-08.spec.ts index 65de777139f..518d6805c5b 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-08.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-08.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import type { AgentStatusIpcPayload } from '../../../shared/agent-status-types' import { OrcaRuntimeService, electronMocks } from '../orca-runtime-test-mocks.spec' import { HEADLESS_LEAF_ID, @@ -284,7 +285,11 @@ describe('OrcaRuntimeService', () => { const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( makeWorkspaceSessionWithHeadlessTerminal() ) - const runtime = new OrcaRuntimeService(runtimeStore as never) + let rows: AgentStatusIpcPayload[] = [] + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => rows, + getAgentProviderSessionRowsForPane: () => [] + }) runtime.setPtyController({ write: () => true, kill: () => true, @@ -293,7 +298,27 @@ describe('OrcaRuntimeService', () => { { id: 'persisted-pty', cwd: TEST_WORKTREE_PATH, title: 'Unrelated PTY' } ] }) + runtime.registerPty('persisted-pty', TEST_WORKTREE_ID, null, { + tabId: 'other-tab', + leafId: '99999999-9999-4999-8999-999999999999' + }) runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + const unrelatedPty = runtime['ptysById'].get('persisted-pty')! + const unrelatedHandle = runtime['issuePtyHandle'](unrelatedPty) + rows = [ + { + paneKey: 'other-tab:99999999-9999-4999-8999-999999999999', + tabId: 'other-tab', + worktreeId: TEST_WORKTREE_ID, + terminalHandle: unrelatedHandle, + connectionId: null, + state: 'working', + prompt: 'unrelated task', + agentType: 'codex', + receivedAt: Date.now(), + stateStartedAt: Date.now() + } + ] const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) @@ -304,6 +329,45 @@ describe('OrcaRuntimeService', () => { status: 'pending-handle', terminal: null }) + expect(listed.tabs[0]).not.toHaveProperty('agentStatus') + }) + + it('reads and indexes the full agent-status snapshot once per mobile projection', async () => { + const tabCount = 20 + const session = makeWorkspaceSessionWithHeadlessTerminal() + const tabs = Array.from({ length: tabCount }, (_, index) => ({ + ...session.tabsByWorktree[TEST_WORKTREE_ID]![0]!, + id: `host-tab-${index}`, + ptyId: `missing-pty-${index}` + })) + const terminalLayoutsByTabId = Object.fromEntries( + tabs.map((tab, index) => [ + tab.id, + makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: `missing-pty-${index}` }) + ]) + ) + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...session, + tabsByWorktree: { [TEST_WORKTREE_ID]: tabs }, + terminalLayoutsByTabId + }) + const getAgentStatusSnapshot = vi.fn(() => []) + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot, + getAgentProviderSessionRowsForPane: () => [] + }) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + + const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(listed.tabs).toHaveLength(tabCount) + expect(getAgentStatusSnapshot).toHaveBeenCalledOnce() }) it('kills persisted SSH PTYs when closing hydrated headless tabs before pane metadata is restored', async () => { diff --git a/src/main/runtime/orca-runtime-tests/mobile-summaries-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-summaries-part-02.spec.ts index bd39e91e460..58fa4ae750b 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-summaries-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-summaries-part-02.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { makeAgentStatusStoreWiring } from '../agent-status-store-wiring.test-fixture' import { AGENT_STATUS_STALE_AFTER_MS, MOCK_GIT_WORKTREES, @@ -254,23 +255,18 @@ describe('OrcaRuntimeService', () => { }) it('keeps a fresh OSC row when the cached hook row for the same pane is older', async () => { - const now = Date.now() const leafId = '44444444-4444-4444-8444-444444444444' const paneKey = `tab-1:${leafId}` - const runtime = new OrcaRuntimeService(store, undefined, { - getAgentStatusSnapshot: () => [ - { - paneKey, - worktreeId: TEST_WORKTREE_ID, - tabId: 'tab-1', - state: 'working', - prompt: 'stale hook row', - agentType: 'claude', - connectionId: null, - receivedAt: now - AGENT_STATUS_STALE_AFTER_MS - 1, - stateStartedAt: now - AGENT_STATUS_STALE_AFTER_MS - 100 - } - ] + const statusWiring = makeAgentStatusStoreWiring() + const runtime = new OrcaRuntimeService(store, undefined, statusWiring.deps) + statusWiring.statusStore.ingestTerminalStatus({ + paneKey, + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + connectionId: null, + // Same agent as the OSC turn below: the store resolves pane identity itself, and a + // cross-agent flip inside the inheritance window is a different rule's subject. + payload: { state: 'working', prompt: 'earlier hook row', agentType: 'codex' } }) runtime.attachWindow(1) runtime.syncWindowGraph(1, { @@ -567,15 +563,19 @@ describe('OrcaRuntimeService', () => { ]) }) - it('keeps a retained OSC row via its connected PTY after the pane binding is cleared', async () => { + it('keeps an OSC row via its connected PTY after the pane binding is cleared', async () => { // A controller incarnation change nulls pty.tabId/paneKey while the PTY - // stays connected (adoptControllerTerminalHandle); the ptyId conjunct is - // then the only rescue for the retained OSC row. + // stays connected (adoptControllerTerminalHandle); the terminal handle the row was + // stamped with is then the only rescue left for it. const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ ...getDefaultWorkspaceSession(), tabsByWorktree: {} }) - const runtime = new OrcaRuntimeService(runtimeStore as never) + const runtime = new OrcaRuntimeService( + runtimeStore as never, + undefined, + makeAgentStatusStoreWiring().deps + ) runtime['recordPtyWorktree']('osc-pty', TEST_WORKTREE_ID, { connected: true, tabId: 'osc-tab', @@ -604,21 +604,8 @@ describe('OrcaRuntimeService', () => { ...getDefaultWorkspaceSession(), tabsByWorktree: {} }) - const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { - getAgentStatusSnapshot: () => [ - { - paneKey, - worktreeId: TEST_WORKTREE_ID, - tabId: 'race-tab', - state: 'working', - prompt: 'hook-fresh agent', - agentType: 'codex', - connectionId: null, - receivedAt: Date.now() + 60_000, - stateStartedAt: Date.now() - 100 - } - ] - }) + const statusWiring = makeAgentStatusStoreWiring() + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, statusWiring.deps) runtime['recordPtyWorktree']('race-pty', TEST_WORKTREE_ID, { connected: true, tabId: 'race-tab', @@ -629,6 +616,13 @@ describe('OrcaRuntimeService', () => { '\x1b]9999;{"state":"working","prompt":"osc ping","agentType":"codex"}\x07', 1 ) + statusWiring.statusStore.ingestTerminalStatus({ + paneKey, + tabId: 'race-tab', + worktreeId: TEST_WORKTREE_ID, + connectionId: null, + payload: { state: 'working', prompt: 'hook-fresh agent', agentType: 'codex' } + }) const pty = runtime['ptysById'].get('race-pty')! pty.tabId = null pty.paneKey = null diff --git a/src/main/runtime/orca-runtime-tests/mobile-summaries-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-summaries-part-03.spec.ts index d1f60e8cda7..b82e5863c2e 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-summaries-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-summaries-part-03.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { makeAgentStatusStoreWiring } from '../agent-status-store-wiring.test-fixture' import { OrcaRuntimeService, getDefaultWorkspaceSession, @@ -17,14 +18,18 @@ import { } from '../orca-runtime-test-fixtures.spec' describe('OrcaRuntimeService', () => { - it('keeps a retained OSC row from an SSH pane after its PTY disconnects', async () => { + it('keeps an OSC row from an SSH pane after its PTY disconnects', async () => { // Why: OSC snapshots must carry the pane transport; hardcoding local would // strip the SSH exemption off rows whose freshest update arrived via OSC. const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ ...getDefaultWorkspaceSession(), tabsByWorktree: {} }) - const runtime = new OrcaRuntimeService(runtimeStore as never) + const runtime = new OrcaRuntimeService( + runtimeStore as never, + undefined, + makeAgentStatusStoreWiring().deps + ) runtime['recordPtyWorktree']('ssh-osc-pty', TEST_WORKTREE_ID, { connected: true, connectionId: 'ssh-osc-1', diff --git a/src/main/runtime/orca-runtime-tests/mobile-summaries.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-summaries.spec.ts index 60bd9086e56..64ad592209b 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-summaries.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-summaries.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { makeAgentStatusStoreWiring } from '../agent-status-store-wiring.test-fixture' import { OrcaRuntimeService, listWorktrees } from '../orca-runtime-test-mocks.spec' import { HEADLESS_LEAF_ID, @@ -386,7 +387,7 @@ describe('OrcaRuntimeService', () => { }) it('attaches inline agent rows from the latest OSC 9999 status', async () => { - const runtime = new OrcaRuntimeService(store) + const runtime = new OrcaRuntimeService(store, undefined, makeAgentStatusStoreWiring().deps) const leafId = '22222222-2222-4222-8222-222222222222' runtime.attachWindow(1) runtime.syncWindowGraph(1, { @@ -611,24 +612,37 @@ describe('OrcaRuntimeService', () => { ]) }) it('does not carry hook monitoring mode into a newer OSC turn', async () => { - const now = Date.now() - const runtime = new OrcaRuntimeService(store, undefined, { - getAgentStatusSnapshot: () => [ + // One store, so the newer turn simply replaces the monitoring row; nothing reconciles them. + const leafId = '55555555-5555-4555-8555-555555555555' + const statusWiring = makeAgentStatusStoreWiring() + const runtime = new OrcaRuntimeService(store, undefined, statusWiring.deps) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ { - paneKey: 'tab-1:1', - worktreeId: TEST_WORKTREE_ID, tabId: 'tab-1', - state: 'working', - workingMode: 'monitoring', - prompt: 'watch tests', - agentType: 'claude', - connectionId: null, - receivedAt: now - 100, - stateStartedAt: now - 200 + worktreeId: TEST_WORKTREE_ID, + title: 'Claude', + activeLeafId: leafId, + layout: null } + ], + leaves: [ + { tabId: 'tab-1', worktreeId: TEST_WORKTREE_ID, leafId, paneRuntimeId: 1, ptyId: 'pty-1' } ] }) - syncSinglePty(runtime) + statusWiring.statusStore.ingestTerminalStatus({ + paneKey: `tab-1:${leafId}`, + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + connectionId: null, + payload: { + state: 'working', + workingMode: 'monitoring', + prompt: 'watch tests', + agentType: 'claude' + } + }) runtime.onPtyData( 'pty-1', '\x1b]9999;{"state":"working","prompt":"fix tests","agentType":"claude"}\x07', diff --git a/src/main/runtime/orca-runtime-tests/terminal-handles-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-handles-part-02.spec.ts index 438ec34118e..170a20b278c 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-handles-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-handles-part-02.spec.ts @@ -9,10 +9,10 @@ import { import { makePaneKey } from '../orca-runtime-test-mocks.spec' describe('OrcaRuntimeService', () => { - it('invalidates a re-keyed leaf-unique handle so in-flight waiters fail fast', async () => { + it('keeps a no-incarnation handle across an in-graph pane remint', async () => { const runtime = createRuntime() const tabId = 'tab-1' - // No preAllocateHandleForPty: a plain terminal's handle is leaf-unique, so a re-key leaves it with no next owner and it goes stale immediately. + // No preallocated handle or incarnation id: the live PTY itself is the continuity proof within this graph. runtime.attachWindow(TEST_WINDOW_ID) runtime.syncWindowGraph(TEST_WINDOW_ID, { tabs: [ @@ -36,8 +36,8 @@ describe('OrcaRuntimeService', () => { }) const before = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`) expect(before.terminals).toHaveLength(1) - const staleHandle = before.terminals[0].handle - const waiting = runtime.waitForTerminal(staleHandle, { condition: 'exit', timeoutMs: 30_000 }) + const stableHandle = before.terminals[0].handle + const waiting = runtime.waitForTerminal(stableHandle, { condition: 'exit', timeoutMs: 30_000 }) // Re-key WITHOUT a renderer reload (e.g. a pane moved across tabs) while the same PTY stays live under a new leaf. runtime.syncWindowGraph(TEST_WINDOW_ID, { @@ -61,11 +61,11 @@ describe('OrcaRuntimeService', () => { ] }) - // The waiter must fail fast, not hang until timeout on a dead leaf. - await expect(waiting).rejects.toThrow('terminal_handle_stale') const after = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`) expect(after.terminals).toHaveLength(1) - expect(after.terminals[0].handle).not.toBe(staleHandle) + expect(after.terminals[0].handle).toBe(stableHandle) + runtime.onPtyExit('pty-plain', 0) + await expect(waiting).resolves.toMatchObject({ handle: stableHandle, status: 'exited' }) }) it('keeps a live CLI waiter pending when a re-keyed shared handle transfers to the live leaf', async () => { diff --git a/src/main/runtime/orca-runtime-tests/terminal-handles.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-handles.spec.ts index 4ae933afef4..8433dc5a71e 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-handles.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-handles.spec.ts @@ -82,6 +82,7 @@ describe('OrcaRuntimeService', () => { if (!mobileHandle) { throw new Error('expected mobile terminal handle') } + expect(mobileHandle).toBe(terminals.terminals[0].handle) const processLists = [[{ id: 'pty-1', cwd: '/tmp/worktree-a', title: 'Claude' }], []] runtime.setPtyController({ @@ -101,7 +102,7 @@ describe('OrcaRuntimeService', () => { (event) => event.type === 'worktreeTerminalSleepState' && event.phase === 'started' ) ).toMatchObject({ - terminalHandles: [terminals.terminals[0].handle, mobileHandle].sort() + terminalHandles: [...new Set([terminals.terminals[0].handle, mobileHandle])].sort() }) }) diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery.spec.ts index 4c17cc9df18..21d1450b641 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery.spec.ts @@ -1,4 +1,5 @@ import { settledWriteStub } from '../../providers/settled-pty-write-stub' +import { makeAgentStatusStoreWiring } from '../agent-status-store-wiring.test-fixture' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, @@ -139,7 +140,9 @@ describe('OrcaRuntimeService', () => { // #7970: headless serve has no renderer syncing tab.agentStatus, so hook-only transitions must republish the snapshot carrying the retained hook payload. it('republishes mobile session tabs with hook payloads for title-less OSC 9999 transitions', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'hook-only-pty' }) - const runtime = new OrcaRuntimeService(store) + const statusWiring = makeAgentStatusStoreWiring() + const runtime = new OrcaRuntimeService(store, undefined, statusWiring.deps) + const uninstallRepublish = statusWiring.attach(runtime) runtime.setPtyController({ spawn, write: () => true, @@ -188,12 +191,15 @@ describe('OrcaRuntimeService', () => { ) unsubscribe() + uninstallRepublish() }) // Why: restored OMP panes can retain the hook while the wrapped Pi owns foreground (#6364). it('keeps an OMP hook labeled OMP when the wrapped pi child owns the foreground', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'omp-flicker-pty' }) - const runtime = new OrcaRuntimeService(store) + const statusWiring = makeAgentStatusStoreWiring() + const runtime = new OrcaRuntimeService(store, undefined, statusWiring.deps) + const uninstallRepublish = statusWiring.attach(runtime) runtime.setPtyController({ spawn, write: () => true, @@ -240,11 +246,14 @@ describe('OrcaRuntimeService', () => { ) unsubscribe() + uninstallRepublish() }) it('does not republish mobile session tabs for repeated identical OSC 9999 payloads', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'hook-ping-pty' }) - const runtime = new OrcaRuntimeService(store) + const statusWiring = makeAgentStatusStoreWiring() + const runtime = new OrcaRuntimeService(store, undefined, statusWiring.deps) + const uninstallRepublish = statusWiring.attach(runtime) runtime.setPtyController({ spawn, write: () => true, @@ -270,6 +279,7 @@ describe('OrcaRuntimeService', () => { expect(events).toHaveLength(1) unsubscribe() + uninstallRepublish() }) it('suppresses a retained hook working status once the shell owns the pane title again', async () => { diff --git a/src/main/runtime/orca-runtime-tests/worktree-ps-agent-row-dismissal.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-ps-agent-row-dismissal.spec.ts new file mode 100644 index 00000000000..351996166c2 --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/worktree-ps-agent-row-dismissal.spec.ts @@ -0,0 +1,313 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec' +import { TEST_WORKTREE_ID, store } from '../orca-runtime-test-fixtures.spec' +import { makeAgentStatusStoreWiring } from '../agent-status-store-wiring.test-fixture' + +/** + * One store means one dismissal. Before PR 1b the runtime kept its own copy of the OSC row, so a + * row the user dismissed on the desktop stayed in `orca worktree ps` and on the phone until the + * PTY exited. These drive the real OSC byte path so the producer under test is the runtime's own + * parse, not a hand-built snapshot. + */ +const LEAF_ID = '77777777-7777-4777-8777-777777777777' +const REMINTED_LEAF_ID = '88888888-8888-4888-8888-888888888888' +const PANE_KEY = `tab-dismiss:${LEAF_ID}` + +function wiredRuntime(incarnationId?: string): { + runtime: OrcaRuntimeService + statusWiring: ReturnType +} { + const statusWiring = makeAgentStatusStoreWiring() + const runtime = new OrcaRuntimeService(store, undefined, statusWiring.deps) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-dismiss', + worktreeId: TEST_WORKTREE_ID, + title: 'Codex', + activeLeafId: LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-dismiss', + worktreeId: TEST_WORKTREE_ID, + leafId: LEAF_ID, + paneRuntimeId: 1, + ptyId: 'dismiss-pty' + } + ] + }) + if (incarnationId) { + runtime.registerPty('dismiss-pty', TEST_WORKTREE_ID, null, { + tabId: 'tab-dismiss', + leafId: LEAF_ID, + incarnationId + }) + } + return { runtime, statusWiring } +} + +function emitWorkingStatus(runtime: OrcaRuntimeService, sequence: number): void { + runtime.onPtyData( + 'dismiss-pty', + '\x1b]9999;{"state":"working","prompt":"ship it","agentType":"codex"}\x07', + sequence + ) +} + +describe('worktree ps follows a dismissal out of the agent-status store', () => { + it('drops the row as soon as the user dismisses it, without waiting for the PTY to exit', async () => { + const { runtime, statusWiring } = wiredRuntime() + emitWorkingStatus(runtime, 1) + + const listed = await runtime.getWorktreePs() + expect( + listed.worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)?.agents + ).toEqual([expect.objectContaining({ paneKey: PANE_KEY, prompt: 'ship it' })]) + + statusWiring.statusStore.dropStatusEntry(PANE_KEY) + + // The PTY is untouched and still connected; only the store was told. + expect(runtime['ptysById'].get('dismiss-pty')?.connected).toBe(true) + const afterDismissal = await runtime.getWorktreePs() + expect( + afterDismissal.worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)?.agents + ).toEqual([]) + }) + + it('tells paired clients to republish on the transition and on the dismissal', async () => { + const { runtime, statusWiring } = wiredRuntime() + const republish = vi.spyOn(runtime, 'touchMobileSessionTabsForWorktree') + const uninstall = statusWiring.attach(runtime) + try { + emitWorkingStatus(runtime, 1) + expect(republish).toHaveBeenCalledWith(TEST_WORKTREE_ID) + + // The same payload again changes nothing a client would render. + republish.mockClear() + emitWorkingStatus(runtime, 2) + expect(republish).not.toHaveBeenCalled() + + runtime.onPtyData( + 'dismiss-pty', + '\x1b]9999;{"state":"done","prompt":"ship it","agentType":"codex"}\x07', + 3 + ) + expect(republish).toHaveBeenCalledWith(TEST_WORKTREE_ID) + + republish.mockClear() + statusWiring.statusStore.dropStatusEntry(PANE_KEY) + expect(republish).toHaveBeenCalledWith(TEST_WORKTREE_ID) + } finally { + uninstall() + republish.mockRestore() + } + }) + + it.each([ + ['leaf binding', undefined, false], + ['controller incarnation', 'incarnation-1', true] + ] as const)( + 'rejoins a row through its %s handle after pane ownership clears', + async (_, incarnationId, clearLeafBinding) => { + const { runtime, statusWiring } = wiredRuntime(incarnationId) + emitWorkingStatus(runtime, 1) + const row = statusWiring.statusStore.getStatusSnapshot()[0]! + const internals = runtime as unknown as { + handleByLeafKey: Map + handleByPtyIncarnation: Map + ptysById: Map + } + const pty = internals.ptysById.get('dismiss-pty')! + pty.paneKey = null + pty.tabId = null + if (clearLeafBinding) { + expect(internals.handleByPtyIncarnation.get('dismiss-pty')?.handle).toBe(row.terminalHandle) + internals.handleByLeafKey.clear() + } + + const listed = await runtime.getWorktreePs() + + expect( + listed.worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)?.agents + ).toEqual([expect.objectContaining({ prompt: 'ship it' })]) + statusWiring.statusStore.stop() + } + ) + + it('publishes one provider-addressable row through remint, dismissal, and exit', async () => { + const { runtime, statusWiring } = wiredRuntime('incarnation-1') + emitWorkingStatus(runtime, 1) + const row = statusWiring.statusStore.getStatusSnapshot()[0]! + expect(row.terminalHandle).toMatch(/^term_/) + statusWiring.statusStore.ingestRemote( + { + paneKey: PANE_KEY, + tabId: 'tab-dismiss', + worktreeId: TEST_WORKTREE_ID, + providerSession: { key: 'session_id', id: 'provider-session-1' }, + payload: { state: 'working', prompt: 'ship it', agentType: 'codex' } + }, + null + ) + + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-reminted', + worktreeId: TEST_WORKTREE_ID, + title: 'Codex', + activeLeafId: REMINTED_LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-reminted', + worktreeId: TEST_WORKTREE_ID, + leafId: REMINTED_LEAF_ID, + paneRuntimeId: 1, + ptyId: 'dismiss-pty' + } + ], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'reminted-epoch', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: `tab-reminted::${REMINTED_LEAF_ID}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `tab-reminted::${REMINTED_LEAF_ID}`, + parentTabId: 'tab-reminted', + leafId: REMINTED_LEAF_ID, + ptyId: 'dismiss-pty', + title: 'Codex', + isActive: true + } + ] + } + ] + }) + + const before = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + const events: Awaited>[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + const uninstall = statusWiring.attach(runtime) + try { + emitWorkingStatus(runtime, 2) + await vi.waitFor(() => expect(events).toHaveLength(1)) + const remintedPaneKey = `tab-reminted:${REMINTED_LEAF_ID}` + expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: remintedPaneKey, + terminalHandle: row.terminalHandle, + providerSession: { key: 'session_id', id: 'provider-session-1' } + }) + ]) + expect(events[0]).toMatchObject({ + snapshotVersion: before.snapshotVersion + 1, + tabs: [ + expect.objectContaining({ + agentStatus: expect.objectContaining({ + state: 'working', + providerSession: { key: 'session_id', id: 'provider-session-1' } + }) + }) + ] + }) + + statusWiring.statusStore.dropStatusEntry(remintedPaneKey) + await vi.waitFor(() => expect(events).toHaveLength(2)) + expect(events[1]).toMatchObject({ + snapshotVersion: before.snapshotVersion + 2, + tabs: [expect.objectContaining({ agentStatus: expect.objectContaining({ state: 'done' }) })] + }) + expect((await runtime.getWorktreePs()).worktrees[0]?.agents).toEqual([]) + + runtime.onPtyExit('dismiss-pty', 0) + await vi.waitFor(() => expect(events).toHaveLength(3)) + expect(events[2]).toMatchObject({ snapshotVersion: before.snapshotVersion + 4 }) + expect( + events[2]?.tabs.every((tab) => tab.type !== 'terminal' || tab.agentStatus === undefined) + ).toBe(true) + expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([]) + } finally { + uninstall() + unsubscribe() + statusWiring.statusStore.stop() + } + }) + + it('keeps runtime-owned legacy OSC rows in worktree.ps and mobile projections', async () => { + const statusWiring = makeAgentStatusStoreWiring() + const runtime = new OrcaRuntimeService(store, undefined, statusWiring.deps) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'legacy-tab', + worktreeId: TEST_WORKTREE_ID, + title: 'Codex', + activeLeafId: 'pane:7', + layout: null + } + ], + leaves: [ + { + tabId: 'legacy-tab', + worktreeId: TEST_WORKTREE_ID, + leafId: 'pane:7', + paneRuntimeId: 7, + ptyId: 'legacy-pty' + } + ], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'legacy-epoch', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: 'legacy-tab::pane:7', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'legacy-tab::pane:7', + parentTabId: 'legacy-tab', + leafId: 'pane:7', + ptyId: 'legacy-pty', + title: 'Codex', + isActive: true + } + ] + } + ] + }) + runtime.onPtyData( + 'legacy-pty', + '\x1b]9999;{"state":"working","prompt":"legacy task","agentType":"codex"}\x07', + 1 + ) + + const listed = await runtime.getWorktreePs() + const mobile = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect( + listed.worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)?.agents + ).toEqual([expect.objectContaining({ paneKey: 'legacy-tab:7', prompt: 'legacy task' })]) + expect(mobile.tabs[0]).toMatchObject({ + type: 'terminal', + agentStatus: { paneKey: 'legacy-tab:7', prompt: 'legacy task' } + }) + runtime.onPtyExit('legacy-pty', 0) + expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([]) + statusWiring.statusStore.stop() + }) +}) diff --git a/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts b/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts index b7ca09af705..7a08f8f3bac 100644 --- a/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts +++ b/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts @@ -21,6 +21,7 @@ export class OrcaRuntimeWithTouchMobileSessionTabsForWorktree extends OrcaRuntim if (!snapshot) { return } + this.mobileSessionTabsAgentStatusHeartbeat.observeWorktreeRefresh(worktreeId) this.storeMobileSessionSnapshot(worktreeId, { ...snapshot, snapshotVersion: snapshot.snapshotVersion + 1 @@ -36,6 +37,13 @@ export class OrcaRuntimeWithTouchMobileSessionTabsForWorktree extends OrcaRuntim this.scheduleMobileSessionTabsChanged(worktreeId) } + scheduleMobileSessionTabsAgentStatusHeartbeatForWorktree(worktreeId: string): void { + if (this.mobileSessionTabListeners.size === 0) { + return + } + this.mobileSessionTabsAgentStatusHeartbeat.scheduleWorktreeHeartbeat(worktreeId) + } + /** Republish the workspace snapshot after a pane's hook status changed. * Hook rows feed the headless `agentStatus` projection, which nothing else touches. */ touchMobileSessionTabsForPane(paneKey: string, worktreeId?: string | null): void { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 74098040c27..7ec1fd5fc35 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -86,6 +86,7 @@ await import('./orca-runtime-tests/mobile-summaries-part-02.spec') await import('./orca-runtime-tests/mobile-summaries-part-03.spec') await import('./orca-runtime-tests/mobile-summaries-part-04.spec') await import('./orca-runtime-tests/worktree-ps-structured-host.spec') +await import('./orca-runtime-tests/worktree-ps-agent-row-dismissal.spec') await import('./orca-runtime-tests/terminal-sleep-and-teardown.spec') await import('./orca-runtime-tests/terminal-sleep-and-teardown-part-02.spec') await import('./orca-runtime-tests/terminal-sleep-and-teardown-part-03.spec') diff --git a/src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts index 61a28f4612c..afed92ec8aa 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts @@ -97,7 +97,7 @@ const CENSUS: readonly CensusRow[] = [ { path: 'main/orcad/orcad-entry.ts', kind: 'wiring', - role: 'binds the same snapshot and structured sink into the headless orcad runtime deps' + role: 'binds the same snapshot, OSC producer and structured sink into the headless orcad runtime deps' }, { path: 'main/runtime/orca-runtime-state-fields.ts', @@ -157,7 +157,7 @@ const CENSUS: readonly CensusRow[] = [ { path: 'main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts', kind: 'consumes', - role: 'mobile tab-group pruning from provider-session rows, and the pane identity accessors' + role: 'mobile tab-group pruning and its live agent row, plus the pane identity accessors' } ] diff --git a/src/main/runtime/rpc/methods/orchestration/worker/fleet-status-observed-identity.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/fleet-status-observed-identity.test.ts index 0e5addf1ba3..8aa6d03ed84 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/fleet-status-observed-identity.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/fleet-status-observed-identity.test.ts @@ -5,9 +5,11 @@ import type { OrcaRuntimeService } from '../../../../orca-runtime' import type { OrchestrationDb } from '../../../../orchestration/db' import { OrcaRuntimeWithGetOrchestrationDispatchAuthority } from '../../../../orca-runtime-get-orchestration-dispatch-authority' import { + AgentStatusObservedPaneIdentityCapture, AgentStatusObservedPaneIdentities, recordObservedAgentStatusPaneIdentity } from '../../../../agent-status-observed-pane-identity' +import type { EnrichedAgentHookEventPayload } from '../../../../../agent-hooks/server/server-types' import { projectFleetWorkerPage } from './worker-observation' /** @@ -135,6 +137,33 @@ function livenessOf(world: ObservedWorld, db: OrchestrationDb, dispatchId: strin } describe('fleet evidence keeps the identity it was observed under', () => { + it('buffers startup observations until terminal recovery is ready', () => { + const identities = new AgentStatusObservedPaneIdentities() + const capture = new AgentStatusObservedPaneIdentityCapture(identities) + const runtime = { + getAgentStatusTerminalHandleForPaneKey: () => TERMINAL_HANDLE, + getTerminalProcessIncarnation: () => INCARNATION_ONE, + getAgentStatusOrchestrationContextForPaneKey: () => undefined + } + const entry = { + paneKey: PANE_KEY, + payload: { state: 'working', prompt: 'startup', agentType: 'claude' }, + receivedAt: 1, + stateStartedAt: 1 + } as EnrichedAgentHookEventPayload + + capture.observe(entry) + expect(identities.read(PANE_KEY)).toEqual({ kind: 'unobserved' }) + + capture.attach(runtime) + expect(identities.read(PANE_KEY)).toEqual({ + kind: 'observed', + terminalHandle: TERMINAL_HANDLE, + processIncarnation: INCARNATION_ONE, + dispatchId: null + }) + }) + it('reads live while the pane still runs the process the row was observed on', () => { const world = createWorld() world.bindPane(PANE_KEY, TERMINAL_HANDLE) diff --git a/src/main/runtime/runtime-agent-row-store.ts b/src/main/runtime/runtime-agent-row-store.ts deleted file mode 100644 index c0c58d7ca82..00000000000 --- a/src/main/runtime/runtime-agent-row-store.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { - AGENT_STATUS_STALE_AFTER_MS, - type AgentStatusEntry, - type AgentStatusIpcPayload, - type ParsedAgentStatusPayload -} from '../../shared/agent-status-types' -import type { - RuntimeTerminalAgentStatus, - RuntimeMobileSessionTerminalTab -} from '../../shared/runtime-types' -import { mapExplicitAgentStateToRuntimeTerminalStatus } from './runtime-worktree-status-projection' -import type { RuntimeAgentRowSnapshot } from './runtime-worktree-agent-rows' -import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' - -export class RuntimeAgentRowStore { - private readonly byPaneKey = new Map() - - values(): IterableIterator { - return this.byPaneKey.values() - } - - retain(args: { - ptyId: string - paneKey: string - worktreeId?: string - tabId?: string - connectionId: string | null - payload: ParsedAgentStatusPayload - }): boolean { - const now = Date.now() - const previous = this.byPaneKey.get(args.paneKey) - const stateStartedAt = - previous?.payload.state === args.payload.state ? previous.stateStartedAt : now - this.byPaneKey.set(args.paneKey, { ...args, stateStartedAt, updatedAt: now }) - return ( - !previous || - previous.payload.state !== args.payload.state || - previous.payload.workingMode !== args.payload.workingMode || - previous.payload.prompt !== args.payload.prompt || - (previous.payload.agentType ?? null) !== (args.payload.agentType ?? null) || - (previous.payload.toolName ?? null) !== (args.payload.toolName ?? null) || - (previous.payload.interactivePrompt ?? null) !== (args.payload.interactivePrompt ?? null) || - (previous.payload.interrupted ?? false) !== (args.payload.interrupted ?? false) || - (previous.payload.turnCompletedAt ?? null) !== (args.payload.turnCompletedAt ?? null) || - (previous.payload.lastAssistantMessage ?? null) !== - (args.payload.lastAssistantMessage ?? null) - ) - } - - clearPty(ptyId: string): void { - for (const [paneKey, snapshot] of this.byPaneKey) { - if (snapshot.ptyId === ptyId) { - this.byPaneKey.delete(paneKey) - } - } - } - - getFreshForMobile( - paneKey: string, - pty: RuntimePtyWorktreeRecord | null, - tab: RuntimeMobileSessionTerminalTab - ): RuntimeAgentRowSnapshot | null { - let retained = this.byPaneKey.get(paneKey) ?? null - if (!retained) { - const ptyId = pty?.ptyId ?? tab.ptyId ?? null - if (ptyId) { - for (const snapshot of this.byPaneKey.values()) { - if (snapshot.ptyId === ptyId && (!retained || snapshot.updatedAt > retained.updatedAt)) { - retained = snapshot - } - } - } - } - return retained && Date.now() - retained.updatedAt <= AGENT_STATUS_STALE_AFTER_MS - ? retained - : null - } - - getFreshExplicit(args: { - handle: string - paneKey: string | null - hookRows: readonly AgentStatusIpcPayload[] - }): { - status: NonNullable - updatedAt: number - stateStartedAt: number - } | null { - const now = Date.now() - let bestStatus: NonNullable | null = null - let bestUpdatedAt = -1 - let bestStateStartedAt = -1 - const consider = ( - state: AgentStatusEntry['state'] | undefined, - updatedAt: number | null | undefined, - restoredUnconfirmed = false, - stateStartedAt?: number | null - ): void => { - if (!state || restoredUnconfirmed || typeof updatedAt !== 'number') { - return - } - if (now - updatedAt > AGENT_STATUS_STALE_AFTER_MS) { - return - } - const status = mapExplicitAgentStateToRuntimeTerminalStatus(state) - if (updatedAt > bestUpdatedAt || (updatedAt === bestUpdatedAt && status === 'permission')) { - bestStatus = status - bestUpdatedAt = updatedAt - bestStateStartedAt = typeof stateStartedAt === 'number' ? stateStartedAt : updatedAt - } - } - if (args.paneKey) { - const retained = this.byPaneKey.get(args.paneKey) - consider(retained?.payload.state, retained?.updatedAt, false, retained?.stateStartedAt) - } - for (const row of args.hookRows) { - if (row.terminalHandle !== args.handle && (!args.paneKey || row.paneKey !== args.paneKey)) { - continue - } - consider(row.state, row.receivedAt, row.restoredUnconfirmed, row.stateStartedAt) - } - return bestStatus - ? { status: bestStatus, updatedAt: bestUpdatedAt, stateStartedAt: bestStateStartedAt } - : null - } -} diff --git a/src/main/runtime/runtime-hook-agent-row-selection.test.ts b/src/main/runtime/runtime-hook-agent-row-selection.test.ts new file mode 100644 index 00000000000..662fc5c160f --- /dev/null +++ b/src/main/runtime/runtime-hook-agent-row-selection.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest' +import { + selectFreshAgentRowForMobileTab, + selectFreshExplicitAgentStatus +} from './runtime-hook-agent-row-selection' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-types' +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' + +const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111' +const OTHER_PANE_KEY = 'tab-1:22222222-2222-4222-8222-222222222222' +const HANDLE = 'term_selection' +const PROVIDER_SESSION = { key: 'session_id' as const, id: 'session-1' } + +function row(overrides: Partial = {}): AgentStatusIpcPayload { + const now = Date.now() + return { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'worktree', + connectionId: null, + terminalHandle: HANDLE, + state: 'working', + prompt: 'ship it', + agentType: 'codex', + receivedAt: now, + stateStartedAt: now - 500, + ...overrides + } +} + +describe('selectFreshExplicitAgentStatus', () => { + it('matches on the terminal handle when the pane key has moved', () => { + const selected = selectFreshExplicitAgentStatus({ + handle: HANDLE, + paneKey: OTHER_PANE_KEY, + hookRows: [row()] + }) + expect(selected).toMatchObject({ status: 'working' }) + }) + + it('ignores a row belonging to neither the handle nor the pane', () => { + expect( + selectFreshExplicitAgentStatus({ + handle: 'term_other', + paneKey: OTHER_PANE_KEY, + hookRows: [row()] + }) + ).toBeNull() + }) + + it('refuses restored, identity-only and stale evidence rows', () => { + const args = { handle: HANDLE, paneKey: PANE_KEY } + expect( + selectFreshExplicitAgentStatus({ ...args, hookRows: [row({ restoredUnconfirmed: true })] }) + ).toBeNull() + expect( + selectFreshExplicitAgentStatus({ ...args, hookRows: [row({ providerSessionOnly: true })] }) + ).toBeNull() + expect( + selectFreshExplicitAgentStatus({ + ...args, + hookRows: [row({ receivedAt: Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1 })] + }) + ).toBeNull() + expect( + selectFreshExplicitAgentStatus({ + ...args, + hookRows: [ + row({ + receivedAt: Date.now(), + evidenceObservedAt: Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1 + }) + ] + }) + ).toBeNull() + }) + + it('prefers a permission row over a working row stamped at the same instant', () => { + const at = Date.now() + const selected = selectFreshExplicitAgentStatus({ + handle: HANDLE, + paneKey: PANE_KEY, + hookRows: [ + row({ receivedAt: at }), + row({ paneKey: OTHER_PANE_KEY, state: 'blocked', receivedAt: at }) + ] + }) + expect(selected?.status).toBe('permission') + }) +}) + +describe('selectFreshAgentRowForMobileTab', () => { + it('prefers the pane own row over one that only shares its terminal', () => { + const selected = selectFreshAgentRowForMobileTab({ + paneKey: PANE_KEY, + terminalHandle: HANDLE, + hookRows: [ + row({ paneKey: OTHER_PANE_KEY, prompt: 'sibling pane', receivedAt: Date.now() }), + row({ prompt: 'this pane', receivedAt: Date.now() - 50 }) + ] + }) + expect(selected?.payload.prompt).toBe('this pane') + }) + + it('falls back to the terminal handle once the pane key no longer matches', () => { + const selected = selectFreshAgentRowForMobileTab({ + paneKey: OTHER_PANE_KEY, + terminalHandle: HANDLE, + hookRows: [row()] + }) + expect(selected).toMatchObject({ paneKey: PANE_KEY, payload: { prompt: 'ship it' } }) + }) + + it('carries provider-session identity through a terminal-handle rejoin', () => { + const selected = selectFreshAgentRowForMobileTab({ + paneKey: OTHER_PANE_KEY, + terminalHandle: HANDLE, + hookRows: [row({ providerSession: PROVIDER_SESSION })] + }) + expect(selected?.providerSession).toEqual(PROVIDER_SESSION) + }) + + it('has no fallback when the tab is bound to no terminal', () => { + expect( + selectFreshAgentRowForMobileTab({ + paneKey: OTHER_PANE_KEY, + terminalHandle: null, + hookRows: [row()] + }) + ).toBeNull() + }) + + it('refuses restored, resume-identity and stale rows', () => { + const args = { paneKey: PANE_KEY, terminalHandle: HANDLE } + expect( + selectFreshAgentRowForMobileTab({ ...args, hookRows: [row({ restoredUnconfirmed: true })] }) + ).toBeNull() + expect( + selectFreshAgentRowForMobileTab({ ...args, hookRows: [row({ providerSessionOnly: true })] }) + ).toBeNull() + expect( + selectFreshAgentRowForMobileTab({ + ...args, + hookRows: [row({ receivedAt: Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1 })] + }) + ).toBeNull() + expect( + selectFreshAgentRowForMobileTab({ + ...args, + hookRows: [ + row({ + receivedAt: Date.now(), + evidenceObservedAt: Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1 + }) + ] + }) + ).toBeNull() + }) +}) diff --git a/src/main/runtime/runtime-hook-agent-row-selection.ts b/src/main/runtime/runtime-hook-agent-row-selection.ts new file mode 100644 index 00000000000..67c1b698a1d --- /dev/null +++ b/src/main/runtime/runtime-hook-agent-row-selection.ts @@ -0,0 +1,135 @@ +import { + AGENT_STATUS_STALE_AFTER_MS, + pickParsedAgentStatusPayload, + type AgentStatusEntry, + type AgentStatusIpcPayload, + type ParsedAgentStatusPayload +} from '../../shared/agent-status-types' +import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume' +import type { RuntimeTerminalAgentStatus } from '../../shared/runtime-types' +import { mapExplicitAgentStateToRuntimeTerminalStatus } from './runtime-worktree-status-projection' + +/** One hook-server row projected into the shape the runtime's own readers consume. */ +export type RuntimeAgentRowSnapshot = { + paneKey: string + worktreeId?: string + tabId?: string + connectionId: string | null + payload: ParsedAgentStatusPayload + stateStartedAt: number + updatedAt: number + evidenceObservedAt?: number + providerSession?: AgentProviderSessionMetadata +} + +function isLiveObservation(row: AgentStatusIpcPayload): boolean { + // A restored row cannot prove liveness (the turn may have ended while offline), and a + // resume-identity row carries no status at all. + return row.restoredUnconfirmed !== true && row.providerSessionOnly !== true +} + +/** The freshest explicit state for a terminal, matched on its handle or its pane key. */ +export function selectFreshExplicitAgentStatus(args: { + handle: string + paneKey: string | null + hookRows: readonly AgentStatusIpcPayload[] +}): { + status: NonNullable + updatedAt: number + stateStartedAt: number +} | null { + const now = Date.now() + let bestStatus: NonNullable | null = null + let bestUpdatedAt = -1 + let bestStateStartedAt = -1 + const consider = ( + state: AgentStatusEntry['state'] | undefined, + updatedAt: number | null | undefined, + evidenceObservedAt: number | null | undefined, + restoredUnconfirmed = false, + providerSessionOnly = false, + stateStartedAt?: number | null + ): void => { + if (!state || restoredUnconfirmed || providerSessionOnly || typeof updatedAt !== 'number') { + return + } + if (now - (evidenceObservedAt ?? updatedAt) > AGENT_STATUS_STALE_AFTER_MS) { + return + } + const status = mapExplicitAgentStateToRuntimeTerminalStatus(state) + if (updatedAt > bestUpdatedAt || (updatedAt === bestUpdatedAt && status === 'permission')) { + bestStatus = status + bestUpdatedAt = updatedAt + bestStateStartedAt = typeof stateStartedAt === 'number' ? stateStartedAt : updatedAt + } + } + for (const row of args.hookRows) { + if (row.terminalHandle !== args.handle && (!args.paneKey || row.paneKey !== args.paneKey)) { + continue + } + consider( + row.state, + row.receivedAt, + row.evidenceObservedAt, + row.restoredUnconfirmed, + row.providerSessionOnly, + row.stateStartedAt + ) + } + return bestStatus + ? { + status: bestStatus, + updatedAt: bestUpdatedAt, + stateStartedAt: bestStateStartedAt + } + : null +} + +/** The pane's live row for the mobile projection: its own key first, then the terminal it is + * bound to, which is the only join left once a pane key has moved. */ +export function selectFreshAgentRowForMobileTab(args: { + paneKey: string + terminalHandle: string | null + hookRows: readonly AgentStatusIpcPayload[] +}): RuntimeAgentRowSnapshot | null { + let match: AgentStatusIpcPayload | null = null + const now = Date.now() + for (const row of args.hookRows) { + if ( + !isLiveObservation(row) || + now - (row.evidenceObservedAt ?? row.receivedAt) > AGENT_STATUS_STALE_AFTER_MS + ) { + continue + } + if (row.paneKey === args.paneKey) { + if (!match || match.paneKey !== args.paneKey || row.receivedAt > match.receivedAt) { + match = row + } + continue + } + if ( + match?.paneKey !== args.paneKey && + args.terminalHandle !== null && + row.terminalHandle === args.terminalHandle && + (!match || row.receivedAt > match.receivedAt) + ) { + match = row + } + } + if (!match) { + return null + } + return { + paneKey: match.paneKey, + connectionId: match.connectionId ?? null, + ...(match.worktreeId ? { worktreeId: match.worktreeId } : {}), + ...(match.tabId ? { tabId: match.tabId } : {}), + payload: pickParsedAgentStatusPayload(match), + stateStartedAt: match.stateStartedAt ?? match.receivedAt, + updatedAt: match.receivedAt, + ...(match.providerSession ? { providerSession: match.providerSession } : {}), + ...(match.evidenceObservedAt !== undefined + ? { evidenceObservedAt: match.evidenceObservedAt } + : {}) + } +} diff --git a/src/main/runtime/runtime-mobile-agent-status-builder.test.ts b/src/main/runtime/runtime-mobile-agent-status-builder.test.ts new file mode 100644 index 00000000000..f9e21d7e323 --- /dev/null +++ b/src/main/runtime/runtime-mobile-agent-status-builder.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import type { RuntimeMobileSessionTerminalTab } from '../../shared/runtime-types' +import type { RuntimeAgentRowSnapshot } from './runtime-hook-agent-row-selection' +import { buildRuntimeMobileAgentStatus } from './runtime-mobile-agent-status-builder' + +const PROVIDER_SESSION = { key: 'session_id' as const, id: 'session-1' } +const TAB: RuntimeMobileSessionTerminalTab = { + type: 'terminal', + id: 'tab::leaf', + parentTabId: 'tab', + leafId: 'leaf', + title: 'Terminal', + isActive: true +} + +describe('mobile agent status builder', () => { + it('keeps provider-session identity from a terminal-handle row rejoin', () => { + const retained: RuntimeAgentRowSnapshot = { + paneKey: 'old-tab:old-leaf', + connectionId: null, + payload: { state: 'working', prompt: 'ship it', agentType: 'codex' }, + stateStartedAt: 10, + updatedAt: 10, + providerSession: PROVIDER_SESSION + } + + const result = buildRuntimeMobileAgentStatus(null, TAB, 'term-1', retained, () => [], { + getPaneKey: () => 'new-tab:new-leaf', + getLeaf: () => null, + getTrackedTitle: () => null + }) + + expect(result).toEqual( + expect.objectContaining({ + agentStatus: expect.objectContaining({ providerSession: PROVIDER_SESSION }) + }) + ) + }) +}) diff --git a/src/main/runtime/runtime-mobile-agent-status-builder.ts b/src/main/runtime/runtime-mobile-agent-status-builder.ts index 2f8480b7580..5ce39d49d6a 100644 --- a/src/main/runtime/runtime-mobile-agent-status-builder.ts +++ b/src/main/runtime/runtime-mobile-agent-status-builder.ts @@ -33,13 +33,13 @@ export function buildRuntimeMobileAgentStatus( host: RuntimeMobileAgentStatusHost ): { agentStatus: AgentStatusEntry } | Record { const paneKey = host.getPaneKey(tab) - // Why: neither the OSC-retained row nor a title-derived status can carry a - // provider session — only the hook payload does, and headless serve has no + // Why: neither the live-status projection nor a title-derived status carries a + // provider session — only the full hook payload does, and headless serve has no // renderer to publish `tab.agentStatus`. Without it mobile native chat has no // transcript to address and sits on the empty state forever. const hookRow = selectRuntimeHookAgentRowForPane(getHookRowsForPane(paneKey)) // Why: the hook row is evidence in its own right. Returning early on a missing - // PTY status/retained row put this check ahead of the only headless carrier, so + // PTY status/projected row put this check ahead of the only headless carrier, so // an agent that reported its session but never emitted a recognized title got no // `agentStatus` at all — exactly the hook-only case the fallback exists for. if (!pty?.lastAgentStatus && !retained && !hookRow.agentType && !hookRow.providerSession) { @@ -47,7 +47,9 @@ export function buildRuntimeMobileAgentStatus( } const providerSession = hookRow.providerSession ? { providerSession: hookRow.providerSession } - : {} + : retained?.providerSession + ? { providerSession: retained.providerSession } + : {} const leaf = host.getLeaf(tab) const trackerOnlyTitle = host.getTrackedTitle(pty?.ptyId ?? leaf?.ptyId ?? null) const ptyTitle = pty @@ -101,6 +103,9 @@ export function buildRuntimeMobileAgentStatus( ...liveRow.payload, paneKey, updatedAt: liveRow.updatedAt, + ...(liveRow.evidenceObservedAt !== undefined + ? { evidenceObservedAt: liveRow.evidenceObservedAt } + : {}), stateStartedAt: liveRow.stateStartedAt, stateHistory: [], ...(terminalHandle ? { terminalHandle } : {}), diff --git a/src/main/runtime/runtime-mobile-agent-status-projection.ts b/src/main/runtime/runtime-mobile-agent-status-projection.ts index 7c7749c76ab..b21fd8bb3ff 100644 --- a/src/main/runtime/runtime-mobile-agent-status-projection.ts +++ b/src/main/runtime/runtime-mobile-agent-status-projection.ts @@ -1,5 +1,6 @@ import { AGENT_STATUS_STALE_AFTER_MS, + agentStatusAuthorityObservedAt, pickParsedAgentStatusPayload, type AgentStatusEntry, type AgentStatusIpcPayload @@ -22,7 +23,7 @@ export function renewRuntimeMobileAgentStatusFromPtyTitle( if ( (status.state === 'waiting' || status.state === 'blocked') && pty.lastAgentStatus === 'idle' && - Date.now() - status.updatedAt <= AGENT_STATUS_STALE_AFTER_MS + Date.now() - agentStatusAuthorityObservedAt(status) <= AGENT_STATUS_STALE_AFTER_MS ) { return status } @@ -35,7 +36,7 @@ export function renewRuntimeMobileAgentStatusFromPtyTitle( } const richStatusCanOwnTitleInterval = pty.lastAgentStatusRichInvalidatedAtEpochMs === null || - status.updatedAt > pty.lastAgentStatusRichInvalidatedAtEpochMs + agentStatusAuthorityObservedAt(status) > pty.lastAgentStatusRichInvalidatedAtEpochMs const titleEvidenceAt = pty.lastOscTitleEpochMs if (titleEvidenceAt === null) { return richStatusCanOwnTitleInterval ? status : null @@ -63,7 +64,10 @@ export function renewRuntimeMobileAgentStatusFromPtyTitle( (pty.lastAgentStatus === 'permission' && (status.state === 'blocked' || status.state === 'waiting')) if (!titleConfirmsState) { - if (richStatusCanOwnTitleInterval && status.updatedAt >= titleEvidenceAt) { + if ( + richStatusCanOwnTitleInterval && + agentStatusAuthorityObservedAt(status) >= titleEvidenceAt + ) { return status } if (pty.lastAgentStatus === null && !terminalTitleBlocksExplicitAgentStatus(pty.lastOscTitle)) { @@ -82,7 +86,8 @@ export function renewRuntimeMobileAgentStatusFromPtyTitle( ) } const richStatusOwnsCurrentState = - Date.now() - status.updatedAt <= AGENT_STATUS_STALE_AFTER_MS && richStatusCanOwnTitleInterval + Date.now() - agentStatusAuthorityObservedAt(status) <= AGENT_STATUS_STALE_AFTER_MS && + richStatusCanOwnTitleInterval // Fresh explicit evidence from this title interval owns acknowledgement identity. const stateStartedAt = richStatusOwnsCurrentState ? status.stateStartedAt @@ -124,7 +129,7 @@ export function selectRuntimeHookAgentRowForPane( entry.agentType && (entry.providerSessionOnly !== true || (entry.agentType === 'pi' && entry.providerSession != null)) && - entry.receivedAt >= freshAfter && + (entry.evidenceObservedAt ?? entry.receivedAt) >= freshAfter && (!agent || entry.receivedAt > agent.receivedAt) ) { agent = entry @@ -133,7 +138,7 @@ export function selectRuntimeHookAgentRowForPane( entry.providerSessionOnly !== true && // Restored rows cannot prove liveness because the turn may have ended while offline (#12346). entry.restoredUnconfirmed !== true && - entry.receivedAt >= freshAfter && + (entry.evidenceObservedAt ?? entry.receivedAt) >= freshAfter && (!live || entry.receivedAt > live.receivedAt) ) { live = entry @@ -149,6 +154,9 @@ export function selectRuntimeHookAgentRowForPane( ? { payload: pickParsedAgentStatusPayload(live), updatedAt: live.receivedAt, + ...(live.evidenceObservedAt !== undefined + ? { evidenceObservedAt: live.evidenceObservedAt } + : {}), stateStartedAt: live.stateStartedAt ?? live.receivedAt, ...(live.worktreeId ? { worktreeId: live.worktreeId } : {}) } @@ -167,6 +175,13 @@ export function resolveRuntimeHookLiveAgentRow( if (live.payload.interactivePrompt != null) { return live } - // This is the pane's only wall-clock title timestamp comparable to hook `receivedAt`. - return !nonAgentTitle && live.updatedAt >= (pty?.lastOscTitleEpochMs ?? 0) ? live : null + // This is the pane's only wall-clock title timestamp comparable to when the hook evidence + // was observed; replay delivery order must not make old evidence outrank a newer title. + return !nonAgentTitle && + agentStatusAuthorityObservedAt({ + updatedAt: live.updatedAt, + evidenceObservedAt: live.evidenceObservedAt + }) >= (pty?.lastOscTitleEpochMs ?? 0) + ? live + : null } diff --git a/src/main/runtime/runtime-mobile-session-projection-contract.ts b/src/main/runtime/runtime-mobile-session-projection-contract.ts index 6aaed42764c..f4174b7715a 100644 --- a/src/main/runtime/runtime-mobile-session-projection-contract.ts +++ b/src/main/runtime/runtime-mobile-session-projection-contract.ts @@ -18,6 +18,7 @@ export type RuntimeMobileSessionProjectionHost = { getLiveBrowserTabs(worktreeId: string): Map getProviderSessionRows(paneKey: string): AgentStatusIpcPayload[] | undefined getProviderSessionSnapshot(): AgentStatusIpcPayload[] + getStatusSnapshot(): AgentStatusIpcPayload[] getLeafKey(tabId: string, leafId: string): string findPty( worktreeId: string, @@ -27,7 +28,8 @@ export type RuntimeMobileSessionProjectionHost = { getRetainedStatus( paneKey: string, pty: RuntimePtyWorktreeRecord | null, - tab: RuntimeMobileSessionTerminalTab + tab: RuntimeMobileSessionTerminalTab, + getRows: (paneKey: string, terminalHandle: string | null) => AgentStatusIpcPayload[] ): RuntimeAgentRowSnapshot | null getTrackedTitle(ptyId: string | null): string | null issuePtyHandle(pty: RuntimePtyWorktreeRecord): string diff --git a/src/main/runtime/runtime-mobile-session-projection.ts b/src/main/runtime/runtime-mobile-session-projection.ts index 8fa9bb954dc..db1ef0619ca 100644 --- a/src/main/runtime/runtime-mobile-session-projection.ts +++ b/src/main/runtime/runtime-mobile-session-projection.ts @@ -48,6 +48,42 @@ export function projectRuntimeMobileSessionTabs( hookRowsForPane.set(paneKey, rows) return rows } + let statusRowsByPaneKey: Map | null = null + let statusRowsByTerminalHandle: Map | null = null + const getStatusRows = ( + paneKey: string, + terminalHandle: string | null + ): AgentStatusIpcPayload[] => { + if (!statusRowsByPaneKey || !statusRowsByTerminalHandle) { + statusRowsByPaneKey = new Map() + statusRowsByTerminalHandle = new Map() + for (const row of host.getStatusSnapshot()) { + const paneRows = statusRowsByPaneKey.get(row.paneKey) + if (paneRows) { + paneRows.push(row) + } else { + statusRowsByPaneKey.set(row.paneKey, [row]) + } + if (row.terminalHandle) { + const handleRows = statusRowsByTerminalHandle.get(row.terminalHandle) + if (handleRows) { + handleRows.push(row) + } else { + statusRowsByTerminalHandle.set(row.terminalHandle, [row]) + } + } + } + } + const paneRows = statusRowsByPaneKey.get(paneKey) ?? [] + if (!terminalHandle) { + return paneRows + } + const handleRows = statusRowsByTerminalHandle.get(terminalHandle) ?? [] + if (paneRows.length === 0) { + return handleRows + } + return [...paneRows, ...handleRows.filter((row) => !paneRows.includes(row))] + } // Why: a live PTY backs one surface; claim each once so two leaves resolving to it can't emit duplicate React keys and crash the client. const claimedLivePtyIds = new Set() for (const tab of snapshot.tabs) { @@ -98,11 +134,11 @@ export function projectRuntimeMobileSessionTabs( ? makePaneKey(tab.parentTabId, tab.leafId) : `${tab.parentTabId}:${legacyPaneId ?? tab.leafId}` const mobileStatusPty = livePty ?? pty - // Why: headless hooks live only in main's retained rows; reuse this lookup + // Why: headless hooks live in main's status store; reuse this lookup // for both title ownership and status publication so the two cannot diverge. const retainedAgentStatus = tab.agentStatus ? null - : host.getRetainedStatus(paneKey, liveLeafPty ?? mobileStatusPty, tab) + : host.getRetainedStatus(paneKey, liveLeafPty ?? mobileStatusPty, tab, getStatusRows) const hookAgentStatus = tab.agentStatus ? selectRuntimeHookAgentRowForPane(getHookRowsForPane(paneKey)) : null diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 875eef03600..227788af7e1 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -96,12 +96,15 @@ export type RuntimeTerminalAgentStatusEvent = { tabId?: string worktreeId?: string connectionId?: string | null + /** The pane's terminal handle, when it is bound to one. Stamped on the stored row so a + * reader can rejoin it to the terminal after the pane key moved. */ + terminalHandle?: string payload: ParsedAgentStatusPayload } export type HookLiveAgentRow = Pick< RuntimeAgentRowSnapshot, - 'payload' | 'updatedAt' | 'stateStartedAt' | 'worktreeId' + 'payload' | 'updatedAt' | 'evidenceObservedAt' | 'stateStartedAt' | 'worktreeId' > export type RuntimePtyDataAdmission = Readonly<{ diff --git a/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts b/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts index 83b3d651642..5b517f1bfc8 100644 --- a/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts +++ b/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts @@ -54,8 +54,11 @@ function attach(summaries: AgentSessionStatusSummary[]): RuntimeWorktreePsSummar workingTerminalEvidenceByWorktreeId: new Map(), rowSources: collectRuntimeWorktreeAgentSources({ mirroredWorktreeIdByTabId: new Map(), - connectedPtyEvidence: { tabIds: new Set(), paneKeys: new Set(), ptyIds: new Set() }, - retainedSnapshots: [], + connectedPtyEvidence: { + tabIds: new Set(), + paneKeys: new Set(), + ptyIdByTerminalHandle: new Map() + }, hookSnapshots: store.getStatusSnapshot() }), orchestrationByPaneKey: null, diff --git a/src/main/runtime/runtime-worktree-agent-rows.ts b/src/main/runtime/runtime-worktree-agent-rows.ts index da145b67091..20c17f9b01a 100644 --- a/src/main/runtime/runtime-worktree-agent-rows.ts +++ b/src/main/runtime/runtime-worktree-agent-rows.ts @@ -4,7 +4,7 @@ import { mergeWorktreeSummaryStatus } from './runtime-worktree-status-projection import type { RuntimeWorktreeSummaryPathIndex } from './runtime-worktree-summary-paths' import type { RuntimeWorkingTerminalEvidence } from './runtime-worktree-ps-activity' import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source' -export type { RuntimeAgentRowSnapshot } from './runtime-worktree-pty-agent-sources' +export type { RuntimeAgentRowSnapshot } from './runtime-hook-agent-row-selection' type OrchestrationDisplay = { taskTitle?: string | null diff --git a/src/main/runtime/runtime-worktree-agent-sources.test.ts b/src/main/runtime/runtime-worktree-agent-sources.test.ts index c0395cd6831..5da2f6e8550 100644 --- a/src/main/runtime/runtime-worktree-agent-sources.test.ts +++ b/src/main/runtime/runtime-worktree-agent-sources.test.ts @@ -1,47 +1,48 @@ import { describe, expect, it } from 'vitest' import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources' -import type { RuntimeAgentRowSnapshot } from './runtime-worktree-pty-agent-sources' import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' const paneKey = 'worktree:tab:0' const now = Date.now() -const retained: RuntimeAgentRowSnapshot = { +const hookRow: AgentStatusIpcPayload = { paneKey, - ptyId: 'pty', tabId: 'tab', + terminalHandle: 'term_row', worktreeId: 'worktree', connectionId: null, - payload: { state: 'working', prompt: 'implement', agentType: 'codex' }, + state: 'working', + prompt: 'implement', + agentType: 'codex', stateStartedAt: now, - updatedAt: now + receivedAt: now } const base = { - retainedSnapshots: [retained], - hookSnapshots: [] as AgentStatusIpcPayload[], - structuredSummaries: [], + hookSnapshots: [hookRow], mirroredWorktreeIdByTabId: new Map(), connectedPtyEvidence: { tabIds: new Set(), paneKeys: new Set(), - ptyIds: new Set() + ptyIdByTerminalHandle: new Map() + } +} +const connected = { + ...base, + connectedPtyEvidence: { + tabIds: new Set(['tab']), + paneKeys: new Set([paneKey]), + ptyIdByTerminalHandle: new Map([['term_row', 'pty']]) } } describe('worktree agent source admission', () => { it('rejects a disconnected local terminal before row assembly', () => { expect(collectRuntimeWorktreeAgentSources(base).size).toBe(0) - const connected = { - ...base, - connectedPtyEvidence: { ...base.connectedPtyEvidence, ptyIds: new Set(['pty']) } - } expect(collectRuntimeWorktreeAgentSources(connected).get(paneKey)?.state).toBe('working') }) it('keeps remote evidence and resolves mirrored workspace ownership', () => { - const remote = { ...retained, connectionId: 'ssh-connection' } - expect(collectRuntimeWorktreeAgentSources({ ...base, retainedSnapshots: [remote] }).size).toBe( - 1 - ) + const remote = { ...hookRow, connectionId: 'ssh-connection' } + expect(collectRuntimeWorktreeAgentSources({ ...base, hookSnapshots: [remote] }).size).toBe(1) const sources = collectRuntimeWorktreeAgentSources({ ...base, mirroredWorktreeIdByTabId: new Map([['tab', 'remote-worktree']]) @@ -49,22 +50,38 @@ describe('worktree agent source admission', () => { expect(sources.get(paneKey)?.worktreeId).toBe('remote-worktree') }) - it('preserves fresh monitoring enrichment on a newer retained report', () => { - const hook: AgentStatusIpcPayload = { - ...retained.payload, - paneKey, - tabId: 'tab', - worktreeId: 'worktree', - connectionId: null, - stateStartedAt: now - 1, - receivedAt: now - 1, - workingMode: 'monitoring' - } - const sources = collectRuntimeWorktreeAgentSources({ + it('rejoins the row to the connected PTY behind its terminal handle', () => { + expect(collectRuntimeWorktreeAgentSources(connected).get(paneKey)?.ptyId).toBe('pty') + // The handle is the last rescue once a controller incarnation nulls the pane binding. + const bindingCleared = collectRuntimeWorktreeAgentSources({ ...base, - hookSnapshots: [hook], - connectedPtyEvidence: { ...base.connectedPtyEvidence, ptyIds: new Set(['pty']) } + connectedPtyEvidence: { + ...base.connectedPtyEvidence, + ptyIdByTerminalHandle: new Map([['term_row', 'pty']]) + } }) - expect(sources.get(paneKey)).toMatchObject({ updatedAt: now, workingMode: 'monitoring' }) + expect(bindingCleared.get(paneKey)?.ptyId).toBe('pty') + // No connected PTY answers to the handle and no pane evidence: the row is not admitted. + expect(collectRuntimeWorktreeAgentSources(base).size).toBe(0) + }) + + it('carries the row own working mode and drops non-live rows', () => { + const monitoring = collectRuntimeWorktreeAgentSources({ + ...connected, + hookSnapshots: [{ ...hookRow, workingMode: 'monitoring' as const }] + }) + expect(monitoring.get(paneKey)).toMatchObject({ updatedAt: now, workingMode: 'monitoring' }) + + const restored = collectRuntimeWorktreeAgentSources({ + ...connected, + hookSnapshots: [{ ...hookRow, restoredUnconfirmed: true as const }] + }) + expect(restored.size).toBe(0) + + const providerSessionOnly = collectRuntimeWorktreeAgentSources({ + ...connected, + hookSnapshots: [{ ...hookRow, providerSessionOnly: true }] + }) + expect(providerSessionOnly.size).toBe(0) }) }) diff --git a/src/main/runtime/runtime-worktree-ps-activity.ts b/src/main/runtime/runtime-worktree-ps-activity.ts index c6c8d7fafc3..ae3ecdee4c3 100644 --- a/src/main/runtime/runtime-worktree-ps-activity.ts +++ b/src/main/runtime/runtime-worktree-ps-activity.ts @@ -188,10 +188,16 @@ export function applyRuntimeWorktreePsSessionActivity(args: { missingIds: Set ptysById: ReadonlyMap tabs: ReadonlyMap + /** Non-minting: a listing must not issue handles, only recognise the ones already bound. */ + getTerminalHandlesForPty: (ptyId: string) => readonly string[] getSummary: SummaryLookup }): { mirroredWorktreeIdByTabId: Map - connectedPtyEvidence: { tabIds: Set; paneKeys: Set; ptyIds: Set } + connectedPtyEvidence: { + tabIds: Set + paneKeys: Set + ptyIdByTerminalHandle: Map + } } { const mirroredWorktreeIdByTabId = new Map() const sessionsByHostId = new Map() @@ -244,19 +250,21 @@ export function applyRuntimeWorktreePsSessionActivity(args: { const connectedPtyEvidence = { tabIds: new Set(), paneKeys: new Set(), - ptyIds: new Set() + ptyIdByTerminalHandle: new Map() } for (const pty of args.ptysById.values()) { if (!pty.connected) { continue } - connectedPtyEvidence.ptyIds.add(pty.ptyId) if (pty.tabId) { connectedPtyEvidence.tabIds.add(pty.tabId) } if (pty.paneKey) { connectedPtyEvidence.paneKeys.add(pty.paneKey) } + for (const terminalHandle of args.getTerminalHandlesForPty(pty.ptyId)) { + connectedPtyEvidence.ptyIdByTerminalHandle.set(terminalHandle, pty.ptyId) + } } return { mirroredWorktreeIdByTabId, connectedPtyEvidence } } diff --git a/src/main/runtime/runtime-worktree-pty-agent-sources.ts b/src/main/runtime/runtime-worktree-pty-agent-sources.ts index 9f297d7edf7..058d378df11 100644 --- a/src/main/runtime/runtime-worktree-pty-agent-sources.ts +++ b/src/main/runtime/runtime-worktree-pty-agent-sources.ts @@ -1,34 +1,23 @@ import { - AGENT_STATUS_STALE_AFTER_MS, pickParsedAgentStatusPayload, type AgentStatusIpcPayload, type ParsedAgentStatusPayload } from '../../shared/agent-status-types' -import { terminalStatusPayloadMatchesHook } from '../../shared/agent-terminal-status-equivalence' import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id' import { isWslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract' import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source' -export type RuntimeAgentRowSnapshot = { - paneKey: string - ptyId: string - worktreeId?: string - tabId?: string - connectionId: string | null - payload: ParsedAgentStatusPayload - stateStartedAt: number - updatedAt: number -} - export type ConnectedPtyEvidence = { tabIds: ReadonlySet paneKeys: ReadonlySet - ptyIds: ReadonlySet + /** The connected PTY behind each issued terminal handle. A status row names a pane and the + * handle it was observed under, never a process, so this is where it rejoins its terminal — + * and it is the only rescue left for a row whose pane binding was cleared under it. */ + ptyIdByTerminalHandle: ReadonlyMap } -/** Reconcile terminal status, then admit rows using their execution-host evidence. */ +/** Admit hook-server rows using their execution-host evidence. */ export function collectRuntimeWorktreePtyAgentSources(args: { - retainedSnapshots: Iterable hookSnapshots: readonly AgentStatusIpcPayload[] mirroredWorktreeIdByTabId: ReadonlyMap connectedPtyEvidence: ConnectedPtyEvidence @@ -37,50 +26,16 @@ export function collectRuntimeWorktreePtyAgentSources(args: { string, RuntimeWorktreeAgentSource & { payload: ParsedAgentStatusPayload } >() - const now = Date.now() - for (const snapshot of args.retainedSnapshots) { - const { payload } = snapshot - rowSources.set(snapshot.paneKey, { - paneKey: snapshot.paneKey, - ptyId: snapshot.ptyId, - tabId: snapshot.tabId, - worktreeId: snapshot.worktreeId, - connectionId: snapshot.connectionId, - payload, - state: payload.state, - ...(payload.workingMode ? { workingMode: payload.workingMode } : {}), - agentType: payload.agentType ?? null, - prompt: payload.prompt, - lastAssistantMessage: payload.lastAssistantMessage ?? null, - toolName: payload.toolName ?? null, - toolInput: payload.toolInput ?? null, - interrupted: payload.interrupted ?? false, - stateStartedAt: snapshot.stateStartedAt, - updatedAt: snapshot.updatedAt - }) - } for (const entry of args.hookSnapshots) { - if (entry.restoredUnconfirmed === true) { + if (entry.restoredUnconfirmed === true || entry.providerSessionOnly === true) { continue } - const existing = rowSources.get(entry.paneKey) const hookPayload = pickParsedAgentStatusPayload(entry) - if (existing && existing.updatedAt > entry.receivedAt) { - if ( - entry.workingMode === 'monitoring' && - now - entry.receivedAt <= AGENT_STATUS_STALE_AFTER_MS && - terminalStatusPayloadMatchesHook(hookPayload, existing.payload) - ) { - existing.workingMode = 'monitoring' - if (existing.payload.workingMode === undefined) { - existing.payload = { ...existing.payload, workingMode: 'monitoring' } - } - } - continue - } rowSources.set(entry.paneKey, { paneKey: entry.paneKey, - ptyId: existing?.ptyId, + ptyId: entry.terminalHandle + ? args.connectedPtyEvidence.ptyIdByTerminalHandle.get(entry.terminalHandle) + : undefined, tabId: entry.tabId, worktreeId: entry.worktreeId, connectionId: entry.connectionId, @@ -94,10 +49,8 @@ export function collectRuntimeWorktreePtyAgentSources(args: { toolInput: entry.toolInput ?? null, interrupted: entry.interrupted ?? false, stateStartedAt: entry.stateStartedAt, - // A structured row's clock is its journal, so a restart's republish does not read as new. - updatedAt: entry.structuredHost - ? (entry.evidenceObservedAt ?? entry.receivedAt) - : entry.receivedAt, + // A replay advances delivery order, not the age of the evidence shown by worktree.ps. + updatedAt: entry.evidenceObservedAt ?? entry.receivedAt, ...(entry.structuredHost ? { structuredHost: entry.structuredHost } : {}) }) } @@ -117,7 +70,8 @@ export function collectRuntimeWorktreePtyAgentSources(args: { (source.connectionId === null || isWslHookRelayConnectionId(source.connectionId)) && !args.connectedPtyEvidence.tabIds.has(tabId) && !args.connectedPtyEvidence.paneKeys.has(source.paneKey) && - (source.ptyId === undefined || !args.connectedPtyEvidence.ptyIds.has(source.ptyId)) + // Resolved only from a connected PTY's handle, so its presence is the liveness evidence. + source.ptyId === undefined ) { continue } diff --git a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts index 519b9026f17..afe5a3a2a71 100644 --- a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts +++ b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts @@ -115,8 +115,11 @@ function worktreeFor(store: AgentHookServer): RuntimeWorktreePsSummary { workingTerminalEvidenceByWorktreeId: new Map(), rowSources: collectRuntimeWorktreeAgentSources({ mirroredWorktreeIdByTabId: new Map(), - connectedPtyEvidence: { tabIds: new Set(), paneKeys: new Set(), ptyIds: new Set() }, - retainedSnapshots: [], + connectedPtyEvidence: { + tabIds: new Set(), + paneKeys: new Set(), + ptyIdByTerminalHandle: new Map() + }, hookSnapshots: store.getStatusSnapshot() }), orchestrationByPaneKey: null, diff --git a/src/main/runtime/terminal-interactive-wait-visibility.test.ts b/src/main/runtime/terminal-interactive-wait-visibility.test.ts index 5e652af41f6..3a6470b41f9 100644 --- a/src/main/runtime/terminal-interactive-wait-visibility.test.ts +++ b/src/main/runtime/terminal-interactive-wait-visibility.test.ts @@ -1,10 +1,12 @@ // A worker parked on an interactive prompt must be distinguishable from one that is thinking // or inside a long tool call (STA-4513, STA-3714). import { readFileSync } from 'node:fs' +import { makeAgentStatusStoreWiring } from './agent-status-store-wiring.test-fixture' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { - createTranscriptPane as createPane, + createTranscriptPane, + type TranscriptPaneOptions, TRANSCRIPT_PANE_PTY_ID as PTY_ID } from './agent-transcript-pane-test-harness' import { assertTerminalAgentSendable } from './rpc/terminal-agent-send-guard' @@ -41,6 +43,15 @@ function agentStatusOsc(state: string): string { return `]9999;${JSON.stringify({ state, prompt: 'ship it', agentType: 'claude' })}` } +async function createPane( + options: TranscriptPaneOptions +): Promise>> { + // Compose the same central hook-store wiring as desktop and orcad so OSC rows exercise the + // production status path rather than silently disappearing in a bare runtime fixture. + const statusWiring = makeAgentStatusStoreWiring() + return createTranscriptPane(options, statusWiring.deps) +} + // cursor-agent renders a braille spinner in its OSC title while it works, and Orca reads // that as `working`; the title is identical whether it is running a command or waiting. const CURSOR_TITLE = '⠇ Cursor Agent' diff --git a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts index 76d92e77e9a..87d5c77f205 100644 --- a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts +++ b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts @@ -412,7 +412,7 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { expect(events).toHaveLength(2) }) - it('clears stamped status on reconnect loss but not final shutdown', async () => { + it('keeps stamped status unverifiable across reconnect loss and final shutdown', async () => { const initialRelay = createFakeRelay() relay = createFakeRelay() vi.mocked(deployAndLaunchRelay) @@ -436,16 +436,13 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { await session.reconnect({} as SshConnection) initialRelay.dispose() - expect(agentHookServer.getStatusSnapshot()).toEqual([]) - expect(clearListener).toHaveBeenCalledOnce() - expect(clearListener).toHaveBeenCalledWith({ - transient: true, - connectionId: 'conn-clear', - clearedAt: expect.any(Number) - }) + expect(agentHookServer.getStatusSnapshot()).toEqual([ + expect.objectContaining({ connectionId: 'conn-clear', state: 'working' }) + ]) + expect(clearListener).not.toHaveBeenCalled() session.dispose() session = null - expect(clearListener).toHaveBeenCalledOnce() + expect(clearListener).not.toHaveBeenCalled() }) it('asks the fake relay for cached hook replay after the session wires its listener', async () => { diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index a4fdb0f0fa7..08754ca76e1 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -1679,10 +1679,9 @@ export class SshRelaySession { if (reason === 'shutdown') { clearPtyOwnershipForConnection(this.targetId) - } else { - // Why: handlers detached above, so no late event can re-stamp status between this clear and reconnect replay. - agentHookServer.clearStatusEntriesForConnection(this.targetId) } + // Connection loss makes remote status unverifiable, not exited. Keep the last observation; + // replay or certified process teardown will update or remove it on the execution host. const ptyProvider = getSshPtyProvider(this.targetId) if (ptyProvider && 'dispose' in ptyProvider) { diff --git a/src/main/startup/headless-pty-hydration-ordering.test.ts b/src/main/startup/headless-pty-hydration-ordering.test.ts index e866a5d1926..3b661dede99 100644 --- a/src/main/startup/headless-pty-hydration-ordering.test.ts +++ b/src/main/startup/headless-pty-hydration-ordering.test.ts @@ -52,4 +52,64 @@ describe('headless PTY registry hydration ordering', () => { expect(rpc).toBeGreaterThan(handlersAndHydration) expect(readiness).toBeGreaterThan(rpc) }) + + it('starts the orcad hook owner after Store hydration and before daemon PTY recovery', () => { + const source = readFileSync(join(process.cwd(), 'src/main/orcad/orcad-entry.ts'), 'utf8') + const cleanup = source.indexOf('registerCleanup(async () => {') + const hookStop = source.indexOf('agentHookServer.stop()', cleanup) + const store = source.indexOf('const store = new Store(') + const hookStart = source.indexOf('await agentHookServer.start(', store) + const daemon = source.indexOf('await startOrcadDaemon()', hookStart) + const hookEnv = source.indexOf('buildAgentHookPtyEnv:', daemon) + const handlersAndHydration = source.indexOf('await registerHeadlessPtyRuntime(', hookEnv) + + expect(cleanup).toBeGreaterThanOrEqual(0) + expect(hookStop).toBeGreaterThan(cleanup) + expect(store).toBeGreaterThan(hookStop) + expect(hookStart).toBeGreaterThan(store) + expect(daemon).toBeGreaterThan(hookStart) + expect(hookEnv).toBeGreaterThan(daemon) + expect(source.slice(hookEnv, handlersAndHydration)).toContain('agentHookServer.buildPtyEnv()') + expect(handlersAndHydration).toBeGreaterThan(hookEnv) + }) + + it('captures orcad status identity at ingest for fleet stale-row fencing', () => { + const source = readFileSync(join(process.cwd(), 'src/main/orcad/orcad-entry.ts'), 'utf8') + const runtime = source.indexOf('const runtime = new OrcaRuntimeService(') + const identityReader = source.indexOf('readObservedAgentStatusPaneIdentity:', runtime) + const identitySubscription = source.indexOf('agentHookServer.subscribeEnrichedStatus(') + const hooksEnabled = source.indexOf('if (isAgentStatusHooksEnabled(', identitySubscription) + const identityFlush = source.indexOf('observedStatusCapture.attach(runtime)', runtime) + + expect(runtime).toBeGreaterThanOrEqual(0) + expect(identityReader).toBeGreaterThan(runtime) + expect(identitySubscription).toBeGreaterThanOrEqual(0) + expect(identitySubscription).toBeLessThan(runtime) + expect(hooksEnabled).toBeGreaterThan(identitySubscription) + expect(identityFlush).toBeGreaterThan(runtime) + expect(source.slice(identitySubscription, runtime)).toContain( + 'observedStatusCapture.observe(enriched)' + ) + }) + + it('captures spool-replayed identity after the orcad runtime is ready', () => { + const source = readFileSync(join(process.cwd(), 'src/main/orcad/orcad-entry.ts'), 'utf8') + const subscription = source.indexOf('agentHookServer.subscribeEnrichedStatus(') + const hookStart = source.indexOf('await agentHookServer.start(', subscription) + const runtime = source.indexOf('const runtime = new OrcaRuntimeService(') + const handlers = source.indexOf('await registerHeadlessPtyRuntime(', runtime) + const identityRecovery = source.indexOf('await runtime.refreshRestoredOrchestrationAuthority()') + const workerRecovery = source.indexOf('await runtime.reconcileLegacyWorkerTerminals()') + const replay = source.indexOf('observedStatusCapture.attach(runtime)', runtime) + + expect(subscription).toBeGreaterThanOrEqual(0) + expect(hookStart).toBeGreaterThan(subscription) + expect(runtime).toBeGreaterThan(hookStart) + expect(handlers).toBeGreaterThan(runtime) + expect(identityRecovery).toBeGreaterThan(handlers) + expect(workerRecovery).toBeGreaterThan(identityRecovery) + expect(replay).toBeGreaterThan(workerRecovery) + expect(source.slice(subscription, runtime)).toContain('observedStatusCapture.observe(enriched)') + expect(source.slice(replay)).toContain('observedStatusCapture.attach(runtime)') + }) }) diff --git a/src/main/startup/main-process-observers.ts b/src/main/startup/main-process-observers.ts index ba37b0a312f..86f7992e10d 100644 --- a/src/main/startup/main-process-observers.ts +++ b/src/main/startup/main-process-observers.ts @@ -3,9 +3,8 @@ import { join } from 'node:path' import { AgentAwakeService } from '../agent-awake-service' import { normalizeComputerAwakeMode } from '../../shared/computer-awake-mode' import { registerSystemResumeBroadcast } from '../system-resume-broadcast' -import { agentHookServer, type AgentHookProviderSessionIdentity } from '../agent-hooks/server' -import { createHookProviderSessionInvalidator } from '../agent-hooks/hook-provider-session-invalidation' -import { createHookStatusSessionTabsInvalidator } from '../agent-hooks/hook-status-session-tabs-invalidation' +import { agentHookServer } from '../agent-hooks/server' +import { installHookStatusSessionTabsRepublish } from '../agent-hooks/hook-status-session-tabs-republish' import { initTelemetry, track } from '../telemetry/client' import { setCodexTrustGrantTelemetry } from '../codex/codex-trust-grant-telemetry' import { initObservability } from '../observability' @@ -40,55 +39,20 @@ export function initializeMainProcessObservers(): void { isQuitting: () => state.isQuitting, getWorkingAgentCount: () => state.agentAwakeService?.getWorkingAgentCount() ?? 0 }) - const collectChangedProviderSessionWorktrees = createHookProviderSessionInvalidator() - const publishProviderSessionChanges = (identities: AgentHookProviderSessionIdentity[]): void => { - const ownedIdentities = identities.map((identity) => ({ - ...identity, - worktreeId: - identity.worktreeId ?? - state.runtime?.getTerminalWorktreeIdForPaneKey(identity.paneKey) ?? - undefined - })) - for (const worktreeId of collectChangedProviderSessionWorktrees(ownedIdentities)) { - // Why not `notifyMobileSessionTabsChanged` alone: it re-emits at the unchanged - // `snapshotVersion`, which every client drops on its monotonic gate. - state.runtime?.touchMobileSessionTabsForWorktree(worktreeId, { immediate: true }) - } - } - state.publishProviderSessionChanges = publishProviderSessionChanges const unsubscribeStatusChanges = agentHookServer.subscribeStatusChanges((statuses) => { state.agentAwakeService?.setStatuses(statuses) }) - // Healthy session.tabs streams need a push when transcript identity changes. - const unsubscribeProviderSessionChanges = agentHookServer.subscribeProviderSessionChanges( - (sessions) => publishProviderSessionChanges(sessions) + const unsubscribeStatusFreshness = agentHookServer.subscribeStatusFreshness((status) => { + state.agentAwakeService?.observeStatusFreshness(status) + }) + const uninstallHookStatusRepublish = installHookStatusSessionTabsRepublish( + agentHookServer, + () => state.runtime ) - // Why: hook rows are the only carrier of live agent state on a headless host, and - // nothing else republishes `session.tabs` when one changes — so a paired client - // would keep the pane's last projection until an unrelated PTY touch came along. - const hookStatusChangedSessionTabs = createHookStatusSessionTabsInvalidator() - const unsubscribeHookStatusSessionTabs = agentHookServer.subscribeEnrichedStatus((enriched) => { - if (hookStatusChangedSessionTabs(enriched)) { - state.runtime?.touchMobileSessionTabsForPane(enriched.paneKey, enriched.worktreeId ?? null) - } - }) - // Teardown: agent exit, pane close, and the SSH transient-disconnect batch all land - // here. Without it the live state published above becomes a zombie question card. - const unsubscribeHookStatusClear = agentHookServer.subscribePaneStatusClear((clear) => { - const clearedPaneKeys = - 'paneKey' in clear - ? [clear.paneKey] - : hookStatusChangedSessionTabs.forgetConnection(clear.connectionId) - for (const paneKey of clearedPaneKeys) { - hookStatusChangedSessionTabs.forgetPane(paneKey) - state.runtime?.touchMobileSessionTabsForPane(paneKey) - } - }) state.unsubscribeAgentAwakeStatusChanges = () => { unsubscribeStatusChanges() - unsubscribeProviderSessionChanges() - unsubscribeHookStatusSessionTabs() - unsubscribeHookStatusClear() + unsubscribeStatusFreshness() + uninstallHookStatusRepublish() } // Why: telemetry must init before any IPC handler/renderer can call track(); it's a no-op in dev and while TELEMETRY_ENABLED is false, so it's safe early. initTelemetry(store) diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 8af5630e02b..3aac4a03b3c 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -138,7 +138,6 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { // Why before anything can attach: a client host that reattaches to a restarted runtime is only // handed its pages back if the runtime found them first. runtime.rehydrateClientHostedBrowserPages() - state.publishProviderSessionChanges?.(agentHookServer.getProviderSessionIdentities()) browserManager.setBrowserGuestStateChangedListener((worktreeId) => { runtime.notifyMobileSessionTabsChanged(worktreeId) }) diff --git a/src/main/startup/main-process-state.ts b/src/main/startup/main-process-state.ts index 17a5eb427c6..2b9361a6d51 100644 --- a/src/main/startup/main-process-state.ts +++ b/src/main/startup/main-process-state.ts @@ -25,7 +25,6 @@ import type { PluginMarketplaceInstaller } from '../plugins/plugin-marketplace-i import type { KeybindingService } from '../keybindings/keybinding-service' import type { RelayBrokerStatus } from '../runtime/relay/relay-session-broker' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' -import type { AgentHookProviderSessionIdentity } from '../agent-hooks/server' import type { EmulatorBridge } from '../emulator/emulator-bridge' import type { GpuFallbackMarker, GpuFallbackEnvironment } from './gpu-fallback-marker' import type { createCodexSessionMigrationScheduler } from '../codex/codex-session-migration-scheduler' @@ -78,9 +77,6 @@ export const mainProcessState = { repoMaintenanceShutdown: Promise.resolve() as Promise, crashReports: null as CrashReportStore | null, unsubscribeAgentAwakeStatusChanges: null as (() => void) | null, - publishProviderSessionChanges: null as - | ((identities: AgentHookProviderSessionIdentity[]) => void) - | null, unsubscribeSystemResumeBroadcast: null as (() => void) | null, watcherShutdownPromise: null as Promise | null, watcherShutdownDone: false, diff --git a/src/shared/agent-hook-listener/listener-event.ts b/src/shared/agent-hook-listener/listener-event.ts index 9bca14cc857..e31222d0bb6 100644 --- a/src/shared/agent-hook-listener/listener-event.ts +++ b/src/shared/agent-hook-listener/listener-event.ts @@ -44,6 +44,10 @@ export type AgentHookEventPayload = { /** Row projected from a structured session the host holds: `owned` while its provider child * runs here, `held` once the child is gone but the session is still open. Never persisted. */ structuredHost?: StructuredHostStatus + /** Runtime terminal handle the pane resolved to when main parsed this status off the PTY. + * Lets a reader rejoin the row to its terminal after the pane key moved. Never persisted: + * a handle belongs to the runtime that issued it. */ + terminalHandle?: string payload: ParsedAgentStatusPayload } diff --git a/src/shared/orchestration-fleet-agent-status-evidence.ts b/src/shared/orchestration-fleet-agent-status-evidence.ts index f03b1d9cbfa..43ff2c40cf4 100644 --- a/src/shared/orchestration-fleet-agent-status-evidence.ts +++ b/src/shared/orchestration-fleet-agent-status-evidence.ts @@ -1,7 +1,8 @@ // ─── The one identity/clock contract the fleet path reads ──────────────────── // A hook row carries a pane key, a delivery timestamp and, from newer hosts, an -// observation timestamp. Terminal identity lives on the runtime, not on the row. -// The fleet matcher needs both, and every fact it needs used to be an OPTIONAL +// observation timestamp. A row may carry the runtime handle observed with OSC, but +// fleet authority still resolves terminal identity from the runtime. The matcher needs both, +// and every fact it needs used to be an OPTIONAL // field on `AgentStatusIpcPayload` — so an unenriched producer published a row the // matcher silently failed to identify (failure table L-1) and a missing observation // clock silently degraded to the delivery clock (W1-14 / RR-W-P1A). @@ -10,8 +11,8 @@ // deliberately exposes no `terminalHandle?`, no `evidenceObservedAt?` and no raw // payload, so a consumer cannot read an absent identity or clock by accident. // -// This type never crosses IPC or the wire. `AgentStatusIpcPayload` is unchanged and -// remains what `agentStatus:set` / `agentStatus:getSnapshot` publish. +// This type never crosses IPC or the wire. `AgentStatusIpcPayload` remains what +// `agentStatus:set` / `agentStatus:getSnapshot` publish. import type { AgentStatusIpcPayload } from './agent-status-ipc-payload' import type { AgentStatusState, AgentType } from './agent-status-types' diff --git a/tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts b/tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts index 09cfa6d6d7c..9a5528938be 100644 --- a/tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts +++ b/tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts @@ -16,6 +16,7 @@ import { resetWebSessionTabsSnapshotFreshnessForTests, type WebSessionTabsSyncState } from '../../src/renderer/src/runtime/web-session-tabs-sync' +import { makeAgentStatusStoreWiring } from '../../src/main/runtime/agent-status-store-wiring.test-fixture' vi.mock('../../src/renderer/src/store', () => ({ useAppStore: { @@ -689,7 +690,9 @@ describe('real PTY decorative session-tabs fanout', () => { }) it('renews retained hook status without resetting its state start', () => { - const runtime = new OrcaRuntimeService() + const statusWiring = makeAgentStatusStoreWiring() + const runtime = new OrcaRuntimeService(null, undefined, statusWiring.deps) + const uninstallStatusRepublish = statusWiring.attach(runtime) const ptyId = seedWorktree(runtime, 0) const internals = runtime as unknown as RuntimeInternals const seededTab = internals.mobileSessionTabsByWorktree.get('workspace-0')?.tabs[0] @@ -769,5 +772,7 @@ describe('real PTY decorative session-tabs fanout', () => { true ) unsubscribe() + uninstallStatusRepublish() + statusWiring.statusStore.stop() }) }) diff --git a/tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts b/tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts index 2c60fe19082..d15a6513cee 100644 --- a/tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts +++ b/tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from '../../src/main/runtime/orca-runtime' +import { makeAgentStatusStoreWiring } from '../../src/main/runtime/agent-status-store-wiring.test-fixture' import type { RuntimeMobileSessionTabsResult, RuntimeMobileSessionTabsSnapshot @@ -27,7 +28,9 @@ type Harness = { } function createHarness(): Harness { - const runtime = new OrcaRuntimeService() + const statusWiring = makeAgentStatusStoreWiring() + const runtime = new OrcaRuntimeService(null, undefined, statusWiring.deps) + const uninstallStatusRepublish = statusWiring.attach(runtime) runtime.registerPty(PTY_ID, WORKTREE_ID) const tab: TerminalTab = { type: 'terminal', @@ -58,7 +61,17 @@ function createHarness(): Harness { const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { publications.push(structuredClone(snapshot)) }) - return { internals, publications, runtime, tab, unsubscribe } + return { + internals, + publications, + runtime, + tab, + unsubscribe: () => { + unsubscribe() + uninstallStatusRepublish() + statusWiring.statusStore.stop() + } + } } function setRichStatus( diff --git a/tests/e2e/worktree-switch-first-paint.spec.ts b/tests/e2e/worktree-switch-first-paint.spec.ts index aaaea9aaac8..f062b6e246a 100644 --- a/tests/e2e/worktree-switch-first-paint.spec.ts +++ b/tests/e2e/worktree-switch-first-paint.spec.ts @@ -471,7 +471,9 @@ test.describe('Worktree switch first paint', () => { // runners cannot hold a latency threshold, but "the switch mounted one pane" // and "the warm set came back" are exact and are the real regression guards. if (process.env.CI) { - console.log(`[switch-budget] CI run, latency budget not enforced (median ${median(restored).toFixed(1)}ms)`) + console.log( + `[switch-budget] CI run, latency budget not enforced (median ${median(restored).toFixed(1)}ms)` + ) return } expect(median(restored)).toBeLessThanOrEqual(FIRST_PAINT_BUDGET_MS) From 9c918b93a664d28ae715d331bf5c696f8ec024a4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:43:43 -0700 Subject: [PATCH 020/191] fix(deps): take Electron 43.7.0 for the glibc environ use-after-free (#20089) --- config/scripts/electron-runtime-floor.test.ts | 57 +++++++++++++++++ docs/reference/linux-glibc-compatibility.md | 30 +++++++++ package.json | 2 +- pnpm-lock.yaml | 63 ++++++++++--------- pnpm-workspace.yaml | 1 + 5 files changed, 121 insertions(+), 32 deletions(-) create mode 100644 config/scripts/electron-runtime-floor.test.ts diff --git a/config/scripts/electron-runtime-floor.test.ts b/config/scripts/electron-runtime-floor.test.ts new file mode 100644 index 00000000000..77a1d4741e0 --- /dev/null +++ b/config/scripts/electron-runtime-floor.test.ts @@ -0,0 +1,57 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * Why a floor and not just a pin: Electron 43.5.0/43.6.0 set and unset `GDK_GL` + * around `gtk_init()` while FontConfig warmed up on a pool thread, and below + * glibc 2.41 that frees `environ` under a concurrent `getenv()` — a launch-time + * use-after-free on every Ubuntu we support (stablyai/orca#20081). 43.7.0 stops + * freeing the published `environ`. A downgrade past it re-ships that crash, and + * nothing else in the tree would notice. + */ +const MINIMUM_ELECTRON_VERSION = '43.7.0' + +function parseVersion(specifier: string): [number, number, number] { + const match = /(\d+)\.(\d+)\.(\d+)/.exec(specifier) + if (!match) { + throw new Error(`unparseable Electron version: ${specifier}`) + } + return [Number(match[1]), Number(match[2]), Number(match[3])] +} + +function meetsRuntimeFloor(specifier: string): boolean { + const version = parseVersion(specifier) + const floor = parseVersion(MINIMUM_ELECTRON_VERSION) + for (const [index, part] of version.entries()) { + if (part !== floor[index]) { + return part > floor[index] + } + } + return true +} + +describe('electron runtime floor', () => { + it.each([ + ['42.9.0', false], + ['43.6.0', false], + ['43.7.0', true], + ['43.7.1', true], + ['43.8.0', true], + ['44.0.0', true] + ])('reads %s as meeting the floor: %s', (specifier, expected) => { + expect(meetsRuntimeFloor(specifier)).toBe(expected) + }) + + it('pins Electron at or above the glibc environ-race fix', () => { + const packageJson = JSON.parse( + readFileSync(join(__dirname, '../../package.json'), 'utf-8') + ) as { devDependencies: Record } + const specifier = packageJson.devDependencies.electron + + expect( + meetsRuntimeFloor(specifier), + `electron ${specifier} is below the ${MINIMUM_ELECTRON_VERSION} runtime floor` + ).toBe(true) + }) +}) diff --git a/docs/reference/linux-glibc-compatibility.md b/docs/reference/linux-glibc-compatibility.md index 20e9b38acb5..64e72a67b83 100644 --- a/docs/reference/linux-glibc-compatibility.md +++ b/docs/reference/linux-glibc-compatibility.md @@ -131,3 +131,33 @@ a hole in the matrix. No strong `GLIBC_` node may exceed `2.31`, and no `GLIBCXX_`/`CXXABI_` node may exceed `3.4.28`/`1.3.12` — what stock Ubuntu 20.04 ships. + +## Runtime floor: the `environ` race below glibc 2.41 (Electron ≥ 43.7.0) + +Separate from the build floor above, one glibc runtime bug constrains which +Electron we may ship. Before glibc 2.41, `setenv`/`unsetenv` reallocate the +`environ` array and **free** the old one, so a concurrent `getenv()` on another +thread reads freed memory. Ubuntu 20.04–24.04 (2.31–2.39) are all below that +line, so every Linux target we support is exposed. + +Electron 43.5.0 made that latent race reachable on every launch: it started +setting `GDK_GL=disable` around `gtk_init()` and unsetting it right after, while +in the same change moving FontConfig warm-up onto a thread-pool thread that runs +concurrently and calls `getenv()` constantly +([electron#53070](https://github.com/electron/electron/pull/53070)). The result +is a browser-process use-after-free about a second into startup — no window, no +GPU child involved, and the corruption surfaces wherever the next allocation +lands, which is why reports name unrelated frames (`gtk_widget_realize`, +libxcb-dri3, FontConfig/expat). Orca 1.4.199/1.4.200 shipped that runtime and +died on launch on Ubuntu + NVIDIA/X11 +([#20081](https://github.com/stablyai/orca/issues/20081)). + +Electron 43.7.0 fixes it by overriding `setenv`/`unsetenv`/`putenv`/`clearenv` +so a published `environ` is never freed, deferring to glibc on 2.41+ +([electron#53491](https://github.com/electron/electron/pull/53491), backported +to 42/43/44/45). **Do not downgrade Electron below 43.7.0, or move to another +line, without confirming that backport is in the target release** — +`config/scripts/electron-runtime-floor.test.ts` fails the suite if the pin drops +below the floor. Orca itself writes `process.env` during early startup +(`patchPackagedProcessPath`, `configureOrcaUserDataPathEnv`, +`hydrate-shell-path`), so it is a first-class trigger, not just a bystander. diff --git a/package.json b/package.json index 4f03d793aa6..6bf087b987f 100644 --- a/package.json +++ b/package.json @@ -240,7 +240,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "dompurify": "3.4.14", - "electron": "43.6.0", + "electron": "43.7.0", "electron-builder": "^26.15.3", "electron-builder-squirrel-windows": "^26.15.3", "electron-vite": "^5.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7add63b397b..a7ea90a44bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,10 +127,10 @@ importers: version: 0.3.251(@anthropic-ai/sdk@0.122.0(zod@4.5.4))(@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@4.5.4))(zod@4.5.4) '@electron-toolkit/preload': specifier: ^3.0.2 - version: 3.0.2(electron@43.6.0(supports-color@7.2.0)) + version: 3.0.2(electron@43.7.0(supports-color@7.2.0)) '@electron-toolkit/utils': specifier: ^4.0.0 - version: 4.0.0(electron@43.6.0(supports-color@7.2.0)) + version: 4.0.0(electron@43.7.0(supports-color@7.2.0)) '@floating-ui/dom': specifier: 1.7.6 version: 1.7.6 @@ -224,7 +224,7 @@ importers: version: 2.1.14(@playwright/test@1.59.1)(zod@4.5.4) '@tailwindcss/vite': specifier: ^4.2.4 - version: 4.2.4(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4)) + version: 4.2.4(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4)) '@tanstack/react-virtual': specifier: ^3.14.10 version: 3.14.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -314,7 +314,7 @@ importers: version: 8.18.1 '@vitejs/plugin-react': specifier: ^5.2.0 - version: 5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4)) + version: 5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4)) '@xterm/addon-fit': specifier: 0.12.0-beta.300 version: 0.12.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=1f36ce689bc50c703ae09aeda0e064f107e18e4a5ecba19e746fa5edc4b02ef4)) @@ -349,8 +349,8 @@ importers: specifier: 3.4.14 version: 3.4.14 electron: - specifier: 43.6.0 - version: 43.6.0(supports-color@7.2.0) + specifier: 43.7.0 + version: 43.7.0(supports-color@7.2.0) electron-builder: specifier: ^26.15.3 version: 26.15.3(electron-builder-squirrel-windows@26.15.3) @@ -359,7 +359,7 @@ importers: version: 26.15.3(dmg-builder@26.15.3) electron-vite: specifier: ^5.0.0 - version: 5.0.0(@swc/core@1.15.46)(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4)) + version: 5.0.0(@swc/core@1.15.46)(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4)) emoji-picker-react: specifier: ^4.19.1 version: 4.19.1(react@19.2.8) @@ -497,10 +497,10 @@ importers: version: 11.0.5 vite: specifier: npm:rolldown-vite@7.3.1 - version: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4) + version: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4) vitest: specifier: ^4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(happy-dom@20.11.8)(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(happy-dom@20.11.8)(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4)) vscode-oniguruma: specifier: ^2.0.1 version: 2.0.1 @@ -4330,8 +4330,8 @@ packages: resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} engines: {node: '>=8.0.0'} - electron@43.6.0: - resolution: {integrity: sha512-DqVKYV+FXheMSLTxcMQ+NCo78BDgpnToSyIzXctlUtbP3lRGEuoo1P+C2n/90rJ7TvHgzP0bpP9fbbXxp4noIg==} + electron@43.7.0: + resolution: {integrity: sha512-m98FUehwnoo6q2D9Mr+n602Natb2CRFeKD81GhTdJlKh/Iyud0R1X8hChjpaMBLSsIX9r2ZEFnz0sdIALiO9ZQ==} engines: {node: '>= 22.12.0'} hasBin: true @@ -7332,17 +7332,17 @@ snapshots: '@electron-internal/extract-zip@1.0.4': {} - '@electron-toolkit/preload@3.0.2(electron@43.6.0(supports-color@7.2.0))': + '@electron-toolkit/preload@3.0.2(electron@43.7.0(supports-color@7.2.0))': dependencies: - electron: 43.6.0(supports-color@7.2.0) + electron: 43.7.0(supports-color@7.2.0) '@electron-toolkit/tsconfig@2.0.0(@types/node@25.9.5)': dependencies: '@types/node': 25.9.5 - '@electron-toolkit/utils@4.0.0(electron@43.6.0(supports-color@7.2.0))': + '@electron-toolkit/utils@4.0.0(electron@43.7.0(supports-color@7.2.0))': dependencies: - electron: 43.6.0(supports-color@7.2.0) + electron: 43.7.0(supports-color@7.2.0) '@electron/asar@3.4.1': dependencies: @@ -7739,7 +7739,7 @@ snapshots: eventsource: 3.0.7 eventsource-parser: 3.0.8 express: 5.2.1(supports-color@7.2.0) - express-rate-limit: 8.5.2(express@5.2.1) + express-rate-limit: 8.5.2(express@5.2.1(supports-color@7.2.0)) hono: 4.13.0 jose: 6.2.3 json-schema-typed: 8.0.2 @@ -7761,7 +7761,7 @@ snapshots: eventsource: 3.0.7 eventsource-parser: 3.0.8 express: 5.2.1(supports-color@7.2.0) - express-rate-limit: 8.5.2(express@5.2.1) + express-rate-limit: 8.5.2(express@5.2.1(supports-color@7.2.0)) hono: 4.13.0 jose: 6.2.3 json-schema-typed: 8.0.2 @@ -9151,12 +9151,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4 '@tailwindcss/oxide-win32-x64-msvc': 4.2.4 - '@tailwindcss/vite@4.2.4(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))': + '@tailwindcss/vite@4.2.4(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4))': dependencies: '@tailwindcss/node': 4.2.4 '@tailwindcss/oxide': 4.2.4 tailwindcss: 4.2.4 - vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4) + vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4) '@tanstack/react-virtual@3.14.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: @@ -9767,7 +9767,7 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) - '@vitejs/plugin-react@5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))': + '@vitejs/plugin-react@5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.7) @@ -9775,7 +9775,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4) + vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4) transitivePeerDependencies: - supports-color @@ -9788,14 +9788,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.11(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))': + '@vitest/mocker@4.1.11(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.14.3(@types/node@25.9.5)(typescript@7.0.2) - vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4) + vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4) '@vitest/pretty-format@4.1.11': dependencies: @@ -10662,7 +10662,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron-vite@5.0.0(@swc/core@1.15.46)(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4)): + electron-vite@5.0.0(@swc/core@1.15.46)(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4)): dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.7) @@ -10670,7 +10670,7 @@ snapshots: esbuild: 0.25.12 magic-string: 0.30.21 picocolors: 1.1.1 - vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4) + vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4) optionalDependencies: '@swc/core': 1.15.46 transitivePeerDependencies: @@ -10688,7 +10688,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron@43.6.0(supports-color@7.2.0): + electron@43.7.0(supports-color@7.2.0): dependencies: '@electron-internal/extract-zip': 1.0.4 '@electron/get': 5.0.0(supports-color@7.2.0) @@ -10862,7 +10862,7 @@ snapshots: exponential-backoff@3.1.3: {} - express-rate-limit@8.5.2(express@5.2.1): + express-rate-limit@8.5.2(express@5.2.1(supports-color@7.2.0)): dependencies: express: 5.2.1(supports-color@7.2.0) ip-address: 10.4.0 @@ -13077,7 +13077,7 @@ snapshots: robust-predicates@3.0.3: {} - rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4): + rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4): dependencies: '@oxc-project/runtime': 0.101.0 fdir: 6.5.0(picomatch@4.0.4) @@ -13088,6 +13088,7 @@ snapshots: tinyglobby: 0.2.16 optionalDependencies: '@types/node': 25.9.5 + esbuild: 0.25.12 fsevents: 2.3.3 jiti: 2.7.0 yaml: 2.8.4 @@ -13717,10 +13718,10 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(happy-dom@20.11.8)(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4)): + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(happy-dom@20.11.8)(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4)) + '@vitest/mocker': 4.1.11(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -13737,7 +13738,7 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4) + vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.25.12)(jiti@2.7.0)(yaml@2.8.4) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 30fdf0f16b9..4111f3708a0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,6 +10,7 @@ minimumReleaseAge: 4320 minimumReleaseAgeExclude: - pdfjs-dist@6.3.289 - zod@4.5.4 + - electron@43.7.0 shamefullyHoist: true # Orca always launches the user's own resolved Claude CLI via From 6252f8149bc5b72985e830ff830a178f61997b8b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:59:56 -0700 Subject: [PATCH 021/191] fix(browser): decode Chromium SameSite storage values (135 cookies silently lost per profile) (#20076) * fix(browser): decode Chromium SameSite storage values * fix(browser): document Firefox's real SameSite domain and pin its default-arm inputs Third-reviewer finding: the replacement comment reproduced the failure mode this PR exists to kill. It said "Firefox's moz_cookies uses the same 0/1/2 values", which is true of the overlap and silently wrong about the rest of the domain. Firefox writes 256 (nsICookie SAMESITE_UNSET) for every cookie with no SameSite attribute -- the most common shape in a modern profile -- NULL on pre-v10 rows, and 0 for explicit None OR a legacy unset row the schema-15 migration left behind, which are not distinguishable in the column. All of those must land on unspecified, so the default arm is load-bearing for Firefox rather than incidental. A later reader making the switch exhaustive against the old comment would have regressed Firefox silently. Also pins the inputs that were riding the default untested: 256, and the non-integer arrivals (null, undefined, NaN) that the `?? -1` scan fallback and pre-v10 Firefox rows produce. Decoder behaviour is unchanged; 11/11 pass. --------- Co-authored-by: Merge Sim --- .../browser/browser-cookie-chromium-scan.ts | 5 +- .../browser/browser-cookie-firefox-import.ts | 4 +- .../browser-cookie-import-test-database.ts | 6 +- .../browser-cookie-samesite.electron.test.ts | 268 ++++++++++++++++++ .../browser/browser-cookie-validation.test.ts | 30 ++ src/main/browser/browser-cookie-validation.ts | 27 +- 6 files changed, 317 insertions(+), 23 deletions(-) create mode 100644 src/main/browser/browser-cookie-samesite.electron.test.ts create mode 100644 src/main/browser/browser-cookie-validation.test.ts diff --git a/src/main/browser/browser-cookie-chromium-scan.ts b/src/main/browser/browser-cookie-chromium-scan.ts index e3bdc6a6409..eb057712a11 100644 --- a/src/main/browser/browser-cookie-chromium-scan.ts +++ b/src/main/browser/browser-cookie-chromium-scan.ts @@ -7,7 +7,7 @@ import { } from './browser-cookie-import-policy' import { prepareStagedCookiesForImport } from './browser-cookie-staged-import' import { chromiumTimestampToUnix, buildChromiumCookieInsertParams } from './browser-cookie-sqlite' -import { chromiumSameSite } from './browser-cookie-validation' +import { databaseSameSite } from './browser-cookie-validation' import { buildUndecryptableWarning, cookieEncryptionVersion, @@ -97,7 +97,8 @@ export function scanChromiumCookieRows( const path = sourceRow.path as string const secure = sourceRow.is_secure === 1n const httpOnly = sourceRow.is_httponly === 1n - const sameSite = chromiumSameSite(Number(sourceRow.samesite ?? 0)) + // Why: pre-samesite schemas and NULL rows follow Chromium's own unspecified fallback. + const sameSite = databaseSameSite(Number(sourceRow.samesite ?? -1)) const expiresUtc = chromiumTimestampToUnix(sourceRow.expires_utc as bigint) const partition = partitionBySourceRow.get(sourceRow)! // Why: cookie values are raw bytes, not UTF-8; latin1 preserves 0x00–0xFF without lossy replacement. diff --git a/src/main/browser/browser-cookie-firefox-import.ts b/src/main/browser/browser-cookie-firefox-import.ts index 60482b30e31..d7ada7e76de 100644 --- a/src/main/browser/browser-cookie-firefox-import.ts +++ b/src/main/browser/browser-cookie-firefox-import.ts @@ -9,7 +9,7 @@ import { cookieImportTarget, type CookieImportOptions } from './browser-cookie-import-pipeline' -import { deriveUrl, firefoxSameSite, type ValidatedCookie } from './browser-cookie-validation' +import { databaseSameSite, deriveUrl, type ValidatedCookie } from './browser-cookie-validation' import type { DetectedBrowser } from './browser-cookie-detection-types' import { diag } from './browser-cookie-import-diagnostics' @@ -108,7 +108,7 @@ export async function importCookiesFromFirefox( path: row.path || '/', secure, httpOnly: row.isHttpOnly === 1, - sameSite: firefoxSameSite(row.sameSite), + sameSite: databaseSameSite(row.sameSite), expirationDate: row.expiry > 0 ? row.expiry : undefined, partition: readFirefoxRowPartition(row, firefoxColumns) }) diff --git a/src/main/browser/browser-cookie-import-test-database.ts b/src/main/browser/browser-cookie-import-test-database.ts index 31cdfc1f157..d3617928fb2 100644 --- a/src/main/browser/browser-cookie-import-test-database.ts +++ b/src/main/browser/browser-cookie-import-test-database.ts @@ -13,7 +13,7 @@ type ChromiumCookieTestRow = { hasCrossSiteAncestor?: 0 | 1 isSecure?: 0 | 1 isHttpOnly?: 0 | 1 - sameSite?: 0 | 1 | 2 | 3 + sameSite?: -1 | 0 | 1 | 2 | 3 | null } export function createChromiumCookieTestDatabase( @@ -38,7 +38,7 @@ export function createChromiumCookieTestDatabase( expires_utc INTEGER NOT NULL, is_secure INTEGER NOT NULL, is_httponly INTEGER NOT NULL, - samesite INTEGER NOT NULL, + samesite INTEGER, source_scheme INTEGER NOT NULL DEFAULT 0, source_port INTEGER NOT NULL DEFAULT -1, last_update_utc INTEGER NOT NULL DEFAULT 0, @@ -75,7 +75,7 @@ export function createChromiumCookieTestDatabase( row.encryptedValue ?? Buffer.alloc(0), row.isSecure ?? 0, row.isHttpOnly ?? 0, - row.sameSite ?? 0, + row.sameSite === undefined ? -1 : row.sameSite, 0, row.hasCrossSiteAncestor ?? 0 ) diff --git a/src/main/browser/browser-cookie-samesite.electron.test.ts b/src/main/browser/browser-cookie-samesite.electron.test.ts new file mode 100644 index 00000000000..4f04f93c72e --- /dev/null +++ b/src/main/browser/browser-cookie-samesite.electron.test.ts @@ -0,0 +1,268 @@ +import { spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { build as buildVite } from 'vite' +import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database' + +type CookieSameSite = 'unspecified' | 'no_restriction' | 'lax' | 'strict' + +type ExpectedCookie = { + name: string + rawSameSite: -1 | 0 | 1 | 2 + secure: boolean + sameSite: CookieSameSite +} + +type JarCookie = Pick + +type ImportResult = { + ok: boolean + reason?: string + summary?: { importedCookies: number; skippedCookies: number } +} + +type FixtureResult = { + step: string + error?: string + beforeCookieCount: number + importResult: ImportResult + afterCookies: JarCookie[] +} + +type SourceShape = { + name: string + samesite: number | null + is_secure: number +} + +const electronBinary = createRequire(import.meta.url)('electron') as string +const fixtureRoots: string[] = [] + +const VALID_COMBINATIONS: readonly ExpectedCookie[] = [ + { + name: 'raw-minus-1-secure-0', + rawSameSite: -1, + secure: false, + sameSite: 'unspecified' + }, + // Ablation C: neither the old decoder nor the null-default regression affects this row. + { + name: 'raw-minus-1-secure-1', + rawSameSite: -1, + secure: true, + sameSite: 'unspecified' + }, + { name: 'raw-0-secure-1', rawSameSite: 0, secure: true, sameSite: 'no_restriction' }, + { name: 'raw-1-secure-0', rawSameSite: 1, secure: false, sameSite: 'lax' }, + { name: 'raw-1-secure-1', rawSameSite: 1, secure: true, sameSite: 'lax' }, + { name: 'raw-2-secure-0', rawSameSite: 2, secure: false, sameSite: 'strict' }, + { name: 'raw-2-secure-1', rawSameSite: 2, secure: true, sameSite: 'strict' } +] + +const REJECTION_CONTROL = { + name: 'raw-0-secure-0', + rawSameSite: 0, + secure: false +} as const + +const NULL_CASE = { + name: 'raw-null-secure-0', + rawSameSite: null, + secure: false, + sameSite: 'unspecified' +} as const + +afterAll(() => { + for (const root of fixtureRoots) { + rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + } +}) + +function buildFixtureMain(bundlePath: string, resultPath: string, sourceDbPath: string): string { + return ` +const { app, BrowserWindow, session } = require('electron') +const { writeFileSync } = require('node:fs') +const { importCookiesFromBrowser } = require(${JSON.stringify(bundlePath)}) +const resultPath = ${JSON.stringify(resultPath)} +let currentStep = 'starting' + +const mark = (step) => { + currentStep = step + writeFileSync(resultPath, JSON.stringify({ step })) +} + +async function run() { + const timeout = setTimeout(() => { + writeFileSync(resultPath, JSON.stringify({ step: 'timed out after ' + currentStep })) + app.exit(1) + }, 30000) + await app.whenReady() + mark('ready') + const partition = 'persist:samesite-enum-cookie-test' + const targetSession = session.fromPartition(partition) + const window = new BrowserWindow({ show: false, webPreferences: { partition } }) + mark('window created') + await window.loadURL('data:text/html,same-site enum fixture') + mark('window loaded') + const beforeCookieCount = (await targetSession.cookies.get({})).length + + const importResult = await importCookiesFromBrowser( + { + family: 'chrome', + label: 'Google Chrome', + cookiesPath: ${JSON.stringify(sourceDbPath)}, + profiles: [], + selectedProfile: '' + }, + partition + ) + mark('import finished') + + const afterCookies = (await targetSession.cookies.get({})) + .filter((cookie) => cookie.name.startsWith('raw-')) + .map((cookie) => ({ + name: cookie.name, + sameSite: cookie.sameSite, + secure: cookie.secure + })) + clearTimeout(timeout) + writeFileSync(resultPath, JSON.stringify({ + step: currentStep, + beforeCookieCount, + importResult, + afterCookies + })) + window.destroy() + app.exit(0) +} + +run().catch((error) => { + writeFileSync(resultPath, JSON.stringify({ step: currentStep, error: String(error?.stack || error) })) + app.exit(1) +}) +` +} + +function readSourceShape(sourceDbPath: string): SourceShape[] { + const db = new DatabaseSync(sourceDbPath, { readOnly: true }) + try { + return db + .prepare('SELECT name, samesite, is_secure FROM cookies ORDER BY rowid') + .all() as SourceShape[] + } finally { + db.close() + } +} + +async function runFixture(): Promise<{ fixture: FixtureResult; sourceShape: SourceShape[] }> { + const root = mkdtempSync(join(tmpdir(), 'orca-samesite-enum-')) + fixtureRoots.push(root) + const bundlePath = join(root, 'cookie-import-samesite.cjs') + const bundleEntryPath = join(root, 'cookie-import-samesite.ts') + const resultPath = join(root, 'result.json') + const fixturePath = join(root, 'main.cjs') + const sourceDbPath = join(root, 'source-cookies.db') + const rows = [REJECTION_CONTROL, ...VALID_COMBINATIONS, NULL_CASE].map( + ({ name, rawSameSite, secure }) => ({ + domain: '.samesite.example', + name, + value: 'synthetic-value', + isSecure: secure ? (1 as const) : (0 as const), + sameSite: rawSameSite + }) + ) + createChromiumCookieTestDatabase(sourceDbPath, rows).close() + const sourceShape = readSourceShape(sourceDbPath) + writeFileSync( + bundleEntryPath, + `export { importCookiesFromBrowser } from ${JSON.stringify(join(process.cwd(), 'src/main/browser/browser-cookie-import.ts'))}` + ) + await buildVite({ + configFile: false, + logLevel: 'silent', + build: { + emptyOutDir: false, + lib: { + entry: bundleEntryPath, + formats: ['cjs'], + fileName: () => 'cookie-import-samesite.cjs' + }, + outDir: root, + target: 'node20', + rollupOptions: { external: ['electron', /^node:/] } + } + }) + writeFileSync(fixturePath, buildFixtureMain(bundlePath, resultPath, sourceDbPath)) + const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env + const electronArgs = [fixturePath, `--user-data-dir=${join(root, 'profile')}`] + const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary + const args = + process.platform === 'linux' + ? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox'] + : electronArgs + const run = spawnSync(executable, args, { + encoding: 'utf8', + env: { ...env, ORCA_BACKGROUND_LAUNCH: '1' }, + timeout: 90_000 + }) + const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result' + expect(run.error).toBeUndefined() + expect(run.status, `${fixtureResult}\n${run.stdout}\n${run.stderr}`).toBe(0) + return { fixture: JSON.parse(fixtureResult) as FixtureResult, sourceShape } +} + +describe('Chromium SameSite storage enum import', () => { + let fixture: FixtureResult + let sourceShape: SourceShape[] + + beforeAll(async () => { + ;({ fixture, sourceShape } = await runFixture()) + }, 120_000) + + it('runs the real Chromium import against the complete synthetic matrix', () => { + expect(fixture.step).toBe('import finished') + expect(fixture.beforeCookieCount).toBe(0) + expect(fixture.importResult.ok).toBe(true) + expect(sourceShape).toEqual( + [REJECTION_CONTROL, ...VALID_COMBINATIONS, NULL_CASE].map( + ({ name, rawSameSite, secure }) => ({ + name, + samesite: rawSameSite, + is_secure: secure ? 1 : 0 + }) + ) + ) + }) + + it.each(VALID_COMBINATIONS)( + 'imports $name with the decoded SameSite and authored Secure flag', + ({ name, sameSite, secure }) => { + expect(fixture.afterCookies.find((cookie) => cookie.name === name)).toEqual({ + name, + sameSite, + secure + }) + } + ) + + it('rejects the synthetic SameSite=None insecure control and continues later writes', () => { + // Chromium refuses this shape, so real profiles cannot contain it. Keeping the synthetic row + // proves the fixture can observe rejection instead of making every presence assertion vacuous. + expect( + fixture.afterCookies.find((cookie) => cookie.name === REJECTION_CONTROL.name) + ).toBeUndefined() + expect(fixture.afterCookies.find((cookie) => cookie.name === 'raw-2-secure-1')).toBeDefined() + }) + + it('imports a null SameSite column as unspecified without changing Secure', () => { + expect(fixture.afterCookies.find((cookie) => cookie.name === NULL_CASE.name)).toEqual({ + name: NULL_CASE.name, + sameSite: NULL_CASE.sameSite, + secure: NULL_CASE.secure + }) + }) +}) diff --git a/src/main/browser/browser-cookie-validation.test.ts b/src/main/browser/browser-cookie-validation.test.ts new file mode 100644 index 00000000000..d7b065beeda --- /dev/null +++ b/src/main/browser/browser-cookie-validation.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { databaseSameSite } from './browser-cookie-validation' + +describe('databaseSameSite', () => { + it.each([ + { raw: -1, expected: 'unspecified' }, + { raw: 0, expected: 'no_restriction' }, + { raw: 1, expected: 'lax' }, + { raw: 2, expected: 'strict' }, + { raw: 3, expected: 'unspecified' }, + // Why: 256 is Firefox's nsICookie SAMESITE_UNSET, written for every cookie with no SameSite + // attribute -- the most common shape in a modern Firefox profile. It reaches the default arm, + // so without this case the decoder's busiest Firefox input would be untested. + { raw: 256, expected: 'unspecified' }, + { raw: 99, expected: 'unspecified' }, + { raw: 1.5, expected: 'unspecified' } + ] as const)('decodes $raw as $expected', ({ raw, expected }) => { + expect(databaseSameSite(raw)).toBe(expected) + }) + + // Why: pre-v10 Firefox rows carry NULL, and the Chromium scan feeds `?? -1`. Both arrive here as + // a non-integer rather than a number, and both must be unspecified rather than None (0). + it.each([ + { label: 'null', raw: null }, + { label: 'undefined', raw: undefined }, + { label: 'NaN', raw: Number.NaN } + ])('decodes $label as unspecified', ({ raw }) => { + expect(databaseSameSite(raw as unknown as number)).toBe('unspecified') + }) +}) diff --git a/src/main/browser/browser-cookie-validation.ts b/src/main/browser/browser-cookie-validation.ts index 6ae543245ef..7f08f713f2e 100644 --- a/src/main/browser/browser-cookie-validation.ts +++ b/src/main/browser/browser-cookie-validation.ts @@ -25,21 +25,16 @@ export type ValidatedCookie = ImportedCookieFields & { partition: SourcePartitionRead } -// Why: Chromium's CookieSameSiteForStorage enum (0=Unspecified,1=None,2=Lax,3=Strict) differs from Firefox's numbering. -export function chromiumSameSite(raw: number): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { - switch (raw) { - case 1: - return 'no_restriction' - case 2: - return 'lax' - case 3: - return 'strict' - default: - return 'unspecified' - } -} - -export function firefoxSameSite(raw: number): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { +// Chromium stores net::CookieSameSite unchanged; see net/cookies/cookie_constants.h and +// net/extras/sqlite/sqlite_persistent_cookie_store.cc (-1 unspecified, 0 None, 1 Lax, 2 Strict; +// 3 is the deprecated EXTENDED value Chromium itself folds to unspecified). +// Firefox's moz_cookies OVERLAPS on 1=Lax and 2=Strict but its domain is wider, so the default arm +// is load-bearing for it, not incidental: 256 (nsICookie SAMESITE_UNSET) is what modern Firefox +// writes for every cookie with no SameSite attribute, NULL appears on pre-v10 rows, and 0 means +// explicit None OR a legacy unset row the schema-15 migration left behind — the two are not +// distinguishable in the column. Every one of those must land on unspecified, so do NOT make this +// switch exhaustive or drop the default without re-checking both browsers' real value domains. +export function databaseSameSite(raw: number): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { switch (raw) { case 0: return 'no_restriction' @@ -56,7 +51,7 @@ export function normalizeSameSite( raw: unknown ): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { if (typeof raw === 'number') { - return chromiumSameSite(raw) + return databaseSameSite(raw) } if (typeof raw !== 'string') { return 'unspecified' From 353a1c8038ae70547a78c07eafa481f7f898be2b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:03:05 -0700 Subject: [PATCH 022/191] fix(terminal): re-run deferred tab admission when a plan installs mid-activation (#20176) planColdActivationTabDeferral can install an empty allowed set, deferring every tab so the pane filter renders none. The drain that undoes that, useActivationDeferredTabAdmission, depends only on backgroundMountRevision and renderedActiveWorktreeId while reading the deferred set from a mutable ref, and the install bumps neither: the only revision producers are the drain itself and the background-mount event path, which the activation plan never reaches. So this pass strands the workspace with zero panes: the worktree is already rendered-active while the startup gate is closed, which resets lastActivationWorktreeIdRef, then the gate opens on the same worktree and installs a plan. Nothing re-runs the drain until the user switches workspaces and back. The hook's own comment anticipates this launch shape and relies on re-reading on growth, but that re-read only happens on a dep change. applyTerminalColdActivation now returns activationDeferralPlanRevision, backed by a ref in the parking foundation and incremented only when the plan actually installs, which the admission effect takes as a dep. A ref rather than state because the pass runs during render, where a setState would be a render-phase update. The 4-tab admission cap is deliberately untouched: it reproduces the warm set an eager activation used to mount, so steady-state pane and WebGL-context population is unchanged. Ablated: with the change reverted the new suite's drain case fails on the deferred set surviving the timers; the precondition and the away-and-back control pass either way. Co-authored-by: Merge Sim --- .../components/terminal-cold-activation.ts | 13 +- .../terminal-workspace-surface-ids.test.tsx | 1 + ...cold-activation-deferral-stranding.test.ts | 230 ++++++++++++++++++ .../use-activation-deferred-tab-admission.ts | 7 +- .../use-terminal-parking-foundation.ts | 6 +- 5 files changed, 253 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/components/terminal/cold-activation-deferral-stranding.test.ts diff --git a/src/renderer/src/components/terminal-cold-activation.ts b/src/renderer/src/components/terminal-cold-activation.ts index d57cb82d766..8b6e96467ee 100644 --- a/src/renderer/src/components/terminal-cold-activation.ts +++ b/src/renderer/src/components/terminal-cold-activation.ts @@ -16,6 +16,7 @@ import type { TerminalParkingFoundation } from './use-terminal-parking-foundatio export function applyTerminalColdActivation(controller: TerminalParkingFoundation) { const { + activationDeferralPlanRevisionRef, activationDeferredMountTabIdsByWorktreeRef, activeGroupIdByWorktree, activeTabId, @@ -96,7 +97,7 @@ export function applyTerminalColdActivation(controller: TerminalParkingFoundatio if (lastActivationWorktreeIdRef.current !== renderedActiveWorktreeId) { lastActivationWorktreeIdRef.current = renderedActiveWorktreeId const tabById = new Map(worktreeTabs.map((tab) => [tab.id, tab])) - planColdActivationTabDeferral({ + const installedDeferralPlan = planColdActivationTabDeferral({ restrictions: backgroundMountTabIdsByWorktreeRef.current, deferredMountTabIdsByWorktree: activationDeferredMountTabIdsByWorktreeRef.current, worktreeId: renderedActiveWorktreeId, @@ -118,6 +119,11 @@ export function applyTerminalColdActivation(controller: TerminalParkingFoundatio }, immediateTabIds }) + // Why: the install mutates only refs, so without a returned revision the + // admission drain's effect deps never change and the plan strands. + if (installedDeferralPlan) { + activationDeferralPlanRevisionRef.current += 1 + } } else if (!coldActivationDeferralEnabled || !activationHostSupportsDeferral) { backgroundMountTabIdsByWorktreeRef.current.delete(renderedActiveWorktreeId) activationDeferredMountTabIdsByWorktreeRef.current.delete(renderedActiveWorktreeId) @@ -165,7 +171,10 @@ export function applyTerminalColdActivation(controller: TerminalParkingFoundatio groupsByWorktree, activeGroupIdByWorktree ) - return { anyMountedWorktreeHasLayout } + return { + anyMountedWorktreeHasLayout, + activationDeferralPlanRevision: activationDeferralPlanRevisionRef.current + } } export type TerminalColdActivationController = TerminalParkingFoundation & diff --git a/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx b/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx index 59984d7fe06..f93b06a3b05 100644 --- a/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx +++ b/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx @@ -78,6 +78,7 @@ describe('workspace surface ids', () => { const ids = Array.from({ length: 423 }, (_, index) => `repo::/worktree-${index}`) const { surfaces, mapCalls } = countingSurfaces(ids) const controller = { + activationDeferralPlanRevisionRef: { current: 0 }, activationDeferredMountTabIdsByWorktreeRef: { current: new Map() }, activeGroupIdByWorktree: {}, activeTabId: null, diff --git a/src/renderer/src/components/terminal/cold-activation-deferral-stranding.test.ts b/src/renderer/src/components/terminal/cold-activation-deferral-stranding.test.ts new file mode 100644 index 00000000000..89737f42991 --- /dev/null +++ b/src/renderer/src/components/terminal/cold-activation-deferral-stranding.test.ts @@ -0,0 +1,230 @@ +// @vitest-environment happy-dom + +/** + * Defect under test: a startup-gate-open pass can install an activation + * deferral plan (restrictions.set(worktree, EMPTY set) — every tab deferred, + * zero panes rendered) by mutating only refs. The admission drain's effect + * used to depend only on [backgroundMountRevision, renderedActiveWorktreeId], + * and the only producers of backgroundMountRevision are the drain itself and + * the background-mount EVENT path — never the activation plan. Neither dep + * changes on the gate-open pass, so the plan stranded every tab unmounted + * until the user switched workspaces and back. The fix returns + * activationDeferralPlanRevision from applyTerminalColdActivation as a third + * dep, bumped only when a plan actually installs. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, renderHook } from '@testing-library/react' +import { useRef, useState } from 'react' +import { useAppStore } from '@/store' +import { applyTerminalColdActivation } from '../terminal-cold-activation' +import { useActivationDeferredTabAdmission } from './use-activation-deferred-tab-admission' +import { shouldMountBackgroundWorktreeTab } from './background-terminal-worktree-mount' +import { + clearTerminalProviderSnapshotCapabilities, + synchronizeTerminalProviderSnapshotCapabilities, + terminalProviderHasAuthoritativeSnapshot +} from './terminal-provider-snapshot-capability' +import { + canWatcherCoverParkedTerminalTab, + captureParkedTerminalPaneCandidates +} from '../terminal-pane/terminal-parked-tab-watchers' +import { capturedPanesByTabId } from '../terminal-pane/terminal-parked-watcher-registry' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { TerminalParkingFoundation } from '../use-terminal-parking-foundation' + +const WORKTREE_ID = 'repo::/worktree' +const OTHER_WORKTREE_ID = 'repo::/other-worktree' +const TAB_1 = 'tab-1' +const TAB_2 = 'tab-2' +const PTY_1 = `${WORKTREE_ID}@@session-1` +const PTY_2 = `${WORKTREE_ID}@@session-2` +const LEAF_1 = '11111111-1111-4111-8111-111111111111' +const LEAF_2 = '22222222-2222-4222-8222-222222222222' +const SURFACE_IDS = [WORKTREE_ID, OTHER_WORKTREE_ID] + +const initialState = useAppStore.getInitialState() +const originalRequestIdle = globalThis.requestIdleCallback +const originalCancelIdle = globalThis.cancelIdleCallback + +function terminalTab(id: string, ptyId: string): TerminalTab { + return { + id, + ptyId, + worktreeId: WORKTREE_ID, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +/** Seeds the real store and primes real authoritative-snapshot capabilities. */ +async function seedDeferrableWorktree(): Promise { + useAppStore.setState({ + tabsByWorktree: { [WORKTREE_ID]: [terminalTab(TAB_1, PTY_1), terminalTab(TAB_2, PTY_2)] } + }) + captureParkedTerminalPaneCandidates(TAB_1, WORKTREE_ID, [ + { ptyId: PTY_1, paneId: 1, leafId: LEAF_1, drivesTabTitle: true } + ]) + captureParkedTerminalPaneCandidates(TAB_2, WORKTREE_ID, [ + { ptyId: PTY_2, paneId: 2, leafId: LEAF_2, drivesTabTitle: true } + ]) + await synchronizeTerminalProviderSnapshotCapabilities([PTY_1, PTY_2], async (ids) => + ids.map((id) => ({ id, authoritative: true })) + ) +} + +type HarnessProps = { worktreeId: string | null; gateOpen: boolean } + +/** Mirrors use-terminal-controller.ts:30-32: cold activation during render, then admission. */ +function useStrandingHarness(props: HarnessProps) { + const backgroundMountTabIdsByWorktreeRef = useRef(new Map>()) + const activationDeferredMountTabIdsByWorktreeRef = useRef(new Map>()) + const lastActivationWorktreeIdRef = useRef(null) + const mountedWorktreeIdsRef = useRef(new Set()) + const activationDeferralPlanRevisionRef = useRef(0) + const [backgroundMountRevision, setBackgroundMountRevision] = useState(0) + const foundation = { + activationDeferralPlanRevisionRef, + activationDeferredMountTabIdsByWorktreeRef, + activeGroupIdByWorktree: {}, + activeTabId: null, + activeTabIdByWorktree: {}, + activeWorktreeDeferralHostId: 'local', + activityTerminalPortals: [], + backgroundMountRevision, + backgroundMountTabIdsByWorktreeRef, + groupsByWorktree: {}, + hydrationSucceeded: props.gateOpen, + lastActivationWorktreeIdRef, + layoutByWorktree: {}, + mountedWorktreeIdsRef, + pairedRuntimeParkingEnvironmentIds: new Set(), + pendingStartupByTabId: {}, + renderedActiveWorktreeId: props.worktreeId, + setBackgroundMountRevision, + startupWorktreeRefreshCompleted: props.gateOpen, + tabsByWorktree: useAppStore.getState().tabsByWorktree, + terminalParkingEnabled: true, + terminalTitleSnapshotAuthorityEnabled: true, + workspaceSessionReady: props.gateOpen, + workspaceSurfaceIds: SURFACE_IDS, + workspaceSurfaceIdSet: new Set(SURFACE_IDS) + } as unknown as TerminalParkingFoundation + const coldActivation = Object.assign(foundation, applyTerminalColdActivation(foundation)) + useActivationDeferredTabAdmission(coldActivation) + return { activationDeferredMountTabIdsByWorktreeRef, backgroundMountTabIdsByWorktreeRef } +} + +/** Fires the timer-fallback admission chain: one tab admitted per pass. */ +function drainIdleAdmissions(passes: number): void { + for (let index = 0; index < passes; index += 1) { + act(() => { + vi.advanceTimersByTime(1) + }) + } +} + +describe('cold-activation deferral stranding', () => { + beforeEach(() => { + useAppStore.setState(initialState, true) + // Deterministic drain: force scheduleActivationDeferredAdmission onto timers. + // @ts-expect-error -- exercising the no-requestIdleCallback environment + globalThis.requestIdleCallback = undefined + // @ts-expect-error -- exercising the no-requestIdleCallback environment + globalThis.cancelIdleCallback = undefined + }) + + afterEach(() => { + cleanup() + vi.useRealTimers() + globalThis.requestIdleCallback = originalRequestIdle + globalThis.cancelIdleCallback = originalCancelIdle + clearTerminalProviderSnapshotCapabilities() + capturedPanesByTabId.clear() + useAppStore.setState(initialState, true) + }) + + it('precondition: both seeded tabs are deferrable under the real coverage predicate', async () => { + await seedDeferrableWorktree() + const [first, second] = useAppStore.getState().tabsByWorktree[WORKTREE_ID]! + expect( + canWatcherCoverParkedTerminalTab( + WORKTREE_ID, + first!, + terminalProviderHasAuthoritativeSnapshot + ) + ).toBe(true) + expect( + canWatcherCoverParkedTerminalTab( + WORKTREE_ID, + second!, + terminalProviderHasAuthoritativeSnapshot + ) + ).toBe(true) + }) + + it('drains a plan installed by the gate-open pass without the active worktree changing', async () => { + await seedDeferrableWorktree() + vi.useFakeTimers() + // Pass 1: worktree already rendered-active while the startup gate is + // closed — the else branch resets lastActivationWorktreeIdRef to null. + const { result, rerender } = renderHook((props: HarnessProps) => useStrandingHarness(props), { + initialProps: { worktreeId: WORKTREE_ID, gateOpen: false } + }) + expect(result.current.activationDeferredMountTabIdsByWorktreeRef.current.size).toBe(0) + + // Pass 2: gate opens with the SAME rendered-active worktree; the plan + // installs an empty allowed set — every tab deferred, zero panes. + rerender({ worktreeId: WORKTREE_ID, gateOpen: true }) + const restrictions = result.current.backgroundMountTabIdsByWorktreeRef.current + const deferred = result.current.activationDeferredMountTabIdsByWorktreeRef.current + expect(deferred.get(WORKTREE_ID)?.size).toBe(2) + expect(shouldMountBackgroundWorktreeTab(restrictions.get(WORKTREE_ID) ?? null, TAB_1)).toBe( + false + ) + expect(shouldMountBackgroundWorktreeTab(restrictions.get(WORKTREE_ID) ?? null, TAB_2)).toBe( + false + ) + + // The fix: the install bumps activationDeferralPlanRevision, so the + // admission effect re-runs and drains — no worktree switch required. + drainIdleAdmissions(3) + expect(deferred.has(WORKTREE_ID)).toBe(false) + expect(shouldMountBackgroundWorktreeTab(restrictions.get(WORKTREE_ID) ?? null, TAB_1)).toBe( + true + ) + expect(shouldMountBackgroundWorktreeTab(restrictions.get(WORKTREE_ID) ?? null, TAB_2)).toBe( + true + ) + }) + + it('control: the same stranded state drains when the active worktree bounces away and back', async () => { + await seedDeferrableWorktree() + vi.useFakeTimers() + const { result, rerender } = renderHook((props: HarnessProps) => useStrandingHarness(props), { + initialProps: { worktreeId: WORKTREE_ID, gateOpen: false } + }) + rerender({ worktreeId: WORKTREE_ID, gateOpen: true }) + expect( + result.current.activationDeferredMountTabIdsByWorktreeRef.current.get(WORKTREE_ID)?.size + ).toBe(2) + + // Bounce: renderedActiveWorktreeId changes, so the admission effect's + // pre-fix deps already covered this path — the drain must always work here. + rerender({ worktreeId: OTHER_WORKTREE_ID, gateOpen: true }) + rerender({ worktreeId: WORKTREE_ID, gateOpen: true }) + drainIdleAdmissions(3) + const restrictions = result.current.backgroundMountTabIdsByWorktreeRef.current + expect(result.current.activationDeferredMountTabIdsByWorktreeRef.current.has(WORKTREE_ID)).toBe( + false + ) + expect(shouldMountBackgroundWorktreeTab(restrictions.get(WORKTREE_ID) ?? null, TAB_1)).toBe( + true + ) + expect(shouldMountBackgroundWorktreeTab(restrictions.get(WORKTREE_ID) ?? null, TAB_2)).toBe( + true + ) + }) +}) diff --git a/src/renderer/src/components/terminal/use-activation-deferred-tab-admission.ts b/src/renderer/src/components/terminal/use-activation-deferred-tab-admission.ts index 8a46a4e7594..71390ebbe2e 100644 --- a/src/renderer/src/components/terminal/use-activation-deferred-tab-admission.ts +++ b/src/renderer/src/components/terminal/use-activation-deferred-tab-admission.ts @@ -23,6 +23,7 @@ export function useActivationDeferredTabAdmission( controller: TerminalColdActivationController ): void { const { + activationDeferralPlanRevision, activationDeferredMountTabIdsByWorktreeRef, backgroundMountRevision, backgroundMountTabIdsByWorktreeRef, @@ -78,6 +79,10 @@ export function useActivationDeferredTabAdmission( }) setBackgroundMountRevision((revision) => revision + 1) }) + // Why activationDeferralPlanRevision is a dep: a startup-gate-open pass can + // install a plan for the already-active worktree by mutating only refs — + // neither other dep changes, and without this revision the tabs stay + // unmounted until the user switches workspaces and back. // oxlint-disable-next-line react-hooks/exhaustive-deps -- controller refs and setters preserve their original stable identities. - }, [backgroundMountRevision, renderedActiveWorktreeId]) + }, [activationDeferralPlanRevision, backgroundMountRevision, renderedActiveWorktreeId]) } diff --git a/src/renderer/src/components/use-terminal-parking-foundation.ts b/src/renderer/src/components/use-terminal-parking-foundation.ts index 634813d70be..777b51ee26d 100644 --- a/src/renderer/src/components/use-terminal-parking-foundation.ts +++ b/src/renderer/src/components/use-terminal-parking-foundation.ts @@ -68,6 +68,9 @@ export function useTerminalParkingFoundation(controller: TerminalEditorCloseCont const backgroundMountTabIdsByWorktreeRef = useRef(new Map>()) const activationDeferredMountTabIdsByWorktreeRef = useRef(new Map>()) const lastActivationWorktreeIdRef = useRef(null) + // Why a ref, not state: the cold-activation pass runs during render, where a + // setState would be a render-phase update; the pass returns the count instead. + const activationDeferralPlanRevisionRef = useRef(0) useEffect(() => { const timers = measurableBackgroundWorktreeTimersRef.current @@ -163,7 +166,8 @@ export function useTerminalParkingFoundation(controller: TerminalEditorCloseCont forceParkedCaptureDoneRef, backgroundMountTabIdsByWorktreeRef, activationDeferredMountTabIdsByWorktreeRef, - lastActivationWorktreeIdRef + lastActivationWorktreeIdRef, + activationDeferralPlanRevisionRef } } From 70a588c8bf06a135980ae7f7005d4cf7aa7419c6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:07:15 -0700 Subject: [PATCH 023/191] fix(workspaces): complete a worktree create when a post-create step throws (#20175) * fix(workspaces): complete a worktree create when a post-create step throws executeWorktreeCreation's try/catch ends once createWorktree resolves, and all three callers fire it with a bare void and no .catch. completeWorktreeCreation is the only thing that removes the pending creation, so a throw in that tail left pendingWorktreeCreations and activePendingCreationId set: the creation surface stayed up, workspaceChromeActive went false, and the finished workspace rendered no tab chrome while its panes mounted invisibly behind the panel. It only cleared when the user switched workspaces, because setActiveWorktree nulls the pointer. Silently -- no toast, no error state. activateAndRevealWorktree, ensureWorktreeHasInitialTerminal and ensureWebRuntimeWorktreeTerminalAfterWake are all synchronous with no internal guard; launchStructuredWorktreeSession guards only its awaited launch, and that catch's comment already names this stranding hazard. The worktree exists past that point, so each follow-up step is now guarded individually and falls back to the values the skip paths already used; control flow always reaches completion. The structured-launch cancelled/visibility returns keep their semantics, and a throw there is treated as a failed launch, matching what that module already returns for 'failed'. A .catch backstop on the three call sites turns anything that still escapes -- including prepareRequestForCreate, whose VM await has try/finally with no catch -- into a visible error state plus toast. Ablated: with the guards removed the new suite fails 4 of 5, the survivor being the no-throw control. * fix(workspaces): recover terminal after partial activation * fix(workspaces): preserve stamped launch tab on recovery * test(workspaces): cover recovered agent tab delivery * test(workspaces): name recovered agent delivery coverage --------- Co-authored-by: Merge Sim --- .../src/lib/worktree-creation-flow-execute.ts | 165 +++++--- ...ree-creation-flow-stranded-surface.test.ts | 377 ++++++++++++++++++ .../src/lib/worktree-creation-flow.ts | 30 +- 3 files changed, 524 insertions(+), 48 deletions(-) create mode 100644 src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts diff --git a/src/renderer/src/lib/worktree-creation-flow-execute.ts b/src/renderer/src/lib/worktree-creation-flow-execute.ts index 839f454eef5..d4409d10549 100644 --- a/src/renderer/src/lib/worktree-creation-flow-execute.ts +++ b/src/renderer/src/lib/worktree-creation-flow-execute.ts @@ -19,7 +19,10 @@ import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' import { createBrowserUuid } from '@/lib/browser-uuid' import { resolveBackendDraftStartup } from '@/lib/worktree-draft-startup-view-mode' import { buildWorktreeCreationStartupOpt } from '@/lib/worktree-creation-flow-startup' -import { launchStructuredWorktreeSession } from '@/lib/worktree-creation-structured-session' +import { + launchStructuredWorktreeSession, + type WorktreeCreationStructuredSessionResult +} from '@/lib/worktree-creation-structured-session' import { completeWorktreeCreation } from '@/lib/worktree-creation-completion' import { markStructuredWorktreeLaunchUnconfirmed } from '@/lib/worktree-creation-structured-recovery' import { ensureWebRuntimeWorktreeTerminalAfterWake } from '@/lib/web-runtime-worktree-terminal-after-wake' @@ -164,28 +167,41 @@ export async function executeWorktreeCreation( (completionState.activeView === 'terminal' && completionState.activePendingCreationId === null)) + // Why: the worktree exists past this point and nothing awaits this caller, so + // each follow-up step is best-effort — an escaped throw would strand the + // creation surface over the finished workspace instead of reaching completion. let activation: ActivateAndRevealResult | false = false - let primaryTabId: string | null + let primaryTabId: string | null = null if (shouldActivateOnCompletion && !structuredLaunch) { - activation = activateAndRevealWorktree(worktree.id, { - sidebarRevealBehavior: 'auto', - ...(preparedRequest.agent !== null ? { agent: preparedRequest.agent } : {}), - ...(result.setup ? { setup: result.setup } : {}), - ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}), - ...(startupOpt ? { startup: startupOpt } : {}), - ...(preparedRequest.issueCommand ? { issueCommand: preparedRequest.issueCommand } : {}), - ...(backendSpawned ? { backendStartupTerminalSpawned: true } : {}) - }) - primaryTabId = activation === false ? null : activation.primaryTabId - } else { - // Keep chat creation on its pending surface until the session is ready. - const hasExplicitTerminalWork = Boolean( - startupOpt || result.setup || preparedRequest.issueCommand || result.defaultTabs - ) - primaryTabId = - preparedRequest.agent !== null && !hasExplicitTerminalWork - ? null - : ensureWorktreeHasInitialTerminal( + try { + activation = activateAndRevealWorktree(worktree.id, { + sidebarRevealBehavior: 'auto', + ...(preparedRequest.agent !== null ? { agent: preparedRequest.agent } : {}), + ...(result.setup ? { setup: result.setup } : {}), + ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}), + ...(startupOpt ? { startup: startupOpt } : {}), + ...(preparedRequest.issueCommand ? { issueCommand: preparedRequest.issueCommand } : {}), + ...(backendSpawned ? { backendStartupTerminalSpawned: true } : {}) + }) + primaryTabId = activation === false ? null : activation.primaryTabId + } catch (error) { + console.error('worktree create: activate-and-reveal failed', worktree.id, error) + // Activation can publish the worktree before a later step throws. Do not + // infer a primary tab from default-tab ordering; only a fresh seed may + // return one here. + const stateAfterActivationFailure = useAppStore.getState() + const existingTabs = stateAfterActivationFailure.tabsByWorktree[worktree.id] ?? [] + const launchAgent = startupOpt?.launchAgent ?? preparedRequest.agent + const verifiedLaunchTabId = + result.startupTerminal?.tabId ?? + (launchAgent ? existingTabs.find((tab) => tab.launchAgent === launchAgent)?.id : undefined) + if (verifiedLaunchTabId) { + // Startup terminal ids and stamped agent tabs are the only safe primary + // ids when activation returned no result. + primaryTabId = verifiedLaunchTabId + } else if (existingTabs.length === 0) { + try { + primaryTabId = ensureWorktreeHasInitialTerminal( useAppStore.getState(), worktree.id, startupOpt, @@ -193,17 +209,67 @@ export async function executeWorktreeCreation( preparedRequest.issueCommand, result.defaultTabs, { - activateCreatedTabs: false, ...(preparedRequest.agent !== null ? { callerProvidesSurface: true } : {}), ...(backendSpawned ? { backendStartupTerminalSpawned: true } : {}) } ) + } catch (recoveryError) { + console.error( + 'worktree create: activation recovery seeding failed', + worktree.id, + recoveryError + ) + } + } + if (!backendSpawned) { + try { + ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id, { + startup: startupOpt, + agent: preparedRequest.agent + }) + } catch (recoveryError) { + console.error( + 'worktree create: activation recovery after-wake seeding failed', + worktree.id, + recoveryError + ) + } + } + } + } else { + // Keep chat creation on its pending surface until the session is ready. + const hasExplicitTerminalWork = Boolean( + startupOpt || result.setup || preparedRequest.issueCommand || result.defaultTabs + ) + if (preparedRequest.agent === null || hasExplicitTerminalWork) { + try { + primaryTabId = ensureWorktreeHasInitialTerminal( + useAppStore.getState(), + worktree.id, + startupOpt, + result.setup, + preparedRequest.issueCommand, + result.defaultTabs, + { + activateCreatedTabs: false, + ...(preparedRequest.agent !== null ? { callerProvidesSurface: true } : {}), + ...(backendSpawned ? { backendStartupTerminalSpawned: true } : {}) + } + ) + } catch (error) { + console.error('worktree create: initial terminal seeding failed', worktree.id, error) + } + } if (!structuredLaunch && !backendSpawned) { - ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id, { - startup: startupOpt, - agent: preparedRequest.agent, - activate: false - }) + try { + ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id, { + startup: startupOpt, + agent: preparedRequest.agent, + activate: false + }) + } catch (error) { + console.error('worktree create: after-wake terminal seeding failed', worktree.id, error) + } } } @@ -213,25 +279,34 @@ export async function executeWorktreeCreation( agentLaunchRoute === 'structured-native-chat' && isAgentSessionHandleProvider(preparedRequest.agent) ) { - const structuredSession = await launchStructuredWorktreeSession({ - creationId, - request: preparedRequest, - agentLaunchRoute, - worktreeId: worktree.id, - shouldActivateOnCompletion, - fallbackStartupOpt, - activation, - primaryTabId - }) - structuredLaunchAccepted = structuredSession.accepted - activation = structuredSession.activation - primaryTabId = structuredSession.primaryTabId - if (structuredSession.cancelled) { - return + let structuredSession: WorktreeCreationStructuredSessionResult | null = null + try { + structuredSession = await launchStructuredWorktreeSession({ + creationId, + request: preparedRequest, + agentLaunchRoute, + worktreeId: worktree.id, + shouldActivateOnCompletion, + fallbackStartupOpt, + activation, + primaryTabId + }) + } catch (error) { + // Why: plan.launch is guarded inside, but its sync prologue is not; treat + // an escaped throw like a failed launch (accepted) and still complete. + console.error('worktree create: structured session launch failed', worktree.id, error) } - if (structuredSession.visibilityUnknown) { - markStructuredWorktreeLaunchUnconfirmed(creationId, worktree.id) - return + if (structuredSession) { + structuredLaunchAccepted = structuredSession.accepted + activation = structuredSession.activation + primaryTabId = structuredSession.primaryTabId + if (structuredSession.cancelled) { + return + } + if (structuredSession.visibilityUnknown) { + markStructuredWorktreeLaunchUnconfirmed(creationId, worktree.id) + return + } } } diff --git a/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts b/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts new file mode 100644 index 00000000000..143645e9008 --- /dev/null +++ b/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts @@ -0,0 +1,377 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { + PendingWorktreeCreation, + WorktreeCreationRequest +} from '@/lib/pending-worktree-creation' +import { shouldShowWorktreeCreationSurface } from '@/lib/worktree-creation-surface' + +// Guards executeWorktreeCreation's post-create tail: callers fire and forget, +// so a throw after createWorktree succeeds must be contained per-step and the +// creation must still reach completeWorktreeCreation, which tears the creation +// surface down. Also covers the caller-side .catch() backstop: a rejection that +// still escapes (e.g. pre-create preparation) becomes a visible error state +// plus toast instead of a panel silently stuck at "creating". + +type TestActiveView = 'terminal' | 'tasks' + +const store = { + settings: { + activeRuntimeEnvironmentId: null as string | null, + experimentalNativeChat: undefined as boolean | undefined, + openAgentTabsInChatByDefault: undefined as boolean | undefined + }, + activeView: 'terminal' as TestActiveView, + activePendingCreationId: 'creation-1' as string | null, + repos: [] as { id: string; connectionId: string | null }[], + pendingWorktreeCreations: {} as Record, + beginPendingWorktreeCreation: vi.fn((entry: PendingWorktreeCreation) => { + store.pendingWorktreeCreations[entry.creationId] = entry + store.activePendingCreationId = entry.creationId + }), + updatePendingWorktreeCreation: vi.fn( + (creationId: string, patch: Partial) => { + const entry = store.pendingWorktreeCreations[creationId] + if (entry) { + store.pendingWorktreeCreations[creationId] = { ...entry, ...patch } + } + } + ), + // Mirrors pending-worktree-creation.ts: drop the entry and the active pointer. + removePendingWorktreeCreation: vi.fn((creationId: string) => { + delete store.pendingWorktreeCreations[creationId] + if (store.activePendingCreationId === creationId) { + store.activePendingCreationId = null + } + }), + setActivePendingWorktreeCreation: vi.fn((creationId: string | null) => { + store.activePendingCreationId = creationId + }), + setActiveView: vi.fn((view: TestActiveView) => { + store.activeView = view + }), + setSidebarOpen: vi.fn(), + updateWorktreeMeta: vi.fn(), + createWorktree: vi.fn(), + tabsByWorktree: {} as Record, + unifiedTabsByWorktree: {} +} + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => store + } +})) + +vi.mock('sonner', () => ({ + toast: { error: vi.fn() } +})) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: vi.fn() +})) + +vi.mock('@/lib/worktree-initial-terminal-seeding', () => ({ + ensureWorktreeHasInitialTerminal: vi.fn() +})) + +vi.mock('@/lib/web-runtime-worktree-terminal-after-wake', () => ({ + ensureWebRuntimeWorktreeTerminalAfterWake: vi.fn() +})) + +vi.mock('@/lib/workspace-activation-terminal-focus', () => ({ + queueWorkspaceActivationTerminalFocus: vi.fn() +})) + +vi.mock('@/lib/new-workspace', () => ({ + ensureAgentStartupInTerminal: vi.fn() +})) + +vi.mock('@/lib/worktree-creation-agent-seeds', () => ({ + seedAgentTabStateAfterWorktreeCreate: vi.fn() +})) + +vi.mock('@/lib/ephemeral-vm-workspace-target', () => ({ + prepareEphemeralVmWorkspaceTarget: vi.fn() +})) + +vi.mock('@/lib/ephemeral-vm-worktree-creation', () => ({ + prepareRequestForCreate: vi.fn( + async (_creationId: string, request: WorktreeCreationRequest) => request + ), + attachEphemeralVmRuntimeToWorkspace: vi.fn(async () => undefined), + cleanupEphemeralVmRuntimeForFailedCreate: vi.fn(async () => undefined) +})) + +vi.mock('@/lib/worktree-creation-structured-recovery', () => ({ + markStructuredWorktreeLaunchUnconfirmed: vi.fn(), + retryStructuredWorktreeLaunch: vi.fn() +})) + +import { toast } from 'sonner' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding' +import { ensureWebRuntimeWorktreeTerminalAfterWake } from '@/lib/web-runtime-worktree-terminal-after-wake' +import { ensureAgentStartupInTerminal } from '@/lib/new-workspace' +import { prepareRequestForCreate } from '@/lib/ephemeral-vm-worktree-creation' +import { executeWorktreeCreation } from './worktree-creation-flow-execute' +import { runBackgroundWorktreeCreation } from './worktree-creation-flow' + +function makeRequest(overrides: Partial = {}): WorktreeCreationRequest { + return { + repoId: 'repo-1', + name: 'feature', + setupDecision: 'inherit', + agent: null, + pendingFirstAgentMessageRename: false, + note: '', + startupPlan: null, + quickPrompt: '', + quickTelemetry: null, + ...overrides + } as WorktreeCreationRequest +} + +function seedPendingCreation(request: WorktreeCreationRequest): void { + store.pendingWorktreeCreations = { + 'creation-1': { + creationId: 'creation-1', + phase: 'fetching', + status: 'creating', + startedAt: 1, + indeterminate: false, + loaderVisible: true, + request + } + } + store.activePendingCreationId = 'creation-1' +} + +function surfaceInput(activeView: TestActiveView): { + activeView: TestActiveView + activePendingCreationId: string | null + hasActivePendingCreation: boolean +} { + return { + activeView, + activePendingCreationId: store.activePendingCreationId, + hasActivePendingCreation: + store.activePendingCreationId !== null && + store.pendingWorktreeCreations[store.activePendingCreationId] !== undefined + } +} + +beforeEach(() => { + // resetAllMocks: implementations from prior tests (the injected throws) must not leak. + vi.resetAllMocks() + vi.spyOn(console, 'error').mockImplementation(() => undefined) + store.activeView = 'terminal' + store.repos = [{ id: 'repo-1', connectionId: null }] + store.tabsByWorktree = {} + store.pendingWorktreeCreations = {} + store.activePendingCreationId = null + store.createWorktree.mockResolvedValue({ + worktree: { id: 'wt-1', repoId: 'repo-1' } + }) +}) + +describe('a throw after createWorktree succeeds no longer strands the creation surface', () => { + it('activating branch: a throw in activateAndRevealWorktree recovers a terminal and completes', async () => { + const request = makeRequest() + seedPendingCreation(request) + vi.mocked(ensureWorktreeHasInitialTerminal).mockReturnValue('recovered-tab') + vi.mocked(activateAndRevealWorktree).mockImplementation(() => { + throw new Error('activation exploded') + }) + + await executeWorktreeCreation('creation-1', request) + + expect(console.error).toHaveBeenCalledWith( + 'worktree create: activate-and-reveal failed', + 'wt-1', + expect.any(Error) + ) + expect(ensureWorktreeHasInitialTerminal).toHaveBeenCalledWith( + store, + 'wt-1', + undefined, + undefined, + undefined, + undefined, + {} + ) + // Contained: completion still tears the surface down. + expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', { + cleanupVm: false + }) + expect(store.pendingWorktreeCreations['creation-1']).toBeUndefined() + expect(store.activePendingCreationId).toBeNull() + expect(shouldShowWorktreeCreationSurface(surfaceInput('terminal'))).toBe(false) + }) + + it('activating branch: leaves existing default tabs untouched after a partial failure', async () => { + const request = makeRequest({ issueCommand: { command: 'echo setup' } }) + seedPendingCreation(request) + store.tabsByWorktree = { 'wt-1': [{ id: 'existing-tab' }] } + vi.mocked(activateAndRevealWorktree).mockImplementation(() => { + throw new Error('reveal exploded after tab creation') + }) + + await executeWorktreeCreation('creation-1', request) + + expect(ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled() + expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', { + cleanupVm: false + }) + }) + + it('activating branch: routes draft and follow-up delivery to the stamped agent tab', async () => { + const request = makeRequest({ + agent: 'codex', + startupPlan: { + agent: 'codex', + launchCommand: 'codex', + expectedProcess: 'codex', + draftPrompt: 'draft context', + followupPrompt: 'follow-up context', + launchConfig: { agentArgs: '', agentEnv: {} } + } + }) + seedPendingCreation(request) + store.tabsByWorktree = { + 'wt-1': [{ id: 'default-tab' }, { id: 'agent-tab', launchAgent: 'codex' }] + } + vi.mocked(activateAndRevealWorktree).mockImplementation(() => { + throw new Error('reveal exploded after default tabs were created') + }) + + await executeWorktreeCreation('creation-1', request) + + expect(ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled() + expect(ensureAgentStartupInTerminal).toHaveBeenCalledWith( + expect.objectContaining({ primaryTabId: 'agent-tab' }) + ) + }) + + it('background branch: a throw in after-wake seeding is contained after tabs are seeded', async () => { + // User left the terminal view mid-create, so the non-activating branch runs. + store.activeView = 'tasks' + const request = makeRequest() + seedPendingCreation(request) + vi.mocked(ensureWorktreeHasInitialTerminal).mockReturnValue('tab-1') + vi.mocked(ensureWebRuntimeWorktreeTerminalAfterWake).mockImplementation(() => { + throw new Error('after-wake exploded') + }) + + await executeWorktreeCreation('creation-1', request) + + // Tabs were seeded for the new worktree... + expect(ensureWorktreeHasInitialTerminal).toHaveBeenCalledWith( + store, + 'wt-1', + undefined, + undefined, + undefined, + undefined, + expect.objectContaining({ activateCreatedTabs: false }) + ) + expect(console.error).toHaveBeenCalledWith( + 'worktree create: after-wake terminal seeding failed', + 'wt-1', + expect.any(Error) + ) + // ...and the creation still completed instead of stranding the entry. + expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', { + cleanupVm: false + }) + expect(store.pendingWorktreeCreations['creation-1']).toBeUndefined() + expect(store.activePendingCreationId).toBeNull() + expect(shouldShowWorktreeCreationSurface(surfaceInput('terminal'))).toBe(false) + }) + + it('concurrent create: a throw completing a backgrounded creation still tears its entry down', async () => { + // A second submitted create repointed activePendingCreationId, so this + // creation's completion takes the non-activating branch on the terminal view. + store.activeView = 'terminal' + const request = makeRequest() + seedPendingCreation(request) + store.activePendingCreationId = 'creation-2' + store.createWorktree.mockResolvedValue({ + worktree: { id: 'wt-1', repoId: 'repo-1' }, + setup: { runnerScriptPath: '/tmp/setup.sh' } + }) + vi.mocked(ensureWorktreeHasInitialTerminal).mockReturnValue('tab-1') + vi.mocked(ensureWebRuntimeWorktreeTerminalAfterWake).mockImplementation(() => { + throw new Error('after-wake exploded') + }) + + await executeWorktreeCreation('creation-1', request) + + // Blank terminal + Setup tab are seeded by this one synchronous call. + expect(activateAndRevealWorktree).not.toHaveBeenCalled() + expect(ensureWorktreeHasInitialTerminal).toHaveBeenCalledWith( + store, + 'wt-1', + undefined, + { runnerScriptPath: '/tmp/setup.sh' }, + undefined, + undefined, + expect.objectContaining({ activateCreatedTabs: false }) + ) + // The entry is gone; the pointer stays on the other in-flight creation. + expect(store.pendingWorktreeCreations['creation-1']).toBeUndefined() + expect(store.activePendingCreationId).toBe('creation-2') + expect(shouldShowWorktreeCreationSurface(surfaceInput('terminal'))).toBe(false) + }) + + it('control: with no throw the same flow completes and tears the surface down', async () => { + store.activeView = 'tasks' + const request = makeRequest() + seedPendingCreation(request) + vi.mocked(ensureWorktreeHasInitialTerminal).mockReturnValue('tab-1') + + await executeWorktreeCreation('creation-1', request) + + expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', { + cleanupVm: false + }) + expect(store.pendingWorktreeCreations['creation-1']).toBeUndefined() + expect(store.activePendingCreationId).toBeNull() + expect(shouldShowWorktreeCreationSurface(surfaceInput('terminal'))).toBe(false) + }) + + it('backstop: a rejection that escapes the execute promise becomes a visible inline error', async () => { + // Pre-create preparation runs before the in-function try/catch. + vi.mocked(prepareRequestForCreate).mockRejectedValue(new Error('prepare exploded')) + + const creationId = runBackgroundWorktreeCreation(makeRequest()) + + await vi.waitFor(() => { + expect(store.pendingWorktreeCreations[creationId]).toMatchObject({ + status: 'error', + error: 'prepare exploded' + }) + }) + expect(toast.error).not.toHaveBeenCalled() + expect(store.removePendingWorktreeCreation).not.toHaveBeenCalled() + expect(console.error).toHaveBeenCalledWith( + 'worktree create: unhandled failure', + creationId, + expect.any(Error) + ) + }) + + it('backstop: a rejection after leaving the panel is announced with a toast', async () => { + store.activeView = 'tasks' + vi.mocked(prepareRequestForCreate).mockRejectedValue(new Error('prepare exploded')) + + const creationId = runBackgroundWorktreeCreation(makeRequest()) + // The pending surface is revealed synchronously; move away before the + // rejected preparation reaches the fire-and-forget backstop. + store.activeView = 'tasks' + + await vi.waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('prepare exploded') + }) + expect(store.pendingWorktreeCreations[creationId]).toMatchObject({ status: 'error' }) + }) +}) diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index dfde4e7fe58..7a46e708417 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -1,3 +1,4 @@ +import { toast } from 'sonner' import { useAppStore } from '@/store' import { findPendingLinkedWorkItemCreationId, @@ -11,11 +12,34 @@ import { getWorktreeCreationIndeterminate } from '@/lib/worktree-creation-flow-startup' import { retryStructuredWorktreeLaunch } from '@/lib/worktree-creation-structured-recovery' +import { + formatWorkspaceCreateError, + getWorkspaceCreateErrorToastMessage +} from '@/lib/workspace-create-error-format' type ContinueBackgroundWorktreeCreationOptions = { revealCreationSurface?: boolean } +// Why: nothing awaits these creations, so an escaped rejection would otherwise +// strand the pending entry — and the creation surface — with no error shown. +function startWorktreeCreation(creationId: string, request: WorktreeCreationRequest): void { + executeWorktreeCreation(creationId, request).catch((error: unknown) => { + console.error('worktree create: unhandled failure', creationId, error) + const store = useAppStore.getState() + if (!store.pendingWorktreeCreations[creationId]) { + return + } + const message = getWorkspaceCreateErrorToastMessage(formatWorkspaceCreateError(error)) + store.updatePendingWorktreeCreation(creationId, { status: 'error', error: message }) + // Why: the panel renders this error inline while its surface is visible; + // only announce it separately after the user has navigated away. + if (!(store.activeView === 'terminal' && store.activePendingCreationId === creationId)) { + toast.error(message) + } + }) +} + function revealPendingCreation( creationId: string, request: WorktreeCreationRequest, @@ -62,7 +86,7 @@ export function runBackgroundWorktreeCreation(request: WorktreeCreationRequest): // client over plain HTTP). createBrowserUuid falls back to getRandomValues. const creationId = createBrowserUuid() revealPendingCreation(creationId, request, getInitialWorktreeCreationPhase(request)) - void executeWorktreeCreation(creationId, request) + startWorktreeCreation(creationId, request) return creationId } @@ -101,7 +125,7 @@ export function continueBackgroundWorktreeCreation( store.setActiveView('terminal') store.setSidebarOpen(true) } - void executeWorktreeCreation(creationId, request) + startWorktreeCreation(creationId, request) return true } @@ -133,5 +157,5 @@ export function retryBackgroundWorktreeCreation(creationId: string): void { ) return } - void executeWorktreeCreation(creationId, entry.request) + startWorktreeCreation(creationId, entry.request) } From 113e58f34e53d7496b0473346dbc209ff0a805be Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:27:13 -0400 Subject: [PATCH 024/191] feat(relay): support protocol 3 in cell rollout gates (#20174) * feat(relay): support protocol 3 in cell rollout gates * fix(relay): validate and prove protocol-3 cell rollouts * docs(relay): clarify regional capability deployment prerequisite * test(relay): cover protocol-3 plans across rollout cells --- ...d-deploy-relay-production-same-cap-job.yml | 6 +++--- ...cloud-deploy-relay-production-same-cap.yml | 4 ++-- .../relay-same-cap-script-census.test.mjs | 20 ++++++++++++------- .../scripts/validate-relay-capacity-plan.mjs | 6 +++--- .../validate-relay-capacity-plan.test.mjs | 4 ++++ .../verify-relay-capacity-transition.mjs | 4 ++-- cloud/docs/orca-relay-operations.md | 15 +++++++++----- 7 files changed, 37 insertions(+), 22 deletions(-) diff --git a/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml b/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml index 8ef61507088..2b4bb3fa439 100644 --- a/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml +++ b/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml @@ -67,8 +67,8 @@ jobs: [[ "${TARGET_IMAGE_DIGEST}" =~ ^sha256:[a-f0-9]{64}$ ]] [[ "${ROLLBACK_IMAGE_DIGEST}" =~ ^sha256:[a-f0-9]{64}$ ]] test "${TARGET_IMAGE_DIGEST}" != "${ROLLBACK_IMAGE_DIGEST}" - [[ "${TARGET_REHOME_PROTOCOL}" =~ ^[01]$ ]] - [[ "${ROLLBACK_REHOME_PROTOCOL}" =~ ^[01]$ ]] + [[ "${TARGET_REHOME_PROTOCOL}" =~ ^(0|1|3)$ ]] + [[ "${ROLLBACK_REHOME_PROTOCOL}" =~ ^(0|1|3)$ ]] [[ "${EXPECTED_SELECTOR_GENERATION}" =~ ^(0|[1-9][0-9]*)$ ]] [[ "${EXPECTED_REHOME_GENERATION}" =~ ^(0|[1-9][0-9]*)$ ]] [[ "${WAVE_INDEX}" =~ ^[0-3]$ ]] @@ -599,7 +599,7 @@ jobs: | jq -e '.control.enabled == false' >/dev/null - name: Prove exact per-host trust and idempotent no-neighbor behavior - if: ${{ inputs.mode != 'verify' && ((inputs.mode == 'rollback' && inputs.rollback-rehome-protocol == '1') || (inputs.mode != 'rollback' && inputs.target-rehome-protocol == '1')) }} + if: ${{ inputs.mode != 'verify' && ((inputs.mode == 'rollback' && inputs.rollback-rehome-protocol != '0') || (inputs.mode != 'rollback' && inputs.target-rehome-protocol != '0')) }} env: ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.post-auth.outputs.id_token }} run: | diff --git a/.github/workflows/cloud-deploy-relay-production-same-cap.yml b/.github/workflows/cloud-deploy-relay-production-same-cap.yml index fba5df0dcb9..50d609b30d3 100644 --- a/.github/workflows/cloud-deploy-relay-production-same-cap.yml +++ b/.github/workflows/cloud-deploy-relay-production-same-cap.yml @@ -26,13 +26,13 @@ on: required: true default: '1' type: choice - options: ['0', '1'] + options: ['0', '1', '3'] rollback-rehome-protocol: description: Exact rollback regional-rehome protocol required: true default: '0' type: choice - options: ['0', '1'] + options: ['0', '1', '3'] expected-selector-generation: description: Exact selector generation before the first cell required: true diff --git a/cloud/dev/scripts/relay-same-cap-script-census.test.mjs b/cloud/dev/scripts/relay-same-cap-script-census.test.mjs index 7d5e4fee73e..3e5f0758028 100644 --- a/cloud/dev/scripts/relay-same-cap-script-census.test.mjs +++ b/cloud/dev/scripts/relay-same-cap-script-census.test.mjs @@ -77,14 +77,14 @@ function rollPlan({ cellId, cap, protocol }) { metadata_startup_script: startupScript({ cap, image: ROLLBACK_IMAGE, - trusted: protocol === 1 + trusted: protocol >= 1 }) }, after: { metadata_startup_script: startupScript({ cap, image: TARGET_IMAGE, - trusted: protocol === 1 + trusted: protocol >= 1 }), self_link: null }, @@ -178,11 +178,9 @@ describe('same-cap roll scripts accept every same-cap cell', () => { }) it('validates a correct plan for every wave cell at that cell\'s rehome protocol', () => { - for (const cellId of SAME_CAP_CELLS) { + for (const [cellId, protocol] of SAME_CAP_CELLS.flatMap((cell) => [[cell, 1], [cell, 3]])) { const [, cap] = resolveCellShape(cellId).stdout.trim().split(' ') - const protocol = REHOME_SOURCE_CELLS.has(cellId) ? 1 : 0 - // Every reviewed serving cell carries rehome trust now, in either region. - assert.equal(protocol, 1, cellId) + assert.equal(REHOME_SOURCE_CELLS.has(cellId), true, cellId) const config = { mode: 'same-cap-cell', cellId, @@ -204,7 +202,7 @@ describe('same-cap roll scripts accept every same-cap cell', () => { assert.throws( () => validateCapacityPlan(plan, { ...config, - regionalRehomeProtocol: String(1 - protocol) + regionalRehomeProtocol: '0' }), /reviewed image and capacity/, cellId @@ -239,3 +237,11 @@ describe('same-cap roll scripts accept every same-cap cell', () => { assert.doesNotMatch(capacityWorkflow, /--approved-cells/) }) }) + +// Both trusted versions must prove the same authenticated drain boundary. +it('proves rehome trust for protocol 3 on forward and rollback rolls', () => { + const step = workflow.split('name: Prove exact per-host trust and idempotent no-neighbor behavior')[1].split('\n - name:')[0] + assert.match(step, /inputs\.rollback-rehome-protocol != '0'/) + assert.match(step, /inputs\.target-rehome-protocol != '0'/) + assert.match(step, /probe-relay-rehome-trust\.mjs/) +}) diff --git a/cloud/dev/scripts/validate-relay-capacity-plan.mjs b/cloud/dev/scripts/validate-relay-capacity-plan.mjs index 294e85ae31d..34e84d51ead 100644 --- a/cloud/dev/scripts/validate-relay-capacity-plan.mjs +++ b/cloud/dev/scripts/validate-relay-capacity-plan.mjs @@ -9,7 +9,7 @@ const REHOME_CONFIG = // Only cells listed as regional rehome sources get rehome trust lines in their startup script. function rehomeProtocol({ regionalRehomeProtocol }) { - if (![0, 1, '0', '1'].includes(regionalRehomeProtocol)) { + if (![0, 1, 3, '0', '1', '3'].includes(regionalRehomeProtocol)) { throw new Error('same-cap Terraform plan has an invalid regional rehome protocol') } return Number(regionalRehomeProtocol) @@ -43,7 +43,7 @@ export function parseCapacityPlanArguments(argv) { (!values['rollback-image'] || !values['rehome-director-service-account'] || !values['rehome-audience'] || - !['0', '1'].includes(values['regional-rehome-protocol'])) + !['0', '1', '3'].includes(values['regional-rehome-protocol'])) ) throw new Error('same-cap validation requires rollback image and rehome trust config') if (values.mode !== 'same-cap-cell' && values['regional-rehome-protocol'] !== undefined) { throw new Error('--regional-rehome-protocol applies only to same-cap-cell validation') @@ -227,7 +227,7 @@ function requireDesiredStartupScript(script, config) { ` printf 'ORCA_RELAY_CAPACITY_SERVICE_ACCOUNT=%s\\n' '${config.capacityServiceAccount}'` ]) } - const rehomeTrusted = config.mode === 'same-cap-cell' && rehomeProtocol(config) === 1 + const rehomeTrusted = config.mode === 'same-cap-cell' && rehomeProtocol(config) >= 1 if (rehomeTrusted) { expected.push( [ diff --git a/cloud/dev/scripts/validate-relay-capacity-plan.test.mjs b/cloud/dev/scripts/validate-relay-capacity-plan.test.mjs index fb6ccb57e1c..d6886c58011 100644 --- a/cloud/dev/scripts/validate-relay-capacity-plan.test.mjs +++ b/cloud/dev/scripts/validate-relay-capacity-plan.test.mjs @@ -756,6 +756,10 @@ test('the rehome protocol argument is required by same-cap-cell mode alone', () .regionalRehomeProtocol, '0' ) + assert.equal( + parseCapacityPlanArguments(sameCapArguments('--regional-rehome-protocol', '3')).regionalRehomeProtocol, + '3' + ) assert.throws( () => parseCapacityPlanArguments(sameCapArguments()), /requires rollback image and rehome trust config/ diff --git a/cloud/dev/scripts/verify-relay-capacity-transition.mjs b/cloud/dev/scripts/verify-relay-capacity-transition.mjs index b81ea15afb3..0c297fdad53 100644 --- a/cloud/dev/scripts/verify-relay-capacity-transition.mjs +++ b/cloud/dev/scripts/verify-relay-capacity-transition.mjs @@ -108,8 +108,8 @@ export function parseCapacityTransitionArguments(argv) { const regionalRehomeProtocol = values['regional-rehome-protocol'] === undefined ? undefined : integer(values['regional-rehome-protocol'], '--regional-rehome-protocol') - if (regionalRehomeProtocol !== undefined && ![0, 1].includes(regionalRehomeProtocol)) { - throw new Error('--regional-rehome-protocol must be 0 or 1') + if (regionalRehomeProtocol !== undefined && ![0, 1, 3].includes(regionalRehomeProtocol)) { + throw new Error('--regional-rehome-protocol must be 0, 1, or 3') } if (runtime === 'unavailable' && regionalRehomeProtocol !== undefined) { throw new Error('unavailable runtime cannot prove the regional rehome protocol') diff --git a/cloud/docs/orca-relay-operations.md b/cloud/docs/orca-relay-operations.md index f27b437fff9..4515048b29b 100644 --- a/cloud/docs/orca-relay-operations.md +++ b/cloud/docs/orca-relay-operations.md @@ -466,11 +466,16 @@ After a deployment traffic shift, preserve the old revision/tag until metrics an ## Regional rehoming -Rehoming moves a host to a general cell in the region its desktop last reported, in either -direction. Both roles need the drain protocol: a cell without it can be neither a source nor a -target, and it is not part of the fleet whose telemetry gates the worker. Until the asia-east2 -cells run `regionalRehomeProtocol` 1 they are none of the three, so no host is moved into or out -of Asia and an Asia cell in distress does not pause the worker. +Idle regional correction requires both source and target cells to advertise +`regionalRehomeProtocol >= 3`. PR #20105 introduced this capability version with +the idle handoff implementation. With that runtime, both rehome trust environment +settings must be configured to advertise 3; otherwise the cell advertises 0. +An older trusted runtime can advertise 1: configuring trust alone does not upgrade +its implementation. The separate `connectionCapacityProtocol: 2` health field does +not establish regional-correction readiness. Verify the live runtime version and +image, not only instance-template configuration, before rollout or enablement. +Incompatible cells are excluded from correction selection; enabling the cohort +cannot override this check. Director and cell deployments are separate operations. `host-cooldown-ms` is the minimum gap between two rehomes of one host. It bounds the damage from a desktop whose region probe flips: without it the host would be dragged back across the ocean on From 556a7772ed7bdf67d5f811449d07f06dcd4285f4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:37:16 -0700 Subject: [PATCH 025/191] fix(e2e): remove four real flake sources and one caret race (#20169) Four E2E specs failed once each across six main runs. Each traces to a timing boundary the test could not control, not to product instability: - linear-url-workspace-entry: pasted before X selection ownership landed, delivering stale text. Gate on a clipboard read-back. - native-chat-first-flush-race: a bare 1_500ms sleep is exactly UNFLUSHED_SETTLE_MS, so it straddled the boundary deciding which of two hydration paths carried the test. Observe the not-yet-flushed read instead; a notFound is never cached, so this cannot perturb hydration. - orchestration-idle-mail-delivery: asserted that a PTY -> daemon -> main round trip beats a 500ms production heuristic. Use the existing ORCA_E2E_ORCHESTRATION_POINTER_ENTER_DELAY_MS knob. - tasks-page: the probe timeout was the one figure in the file not derived from GITHUB_TASK_SEARCH_IDLE_MS. worktree.spec.ts exposed a real product race rather than a test bug: the emoji caret-restore frame stayed armed through ordinary typing, so a late frame could yank the caret back mid-input. Cancel it on the non-emoji onChange path. Also repairs a stale assertion: #20025 changed remountTerminalTabForRecovery to return a result object and updated the sibling call site but missed this one, so the comparison to `true` could never pass. It is a deterministic break, not a flake. Co-authored-by: Merge Sim --- .../smart-workspace-name-input-surface.tsx | 2 ++ tests/e2e/linear-url-workspace-entry.spec.ts | 4 ++++ .../e2e/native-chat-first-flush-race.spec.ts | 22 +++++++++++++++---- .../orchestration-idle-mail-delivery.spec.ts | 7 +++++- .../e2e/slept-workspace-remount-wake.spec.ts | 2 +- tests/e2e/tasks-page.spec.ts | 15 ++++++++++--- 6 files changed, 43 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/components/new-workspace/smart-workspace-name-input-surface.tsx b/src/renderer/src/components/new-workspace/smart-workspace-name-input-surface.tsx index 277f24da30b..d43695e1c01 100644 --- a/src/renderer/src/components/new-workspace/smart-workspace-name-input-surface.tsx +++ b/src/renderer/src/components/new-workspace/smart-workspace-name-input-surface.tsx @@ -192,6 +192,8 @@ export function renderSmartWorkspaceNameInput( applyEmojiReplacement(completedEmoji) return } + // A pending emoji caret frame would otherwise yank the caret back mid-typing. + cancelLocalInputFocusFrame() onValueChange(nextValue) setEmojiCursor(nextCursor) if (!disabled && mode !== 'text') { diff --git a/tests/e2e/linear-url-workspace-entry.spec.ts b/tests/e2e/linear-url-workspace-entry.spec.ts index 76e17e11ffe..d1cb2677257 100644 --- a/tests/e2e/linear-url-workspace-entry.spec.ts +++ b/tests/e2e/linear-url-workspace-entry.spec.ts @@ -97,6 +97,10 @@ async function releaseHeldLinearLookup(page: Page): Promise { async function pasteLinearUrl(page: Page, input: ReturnType): Promise { await page.evaluate((text) => window.api.ui.writeClipboardText(text), LINEAR_URL) + // X selection ownership is async; pasting before it lands delivers stale text. + await expect + .poll(() => page.evaluate(() => window.api.ui.readClipboardText()), { timeout: 5_000 }) + .toBe(LINEAR_URL) await input.focus() await page.keyboard.press(pasteChord()) } diff --git a/tests/e2e/native-chat-first-flush-race.spec.ts b/tests/e2e/native-chat-first-flush-race.spec.ts index 1362a882902..2e85e2dc132 100644 --- a/tests/e2e/native-chat-first-flush-race.spec.ts +++ b/tests/e2e/native-chat-first-flush-race.spec.ts @@ -141,10 +141,24 @@ test.describe('Native chat first-flush transcript race (#8401)', () => { path: path.join(screenshotDir, '01-loading-no-error.png') }) - // Why: a short real delay proves the first readSession attempt already - // hit the not-yet-flushed file (returning notFound) and the renderer's - // backoff retry — not a lucky first read — is what picks it up below. - await orcaPage.waitForTimeout(1_500) + // Why observe, not sleep: 1_500ms is exactly UNFLUSHED_SETTLE_MS, so a fixed + // wait straddles the boundary where the host reports the transcript pending + // and the renderer cancels its own retry. Read through the same IPC instead, + // proving the miss directly. A notFound is never cached, so this cannot + // perturb the hydration the assertions below measure. + await expect + .poll( + () => + orcaPage.evaluate( + ({ id, file }) => + window.api.nativeChat + .readSession('claude', id, 50, file) + .then((result) => Boolean(result && 'error' in result && result.notFound)), + { id: sessionId, file: transcriptPath } + ), + { timeout: 10_000, message: 'transcript resolved before the first flush' } + ) + .toBe(true) await expect(orcaPage.getByText(ERROR_TITLE)).toHaveCount(0) const userText = 'Explain the native chat first-flush race fix for #8401' diff --git a/tests/e2e/orchestration-idle-mail-delivery.spec.ts b/tests/e2e/orchestration-idle-mail-delivery.spec.ts index cffa6415208..9d90aed1875 100644 --- a/tests/e2e/orchestration-idle-mail-delivery.spec.ts +++ b/tests/e2e/orchestration-idle-mail-delivery.spec.ts @@ -653,7 +653,12 @@ test.describe('orchestration delivery to a cold-parked agent', () => { const parkingDelayMs = 500 test.use({ - orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(parkingDelayMs) } + orcaAppExtraEnv: { + ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(parkingDelayMs), + // The working-title round trip (PTY -> daemon -> main) must beat the Enter + // timer; 500ms is a production heuristic, not a budget CI can honour. + ORCA_E2E_ORCHESTRATION_POINTER_ENTER_DELAY_MS: '5000' + } }) test('keeps one pointer and one idempotent prompt on the same parked PTY', async ({ diff --git a/tests/e2e/slept-workspace-remount-wake.spec.ts b/tests/e2e/slept-workspace-remount-wake.spec.ts index f820a7c7f69..54109a6b532 100644 --- a/tests/e2e/slept-workspace-remount-wake.spec.ts +++ b/tests/e2e/slept-workspace-remount-wake.spec.ts @@ -66,7 +66,7 @@ test('remounting a slept hidden pane does not respawn its PTY', async ({ orcaPag expect(sample.tabPtyHints[0], 'sleep must keep the session id as a wake hint').toBeTruthy() const remounted = await orcaPage.evaluate( - (tabId) => window.__store?.getState().remountTerminalTabForRecovery(tabId) ?? false, + (tabId) => window.__store?.getState().remountTerminalTabForRecovery(tabId).remounted ?? false, sleptTabId ) expect(remounted, 'remountTerminalTabForRecovery did not find the slept tab').toBe(true) diff --git a/tests/e2e/tasks-page.spec.ts b/tests/e2e/tasks-page.spec.ts index b8f57a1996f..962f86de0c5 100644 --- a/tests/e2e/tasks-page.spec.ts +++ b/tests/e2e/tasks-page.spec.ts @@ -13,6 +13,9 @@ import { GITHUB_TASK_SEARCH_IDLE_MS } from '../../src/renderer/src/components/us // on a loaded runner, so one slow keystroke committed a prefix and failed the assertion. const TASK_SEARCH_TYPING_DELAY_MS = Math.round(GITHUB_TASK_SEARCH_IDLE_MS / 6) const TASK_SEARCH_SETTLE_MS = GITHUB_TASK_SEARCH_IDLE_MS + 50 +// Why derived: the probe must outlast the idle window plus a React commit and two +// store round trips; a flat 2s left ~1.2s of slack on a single-worker runner. +const TASK_SEARCH_PROBE_TIMEOUT_MS = GITHUB_TASK_SEARCH_IDLE_MS * 6 type RenderedTaskSource = { source: string @@ -411,7 +414,9 @@ test.describe('Tasks page', () => { await input.fill('') await expect - .poll(async () => readTaskSearchRequestProbe(orcaPage), { timeout: 2_000 }) + .poll(async () => readTaskSearchRequestProbe(orcaPage), { + timeout: TASK_SEARCH_PROBE_TIMEOUT_MS + }) .toEqual({ countQueries: ['is:issue is:open'], fetchQueries: ['is:issue is:open'] }) await resetTaskSearchRequestProbe(orcaPage) @@ -422,7 +427,9 @@ test.describe('Tasks page', () => { // The contract is that no prefix of the typed query is ever queried, not that the // probe is empty at one instant: exactly one request per surface, for the final value. await expect - .poll(async () => readTaskSearchRequestProbe(orcaPage), { timeout: 2_000 }) + .poll(async () => readTaskSearchRequestProbe(orcaPage), { + timeout: TASK_SEARCH_PROBE_TIMEOUT_MS + }) .toEqual({ countQueries: ['is:issue rate'], fetchQueries: ['is:issue rate'] }) await resetTaskSearchRequestProbe(orcaPage) @@ -430,7 +437,9 @@ test.describe('Tasks page', () => { await input.press('Enter') await expect - .poll(async () => readTaskSearchRequestProbe(orcaPage), { timeout: 2_000 }) + .poll(async () => readTaskSearchRequestProbe(orcaPage), { + timeout: TASK_SEARCH_PROBE_TIMEOUT_MS + }) .toEqual({ countQueries: ['is:issue ratex'], fetchQueries: ['is:issue ratex'] }) await orcaPage.waitForTimeout(TASK_SEARCH_SETTLE_MS) expect(await readTaskSearchRequestProbe(orcaPage)).toEqual({ From 3b13ce09a51e8f292f34b96b796353618f681183 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:20:47 -0700 Subject: [PATCH 026/191] fix(source-control): pass the Antigravity prompt to agy --print (#20147) --- src/shared/commit-message-agent-spec.test.ts | 30 ++++- .../commit-message-agent-specs-secondary.ts | 7 +- src/shared/commit-message-plan.test.ts | 109 ++++++++++++++++++ 3 files changed, 140 insertions(+), 6 deletions(-) diff --git a/src/shared/commit-message-agent-spec.test.ts b/src/shared/commit-message-agent-spec.test.ts index c9bfb8eb789..de5cc14529b 100644 --- a/src/shared/commit-message-agent-spec.test.ts +++ b/src/shared/commit-message-agent-spec.test.ts @@ -573,10 +573,32 @@ describe('buildArgs (OpenCode)', () => { describe('buildArgs (Antigravity)', () => { const spec = getCommitMessageAgentSpec('antigravity')! - it('runs agy with --print, --sandbox, and --model flags', () => { - const args = spec.buildArgs({ prompt: '', model: 'Gemini 3.5 Flash (Medium)' }) - expect(args).toEqual(['--print', '--sandbox', '--model', 'Gemini 3.5 Flash (Medium)']) - expect(spec.promptDelivery).toBe('stdin') + it('runs agy with the prompt attached to --print, then --sandbox and --model flags', () => { + const args = spec.buildArgs({ + prompt: 'real commit prompt', + model: 'Gemini 3.5 Flash (Medium)' + }) + expect(args).toEqual([ + '--print=real commit prompt', + '--sandbox', + '--model', + 'Gemini 3.5 Flash (Medium)' + ]) + expect(spec.promptDelivery).toBe('argv') + }) + + it('binds a leading-dash prompt to --print instead of letting it parse as an option', () => { + const args = spec.buildArgs({ prompt: '-fix: something', model: 'Gemini 3.5 Flash (Medium)' }) + expect(args[0]).toBe('--print=-fix: something') + }) + + // Why: pins argv construction only. Real agy 1.2.1 separately rejects a --print value + // that exactly matches a registered flag name (its own heuristic, independent of this + // fix) — verified `agy --print=--sandbox` still errors there. Real prompts are never + // literally a bare flag name, so this doesn't affect actual generation. + it('still glues a prompt that collides with a flag name onto --print', () => { + const args = spec.buildArgs({ prompt: '--sandbox', model: 'Gemini 3.5 Flash (Medium)' }) + expect(args[0]).toBe('--print=--sandbox') }) it('uses dynamic model discovery via agy models', () => { diff --git a/src/shared/commit-message-agent-specs-secondary.ts b/src/shared/commit-message-agent-specs-secondary.ts index e22fce018f8..2ad7691ac36 100644 --- a/src/shared/commit-message-agent-specs-secondary.ts +++ b/src/shared/commit-message-agent-specs-secondary.ts @@ -212,8 +212,11 @@ export function buildSecondaryCommitMessageAgentSpecs({ id: 'antigravity', label: 'Antigravity', binary: 'agy', - promptDelivery: 'stdin', - buildArgs: ({ model }) => ['--print', '--sandbox', '--model', model], + // agy's --print takes the prompt as its value (#19539, #14059). Deliver on argv + // using `--print=` so a leading-dash prompt binds to the flag instead of + // being parsed as its own option, and --sandbox/--model stay separate options. + promptDelivery: 'argv', + buildArgs: ({ prompt, model }) => [`--print=${prompt}`, '--sandbox', '--model', model], modelSource: 'dynamic', modelDiscovery: { binary: 'agy', args: ['models'], parse: parseAntigravityModels }, models: [ diff --git a/src/shared/commit-message-plan.test.ts b/src/shared/commit-message-plan.test.ts index 3c2fa62aea5..2d58a5cf6e7 100644 --- a/src/shared/commit-message-plan.test.ts +++ b/src/shared/commit-message-plan.test.ts @@ -178,6 +178,115 @@ describe('planCommitMessageGeneration', () => { }) }) + it('plans Antigravity generation with the prompt attached to --print, not stdin (#19539, #14059)', () => { + const result = planCommitMessageGeneration( + { + agentId: 'antigravity', + model: 'Gemini 3.5 Flash (Medium)' + }, + 'real commit prompt' + ) + + expect(result).toEqual({ + ok: true, + plan: { + binary: 'agy', + args: ['--print=real commit prompt', '--sandbox', '--model', 'Gemini 3.5 Flash (Medium)'], + stdinPayload: null, + label: 'Antigravity' + } + }) + }) + + it('keeps a leading-dash Antigravity prompt bound to --print instead of parsing as an option', () => { + const result = planCommitMessageGeneration( + { agentId: 'antigravity', model: 'Gemini 3.5 Flash (Medium)' }, + '-fix: something' + ) + + expect(result.ok).toBe(true) + expect(result.ok && result.plan.args.slice(0, 2)).toEqual([ + '--print=-fix: something', + '--sandbox' + ]) + }) + + // Why: pins argv construction only. Real agy 1.2.1 separately rejects a --print value + // that exactly matches a registered flag name (its own heuristic, independent of this + // fix) — verified `agy --print=--sandbox` still errors there. Real prompts are never + // literally a bare flag name, so this doesn't affect actual generation. + it('still glues an Antigravity prompt that collides with a flag name onto --print', () => { + const result = planCommitMessageGeneration( + { agentId: 'antigravity', model: 'Gemini 3.5 Flash (Medium)' }, + '--sandbox' + ) + + expect(result.ok).toBe(true) + expect(result.ok && result.plan.args.slice(0, 2)).toEqual(['--print=--sandbox', '--sandbox']) + }) + + // Why: agy has no documented stdin mode for --print (#19539's body: "--print ... is + // not a boolean flag that automatically reads from stdin; it expects the prompt + // string as its option argument"), so a large staged patch now rides on argv. This + // is the same unguarded argv delivery cursor/kimi/copilot already use (see the + // parity assertion below) — pinned here as a known property, not a regression. + it('puts a large Antigravity prompt on argv with no size guard, same as other argv-delivery agents', () => { + const bigPrompt = 'y'.repeat(70_000) + const result = planCommitMessageGeneration( + { agentId: 'antigravity', model: 'Gemini 3.5 Flash (Medium)' }, + bigPrompt + ) + + expect(result.ok).toBe(true) + expect(result.ok && result.plan.args[0]).toBe(`--print=${bigPrompt}`) + expect(result.ok && result.plan.stdinPayload).toBeNull() + + const cursorResult = planCommitMessageGeneration( + { agentId: 'cursor', model: 'auto' }, + bigPrompt + ) + expect(cursorResult.ok).toBe(true) + expect(cursorResult.ok && cursorResult.plan.args.at(-1)).toBe(bigPrompt) + expect(cursorResult.ok && cursorResult.plan.stdinPayload).toBeNull() + }) + + // Why: real #14059 reproduction config — CLI arguments field repeats --model and adds + // --add-dir/--effort/--dangerously-skip-permissions. Confirms none of it gets swallowed + // into the --print operand and the duplicate --model is deduped the same way every + // other spec's recipe args already are (DEFAULT_SINGLETON_OPTIONS, unaffected by + // argument order). + it('keeps #14059-style recipe CLI arguments intact and deduped around the print operand', () => { + const result = planCommitMessageGeneration( + { + agentId: 'antigravity', + model: 'Gemini 3.5 Flash (Medium)', + agentArgs: + '--add-dir . --model gemini-3.6-flash --effort low --dangerously-skip-permissions' + }, + 'Generate a concise git commit message for the currently staged changes.' + ) + + expect(result).toEqual({ + ok: true, + plan: { + binary: 'agy', + args: [ + '--print=Generate a concise git commit message for the currently staged changes.', + '--sandbox', + '--model', + 'gemini-3.6-flash', + '--add-dir', + '.', + '--effort', + 'low', + '--dangerously-skip-permissions' + ], + stdinPayload: null, + label: 'Antigravity' + } + }) + }) + it('plans Codex exec as non-interactive read-only generation with the prompt on stdin only', () => { const result = planCommitMessageGeneration( { From 76c8e91d4a74137bba2e05974de2876fdad60ba4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:25:49 -0700 Subject: [PATCH 027/191] fix(e2e): run worktree first-paint probe on a mapped window (#20197) Co-authored-by: Merge Sim --- .github/workflows/e2e.yml | 5 +++++ config/scripts/pr-e2e-gate-contract.test.mjs | 7 +++++++ tests/e2e/worktree-switch-first-paint.spec.ts | 7 ++++++- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 942f0f34a56..a07bf0991ec 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -173,6 +173,11 @@ jobs: - name: Run E2E tests (${{ matrix.shard_name }}) run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --shard=${{ matrix.shard }} + # The frame benchmark needs a mapped window, which the headless shards exclude. + - name: Run worktree first-paint benchmark + if: matrix.shard == '1/14' + run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/worktree-switch-first-paint.spec.ts --config tests/playwright.config.ts --project=electron-headful --workers=1 + # Why: Playwright retains traces/screenshots only on failure. Uploading # them as an artifact makes post-mortem debugging on CI possible without # re-running locally. diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index 4f012b105b4..22677bb152f 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -179,6 +179,13 @@ describe('PR E2E gate contract', () => { 'pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}"' ) expect(playwrightConfig).toContain('retries: 0') + const steps = e2eWorkflow.jobs.e2e.steps.filter((step) => + step.run?.includes('tests/e2e/worktree-switch-first-paint.spec.ts') + ) + expect(steps).toHaveLength(1) + expect(steps[0].if).toBe("matrix.shard == '1/14'") + expect(steps[0].run).toContain('xvfb-run --auto-servernum') + expect(steps[0].run).toContain('--project=electron-headful --workers=1') }) it('keeps startup-exec live parity in the isolated SSH lane', () => { diff --git a/tests/e2e/worktree-switch-first-paint.spec.ts b/tests/e2e/worktree-switch-first-paint.spec.ts index f062b6e246a..307e179faf6 100644 --- a/tests/e2e/worktree-switch-first-paint.spec.ts +++ b/tests/e2e/worktree-switch-first-paint.spec.ts @@ -372,7 +372,12 @@ function median(values: readonly number[]): number { return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle] } -test.describe('Worktree switch first paint', () => { +// Linux needs a mapped window for animation frames after reload; run on an isolated display. +test.describe('Worktree switch first paint @headful', () => { + test.skip( + process.env.ORCA_BACKGROUND_LAUNCH === '1', + 'First-paint measurement requires a mapped window' + ) test('repaints an unmounted worktree within the switch budget', async ({ orcaPage, testRepoPath From f7238ce4693c450ee06976575f01f1f168876a51 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:58:24 -0400 Subject: [PATCH 028/191] fix(relay): bound idle rehome polling and skip disabled scans (#20203) * fix(relay): bound idle rehome polling and skip disabled scans * test(relay): align sweep jitter expectation with polling budget --- cloud/apps/relay/src/assignment-store.ts | 4 ++ .../src/idle-regional-rehome-store.test.ts | 46 +++++++++++++++++++ .../relay/src/regional-rehome-worker.test.ts | 25 ++++++++++ .../apps/relay/src/regional-rehome-worker.ts | 3 +- .../relay/src/relay-sweep-schedule.test.ts | 4 +- 5 files changed, 79 insertions(+), 3 deletions(-) diff --git a/cloud/apps/relay/src/assignment-store.ts b/cloud/apps/relay/src/assignment-store.ts index 680caac715f..7b877e3af7d 100644 --- a/cloud/apps/relay/src/assignment-store.ts +++ b/cloud/apps/relay/src/assignment-store.ts @@ -3351,6 +3351,10 @@ export class RelayAssignmentStore { ): Promise> { const now = this.now() if (!processSafety || this.regionalRehomeCohortPercent === 0) return [] + const control = (await this.database.query( + "SELECT enabled, not_before FROM relay_region_rehome_control WHERE control_id = 'global'" + ))[0] + if (!control || Number(control.enabled) !== 1 || Number(control.not_before) > now) return [] const fleetSafety = await this.readRegionalRehomeFleetSafety(this.database, now) if (regionalRehomeFleetSafetyFailure(processSafety, fleetSafety, now)) return [] const candidates = await selectIdleRegionalRehomes({ diff --git a/cloud/apps/relay/src/idle-regional-rehome-store.test.ts b/cloud/apps/relay/src/idle-regional-rehome-store.test.ts index 5d0d3cff343..92ced43b510 100644 --- a/cloud/apps/relay/src/idle-regional-rehome-store.test.ts +++ b/cloud/apps/relay/src/idle-regional-rehome-store.test.ts @@ -111,6 +111,52 @@ async function setup() { } describe('constrained idle regional assignment transaction', () => { + it.each(['missing', 'disabled', 'future'] as const)( + 'does only one read per tick with %s durable control and sees later enablement', + async (state) => { + const { store, database, safety } = await setup() + const control = (await database.query( + "SELECT * FROM relay_region_rehome_control WHERE control_id = 'global'" + ))[0]! + if (state === 'missing') { + await database.query('DELETE FROM relay_region_rehome_control') + } else { + await database.query( + "UPDATE relay_region_rehome_control SET enabled = ?, not_before = ? WHERE control_id = 'global'", + [state === 'disabled' ? 0 : 1, safety.observedAt + (state === 'future' ? 1 : 0)] + ) + } + const query = vi.spyOn(database, 'query') + const transaction = vi.spyOn(database, 'transaction') + for (let tick = 0; tick < 3; tick++) { + query.mockClear() + expect(await store.selectIdleRegionalRehomeCandidates(safety)).toEqual([]) + expect(query).toHaveBeenCalledTimes(1) + expect(query.mock.calls[0]![0]).toMatch(/^SELECT .*FROM relay_region_rehome_control/s) + expect(transaction).not.toHaveBeenCalled() + } + if (state === 'missing') { + const columns = Object.keys(control) + await database.query( + `INSERT INTO relay_region_rehome_control (${columns.join(',')}) VALUES (${columns.map(() => '?').join(',')})`, + Object.values(control) + ) + } else { + await database.query( + "UPDATE relay_region_rehome_control SET enabled = 1, not_before = ? WHERE control_id = 'global'", + [safety.observedAt] + ) + } + query.mockClear() + expect(await store.selectIdleRegionalRehomeCandidates(safety)).toHaveLength(1) + expect(query.mock.calls.length).toBeGreaterThan(1) + await database.query("UPDATE relay_region_rehome_control SET enabled = 0 WHERE control_id = 'global'") + query.mockClear() + expect(await store.selectIdleRegionalRehomeCandidates(safety)).toEqual([]) + expect(query).toHaveBeenCalledTimes(1) + } + ) + it.each([10, 11])('reserves source activity plus assignment at target capacity %i', async (capacity) => { const { store, database, safety, request } = await setup() // Model three source activity units and seven units already reserved at the target. diff --git a/cloud/apps/relay/src/regional-rehome-worker.test.ts b/cloud/apps/relay/src/regional-rehome-worker.test.ts index bd47923bdf1..640ae06b121 100644 --- a/cloud/apps/relay/src/regional-rehome-worker.test.ts +++ b/cloud/apps/relay/src/regional-rehome-worker.test.ts @@ -11,6 +11,31 @@ import { startRegionalRehomeWorker } from './regional-rehome-worker.js' describe('regional rehome worker', () => { afterEach(() => vi.restoreAllMocks()) + it('bounds empty polling to the six-second cadence and stops its timer', async () => { + vi.useFakeTimers() + const selectIdleRegionalRehomeCandidates = vi.fn().mockResolvedValue([]) + const worker = startRegionalRehomeWorker(config(), { + selectIdleRegionalRehomeCandidates + } as unknown as RelayAssignmentStore, { + safetySnapshot: () => safety(Date.now()), + random: () => 0 + })! + try { + await vi.advanceTimersByTimeAsync(0) + expect(selectIdleRegionalRehomeCandidates).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(5_999) + expect(selectIdleRegionalRehomeCandidates).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(selectIdleRegionalRehomeCandidates).toHaveBeenCalledTimes(2) + worker.stop() + await vi.advanceTimersByTimeAsync(60_000) + expect(selectIdleRegionalRehomeCandidates).toHaveBeenCalledTimes(2) + } finally { + worker.stop() + vi.useRealTimers() + } + }) + it('passes unsafe process telemetry to the durable claim gate', async () => { let now = 0 let sqlFailures = 0 diff --git a/cloud/apps/relay/src/regional-rehome-worker.ts b/cloud/apps/relay/src/regional-rehome-worker.ts index 5f3827f5188..67d872b888a 100644 --- a/cloud/apps/relay/src/regional-rehome-worker.ts +++ b/cloud/apps/relay/src/regional-rehome-worker.ts @@ -97,7 +97,8 @@ export function startRegionalRehomeWorker( } const timer = setInterval( () => void run(), - options.intervalMs ?? jitteredSweepIntervalMs(1_000, options.random) + // Match the initial ten-moves/minute budget without replanning the join every second. + options.intervalMs ?? jitteredSweepIntervalMs(6_000, options.random) ) timer.unref() void run() diff --git a/cloud/apps/relay/src/relay-sweep-schedule.test.ts b/cloud/apps/relay/src/relay-sweep-schedule.test.ts index 55469cab91c..b9965ae29ff 100644 --- a/cloud/apps/relay/src/relay-sweep-schedule.test.ts +++ b/cloud/apps/relay/src/relay-sweep-schedule.test.ts @@ -19,7 +19,7 @@ describe('sweep schedule jitter', () => { expect(SWEEP_JITTER_FRACTION).toBeGreaterThan(0) }) - it('jitters the regional rehome dispatch tick, which every director runs each second', () => { + it('jitters the six-second regional rehome dispatch tick across directors', () => { const timers: number[] = [] const setIntervalSpy = vi .spyOn(globalThis, 'setInterval') @@ -42,7 +42,7 @@ describe('sweep schedule jitter', () => { setIntervalSpy.mockRestore() } - expect(timers).toEqual([1_100]) + expect(timers).toEqual([6_600]) }) // Why: index.ts boots a server on import, so its wiring can only be read. From 438e0f4f5a8f1eda94007e5d31b0ee18da57d4be Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:22:06 -0700 Subject: [PATCH 029/191] fix(hooks): stop orphaned managed markers from consuming user TOML (#20148) A managed block missing its end marker was treated as Orca-owned through EOF, so uninstall/reinstall deleted appended user tables. The same shape existed a second time in the Codex legacy profile cleanup. Ownership is now two separate claims: a marker pair proves extent, and a provider that can recognize its own emitted tables owns them wherever they sit. An orphaned marker owns only its own line. Recognition uses the same test for remove, install and status, so a table Orca cannot see is never one it leaves running. Co-authored-by: maoking Fixes #18861 --- config/tsconfig.cli.json | 1 + .../managed-toml-ownership.test.ts | 154 +++++++ .../agent-hooks/managed-toml-ownership.ts | 161 ++++++++ src/main/codex/codex-hook-legacy-cleanup.ts | 34 +- .../codex-hook-legacy-profile-block.test.ts | 38 ++ src/main/kimi/hook-service.ts | 17 +- src/main/kimi/kimi-hook-config-toml.test.ts | 381 +++++++++++++++++- src/main/kimi/kimi-hook-config-toml.ts | 208 +++++++--- 8 files changed, 912 insertions(+), 82 deletions(-) create mode 100644 src/main/agent-hooks/managed-toml-ownership.test.ts create mode 100644 src/main/agent-hooks/managed-toml-ownership.ts create mode 100644 src/main/codex/codex-hook-legacy-profile-block.test.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index a23d90e6a1e..3fea6c33a57 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -11,6 +11,7 @@ "../src/main/agent-hooks/installer-utils.ts", "../src/main/agent-hooks/installer-utils-remote.ts", "../src/main/agent-hooks/local-agent-cli-presence.ts", + "../src/main/agent-hooks/managed-toml-ownership.ts", "../src/main/agent-hooks/managed-agent-hook-controls.ts", "../src/main/agent-hooks/managed-agent-hook-registry.ts", "../src/main/agent-hooks/managed-hook-script-refresh.ts", diff --git a/src/main/agent-hooks/managed-toml-ownership.test.ts b/src/main/agent-hooks/managed-toml-ownership.test.ts new file mode 100644 index 00000000000..a94ac3c2b09 --- /dev/null +++ b/src/main/agent-hooks/managed-toml-ownership.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest' +import { + findManagedTomlBlocks, + findRecognizedManagedTables, + stripManagedTomlRegions, + type ManagedTomlMarkers +} from './managed-toml-ownership' + +const START = '# >>> start >>>' +const END = '# <<< end <<<' +const MARKERS: ManagedTomlMarkers = { startMarker: START, endMarker: END } + +// Recognizes an `[owned]` table plus its `k = ...` lines; anything else is user text. +const recognizeOwned = ( + lines: readonly string[], + index: number +): { lineCount: number; value: string } | null => { + if (lines[index].trim() !== '[owned]') { + return null + } + let cursor = index + 1 + while (cursor < lines.length && /^k\d* = /.test(lines[cursor].trim())) { + cursor++ + } + return { lineCount: cursor - index, value: lines[index].trim() } +} + +function strip(text: string): string { + return stripManagedTomlRegions(text, [ + ...findManagedTomlBlocks(text, MARKERS), + ...findRecognizedManagedTables(text, recognizeOwned) + ]).text +} + +describe('managed TOML marker blocks', () => { + it('finds nothing in a file without the start marker', () => { + expect(findManagedTomlBlocks('a = 1\n', MARKERS)).toEqual([]) + expect(stripManagedTomlRegions('a = 1\n', [])).toMatchObject({ + text: 'a = 1\n', + changed: false + }) + }) + + it('owns everything between the markers regardless of content', () => { + const text = `a = 1\n\n${START}\n[whatever]\nx = 2\n${END}\nb = 3\n` + expect(findManagedTomlBlocks(text, MARKERS)[0].terminated).toBe(true) + expect(strip(text)).toBe('a = 1\nb = 3\n') + }) + + it('an orphaned block owns only its stray marker line', () => { + const text = `${START}\n[anything]\nkeep = true\n` + const [region] = findManagedTomlBlocks(text, MARKERS) + expect(region.terminated).toBe(false) + expect(stripManagedTomlRegions(text, [region]).text).toBe('[anything]\nkeep = true\n') + }) + + it('does not let a terminated block swallow a later stray start marker', () => { + const text = `${START}\n[owned]\nk = 1\n${END}\n${START}\n[user]\nkeep = true\n` + expect(findManagedTomlBlocks(text, MARKERS).map((region) => region.terminated)).toEqual([ + true, + false + ]) + expect(strip(text)).toBe('[user]\nkeep = true\n') + }) + + it('absorbs the blank run above the marker without crossing the block above', () => { + expect(strip(`a = 1\n\n\n${START}\nx\n${END}\n`)).toBe('a = 1\n') + }) + + // CodeRabbit on #20148: a prefix match let a user's own comment open or close + // a region, deleting every byte between two quoted markers. + it("ignores a marker line carrying a trailing comment of the user's own", () => { + const text = [ + 'a = 1', + `${START} (example from the docs)`, + '[user]', + 'keep = true', + `${END} (end of example)`, + 'b = 2' + ].join('\n') + expect(findManagedTomlBlocks(text, MARKERS)).toEqual([]) + expect(strip(text)).toBe(text) + }) + + it('ignores a marker line with a prefix or altered text', () => { + for (const near of [`x ${START}`, START.replace('>>>', '>>'), `${START}x`]) { + expect(findManagedTomlBlocks(`${near}\n[user]\nkeep = true\n`, MARKERS)).toEqual([]) + } + }) + + it('still matches a marker indented or with trailing whitespace', () => { + const text = `a = 1\n ${START} \n[owned]\nk = 1\n ${END}\nb = 2\n` + expect(findManagedTomlBlocks(text, MARKERS)[0].terminated).toBe(true) + expect(strip(text)).toBe('a = 1\nb = 2\n') + }) + + it('handles a marker on the last line with no trailing newline', () => { + expect(strip(`a = 1\n${START}`)).toBe('a = 1\n') + expect(strip(`a = 1\n${START}\n[owned]\nk = 1`)).toBe('a = 1\n') + }) +}) + +describe('recognized managed tables', () => { + it('reclaims a recognized table wherever it sits, and nothing else', () => { + const text = `[user]\nkeep = true\n\n[owned]\nk = 1\nk2 = 2\n\n[user2]\nalso = true\n` + expect(findRecognizedManagedTables(text, recognizeOwned)).toHaveLength(1) + expect(strip(text)).toBe('[user]\nkeep = true\n\n[user2]\nalso = true\n') + }) + + it('reclaims tables stranded below user text after an orphaned marker', () => { + const text = `${START}\n[owned]\nk = 1\n[user]\nkeep = true\n[owned]\nk = 2\n` + expect(strip(text)).toBe('[user]\nkeep = true\n') + }) + + it('leaves an unrecognized table alone', () => { + const text = `${START}\n[user]\nkeep = true\n` + expect(strip(text)).toBe('[user]\nkeep = true\n') + }) + + it('reports each recognized table to readers', () => { + const text = `[owned]\nk = 1\n[user]\nx = 1\n[owned]\nk = 2\n` + expect(findRecognizedManagedTables(text, recognizeOwned).map((t) => t.value)).toEqual([ + '[owned]', + '[owned]' + ]) + }) + + it('clamps a recognizer that claims more lines than the file has', () => { + const greedy = (): { lineCount: number; value: null } => ({ lineCount: 999, value: null }) + const text = 'a = 1\n' + expect(stripManagedTomlRegions(text, findRecognizedManagedTables(text, greedy)).text).toBe('') + }) +}) + +describe('splicing owned regions', () => { + it('merges a recognized table nested inside a marker block', () => { + const text = `a = 1\n${START}\n[owned]\nk = 1\n${END}\nb = 2\n` + const regions = [ + ...findManagedTomlBlocks(text, MARKERS), + ...findRecognizedManagedTables(text, recognizeOwned) + ] + expect(regions).toHaveLength(2) + expect(stripManagedTomlRegions(text, regions).text).toBe('a = 1\nb = 2\n') + }) + + it('splices CRLF text back verbatim', () => { + expect(strip(`a = 1\r\n\r\n${START}\r\n[owned]\r\nk = 1\r\n${END}\r\nb = 2\r\n`)).toBe( + 'a = 1\r\nb = 2\r\n' + ) + expect(strip(`${START}\r\n[owned]\r\nk = 1\r\n[user]\r\nkeep = true\r\n`)).toBe( + '[user]\r\nkeep = true\r\n' + ) + }) +}) diff --git a/src/main/agent-hooks/managed-toml-ownership.ts b/src/main/agent-hooks/managed-toml-ownership.ts new file mode 100644 index 00000000000..940048c0132 --- /dev/null +++ b/src/main/agent-hooks/managed-toml-ownership.ts @@ -0,0 +1,161 @@ +// Orca appends marker-delimited blocks to user-owned TOML config files. Two +// independent things can make a byte Orca's: it sits between a matched start and +// end marker, or the provider positively recognizes it as content Orca emitted. +// The end marker is the only proof of a block's extent, so once a hand-edit +// deletes it the rest of the file is unknown text — #18861: assuming otherwise +// deleted user tables through EOF. An orphaned block therefore owns nothing but +// its own stray marker line, and anything Orca actually wrote is reclaimed by +// recognition instead, wherever in the file it ended up. + +export type ManagedTomlMarkers = { + startMarker: string + endMarker: string +} + +export type ManagedTomlRegion = { + /** First removable offset — includes the blank-line run above the content. */ + startOffset: number + /** Offset one past the last owned line, terminator included. */ + endOffset: number +} + +export type ManagedTomlBlockRegion = ManagedTomlRegion & { + /** Offset of the start-marker line itself. */ + markerOffset: number + /** End marker found: everything between the markers is Orca's. */ + terminated: boolean +} + +export type RecognizedManagedTable = ManagedTomlRegion & { value: T } + +/** Line count of the table starting at `index` plus what the reader needs, or null. */ +export type ManagedTableRecognizer = ( + lines: readonly string[], + index: number +) => { lineCount: number; value: T } | null + +type ScannedLine = { + text: string + offset: number + endOffset: number +} + +// Keeps offsets on the raw text so CRLF terminators are spliced back verbatim. +function scanLines(text: string): ScannedLine[] { + const lines: ScannedLine[] = [] + let offset = 0 + while (offset < text.length) { + const newlineIndex = text.indexOf('\n', offset) + const endOffset = newlineIndex === -1 ? text.length : newlineIndex + 1 + lines.push({ + text: text.slice(offset, endOffset).replace(/\r?\n$/, ''), + offset, + endOffset + }) + offset = endOffset + } + return lines +} + +// Absorb the blank run above so install/remove cycles do not accumulate +// whitespace; overlapping runs are merged away by stripManagedTomlRegions. +function startOffsetAbsorbingBlanksAbove(lines: readonly ScannedLine[], index: number): number { + let startLine = index + while (startLine > 0 && lines[startLine - 1].text.trim() === '') { + startLine-- + } + return lines[startLine].offset +} + +export function findManagedTomlBlocks( + text: string, + markers: ManagedTomlMarkers +): ManagedTomlBlockRegion[] { + const lines = scanLines(text) + // Exact, not startsWith: a user quoting a marker in a comment of their own + // ("# >>> ... >>> (example from the docs)") would otherwise open or close a + // region and take every byte between the two quoted lines. Both emitters + // write the marker as its own line, so nothing legitimate carries a suffix. + const isStart = (index: number): boolean => lines[index].text.trim() === markers.startMarker + const isEnd = (index: number): boolean => lines[index].text.trim() === markers.endMarker + + const regions: ManagedTomlBlockRegion[] = [] + for (let index = 0; index < lines.length; index++) { + if (!isStart(index)) { + continue + } + let last = index + let terminated = false + for (let cursor = index + 1; cursor < lines.length; cursor++) { + // A second start marker never belongs to the block already open. + if (isStart(cursor)) { + break + } + if (isEnd(cursor)) { + last = cursor + terminated = true + break + } + } + // Not terminated: `last` stays on the marker line, so the orphan owns only + // the stray marker. Its body, if Orca wrote it, is reclaimed by recognition. + regions.push({ + startOffset: startOffsetAbsorbingBlanksAbove(lines, index), + markerOffset: lines[index].offset, + endOffset: lines[last].endOffset, + terminated + }) + index = last + } + return regions +} + +/** + * Every table the provider recognizes as its own, anywhere in the file. Marker + * position is irrelevant: content Orca emitted is Orca's to remove even when a + * hand-edit stranded it outside the block (#18861). + */ +export function findRecognizedManagedTables( + text: string, + recognize: ManagedTableRecognizer +): RecognizedManagedTable[] { + const lines = scanLines(text) + const texts = lines.map((line) => line.text) + const tables: RecognizedManagedTable[] = [] + for (let index = 0; index < lines.length; index++) { + const match = recognize(texts, index) + if (!match || match.lineCount <= 0) { + continue + } + const last = Math.min(index + match.lineCount, lines.length) - 1 + tables.push({ + startOffset: startOffsetAbsorbingBlanksAbove(lines, index), + endOffset: lines[last].endOffset, + value: match.value + }) + index = last + } + return tables +} + +/** Splices every owned region out in one pass, merging overlaps and nesting. */ +export function stripManagedTomlRegions( + text: string, + regions: readonly ManagedTomlRegion[] +): { text: string; changed: boolean } { + if (regions.length === 0) { + return { text, changed: false } + } + const ordered = [...regions].sort((a, b) => a.startOffset - b.startOffset) + let stripped = '' + let cursor = 0 + for (const region of ordered) { + if (region.endOffset <= cursor) { + continue + } + stripped += text.slice(cursor, Math.max(cursor, region.startOffset)) + cursor = region.endOffset + } + stripped += text.slice(cursor) + return { text: stripped, changed: stripped !== text } +} diff --git a/src/main/codex/codex-hook-legacy-cleanup.ts b/src/main/codex/codex-hook-legacy-cleanup.ts index 3177fdaa5a1..d2b5c3b586c 100644 --- a/src/main/codex/codex-hook-legacy-cleanup.ts +++ b/src/main/codex/codex-hook-legacy-cleanup.ts @@ -9,6 +9,7 @@ import { } from '../agent-hooks/installer-utils' import { resolveHooksJsonWritePath } from '../agent-hooks/hook-config-write-path' import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { findManagedTomlBlocks } from '../agent-hooks/managed-toml-ownership' import { writeConfigAtomically, type CodexTrustEntry } from './config-toml-trust' import { getConfigPath, @@ -149,22 +150,37 @@ async function sweepLegacySystemManagedHooks(): Promise { } } -function stripLegacyManagedProfileBlock(content: string): string { - const start = content.indexOf(LEGACY_ORCA_PROFILE_BLOCK_START) - if (start === -1) { +export function stripLegacyManagedProfileBlock(content: string): string { + const regions = findManagedTomlBlocks(content, { + startMarker: LEGACY_ORCA_PROFILE_BLOCK_START, + endMarker: LEGACY_ORCA_PROFILE_BLOCK_END + }) + // A stray marker above a complete block must not hide it: take the first + // terminated region and leave the orphan (and the user text around it) alone. + const region = regions.find((candidate) => candidate.terminated) ?? regions[0] + if (!region) { return content } - const endMarker = content.indexOf(LEGACY_ORCA_PROFILE_BLOCK_END, start) - const end = endMarker === -1 ? content.length : endMarker + LEGACY_ORCA_PROFILE_BLOCK_END.length - const before = content.slice(0, start).replace(/[ \t]*(?:\r?\n)*$/, '') - const after = content.slice(end).replace(/^(?:\r?\n)+/, '') + if (!region.terminated) { + // #18861: deleting to EOF took user text appended below the block. This + // legacy body's shape is not knowable from current source, so there is + // nothing to recognize it by; leave the whole thing alone. The stale profile + // is inert (runtime CODEX_HOME supersedes it), so that costs nothing next to + // destroying the user's trust entries. + return content + } + // Rejoin with the file's own terminator; a bare \n seam here left Windows + // configs with mixed endings. + const eol = content.includes('\r\n') ? '\r\n' : '\n' + const before = content.slice(0, region.markerOffset).replace(/[ \t]*(?:\r?\n)*$/, '') + const after = content.slice(region.endOffset).replace(/^(?:\r?\n)+/, '') if (!before) { return after } if (!after) { - return before.endsWith('\n') ? before : `${before}\n` + return before.endsWith('\n') ? before : `${before}${eol}` } - return `${before}\n\n${after}` + return `${before}${eol}${eol}${after}` } function cleanupLegacyCodexProfileHooks(): void { diff --git a/src/main/codex/codex-hook-legacy-profile-block.test.ts b/src/main/codex/codex-hook-legacy-profile-block.test.ts new file mode 100644 index 00000000000..5225f482b13 --- /dev/null +++ b/src/main/codex/codex-hook-legacy-profile-block.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { stripLegacyManagedProfileBlock } from './codex-hook-legacy-cleanup' + +const START = '# BEGIN ORCA AGENT STATUS HOOKS' +const END = '# END ORCA AGENT STATUS HOOKS' + +describe('legacy Codex managed profile block', () => { + it('strips a well-formed block and keeps the surrounding config', () => { + const content = `model = "o3"\n\n${START}\n[[hooks]]\nx = 1\n${END}\n\ntail = true\n` + expect(stripLegacyManagedProfileBlock(content)).toBe('model = "o3"\n\ntail = true\n') + }) + + it('leaves a file with no managed block untouched', () => { + expect(stripLegacyManagedProfileBlock('model = "o3"\n')).toBe('model = "o3"\n') + }) + + it('rejoins a CRLF config with CRLF', () => { + const content = `model = "o3"\r\n\r\n${START}\r\n[[hooks]]\r\n${END}\r\n\r\ntail = true\r\n` + const next = stripLegacyManagedProfileBlock(content) + expect(next).toBe('model = "o3"\r\n\r\ntail = true\r\n') + expect(next).not.toMatch(/[^\r]\n/) + }) + + // CodeRabbit on #20148: a stray marker above a complete block must not hide it. + it('removes a complete block that sits below an orphaned marker', () => { + const content = `${START}\nstray = 1\n\n${START}\n[[hooks]]\nx = 1\n${END}\n\ntail = true\n` + const next = stripLegacyManagedProfileBlock(content) + expect(next).not.toContain('[[hooks]]') + expect(next).toContain('stray = 1') + expect(next).toContain('tail = true') + }) + + // #18861: the old strip ran to EOF whenever the end marker was gone. + it('fails closed when the end marker was hand-deleted', () => { + const content = `model = "o3"\n${START}\n[[hooks]]\nx = 1\n\n[user.table]\nkeep = "mine"\n` + expect(stripLegacyManagedProfileBlock(content)).toBe(content) + }) +}) diff --git a/src/main/kimi/hook-service.ts b/src/main/kimi/hook-service.ts index e397fa0b026..21fee64d276 100644 --- a/src/main/kimi/hook-service.ts +++ b/src/main/kimi/hook-service.ts @@ -51,6 +51,10 @@ function getConfigPath(): string { // single curl-based script body works on every platform. const MANAGED_SCRIPT_FILE_NAME = 'kimi-hook.sh' +// Ownership test for every managed-block path: status, install, remove and the +// bounded orphan recovery all agree on what counts as an Orca-written hook. +const isManagedKimiCommand = createManagedCommandMatcher(MANAGED_SCRIPT_FILE_NAME) + function getManagedScriptPath(): string { return getSharedManagedScriptPath(MANAGED_SCRIPT_FILE_NAME) } @@ -194,8 +198,7 @@ export class KimiHookService { detail: 'Could not read Kimi config.toml' } } - const isManagedCommand = createManagedCommandMatcher(MANAGED_SCRIPT_FILE_NAME) - return buildStatus(readManagedKimiHookEvents(text, isManagedCommand), configPath) + return buildStatus(readManagedKimiHookEvents(text, isManagedKimiCommand), configPath) } install(): AgentHookInstallStatus { @@ -214,7 +217,7 @@ export class KimiHookService { const command = getManagedCommand(scriptPath) // Write the script first so config.toml never points at a missing script. writeManagedScript(scriptPath, getManagedScript()) - writeConfigToml(configPath, applyManagedKimiHooks(text, command)) + writeConfigToml(configPath, applyManagedKimiHooks(text, command, isManagedKimiCommand)) return this.getStatus() } @@ -235,7 +238,11 @@ export class KimiHookService { const command = wrapPosixHookCommand(remoteScriptPath) // Write the script first so config.toml never points at a missing script. await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) - await writeTextFileRemoteAtomic(sftp, remoteConfigPath, applyManagedKimiHooks(text, command)) + await writeTextFileRemoteAtomic( + sftp, + remoteConfigPath, + applyManagedKimiHooks(text, command, isManagedKimiCommand) + ) return { agent: 'kimi', state: 'installed', @@ -266,7 +273,7 @@ export class KimiHookService { detail: 'Could not read Kimi config.toml' } } - const { text: nextText, changed } = removeManagedKimiHooks(text) + const { text: nextText, changed } = removeManagedKimiHooks(text, isManagedKimiCommand) if (changed) { writeConfigToml(configPath, nextText) } diff --git a/src/main/kimi/kimi-hook-config-toml.test.ts b/src/main/kimi/kimi-hook-config-toml.test.ts index 954e3639975..525cca03efc 100644 --- a/src/main/kimi/kimi-hook-config-toml.test.ts +++ b/src/main/kimi/kimi-hook-config-toml.test.ts @@ -12,6 +12,14 @@ const COMMAND = const isManaged = (command: string | undefined): boolean => typeof command === 'string' && command.includes('agent-hooks/kimi-hook.sh') +const END_MARKER_LINE = '# <<< orca-managed-kimi-hooks <<<' +const START_MARKER = '# >>> orca-managed-kimi-hooks (managed by Orca; do not edit) >>>' + +/** Drops only the `# <<< ... <<<` line, the hand-edit that orphans the block. */ +function deleteEndMarker(text: string): string { + return text.replace(/\r?\n# <<< orca-managed-kimi-hooks <<<(?=\r?\n|$)/, '') +} + describe('kimi managed hooks TOML block', () => { it('installs every managed event without a matcher', () => { const block = buildManagedKimiHooksBlock(COMMAND) @@ -20,9 +28,9 @@ describe('kimi managed hooks TOML block', () => { } // Kimi treats matcher as a regex; omitting it matches all tools. expect(block).not.toContain('matcher') - expect(readManagedKimiHookEvents(applyManagedKimiHooks('', COMMAND), isManaged)).toEqual( - new Set(KIMI_HOOK_EVENTS) - ) + expect( + readManagedKimiHookEvents(applyManagedKimiHooks('', COMMAND, isManaged), isManaged) + ).toEqual(new Set(KIMI_HOOK_EVENTS)) }) it('preserves existing user config above the managed block', () => { @@ -40,7 +48,7 @@ describe('kimi managed hooks TOML block', () => { '' ].join('\n') - const next = applyManagedKimiHooks(userConfig, COMMAND) + const next = applyManagedKimiHooks(userConfig, COMMAND, isManaged) expect(next).toContain('default_model = "kimi-k2.6"') expect(next).toContain('api_key = "sk-secret"') // The user's own hook survives untouched. @@ -49,8 +57,8 @@ describe('kimi managed hooks TOML block', () => { }) it('is idempotent — reinstalling does not duplicate the block', () => { - const once = applyManagedKimiHooks('default_model = "x"\n', COMMAND) - const twice = applyManagedKimiHooks(once, COMMAND) + const once = applyManagedKimiHooks('default_model = "x"\n', COMMAND, isManaged) + const twice = applyManagedKimiHooks(once, COMMAND, isManaged) expect(twice).toBe(once) const markerCount = (twice.match(/orca-managed-kimi-hooks \(/g) ?? []).length expect(markerCount).toBe(1) @@ -58,52 +66,385 @@ describe('kimi managed hooks TOML block', () => { it('removes the managed block and restores the user config', () => { const userConfig = 'default_model = "kimi-k2.6"\n' - const installed = applyManagedKimiHooks(userConfig, COMMAND) - const { text, changed } = removeManagedKimiHooks(installed) + const installed = applyManagedKimiHooks(userConfig, COMMAND, isManaged) + const { text, changed } = removeManagedKimiHooks(installed, isManaged) expect(changed).toBe(true) expect(text).toBe(userConfig) expect(readManagedKimiHookEvents(text, isManaged).size).toBe(0) }) it('reports no change when removing from a config without the managed block', () => { - const { text, changed } = removeManagedKimiHooks('default_model = "x"\n') + const { text, changed } = removeManagedKimiHooks('default_model = "x"\n', isManaged) expect(changed).toBe(false) expect(text).toBe('default_model = "x"\n') }) it('is stable across repeated calls (no stateful global-regex lastIndex drift)', () => { - const installed = applyManagedKimiHooks('default_model = "x"\n', COMMAND) + const installed = applyManagedKimiHooks('default_model = "x"\n', COMMAND, isManaged) // Repeated detection/removal on the same and on a clean input must be // consistent — a `g`-flagged .test() would drift lastIndex and flip results. - expect(removeManagedKimiHooks(installed).changed).toBe(true) - expect(removeManagedKimiHooks(installed).changed).toBe(true) - expect(removeManagedKimiHooks('default_model = "x"\n').changed).toBe(false) - expect(removeManagedKimiHooks(installed).changed).toBe(true) + expect(removeManagedKimiHooks(installed, isManaged).changed).toBe(true) + expect(removeManagedKimiHooks(installed, isManaged).changed).toBe(true) + expect(removeManagedKimiHooks('default_model = "x"\n', isManaged).changed).toBe(false) + expect(removeManagedKimiHooks(installed, isManaged).changed).toBe(true) expect(readManagedKimiHookEvents(installed, isManaged)).toEqual(new Set(KIMI_HOOK_EVENTS)) expect(readManagedKimiHookEvents(installed, isManaged)).toEqual(new Set(KIMI_HOOK_EVENTS)) }) it('recovers when a hand-edit deletes only the trailing end marker', () => { - const installed = applyManagedKimiHooks('default_model = "x"\n', COMMAND) - // Simulate a user deleting just the `# <<< ... <<<` end-marker line. - const orphaned = installed.replace(/\n# <<< orca-managed-kimi-hooks <<<\n?/, '\n') + const installed = applyManagedKimiHooks('default_model = "x"\n', COMMAND, isManaged) + const orphaned = deleteEndMarker(installed) expect(orphaned).not.toContain('<<<') // The orphaned (still-active) hook tables are still recognized... expect(readManagedKimiHookEvents(orphaned, isManaged)).toEqual(new Set(KIMI_HOOK_EVENTS)) // ...remove strips them... - expect(removeManagedKimiHooks(orphaned)).toEqual({ + expect(removeManagedKimiHooks(orphaned, isManaged)).toEqual({ text: 'default_model = "x"\n', changed: true }) // ...and reinstall converges to a single block instead of duplicating. - const reinstalled = applyManagedKimiHooks(orphaned, COMMAND) + const reinstalled = applyManagedKimiHooks(orphaned, COMMAND, isManaged) expect((reinstalled.match(/orca-managed-kimi-hooks \(/g) ?? []).length).toBe(1) }) it('treats stale managed entries pointing at a moved script path as managed', () => { const staleCommand = "if [ -x '/old/userData/agent-hooks/kimi-hook.sh' ]; then /bin/sh '/old/userData/agent-hooks/kimi-hook.sh'; fi" - const stale = applyManagedKimiHooks('', staleCommand) + const stale = applyManagedKimiHooks('', staleCommand, isManaged) expect(readManagedKimiHookEvents(stale, isManaged)).toEqual(new Set(KIMI_HOOK_EVENTS)) }) }) + +// #18861: an orphaned start marker used to make every following byte "managed". +describe('orphaned managed block ownership (#18861)', () => { + const USER_TAIL = [ + '[providers."mine"]', + 'type = "openai"', + 'api_key = "sk-secret"', + '', + '[[hooks]]', + 'event = "Stop"', + 'command = "node my-own-hook.mjs"' + ].join('\n') + + function orphanedWithUserTail(): string { + const installed = applyManagedKimiHooks('default_model = "x"\n', COMMAND, isManaged) + return `${deleteEndMarker(installed)}\n${USER_TAIL}\n` + } + + it('keeps user tables appended after an orphaned block through remove', () => { + const { text, changed } = removeManagedKimiHooks(orphanedWithUserTail(), isManaged) + expect(changed).toBe(true) + expect(text).toContain('api_key = "sk-secret"') + expect(text).toContain('command = "node my-own-hook.mjs"') + expect(text).toContain('default_model = "x"') + // The reclaimed managed tables and the stray marker are gone. + expect(text).not.toContain(START_MARKER) + expect(text).not.toContain('agent-hooks/kimi-hook.sh') + }) + + it('keeps user tables appended after an orphaned block through reinstall', () => { + const reinstalled = applyManagedKimiHooks(orphanedWithUserTail(), COMMAND, isManaged) + expect(reinstalled).toContain('api_key = "sk-secret"') + expect(reinstalled).toContain('command = "node my-own-hook.mjs"') + // Exactly one well-formed block, appended after the surviving user bytes. + expect((reinstalled.match(/orca-managed-kimi-hooks \(/g) ?? []).length).toBe(1) + expect(reinstalled.indexOf('sk-secret')).toBeLessThan(reinstalled.indexOf(START_MARKER)) + expect(readManagedKimiHookEvents(reinstalled, isManaged)).toEqual(new Set(KIMI_HOOK_EVENTS)) + // And a second install is a no-op, so the recovery converges. + expect(applyManagedKimiHooks(reinstalled, COMMAND, isManaged)).toBe(reinstalled) + }) + + it('reclaims a genuinely managed orphan table but stops at the first user line', () => { + const orphan = [ + 'default_model = "x"', + '', + START_MARKER, + '[[hooks]]', + `event = "Stop"`, + `command = "${COMMAND.replaceAll('"', '')}"`, + 'timeout = 10', + '[hand.written]', + 'value = "keep"', + '' + ].join('\n') + const { text, changed } = removeManagedKimiHooks(orphan, isManaged) + expect(changed).toBe(true) + expect(text).toBe('default_model = "x"\n[hand.written]\nvalue = "keep"\n') + }) + + it('removes only the stray marker when an orphan owns no managed content', () => { + const orphan = `default_model = "x"\n\n${START_MARKER}\n[user.table]\nvalue = "keep"\n` + const { text, changed } = removeManagedKimiHooks(orphan, isManaged) + expect(changed).toBe(true) + expect(text).toBe('default_model = "x"\n[user.table]\nvalue = "keep"\n') + }) + + it('does not treat a user [[hooks]] table as Orca-owned content', () => { + const orphan = [ + START_MARKER, + '[[hooks]]', + 'event = "Stop"', + 'command = "node my-own-hook.mjs"', + 'timeout = 10', + '' + ].join('\n') + const { text } = removeManagedKimiHooks(orphan, isManaged) + expect(text).toContain('command = "node my-own-hook.mjs"') + expect(text).not.toContain(START_MARKER) + }) + + // A user adding keys has customised Orca's hook, not authored their own: the + // command path is what makes it fire. Leaving it would keep sending Orca their + // events after uninstall, and reinstall would double-fire the event. + it('owns a managed table the user added an extra key to', () => { + const orphan = [ + START_MARKER, + '[[hooks]]', + 'event = "Stop"', + `command = "${COMMAND.replaceAll('"', '')}"`, + 'timeout = 10', + 'matcher = "Bash"', + '' + ].join('\n') + expect(removeManagedKimiHooks(orphan, isManaged).text).toBe('') + }) + + it('owns a customised managed table sitting outside any marker', () => { + const customised = [ + 'default_model = "x"', + '', + '[[hooks]]', + 'event = "Stop"', + `command = "${COMMAND.replaceAll('"', '')}"`, + 'timeout = 10', + 'matcher = "Bash"', + '' + ].join('\n') + expect(removeManagedKimiHooks(customised, isManaged).text).toBe('default_model = "x"\n') + // Status agrees, so install cannot append a second table for the same event. + expect(readManagedKimiHookEvents(customised, isManaged)).toEqual(new Set(['Stop'])) + const reinstalled = applyManagedKimiHooks(customised, COMMAND, isManaged) + expect((reinstalled.match(/event = "Stop"/g) ?? []).length).toBe(1) + }) + + // Extent safety: a multi-line value means the table's end is not knowable by + // line scanning, so splicing it would take the wrong bytes. + it('fails closed on a table whose value spans lines', () => { + const orphan = [ + START_MARKER, + '[[hooks]]', + 'event = "Stop"', + `command = "${COMMAND.replaceAll('"', '')}"`, + 'args = [', + ' "a"', + ']', + '' + ].join('\n') + const { text } = removeManagedKimiHooks(orphan, isManaged) + expect(text).toContain('args = [') + expect(text).not.toContain(START_MARKER) + }) + + // CodeRabbit on #20148: the old regex reader matched key *suffixes* and + // commented-out keys. Keys are parsed exactly now; these must not register. + it('does not read a managed event from key suffixes or commented keys', () => { + const nearMiss = [ + START_MARKER, + '[[hooks]]', + 'previous_event = "Stop"', + `fallback_command = "${COMMAND.replaceAll('"', '')}"`, + 'timeout = 10', + END_MARKER_LINE, + '', + '[[hooks]]', + '# event = "PreToolUse"', + `# command = "${COMMAND.replaceAll('"', '')}"`, + '' + ].join('\n') + expect(readManagedKimiHookEvents(nearMiss, isManaged)).toEqual(new Set()) + }) + + // CodeRabbit on #20148: a blank or comment between keys does not end a TOML + // table. Splicing the bounded run would strand `timeout` without its header. + it('fails closed when more keys follow a gap inside the table', () => { + for (const gap of ['', '# note']) { + const orphan = [ + START_MARKER, + '[[hooks]]', + 'event = "Stop"', + `command = "${COMMAND.replaceAll('"', '')}"`, + gap, + 'timeout = 10', + '' + ].join('\n') + const { text } = removeManagedKimiHooks(orphan, isManaged) + expect(text).toContain('timeout = 10') + expect(text).toContain('[[hooks]]') + expect(text).not.toContain(START_MARKER) + } + }) + + it('still owns a table whose keys are followed by a gap and a new table', () => { + const orphan = [ + START_MARKER, + '[[hooks]]', + 'event = "Stop"', + `command = "${COMMAND.replaceAll('"', '')}"`, + 'timeout = 10', + '', + '# a user comment', + '', + '[user.table]', + 'v = 1', + '' + ].join('\n') + const { text } = removeManagedKimiHooks(orphan, isManaged) + expect(text).not.toContain(START_MARKER) + expect(text).not.toContain('agent-hooks/kimi-hook.sh') + // The user's comment and table are theirs; only the managed table goes. + expect(text).toContain('# a user comment') + expect(text).toContain('[user.table]') + }) + + // pullfrog on #20148: ownership keys on `command`, so an `event` Orca cannot + // parse must never let status claim nothing is installed. + it('never reports not_installed for a table remove() would strip', () => { + for (const eventLine of [`event = 'Stop'`, 'event = "Stop" # note', 'event = 12']) { + const config = [ + START_MARKER, + '[[hooks]]', + eventLine, + `command = "${COMMAND.replaceAll('"', '')}"`, + 'timeout = 10', + END_MARKER_LINE, + '' + ].join('\n') + // remove() strips it, so status must see it too. + expect(removeManagedKimiHooks(config, isManaged).changed).toBe(true) + expect(readManagedKimiHookEvents(config, isManaged).size).toBeGreaterThan(0) + } + // The single-quoted form resolves to the real event name. + const singleQuoted = [ + START_MARKER, + '[[hooks]]', + `event = 'Stop'`, + `command = "${COMMAND.replaceAll('"', '')}"`, + 'timeout = 10', + END_MARKER_LINE, + '' + ].join('\n') + expect(readManagedKimiHookEvents(singleQuoted, isManaged)).toEqual(new Set(['Stop'])) + }) + + it('leaves a hook table that does not invoke the managed script', () => { + const orphan = [ + START_MARKER, + '[[hooks]]', + 'event = "Stop"', + 'command = "node my-own-hook.mjs"', + 'timeout = 10', + 'matcher = "Bash"', + '' + ].join('\n') + const { text } = removeManagedKimiHooks(orphan, isManaged) + expect(text).toContain('command = "node my-own-hook.mjs"') + expect(text).toContain('matcher = "Bash"') + }) + + // A stranded managed table still executes, so remove() must reclaim it wherever + // a hand-edit left it; only the user's own bytes are off limits. + it('reclaims managed tables stranded below user text', () => { + const managedTable = [ + '[[hooks]]', + 'event = "Stop"', + `command = "${COMMAND.replaceAll('"', '')}"`, + 'timeout = 10' + ].join('\n') + const orphan = `${START_MARKER}\n${managedTable}\n[user.table]\nv = 1\n${managedTable}\n` + const { text } = removeManagedKimiHooks(orphan, isManaged) + expect(text).toBe('[user.table]\nv = 1\n') + }) + + it('reports a stranded managed table as live so status cannot claim uninstalled', () => { + const stranded = [ + '[user.table]', + 'v = 1', + '', + '[[hooks]]', + 'event = "PreToolUse"', + `command = "${COMMAND.replaceAll('"', '')}"`, + 'timeout = 10', + '' + ].join('\n') + expect(readManagedKimiHookEvents(stranded, isManaged)).toEqual(new Set(['PreToolUse'])) + }) + + it('reinstalling over a stranded table does not double-register its event', () => { + const stranded = [ + '[user.table]', + 'v = 1', + '', + '[[hooks]]', + 'event = "PreToolUse"', + `command = "${COMMAND.replaceAll('"', '')}"`, + 'timeout = 10', + '' + ].join('\n') + const reinstalled = applyManagedKimiHooks(stranded, COMMAND, isManaged) + expect((reinstalled.match(/event = "PreToolUse"/g) ?? []).length).toBe(1) + expect(reinstalled).toContain('[user.table]') + expect(readManagedKimiHookEvents(reinstalled, isManaged)).toEqual(new Set(KIMI_HOOK_EVENTS)) + }) + + it('stops an orphaned block at a second start marker', () => { + const installed = applyManagedKimiHooks('default_model = "x"\n', COMMAND, isManaged) + const duplicated = `${deleteEndMarker(installed)}\n${START_MARKER}\n[user.table]\nv = 1\n` + const { text, changed } = removeManagedKimiHooks(duplicated, isManaged) + expect(changed).toBe(true) + expect(text).toContain('[user.table]') + expect(text).not.toContain(START_MARKER) + expect(text).not.toContain('agent-hooks/kimi-hook.sh') + }) + + it('removes both blocks when the markers are duplicated wholesale', () => { + const installed = applyManagedKimiHooks('default_model = "x"\n', COMMAND, isManaged) + const block = buildManagedKimiHooksBlock(COMMAND) + const doubled = `${installed}\n${block}\n[user.table]\nv = 1\n` + const { text, changed } = removeManagedKimiHooks(doubled, isManaged) + expect(changed).toBe(true) + expect(text).toBe('default_model = "x"\n[user.table]\nv = 1\n') + }) + + it('leaves a stray start marker after a well-formed block bounded', () => { + const installed = applyManagedKimiHooks('default_model = "x"\n', COMMAND, isManaged) + const withStray = `${installed}${START_MARKER}\n[user.table]\nv = 1\n` + const { text } = removeManagedKimiHooks(withStray, isManaged) + expect(text).toBe('default_model = "x"\n[user.table]\nv = 1\n') + }) +}) + +describe('CRLF configs', () => { + const userConfig = 'default_model = "kimi-k2.6"\r\n' + + it('writes the managed block with the file’s existing CRLF endings', () => { + const installed = applyManagedKimiHooks(userConfig, COMMAND, isManaged) + expect(installed).not.toMatch(/[^\r]\n/) + expect(readManagedKimiHookEvents(installed, isManaged)).toEqual(new Set(KIMI_HOOK_EVENTS)) + expect(applyManagedKimiHooks(installed, COMMAND, isManaged)).toBe(installed) + expect(removeManagedKimiHooks(installed, isManaged)).toEqual({ + text: userConfig, + changed: true + }) + }) + + it('keeps CRLF user bytes after an orphaned block', () => { + const installed = applyManagedKimiHooks(userConfig, COMMAND, isManaged) + const orphaned = `${deleteEndMarker(installed)}\r\n[providers."mine"]\r\napi_key = "sk-secret"\r\n` + const { text, changed } = removeManagedKimiHooks(orphaned, isManaged) + expect(changed).toBe(true) + expect(text).toContain('api_key = "sk-secret"') + expect(text).not.toContain('agent-hooks/kimi-hook.sh') + expect(text).not.toMatch(/[^\r]\n/) + }) +}) diff --git a/src/main/kimi/kimi-hook-config-toml.ts b/src/main/kimi/kimi-hook-config-toml.ts index aae898fd327..fe3dc9d816e 100644 --- a/src/main/kimi/kimi-hook-config-toml.ts +++ b/src/main/kimi/kimi-hook-config-toml.ts @@ -2,12 +2,19 @@ // lifecycle hooks from an array of `[[hooks]]` tables. There is no JSON settings // file to reuse the shared JSON installer with, and no TOML library is vendored, // so Orca manages only its own marker-delimited block: install rewrites the -// block, remove strips it, and arbitrary user config outside the markers is left -// untouched. Appending table headers is always valid TOML, so the block can live -// at the end of any existing file. +// block, remove strips it, and user config is left untouched apart from hook +// tables Orca itself emitted. Appending table headers is always valid TOML, so +// the block can live at the end of any existing file. import { MANAGED_HOOK_TIMEOUT_SECONDS } from '../agent-hooks/installer-utils' -import { escapeRegex } from '../../shared/string-utils' +import { + findManagedTomlBlocks, + findRecognizedManagedTables, + stripManagedTomlRegions, + type ManagedTomlMarkers, + type ManagedTomlRegion, + type RecognizedManagedTable +} from '../agent-hooks/managed-toml-ownership' // Why: mirror the Claude-compatible events Orca normalizes for status. Kimi uses // these exact event names (see normalizeKimiEvent), so each maps to a @@ -22,19 +29,112 @@ export const KIMI_HOOK_EVENTS = [ 'StopFailure' ] as const -const BLOCK_START = '# >>> orca-managed-kimi-hooks (managed by Orca; do not edit) >>>' -const BLOCK_END = '# <<< orca-managed-kimi-hooks <<<' +const MARKERS: ManagedTomlMarkers = { + startMarker: '# >>> orca-managed-kimi-hooks (managed by Orca; do not edit) >>>', + endMarker: '# <<< orca-managed-kimi-hooks <<<' +} +const HOOK_TABLE_HEADER = '[[hooks]]' -// Matches the managed block plus any blank lines immediately preceding it so -// repeated install/remove cycles do not accumulate whitespace. The `|$` -// fallback also matches from BLOCK_START to end-of-file when the trailing -// BLOCK_END marker is missing (e.g. a hand-edit deleted it): the managed block -// is always written last, so this recovers orphaned hook tables and lets -// install re-converge in one step instead of appending a duplicate block. -const MANAGED_BLOCK_RE = new RegExp( - `\\n*${escapeRegex(BLOCK_START)}[\\s\\S]*?(?:${escapeRegex(BLOCK_END)}[^\\n]*|$)`, - 'g' -) +export type ManagedCommandMatcher = (command: string | undefined) => boolean + +// A `[[hooks]]` table that invokes Orca's managed script is Orca's hook: that +// command path is the only reason it fires, and it is there because Orca put it +// there. Extra keys are a user customising our hook, not authoring their own, so +// uninstall still owns it — leaving it would keep feeding Orca their events +// after they asked it to stop, and reinstall would double-fire the event. +// +// The key run is still parsed strictly: an unrecognized line shape (a multi-line +// array or string, say) means the table's extent is unknown, and guessing it +// would splice the wrong bytes. That case fails closed. +function matchManagedHookTable( + lines: readonly string[], + index: number, + isManagedCommand: ManagedCommandMatcher +): { lineCount: number; value: string | null } | null { + if (lines[index].trim() !== HOOK_TABLE_HEADER) { + return null + } + const pairs = new Map() + let cursor = index + 1 + while (cursor < lines.length) { + const line = lines[cursor].trim() + // A blank, the next table header or a comment (the end marker included) + // ends the table's key run. + if (line === '' || line.startsWith('[') || line.startsWith('#')) { + break + } + const pair = line.match(/^([A-Za-z_][\w-]*)\s*=\s*(.*)$/) + if (!pair || pairs.has(pair[1])) { + return null + } + pairs.set(pair[1], pair[2].trim()) + cursor++ + } + // TOML lets blank lines and comments sit between keys of one table, so a gap + // is not proof the table ended. If more keys follow it, the run above covered + // only part of the table and splicing it would strand the rest without its + // header — the extent is unknown, so fail closed. + if (keysFollowGap(lines, cursor)) { + return null + } + // Raw (still-escaped) literal; createManagedCommandMatcher normalizes separators itself. + const command = readTomlString(pairs.get('command')) + if (!isManagedCommand(command)) { + return null + } + return { lineCount: cursor - index, value: readEventName(pairs.get('event')) } +} + +// True when a key line follows the gap before the next table header, meaning +// the table extends past the bounded key run above. +function keysFollowGap(lines: readonly string[], from: number): boolean { + for (let cursor = from; cursor < lines.length; cursor++) { + const line = lines[cursor].trim() + if (line === '' || line.startsWith('#')) { + continue + } + return !line.startsWith('[') + } + return false +} + +// Basic or literal TOML string, ignoring any inline comment after it. +function readTomlString(value: string | undefined): string | undefined { + return value?.match(/^"((?:[^"\\]|\\.)*)"/)?.[1] ?? value?.match(/^'([^']*)'/)?.[1] +} + +// Ownership keys on the command, so an event Orca cannot parse must still +// register: status reporting `not_installed` for a table remove() will strip is +// the exact split this recognizer exists to close. An unreadable literal falls +// back to its raw text, which matches no known event and lands status on +// `partial` rather than claiming nothing is installed. +function readEventName(value: string | undefined): string | null { + if (value === undefined) { + return null + } + return readTomlString(value) ?? value.trim() ?? null +} + +function recognizeManagedTables( + configText: string, + isManagedCommand: ManagedCommandMatcher +): RecognizedManagedTable[] { + return findRecognizedManagedTables(configText, (lines, index) => + matchManagedHookTable(lines, index, isManagedCommand) + ) +} + +// Orca owns two things here: whatever sits inside a matched marker pair, and +// every table it can positively recognize wherever that table ended up. +function findOwnedRegions( + configText: string, + isManagedCommand: ManagedCommandMatcher +): ManagedTomlRegion[] { + return [ + ...findManagedTomlBlocks(configText, MARKERS), + ...recognizeManagedTables(configText, isManagedCommand) + ] +} // TOML basic (double-quoted) string. The managed command may contain single // quotes (from POSIX quoting) but no double quotes or backslashes on the paths @@ -50,7 +150,7 @@ function tomlBasicString(value: string): string { return `"${escaped}"` } -export function buildManagedKimiHooksBlock(command: string): string { +export function buildManagedKimiHooksBlock(command: string, eol = '\n'): string { const commandLiteral = tomlBasicString(command) // Omit `matcher`: Kimi treats it as a regex (so Claude's literal "*" is // invalid) and an absent matcher already matches every tool. @@ -58,52 +158,64 @@ export function buildManagedKimiHooksBlock(command: string): string { // the normal dead-endpoint bound. const entries = KIMI_HOOK_EVENTS.map((event) => [ - `[[hooks]]`, + HOOK_TABLE_HEADER, `event = "${event}"`, `command = ${commandLiteral}`, `timeout = ${MANAGED_HOOK_TIMEOUT_SECONDS}` - ].join('\n') + ].join(eol) ) - return [BLOCK_START, ...entries, BLOCK_END].join('\n') + return [MARKERS.startMarker, ...entries, MARKERS.endMarker].join(eol) } -export function applyManagedKimiHooks(configText: string, command: string): string { - const withoutManaged = configText.replace(MANAGED_BLOCK_RE, '').replace(/\s+$/, '') - const block = buildManagedKimiHooksBlock(command) - return withoutManaged.length > 0 ? `${withoutManaged}\n\n${block}\n` : `${block}\n` +function detectEol(configText: string): string { + return configText.includes('\r\n') ? '\r\n' : '\n' } -export function removeManagedKimiHooks(configText: string): { text: string; changed: boolean } { - // Why: compare instead of MANAGED_BLOCK_RE.test() — the regex carries the `g` - // flag, so .test() advances lastIndex and would behave inconsistently across - // calls. .replace() ignores/resets lastIndex, so it is safe to reuse. - const stripped = configText.replace(MANAGED_BLOCK_RE, '') - if (stripped === configText) { +export function applyManagedKimiHooks( + configText: string, + command: string, + isManagedCommand: ManagedCommandMatcher +): string { + const eol = detectEol(configText) + const withoutManaged = stripManagedTomlRegions( + configText, + findOwnedRegions(configText, isManagedCommand) + ).text.replace(/\s+$/, '') + const block = buildManagedKimiHooksBlock(command, eol) + return withoutManaged.length > 0 + ? `${withoutManaged}${eol}${eol}${block}${eol}` + : `${block}${eol}` +} + +export function removeManagedKimiHooks( + configText: string, + isManagedCommand: ManagedCommandMatcher +): { text: string; changed: boolean } { + const stripped = stripManagedTomlRegions( + configText, + findOwnedRegions(configText, isManagedCommand) + ) + if (!stripped.changed) { return { text: configText, changed: false } } - const trimmed = stripped.replace(/\s+$/, '') - return { text: trimmed.length > 0 ? `${trimmed}\n` : '', changed: true } + const eol = detectEol(configText) + const trimmed = stripped.text.replace(/\s+$/, '') + return { text: trimmed.length > 0 ? `${trimmed}${eol}` : '', changed: true } } -// Returns the managed events present in the block whose command still matches an -// Orca-managed script (by filename, so a moved userData path is still swept). +// Events a managed table is live for, counted wherever the table sits (by script +// filename, so a moved userData path is still seen). Status must include tables +// stranded outside the markers — those still fire, so reporting them absent +// would tell the user a hook is uninstalled while Orca keeps receiving events. export function readManagedKimiHookEvents( configText: string, - isManagedCommand: (command: string | undefined) => boolean + isManagedCommand: ManagedCommandMatcher ): Set { - const present = new Set() - const match = configText.match(MANAGED_BLOCK_RE) - if (!match) { - return present - } - const blockText = match[0] - // Split on each table header and pair the `event`/`command` lines within. - for (const chunk of blockText.split('[[hooks]]').slice(1)) { - const event = chunk.match(/event\s*=\s*"([^"]+)"/)?.[1] - const command = chunk.match(/command\s*=\s*"((?:[^"\\]|\\.)*)"/)?.[1] - if (event && isManagedCommand(command)) { - present.add(event) + const events = new Set() + for (const table of recognizeManagedTables(configText, isManagedCommand)) { + if (table.value) { + events.add(table.value) } } - return present + return events } From 20ab99506541ad7bd47b317ba39aec8be263e0dc Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:22:10 -0700 Subject: [PATCH 030/191] fix(codex): reconcile marketplace and plugin tables through the config mirror (#20150) Scalar promotion omitted the marketplace and plugin tables, and the mirror rebuilt ordinary config from canonical while only trust sections survived, so a managed-account registration and refreshed provider metadata were both destroyed at the same boundary. Registrations now reconcile through one baseline-aware pass before the canonical->runtime copy: a runtime-only table is promoted, a table the canonical config removed since the last mirror stays removed, canonical wins on an identity change, marketplace refresh metadata is promoted only for a strictly newer valid timestamp with its paired revision, and a plugin `enabled` toggle promotes only when the runtime alone changed it. The settings baseline gains an optional `registrations` map at version 3. Absent means never mirrored, which makes the v2 upgrade lossless; an older build rejects version 3 and rebuilds, so downgrade is a safe degrade. Verified end to end against a real codex-cli binary, which wrote the registration into a managed home and read the promoted result back: `No marketplace plugins found.` becomes `ponytail@ponytail installed, enabled`. Fixes #10489 Fixes #11770 Co-authored-by: BsTiger <96857444+Bongseop-Kim@users.noreply.github.com> Co-authored-by: Rod Boev --- config/tsconfig.cli.json | 3 + src/main/codex/codex-config-mirror.ts | 12 +- .../codex/codex-config-settings-upsert.ts | 23 +- ...nfig-plugin-registration-promotion.test.ts | 687 ++++++++++++++++++ .../config-plugin-registration-promotion.ts | 273 +++++++ .../config-settings-baseline-upgrade.test.ts | 2 +- src/main/codex/config-settings-baseline.ts | 40 +- .../codex/config-settings-promotion.test.ts | 2 +- src/main/codex/config-settings-promotion.ts | 228 ++---- src/main/codex/config-toml-line-scan.ts | 18 + .../config-toml-plugin-registration-tables.ts | 242 ++++++ .../config-toml-promoted-setting-values.ts | 145 ++++ ...hared-state-survives-a-failed-read.test.ts | 4 +- 13 files changed, 1488 insertions(+), 191 deletions(-) create mode 100644 src/main/codex/config-plugin-registration-promotion.test.ts create mode 100644 src/main/codex/config-plugin-registration-promotion.ts create mode 100644 src/main/codex/config-toml-plugin-registration-tables.ts create mode 100644 src/main/codex/config-toml-promoted-setting-values.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 3fea6c33a57..70d52e0802c 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -74,6 +74,9 @@ "../src/main/codex/codex-wsl-hook-install-plan.ts", "../src/main/codex/config-settings-baseline.ts", "../src/main/codex/config-settings-conflict-resolution.ts", + "../src/main/codex/config-plugin-registration-promotion.ts", + "../src/main/codex/config-toml-plugin-registration-tables.ts", + "../src/main/codex/config-toml-promoted-setting-values.ts", "../src/main/codex/config-settings-promotion.ts", "../src/main/codex/config-settings-promotion-write-target.ts", "../src/main/codex/config-sync-stall.ts", diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index 0b0e66c35be..d84a5bea542 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -93,12 +93,14 @@ export function syncSystemConfigIntoManagedCodexHome( } // Why: the baseline advances only after a successful mirror; recording an // unpromoted runtime change as Orca-written would strand it forever. - snapshotCodexRuntimeSettingsBaseline( - homes.runtimeHomePath, - new Map( + snapshotCodexRuntimeSettingsBaseline(homes.runtimeHomePath, { + conflicts: new Map( [...promotionPlan.conflicts].filter(([key]) => mirrorResult.preservedConflictKeys.has(key)) - ) - ) + ), + // Why: this pass made the runtime's marketplace and plugin tables canonical, + // so a later source config that lacks one is a removal, not an addition. + mirroredRegistrations: true + }) } /** diff --git a/src/main/codex/codex-config-settings-upsert.ts b/src/main/codex/codex-config-settings-upsert.ts index 963d46d239a..55fcd4db480 100644 --- a/src/main/codex/codex-config-settings-upsert.ts +++ b/src/main/codex/codex-config-settings-upsert.ts @@ -2,7 +2,10 @@ import { createTomlLineScanState, getTomlTableHeader, isTomlStructuralLine, - updateTomlLineScanState + joinPreservingTrailingNewline, + updateTomlLineScanState, + withCrLine, + withTrailingCr } from './config-toml-line-scan' import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path' @@ -302,21 +305,3 @@ function appendNewTuiTable(lines: string[], keyRenders: string[], usesCrlf: bool const block = appendAt > 0 ? ['', '[tui]', ...keyRenders] : ['[tui]', ...keyRenders] lines.splice(appendAt, 0, ...block.map((line) => withCrLine(line, usesCrlf))) } - -function withTrailingCr(originalLine: string, rendered: string): string { - return originalLine.endsWith('\r') ? `${rendered}\r` : rendered -} - -function withCrLine(rendered: string, usesCrlf: boolean): string { - return usesCrlf ? `${rendered}\r` : rendered -} - -// Why: a missing trailing newline is restored in the file's own EOL so a -// preamble-only or table-appended rewrite matches the source's newline behavior. -function joinPreservingTrailingNewline(lines: string[], usesCrlf: boolean): string { - const result = lines.join('\n') - if (result.endsWith('\n') || result.length === 0) { - return result - } - return result.endsWith('\r') ? `${result}\n` : `${result}${usesCrlf ? '\r\n' : '\n'}` -} diff --git a/src/main/codex/config-plugin-registration-promotion.test.ts b/src/main/codex/config-plugin-registration-promotion.test.ts new file mode 100644 index 00000000000..53cc9873f01 --- /dev/null +++ b/src/main/codex/config-plugin-registration-promotion.test.ts @@ -0,0 +1,687 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import type * as Os from 'node:os' +import { join } from 'node:path' +import type * as CodexFsUtils from '../codex-accounts/fs-utils' + +const { homedirMock, registrationTestState } = vi.hoisted(() => ({ + homedirMock: vi.fn<() => string>(), + registrationTestState: { failAtomicWrite: false } +})) + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + homedir: homedirMock + } +}) + +vi.mock('../codex-accounts/fs-utils', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + writeFileAtomically: (...args: Parameters) => { + if (registrationTestState.failAtomicWrite) { + throw new Error('injected atomic write failure') + } + return actual.writeFileAtomically(...args) + } + } +}) + +import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' +import { + getCodexRegistrationKey, + readCodexRegistrationEntries +} from './config-toml-plugin-registration-tables' + +// The tables Codex 0.145 writes into CODEX_HOME for `plugin marketplace add` +// followed by `plugin add`, including the quoted `@` key. +const MARKETPLACE_TABLE = [ + '[marketplaces.ponytail]', + 'source_type = "git"', + 'source = "https://github.com/DietrichGebert/ponytail.git"', + 'ref_name = "main"', + 'last_updated = "2026-01-05T10:00:00Z"', + 'last_revision = "aaaa111"' +].join('\n') + +const PLUGIN_TABLE = ['[plugins."ponytail@ponytail"]', 'enabled = true', 'version = "4.8.4"'].join( + '\n' +) + +const MARKETPLACE_KEY = getCodexRegistrationKey('marketplaces', 'ponytail') +const PLUGIN_KEY = getCodexRegistrationKey('plugins', 'ponytail@ponytail') + +let tmpHome: string +let userDataDir: string +let previousUserDataPath: string | undefined + +beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'orca-codex-registration-home-')) + userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-registration-user-data-')) + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir + homedirMock.mockReturnValue(tmpHome) + registrationTestState.failAtomicWrite = false + // Why: promotion writes into homedir()/.codex — if the mock ever fails to + // intercept, these tests would rewrite the developer's real Codex config. + if (homedir() !== tmpHome) { + throw new Error('node:os homedir mock is not active; refusing to touch the real ~/.codex') + } +}) + +afterEach(() => { + rmSync(tmpHome, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true }) + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + vi.clearAllMocks() +}) + +function systemHomeDir(): string { + return join(tmpHome, '.codex') +} + +function runtimeHomeDir(): string { + return join(userDataDir, 'codex-runtime-home', 'home') +} + +function runtimeConfigPath(): string { + return join(runtimeHomeDir(), 'config.toml') +} + +function baselinePath(homePath = runtimeHomeDir()): string { + return join(homePath, '.orca-config-settings-baseline.json') +} + +function writeSystemConfig(content: string, homePath = systemHomeDir()): void { + mkdirSync(homePath, { recursive: true }) + writeFileSync(join(homePath, 'config.toml'), content, 'utf-8') +} + +function readSystemConfig(homePath = systemHomeDir()): string { + return readFileSync(join(homePath, 'config.toml'), 'utf-8') +} + +function readRuntimeConfig(homePath = runtimeHomeDir()): string { + return readFileSync(join(homePath, 'config.toml'), 'utf-8') +} + +/** Mimics Codex appending a registration table to the CODEX_HOME it was launched with. */ +function simulateCodexRegistrationWrite(block: string, homePath = runtimeHomeDir()): void { + mkdirSync(homePath, { recursive: true }) + const configPath = join(homePath, 'config.toml') + const existing = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : '' + writeFileSync(configPath, `${existing.trimEnd()}\n\n${block}\n`, 'utf-8') +} + +/** Mimics Codex rewriting a value inside a registration table it already owns. */ +function simulateCodexRegistrationFieldWrite( + field: string, + rawValue: string, + homePath = runtimeHomeDir() +): void { + const configPath = join(homePath, 'config.toml') + const pattern = new RegExp(`^${field}[ \\t]*=.*$`, 'm') + const existing = readFileSync(configPath, 'utf-8') + writeFileSync(configPath, existing.replace(pattern, `${field} = ${rawValue}`), 'utf-8') +} + +function mirrorTwice(): void { + syncSystemConfigIntoManagedCodexHome() + syncSystemConfigIntoManagedCodexHome() +} + +describe('codex plugin registration survives the managed-home mirror', () => { + it('keeps a marketplace and a quoted plugin registered from the managed home across two mirrors', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + + simulateCodexRegistrationWrite(`${MARKETPLACE_TABLE}\n\n${PLUGIN_TABLE}`) + mirrorTwice() + + const runtime = readRuntimeConfig() + expect(runtime).toContain('[marketplaces.ponytail]') + expect(runtime).toContain('[plugins."ponytail@ponytail"]') + expect(runtime).toContain('enabled = true') + expect(readSystemConfig()).toContain('[marketplaces.ponytail]') + expect(readSystemConfig()).toContain('[plugins."ponytail@ponytail"]') + }) + + it('reaches a byte-stable steady state, so a repeated mirror is a no-op', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + simulateCodexRegistrationWrite(`${MARKETPLACE_TABLE}\n\n${PLUGIN_TABLE}`) + mirrorTwice() + + const settledRuntime = readRuntimeConfig() + const settledSystem = readSystemConfig() + syncSystemConfigIntoManagedCodexHome() + + expect(readRuntimeConfig()).toBe(settledRuntime) + expect(readSystemConfig()).toBe(settledSystem) + }) + + // Why: #11770's metadata-only policy deliberately skips runtime-only + // marketplaces, so it would drop this one even though its timestamps are fine. + it('promotes a runtime-only marketplace that a metadata-only policy would drop', () => { + writeSystemConfig('model = "gpt-5"\n\n[marketplaces.other]\nsource = "other"\n') + syncSystemConfigIntoManagedCodexHome() + + simulateCodexRegistrationWrite(MARKETPLACE_TABLE) + mirrorTwice() + + expect(readSystemConfig()).toContain('[marketplaces.ponytail]') + expect(readRuntimeConfig()).toContain('[marketplaces.ponytail]') + expect(readRuntimeConfig()).toContain('[marketplaces.other]') + }) + + it('does not treat a cached marketplace clone or plugin directory as a registration', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + mkdirSync(join(runtimeHomeDir(), '.tmp', 'marketplaces', 'ponytail'), { + recursive: true + }) + mkdirSync(join(runtimeHomeDir(), 'plugins', 'ponytail'), { + recursive: true + }) + + mirrorTwice() + + expect(readSystemConfig()).not.toContain('marketplaces') + expect(readRuntimeConfig()).not.toContain('marketplaces') + }) + + it('honors a canonical removal instead of resurrecting the registration', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + simulateCodexRegistrationWrite(`${MARKETPLACE_TABLE}\n\n${PLUGIN_TABLE}`) + mirrorTwice() + expect(readSystemConfig()).toContain('[marketplaces.ponytail]') + + // The user edits ~/.codex outside Orca and deletes both registrations. + writeSystemConfig('model = "gpt-5"\n') + mirrorTwice() + + expect(readSystemConfig()).toBe('model = "gpt-5"\n') + expect(readRuntimeConfig()).not.toContain('marketplaces.ponytail') + expect(readRuntimeConfig()).not.toContain('ponytail@ponytail') + }) + + it('re-mirrors a canonical registration the managed home deleted rather than propagating the delete', () => { + writeSystemConfig(`model = "gpt-5"\n\n${MARKETPLACE_TABLE}\n\n${PLUGIN_TABLE}\n`) + syncSystemConfigIntoManagedCodexHome() + + writeFileSync(runtimeConfigPath(), 'model = "gpt-5"\n', 'utf-8') + mirrorTwice() + + expect(readSystemConfig()).toContain('[plugins."ponytail@ponytail"]') + expect(readRuntimeConfig()).toContain('[plugins."ponytail@ponytail"]') + }) + + it('promotes an in-Codex plugin disable and keeps it disabled', () => { + writeSystemConfig(`model = "gpt-5"\n\n${MARKETPLACE_TABLE}\n\n${PLUGIN_TABLE}\n`) + syncSystemConfigIntoManagedCodexHome() + + simulateCodexRegistrationFieldWrite('enabled', 'false') + mirrorTwice() + + expect(readSystemConfig()).toContain('enabled = false') + expect(readRuntimeConfig()).toContain('enabled = false') + }) + + it('lets the canonical config win when both sides changed plugin enablement', () => { + writeSystemConfig(`model = "gpt-5"\n\n${MARKETPLACE_TABLE}\n\n${PLUGIN_TABLE}\n`) + syncSystemConfigIntoManagedCodexHome() + + simulateCodexRegistrationFieldWrite('enabled', 'false') + writeSystemConfig( + `model = "gpt-5"\n\n${MARKETPLACE_TABLE}\n\n${PLUGIN_TABLE.replace('enabled = true', 'enabled = false\ndisabled_reason = "canonical"')}\n` + ) + mirrorTwice() + + expect(readSystemConfig()).toContain('disabled_reason = "canonical"') + expect(readRuntimeConfig()).toContain('disabled_reason = "canonical"') + }) + + // Why: `enabled` is three-valued in practice — true, false, and absent — so + // "both sides changed" is only reachable when one of them adds the key. + it('lets the canonical config win when enablement changed to a different value on each side', () => { + writeSystemConfig( + `model = "gpt-5"\n\n${MARKETPLACE_TABLE}\n\n${PLUGIN_TABLE.replace('enabled = true\n', '')}\n` + ) + syncSystemConfigIntoManagedCodexHome() + expect(readFileSync(baselinePath(), 'utf-8')).not.toContain('"enabled"') + + writeFileSync( + runtimeConfigPath(), + readRuntimeConfig().replace('version = "4.8.4"', 'version = "4.8.4"\nenabled = false'), + 'utf-8' + ) + writeSystemConfig( + readSystemConfig().replace('version = "4.8.4"', 'version = "4.8.4"\nenabled = true') + ) + mirrorTwice() + + expect(readSystemConfig()).toContain('enabled = true') + expect(readSystemConfig()).not.toContain('enabled = false') + expect(readRuntimeConfig()).toContain('enabled = true') + }) + + it('lets the canonical config win when the registration has no mirrored ancestor', () => { + writeSystemConfig(`model = "gpt-5"\n\n${MARKETPLACE_TABLE}\n\n${PLUGIN_TABLE}\n`) + syncSystemConfigIntoManagedCodexHome() + + simulateCodexRegistrationFieldWrite('enabled', 'false') + // A v2 baseline, or one rebuilt after corruption, tracks no registration at all. + const baseline = JSON.parse(readFileSync(baselinePath(), 'utf-8')) + delete baseline.registrations + writeFileSync(baselinePath(), `${JSON.stringify(baseline, null, 2)}\n`, 'utf-8') + mirrorTwice() + + expect(readSystemConfig()).toContain('enabled = true') + expect(readRuntimeConfig()).toContain('enabled = true') + }) + + it('keeps an unrelated canonical edit authoritative while a registration is promoted', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + + simulateCodexRegistrationWrite(MARKETPLACE_TABLE) + writeSystemConfig('model = "gpt-5-canonical"\n\n[features]\nhooks = true\n') + mirrorTwice() + + expect(readRuntimeConfig()).toContain('model = "gpt-5-canonical"') + expect(readRuntimeConfig()).toContain('hooks = true') + expect(readRuntimeConfig()).toContain('[marketplaces.ponytail]') + }) +}) + +describe('codex marketplace refresh metadata promotion', () => { + function seedMirroredMarketplace(): void { + writeSystemConfig( + `# user comment\nmodel = "gpt-5"\n\n${MARKETPLACE_TABLE}\n\n[mcp_servers.docs]\ncommand = "docs"\n` + ) + syncSystemConfigIntoManagedCodexHome() + } + + it('promotes a newer last_updated with its paired last_revision', () => { + seedMirroredMarketplace() + + simulateCodexRegistrationFieldWrite('last_updated', '"2026-02-01T09:30:00Z"') + simulateCodexRegistrationFieldWrite('last_revision', '"bbbb222"') + syncSystemConfigIntoManagedCodexHome() + + const system = readSystemConfig() + expect(system).toContain('last_updated = "2026-02-01T09:30:00Z"') + expect(system).toContain('last_revision = "bbbb222"') + // Every other field, the comment, and unrelated tables are untouched. + expect(system).toContain('# user comment') + expect(system).toContain('ref_name = "main"') + expect(system).toContain('[mcp_servers.docs]') + expect(system).toContain('source = "https://github.com/DietrichGebert/ponytail.git"') + }) + + it('does not repeat the refresh on the next synchronization', () => { + seedMirroredMarketplace() + simulateCodexRegistrationFieldWrite('last_updated', '"2026-02-01T09:30:00Z"') + simulateCodexRegistrationFieldWrite('last_revision', '"bbbb222"') + mirrorTwice() + + const settledSystem = readSystemConfig() + const settledRuntime = readRuntimeConfig() + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe(settledSystem) + expect(readRuntimeConfig()).toBe(settledRuntime) + }) + + it('skips an older managed timestamp', () => { + seedMirroredMarketplace() + + simulateCodexRegistrationFieldWrite('last_updated', '"2020-01-01T00:00:00Z"') + simulateCodexRegistrationFieldWrite('last_revision', '"stale99"') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toContain('last_updated = "2026-01-05T10:00:00Z"') + expect(readSystemConfig()).toContain('last_revision = "aaaa111"') + }) + + it('skips a malformed managed timestamp', () => { + seedMirroredMarketplace() + + simulateCodexRegistrationFieldWrite('last_updated', '"not-a-timestamp"') + simulateCodexRegistrationFieldWrite('last_revision', '"cccc333"') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toContain('last_updated = "2026-01-05T10:00:00Z"') + expect(readSystemConfig()).toContain('last_revision = "aaaa111"') + }) + + // Why: Date.parse rolls an impossible day forward, which would read as newer. + it('rejects an impossible calendar date instead of rolling it forward', () => { + seedMirroredMarketplace() + + simulateCodexRegistrationFieldWrite('last_updated', '"2026-02-30T00:00:00Z"') + simulateCodexRegistrationFieldWrite('last_revision', '"rolled99"') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toContain('last_updated = "2026-01-05T10:00:00Z"') + expect(readSystemConfig()).toContain('last_revision = "aaaa111"') + }) + + it('skips the refresh when the runtime cannot supply the paired last_revision', () => { + seedMirroredMarketplace() + + writeFileSync( + runtimeConfigPath(), + readRuntimeConfig() + .replace('last_updated = "2026-01-05T10:00:00Z"', 'last_updated = "2026-09-01T00:00:00Z"') + .replace('last_revision = "aaaa111"\n', ''), + 'utf-8' + ) + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toContain('last_updated = "2026-01-05T10:00:00Z"') + expect(readSystemConfig()).toContain('last_revision = "aaaa111"') + }) + + it('leaves the canonical config in control when the marketplace source changed', () => { + seedMirroredMarketplace() + + simulateCodexRegistrationFieldWrite('last_updated', '"2026-02-01T09:30:00Z"') + simulateCodexRegistrationFieldWrite('last_revision', '"bbbb222"') + writeSystemConfig( + readSystemConfig().replace( + 'source = "https://github.com/DietrichGebert/ponytail.git"', + 'source = "https://github.com/DietrichGebert/ponytail-fork.git"' + ) + ) + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toContain('last_updated = "2026-01-05T10:00:00Z"') + expect(readSystemConfig()).toContain('ponytail-fork.git') + expect(readRuntimeConfig()).toContain('ponytail-fork.git') + }) + + it('refreshes several marketplaces independently', () => { + writeSystemConfig( + [ + 'model = "gpt-5"', + '', + MARKETPLACE_TABLE, + '', + '[marketplaces.other]', + 'source_type = "git"', + 'source = "https://example.test/other.git"', + 'last_updated = "2026-01-05T10:00:00Z"', + 'last_revision = "other111"', + '' + ].join('\n') + ) + syncSystemConfigIntoManagedCodexHome() + + const runtime = readRuntimeConfig() + .replace('last_updated = "2026-01-05T10:00:00Z"', 'last_updated = "2026-03-01T00:00:00Z"') + .replace('last_revision = "aaaa111"', 'last_revision = "fresh11"') + writeFileSync(runtimeConfigPath(), runtime, 'utf-8') + syncSystemConfigIntoManagedCodexHome() + + const system = readSystemConfig() + expect(system).toContain('last_updated = "2026-03-01T00:00:00Z"') + expect(system).toContain('last_revision = "fresh11"') + expect(system).toContain('last_revision = "other111"') + expect(system).toContain('last_updated = "2026-01-05T10:00:00Z"') + }) + + it('promotes only last_updated and last_revision, never another refreshed field', () => { + writeSystemConfig(`model = "gpt-5"\n\n${MARKETPLACE_TABLE}\ndescription = "canonical"\n`) + syncSystemConfigIntoManagedCodexHome() + + simulateCodexRegistrationFieldWrite('last_updated', '"2026-02-01T09:30:00Z"') + simulateCodexRegistrationFieldWrite('description', '"runtime"') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toContain('last_updated = "2026-02-01T09:30:00Z"') + expect(readSystemConfig()).toContain('description = "canonical"') + expect(readRuntimeConfig()).toContain('description = "canonical"') + }) + + it('seeds an absent canonical config from the runtime without duplicating its tables', () => { + writeSystemConfig('[features]\nhooks = true\n') + syncSystemConfigIntoManagedCodexHome() + + rmSync(join(systemHomeDir(), 'config.toml')) + simulateCodexRegistrationWrite(MARKETPLACE_TABLE) + writeFileSync(runtimeConfigPath(), `model = "o4"\n${readRuntimeConfig()}`, 'utf-8') + mirrorTwice() + + const system = readSystemConfig() + expect(system).toContain('model = "o4"') + expect(system.match(/\[marketplaces\.ponytail\]/g)).toHaveLength(1) + expect(readRuntimeConfig().match(/\[marketplaces\.ponytail\]/g)).toHaveLength(1) + }) + + it('preserves the managed config and its baseline when the promotion write fails', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + simulateCodexRegistrationWrite(MARKETPLACE_TABLE) + const runtimeBeforeFailure = readRuntimeConfig() + const baselineBeforeFailure = readFileSync(baselinePath(), 'utf-8') + + registrationTestState.failAtomicWrite = true + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "gpt-5"\n') + expect(readRuntimeConfig()).toBe(runtimeBeforeFailure) + expect(readFileSync(baselinePath(), 'utf-8')).toBe(baselineBeforeFailure) + + registrationTestState.failAtomicWrite = false + mirrorTwice() + + expect(readSystemConfig()).toContain('[marketplaces.ponytail]') + expect(readRuntimeConfig()).toContain('[marketplaces.ponytail]') + }) + + it('keeps CRLF line endings when it rewrites refresh metadata', () => { + writeSystemConfig(`model = "gpt-5"\r\n\r\n${MARKETPLACE_TABLE.replaceAll('\n', '\r\n')}\r\n`) + syncSystemConfigIntoManagedCodexHome() + + simulateCodexRegistrationFieldWrite('last_updated', '"2026-02-01T09:30:00Z"') + syncSystemConfigIntoManagedCodexHome() + + const system = readSystemConfig() + expect(system).toContain('last_updated = "2026-02-01T09:30:00Z"\r\n') + expect(system).not.toMatch(/[^\r]\n/) + }) +}) + +describe('codex registration reconciliation isolates accounts and source homes', () => { + function accountHome(name: string): string { + return join(userDataDir, 'codex-accounts', name) + } + + it('promotes each managed account registration into the shared source without crossing baselines', () => { + writeSystemConfig('model = "gpt-5"\n') + const accounts = [accountHome('a'), accountHome('b')] + for (const runtimeHomePath of accounts) { + syncSystemConfigIntoManagedCodexHome({ + runtimeHomePath, + systemHomePath: systemHomeDir() + }) + } + + simulateCodexRegistrationWrite(MARKETPLACE_TABLE, accounts[0]!) + simulateCodexRegistrationWrite( + '[marketplaces.beta]\nsource_type = "git"\nsource = "https://example.test/beta.git"', + accounts[1]! + ) + for (const runtimeHomePath of [...accounts, ...accounts]) { + syncSystemConfigIntoManagedCodexHome({ + runtimeHomePath, + systemHomePath: systemHomeDir() + }) + } + + expect(readSystemConfig()).toContain('[marketplaces.ponytail]') + expect(readSystemConfig()).toContain('[marketplaces.beta]') + for (const runtimeHomePath of accounts) { + expect(readRuntimeConfig(runtimeHomePath)).toContain('[marketplaces.ponytail]') + expect(readRuntimeConfig(runtimeHomePath)).toContain('[marketplaces.beta]') + expect(existsSync(baselinePath(runtimeHomePath))).toBe(true) + } + expect(readFileSync(baselinePath(accounts[0]!), 'utf-8')).toContain('marketplaces:ponytail') + }) + + it('promotes a WSL-lane registration into that distro source home, never the host one', () => { + const wslSourceHome = join(userDataDir, 'wsl-home', '.codex') + const wslRuntimeHome = accountHome('wsl') + writeSystemConfig('model = "host"\n') + writeSystemConfig('model = "wsl"\n', wslSourceHome) + syncSystemConfigIntoManagedCodexHome({ + runtimeHomePath: wslRuntimeHome, + systemHomePath: wslSourceHome + }) + + simulateCodexRegistrationWrite(MARKETPLACE_TABLE, wslRuntimeHome) + for (let pass = 0; pass < 2; pass += 1) { + syncSystemConfigIntoManagedCodexHome({ + runtimeHomePath: wslRuntimeHome, + systemHomePath: wslSourceHome + }) + } + + expect(readSystemConfig(wslSourceHome)).toContain('[marketplaces.ponytail]') + expect(readRuntimeConfig(wslRuntimeHome)).toContain('[marketplaces.ponytail]') + expect(readSystemConfig()).toBe('model = "host"\n') + }) + + it('heals a registration held only by a runtime home still on the v2 baseline schema', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + const { settings } = JSON.parse(readFileSync(baselinePath(), 'utf-8')) + writeFileSync(baselinePath(), `${JSON.stringify({ version: 2, settings }, null, 2)}\n`, 'utf-8') + + simulateCodexRegistrationWrite(`${MARKETPLACE_TABLE}\n\n${PLUGIN_TABLE}`) + mirrorTwice() + + expect(readSystemConfig()).toContain('[marketplaces.ponytail]') + expect(readSystemConfig()).toContain('[plugins."ponytail@ponytail"]') + expect(readRuntimeConfig()).toContain('[plugins."ponytail@ponytail"]') + expect(JSON.parse(readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ + version: 3, + registrations: { [MARKETPLACE_KEY]: {}, [PLUGIN_KEY]: { enabled: 'true' } } + }) + }) + + // Why: the baseline is the only record of what a mirror already made canonical, + // so losing it re-reads a pending canonical removal as a runtime-only addition. + it('re-promotes a canonically removed registration when the baseline is lost first', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + simulateCodexRegistrationWrite(MARKETPLACE_TABLE) + mirrorTwice() + + writeSystemConfig('model = "gpt-5"\n') + rmSync(baselinePath()) + mirrorTwice() + expect(readSystemConfig()).toContain('[marketplaces.ponytail]') + + // Recoverable: with the rebuilt baseline in place, removing it again sticks. + writeSystemConfig('model = "gpt-5"\n') + mirrorTwice() + expect(readSystemConfig()).toBe('model = "gpt-5"\n') + }) + + it('leaves a settled config byte-identical when the baseline is lost with no removal pending', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + simulateCodexRegistrationWrite(MARKETPLACE_TABLE) + mirrorTwice() + const settledSystem = readSystemConfig() + const settledRuntime = readRuntimeConfig() + + rmSync(baselinePath()) + mirrorTwice() + + expect(readSystemConfig()).toBe(settledSystem) + expect(readRuntimeConfig()).toBe(settledRuntime) + }) + + it('treats a runtime home seeded without a baseline as holding additions, not removals', () => { + mkdirSync(runtimeHomeDir(), { recursive: true }) + writeFileSync(runtimeConfigPath(), `model = "gpt-5"\n\n${MARKETPLACE_TABLE}\n`, 'utf-8') + writeSystemConfig('model = "gpt-5"\n') + + mirrorTwice() + + expect(readSystemConfig()).toContain('[marketplaces.ponytail]') + expect(readRuntimeConfig()).toContain('[marketplaces.ponytail]') + }) +}) + +describe('codex registration table identity', () => { + it('reads basic-quoted and literal-quoted table keys as the same registration', () => { + const basic = readCodexRegistrationEntries('[plugins."a@b"]\nenabled = true\n') + const literal = readCodexRegistrationEntries("[plugins.'a@b']\nenabled = false\n") + + expect([...basic.keys()]).toEqual([getCodexRegistrationKey('plugins', 'a@b')]) + expect([...literal.keys()]).toEqual([...basic.keys()]) + }) + + it('captures a multiline array field as one value and marks it unwritable', () => { + const entries = readCodexRegistrationEntries( + '[marketplaces.m]\nsparse_paths = [\n "a",\n "b"\n]\nsource = "s"\n' + ) + const entry = entries.get(getCodexRegistrationKey('marketplaces', 'm')) + + expect(entry?.fields.get('sparse_paths')?.multiline).toBe(true) + expect(entry?.fields.get('sparse_paths')?.raw).toContain('"b"') + expect(entry?.fields.get('source')?.raw).toBe('"s"') + }) + + it('attributes a subtable to its owning registration', () => { + const entries = readCodexRegistrationEntries( + '[marketplaces.m]\nsource = "s"\n\n[marketplaces.m.auth]\ntoken = "t"\n' + ) + const entry = entries.get(getCodexRegistrationKey('marketplaces', 'm')) + + expect(entries.size).toBe(1) + expect(entry?.block).toContain('[marketplaces.m.auth]') + expect(entry?.fields.has('token')).toBe(false) + }) + + it("leaves the next table's leading comment out of the captured block", () => { + const entries = readCodexRegistrationEntries( + '[marketplaces.m]\nsource = "s" # inline\n# keeps this one\nkey = 1\n\n# belongs to mcp_servers\n[mcp_servers.docs]\ncommand = "d"\n' + ) + const block = entries.get(getCodexRegistrationKey('marketplaces', 'm'))?.block + + expect(block).toContain('# keeps this one') + expect(block).toContain('source = "s" # inline') + expect(block).not.toContain('belongs to mcp_servers') + }) + + it('keeps a hash inside a multiline value out of the trailing-comment trim', () => { + const entries = readCodexRegistrationEntries( + '[marketplaces.m]\nnotes = """\n# not a comment"""\n\n[mcp_servers.docs]\ncommand = "d"\n' + ) + const block = entries.get(getCodexRegistrationKey('marketplaces', 'm'))?.block + + expect(block).toContain('# not a comment"""') + }) + + it('ignores an array-of-tables header under a registration root', () => { + expect(readCodexRegistrationEntries('[[marketplaces.m]]\nsource = "s"\n').size).toBe(0) + }) + + it('keys marketplace and plugin registrations in separate namespaces', () => { + expect(MARKETPLACE_KEY).not.toBe(PLUGIN_KEY) + }) +}) diff --git a/src/main/codex/config-plugin-registration-promotion.ts b/src/main/codex/config-plugin-registration-promotion.ts new file mode 100644 index 00000000000..b23df7844ac --- /dev/null +++ b/src/main/codex/config-plugin-registration-promotion.ts @@ -0,0 +1,273 @@ +import { joinPreservingTrailingNewline, withCrLine, withTrailingCr } from './config-toml-line-scan' +import { + normalizeCodexRegistrationValue, + parseCodexRegistrationTimestamp, + readCodexRegistrationEntries, + type CodexRegistrationEntry, + type CodexRegistrationRoot +} from './config-toml-plugin-registration-tables' + +/** + * Reconciles Codex plugin registration tables across the destructive managed-home + * mirror. Scalar promotion covers settings the TUI writes; these are whole tables + * Codex writes when a marketplace or plugin is registered or refreshed, and they + * need two different conflict rules inside one baseline-aware boundary: + * + * | Runtime vs canonical vs baseline | Policy | + * | ---------------------------------------------------- | ---------------------------------------------- | + * | in runtime, not canonical, not in baseline | runtime-only addition -> promote the table | + * | in runtime, not canonical, in baseline | canonical removal -> honor it, promote nothing | + * | in both, identity fields differ | canonical source change -> canonical wins | + * | in both, marketplace, newer valid `last_updated` | promote `last_updated` + paired `last_revision` | + * | in both, marketplace, stale/malformed `last_updated` | skip | + * | in both, plugin, `enabled` changed only in runtime | promote `enabled` | + * | in both, plugin, `enabled` changed on both sides | canonical wins | + * | anything else | canonical wins; the mirror overwrites it | + * + * Presence stays canonical-owned once mirrored, so a runtime-side removal is + * re-mirrored rather than propagated; `enabled` is the durable runtime lever. + */ + +// Why: a marketplace whose source moved is a different marketplace, so its +// refresh metadata describes a clone the canonical config no longer points at. +const MARKETPLACE_IDENTITY_FIELDS = ['source_type', 'source', 'ref_name', 'sparse_paths'] as const +const PLUGIN_IDENTITY_FIELDS = ['marketplace', 'source'] as const + +const MARKETPLACE_METADATA_FIELDS = ['last_updated', 'last_revision'] as const + +// Why: the only registration field the baseline needs a three-way ancestor for. +const PLUGIN_BASELINE_FIELDS = ['enabled'] as const + +export type CodexRegistrationPromotion = + | { kind: 'append'; key: string; block: string } + | { kind: 'field'; key: string; field: string; raw: string | null } + +export type CodexRegistrationBaseline = ReadonlyMap> + +export function planCodexRegistrationPromotion( + runtimeConfig: string, + systemConfig: string, + mirroredRegistrations: CodexRegistrationBaseline +): CodexRegistrationPromotion[] { + const runtimeEntries = readCodexRegistrationEntries(runtimeConfig) + const systemEntries = readCodexRegistrationEntries(systemConfig) + // Why: a marketplace must be declared before the plugins that name it, so the + // canonical file stays readable after an install promotes both at once. + const appends: Record = { + marketplaces: [], + plugins: [] + } + const fields: CodexRegistrationPromotion[] = [] + for (const entry of runtimeEntries.values()) { + const systemEntry = systemEntries.get(entry.key) + if (!systemEntry) { + if (!mirroredRegistrations.has(entry.key)) { + appends[entry.root].push({ kind: 'append', key: entry.key, block: entry.block }) + } + continue + } + if (!hasMatchingRegistrationIdentity(entry, systemEntry)) { + continue + } + fields.push( + ...(entry.root === 'marketplaces' + ? planMarketplaceRefreshPromotion(entry, systemEntry) + : planPluginEnablementPromotion(entry, systemEntry, mirroredRegistrations.get(entry.key))) + ) + } + return [...appends.marketplaces, ...appends.plugins, ...fields] +} + +export function applyCodexRegistrationPromotions( + content: string, + promotions: readonly CodexRegistrationPromotion[] +): string { + if (promotions.length === 0) { + return content + } + const usesCrlf = content.includes('\r\n') + const lines = content.split('\n') + const entries = readCodexRegistrationEntries(content) + const edits: { index: number; deleteCount: number; inserts: string[] }[] = [] + for (const promotion of promotions) { + if (promotion.kind !== 'field') { + continue + } + const entry = entries.get(promotion.key) + const existing = entry?.fields.get(promotion.field) + if (!entry || entry.ownerStart === -1 || existing?.multiline) { + continue + } + const rendered = `${promotion.field} = ${promotion.raw}` + if (existing) { + edits.push({ + index: existing.lineIndex, + deleteCount: 1, + inserts: + promotion.raw === null ? [] : [withTrailingCr(lines[existing.lineIndex] ?? '', rendered)] + }) + continue + } + if (promotion.raw === null) { + continue + } + edits.push({ + index: findTableBodyInsertIndex(lines, entry), + deleteCount: 0, + inserts: [withCrLine(rendered, usesCrlf)] + }) + } + // Why: splice from the bottom so an earlier edit never shifts an index a later + // one was measured against. + for (const edit of edits.sort((left, right) => right.index - left.index)) { + lines.splice(edit.index, edit.deleteCount, ...edit.inserts) + } + let result = joinPreservingTrailingNewline(lines, usesCrlf) + for (const promotion of promotions) { + if (promotion.kind === 'append') { + result = appendRegistrationBlock(result, promotion.block, usesCrlf) + } + } + return result +} + +/** The registration state a successful mirror made canonical, for the next pass's three-way. */ +export function readCodexRegistrationBaseline( + config: string +): Map> { + const baseline = new Map>() + for (const entry of readCodexRegistrationEntries(config).values()) { + const tracked = new Map() + for (const field of getBaselineFields(entry.root)) { + const value = entry.fields.get(field) + if (value && !value.multiline) { + tracked.set(field, normalizeCodexRegistrationValue(value.raw)) + } + } + baseline.set(entry.key, tracked) + } + return baseline +} + +function getBaselineFields(root: CodexRegistrationRoot): readonly string[] { + return root === 'plugins' ? PLUGIN_BASELINE_FIELDS : [] +} + +function hasMatchingRegistrationIdentity( + runtimeEntry: CodexRegistrationEntry, + systemEntry: CodexRegistrationEntry +): boolean { + const identityFields = + runtimeEntry.root === 'marketplaces' ? MARKETPLACE_IDENTITY_FIELDS : PLUGIN_IDENTITY_FIELDS + return identityFields.every( + (field) => readNormalizedField(runtimeEntry, field) === readNormalizedField(systemEntry, field) + ) +} + +function planMarketplaceRefreshPromotion( + runtimeEntry: CodexRegistrationEntry, + systemEntry: CodexRegistrationEntry +): CodexRegistrationPromotion[] { + const runtimeUpdated = runtimeEntry.fields.get('last_updated') + const runtimeRevision = runtimeEntry.fields.get('last_revision') + if (!runtimeUpdated || runtimeUpdated.multiline || runtimeRevision?.multiline) { + return [] + } + // Why: promoting the timestamp alone would clear a canonical revision the runtime + // cannot replace, publishing exactly the mismatched pair the pairing rule prevents. + if (!runtimeRevision && systemEntry.fields.has('last_revision')) { + return [] + } + const runtimeTimestamp = parseCodexRegistrationTimestamp(runtimeUpdated.raw) + if (runtimeTimestamp === null) { + return [] + } + const systemUpdated = systemEntry.fields.get('last_updated') + const systemTimestamp = + systemUpdated && !systemUpdated.multiline + ? parseCodexRegistrationTimestamp(systemUpdated.raw) + : null + if (systemTimestamp !== null && runtimeTimestamp <= systemTimestamp) { + return [] + } + // Why: the revision names the commit the timestamp refreshed to, so promoting + // one without the other would publish a pair that never existed together. + return MARKETPLACE_METADATA_FIELDS.flatMap((field) => + buildFieldPromotion(runtimeEntry, systemEntry, field) + ) +} + +function planPluginEnablementPromotion( + runtimeEntry: CodexRegistrationEntry, + systemEntry: CodexRegistrationEntry, + mirrored: ReadonlyMap | undefined +): CodexRegistrationPromotion[] { + const runtimeValue = readNormalizedField(runtimeEntry, 'enabled') + const systemValue = readNormalizedField(systemEntry, 'enabled') + // Why: without a mirrored ancestor an in-Codex toggle is indistinguishable from + // a stale runtime copy, so the canonical config stays source of truth. + const mirroredValue = mirrored?.get('enabled') ?? null + if ( + !mirrored || + runtimeValue === systemValue || + runtimeValue === mirroredValue || + systemValue !== mirroredValue + ) { + return [] + } + return buildFieldPromotion(runtimeEntry, systemEntry, 'enabled') +} + +function buildFieldPromotion( + runtimeEntry: CodexRegistrationEntry, + systemEntry: CodexRegistrationEntry, + field: string +): CodexRegistrationPromotion[] { + if (readNormalizedField(runtimeEntry, field) === readNormalizedField(systemEntry, field)) { + return [] + } + const runtimeField = runtimeEntry.fields.get(field) + if (runtimeField?.multiline) { + return [] + } + return [ + { + kind: 'field', + key: runtimeEntry.key, + field, + raw: runtimeField?.raw ?? null + } + ] +} + +function readNormalizedField(entry: CodexRegistrationEntry, field: string): string | null { + const value = entry.fields.get(field) + return value ? normalizeCodexRegistrationValue(value.raw) : null +} + +// Why: a key added after a `[root.name.*]` subtable opens would land in the wrong +// table, so absent fields go at the owner body's end, before its trailing blanks. +function findTableBodyInsertIndex(lines: string[], entry: CodexRegistrationEntry): number { + let insertAt = entry.ownerEnd + while (insertAt > entry.ownerStart + 1 && (lines[insertAt - 1] ?? '').trim() === '') { + insertAt -= 1 + } + return insertAt +} + +function appendRegistrationBlock(content: string, block: string, usesCrlf: boolean): string { + const eol = usesCrlf ? '\r\n' : '\n' + const rendered = block + .split('\n') + .map((line) => withCrLine(line.replace(/\r$/, ''), usesCrlf)) + .join('\n') + if (content.trim() === '') { + return `${rendered}${eol}` + } + const separator = content.endsWith(`${eol}${eol}`) + ? '' + : content.endsWith(eol) + ? eol + : `${eol}${eol}` + return `${content}${separator}${rendered}${eol}` +} diff --git a/src/main/codex/config-settings-baseline-upgrade.test.ts b/src/main/codex/config-settings-baseline-upgrade.test.ts index 443f5f429ec..1f49c2bafee 100644 --- a/src/main/codex/config-settings-baseline-upgrade.test.ts +++ b/src/main/codex/config-settings-baseline-upgrade.test.ts @@ -85,7 +85,7 @@ describe('Codex settings baseline schema upgrade', () => { syncSystemConfigIntoManagedCodexHome() expect(readBaseline()).toMatchObject({ - version: 2, + version: 3, settings: { model: '"gpt-5"', 'tui.theme': '"dark"' } }) expect(readBaseline().conflicts).toBeUndefined() diff --git a/src/main/codex/config-settings-baseline.ts b/src/main/codex/config-settings-baseline.ts index c771f3f7d37..5cc26e361b9 100644 --- a/src/main/codex/config-settings-baseline.ts +++ b/src/main/codex/config-settings-baseline.ts @@ -15,12 +15,19 @@ export type CodexSettingsConflict = { export type CodexSettingsBaseline = { settings: ReadonlyMap conflicts: ReadonlyMap + /** + * Plugin/marketplace tables the last mirror made canonical, with the fields a + * three-way needs. An absent entry means "never mirrored", so a runtime-only + * table reads as an addition rather than as a canonical removal. + */ + registrations: ReadonlyMap> } type StoredSettingsBaseline = { - version: 1 | 2 + version: 1 | 2 | 3 settings: Record conflicts?: Record + registrations?: Record> } /** @@ -74,7 +81,7 @@ function readParsedCodexSettingsBaseline( conflicts.set(key, conflict) } } - return { settings, conflicts } + return { settings, conflicts, registrations: readStoredRegistrations(parsed.registrations) } } catch (error) { // Why: invalid baseline state is still `null` — resetting it is the intent, // and only a read that FAILED must be preserved. @@ -82,6 +89,26 @@ function readParsedCodexSettingsBaseline( } } +function readStoredRegistrations( + stored: Record> | undefined +): Map> { + const registrations = new Map>() + for (const [key, fields] of Object.entries(stored ?? {})) { + if (!fields || typeof fields !== 'object' || Array.isArray(fields)) { + continue + } + registrations.set( + key, + new Map( + Object.entries(fields).filter((entry): entry is [string, string] => { + return typeof entry[1] === 'string' + }) + ) + ) + } + return registrations +} + /** Why: known-present baseline state outside its parse/capacity contract is rebuildable, not unreadable. */ function isRebuildableBaselineError(error: unknown): boolean { return ( @@ -96,12 +123,17 @@ export function writeCodexSettingsBaseline( baseline: CodexSettingsBaseline ): void { const file: StoredSettingsBaseline = { - version: 2, + version: 3, settings: Object.fromEntries(baseline.settings) } if (baseline.conflicts.size > 0) { file.conflicts = Object.fromEntries(baseline.conflicts) } + if (baseline.registrations.size > 0) { + file.registrations = Object.fromEntries( + [...baseline.registrations].map(([key, fields]) => [key, Object.fromEntries(fields)]) + ) + } const baselinePath = getCodexSettingsBaselinePath(runtimeHomePath) const serialized = `${JSON.stringify(file, null, 2)}\n` let existing: string | null = null @@ -130,7 +162,7 @@ function isStoredSettingsBaseline(value: unknown): value is StoredSettingsBaseli } const candidate = value as Partial return ( - (candidate.version === 1 || candidate.version === 2) && + (candidate.version === 1 || candidate.version === 2 || candidate.version === 3) && !!candidate.settings && typeof candidate.settings === 'object' && !Array.isArray(candidate.settings) diff --git a/src/main/codex/config-settings-promotion.test.ts b/src/main/codex/config-settings-promotion.test.ts index 5e0e5f10b78..e12d9168b00 100644 --- a/src/main/codex/config-settings-promotion.test.ts +++ b/src/main/codex/config-settings-promotion.test.ts @@ -201,7 +201,7 @@ describe('codex settings write-back promotion', () => { simulateCodexSettingWrite('model', '"o4"') syncSystemConfigIntoManagedCodexHome() expect(readSystemConfig()).toBe('model = "gpt-5"\n') - expect(JSON.parse(readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ version: 2 }) + expect(JSON.parse(readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ version: 3 }) simulateCodexSettingWrite('model', '"o4"') syncSystemConfigIntoManagedCodexHome() diff --git a/src/main/codex/config-settings-promotion.ts b/src/main/codex/config-settings-promotion.ts index 45d95a266b1..52f4d6eefcd 100644 --- a/src/main/codex/config-settings-promotion.ts +++ b/src/main/codex/config-settings-promotion.ts @@ -5,14 +5,13 @@ import { resolvePromotionWriteTarget } from './config-settings-promotion-write-t import { writeFileAtomically } from '../codex-accounts/fs-utils' import { parseWslUncPath } from '../../shared/wsl-paths' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' +import { upsertPromotedSettingsInContent } from './codex-config-settings-upsert' import { - createTomlLineScanState, - getTomlTableHeader, - isTomlStructuralLine, - updateTomlLineScanState -} from './config-toml-line-scan' -import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path' -import { tuiStructuredKey, upsertPromotedSettingsInContent } from './codex-config-settings-upsert' + PROMOTED_STRUCTURED_KEYS, + readPromotedSettingValues, + readPromotedSettingValuesFromContent, + type TopLevelSettingValue +} from './config-toml-promoted-setting-values' import { observeCodexSettingsBaseline, writeCodexSettingsBaseline, @@ -21,137 +20,23 @@ import { } from './config-settings-baseline' import { resolveUntrackedCodexSetting } from './config-settings-conflict-resolution' import { extractOrdinaryCodexSettings } from './config-toml-runtime-owned-sections' +import { + applyCodexRegistrationPromotions, + planCodexRegistrationPromotion, + readCodexRegistrationBaseline +} from './config-plugin-registration-promotion' +import { hasCodexRegistrationEntries } from './config-toml-plugin-registration-tables' // Why: the mirror reverts in-Codex config changes each launch; promotion salvages them by diffing the last baseline. -// Why: only scalars the Codex TUI persists; each key here is written to the user's real ~/.codex, so grow deliberately. -export const PROMOTED_CODEX_SETTING_KEYS = [ - 'model', - 'model_reasoning_effort', - 'approval_policy', - 'sandbox_mode' -] as const - -// Why: the [tui] keys the Codex TUI's user-facing pickers persist (status line, -// terminal title, theme). Like the top-level list, every key here gets written -// into the user's real ~/.codex/config.toml on promotion — grow it deliberately. -export const PROMOTED_CODEX_TUI_SETTING_KEYS = [ - 'status_line', - 'status_line_use_colors', - 'terminal_title', - 'theme' -] as const - -// Why: promotion diffs and upserts operate on structured keys — top-level keys -// keep their bare name, [tui] keys are namespaced tui. so their baseline -// entries cannot collide with a top-level key of the same name. -const PROMOTED_STRUCTURED_KEYS: readonly string[] = [ - ...PROMOTED_CODEX_SETTING_KEYS, - ...PROMOTED_CODEX_TUI_SETTING_KEYS.map(tuiStructuredKey) -] - -function isPromotedTuiKey(key: string): boolean { - return (PROMOTED_CODEX_TUI_SETTING_KEYS as readonly string[]).includes(key) -} - -// Returns the structured tui key a scanned line's key represents, or null. In -// the preamble it recognizes the dotted `tui.` form a user may hand-author; -// inside the first `[tui]` table body it recognizes the bare `` form Codex -// writes. Both map to the same structured key so either config shape promotes. -function matchTuiStructuredKey( - keyPath: string[], - inPreamble: boolean, - tuiBodyActive: boolean -): string | null { - if (inPreamble) { - const tuiKey = keyPath.length === 2 && keyPath[0] === 'tui' ? keyPath[1] : null - return tuiKey && isPromotedTuiKey(tuiKey) ? tuiStructuredKey(tuiKey) : null - } - const tuiKey = keyPath.length === 1 ? keyPath[0] : null - return tuiBodyActive && tuiKey && isPromotedTuiKey(tuiKey) ? tuiStructuredKey(tuiKey) : null -} - -type TopLevelSettingValue = { - raw: string - // Why: a multiline string/array value can't be replaced line-by-line, so it's excluded from promotion. - multiline: boolean -} - -function matchPromotedStructuredKey( - line: string, - inPreamble: boolean, - tuiBodyActive: boolean -): { structuredKey: string; raw: string } | null { - const parsed = parseTomlKeyPath(line) - if (!parsed || line[parsed.end] !== '=') { - return null - } - const raw = line.slice(parsed.end + 1).trim() - const topLevelKey = parsed.segments.length === 1 ? parsed.segments[0] : null - if ( - inPreamble && - topLevelKey && - (PROMOTED_CODEX_SETTING_KEYS as readonly string[]).includes(topLevelKey) - ) { - return { structuredKey: topLevelKey, raw } - } - const tuiKey = matchTuiStructuredKey(parsed.segments, inPreamble, tuiBodyActive) - return tuiKey ? { structuredKey: tuiKey, raw } : null -} - -// Why: top-level preamble scalars keep the historical behavior; [tui] keys are -// collected from the first bare [tui] table body or the dotted preamble form, -// keyed by structured path. Any table header (including [tui.*] subtables) ends -// the [tui] body, and [profiles.*]/other tables are still ignored. -function readPromotedSettingValues(configPath: string): Map { - const result = new Map() - // Why: an unreadable config held no settings only in the sense that we could - // not read them. Returning an empty map says the user cleared every promoted - // value, and the write below then acts on that. - const observation = observeAgentStateFile(configPath) - if (observation.kind === 'absent') { - return result - } - if (observation.kind === 'indeterminate') { - throw observation.error - } - const lines = observation.value.split('\n') - let state = createTomlLineScanState() - let inPreamble = true - let tuiTableSeen = false - let tuiBodyActive = false - for (const line of lines) { - if (isTomlStructuralLine(state)) { - const header = getTomlTableHeader(line) - if (header) { - const table = parseTomlTableHeaderPath(header) - tuiBodyActive = - table !== null && - !table.isArray && - table.segments.length === 1 && - table.segments[0] === 'tui' && - !tuiTableSeen - if (tuiBodyActive) { - tuiTableSeen = true - } - inPreamble = false - state = updateTomlLineScanState(state, line) - continue - } - const matched = matchPromotedStructuredKey(line, inPreamble, tuiBodyActive) - if (matched) { - const nextState = updateTomlLineScanState(state, line) - result.set(matched.structuredKey, { - raw: matched.raw, - multiline: !isTomlStructuralLine(nextState) - }) - state = nextState - continue - } - } - state = updateTomlLineScanState(state, line) - } - return result +export type CodexSettingsBaselineSnapshotOptions = { + conflicts?: ReadonlyMap + /** + * Whether a mirror actually made the runtime's registration tables canonical. + * A bootstrap baseline must leave this false: claiming tables Orca never + * mirrored would read a source config that never had them as a removal. + */ + mirroredRegistrations?: boolean } /** @@ -161,12 +46,18 @@ function readPromotedSettingValues(configPath: string): Map = new Map() + options: CodexSettingsBaselineSnapshotOptions = {} ): void { try { const runtimeTomlPath = join(runtimeHomePath, 'config.toml') // Why: record an empty baseline even for a missing runtime config, so Codex's first write still diffs and promotes. - const runtimeValues = readPromotedSettingValues(runtimeTomlPath) + const observation = observeAgentStateFile(runtimeTomlPath) + if (observation.kind === 'indeterminate') { + throw observation.error + } + const runtimeConfig = observation.kind === 'present' ? observation.value : '' + const conflicts = options.conflicts ?? new Map() + const runtimeValues = readPromotedSettingValuesFromContent(runtimeConfig) const settings = new Map() for (const key of PROMOTED_STRUCTURED_KEYS) { const value = runtimeValues.get(key) @@ -175,7 +66,13 @@ export function snapshotCodexRuntimeSettingsBaseline( settings.set(key, value?.raw ?? null) } } - writeCodexSettingsBaseline(runtimeHomePath, { settings, conflicts }) + writeCodexSettingsBaseline(runtimeHomePath, { + settings, + conflicts, + registrations: options.mirroredRegistrations + ? readCodexRegistrationBaseline(runtimeConfig) + : new Map() + }) } catch (error) { console.warn('[codex-settings-promotion] failed to snapshot settings baseline', error) } @@ -236,7 +133,7 @@ function promoteCodexRuntimeSettingsToSystemUnsafe( // config nobody read. throw runtimeTomlObservation.error } - // Why: without a baseline, a stale runtime value looks like a fresh in-Codex change; skip until the mirror writes one. + // Why: without a baseline, a stale runtime scalar looks like a fresh in-Codex change; skip until the mirror writes one. const baselineObservation = observeCodexSettingsBaseline(runtimeHomePath) if (baselineObservation.kind === 'indeterminate') { // Why: an empty plan here lets the mirror proceed and write the system value @@ -244,24 +141,25 @@ function promoteCodexRuntimeSettingsToSystemUnsafe( // turns a throw into the existing stall-and-retry null. throw new Error('Codex settings baseline could not be read') } - if (baselineObservation.kind === 'absent') { - return emptyPromotionPlan() - } - const baseline = baselineObservation.baseline - const runtimeValues = readPromotedSettingValues(runtimeTomlPath) - const systemValues = readPromotedSettingValues(systemTomlPath) + const baseline = baselineObservation.kind === 'present' ? baselineObservation.baseline : null const updates = new Map() const conflicts = new Map() const runtimeValuesToPreserve = new Map() - collectPromotionChanges({ - baseline, - runtimeValues, - systemValues, - updates, - conflicts, - runtimeValuesToPreserve - }) - if (updates.size === 0) { + if (baseline) { + collectPromotionChanges({ + baseline, + runtimeValues: readPromotedSettingValues(runtimeTomlPath), + systemValues: readPromotedSettingValues(systemTomlPath), + updates, + conflicts, + runtimeValuesToPreserve + }) + } + // Why: registration tables reconcile against the mirrored-table baseline, which + // is legitimately empty before the first mirror — a table Orca never made + // canonical is an addition, never a removal it must honor. Scalars still need a + // real baseline, so they stay gated above. + if (updates.size === 0 && !hasCodexRegistrationEntries(runtimeTomlObservation.value)) { return { conflicts, runtimeValuesToPreserve } } // Why: a fresh host has no ~/.codex; create it owner-only (holds auth.json) or the atomic write ENOENTs and the mirror wipes it. @@ -273,10 +171,11 @@ function promoteCodexRuntimeSettingsToSystemUnsafe( // existence probe sent it down the reconstruct branch below, which replaces // the canonical config with settings derived from Orca's runtime copy. One // read replaces the old existsSync + read pair and its TOCTOU gap. - // The indeterminate arm is a backstop rather than the live guard: an - // unreadable system config already refused in readPromotedSettingValues, - // because `writeTarget.path` always resolves to the same file as - // `systemTomlPath` (its realpath, its dangling-link target, or itself). + // With a baseline, this arm is a backstop — an unreadable system config + // already refused in readPromotedSettingValues, because `writeTarget.path` + // always resolves to the same file as `systemTomlPath` (its realpath, its + // dangling-link target, or itself). Registration reconciliation runs without + // a baseline and skips that read, so here it IS the live guard. const writeTargetObservation = observeAgentStateFile(writeTarget.path) if (writeTargetObservation.kind === 'indeterminate') { throw writeTargetObservation.error @@ -290,7 +189,18 @@ function promoteCodexRuntimeSettingsToSystemUnsafe( writeTargetObservation.kind === 'present' ? writeTargetObservation.value : extractOrdinaryCodexSettings(runtimeTomlObservation.value) - const nextContent = upsertPromotedSettingsInContent(systemContent, updates) + const withPromotedSettings = upsertPromotedSettingsInContent(systemContent, updates) + // Why: plan against the content actually being edited, not a second read of the + // source — when the system config is seeded from the runtime, its registration + // tables are already present and re-appending them would duplicate the table. + const nextContent = applyCodexRegistrationPromotions( + withPromotedSettings, + planCodexRegistrationPromotion( + runtimeTomlObservation.value, + withPromotedSettings, + baseline?.registrations ?? new Map() + ) + ) if (nextContent === systemContent) { return { conflicts, runtimeValuesToPreserve } } diff --git a/src/main/codex/config-toml-line-scan.ts b/src/main/codex/config-toml-line-scan.ts index 48216621636..98d096167fe 100644 --- a/src/main/codex/config-toml-line-scan.ts +++ b/src/main/codex/config-toml-line-scan.ts @@ -296,3 +296,21 @@ function parseTomlUnicodeEscape( return null } } + +export function withTrailingCr(originalLine: string, rendered: string): string { + return originalLine.endsWith('\r') ? `${rendered}\r` : rendered +} + +export function withCrLine(rendered: string, usesCrlf: boolean): string { + return usesCrlf ? `${rendered}\r` : rendered +} + +// Why: a missing trailing newline is restored in the file's own EOL so a +// preamble-only or table-appended rewrite matches the source's newline behavior. +export function joinPreservingTrailingNewline(lines: string[], usesCrlf: boolean): string { + const result = lines.join('\n') + if (result.endsWith('\n') || result.length === 0) { + return result + } + return result.endsWith('\r') ? `${result}\n` : `${result}${usesCrlf ? '\r\n' : '\n'}` +} diff --git a/src/main/codex/config-toml-plugin-registration-tables.ts b/src/main/codex/config-toml-plugin-registration-tables.ts new file mode 100644 index 00000000000..c968d3890b5 --- /dev/null +++ b/src/main/codex/config-toml-plugin-registration-tables.ts @@ -0,0 +1,242 @@ +import { + createTomlLineScanState, + getTomlTableHeader, + isTomlStructuralLine, + parseTomlSingleLineStringValue, + updateTomlLineScanState +} from './config-toml-line-scan' +import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path' + +// Why: Codex persists plugin registration as two table families under a +// managed CODEX_HOME — `[marketplaces.]` and `[plugins."@"]`. +// Reconciling them needs identity, not text, so the name is the parsed header +// segment: `[plugins."a@b"]` and `[plugins.'a@b']` are one registration. + +export const CODEX_REGISTRATION_ROOTS = ['marketplaces', 'plugins'] as const + +export type CodexRegistrationRoot = (typeof CODEX_REGISTRATION_ROOTS)[number] + +export type CodexRegistrationField = { + raw: string + /** A value spanning lines cannot be replaced line-by-line, so it is never rewritten. */ + multiline: boolean + lineIndex: number +} + +export type CodexRegistrationEntry = { + key: string + root: CodexRegistrationRoot + name: string + /** Line range of the `[root.name]` table itself; -1 when only subtables exist. */ + ownerStart: number + ownerEnd: number + /** The registration's full text, including any `[root.name.*]` subtables. */ + block: string + fields: ReadonlyMap +} + +export function getCodexRegistrationKey(root: CodexRegistrationRoot, name: string): string { + return `${root}:${name}` +} + +export function readCodexRegistrationEntries(config: string): Map { + const lines = config.split('\n') + const headers = scanTomlTableHeaders(lines) + const entries = new Map() + for (let index = 0; index < headers.length; index += 1) { + const header = headers[index]! + const root = header.segments[0] + const name = header.segments[1] + // Why: `[[marketplaces.x]]` is not a shape Codex writes; treating an array of + // tables as one registration would key it by a name it may not own. + if (header.isArray || !isCodexRegistrationRoot(root) || name === undefined) { + continue + } + const end = headers[index + 1]?.index ?? lines.length + const key = getCodexRegistrationKey(root, name) + const isOwner = header.segments.length === 2 + const existing = entries.get(key) + const block = readRegistrationBlock(lines, header.index, end) + if (!existing) { + entries.set(key, { + key, + root, + name, + ownerStart: isOwner ? header.index : -1, + ownerEnd: isOwner ? end : -1, + block, + fields: isOwner ? readTomlTableFields(lines, header.index, end) : new Map() + }) + continue + } + entries.set(key, { + ...existing, + // Why: a duplicate owner table is invalid TOML; the first one wins, exactly + // as a TOML reader that rejects the second would have read the file. + ownerStart: existing.ownerStart === -1 && isOwner ? header.index : existing.ownerStart, + ownerEnd: existing.ownerStart === -1 && isOwner ? end : existing.ownerEnd, + block: `${existing.block}\n\n${block}`, + fields: + existing.ownerStart === -1 && isOwner + ? readTomlTableFields(lines, header.index, end) + : existing.fields + }) + } + return entries +} + +export function hasCodexRegistrationEntries(config: string): boolean { + return readCodexRegistrationEntries(config).size > 0 +} + +// Why: the block ends at the NEXT header, so its trailing blank and comment lines +// are that table's leading comment — appending them would copy it into the wrong +// section. Only structural lines are inspected, so a `#` inside a multiline string +// is never mistaken for one. +function readRegistrationBlock(lines: string[], start: number, end: number): string { + let state = createTomlLineScanState() + let lastBodyLine = start + for (let index = start; index < end; index += 1) { + const line = lines[index] ?? '' + const trimmed = line.trim() + if (!isTomlStructuralLine(state) || (trimmed !== '' && !trimmed.startsWith('#'))) { + lastBodyLine = index + } + state = updateTomlLineScanState(state, line) + } + return lines + .slice(start, lastBodyLine + 1) + .join('\n') + .trimEnd() +} + +const REGISTRATION_TIMESTAMP_PATTERN = + /^(\d{4})-(\d{2})-(\d{2})[Tt ]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})?$/ + +function isRealCalendarDate(year: number, month: number, day: number): boolean { + if (month < 1 || month > 12 || day < 1) { + return false + } + // Day 0 of the following month is the last day of this one; setUTCFullYear avoids + // the two-digit-year remapping the Date constructor applies. + const lastOfMonth = new Date(0) + lastOfMonth.setUTCFullYear(year, month, 0) + return day <= lastOfMonth.getUTCDate() +} + +/** Compares values by meaning, so quote style and a trailing comment never read as a change. */ +export function normalizeCodexRegistrationValue(raw: string): string { + const stripped = stripTomlTrailingComment(raw) + const quoted = parseTomlSingleLineStringValue(stripped, 0) + return quoted && quoted.end === stripped.length ? `string:${quoted.value}` : stripped +} + +/** + * Milliseconds for a marketplace refresh timestamp, or null when the value is not + * an RFC 3339 / TOML date-time. Anything unparseable is malformed, never "older". + */ +export function parseCodexRegistrationTimestamp(raw: string): number | null { + const stripped = stripTomlTrailingComment(raw) + const quoted = parseTomlSingleLineStringValue(stripped, 0) + const text = quoted && quoted.end === stripped.length ? quoted.value : stripped + const match = REGISTRATION_TIMESTAMP_PATTERN.exec(text) + // Why: Date.parse rolls `2025-02-30` forward to March 2 rather than rejecting it, + // so a malformed runtime value would read as NEWER and win against canonical. + if (!match || !isRealCalendarDate(Number(match[1]), Number(match[2]), Number(match[3]))) { + return null + } + const parsed = Date.parse(text.replace(' ', 'T')) + return Number.isFinite(parsed) ? parsed : null +} + +function isCodexRegistrationRoot(value: string | undefined): value is CodexRegistrationRoot { + return (CODEX_REGISTRATION_ROOTS as readonly string[]).includes(value ?? '') +} + +type TomlTableHeaderMarker = { + index: number + segments: string[] + isArray: boolean +} + +// Why: an unparseable header still ends the previous table, so it is recorded +// with no segments rather than skipped — otherwise its lines would be attributed +// to the registration above it. +function scanTomlTableHeaders(lines: string[]): TomlTableHeaderMarker[] { + const markers: TomlTableHeaderMarker[] = [] + let state = createTomlLineScanState() + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? '' + if (isTomlStructuralLine(state)) { + const header = getTomlTableHeader(line) + if (header) { + const table = parseTomlTableHeaderPath(header) + markers.push({ + index, + segments: table?.segments ?? [], + isArray: table?.isArray ?? false + }) + } + } + state = updateTomlLineScanState(state, line) + } + return markers +} + +function readTomlTableFields( + lines: string[], + headerIndex: number, + end: number +): Map { + const fields = new Map() + let state = createTomlLineScanState() + let index = headerIndex + 1 + while (index < end) { + const line = lines[index] ?? '' + const parsed = isTomlStructuralLine(state) ? parseTomlKeyPath(line) : null + const name = parsed?.segments.length === 1 ? parsed.segments[0] : null + if (!parsed || !name || line[parsed.end] !== '=') { + state = updateTomlLineScanState(state, line) + index += 1 + continue + } + let raw = line.slice(parsed.end + 1).trim() + state = updateTomlLineScanState(state, line) + let valueEnd = index + 1 + while (!isTomlStructuralLine(state) && valueEnd < end) { + const continuation = lines[valueEnd] ?? '' + raw += `\n${continuation.trim()}` + state = updateTomlLineScanState(state, continuation) + valueEnd += 1 + } + if (!fields.has(name)) { + fields.set(name, { + raw, + multiline: valueEnd > index + 1, + lineIndex: index + }) + } + index = valueEnd + } + return fields +} + +function stripTomlTrailingComment(raw: string): string { + let index = 0 + while (index < raw.length) { + const char = raw[index] + if (char === '#') { + return raw.slice(0, index).trim() + } + if (char === '"' || char === "'") { + const quoted = parseTomlSingleLineStringValue(raw, index) + if (!quoted) { + return raw.trim() + } + index = quoted.end + continue + } + index += 1 + } + return raw.trim() +} diff --git a/src/main/codex/config-toml-promoted-setting-values.ts b/src/main/codex/config-toml-promoted-setting-values.ts new file mode 100644 index 00000000000..543c6476843 --- /dev/null +++ b/src/main/codex/config-toml-promoted-setting-values.ts @@ -0,0 +1,145 @@ +import { observeAgentStateFile } from './codex-path-observation' +import { + createTomlLineScanState, + getTomlTableHeader, + isTomlStructuralLine, + updateTomlLineScanState +} from './config-toml-line-scan' +import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path' +import { tuiStructuredKey } from './codex-config-settings-upsert' + +// Why: only scalars the Codex TUI persists; each key here is written to the user's real ~/.codex, so grow deliberately. +export const PROMOTED_CODEX_SETTING_KEYS = [ + 'model', + 'model_reasoning_effort', + 'approval_policy', + 'sandbox_mode' +] as const + +// Why: the [tui] keys the Codex TUI's user-facing pickers persist (status line, +// terminal title, theme). Like the top-level list, every key here gets written +// into the user's real ~/.codex/config.toml on promotion — grow it deliberately. +export const PROMOTED_CODEX_TUI_SETTING_KEYS = [ + 'status_line', + 'status_line_use_colors', + 'terminal_title', + 'theme' +] as const + +// Why: promotion diffs and upserts operate on structured keys — top-level keys +// keep their bare name, [tui] keys are namespaced tui. so their baseline +// entries cannot collide with a top-level key of the same name. +export const PROMOTED_STRUCTURED_KEYS: readonly string[] = [ + ...PROMOTED_CODEX_SETTING_KEYS, + ...PROMOTED_CODEX_TUI_SETTING_KEYS.map(tuiStructuredKey) +] + +function isPromotedTuiKey(key: string): boolean { + return (PROMOTED_CODEX_TUI_SETTING_KEYS as readonly string[]).includes(key) +} + +// Returns the structured tui key a scanned line's key represents, or null. In +// the preamble it recognizes the dotted `tui.` form a user may hand-author; +// inside the first `[tui]` table body it recognizes the bare `` form Codex +// writes. Both map to the same structured key so either config shape promotes. +function matchTuiStructuredKey( + keyPath: string[], + inPreamble: boolean, + tuiBodyActive: boolean +): string | null { + if (inPreamble) { + const tuiKey = keyPath.length === 2 && keyPath[0] === 'tui' ? keyPath[1] : null + return tuiKey && isPromotedTuiKey(tuiKey) ? tuiStructuredKey(tuiKey) : null + } + const tuiKey = keyPath.length === 1 ? keyPath[0] : null + return tuiBodyActive && tuiKey && isPromotedTuiKey(tuiKey) ? tuiStructuredKey(tuiKey) : null +} + +export type TopLevelSettingValue = { + raw: string + // Why: a multiline string/array value can't be replaced line-by-line, so it's excluded from promotion. + multiline: boolean +} + +function matchPromotedStructuredKey( + line: string, + inPreamble: boolean, + tuiBodyActive: boolean +): { structuredKey: string; raw: string } | null { + const parsed = parseTomlKeyPath(line) + if (!parsed || line[parsed.end] !== '=') { + return null + } + const raw = line.slice(parsed.end + 1).trim() + const topLevelKey = parsed.segments.length === 1 ? parsed.segments[0] : null + if ( + inPreamble && + topLevelKey && + (PROMOTED_CODEX_SETTING_KEYS as readonly string[]).includes(topLevelKey) + ) { + return { structuredKey: topLevelKey, raw } + } + const tuiKey = matchTuiStructuredKey(parsed.segments, inPreamble, tuiBodyActive) + return tuiKey ? { structuredKey: tuiKey, raw } : null +} + +// Why: top-level preamble scalars keep the historical behavior; [tui] keys are +// collected from the first bare [tui] table body or the dotted preamble form, +// keyed by structured path. Any table header (including [tui.*] subtables) ends +// the [tui] body, and [profiles.*]/other tables are still ignored. +export function readPromotedSettingValues(configPath: string): Map { + // Why: an unreadable config held no settings only in the sense that we could + // not read them. Returning an empty map says the user cleared every promoted + // value, and the write below then acts on that. + const observation = observeAgentStateFile(configPath) + if (observation.kind === 'absent') { + return new Map() + } + if (observation.kind === 'indeterminate') { + throw observation.error + } + return readPromotedSettingValuesFromContent(observation.value) +} + +export function readPromotedSettingValuesFromContent( + config: string +): Map { + const result = new Map() + const lines = config.split('\n') + let state = createTomlLineScanState() + let inPreamble = true + let tuiTableSeen = false + let tuiBodyActive = false + for (const line of lines) { + if (isTomlStructuralLine(state)) { + const header = getTomlTableHeader(line) + if (header) { + const table = parseTomlTableHeaderPath(header) + tuiBodyActive = + table !== null && + !table.isArray && + table.segments.length === 1 && + table.segments[0] === 'tui' && + !tuiTableSeen + if (tuiBodyActive) { + tuiTableSeen = true + } + inPreamble = false + state = updateTomlLineScanState(state, line) + continue + } + const matched = matchPromotedStructuredKey(line, inPreamble, tuiBodyActive) + if (matched) { + const nextState = updateTomlLineScanState(state, line) + result.set(matched.structuredKey, { + raw: matched.raw, + multiline: !isTomlStructuralLine(nextState) + }) + state = nextState + continue + } + } + state = updateTomlLineScanState(state, line) + } + return result +} diff --git a/src/main/codex/sta-4823-shared-state-survives-a-failed-read.test.ts b/src/main/codex/sta-4823-shared-state-survives-a-failed-read.test.ts index adab285b592..e976a2a5660 100644 --- a/src/main/codex/sta-4823-shared-state-survives-a-failed-read.test.ts +++ b/src/main/codex/sta-4823-shared-state-survives-a-failed-read.test.ts @@ -368,7 +368,7 @@ describe('STA-4823 D26 — an unreadable settings baseline must stall the mirror // Asserted against the file rather than the new observation API, so this // anchor still means something when the fix is reverted. - expect(JSON.parse(realFs.readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ version: 2 }) + expect(JSON.parse(realFs.readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ version: 3 }) }) it('still replaces a fully-read baseline rejected by the JSON structure limit', () => { @@ -377,7 +377,7 @@ describe('STA-4823 D26 — an unreadable settings baseline must stall the mirror snapshotCodexRuntimeSettingsBaseline(runtimeHomePath) - expect(JSON.parse(realFs.readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ version: 2 }) + expect(JSON.parse(realFs.readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ version: 3 }) }) it('rebuilds an oversized baseline previously produced from a bounded runtime config', () => { From 9f7fd9a27064335d0335dd9639f1c6d35768db7a Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 12 Sep 2026 00:46:46 -0400 Subject: [PATCH 031/191] fix(relay): reuse canary across completed rollout batches (#20214) --- ...cloud-deploy-relay-production-same-cap.yml | 2 +- .../relay-production-same-cap-wave.mjs | 9 +++-- .../relay-production-same-cap-wave.test.mjs | 37 ++++++++++++++++++- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/.github/workflows/cloud-deploy-relay-production-same-cap.yml b/.github/workflows/cloud-deploy-relay-production-same-cap.yml index 50d609b30d3..31fe9bfb203 100644 --- a/.github/workflows/cloud-deploy-relay-production-same-cap.yml +++ b/.github/workflows/cloud-deploy-relay-production-same-cap.yml @@ -62,7 +62,7 @@ on: required: false type: string canary-run-id: - description: Successful same-commit canary run required for batch-apply + description: Successful same-code canary in this rehome control generation; reusable across batches required: false type: string confirmation: diff --git a/cloud/dev/scripts/relay-production-same-cap-wave.mjs b/cloud/dev/scripts/relay-production-same-cap-wave.mjs index 6e84c1c9104..8caa68e7ff2 100644 --- a/cloud/dev/scripts/relay-production-same-cap-wave.mjs +++ b/cloud/dev/scripts/relay-production-same-cap-wave.mjs @@ -87,18 +87,21 @@ export function canaryAuthority(input) { } export function verifyCanaryAuthority(authority, expected, repositoryRoot) { + const selectorGeneration = Number(expected.selectorGeneration) if ( authority?.v !== 1 || !/^[0-9a-f]{40}$/.test(authority.commitSha ?? '') || authority.runId !== expected.runId || authority.targetDigest !== expected.targetDigest || authority.rollbackDigest !== expected.rollbackDigest || - authority.selectorGeneration !== Number(expected.selectorGeneration) || + !Number.isSafeInteger(authority.selectorGeneration) || + authority.selectorGeneration < 0 || + !Number.isSafeInteger(selectorGeneration) || + selectorGeneration < authority.selectorGeneration || authority.rehomeGeneration !== Number(expected.rehomeGeneration) || !SAME_CAP_CELLS.includes(authority.cellId) ) throw new Error('canary authority does not match this batch') - // The batch dispatch resolves main after the canary sealed, so bind to the same code, not the - // same SHA; every field above still pins this batch to that exact canary. + // Each cell checks exact live selector state; later batches may reuse this control epoch's canary. requireSameEvidenceCode({ sealedSha: authority.commitSha, currentSha: expected.commitSha, diff --git a/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs b/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs index d636c324b33..7377f7a7af3 100644 --- a/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs +++ b/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs @@ -109,6 +109,41 @@ test('seals and verifies canary authority for later batches', () => { }), /does not match/) }) +test('reuses a canary across selector advances only within the same control epoch', () => { + const authority = canaryAuthority({ + cellIds: 'production-gce-c7', targetDigest, rollbackDigest, + confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} production-gce-c7`, + commitSha: 'c'.repeat(40), runId: '42', selectorGeneration: '11', rehomeGeneration: '4' + }) + const expected = { + commitSha: 'c'.repeat(40), runId: '42', targetDigest, rollbackDigest, + selectorGeneration: '21', rehomeGeneration: '4' + } + for (const generation of ['13', '14', '21', '29']) { + assert.equal(verifyCanaryAuthority(authority, { + ...expected, selectorGeneration: generation + }), authority) + } + for (const generation of ['12', '-1', 'NaN', 'Infinity', '13.5', '9007199254740992']) { + assert.throws(() => verifyCanaryAuthority(authority, { + ...expected, selectorGeneration: generation + }), /does not match/) + } + for (const generation of [-1, NaN, Infinity, 13.5, '13', Number.MAX_SAFE_INTEGER + 1]) { + assert.throws(() => verifyCanaryAuthority({ + ...authority, selectorGeneration: generation + }, expected), /does not match/) + } + for (const mismatch of [ + { rehomeGeneration: '3' }, { rehomeGeneration: '5' }, + { targetDigest: rollbackDigest }, { rollbackDigest: targetDigest }, { runId: '43' } + ]) { + assert.throws(() => verifyCanaryAuthority(authority, { + ...expected, ...mismatch + }), /does not match/) + } +}) + function gitIn(root, ...args) { return execFileSync('git', ['-C', root, ...args], { encoding: 'utf8' }).trim() } @@ -158,7 +193,7 @@ test('a batch trusts a canary sealed by identical code at an ancestor commit', a runId: '42', targetDigest, rollbackDigest, - selectorGeneration: '13', + selectorGeneration: '21', rehomeGeneration: '4' }, repositoryRoot) assert.equal(verifyAt(repository.sameCode, repository.root).cellId, 'production-gce-c7') From 341b13cf679892102d2db8bf74024ae963f1cdd1 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:03:57 -0400 Subject: [PATCH 032/191] Restore mobile push and fix cold-start dismissals (#20068) * Restore mobile push for delivery validation * fix(mobile): register push task before headless startup * Add authenticated mobile push test and fix iOS release entitlements * Mock push-test transport in notification consent tests * Fix slept workspace test for structured remount result * Fix mobile notification review findings * Pad Android notification icon to prevent square cropping * fix(mobile): present visible Android data pushes in foreground * test: use deterministic clock for teardown deadline * fix(mobile): present foreground pushes through Expo public APIs * fix(mobile): check push eligibility before foreground scheduling * fix(mobile): register push from shared host connection lifecycle --- .github/workflows/mobile-ios-release.yml | 2 + mobile/app.config.js | 32 + mobile/app.json | 4 +- mobile/app/_layout.tsx | 62 +- mobile/app/mobile-onboarding.tsx | 4 +- mobile/app/notifications.tsx | 12 +- mobile/assets/notification-icon.png | Bin 0 -> 639 bytes mobile/google-services.json | 39 + mobile/index.js | 3 + .../expo-module.config.json | 7 + .../ios/OrcaNotificationDismissal.podspec | 15 + .../ios/OrcaNotificationDismissalModule.swift | 14 + .../OrcaNotificationDismissalSubscriber.swift | 26 + .../ios/PushDismissalLedger.swift | 67 + .../orca-notification-dismissal/package.json | 5 + .../tests/PushDismissalLedgerChecks.swift | 44 + mobile/package.json | 3 +- .../patches/expo-notifications@55.0.27.patch | 36 + mobile/pnpm-lock.yaml | 2448 ++++++++--------- mobile/pnpm-workspace.yaml | 1 + .../troubleshoot-common-issues.tsx | 12 +- .../NotificationDeliverySection.test.tsx | 37 + .../NotificationDeliverySection.tsx | 72 + .../android-foreground-push.test.ts | 112 + .../notifications/android-foreground-push.ts | 49 + .../desktop-notification-channel.test.ts | 62 + .../desktop-notification-channel.ts | 27 + .../desktop-notification-events.ts | 6 + .../expo-native-token-retry.test.ts | 40 + .../local-notification-scheduling.ts | 191 -- .../mobile-notifications.test.ts | 1002 +------ .../src/notifications/mobile-notifications.ts | 264 +- .../mobile-push-lease-renewal.test.ts | 39 + .../mobile-push-lease-renewal.ts | 19 + .../native-notification-data.test.ts | 22 + .../notifications/native-notification-data.ts | 13 + .../native-push-dismissal.ios.ts | 4 + .../native-push-dismissal.test.ts | 23 + .../notifications/native-push-dismissal.ts | 8 + ...ication-catchup-failure-quarantine.test.ts | 316 --- .../notification-consent-ownership.test.ts | 310 +++ .../notification-delivery-ordering.test.ts | 248 -- .../notification-delivery-preferences.test.ts | 86 + .../notification-delivery-preferences.ts | 49 + .../notification-opt-in-gate.test.ts | 80 +- .../notifications/notification-opt-in-gate.ts | 34 +- .../notification-reconnect-catchup.ts | 412 --- .../notification-reconnect-teardown.test.ts | 201 -- .../notification-routing.test.ts | 27 +- .../src/notifications/notification-routing.ts | 40 +- .../notification-viewing-policy.ts | 19 + .../notification-watermark-seed-race.test.ts | 206 -- .../push-background-dismissal.test.ts | 72 + .../push-background-dismissal.ts | 41 + .../push-dismissal-native-races.test.ts | 128 + .../push-dismissal-reconciliation.test.ts | 145 + .../push-dismissal-reconciliation.ts | 81 + .../push-dismissal-watermarks.test.ts | 93 + .../push-dismissal-watermarks.ts | 104 + .../push-host-fingerprint.test.ts | 62 + .../notifications/push-host-fingerprint.ts | 58 + .../push-notification-identity.test.ts | 25 + .../push-notification-identity.ts | 26 + mobile/src/notifications/push-payload.ts | 43 + .../push-preference-update.test.ts | 86 + mobile/src/notifications/push-receive.test.ts | 274 ++ mobile/src/notifications/push-receive.ts | 152 + .../push-registration-cancellation.test.ts | 263 ++ .../notifications/push-registration.test.ts | 423 +++ mobile/src/notifications/push-registration.ts | 328 +++ .../push-socket-dismissal.test.ts | 73 + .../notifications/push-socket-dismissal.ts | 22 + mobile/src/notifications/push-token.test.ts | 92 + mobile/src/notifications/push-token.ts | 58 + .../notifications/push-tray-dismissal.test.ts | 99 + .../src/notifications/push-tray-dismissal.ts | 63 + .../use-remote-push-capable-hosts.test.tsx | 196 ++ .../use-remote-push-capable-hosts.ts | 109 + .../src/onboarding/MobileOnboardingPage.tsx | 14 +- .../mobile-onboarding-screen.test.ts | 35 +- .../onboarding/mobile-onboarding-styles.ts | 7 + .../mobile-session-route-parity.test.ts | 6 +- mobile/src/session/mobile-session-route.ts | 6 +- .../session/use-mobile-session-controller.ts | 2 + .../use-notification-pane-navigation.test.tsx | 67 + .../use-notification-pane-navigation.ts | 40 + ...ve-notification-delivery-settings.test.tsx | 151 + .../native-notification-delivery-settings.tsx | 97 + ...native-notification-settings-operations.ts | 5 +- .../notification-display-test.test.tsx | 88 + .../settings/notification-display-test.tsx | 125 + .../settings/notification-settings-screen.tsx | 34 +- mobile/src/storage/preferences.test.ts | 29 +- mobile/src/storage/preferences.ts | 40 +- mobile/src/transport/client-context.test.ts | 35 + mobile/src/transport/host-entry-opener.ts | 20 +- .../src/transport/host-open-recovery.test.tsx | 3 + .../transport/host-removal-lifecycle.test.ts | 65 +- .../src/transport/host-removal-lifecycle.ts | 20 +- .../src/transport/runtime-capability-probe.ts | 2 +- .../settings-host-client-lifecycle.test.ts | 3 + .../runtime/push/desktop-push-service.test.ts | 37 + src/main/runtime/push/desktop-push-service.ts | 49 + .../push/push-registration-rpc.test.ts | 23 + src/main/runtime/rpc/methods/notifications.ts | 10 + .../runtime-mobile-notification-controller.ts | 6 + .../runtime-rpc-mobile-method-allowlist.ts | 1 + .../runtime-service-command-surface.ts | 2 + ...ructured-session-worktree-teardown.test.ts | 24 +- src/shared/mobile-push-contract.ts | 4 + .../rpc-params-catalog.generated.ts | 1 + 111 files changed, 6728 insertions(+), 4293 deletions(-) create mode 100644 mobile/app.config.js create mode 100644 mobile/assets/notification-icon.png create mode 100644 mobile/google-services.json create mode 100644 mobile/index.js create mode 100644 mobile/modules/orca-notification-dismissal/expo-module.config.json create mode 100644 mobile/modules/orca-notification-dismissal/ios/OrcaNotificationDismissal.podspec create mode 100644 mobile/modules/orca-notification-dismissal/ios/OrcaNotificationDismissalModule.swift create mode 100644 mobile/modules/orca-notification-dismissal/ios/OrcaNotificationDismissalSubscriber.swift create mode 100644 mobile/modules/orca-notification-dismissal/ios/PushDismissalLedger.swift create mode 100644 mobile/modules/orca-notification-dismissal/package.json create mode 100644 mobile/modules/orca-notification-dismissal/tests/PushDismissalLedgerChecks.swift create mode 100644 mobile/patches/expo-notifications@55.0.27.patch create mode 100644 mobile/src/notifications/NotificationDeliverySection.test.tsx create mode 100644 mobile/src/notifications/NotificationDeliverySection.tsx create mode 100644 mobile/src/notifications/android-foreground-push.test.ts create mode 100644 mobile/src/notifications/android-foreground-push.ts create mode 100644 mobile/src/notifications/desktop-notification-channel.test.ts create mode 100644 mobile/src/notifications/desktop-notification-channel.ts create mode 100644 mobile/src/notifications/desktop-notification-events.ts create mode 100644 mobile/src/notifications/expo-native-token-retry.test.ts delete mode 100644 mobile/src/notifications/local-notification-scheduling.ts create mode 100644 mobile/src/notifications/mobile-push-lease-renewal.test.ts create mode 100644 mobile/src/notifications/mobile-push-lease-renewal.ts create mode 100644 mobile/src/notifications/native-notification-data.test.ts create mode 100644 mobile/src/notifications/native-notification-data.ts create mode 100644 mobile/src/notifications/native-push-dismissal.ios.ts create mode 100644 mobile/src/notifications/native-push-dismissal.test.ts create mode 100644 mobile/src/notifications/native-push-dismissal.ts delete mode 100644 mobile/src/notifications/notification-catchup-failure-quarantine.test.ts create mode 100644 mobile/src/notifications/notification-consent-ownership.test.ts delete mode 100644 mobile/src/notifications/notification-delivery-ordering.test.ts create mode 100644 mobile/src/notifications/notification-delivery-preferences.test.ts create mode 100644 mobile/src/notifications/notification-delivery-preferences.ts delete mode 100644 mobile/src/notifications/notification-reconnect-catchup.ts delete mode 100644 mobile/src/notifications/notification-reconnect-teardown.test.ts create mode 100644 mobile/src/notifications/notification-viewing-policy.ts delete mode 100644 mobile/src/notifications/notification-watermark-seed-race.test.ts create mode 100644 mobile/src/notifications/push-background-dismissal.test.ts create mode 100644 mobile/src/notifications/push-background-dismissal.ts create mode 100644 mobile/src/notifications/push-dismissal-native-races.test.ts create mode 100644 mobile/src/notifications/push-dismissal-reconciliation.test.ts create mode 100644 mobile/src/notifications/push-dismissal-reconciliation.ts create mode 100644 mobile/src/notifications/push-dismissal-watermarks.test.ts create mode 100644 mobile/src/notifications/push-dismissal-watermarks.ts create mode 100644 mobile/src/notifications/push-host-fingerprint.test.ts create mode 100644 mobile/src/notifications/push-host-fingerprint.ts create mode 100644 mobile/src/notifications/push-notification-identity.test.ts create mode 100644 mobile/src/notifications/push-notification-identity.ts create mode 100644 mobile/src/notifications/push-payload.ts create mode 100644 mobile/src/notifications/push-preference-update.test.ts create mode 100644 mobile/src/notifications/push-receive.test.ts create mode 100644 mobile/src/notifications/push-receive.ts create mode 100644 mobile/src/notifications/push-registration-cancellation.test.ts create mode 100644 mobile/src/notifications/push-registration.test.ts create mode 100644 mobile/src/notifications/push-registration.ts create mode 100644 mobile/src/notifications/push-socket-dismissal.test.ts create mode 100644 mobile/src/notifications/push-socket-dismissal.ts create mode 100644 mobile/src/notifications/push-token.test.ts create mode 100644 mobile/src/notifications/push-token.ts create mode 100644 mobile/src/notifications/push-tray-dismissal.test.ts create mode 100644 mobile/src/notifications/push-tray-dismissal.ts create mode 100644 mobile/src/notifications/use-remote-push-capable-hosts.test.tsx create mode 100644 mobile/src/notifications/use-remote-push-capable-hosts.ts create mode 100644 mobile/src/session/use-notification-pane-navigation.test.tsx create mode 100644 mobile/src/session/use-notification-pane-navigation.ts create mode 100644 mobile/src/settings/native-notification-delivery-settings.test.tsx create mode 100644 mobile/src/settings/native-notification-delivery-settings.tsx create mode 100644 mobile/src/settings/notification-display-test.test.tsx create mode 100644 mobile/src/settings/notification-display-test.tsx diff --git a/.github/workflows/mobile-ios-release.yml b/.github/workflows/mobile-ios-release.yml index 934b3f694a3..dd9a265d0c8 100644 --- a/.github/workflows/mobile-ios-release.yml +++ b/.github/workflows/mobile-ios-release.yml @@ -94,6 +94,8 @@ jobs: run: node -e 'const fs = require("node:fs"); const { expo } = require("./app.json"); fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${expo.version}\nbuild_number=${expo.ios.buildNumber}\n`)' - name: Expo prebuild + env: + ORCA_IOS_APS_ENVIRONMENT: production run: npx expo prebuild --platform ios --no-install - name: Install CocoaPods diff --git a/mobile/app.config.js b/mobile/app.config.js new file mode 100644 index 00000000000..e4bb110089f --- /dev/null +++ b/mobile/app.config.js @@ -0,0 +1,32 @@ +// Why this file exists: a bare "expo-notifications" plugin entry writes +// `aps-environment: development` into the iOS entitlements, while push-token.ts +// reports `production` for every non-__DEV__ build. A TestFlight or App Store build +// would then register a production APNs token against a sandbox entitlement, and the +// gateway's pushes would be accepted by Apple and delivered nowhere. Deriving the +// mode from an env var the release workflow sets makes the two agree by construction +// instead of relying on the export step to rewrite the entitlement. +// +// app.json stays the source for everything else: Expo reads it first and hands it to +// this function, so the fastlane version/buildNumber rewrite still flows through. +const APS_ENVIRONMENT = + process.env.ORCA_IOS_APS_ENVIRONMENT === 'production' ? 'production' : 'development' + +module.exports = ({ config }) => ({ + ...config, + ios: { + ...config.ios, + entitlements: { ...config.ios?.entitlements, 'aps-environment': APS_ENVIRONMENT } + }, + plugins: (config.plugins ?? []).map((plugin) => + plugin === 'expo-notifications' + ? [ + 'expo-notifications', + { + enableBackgroundRemoteNotifications: true, + mode: APS_ENVIRONMENT, + icon: './assets/notification-icon.png' + } + ] + : plugin + ) +}) diff --git a/mobile/app.json b/mobile/app.json index fc36687d74f..6121923f775 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -75,10 +75,12 @@ "allowBackup": false, "permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"], "package": "com.stably.orca.mobile", - "versionCode": 16 + "versionCode": 16, + "googleServicesFile": "./google-services.json" }, "plugins": [ "expo-router", + "expo-notifications", "./plugins/android-respect-rotation-lock.js", [ "expo-splash-screen", diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 9080cdedcf9..c65008db7ce 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -1,6 +1,10 @@ +import { startAndroidForegroundPushPresentation } from '../src/notifications/android-foreground-push' +import { registerPushDismissalTask } from '../src/notifications/push-background-dismissal' +import { readNativeNotificationData } from '../src/notifications/native-notification-data' +import { setNotificationViewingWorkspace } from '../src/notifications/notification-viewing-policy' import { useCallback, useEffect, useRef } from 'react' import { View, StyleSheet } from 'react-native' -import { Stack, useRouter } from 'expo-router' +import { Stack, useRouter, useGlobalSearchParams, usePathname } from 'expo-router' import { StatusBar } from 'expo-status-bar' import * as SplashScreen from 'expo-splash-screen' import * as Notifications from 'expo-notifications' @@ -10,6 +14,13 @@ import { OrcaLogo } from '../src/components/OrcaLogo' import { RpcClientProvider } from '../src/transport/client-context' import { getNotificationNavigationTarget } from '../src/notifications/notification-routing' import { useOpenNotificationRoute } from '../src/notifications/use-open-notification-route' +import { + isRemotePushTrigger, + pushNotificationRouteData, + foregroundNotificationBehavior +} from '../src/notifications/push-receive' +import { startPushTokenSync } from '../src/notifications/push-registration' +import { ensureDesktopNotificationChannel } from '../src/notifications/desktop-notification-channel' import { loadHostCatalog } from '../src/transport/host-store' import { extractPairingCodeFromUrl } from '../src/transport/pairing' import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery' @@ -19,22 +30,29 @@ import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing // between the native splash and the first React paint. SplashScreen.preventAutoHideAsync() -// Why: without this, expo-notifications silently drops notifications when -// the app is in the foreground. Setting all three to true makes iOS/Android -// display the banner, play the sound, and show the badge even while the -// app is active. This runs once at module load time before any notification -// is scheduled. +// Why at boot and not only on subscribe: the gateway's FCM payload targets the +// 'orca-desktop' channel, and a background push can land before any socket has +// connected. Android drops a notification whose channel does not exist yet. +void ensureDesktopNotificationChannel().catch(() => {}) +void registerPushDismissalTask().catch(() => {}) + +// Register before scheduling so foreground delivery uses the same suppression policy. Notifications.setNotificationHandler({ - handleNotification: async () => ({ - shouldShowBanner: true, - shouldShowList: true, - shouldPlaySound: true, - shouldSetBadge: false - }) + handleNotification: foregroundNotificationBehavior }) export default function RootLayout() { const router = useRouter() + const pathname = usePathname() + const { hostId, worktreeId } = useGlobalSearchParams<{ hostId?: string; worktreeId?: string }>() + useEffect(() => { + setNotificationViewingWorkspace( + pathname.includes('/session/') && typeof hostId === 'string' && typeof worktreeId === 'string' + ? { hostId, worktreeId } + : null + ) + return () => setNotificationViewingWorkspace(null) + }, [pathname, hostId, worktreeId]) const openNotificationRoute = useOpenNotificationRoute() const handledNotificationIdsRef = useRef>(new Set()) @@ -44,6 +62,11 @@ export default function RootLayout() { void recoverMobileRelayPairing() }, []) + // Why: a rolled APNs/FCM token stops delivering silently, so every paired host + // has to be re-registered with the new one as soon as the provider hands it over. + useEffect(() => startPushTokenSync(), []) + useEffect(() => startAndroidForegroundPushPresentation(), []) + // Why: route `orca://pair?...` deep links to the confirm screen so // the same pairing flow runs whether the link arrived via QR scan, // paste, AirDrop, Messages, or `xcrun simctl openurl`. getInitialURL @@ -94,9 +117,18 @@ export default function RootLayout() { } } - async function getNavigationTarget(data: unknown) { + async function getNavigationTarget(notification: Notifications.Notification) { const hosts = await loadHostCatalog().catch(() => null) - return getNotificationNavigationTarget(data, { + const data = readNativeNotificationData(notification.request) + // A gateway push names its host by key fingerprint, not by this device's hostId. + // With no catalog to resolve against, such a push stays unrouted instead of + // falling back to whatever hostId its raw data carries. + const routeData = pushNotificationRouteData( + data, + hosts ?? [], + isRemotePushTrigger(notification.request.trigger) + ) + return getNotificationNavigationTarget(routeData, { knownHostIds: hosts ? new Set(hosts.map((host) => host.id)) : undefined, credentialStatusByHostId: hosts ? new Map(hosts.map((host) => [host.id, host.credentialStatus])) @@ -124,7 +156,7 @@ export default function RootLayout() { } } - const target = await getNavigationTarget(response.notification.request.content.data) + const target = await getNavigationTarget(response.notification) clearLastNotificationResponse() if (disposed) { return diff --git a/mobile/app/mobile-onboarding.tsx b/mobile/app/mobile-onboarding.tsx index 50957a465fc..213a5c982e5 100644 --- a/mobile/app/mobile-onboarding.tsx +++ b/mobile/app/mobile-onboarding.tsx @@ -22,7 +22,7 @@ import { saveDefaultSessionView, type MobileSessionView } from '../src/storage/session-view-preferences' -import { savePushNotificationsEnabled } from '../src/storage/preferences' +import { setRemotePushEnabled } from '../src/notifications/push-registration' const SLIDE_DURATION_MS = 280 @@ -127,7 +127,7 @@ function MobileOnboardingFlow({ setError(null) try { const enabled = choice === 'enable' ? await ensureNotificationPermissions() : false - await savePushNotificationsEnabled(enabled) + await setRemotePushEnabled(enabled) advanceOrContinue() } catch { setError('Notification settings could not be updated. Try again.') diff --git a/mobile/app/notifications.tsx b/mobile/app/notifications.tsx index d6566f66ac7..4bf9f08c25e 100644 --- a/mobile/app/notifications.tsx +++ b/mobile/app/notifications.tsx @@ -1,3 +1,5 @@ +import { NotificationDisplayTest } from '../src/settings/notification-display-test' +import { NativeNotificationDeliverySettings } from '../src/settings/native-notification-delivery-settings' import { useRouter } from 'expo-router' import NotificationsScreen from '../src/settings/notification-settings-screen' import { nativeNotificationSettingsOperations } from '../src/settings/native-notification-settings-operations' @@ -7,6 +9,14 @@ export default function NativeNotificationsRoute() { router.back()} - /> + description="Get agent alerts even when the app is closed. Delivered through Orca’s push service and Apple or Google." + > + {(enabled) => ( + <> + + router.push('/troubleshoot')} /> + + )} + ) } diff --git a/mobile/assets/notification-icon.png b/mobile/assets/notification-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..774110aca7e39a41462adeaa2831b8615bcbd9ef GIT binary patch literal 639 zcmV-_0)YLAP)j@j7ruv=DpLdxUN1HwA*?wc&hQN#D>Q-G>s&BaRW^Pc(~-QHt65F|;GBuSDaNs|2U Zy#}`baJj4^nj`=K002ovPDHLkV1gQi77_pe literal 0 HcmV?d00001 diff --git a/mobile/google-services.json b/mobile/google-services.json new file mode 100644 index 00000000000..4120a97dafc --- /dev/null +++ b/mobile/google-services.json @@ -0,0 +1,39 @@ +{ + "project_info": { + "project_number": "120364513935", + "project_id": "onorca-cloud", + "storage_bucket": "onorca-cloud.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:120364513935:android:1d951dc430aeb9bc664efa", + "android_client_info": { + "package_name": "com.stably.orca.mobile" + } + }, + "oauth_client": [ + { + "client_id": "120364513935-evfa8502bp5r9hn7afhd9i03oibs8223.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyBmT_w0OUQSiVfxblx-F0qlRvGkBBkTNQU" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "120364513935-evfa8502bp5r9hn7afhd9i03oibs8223.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + } + ], + "configuration_version": "1" +} diff --git a/mobile/index.js b/mobile/index.js new file mode 100644 index 00000000000..489f950c650 --- /dev/null +++ b/mobile/index.js @@ -0,0 +1,3 @@ +// Headless notification launches do not mount the router layout. +import './src/notifications/push-background-dismissal' +import 'expo-router/entry' diff --git a/mobile/modules/orca-notification-dismissal/expo-module.config.json b/mobile/modules/orca-notification-dismissal/expo-module.config.json new file mode 100644 index 00000000000..dbf5942dbd5 --- /dev/null +++ b/mobile/modules/orca-notification-dismissal/expo-module.config.json @@ -0,0 +1,7 @@ +{ + "platforms": ["apple"], + "apple": { + "modules": ["OrcaNotificationDismissalModule"], + "appDelegateSubscribers": ["OrcaNotificationDismissalSubscriber"] + } +} diff --git a/mobile/modules/orca-notification-dismissal/ios/OrcaNotificationDismissal.podspec b/mobile/modules/orca-notification-dismissal/ios/OrcaNotificationDismissal.podspec new file mode 100644 index 00000000000..7e2aee8ebd7 --- /dev/null +++ b/mobile/modules/orca-notification-dismissal/ios/OrcaNotificationDismissal.podspec @@ -0,0 +1,15 @@ +Pod::Spec.new do |s| + s.name = 'OrcaNotificationDismissal' + s.version = '0.0.1' + s.summary = 'Native notification dismissal and sequence fencing' + s.description = s.summary + s.license = { :type => 'MIT' } + s.author = 'Orca' + s.homepage = 'https://onorca.dev' + s.source = { :git => 'https://github.com/stablyai/orca.git' } + s.platforms = { :ios => '15.1' } + s.swift_version = '5.9' + s.static_framework = true + s.dependency 'ExpoModulesCore' + s.source_files = '**/*.swift' +end diff --git a/mobile/modules/orca-notification-dismissal/ios/OrcaNotificationDismissalModule.swift b/mobile/modules/orca-notification-dismissal/ios/OrcaNotificationDismissalModule.swift new file mode 100644 index 00000000000..f86fe8bf891 --- /dev/null +++ b/mobile/modules/orca-notification-dismissal/ios/OrcaNotificationDismissalModule.swift @@ -0,0 +1,14 @@ +import ExpoModulesCore + +public class OrcaNotificationDismissalModule: Module { + public func definition() -> ModuleDefinition { + Name("OrcaNotificationDismissal") + AsyncFunction("remember") { (payload: [String: Any]) in + if let identity = PushDismissalIdentity(payload) { PushDismissalLedger.shared.remember(identity) } + } + AsyncFunction("wasDismissed") { (payload: [String: Any]) -> Bool in + guard let identity = PushDismissalIdentity(payload) else { return false } + return PushDismissalLedger.shared.contains(identity) + } + } +} diff --git a/mobile/modules/orca-notification-dismissal/ios/OrcaNotificationDismissalSubscriber.swift b/mobile/modules/orca-notification-dismissal/ios/OrcaNotificationDismissalSubscriber.swift new file mode 100644 index 00000000000..2bd7d939888 --- /dev/null +++ b/mobile/modules/orca-notification-dismissal/ios/OrcaNotificationDismissalSubscriber.swift @@ -0,0 +1,26 @@ +import ExpoModulesCore +import UserNotifications + +public class OrcaNotificationDismissalSubscriber: ExpoAppDelegateSubscriber { + public func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { + guard let payload = userInfo["orca"] as? [String: Any], + payload["kind"] as? String == "dismiss", let fence = PushDismissalIdentity(payload) + else { completionHandler(.noData); return } + PushDismissalLedger.shared.remember(fence) + let center = UNUserNotificationCenter.current() + center.getDeliveredNotifications { notifications in + let ids = notifications.compactMap { notification -> String? in + guard let data = notification.request.content.userInfo["orca"] as? [String: Any], + data["hostFingerprint"] as? String == fence.hostFingerprint, + PushDismissalLedger.shared.containsNotification(data) else { return nil } + return notification.request.identifier + } + center.removeDeliveredNotifications(withIdentifiers: ids) + completionHandler(ids.isEmpty ? .noData : .newData) + } + } +} diff --git a/mobile/modules/orca-notification-dismissal/ios/PushDismissalLedger.swift b/mobile/modules/orca-notification-dismissal/ios/PushDismissalLedger.swift new file mode 100644 index 00000000000..a147b09d599 --- /dev/null +++ b/mobile/modules/orca-notification-dismissal/ios/PushDismissalLedger.swift @@ -0,0 +1,67 @@ +import Foundation +import CoreFoundation + +struct PushDismissalIdentity: Codable { + let hostFingerprint: String + let notificationId: String + let notificationEpoch: String + let notificationSeq: Int64 + + init?(_ value: [String: Any]) { + guard let host = value["hostFingerprint"] as? String, !host.isEmpty, host.count <= 512, + let id = value["notificationId"] as? String, !id.isEmpty, id.count <= 2048, + let epoch = value["notificationEpoch"] as? String, !epoch.isEmpty, epoch.count <= 128, + let seq = value["notificationSeq"] as? NSNumber, + CFGetTypeID(seq) != CFBooleanGetTypeID(), seq.doubleValue.isFinite, + seq.doubleValue >= 0, seq.doubleValue <= 9_007_199_254_740_991, + seq.doubleValue.rounded(.down) == seq.doubleValue else { return nil } + hostFingerprint = host; notificationId = id; notificationEpoch = epoch + notificationSeq = seq.int64Value + } + + func matches(_ other: PushDismissalIdentity) -> Bool { + hostFingerprint == other.hostFingerprint && notificationId == other.notificationId && + notificationEpoch == other.notificationEpoch + } +} + +final class PushDismissalLedger { + static let shared = PushDismissalLedger() + private struct Entry: Codable { let identity: PushDismissalIdentity; let expiresAt: TimeInterval } + private let defaults: UserDefaults + private let lock = NSLock() + private let storageKey = "orca.pushDismissals.v1" + init(defaults: UserDefaults = .standard) { self.defaults = defaults } + + private func read(now: TimeInterval) -> [Entry] { + guard let data = defaults.data(forKey: storageKey), + let entries = try? JSONDecoder().decode([Entry].self, from: data) else { return [] } + return entries.filter { $0.expiresAt > now } + } + + func remember(_ identity: PushDismissalIdentity, now: TimeInterval = Date().timeIntervalSince1970) { + lock.lock(); defer { lock.unlock() } + let entries = read(now: now) + let previous = entries.first { $0.identity.matches(identity) } + let newest = (previous?.identity.notificationSeq ?? -1) > identity.notificationSeq + ? previous!.identity : identity + // Keep every live fence: count-based eviction lets delayed alerts reappear. + let next = entries.filter { !$0.identity.matches(identity) } + + [Entry(identity: newest, expiresAt: now + 86400)] + if let data = try? JSONEncoder().encode(next) { + defaults.set(data, forKey: storageKey) + } + } + + func contains(_ identity: PushDismissalIdentity, now: TimeInterval = Date().timeIntervalSince1970) -> Bool { + lock.lock(); defer { lock.unlock() } + return read(now: now).contains { + $0.identity.matches(identity) && $0.identity.notificationSeq >= identity.notificationSeq + } + } + + func containsNotification(_ payload: [String: Any], now: TimeInterval = Date().timeIntervalSince1970) -> Bool { + guard let identity = PushDismissalIdentity(payload) else { return false } + return contains(identity, now: now) + } +} diff --git a/mobile/modules/orca-notification-dismissal/package.json b/mobile/modules/orca-notification-dismissal/package.json new file mode 100644 index 00000000000..6710e7adbf6 --- /dev/null +++ b/mobile/modules/orca-notification-dismissal/package.json @@ -0,0 +1,5 @@ +{ + "name": "orca-notification-dismissal", + "version": "0.0.1", + "private": true +} diff --git a/mobile/modules/orca-notification-dismissal/tests/PushDismissalLedgerChecks.swift b/mobile/modules/orca-notification-dismissal/tests/PushDismissalLedgerChecks.swift new file mode 100644 index 00000000000..04e1809ea29 --- /dev/null +++ b/mobile/modules/orca-notification-dismissal/tests/PushDismissalLedgerChecks.swift @@ -0,0 +1,44 @@ +import Foundation +@main struct PushDismissalLedgerChecks { + static func main() { + let suite = "orca.qa.dismissal." + UUID().uuidString + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + func payload(_ seq: Int, _ host: String = "qa-host", _ epoch: String = "qa-epoch", _ id: String = "qa-alert") -> [String: Any] { + ["hostFingerprint": host, "notificationId": id, "notificationEpoch": epoch, "notificationSeq": seq] + } + func identity(_ seq: Int, _ host: String = "qa-host", _ epoch: String = "qa-epoch", _ id: String = "qa-alert") -> PushDismissalIdentity { + PushDismissalIdentity(payload(seq, host, epoch, id))! + } + let ledger = PushDismissalLedger(defaults: defaults) + ledger.remember(identity(2), now: 100) + ledger.remember(identity(1), now: 101) + let restored = PushDismissalLedger(defaults: defaults) + precondition(restored.contains(identity(1), now: 102)) + precondition(restored.contains(identity(2), now: 102)) + precondition(!restored.contains(identity(3), now: 102)) + precondition(!restored.contains(identity(1, "other"), now: 102)) + precondition(!restored.contains(identity(1, "qa-host", "other"), now: 102)) + precondition(!restored.contains(identity(1, "qa-host", "qa-epoch", "other"), now: 102)) + precondition(!restored.contains(identity(1), now: 86501)) + precondition(PushDismissalIdentity(["hostFingerprint":"h", "notificationId":"n", "notificationEpoch":"e", "notificationSeq":true]) == nil) + precondition(restored.containsNotification(payload(1), now: 102)) + precondition(!restored.containsNotification(payload(3), now: 102)) + precondition(!restored.containsNotification(payload(1, "other"), now: 102)) + precondition(!restored.containsNotification(payload(1, "qa-host", "other"), now: 102)) + precondition(!restored.containsNotification(payload(1, "qa-host", "qa-epoch", "other"), now: 102)) + precondition(!restored.containsNotification(["hostFingerprint": "qa-host"], now: 102)) + for hosts in [1, 3] { + for index in 0..<520 { + ledger.remember(identity(2, "host-\(index % hosts)", "epoch-\(hosts)", "note-\(index)"), now: 200) + } + let reopened = PushDismissalLedger(defaults: defaults) + for index in [0, 1, 519] { + precondition(reopened.contains(identity(2, "host-\(index % hosts)", "epoch-\(hosts)", "note-\(index)"), now: 201)) + precondition(!reopened.contains(identity(3, "host-\(index % hosts)", "epoch-\(hosts)", "note-\(index)"), now: 201)) + precondition(!reopened.contains(identity(2, "host-\(index % hosts)", "epoch-\(hosts)", "note-\(index)"), now: 86600)) + } + } + print("Native persisted fence: restart, ordering, identity isolation, expiry and invalid sequence checks passed") + } +} diff --git a/mobile/package.json b/mobile/package.json index 13f2f98acf5..d86c0d524ef 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -2,7 +2,7 @@ "name": "orca-mobile", "version": "0.0.1", "private": true, - "main": "expo-router/entry", + "main": "index.js", "scripts": { "start": "node scripts/start-expo.mjs", "android": "expo run:android", @@ -46,6 +46,7 @@ "expo-secure-store": "^55.0.18", "expo-splash-screen": "^55.0.25", "expo-status-bar": "^55.0.6", + "expo-task-manager": "~55.0.20", "lowlight": "^3.3.0", "lucide-react-native": "^1.14.0", "mermaid": "11.17.2", diff --git a/mobile/patches/expo-notifications@55.0.27.patch b/mobile/patches/expo-notifications@55.0.27.patch new file mode 100644 index 00000000000..e1a8d77ea8a --- /dev/null +++ b/mobile/patches/expo-notifications@55.0.27.patch @@ -0,0 +1,36 @@ +diff --git a/build/getDevicePushTokenAsync.js b/build/getDevicePushTokenAsync.js +index f0875c5eaa84d45d646edd1ad5f21a962f68ab02..d93813b2636f1ac5e09081c3207407410a404698 100644 +--- a/build/getDevicePushTokenAsync.js ++++ b/build/getDevicePushTokenAsync.js +@@ -20,8 +20,11 @@ export async function getDevicePushTokenAsync() { + else { + // Create a new Promise and clear it afterwards + nativeTokenPromise = PushTokenManager.getDevicePushTokenAsync(); +- devicePushToken = await nativeTokenPromise; +- nativeTokenPromise = null; ++ try { ++ devicePushToken = await nativeTokenPromise; ++ } finally { ++ nativeTokenPromise = null; ++ } + } + // @ts-ignore: TS thinks Platform.OS could be anything and can't decide what type is it + return { type: Platform.OS, data: devicePushToken }; +diff --git a/src/getDevicePushTokenAsync.ts b/src/getDevicePushTokenAsync.ts +index ab518dff463bc1a92052329ce5ac7c9055cbcf62..ad164f162998b5c1e218f089be82ab474727d68e 100644 +--- a/src/getDevicePushTokenAsync.ts ++++ b/src/getDevicePushTokenAsync.ts +@@ -24,8 +24,11 @@ export async function getDevicePushTokenAsync(): Promise { + } else { + // Create a new Promise and clear it afterwards + nativeTokenPromise = PushTokenManager.getDevicePushTokenAsync(); +- devicePushToken = await nativeTokenPromise; +- nativeTokenPromise = null; ++ try { ++ devicePushToken = await nativeTokenPromise; ++ } finally { ++ nativeTokenPromise = null; ++ } + } + + // @ts-ignore: TS thinks Platform.OS could be anything and can't decide what type is it diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 7cebee89eec..2bb2b314b09 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -8,12 +8,9 @@ overrides: xcode>uuid: 11.1.1 patchedDependencies: - react-native-webview@13.16.2: - hash: de6761dfa76a5491a23e49f1262566a8831cab26736a336fdffc5d4e7d05ff27 - path: patches/react-native-webview@13.16.2.patch - react-native@0.83.10: - hash: 44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d - path: patches/react-native@0.83.10.patch + expo-notifications@55.0.27: ce20843a3daad4185d7e8571788fa323ba4d11984936188790858650a61749c0 + react-native-webview@13.16.2: de6761dfa76a5491a23e49f1262566a8831cab26736a336fdffc5d4e7d05ff27 + react-native@0.83.10: 44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d importers: @@ -24,10 +21,10 @@ importers: version: 1.8.0 '@orca/expo-two-way-audio': specifier: file:./packages/expo-two-way-audio - version: file:packages/expo-two-way-audio(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + version: file:packages/expo-two-way-audio(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) '@react-native-async-storage/async-storage': specifier: ^2.2.0 - version: 2.2.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)) + version: 2.2.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8)) '@xterm/addon-unicode11': specifier: 0.10.0-beta.300 version: 0.10.0-beta.300(@xterm/xterm@6.1.0-beta.303) @@ -42,19 +39,19 @@ importers: version: 6.0.3 expo: specifier: ^55.0.30 - version: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + version: 55.0.30(09911ea01feb2f63557d787d92391924) expo-build-properties: specifier: ^55.0.18 version: 55.0.18(expo@55.0.30) expo-camera: specifier: ^55.0.23 - version: 55.0.23(@types/emscripten@1.41.5)(expo@55.0.30)(react-native-web@0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + version: 55.0.23(@types/emscripten@1.41.5)(expo@55.0.30)(react-native-web@0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) expo-clipboard: specifier: ^55.0.17 - version: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + version: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) expo-constants: specifier: ^55.0.17 - version: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)) + version: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8)) expo-crypto: specifier: ^55.0.19 version: 55.0.19(expo@55.0.30) @@ -66,7 +63,7 @@ importers: version: 55.0.17(expo@55.0.30) expo-file-system: specifier: 55.0.26 - version: 55.0.26(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)) + version: 55.0.26(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8)) expo-haptics: specifier: ^55.0.18 version: 55.0.18(expo@55.0.30) @@ -81,19 +78,19 @@ importers: version: 55.0.8(expo@55.0.30)(react@19.2.8) expo-linking: specifier: ^55.0.17 - version: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + version: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) expo-modules-core: specifier: ~55.0.25 - version: 55.0.25(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + version: 55.0.25(react-native-worklets@0.8.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) expo-network: specifier: ~55.0.18 version: 55.0.18(expo@55.0.30)(react@19.2.8) expo-notifications: specifier: ^55.0.27 - version: 55.0.27(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@6.0.3) + version: 55.0.27(patch_hash=ce20843a3daad4185d7e8571788fa323ba4d11984936188790858650a61749c0)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@6.0.3) expo-router: specifier: ^55.0.18 - version: 55.0.18(4a60a26fd685ffdcc7f556016ae4bc5e) + version: 55.0.18(98b45897562456c6c413f91e81d6c336) expo-secure-store: specifier: ^55.0.18 version: 55.0.18(expo@55.0.30) @@ -102,13 +99,16 @@ importers: version: 55.0.25(expo@55.0.30)(typescript@6.0.3) expo-status-bar: specifier: ^55.0.6 - version: 55.0.6(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + version: 55.0.6(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + expo-task-manager: + specifier: ~55.0.20 + version: 55.0.20(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8)) lowlight: specifier: ^3.3.0 version: 3.3.0 lucide-react-native: specifier: ^1.14.0 - version: 1.14.0(react-native-svg@15.15.4(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + version: 1.14.0(react-native-svg@15.15.4(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) mermaid: specifier: 11.17.2 version: 11.17.2 @@ -120,34 +120,34 @@ importers: version: 19.2.8(react@19.2.8) react-native: specifier: ^0.83.10 - version: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + version: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) react-native-gesture-handler: specifier: ^2.31.2 - version: 2.31.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + version: 2.31.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) react-native-reanimated: specifier: 4.3.4 - version: 4.3.4(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + version: 4.3.4(react-native-worklets@0.8.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) react-native-safe-area-context: specifier: ^5.7.0 - version: 5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + version: 5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) react-native-screens: specifier: ^4.24.0 - version: 4.24.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + version: 4.24.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) react-native-svg: specifier: ^15.15.4 - version: 15.15.4(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + version: 15.15.4(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) react-native-uitextview: specifier: 2.2.0 - version: 2.2.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + version: 2.2.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) react-native-web: specifier: ^0.21.2 version: 0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react-native-webview: specifier: 13.16.2 - version: 13.16.2(patch_hash=de6761dfa76a5491a23e49f1262566a8831cab26736a336fdffc5d4e7d05ff27)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + version: 13.16.2(patch_hash=de6761dfa76a5491a23e49f1262566a8831cab26736a336fdffc5d4e7d05ff27)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) react-native-worklets: specifier: ^0.8.3 - version: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + version: 0.8.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) tweetnacl: specifier: ^1.0.3 version: 1.0.3 @@ -166,7 +166,7 @@ importers: version: 19.2.14 '@types/react-native': specifier: ^0.73.0 - version: 0.73.0(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + version: 0.73.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) '@types/react-test-renderer': specifier: 19.1.0 version: 19.1.0 @@ -181,7 +181,7 @@ importers: version: 0.25.4 expo-module-scripts: specifier: ^55.0.2 - version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) + version: 55.0.2(@babel/core@7.29.7(supports-color@8.1.1))(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(esbuild@0.25.4)(eslint@9.39.4(supports-color@8.1.1))(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0)(supports-color@8.1.1))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) happy-dom: specifier: ^20.11.8 version: 20.11.8 @@ -205,7 +205,7 @@ importers: version: 8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0) vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@26.4.0)(happy-dom@20.11.8)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.11(@types/node@26.4.0)(happy-dom@20.11.8)(jsdom@20.0.3(supports-color@8.1.1))(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0)) packages: @@ -1632,8 +1632,8 @@ packages: react-native: optional: true - '@expo/dom-webview@55.0.5': - resolution: {integrity: sha512-lt3uxYOCk3wmWvtOOvsC35CKGbDAOx5C2EaY8SH1JVSfBzqmF8Cs0Xp1MPxncDPMyxpMiWx5SvvV/iLF1rJU4A==} + '@expo/dom-webview@55.0.6': + resolution: {integrity: sha512-ZNm8tiNEZysxrr36J0x4mOCGyJDcaIvL/3tMxBz0VJIJDcV19xjuJAhJQxHovu+jKx6s9tRyEAINa1mdrzV39g==} peerDependencies: expo: '*' react: '*' @@ -1669,14 +1669,6 @@ packages: '@expo/local-build-cache-provider@55.0.16': resolution: {integrity: sha512-/m2kb/+G2ryZ60FPZcuiLXzXp55p/X8QPXuU5CV/il0sSJcY0sQFICfM9ApokzHtzNQ0nDQOBUoQY3weilgP2g==} - '@expo/log-box@55.0.11': - resolution: {integrity: sha512-JQHFLWkskIbJi6cxYMjErx8lQqfFJilDQLKmdTO3m3YkdmN9GE/CrzjOfVlCG0DGEGZJ90br0pGKvGPdXNsHKw==} - peerDependencies: - '@expo/dom-webview': ^55.0.5 - expo: '*' - react: '*' - react-native: '*' - '@expo/log-box@55.0.13': resolution: {integrity: sha512-pV623uwyKjw/L1HVWOpwWOu/ISLH1+c+ESVv30alQMbEaE3cLcwcQ+UnHiAGayMBNMQwK57eckOgH40RBXHfCA==} peerDependencies: @@ -1693,8 +1685,8 @@ packages: expo: optional: true - '@expo/metro-runtime@55.0.10': - resolution: {integrity: sha512-7v+ldTvMWRa1ml83Jel9W2f8qT/NZZWrlHaEjf29nb72JTEO50+Xac9PWLo+X3LCDAAuyYuBGKYXOJwfqxV0fQ==} + '@expo/metro-runtime@55.0.12': + resolution: {integrity: sha512-EeqXrRBvChdt6+brlUkZM5749QoS7OlN7Zsn/AT8hhGV+xNKglirVRkcKQFmKqPgjgmNxfwgLJ6ddanwZ9dapg==} peerDependencies: expo: '*' react: '*' @@ -4487,6 +4479,12 @@ packages: react: '*' react-native: '*' + expo-task-manager@55.0.20: + resolution: {integrity: sha512-yxiERbkqibZYDArQ1QKbezKn7YkCxLyaDeiIO8DcyOqopBvUmXDkF3b7FtesW+YnAlg3g223geV8YiW1I4Suew==} + peerDependencies: + expo: '*' + react-native: '*' + expo-updates-interface@55.1.6: resolution: {integrity: sha512-evxNpagCkjT3lE6bGV570TFzRtKuIuLY8I37RYHoriXCJ+ZKCN1hbmklK29uAixya+BxGpeTI2K4FqYeJLvfrw==} peerDependencies: @@ -6876,6 +6874,9 @@ packages: resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} engines: {node: '>=4'} + unimodules-app-loader@55.0.5: + resolution: {integrity: sha512-2eLjtaAVQTK3EeiUAgRbfEnX78f6cMtw5Js8Ri4OcEdkrozsmvG3Wu8YVfr6kfhea17FHZkKZmO1m4dL/Ky2Bg==} + universalify@0.2.0: resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} engines: {node: '>= 4.0.0'} @@ -7233,9 +7234,9 @@ snapshots: package-manager-detector: 1.8.0 tinyexec: 1.1.2 - '@babel/cli@7.28.6(@babel/core@7.29.7)': + '@babel/cli@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@jridgewell/trace-mapping': 0.3.31 commander: 6.2.1 convert-source-map: 2.0.0 @@ -7267,20 +7268,20 @@ snapshots: '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.7': + '@babel/core@7.29.7(supports-color@8.1.1)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@8.1.1) '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -7335,52 +7336,52 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.29.3(@babel/core@7.29.7)': + '@babel/helper-create-class-features-plugin@7.29.3(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-member-expression-to-functions': 7.28.5 '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.7) + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 '@babel/traverse': 7.29.0 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@8.1.1) '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@8.1.1) + '@babel/traverse': 7.29.8(supports-color@8.1.1) semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.29.7)': + '@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.27.3 regexpu-core: 6.4.0 semver: 6.3.1 - '@babel/helper-create-regexp-features-plugin@7.29.7(@babel/core@7.29.7)': + '@babel/helper-create-regexp-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.29.7 regexpu-core: 6.4.0 semver: 6.3.1 - '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.7)': + '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) lodash.debounce: 4.0.8 resolve: 1.22.12 transitivePeerDependencies: @@ -7397,9 +7398,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-member-expression-to-functions@7.29.7': + '@babel/helper-member-expression-to-functions@7.29.7(supports-color@8.1.1)': dependencies: - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@8.1.1) '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -7411,28 +7412,28 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.29.7': + '@babel/helper-module-imports@7.29.7(supports-color@8.1.1)': dependencies: - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@8.1.1) '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.7)': + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-module-imports': 7.28.6 '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-module-imports': 7.29.7(supports-color@8.1.1) '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -7448,39 +7449,39 @@ snapshots: '@babel/helper-plugin-utils@7.29.7': {} - '@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.29.7)': + '@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-wrap-function': 7.28.6 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/helper-remap-async-to-generator@7.29.7(@babel/core@7.29.7)': + '@babel/helper-remap-async-to-generator@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-wrap-function': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/helper-wrap-function': 7.29.7(supports-color@8.1.1) + '@babel/traverse': 7.29.8(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.7)': + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-member-expression-to-functions': 7.28.5 '@babel/helper-optimise-call-expression': 7.27.1 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@8.1.1) '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -7491,9 +7492,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + '@babel/helper-skip-transparent-expression-wrappers@7.29.7(supports-color@8.1.1)': dependencies: - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@8.1.1) '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -7513,15 +7514,15 @@ snapshots: '@babel/helper-wrap-function@7.28.6': dependencies: '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@8.1.1) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-wrap-function@7.29.7': + '@babel/helper-wrap-function@7.29.7(supports-color@8.1.1)': dependencies: '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@8.1.1) '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -7550,887 +7551,887 @@ snapshots: dependencies: '@babel/types': 7.29.8 - '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5(@babel/core@7.29.7)': + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.3(@babel/core@7.29.7)': + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.3(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: - supports-color - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) transitivePeerDependencies: - supports-color - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.7)': + '@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) transitivePeerDependencies: - supports-color - '@babel/plugin-proposal-export-default-from@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-proposal-export-default-from@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-proposal-export-default-from@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-proposal-export-default-from@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7)': + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) - '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7)': + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7)': + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7)': + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7)': + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.7)': + '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-export-default-from@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-syntax-export-default-from@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-export-default-from@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-syntax-export-default-from@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-flow@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-syntax-flow@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-flow@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-syntax-flow@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-import-assertions@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-syntax-import-assertions@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)': + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7)': + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7)': + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7)': + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7)': + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7)': + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7)': + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7)': + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7)': + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7)': + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.7)': + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-async-generator-functions@7.29.0(@babel/core@7.29.7)': + '@babel/plugin-transform-async-generator-functions@7.29.0(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.7) + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-async-generator-functions@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-async-generator-functions@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7) - '@babel/traverse': 7.29.8 + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/traverse': 7.29.8(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-async-to-generator@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-async-to-generator@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-module-imports': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.7) + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-async-to-generator@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-async-to-generator@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-module-imports': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7) + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-block-scoped-functions@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-block-scoped-functions@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-block-scoping@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-block-scoping@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-block-scoping@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-block-scoping@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-class-properties@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-class-properties@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-class-properties@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-class-properties@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-class-static-block@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-class-static-block@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-classes@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-classes@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-globals': 7.28.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.7) + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-classes@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-classes@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.29.7 '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-globals': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) - '@babel/traverse': 7.29.8 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/traverse': 7.29.8(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-computed-properties@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-computed-properties@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 '@babel/template': 7.28.6 - '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.7)': + '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-dotall-regex@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-dotall-regex@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-duplicate-keys@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-duplicate-keys@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.0(@babel/core@7.29.7)': + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.0(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-dynamic-import@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-dynamic-import@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-explicit-resource-management@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-explicit-resource-management@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.7) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-exponentiation-operator@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-exponentiation-operator@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-export-namespace-from@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-export-namespace-from@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-flow-strip-types@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-flow-strip-types@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-flow': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-syntax-flow': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) - '@babel/plugin-transform-flow-strip-types@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-flow-strip-types@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-flow': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-flow': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) - '@babel/plugin-transform-for-of@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-for-of@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-for-of@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-for-of@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-function-name@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-function-name@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-json-strings@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-json-strings@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-literals@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-literals@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-logical-assignment-operators@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-logical-assignment-operators@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-member-expression-literals@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-member-expression-literals@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-modules-amd@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-modules-amd@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-systemjs@7.29.4(@babel/core@7.29.7)': + '@babel/plugin-transform-modules-systemjs@7.29.4(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-identifier': 7.28.5 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-umd@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-modules-umd@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-named-capturing-groups-regex@7.29.0(@babel/core@7.29.7)': + '@babel/plugin-transform-named-capturing-groups-regex@7.29.0(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-new-target@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-new-target@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-nullish-coalescing-operator@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-nullish-coalescing-operator@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-nullish-coalescing-operator@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-nullish-coalescing-operator@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-numeric-separator@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-numeric-separator@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-object-rest-spread@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-object-rest-spread@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.7) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.7) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-object-super@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-object-super@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.7) + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-optional-catch-binding@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-optional-catch-binding@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-optional-catch-binding@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-optional-catch-binding@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-optional-chaining@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-optional-chaining@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-optional-chaining@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-optional-chaining@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.29.7)': + '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-private-methods@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-private-methods@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-private-methods@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-private-methods@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-private-property-in-object@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-private-property-in-object@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.7) + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-private-property-in-object@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-private-property-in-object@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-property-literals@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-property-literals@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.29.7)': + '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-react-display-name@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-react-display-name@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-react-jsx@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-react-jsx@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-module-imports': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-react-jsx@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-react-jsx@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-module-imports': 7.29.7 + '@babel/helper-module-imports': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-react-pure-annotations@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-react-pure-annotations@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-regenerator@7.29.0(@babel/core@7.29.7)': + '@babel/plugin-transform-regenerator@7.29.0(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-regenerator@7.29.8(@babel/core@7.29.7)': + '@babel/plugin-transform-regenerator@7.29.8(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-regexp-modifiers@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-regexp-modifiers@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-reserved-words@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-reserved-words@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-runtime@7.29.0(@babel/core@7.29.7)': + '@babel/plugin-transform-runtime@7.29.0(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-module-imports': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 - babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.7) - babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.7) - babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.7) + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-module-imports': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.7) - babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.7) - babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.7) + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-shorthand-properties@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-shorthand-properties@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-spread@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-spread@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-sticky-regex@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-sticky-regex@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-template-literals@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-template-literals@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-typeof-symbol@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-typeof-symbol@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.7) + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@8.1.1) + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-unicode-escapes@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-unicode-escapes@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-unicode-property-regex@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-unicode-property-regex@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-unicode-regex@7.27.1(@babel/core@7.29.7)': + '@babel/plugin-transform-unicode-regex@7.27.1(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-unicode-regex@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-unicode-regex@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-unicode-sets-regex@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-transform-unicode-sets-regex@7.28.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 - '@babel/preset-env@7.29.5(@babel/core@7.29.7)': + '@babel/preset-env@7.29.5(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: '@babel/compat-data': 7.29.3 - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.28.5(@babel/core@7.29.7) - '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array': 7.29.3(@babel/core@7.29.7) - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7) - '@babel/plugin-syntax-import-assertions': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.7) - '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.29.7) - '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-block-scoped-functions': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.7) - '@babel/plugin-transform-dotall-regex': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-duplicate-keys': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.7) - '@babel/plugin-transform-dynamic-import': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-explicit-resource-management': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-exponentiation-operator': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-json-strings': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-logical-assignment-operators': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-member-expression-literals': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-modules-amd': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-modules-systemjs': 7.29.4(@babel/core@7.29.7) - '@babel/plugin-transform-modules-umd': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.7) - '@babel/plugin-transform-new-target': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-numeric-separator': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-object-super': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.7) - '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-property-literals': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.29.7) - '@babel/plugin-transform-regexp-modifiers': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-reserved-words': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-typeof-symbol': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-unicode-escapes': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-unicode-property-regex': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-unicode-sets-regex': 7.28.6(@babel/core@7.29.7) - '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.7) - babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.7) - babel-plugin-polyfill-corejs3: 0.14.2(@babel/core@7.29.7) - babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.7) + '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array': 7.29.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-import-assertions': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-block-scoped-functions': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-dotall-regex': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-duplicate-keys': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-dynamic-import': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-explicit-resource-management': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-exponentiation-operator': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-json-strings': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-logical-assignment-operators': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-member-expression-literals': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-modules-amd': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-modules-systemjs': 7.29.4(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-modules-umd': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-new-target': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-numeric-separator': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-object-super': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-property-literals': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-regexp-modifiers': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-reserved-words': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-typeof-symbol': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-unicode-escapes': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-unicode-property-regex': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-unicode-sets-regex': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.7(supports-color@8.1.1)) + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + babel-plugin-polyfill-corejs3: 0.14.2(@babel/core@7.29.7(supports-color@8.1.1)) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) core-js-compat: 3.49.0 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.7)': + '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 '@babel/types': 7.29.0 esutils: 2.0.3 - '@babel/preset-react@7.28.5(@babel/core@7.29.7)': + '@babel/preset-react@7.28.5(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-react-pure-annotations': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-react-pure-annotations': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) transitivePeerDependencies: - supports-color - '@babel/preset-typescript@7.28.5(@babel/core@7.29.7)': + '@babel/preset-typescript@7.28.5(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) transitivePeerDependencies: - supports-color @@ -8458,11 +8459,11 @@ snapshots: '@babel/parser': 7.29.3 '@babel/template': 7.28.6 '@babel/types': 7.29.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/traverse@7.29.7': + '@babel/traverse@7.29.7(supports-color@8.1.1)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 @@ -8470,11 +8471,11 @@ snapshots: '@babel/parser': 7.29.7 '@babel/template': 7.29.7 '@babel/types': 7.29.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/traverse@7.29.8': + '@babel/traverse@7.29.8(supports-color@8.1.1)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.8 @@ -8482,7 +8483,7 @@ snapshots: '@babel/parser': 7.29.8 '@babel/template': 7.29.7 '@babel/types': 7.29.8 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -8680,22 +8681,22 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true - '@eslint-community/eslint-utils@4.10.1(eslint@9.39.4)': + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.4(supports-color@8.1.1))': dependencies: - eslint: 9.39.4 + eslint: 9.39.4(supports-color@8.1.1) eslint-visitor-keys: 3.4.3 - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(supports-color@8.1.1))': dependencies: - eslint: 9.39.4 + eslint: 9.39.4(supports-color@8.1.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.2': + '@eslint/config-array@0.21.2(supports-color@8.1.1)': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -8708,10 +8709,10 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.6': + '@eslint/eslintrc@3.3.6(supports-color@8.1.1)': dependencies: ajv: 6.15.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -8733,7 +8734,7 @@ snapshots: '@expo-google-fonts/material-symbols@0.4.34': {} - '@expo/cli@55.0.36(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-constants@55.0.17)(expo-font@55.0.8)(expo-router@55.0.18)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@6.0.3)': + '@expo/cli@55.0.36(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.12)(expo-constants@55.0.17)(expo-font@55.0.8)(expo-router@55.0.18)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@6.0.3)': dependencies: '@expo/code-signing-certificates': 0.0.6 '@expo/config': 55.0.21(typescript@6.0.3) @@ -8742,7 +8743,7 @@ snapshots: '@expo/env': 2.1.3 '@expo/image-utils': 0.8.17(typescript@6.0.3) '@expo/json-file': 10.2.0 - '@expo/log-box': 55.0.13(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@expo/log-box': 55.0.13(@expo/dom-webview@55.0.6)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) '@expo/metro': 55.1.2 '@expo/metro-config': 55.0.27(expo@55.0.30)(typescript@6.0.3) '@expo/osascript': 2.7.0 @@ -8750,7 +8751,7 @@ snapshots: '@expo/plist': 0.5.4 '@expo/prebuild-config': 55.0.22(expo@55.0.30)(typescript@6.0.3) '@expo/require-utils': 55.0.8(typescript@6.0.3) - '@expo/router-server': 55.0.19(@expo/metro-runtime@55.0.10)(expo-constants@55.0.17)(expo-font@55.0.8)(expo-router@55.0.18)(expo-server@55.0.12)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@expo/router-server': 55.0.19(@expo/metro-runtime@55.0.12)(expo-constants@55.0.17)(expo-font@55.0.8)(expo-router@55.0.18)(expo-server@55.0.12)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@expo/schema-utils': 55.0.5 '@expo/spawn-async': 1.8.0 '@expo/ws-tunnel': 1.0.6 @@ -8765,10 +8766,10 @@ snapshots: chalk: 4.1.2 ci-info: 3.9.0 compression: 1.8.1 - connect: 3.7.0 - debug: 4.4.3 + connect: 3.7.0(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) dnssd-advertise: 1.1.6 - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) expo-server: 55.0.12 fetch-nodeshim: 0.4.10 getenv: 2.0.0 @@ -8795,8 +8796,8 @@ snapshots: ws: 8.21.3 zod: 3.25.76 optionalDependencies: - expo-router: 55.0.18(4a60a26fd685ffdcc7f556016ae4bc5e) - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + expo-router: 55.0.18(98b45897562456c6c413f91e81d6c336) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) transitivePeerDependencies: - '@expo/dom-webview' - '@expo/metro-runtime' @@ -8821,7 +8822,7 @@ snapshots: '@expo/plist': 0.5.4 '@expo/sdk-runtime-versions': 1.0.0 chalk: 4.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) getenv: 2.0.0 glob: 13.0.6 resolve-from: 5.0.0 @@ -8839,7 +8840,7 @@ snapshots: '@expo/plist': 0.5.3 '@expo/sdk-runtime-versions': 1.0.0 chalk: 4.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) getenv: 2.0.0 glob: 13.0.6 resolve-from: 5.0.0 @@ -8893,23 +8894,23 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/devtools@55.0.3(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': + '@expo/devtools@55.0.3(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': dependencies: chalk: 4.1.2 optionalDependencies: react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) - '@expo/dom-webview@55.0.5(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': + '@expo/dom-webview@55.0.6(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) '@expo/env@2.1.3': dependencies: chalk: 4.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) getenv: 2.0.0 transitivePeerDependencies: - supports-color @@ -8917,7 +8918,7 @@ snapshots: '@expo/env@2.4.2': dependencies: chalk: 4.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) getenv: 2.0.0 transitivePeerDependencies: - supports-color @@ -8928,7 +8929,7 @@ snapshots: '@expo/spawn-async': 1.8.0 arg: 5.0.2 chalk: 4.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) getenv: 2.0.0 glob: 13.0.6 ignore: 5.3.2 @@ -8979,28 +8980,19 @@ snapshots: - supports-color - typescript - '@expo/log-box@55.0.11(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': + '@expo/log-box@55.0.13(@expo/dom-webview@55.0.6)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': dependencies: - '@expo/dom-webview': 55.0.5(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@expo/dom-webview': 55.0.6(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) anser: 1.4.10 - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) - stacktrace-parser: 0.1.11 - - '@expo/log-box@55.0.13(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': - dependencies: - '@expo/dom-webview': 55.0.5(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - anser: 1.4.10 - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) - react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) stacktrace-parser: 0.1.11 '@expo/metro-config@55.0.27(expo@55.0.30)(typescript@6.0.3)': dependencies: '@babel/code-frame': 7.29.7 - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/generator': 7.29.8 '@expo/config': 55.0.21(typescript@6.0.3) '@expo/env': 2.1.3 @@ -9009,7 +9001,7 @@ snapshots: '@expo/spawn-async': 1.8.0 browserslist: 4.28.8 chalk: 4.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) getenv: 2.0.0 glob: 13.0.6 hermes-parser: 0.32.1 @@ -9019,21 +9011,21 @@ snapshots: postcss: 8.5.25 resolve-from: 5.0.0 optionalDependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) transitivePeerDependencies: - bufferutil - supports-color - typescript - utf-8-validate - '@expo/metro-runtime@55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': + '@expo/metro-runtime@55.0.12(@expo/dom-webview@55.0.6)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': dependencies: - '@expo/log-box': 55.0.11(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@expo/log-box': 55.0.13(@expo/dom-webview@55.0.6)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) anser: 1.4.10 - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) pretty-format: 29.7.0 react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) stacktrace-parser: 0.1.11 whatwg-fetch: 3.6.20 optionalDependencies: @@ -9043,20 +9035,20 @@ snapshots: '@expo/metro@55.1.2': dependencies: - metro: 0.83.8 - metro-babel-transformer: 0.83.8 - metro-cache: 0.83.8 + metro: 0.83.8(supports-color@8.1.1) + metro-babel-transformer: 0.83.8(supports-color@8.1.1) + metro-cache: 0.83.8(supports-color@8.1.1) metro-cache-key: 0.83.8 - metro-config: 0.83.8 + metro-config: 0.83.8(supports-color@8.1.1) metro-core: 0.83.8 - metro-file-map: 0.83.8 + metro-file-map: 0.83.8(supports-color@8.1.1) metro-minify-terser: 0.83.8 metro-resolver: 0.83.8 metro-runtime: 0.83.8 - metro-source-map: 0.83.8 + metro-source-map: 0.83.8(supports-color@8.1.1) metro-symbolicate: 0.83.8 - metro-transform-plugins: 0.83.8 - metro-transform-worker: 0.83.8 + metro-transform-plugins: 0.83.8(supports-color@8.1.1) + metro-transform-worker: 0.83.8(supports-color@8.1.1) transitivePeerDependencies: - bufferutil - supports-color @@ -9099,8 +9091,8 @@ snapshots: '@expo/image-utils': 0.8.17(typescript@6.0.3) '@expo/json-file': 10.2.0 '@react-native/normalize-colors': 0.83.10 - debug: 4.4.3 - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + debug: 4.4.3(supports-color@8.1.1) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) resolve-from: 5.0.0 semver: 7.8.5 xml2js: 0.6.0 @@ -9111,8 +9103,8 @@ snapshots: '@expo/require-utils@55.0.5(typescript@5.9.3)': dependencies: '@babel/code-frame': 7.29.0 - '@babel/core': 7.29.7 - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -9121,24 +9113,24 @@ snapshots: '@expo/require-utils@55.0.8(typescript@6.0.3)': dependencies: '@babel/code-frame': 7.29.7 - '@babel/core': 7.29.7 - '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) optionalDependencies: typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@expo/router-server@55.0.19(@expo/metro-runtime@55.0.10)(expo-constants@55.0.17)(expo-font@55.0.8)(expo-router@55.0.18)(expo-server@55.0.12)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@expo/router-server@55.0.19(@expo/metro-runtime@55.0.12)(expo-constants@55.0.17)(expo-font@55.0.8)(expo-router@55.0.18)(expo-server@55.0.12)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - debug: 4.4.3 - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) - expo-constants: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)) - expo-font: 55.0.8(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + debug: 4.4.3(supports-color@8.1.1) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) + expo-constants: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8)) + expo-font: 55.0.8(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) expo-server: 55.0.12 react: 19.2.8 optionalDependencies: - '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - expo-router: 55.0.18(4a60a26fd685ffdcc7f556016ae4bc5e) + '@expo/metro-runtime': 55.0.12(@expo/dom-webview@55.0.6)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + expo-router: 55.0.18(98b45897562456c6c413f91e81d6c336) react-dom: 19.2.8(react@19.2.8) transitivePeerDependencies: - supports-color @@ -9157,11 +9149,11 @@ snapshots: '@expo/sudo-prompt@9.3.2': {} - '@expo/vector-icons@15.1.1(expo-font@55.0.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': + '@expo/vector-icons@15.1.1(expo-font@55.0.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': dependencies: - expo-font: 55.0.8(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + expo-font: 55.0.8(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) '@expo/ws-tunnel@1.0.6': {} @@ -9216,12 +9208,12 @@ snapshots: jest-util: 29.7.0 slash: 3.0.0 - '@jest/core@29.7.0': + '@jest/core@29.7.0(supports-color@8.1.1)': dependencies: '@jest/console': 29.7.0 - '@jest/reporters': 29.7.0 + '@jest/reporters': 29.7.0(supports-color@8.1.1) '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 + '@jest/transform': 29.7.0(supports-color@8.1.1) '@jest/types': 29.6.3 '@types/node': 26.4.0 ansi-escapes: 4.3.2 @@ -9230,15 +9222,15 @@ snapshots: exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@26.4.0) + jest-config: 29.7.0(@types/node@26.4.0)(supports-color@8.1.1) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 jest-resolve: 29.7.0 - jest-resolve-dependencies: 29.7.0 - jest-runner: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 + jest-resolve-dependencies: 29.7.0(supports-color@8.1.1) + jest-runner: 29.7.0(supports-color@8.1.1) + jest-runtime: 29.7.0(supports-color@8.1.1) + jest-snapshot: 29.7.0(supports-color@8.1.1) jest-util: 29.7.0 jest-validate: 29.7.0 jest-watcher: 29.7.0 @@ -9268,10 +9260,10 @@ snapshots: dependencies: jest-get-type: 29.6.3 - '@jest/expect@29.7.0': + '@jest/expect@29.7.0(supports-color@8.1.1)': dependencies: expect: 29.7.0 - jest-snapshot: 29.7.0 + jest-snapshot: 29.7.0(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -9286,21 +9278,21 @@ snapshots: '@jest/get-type@30.1.0': {} - '@jest/globals@29.7.0': + '@jest/globals@29.7.0(supports-color@8.1.1)': dependencies: '@jest/environment': 29.7.0 - '@jest/expect': 29.7.0 + '@jest/expect': 29.7.0(supports-color@8.1.1) '@jest/types': 29.6.3 jest-mock: 29.7.0 transitivePeerDependencies: - supports-color - '@jest/reporters@29.7.0': + '@jest/reporters@29.7.0(supports-color@8.1.1)': dependencies: '@bcoe/v8-coverage': 0.2.3 '@jest/console': 29.7.0 '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 + '@jest/transform': 29.7.0(supports-color@8.1.1) '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.31 '@types/node': 26.4.0 @@ -9310,9 +9302,9 @@ snapshots: glob: 7.2.3 graceful-fs: 4.2.11 istanbul-lib-coverage: 3.2.2 - istanbul-lib-instrument: 6.0.3 + istanbul-lib-instrument: 6.0.3(supports-color@8.1.1) istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 4.0.1 + istanbul-lib-source-maps: 4.0.1(supports-color@8.1.1) istanbul-reports: 3.2.0 jest-message-util: 29.7.0 jest-util: 29.7.0 @@ -9352,12 +9344,12 @@ snapshots: jest-haste-map: 29.7.0 slash: 3.0.0 - '@jest/transform@29.7.0': + '@jest/transform@29.7.0(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.31 - babel-plugin-istanbul: 6.1.1 + babel-plugin-istanbul: 6.1.1(supports-color@8.1.1) chalk: 4.1.2 convert-source-map: 2.0.0 fast-json-stable-stringify: 2.1.0 @@ -9421,11 +9413,11 @@ snapshots: '@noble/hashes@1.8.0': {} - '@orca/expo-two-way-audio@file:packages/expo-two-way-audio(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': + '@orca/expo-two-way-audio@file:packages/expo-two-way-audio(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) '@oxc-project/types@0.137.0': {} @@ -9737,178 +9729,178 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@react-native-async-storage/async-storage@2.2.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))': + '@react-native-async-storage/async-storage@2.2.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))': dependencies: merge-options: 3.0.4 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) '@react-native/assets-registry@0.83.10': {} - '@react-native/babel-plugin-codegen@0.83.10(@babel/core@7.29.7)': + '@react-native/babel-plugin-codegen@0.83.10(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/traverse': 7.29.8 - '@react-native/codegen': 0.83.10(@babel/core@7.29.7) + '@babel/traverse': 7.29.8(supports-color@8.1.1) + '@react-native/codegen': 0.83.10(@babel/core@7.29.7(supports-color@8.1.1)) transitivePeerDependencies: - '@babel/core' - supports-color - '@react-native/babel-plugin-codegen@0.83.6(@babel/core@7.29.7)': + '@react-native/babel-plugin-codegen@0.83.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/traverse': 7.29.7 - '@react-native/codegen': 0.83.6(@babel/core@7.29.7) + '@babel/traverse': 7.29.7(supports-color@8.1.1) + '@react-native/codegen': 0.83.6(@babel/core@7.29.7(supports-color@8.1.1)) transitivePeerDependencies: - '@babel/core' - supports-color - '@react-native/babel-plugin-codegen@0.85.2(@babel/core@7.29.7)': + '@react-native/babel-plugin-codegen@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/traverse': 7.29.8 - '@react-native/codegen': 0.85.2(@babel/core@7.29.7) + '@babel/traverse': 7.29.8(supports-color@8.1.1) + '@react-native/codegen': 0.85.2(@babel/core@7.29.7(supports-color@8.1.1)) transitivePeerDependencies: - '@babel/core' - supports-color - '@react-native/babel-preset@0.83.10(@babel/core@7.29.7)': + '@react-native/babel-preset@0.83.10(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-proposal-export-default-from': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-export-default-from': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-flow-strip-types': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-logical-assignment-operators': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-numeric-separator': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.7) - '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-display-name': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-regenerator': 7.29.8(@babel/core@7.29.7) - '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/plugin-proposal-export-default-from': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-export-default-from': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-flow-strip-types': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-logical-assignment-operators': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-numeric-separator': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-react-display-name': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-regenerator': 7.29.8(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/template': 7.29.7 - '@react-native/babel-plugin-codegen': 0.83.10(@babel/core@7.29.7) + '@react-native/babel-plugin-codegen': 0.83.10(@babel/core@7.29.7(supports-color@8.1.1)) babel-plugin-syntax-hermes-parser: 0.32.0 - babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7) + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7(supports-color@8.1.1)) react-refresh: 0.14.2 transitivePeerDependencies: - supports-color - '@react-native/babel-preset@0.83.6(@babel/core@7.29.7)': + '@react-native/babel-preset@0.83.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-export-default-from': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.29.7) - '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.7) - '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-logical-assignment-operators': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.7) - '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-numeric-separator': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.7) - '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.29.7) - '@babel/plugin-transform-runtime': 7.29.0(@babel/core@7.29.7) - '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-export-default-from': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-logical-assignment-operators': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-numeric-separator': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-runtime': 7.29.0(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/template': 7.28.6 - '@react-native/babel-plugin-codegen': 0.83.6(@babel/core@7.29.7) + '@react-native/babel-plugin-codegen': 0.83.6(@babel/core@7.29.7(supports-color@8.1.1)) babel-plugin-syntax-hermes-parser: 0.32.0 - babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7) + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7(supports-color@8.1.1)) react-refresh: 0.14.2 transitivePeerDependencies: - supports-color - '@react-native/babel-preset@0.85.2(@babel/core@7.29.7)': + '@react-native/babel-preset@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-proposal-export-default-from': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-export-default-from': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-flow-strip-types': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-display-name': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-regenerator': 7.29.8(@babel/core@7.29.7) - '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) - '@react-native/babel-plugin-codegen': 0.85.2(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/plugin-proposal-export-default-from': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-export-default-from': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-flow-strip-types': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-react-display-name': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-regenerator': 7.29.8(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@react-native/babel-plugin-codegen': 0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) babel-plugin-syntax-hermes-parser: 0.33.3 - babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7) + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7(supports-color@8.1.1)) react-refresh: 0.14.2 transitivePeerDependencies: - supports-color - '@react-native/codegen@0.83.10(@babel/core@7.29.7)': + '@react-native/codegen@0.83.10(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/parser': 7.29.8 glob: 7.2.3 hermes-parser: 0.32.0 @@ -9916,9 +9908,9 @@ snapshots: nullthrows: 1.1.1 yargs: 17.7.3 - '@react-native/codegen@0.83.6(@babel/core@7.29.7)': + '@react-native/codegen@0.83.6(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/parser': 7.29.7 glob: 7.2.3 hermes-parser: 0.32.0 @@ -9926,9 +9918,9 @@ snapshots: nullthrows: 1.1.1 yargs: 17.7.2 - '@react-native/codegen@0.85.2(@babel/core@7.29.7)': + '@react-native/codegen@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/parser': 7.29.8 hermes-parser: 0.33.3 invariant: 2.2.4 @@ -9936,17 +9928,17 @@ snapshots: tinyglobby: 0.2.17 yargs: 17.7.3 - '@react-native/community-cli-plugin@0.83.10(@react-native/metro-config@0.85.2(@babel/core@7.29.7))': + '@react-native/community-cli-plugin@0.83.10(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))': dependencies: '@react-native/dev-middleware': 0.83.10 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) invariant: 2.2.4 - metro: 0.83.7 - metro-config: 0.83.7 + metro: 0.83.7(supports-color@8.1.1) + metro-config: 0.83.7(supports-color@8.1.1) metro-core: 0.83.7 semver: 7.8.5 optionalDependencies: - '@react-native/metro-config': 0.85.2(@babel/core@7.29.7) + '@react-native/metro-config': 0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) transitivePeerDependencies: - bufferutil - supports-color @@ -9966,8 +9958,8 @@ snapshots: '@react-native/debugger-shell': 0.83.10 chrome-launcher: 0.15.2 chromium-edge-launcher: 0.2.0 - connect: 3.7.0 - debug: 4.4.3 + connect: 3.7.0(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) invariant: 2.2.4 nullthrows: 1.1.1 open: 7.4.2 @@ -9984,20 +9976,20 @@ snapshots: '@react-native/js-polyfills@0.85.2': {} - '@react-native/metro-babel-transformer@0.85.2(@babel/core@7.29.7)': + '@react-native/metro-babel-transformer@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7 - '@react-native/babel-preset': 0.85.2(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@react-native/babel-preset': 0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) hermes-parser: 0.33.3 nullthrows: 1.1.1 transitivePeerDependencies: - supports-color - '@react-native/metro-config@0.85.2(@babel/core@7.29.7)': + '@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: '@react-native/js-polyfills': 0.85.2 - '@react-native/metro-babel-transformer': 0.85.2(@babel/core@7.29.7) - metro-config: 0.84.5 + '@react-native/metro-babel-transformer': 0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + metro-config: 0.84.5(supports-color@8.1.1) metro-runtime: 0.84.5 transitivePeerDependencies: - '@babel/core' @@ -10009,24 +10001,24 @@ snapshots: '@react-native/normalize-colors@0.83.10': {} - '@react-native/virtualized-lists@0.83.10(@types/react@19.2.14)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': + '@react-native/virtualized-lists@0.83.10(@types/react@19.2.14)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': dependencies: invariant: 2.2.4 nullthrows: 1.1.1 react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) optionalDependencies: '@types/react': 19.2.14 - ? '@react-navigation/bottom-tabs@7.15.11(@react-navigation/native@7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native-safe-area-context@5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native-screens@4.24.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)' - : dependencies: - '@react-navigation/elements': 2.9.15(@react-navigation/native@7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native-safe-area-context@5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - '@react-navigation/native': 7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@react-navigation/bottom-tabs@7.15.11(edc9e9b19f67aea8a6d8a2ee54babcc5)': + dependencies: + '@react-navigation/elements': 2.9.15(@react-navigation/native@7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native-safe-area-context@5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@react-navigation/native': 7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) color: 4.2.3 react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) - react-native-safe-area-context: 5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - react-native-screens: 4.24.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) + react-native-safe-area-context: 5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + react-native-screens: 4.24.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) sf-symbols-typescript: 2.2.0 transitivePeerDependencies: - '@react-native-masked-view/masked-view' @@ -10043,38 +10035,38 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.8) use-sync-external-store: 1.6.0(react@19.2.8) - '@react-navigation/elements@2.9.15(@react-navigation/native@7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native-safe-area-context@5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': + '@react-navigation/elements@2.9.15(@react-navigation/native@7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native-safe-area-context@5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': dependencies: - '@react-navigation/native': 7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@react-navigation/native': 7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) color: 4.2.3 react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) - react-native-safe-area-context: 5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) + react-native-safe-area-context: 5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) use-latest-callback: 0.2.6(react@19.2.8) use-sync-external-store: 1.6.0(react@19.2.8) - ? '@react-navigation/native-stack@7.14.12(@react-navigation/native@7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native-safe-area-context@5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native-screens@4.24.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)' - : dependencies: - '@react-navigation/elements': 2.9.15(@react-navigation/native@7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native-safe-area-context@5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - '@react-navigation/native': 7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@react-navigation/native-stack@7.14.12(edc9e9b19f67aea8a6d8a2ee54babcc5)': + dependencies: + '@react-navigation/elements': 2.9.15(@react-navigation/native@7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native-safe-area-context@5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@react-navigation/native': 7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) color: 4.2.3 react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) - react-native-safe-area-context: 5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - react-native-screens: 4.24.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) + react-native-safe-area-context: 5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + react-native-screens: 4.24.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) sf-symbols-typescript: 2.2.0 warn-once: 0.1.1 transitivePeerDependencies: - '@react-native-masked-view/masked-view' - '@react-navigation/native@7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': + '@react-navigation/native@7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)': dependencies: '@react-navigation/core': 7.17.2(react@19.2.8) escape-string-regexp: 4.0.0 fast-deep-equal: 3.1.3 nanoid: 3.3.18 react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) use-latest-callback: 0.2.6(react@19.2.8) '@react-navigation/routers@7.5.3': @@ -10148,17 +10140,17 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)': + '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@26.4.0)(supports-color@8.1.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: jest-matcher-utils: 30.3.0 picocolors: 1.1.1 pretty-format: 30.3.0 react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) react-test-renderer: 19.2.8(react@19.2.8) redent: 3.0.0 optionalDependencies: - jest: 29.7.0(@types/node@26.4.0) + jest: 29.7.0(@types/node@26.4.0)(supports-color@8.1.1) '@tootallnate/once@2.0.1': {} @@ -10371,9 +10363,9 @@ snapshots: dependencies: undici-types: 8.3.0 - '@types/react-native@0.73.0(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)': + '@types/react-native@0.73.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8)': dependencies: - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) transitivePeerDependencies: - '@babel/core' - '@react-native-community/cli' @@ -10413,15 +10405,15 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3))(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3) + '@typescript-eslint/parser': 8.59.2(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.59.2 - '@typescript-eslint/type-utils': 8.59.2(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.59.2(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.2(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.59.2 - eslint: 9.39.4 + eslint: 9.39.4(supports-color@8.1.1) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -10429,15 +10421,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3)': + '@typescript-eslint/parser@8.59.2(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.59.2 '@typescript-eslint/types': 8.59.2 '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.59.2 - debug: 4.4.3 - eslint: 9.39.4 - typescript: 6.0.3 + debug: 4.4.3(supports-color@8.1.1) + eslint: 9.39.4(supports-color@8.1.1) + typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -10445,7 +10437,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) '@typescript-eslint/types': 8.59.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -10459,13 +10451,13 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.59.2(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.59.2 '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.2(eslint@9.39.4)(typescript@5.9.3) - debug: 4.4.3 - eslint: 9.39.4 + '@typescript-eslint/utils': 8.59.2(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3) + debug: 4.4.3(supports-color@8.1.1) + eslint: 9.39.4(supports-color@8.1.1) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -10479,7 +10471,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) '@typescript-eslint/types': 8.59.2 '@typescript-eslint/visitor-keys': 8.59.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.5 semver: 7.8.5 tinyglobby: 0.2.17 @@ -10488,13 +10480,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/utils@8.59.2(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(supports-color@8.1.1)) '@typescript-eslint/scope-manager': 8.59.2 '@typescript-eslint/types': 8.59.2 '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) - eslint: 9.39.4 + eslint: 9.39.4(supports-color@8.1.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -10628,9 +10620,9 @@ snapshots: acorn@8.15.0: {} - agent-base@6.0.2: + agent-base@6.0.2(supports-color@8.1.1): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -10765,13 +10757,13 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 - babel-jest@29.7.0(@babel/core@7.29.7): + babel-jest@29.7.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.7 - '@jest/transform': 29.7.0 + '@babel/core': 7.29.7(supports-color@8.1.1) + '@jest/transform': 29.7.0(supports-color@8.1.1) '@types/babel__core': 7.20.5 - babel-plugin-istanbul: 6.1.1 - babel-preset-jest: 29.6.3(@babel/core@7.29.7) + babel-plugin-istanbul: 6.1.1(supports-color@8.1.1) + babel-preset-jest: 29.6.3(@babel/core@7.29.7(supports-color@8.1.1)) chalk: 4.1.2 graceful-fs: 4.2.11 slash: 3.0.0 @@ -10782,12 +10774,12 @@ snapshots: dependencies: object.assign: 4.1.7 - babel-plugin-istanbul@6.1.1: + babel-plugin-istanbul@6.1.1(supports-color@8.1.1): dependencies: '@babel/helper-plugin-utils': 7.29.7 '@istanbuljs/load-nyc-config': 1.1.0 '@istanbuljs/schema': 0.1.6 - istanbul-lib-instrument: 5.2.1 + istanbul-lib-instrument: 5.2.1(supports-color@8.1.1) test-exclude: 6.0.0 transitivePeerDependencies: - supports-color @@ -10799,35 +10791,35 @@ snapshots: '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.28.0 - babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7): + babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1): dependencies: '@babel/compat-data': 7.29.3 - '@babel/core': 7.29.7 - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) semver: 6.3.1 transitivePeerDependencies: - supports-color - babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.29.7): + babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.7 - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) core-js-compat: 3.49.0 transitivePeerDependencies: - supports-color - babel-plugin-polyfill-corejs3@0.14.2(@babel/core@7.29.7): + babel-plugin-polyfill-corejs3@0.14.2(@babel/core@7.29.7(supports-color@8.1.1)): dependencies: - '@babel/core': 7.29.7 - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) core-js-compat: 3.49.0 transitivePeerDependencies: - supports-color - babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.7): + babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.7 - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -10849,102 +10841,102 @@ snapshots: dependencies: hermes-parser: 0.33.3 - babel-plugin-transform-flow-enums@0.0.2(@babel/core@7.29.7): + babel-plugin-transform-flow-enums@0.0.2(@babel/core@7.29.7(supports-color@8.1.1)): dependencies: - '@babel/plugin-syntax-flow': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-syntax-flow': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) transitivePeerDependencies: - '@babel/core' - babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.7): + babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.7(supports-color@8.1.1)): dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.7) - '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.7) - '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.7) - '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7) - '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.7) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.7) - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.7) - '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.7(supports-color@8.1.1)) - babel-preset-expo@55.0.21(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@55.0.30)(react-refresh@0.14.2): + babel-preset-expo@55.0.21(@babel/core@7.29.7(supports-color@8.1.1))(@babel/runtime@7.29.7)(expo@55.0.30)(react-refresh@0.14.2): dependencies: '@babel/generator': 7.29.1 '@babel/helper-module-imports': 7.28.6 - '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.7) - '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-syntax-export-default-from': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.7) - '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-runtime': 7.29.0(@babel/core@7.29.7) - '@babel/preset-react': 7.28.5(@babel/core@7.29.7) - '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7) - '@react-native/babel-preset': 0.83.6(@babel/core@7.29.7) + '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-export-default-from': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-runtime': 7.29.0(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/preset-react': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) + '@react-native/babel-preset': 0.83.6(@babel/core@7.29.7(supports-color@8.1.1)) babel-plugin-react-compiler: 1.0.0 babel-plugin-react-native-web: 0.21.2 babel-plugin-syntax-hermes-parser: 0.32.1 - babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7) - debug: 4.4.3 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7(supports-color@8.1.1)) + debug: 4.4.3(supports-color@8.1.1) react-refresh: 0.14.2 resolve-from: 5.0.0 optionalDependencies: '@babel/runtime': 7.29.7 - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) transitivePeerDependencies: - '@babel/core' - supports-color - babel-preset-expo@55.0.25(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@55.0.30)(react-refresh@0.14.2): + babel-preset-expo@55.0.25(@babel/core@7.29.7(supports-color@8.1.1))(@babel/runtime@7.29.7)(expo@55.0.30)(react-refresh@0.14.2): dependencies: '@babel/generator': 7.29.8 - '@babel/helper-module-imports': 7.29.7 - '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.7) - '@babel/plugin-proposal-export-default-from': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-syntax-export-default-from': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-flow-strip-types': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.7) - '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7) - '@babel/preset-react': 7.28.5(@babel/core@7.29.7) - '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7) - '@react-native/babel-preset': 0.83.10(@babel/core@7.29.7) + '@babel/helper-module-imports': 7.29.7(supports-color@8.1.1) + '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-proposal-export-default-from': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-export-default-from': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-flow-strip-types': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/preset-react': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) + '@react-native/babel-preset': 0.83.10(@babel/core@7.29.7(supports-color@8.1.1)) babel-plugin-react-compiler: 1.0.0 babel-plugin-react-native-web: 0.21.2 babel-plugin-syntax-hermes-parser: 0.32.1 - babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7) - debug: 4.4.3 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7(supports-color@8.1.1)) + debug: 4.4.3(supports-color@8.1.1) react-refresh: 0.14.2 resolve-from: 5.0.0 optionalDependencies: '@babel/runtime': 7.29.7 - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) transitivePeerDependencies: - '@babel/core' - supports-color - babel-preset-jest@29.6.3(@babel/core@7.29.7): + babel-preset-jest@29.6.3(@babel/core@7.29.7(supports-color@8.1.1)): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) babel-plugin-jest-hoist: 29.6.3 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7(supports-color@8.1.1)) badgin@1.2.3: {} @@ -11177,7 +11169,7 @@ snapshots: dependencies: bytes: 3.1.2 compressible: 2.0.18 - debug: 2.6.9 + debug: 2.6.9(supports-color@8.1.1) negotiator: 0.6.4 on-headers: 1.1.0 safe-buffer: 5.2.1 @@ -11187,10 +11179,10 @@ snapshots: concat-map@0.0.1: {} - connect@3.7.0: + connect@3.7.0(supports-color@8.1.1): dependencies: - debug: 2.6.9 - finalhandler: 1.1.2 + debug: 2.6.9(supports-color@8.1.1) + finalhandler: 1.1.2(supports-color@8.1.1) parseurl: 1.3.3 utils-merge: 1.0.1 transitivePeerDependencies: @@ -11210,13 +11202,13 @@ snapshots: dependencies: layout-base: 2.0.1 - create-jest@29.7.0(@types/node@26.4.0): + create-jest@29.7.0(@types/node@26.4.0)(supports-color@8.1.1): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@26.4.0) + jest-config: 29.7.0(@types/node@26.4.0)(supports-color@8.1.1) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -11476,17 +11468,21 @@ snapshots: dayjs@1.11.21: {} - debug@2.6.9: + debug@2.6.9(supports-color@8.1.1): dependencies: ms: 2.0.0 + optionalDependencies: + supports-color: 8.1.1 debug@3.2.7: dependencies: ms: 2.1.3 - debug@4.4.3: + debug@4.4.3(supports-color@8.1.1): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 decimal.js@10.6.0: {} @@ -11789,27 +11785,27 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-compat-utils@0.5.1(eslint@9.39.4): + eslint-compat-utils@0.5.1(eslint@9.39.4(supports-color@8.1.1)): dependencies: - eslint: 9.39.4 + eslint: 9.39.4(supports-color@8.1.1) semver: 7.8.5 - eslint-config-prettier@9.1.2(eslint@9.39.4): + eslint-config-prettier@9.1.2(eslint@9.39.4(supports-color@8.1.1)): dependencies: - eslint: 9.39.4 + eslint: 9.39.4(supports-color@8.1.1) - eslint-config-universe@15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3): + eslint-config-universe@15.0.4(eslint@9.39.4(supports-color@8.1.1))(prettier@2.8.8)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3) - eslint: 9.39.4 - eslint-config-prettier: 9.1.2(eslint@9.39.4) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4) - eslint-plugin-n: 17.24.0(eslint@9.39.4)(typescript@5.9.3) - eslint-plugin-node: 11.1.0(eslint@9.39.4) - eslint-plugin-prettier: 5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4))(eslint@9.39.4)(prettier@2.8.8) - eslint-plugin-react: 7.37.5(eslint@9.39.4) - eslint-plugin-react-hooks: 5.2.0(eslint@9.39.4) + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3))(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.2(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3) + eslint: 9.39.4(supports-color@8.1.1) + eslint-config-prettier: 9.1.2(eslint@9.39.4(supports-color@8.1.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3))(eslint@9.39.4(supports-color@8.1.1)) + eslint-plugin-n: 17.24.0(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3) + eslint-plugin-node: 11.1.0(eslint@9.39.4(supports-color@8.1.1)) + eslint-plugin-prettier: 5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4(supports-color@8.1.1)))(eslint@9.39.4(supports-color@8.1.1))(prettier@2.8.8) + eslint-plugin-react: 7.37.5(eslint@9.39.4(supports-color@8.1.1)) + eslint-plugin-react-hooks: 5.2.0(eslint@9.39.4(supports-color@8.1.1)) globals: 16.5.0 optionalDependencies: prettier: 2.8.8 @@ -11828,30 +11824,30 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4(supports-color@8.1.1)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3) - eslint: 9.39.4 + '@typescript-eslint/parser': 8.59.2(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3) + eslint: 9.39.4(supports-color@8.1.1) eslint-import-resolver-node: 0.3.10 transitivePeerDependencies: - supports-color - eslint-plugin-es-x@7.8.0(eslint@9.39.4): + eslint-plugin-es-x@7.8.0(eslint@9.39.4(supports-color@8.1.1)): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(supports-color@8.1.1)) '@eslint-community/regexpp': 4.12.2 - eslint: 9.39.4 - eslint-compat-utils: 0.5.1(eslint@9.39.4) + eslint: 9.39.4(supports-color@8.1.1) + eslint-compat-utils: 0.5.1(eslint@9.39.4(supports-color@8.1.1)) - eslint-plugin-es@3.0.1(eslint@9.39.4): + eslint-plugin-es@3.0.1(eslint@9.39.4(supports-color@8.1.1)): dependencies: - eslint: 9.39.4 + eslint: 9.39.4(supports-color@8.1.1) eslint-utils: 2.1.0 regexpp: 3.2.0 - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3))(eslint@9.39.4(supports-color@8.1.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -11860,9 +11856,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.39.4 + eslint: 9.39.4(supports-color@8.1.1) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4(supports-color@8.1.1)) hasown: 2.0.3 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -11874,18 +11870,18 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3) + '@typescript-eslint/parser': 8.59.2(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-n@17.24.0(eslint@9.39.4)(typescript@5.9.3): + eslint-plugin-n@17.24.0(eslint@9.39.4(supports-color@8.1.1))(typescript@5.9.3): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(supports-color@8.1.1)) enhanced-resolve: 5.21.0 - eslint: 9.39.4 - eslint-plugin-es-x: 7.8.0(eslint@9.39.4) + eslint: 9.39.4(supports-color@8.1.1) + eslint-plugin-es-x: 7.8.0(eslint@9.39.4(supports-color@8.1.1)) get-tsconfig: 4.14.0 globals: 15.15.0 globrex: 0.1.2 @@ -11895,30 +11891,30 @@ snapshots: transitivePeerDependencies: - typescript - eslint-plugin-node@11.1.0(eslint@9.39.4): + eslint-plugin-node@11.1.0(eslint@9.39.4(supports-color@8.1.1)): dependencies: - eslint: 9.39.4 - eslint-plugin-es: 3.0.1(eslint@9.39.4) + eslint: 9.39.4(supports-color@8.1.1) + eslint-plugin-es: 3.0.1(eslint@9.39.4(supports-color@8.1.1)) eslint-utils: 2.1.0 ignore: 5.3.2 minimatch: 3.1.5 resolve: 1.22.12 semver: 6.3.1 - eslint-plugin-prettier@5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4))(eslint@9.39.4)(prettier@2.8.8): + eslint-plugin-prettier@5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4(supports-color@8.1.1)))(eslint@9.39.4(supports-color@8.1.1))(prettier@2.8.8): dependencies: - eslint: 9.39.4 + eslint: 9.39.4(supports-color@8.1.1) prettier: 2.8.8 prettier-linter-helpers: 1.0.1 synckit: 0.11.12 optionalDependencies: - eslint-config-prettier: 9.1.2(eslint@9.39.4) + eslint-config-prettier: 9.1.2(eslint@9.39.4(supports-color@8.1.1)) - eslint-plugin-react-hooks@5.2.0(eslint@9.39.4): + eslint-plugin-react-hooks@5.2.0(eslint@9.39.4(supports-color@8.1.1)): dependencies: - eslint: 9.39.4 + eslint: 9.39.4(supports-color@8.1.1) - eslint-plugin-react@7.37.5(eslint@9.39.4): + eslint-plugin-react@7.37.5(eslint@9.39.4(supports-color@8.1.1)): dependencies: array-includes: 3.1.9 array.prototype.findlast: 1.2.5 @@ -11926,7 +11922,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.3.2 - eslint: 9.39.4 + eslint: 9.39.4(supports-color@8.1.1) estraverse: 5.3.0 hasown: 2.0.3 jsx-ast-utils: 3.3.5 @@ -11957,14 +11953,14 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.4: + eslint@9.39.4(supports-color@8.1.1): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.4) + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.4(supports-color@8.1.1)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2 + '@eslint/config-array': 0.21.2(supports-color@8.1.1) '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.6 + '@eslint/eslintrc': 3.3.6(supports-color@8.1.1) '@eslint/js': 9.39.4 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.8 @@ -11974,7 +11970,7 @@ snapshots: ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -12050,15 +12046,15 @@ snapshots: expo-application@55.0.19(expo@55.0.30): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) - expo-asset@55.0.20(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@6.0.3): + expo-asset@55.0.20(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@6.0.3): dependencies: '@expo/image-utils': 0.8.17(typescript@6.0.3) - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) - expo-constants: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) + expo-constants: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8)) react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) transitivePeerDependencies: - supports-color - typescript @@ -12066,42 +12062,42 @@ snapshots: expo-build-properties@55.0.18(expo@55.0.30): dependencies: '@expo/schema-utils': 55.0.5 - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) resolve-from: 5.0.0 semver: 7.8.5 - expo-camera@55.0.23(@types/emscripten@1.41.5)(expo@55.0.30)(react-native-web@0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + expo-camera@55.0.23(@types/emscripten@1.41.5)(expo@55.0.30)(react-native-web@0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: barcode-detector: 3.1.3(@types/emscripten@1.41.5) - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) optionalDependencies: react-native-web: 0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) transitivePeerDependencies: - '@types/emscripten' - expo-clipboard@55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + expo-clipboard@55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) - expo-constants@55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)): + expo-constants@55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8)): dependencies: '@expo/env': 2.1.3 - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) transitivePeerDependencies: - supports-color expo-crypto@55.0.19(expo@55.0.30): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) expo-dev-client@55.0.39(expo@55.0.30): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) expo-dev-launcher: 55.0.40(expo@55.0.30) expo-dev-menu: 55.0.34(expo@55.0.30) expo-dev-menu-interface: 55.0.2(expo@55.0.30) @@ -12111,64 +12107,64 @@ snapshots: expo-dev-launcher@55.0.40(expo@55.0.30): dependencies: '@expo/schema-utils': 55.0.5 - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) expo-dev-menu: 55.0.34(expo@55.0.30) expo-manifests: 55.0.21(expo@55.0.30) expo-dev-menu-interface@55.0.2(expo@55.0.30): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) expo-dev-menu@55.0.34(expo@55.0.30): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) expo-dev-menu-interface: 55.0.2(expo@55.0.30) expo-document-picker@55.0.17(expo@55.0.30): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) - expo-file-system@55.0.26(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)): + expo-file-system@55.0.26(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8)): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) - expo-font@55.0.8(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + expo-font@55.0.8(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) fontfaceobserver: 2.3.0 react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) - expo-glass-effect@55.0.11(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + expo-glass-effect@55.0.11(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) expo-haptics@55.0.18(expo@55.0.30): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) expo-image-loader@55.0.1(expo@55.0.30): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) expo-image-manipulator@55.0.21(expo@55.0.30): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) expo-image-loader: 55.0.1(expo@55.0.30) expo-image-picker@55.0.24(expo@55.0.30): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) expo-image-loader: 55.0.1(expo@55.0.30) - expo-image@55.0.11(expo@55.0.30)(react-native-web@0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + expo-image@55.0.11(expo@55.0.30)(react-native-web@0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) sf-symbols-typescript: 2.2.0 optionalDependencies: react-native-web: 0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -12177,45 +12173,45 @@ snapshots: expo-keep-awake@55.0.8(expo@55.0.30)(react@19.2.8): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) react: 19.2.8 - expo-linking@55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + expo-linking@55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: - expo-constants: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)) + expo-constants: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8)) invariant: 2.2.4 react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) transitivePeerDependencies: - expo - supports-color expo-manifests@55.0.21(expo@55.0.30): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) expo-json-utils: 55.0.2 - expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8): + expo-module-scripts@55.0.2(@babel/core@7.29.7(supports-color@8.1.1))(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(esbuild@0.25.4)(eslint@9.39.4(supports-color@8.1.1))(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0)(supports-color@8.1.1))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@babel/cli': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7) - '@babel/preset-env': 7.29.5(@babel/core@7.29.7) - '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7) + '@babel/cli': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/preset-env': 7.29.5(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) '@expo/npm-proofread': 1.0.1 '@expo/spawn-async': 1.7.2 - '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.4.0)(supports-color@8.1.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) '@tsconfig/node18': 18.2.6 '@types/jest': 29.5.14 babel-plugin-dynamic-import-node: 2.3.3 - babel-preset-expo: 55.0.21(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@55.0.30)(react-refresh@0.14.2) + babel-preset-expo: 55.0.21(@babel/core@7.29.7(supports-color@8.1.1))(@babel/runtime@7.29.7)(expo@55.0.30)(react-refresh@0.14.2) commander: 12.1.0 - eslint-config-universe: 15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3) + eslint-config-universe: 15.0.4(eslint@9.39.4(supports-color@8.1.1))(prettier@2.8.8)(typescript@5.9.3) glob: 13.0.6 - jest-expo: 55.0.17(@babel/core@7.29.7)(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3) + jest-expo: 55.0.17(@babel/core@7.29.7(supports-color@8.1.1))(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0)(supports-color@8.1.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3) jest-snapshot-prettier: prettier@2.8.8 - jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.4.0)) + jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.4.0)(supports-color@8.1.1)) resolve-workspace-root: 2.0.1 - ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.4.0))(typescript@5.9.3) + ts-jest: 29.0.5(@babel/core@7.29.7(supports-color@8.1.1))(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.4.0)(supports-color@8.1.1))(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - '@babel/core' @@ -12251,63 +12247,63 @@ snapshots: - supports-color - typescript - expo-modules-core@55.0.25(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + expo-modules-core@55.0.25(react-native-worklets@0.8.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: invariant: 2.2.4 react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) optionalDependencies: - react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + react-native-worklets: 0.8.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) expo-network@55.0.18(expo@55.0.30)(react@19.2.8): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) react: 19.2.8 - expo-notifications@55.0.27(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@6.0.3): + expo-notifications@55.0.27(patch_hash=ce20843a3daad4185d7e8571788fa323ba4d11984936188790858650a61749c0)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@6.0.3): dependencies: '@expo/image-utils': 0.8.17(typescript@6.0.3) abort-controller: 3.0.0 badgin: 1.2.3 - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) expo-application: 55.0.19(expo@55.0.30) - expo-constants: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)) + expo-constants: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8)) react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) transitivePeerDependencies: - supports-color - typescript - expo-router@55.0.18(4a60a26fd685ffdcc7f556016ae4bc5e): + expo-router@55.0.18(98b45897562456c6c413f91e81d6c336): dependencies: - '@expo/log-box': 55.0.13(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@expo/log-box': 55.0.13(@expo/dom-webview@55.0.6)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@expo/metro-runtime': 55.0.12(@expo/dom-webview@55.0.6)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) '@expo/schema-utils': 55.0.5 '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.8) '@radix-ui/react-tabs': 1.1.13(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@react-navigation/bottom-tabs': 7.15.11(@react-navigation/native@7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native-safe-area-context@5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native-screens@4.24.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - '@react-navigation/native': 7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - '@react-navigation/native-stack': 7.14.12(@react-navigation/native@7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native-safe-area-context@5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native-screens@4.24.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@react-navigation/bottom-tabs': 7.15.11(edc9e9b19f67aea8a6d8a2ee54babcc5) + '@react-navigation/native': 7.2.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@react-navigation/native-stack': 7.14.12(edc9e9b19f67aea8a6d8a2ee54babcc5) client-only: 0.0.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) escape-string-regexp: 4.0.0 - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) - expo-constants: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)) - expo-glass-effect: 55.0.11(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - expo-image: 55.0.11(expo@55.0.30)(react-native-web@0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - expo-linking: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) + expo-constants: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8)) + expo-glass-effect: 55.0.11(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + expo-image: 55.0.11(expo@55.0.30)(react-native-web@0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + expo-linking: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) expo-server: 55.0.12 - expo-symbols: 55.0.9(expo-font@55.0.8)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + expo-symbols: 55.0.9(expo-font@55.0.8)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) fast-deep-equal: 3.1.3 invariant: 2.2.4 nanoid: 3.3.18 query-string: 7.1.3 react: 19.2.8 react-fast-compare: 3.2.2 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) - react-native-is-edge-to-edge: 1.3.1(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - react-native-safe-area-context: 5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - react-native-screens: 4.24.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) + react-native-is-edge-to-edge: 1.3.1(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + react-native-safe-area-context: 5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + react-native-screens: 4.24.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) semver: 7.6.3 server-only: 0.0.1 sf-symbols-typescript: 2.2.0 @@ -12315,10 +12311,10 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.8) vaul: 1.1.2(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) optionalDependencies: - '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.4.0)(supports-color@8.1.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) react-dom: 19.2.8(react@19.2.8) - react-native-gesture-handler: 2.31.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - react-native-reanimated: 4.3.4(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + react-native-gesture-handler: 2.31.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + react-native-reanimated: 4.3.4(react-native-worklets@0.8.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) react-native-web: 0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) transitivePeerDependencies: - '@react-native-masked-view/masked-view' @@ -12329,68 +12325,74 @@ snapshots: expo-secure-store@55.0.18(expo@55.0.30): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) expo-server@55.0.12: {} expo-splash-screen@55.0.25(expo@55.0.30)(typescript@6.0.3): dependencies: '@expo/prebuild-config': 55.0.22(expo@55.0.30)(typescript@6.0.3) - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) transitivePeerDependencies: - supports-color - typescript - expo-status-bar@55.0.6(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + expo-status-bar@55.0.6(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) - react-native-is-edge-to-edge: 1.3.1(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) + react-native-is-edge-to-edge: 1.3.1(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - expo-symbols@55.0.9(expo-font@55.0.8)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + expo-symbols@55.0.9(expo-font@55.0.8)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: '@expo-google-fonts/material-symbols': 0.4.34 - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) - expo-font: 55.0.8(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) + expo-font: 55.0.8(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) sf-symbols-typescript: 2.2.0 + expo-task-manager@55.0.20(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8)): + dependencies: + expo: 55.0.30(09911ea01feb2f63557d787d92391924) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) + unimodules-app-loader: 55.0.5 + expo-updates-interface@55.1.6(expo@55.0.30): dependencies: - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) - expo@55.0.30(10e8e71dd92768dd7f344108f3edbbe3): + expo@55.0.30(09911ea01feb2f63557d787d92391924): dependencies: '@babel/runtime': 7.29.7 - '@expo/cli': 55.0.36(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-constants@55.0.17)(expo-font@55.0.8)(expo-router@55.0.18)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@6.0.3) + '@expo/cli': 55.0.36(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.12)(expo-constants@55.0.17)(expo-font@55.0.8)(expo-router@55.0.18)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@6.0.3) '@expo/config': 55.0.21(typescript@6.0.3) '@expo/config-plugins': 55.0.11 - '@expo/devtools': 55.0.3(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@expo/devtools': 55.0.3(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) '@expo/fingerprint': 0.16.8 '@expo/local-build-cache-provider': 55.0.16(typescript@6.0.3) - '@expo/log-box': 55.0.13(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@expo/log-box': 55.0.13(@expo/dom-webview@55.0.6)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) '@expo/metro': 55.1.2 '@expo/metro-config': 55.0.27(expo@55.0.30)(typescript@6.0.3) - '@expo/vector-icons': 15.1.1(expo-font@55.0.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@expo/vector-icons': 15.1.1(expo-font@55.0.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) '@ungap/structured-clone': 1.3.1 - babel-preset-expo: 55.0.25(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@55.0.30)(react-refresh@0.14.2) - expo-asset: 55.0.20(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@6.0.3) - expo-constants: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)) - expo-file-system: 55.0.26(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)) - expo-font: 55.0.8(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + babel-preset-expo: 55.0.25(@babel/core@7.29.7(supports-color@8.1.1))(@babel/runtime@7.29.7)(expo@55.0.30)(react-refresh@0.14.2) + expo-asset: 55.0.20(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@6.0.3) + expo-constants: 55.0.17(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8)) + expo-file-system: 55.0.26(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8)) + expo-font: 55.0.8(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) expo-keep-awake: 55.0.8(expo@55.0.30)(react@19.2.8) expo-modules-autolinking: 55.0.27(typescript@6.0.3) - expo-modules-core: 55.0.25(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + expo-modules-core: 55.0.25(react-native-worklets@0.8.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) pretty-format: 29.7.0 react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) react-refresh: 0.14.2 whatwg-url-minimum: 0.1.2 optionalDependencies: - '@expo/dom-webview': 55.0.5(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - react-native-webview: 13.16.2(patch_hash=de6761dfa76a5491a23e49f1262566a8831cab26736a336fdffc5d4e7d05ff27)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@expo/dom-webview': 55.0.6(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@expo/metro-runtime': 55.0.12(@expo/dom-webview@55.0.6)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + react-native-webview: 13.16.2(patch_hash=de6761dfa76a5491a23e49f1262566a8831cab26736a336fdffc5d4e7d05ff27)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) transitivePeerDependencies: - '@babel/core' - bufferutil @@ -12453,9 +12455,9 @@ snapshots: filter-obj@1.1.0: {} - finalhandler@1.1.2: + finalhandler@1.1.2(supports-color@8.1.1): dependencies: - debug: 2.6.9 + debug: 2.6.9(supports-color@8.1.1) encodeurl: 1.0.2 escape-html: 1.0.3 on-finished: 2.3.0 @@ -12696,25 +12698,25 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - http-proxy-agent@5.0.0: + http-proxy-agent@5.0.0(supports-color@8.1.1): dependencies: '@tootallnate/once': 2.0.1 - agent-base: 6.0.2 - debug: 4.4.3 + agent-base: 6.0.2(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color - https-proxy-agent@5.0.1: + https-proxy-agent@5.0.1(supports-color@8.1.1): dependencies: - agent-base: 6.0.2 - debug: 4.4.3 + agent-base: 6.0.2(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color - https-proxy-agent@7.0.6: + https-proxy-agent@7.0.6(supports-color@8.1.1): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -12916,9 +12918,9 @@ snapshots: istanbul-lib-coverage@3.2.2: {} - istanbul-lib-instrument@5.2.1: + istanbul-lib-instrument@5.2.1(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/parser': 7.29.7 '@istanbuljs/schema': 0.1.6 istanbul-lib-coverage: 3.2.2 @@ -12926,9 +12928,9 @@ snapshots: transitivePeerDependencies: - supports-color - istanbul-lib-instrument@6.0.3: + istanbul-lib-instrument@6.0.3(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/parser': 7.29.8 '@istanbuljs/schema': 0.1.6 istanbul-lib-coverage: 3.2.2 @@ -12942,9 +12944,9 @@ snapshots: make-dir: 4.0.0 supports-color: 7.2.0 - istanbul-lib-source-maps@4.0.1: + istanbul-lib-source-maps@4.0.1(supports-color@8.1.1): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -12970,10 +12972,10 @@ snapshots: jest-util: 29.7.0 p-limit: 3.1.0 - jest-circus@29.7.0: + jest-circus@29.7.0(supports-color@8.1.1): dependencies: '@jest/environment': 29.7.0 - '@jest/expect': 29.7.0 + '@jest/expect': 29.7.0(supports-color@8.1.1) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 '@types/node': 26.4.0 @@ -12984,8 +12986,8 @@ snapshots: jest-each: 29.7.0 jest-matcher-utils: 29.7.0 jest-message-util: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 + jest-runtime: 29.7.0(supports-color@8.1.1) + jest-snapshot: 29.7.0(supports-color@8.1.1) jest-util: 29.7.0 p-limit: 3.1.0 pretty-format: 29.7.0 @@ -12996,16 +12998,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@26.4.0): + jest-cli@29.7.0(@types/node@26.4.0)(supports-color@8.1.1): dependencies: - '@jest/core': 29.7.0 + '@jest/core': 29.7.0(supports-color@8.1.1) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@26.4.0) + create-jest: 29.7.0(@types/node@26.4.0)(supports-color@8.1.1) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@26.4.0) + jest-config: 29.7.0(@types/node@26.4.0)(supports-color@8.1.1) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.3 @@ -13015,23 +13017,23 @@ snapshots: - supports-color - ts-node - jest-config@29.7.0(@types/node@26.4.0): + jest-config@29.7.0(@types/node@26.4.0)(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@jest/test-sequencer': 29.7.0 '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.29.7) + babel-jest: 29.7.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) chalk: 4.1.2 ci-info: 3.9.0 deepmerge: 4.3.1 glob: 7.2.3 graceful-fs: 4.2.11 - jest-circus: 29.7.0 + jest-circus: 29.7.0(supports-color@8.1.1) jest-environment-node: 29.7.0 jest-get-type: 29.6.3 jest-regex-util: 29.6.3 jest-resolve: 29.7.0 - jest-runner: 29.7.0 + jest-runner: 29.7.0(supports-color@8.1.1) jest-util: 29.7.0 jest-validate: 29.7.0 micromatch: 4.0.8 @@ -13080,7 +13082,7 @@ snapshots: '@types/node': 25.6.0 jest-mock: 29.7.0 jest-util: 29.7.0 - jsdom: 20.0.3 + jsdom: 20.0.3(supports-color@8.1.1) transitivePeerDependencies: - bufferutil - supports-color @@ -13095,21 +13097,21 @@ snapshots: jest-mock: 29.7.0 jest-util: 29.7.0 - jest-expo@55.0.17(@babel/core@7.29.7)(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3): + jest-expo@55.0.17(@babel/core@7.29.7(supports-color@8.1.1))(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0)(supports-color@8.1.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3): dependencies: '@expo/config': 55.0.16(typescript@5.9.3) '@expo/json-file': 10.0.14 '@jest/create-cache-key-function': 29.7.0 - '@jest/globals': 29.7.0 - babel-jest: 29.7.0(@babel/core@7.29.7) - expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) + '@jest/globals': 29.7.0(supports-color@8.1.1) + babel-jest: 29.7.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + expo: 55.0.30(09911ea01feb2f63557d787d92391924) jest-environment-jsdom: 29.7.0 - jest-snapshot: 29.7.0 + jest-snapshot: 29.7.0(supports-color@8.1.1) jest-watch-select-projects: 2.0.0 - jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.4.0)) + jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.4.0)(supports-color@8.1.1)) json5: 2.2.3 lodash: 4.18.1 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) react-test-renderer: 19.2.0(react@19.2.8) server-only: 0.0.1 stacktrace-js: 2.0.2 @@ -13184,10 +13186,10 @@ snapshots: jest-regex-util@29.6.3: {} - jest-resolve-dependencies@29.7.0: + jest-resolve-dependencies@29.7.0(supports-color@8.1.1): dependencies: jest-regex-util: 29.6.3 - jest-snapshot: 29.7.0 + jest-snapshot: 29.7.0(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -13203,12 +13205,12 @@ snapshots: resolve.exports: 2.0.3 slash: 3.0.0 - jest-runner@29.7.0: + jest-runner@29.7.0(supports-color@8.1.1): dependencies: '@jest/console': 29.7.0 '@jest/environment': 29.7.0 '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 + '@jest/transform': 29.7.0(supports-color@8.1.1) '@jest/types': 29.6.3 '@types/node': 26.4.0 chalk: 4.1.2 @@ -13220,7 +13222,7 @@ snapshots: jest-leak-detector: 29.7.0 jest-message-util: 29.7.0 jest-resolve: 29.7.0 - jest-runtime: 29.7.0 + jest-runtime: 29.7.0(supports-color@8.1.1) jest-util: 29.7.0 jest-watcher: 29.7.0 jest-worker: 29.7.0 @@ -13229,14 +13231,14 @@ snapshots: transitivePeerDependencies: - supports-color - jest-runtime@29.7.0: + jest-runtime@29.7.0(supports-color@8.1.1): dependencies: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 - '@jest/globals': 29.7.0 + '@jest/globals': 29.7.0(supports-color@8.1.1) '@jest/source-map': 29.6.3 '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 + '@jest/transform': 29.7.0(supports-color@8.1.1) '@jest/types': 29.6.3 '@types/node': 26.4.0 chalk: 4.1.2 @@ -13249,24 +13251,24 @@ snapshots: jest-mock: 29.7.0 jest-regex-util: 29.6.3 jest-resolve: 29.7.0 - jest-snapshot: 29.7.0 + jest-snapshot: 29.7.0(supports-color@8.1.1) jest-util: 29.7.0 slash: 3.0.0 strip-bom: 4.0.0 transitivePeerDependencies: - supports-color - jest-snapshot@29.7.0: + jest-snapshot@29.7.0(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/generator': 7.29.1 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/types': 7.29.0 '@jest/expect-utils': 29.7.0 - '@jest/transform': 29.7.0 + '@jest/transform': 29.7.0(supports-color@8.1.1) '@jest/types': 29.6.3 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7(supports-color@8.1.1)) chalk: 4.1.2 expect: 29.7.0 graceful-fs: 4.2.11 @@ -13305,11 +13307,11 @@ snapshots: chalk: 3.0.0 prompts: 2.4.2 - jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@26.4.0)): + jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@26.4.0)(supports-color@8.1.1)): dependencies: ansi-escapes: 6.2.1 chalk: 4.1.2 - jest: 29.7.0(@types/node@26.4.0) + jest: 29.7.0(@types/node@26.4.0)(supports-color@8.1.1) jest-regex-util: 29.6.3 jest-watcher: 29.7.0 slash: 5.1.0 @@ -13334,12 +13336,12 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@26.4.0): + jest@29.7.0(@types/node@26.4.0)(supports-color@8.1.1): dependencies: - '@jest/core': 29.7.0 + '@jest/core': 29.7.0(supports-color@8.1.1) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@26.4.0) + jest-cli: 29.7.0(@types/node@26.4.0)(supports-color@8.1.1) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -13365,7 +13367,7 @@ snapshots: jsc-safe-url@0.2.4: {} - jsdom@20.0.3: + jsdom@20.0.3(supports-color@8.1.1): dependencies: abab: 2.0.6 acorn: 8.15.0 @@ -13378,8 +13380,8 @@ snapshots: escodegen: 2.1.0 form-data: 4.0.6 html-encoding-sniffer: 3.0.0 - http-proxy-agent: 5.0.0 - https-proxy-agent: 5.0.1 + http-proxy-agent: 5.0.0(supports-color@8.1.1) + https-proxy-agent: 5.0.1(supports-color@8.1.1) is-potential-custom-element-name: 1.0.1 nwsapi: 2.2.23 parse5: 7.3.0 @@ -13448,7 +13450,7 @@ snapshots: lighthouse-logger@1.4.2: dependencies: - debug: 2.6.9 + debug: 2.6.9(supports-color@8.1.1) marky: 1.3.0 transitivePeerDependencies: - supports-color @@ -13546,11 +13548,11 @@ snapshots: dependencies: yallist: 3.1.1 - lucide-react-native@1.14.0(react-native-svg@15.15.4(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + lucide-react-native@1.14.0(react-native-svg@15.15.4(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) - react-native-svg: 15.15.4(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) + react-native-svg: 15.15.4(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) magic-string@0.30.21: dependencies: @@ -13614,9 +13616,9 @@ snapshots: ts-dedent: 2.3.0 uuid: 11.1.1 - metro-babel-transformer@0.83.7: + metro-babel-transformer@0.83.7(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) flow-enums-runtime: 0.0.6 hermes-parser: 0.35.0 metro-cache-key: 0.83.7 @@ -13624,9 +13626,9 @@ snapshots: transitivePeerDependencies: - supports-color - metro-babel-transformer@0.83.8: + metro-babel-transformer@0.83.8(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) flow-enums-runtime: 0.0.6 hermes-parser: 0.35.0 metro-cache-key: 0.83.8 @@ -13634,9 +13636,9 @@ snapshots: transitivePeerDependencies: - supports-color - metro-babel-transformer@0.84.5: + metro-babel-transformer@0.84.5(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) flow-enums-runtime: 0.0.6 hermes-parser: 0.35.0 metro-cache-key: 0.84.5 @@ -13656,40 +13658,40 @@ snapshots: dependencies: flow-enums-runtime: 0.0.6 - metro-cache@0.83.7: + metro-cache@0.83.7(supports-color@8.1.1): dependencies: exponential-backoff: 3.1.3 flow-enums-runtime: 0.0.6 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@8.1.1) metro-core: 0.83.7 transitivePeerDependencies: - supports-color - metro-cache@0.83.8: + metro-cache@0.83.8(supports-color@8.1.1): dependencies: exponential-backoff: 3.1.3 flow-enums-runtime: 0.0.6 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@8.1.1) metro-core: 0.83.8 transitivePeerDependencies: - supports-color - metro-cache@0.84.5: + metro-cache@0.84.5(supports-color@8.1.1): dependencies: exponential-backoff: 3.1.3 flow-enums-runtime: 0.0.6 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@8.1.1) metro-core: 0.84.5 transitivePeerDependencies: - supports-color - metro-config@0.83.7: + metro-config@0.83.7(supports-color@8.1.1): dependencies: - connect: 3.7.0 + connect: 3.7.0(supports-color@8.1.1) flow-enums-runtime: 0.0.6 jest-validate: 29.7.0 - metro: 0.83.7 - metro-cache: 0.83.7 + metro: 0.83.7(supports-color@8.1.1) + metro-cache: 0.83.7(supports-color@8.1.1) metro-core: 0.83.7 metro-runtime: 0.83.7 yaml: 2.9.0 @@ -13698,13 +13700,13 @@ snapshots: - supports-color - utf-8-validate - metro-config@0.83.8: + metro-config@0.83.8(supports-color@8.1.1): dependencies: - connect: 3.7.0 + connect: 3.7.0(supports-color@8.1.1) flow-enums-runtime: 0.0.6 jest-validate: 29.7.0 - metro: 0.83.8 - metro-cache: 0.83.8 + metro: 0.83.8(supports-color@8.1.1) + metro-cache: 0.83.8(supports-color@8.1.1) metro-core: 0.83.8 metro-runtime: 0.83.8 yaml: 2.9.0 @@ -13713,13 +13715,13 @@ snapshots: - supports-color - utf-8-validate - metro-config@0.84.5: + metro-config@0.84.5(supports-color@8.1.1): dependencies: - connect: 3.7.0 + connect: 3.7.0(supports-color@8.1.1) flow-enums-runtime: 0.0.6 jest-validate: 29.7.0 - metro: 0.84.5 - metro-cache: 0.84.5 + metro: 0.84.5(supports-color@8.1.1) + metro-cache: 0.84.5(supports-color@8.1.1) metro-core: 0.84.5 metro-runtime: 0.84.5 yaml: 2.9.0 @@ -13746,9 +13748,9 @@ snapshots: lodash.throttle: 4.1.1 metro-resolver: 0.84.5 - metro-file-map@0.83.7: + metro-file-map@0.83.7(supports-color@8.1.1): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) fb-watchman: 2.0.2 flow-enums-runtime: 0.0.6 graceful-fs: 4.2.11 @@ -13760,9 +13762,9 @@ snapshots: transitivePeerDependencies: - supports-color - metro-file-map@0.83.8: + metro-file-map@0.83.8(supports-color@8.1.1): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) fb-watchman: 2.0.2 flow-enums-runtime: 0.0.6 graceful-fs: 4.2.11 @@ -13774,9 +13776,9 @@ snapshots: transitivePeerDependencies: - supports-color - metro-file-map@0.84.5: + metro-file-map@0.84.5(supports-color@8.1.1): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) fb-watchman: 2.0.2 flow-enums-runtime: 0.0.6 graceful-fs: 4.2.11 @@ -13830,9 +13832,9 @@ snapshots: '@babel/runtime': 7.29.7 flow-enums-runtime: 0.0.6 - metro-source-map@0.83.7: + metro-source-map@0.83.7(supports-color@8.1.1): dependencies: - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@8.1.1) '@babel/types': 7.29.8 flow-enums-runtime: 0.0.6 invariant: 2.2.4 @@ -13844,9 +13846,9 @@ snapshots: transitivePeerDependencies: - supports-color - metro-source-map@0.83.8: + metro-source-map@0.83.8(supports-color@8.1.1): dependencies: - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@8.1.1) '@babel/types': 7.29.8 flow-enums-runtime: 0.0.6 invariant: 2.2.4 @@ -13858,9 +13860,9 @@ snapshots: transitivePeerDependencies: - supports-color - metro-source-map@0.84.5: + metro-source-map@0.84.5(supports-color@8.1.1): dependencies: - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@8.1.1) '@babel/types': 7.29.8 flow-enums-runtime: 0.0.6 invariant: 2.2.4 @@ -13876,141 +13878,135 @@ snapshots: dependencies: flow-enums-runtime: 0.0.6 invariant: 2.2.4 - metro-source-map: 0.83.7 + metro-source-map: 0.83.7(supports-color@8.1.1) nullthrows: 1.1.1 source-map: 0.5.7 vlq: 1.0.1 - transitivePeerDependencies: - - supports-color metro-symbolicate@0.83.8: dependencies: flow-enums-runtime: 0.0.6 invariant: 2.2.4 - metro-source-map: 0.83.8 + metro-source-map: 0.83.8(supports-color@8.1.1) nullthrows: 1.1.1 source-map: 0.5.7 vlq: 1.0.1 - transitivePeerDependencies: - - supports-color metro-symbolicate@0.84.5: dependencies: flow-enums-runtime: 0.0.6 invariant: 2.2.4 - metro-source-map: 0.84.5 + metro-source-map: 0.84.5(supports-color@8.1.1) nullthrows: 1.1.1 source-map: 0.5.7 vlq: 1.0.1 - transitivePeerDependencies: - - supports-color - metro-transform-plugins@0.83.7: + metro-transform-plugins@0.83.7(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/generator': 7.29.8 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@8.1.1) flow-enums-runtime: 0.0.6 nullthrows: 1.1.1 transitivePeerDependencies: - supports-color - metro-transform-plugins@0.83.8: + metro-transform-plugins@0.83.8(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/generator': 7.29.8 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@8.1.1) flow-enums-runtime: 0.0.6 nullthrows: 1.1.1 transitivePeerDependencies: - supports-color - metro-transform-plugins@0.84.5: + metro-transform-plugins@0.84.5(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/generator': 7.29.8 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@8.1.1) flow-enums-runtime: 0.0.6 nullthrows: 1.1.1 transitivePeerDependencies: - supports-color - metro-transform-worker@0.83.7: + metro-transform-worker@0.83.7(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/generator': 7.29.8 '@babel/parser': 7.29.8 '@babel/types': 7.29.8 flow-enums-runtime: 0.0.6 - metro: 0.83.7 - metro-babel-transformer: 0.83.7 - metro-cache: 0.83.7 + metro: 0.83.7(supports-color@8.1.1) + metro-babel-transformer: 0.83.7(supports-color@8.1.1) + metro-cache: 0.83.7(supports-color@8.1.1) metro-cache-key: 0.83.7 metro-minify-terser: 0.83.7 - metro-source-map: 0.83.7 - metro-transform-plugins: 0.83.7 + metro-source-map: 0.83.7(supports-color@8.1.1) + metro-transform-plugins: 0.83.7(supports-color@8.1.1) nullthrows: 1.1.1 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - metro-transform-worker@0.83.8: + metro-transform-worker@0.83.8(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/generator': 7.29.8 '@babel/parser': 7.29.8 '@babel/types': 7.29.8 flow-enums-runtime: 0.0.6 - metro: 0.83.8 - metro-babel-transformer: 0.83.8 - metro-cache: 0.83.8 + metro: 0.83.8(supports-color@8.1.1) + metro-babel-transformer: 0.83.8(supports-color@8.1.1) + metro-cache: 0.83.8(supports-color@8.1.1) metro-cache-key: 0.83.8 metro-minify-terser: 0.83.8 - metro-source-map: 0.83.8 - metro-transform-plugins: 0.83.8 + metro-source-map: 0.83.8(supports-color@8.1.1) + metro-transform-plugins: 0.83.8(supports-color@8.1.1) nullthrows: 1.1.1 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - metro-transform-worker@0.84.5: + metro-transform-worker@0.84.5(supports-color@8.1.1): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/generator': 7.29.8 '@babel/parser': 7.29.8 '@babel/types': 7.29.8 flow-enums-runtime: 0.0.6 - metro: 0.84.5 - metro-babel-transformer: 0.84.5 - metro-cache: 0.84.5 + metro: 0.84.5(supports-color@8.1.1) + metro-babel-transformer: 0.84.5(supports-color@8.1.1) + metro-cache: 0.84.5(supports-color@8.1.1) metro-cache-key: 0.84.5 metro-minify-terser: 0.84.5 - metro-source-map: 0.84.5 - metro-transform-plugins: 0.84.5 + metro-source-map: 0.84.5(supports-color@8.1.1) + metro-transform-plugins: 0.84.5(supports-color@8.1.1) nullthrows: 1.1.1 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - metro@0.83.7: + metro@0.83.7(supports-color@8.1.1): dependencies: '@babel/code-frame': 7.29.7 - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/generator': 7.29.8 '@babel/parser': 7.29.8 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@8.1.1) '@babel/types': 7.29.8 accepts: 2.0.0 ci-info: 2.0.0 - connect: 3.7.0 - debug: 4.4.3 + connect: 3.7.0(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) error-stack-parser: 2.1.4 flow-enums-runtime: 0.0.6 graceful-fs: 4.2.11 @@ -14020,18 +14016,18 @@ snapshots: jest-worker: 29.7.0 jsc-safe-url: 0.2.4 lodash.throttle: 4.1.1 - metro-babel-transformer: 0.83.7 - metro-cache: 0.83.7 + metro-babel-transformer: 0.83.7(supports-color@8.1.1) + metro-cache: 0.83.7(supports-color@8.1.1) metro-cache-key: 0.83.7 - metro-config: 0.83.7 + metro-config: 0.83.7(supports-color@8.1.1) metro-core: 0.83.7 - metro-file-map: 0.83.7 + metro-file-map: 0.83.7(supports-color@8.1.1) metro-resolver: 0.83.7 metro-runtime: 0.83.7 - metro-source-map: 0.83.7 + metro-source-map: 0.83.7(supports-color@8.1.1) metro-symbolicate: 0.83.7 - metro-transform-plugins: 0.83.7 - metro-transform-worker: 0.83.7 + metro-transform-plugins: 0.83.7(supports-color@8.1.1) + metro-transform-worker: 0.83.7(supports-color@8.1.1) mime-types: 3.0.2 nullthrows: 1.1.1 serialize-error: 2.1.0 @@ -14044,19 +14040,19 @@ snapshots: - supports-color - utf-8-validate - metro@0.83.8: + metro@0.83.8(supports-color@8.1.1): dependencies: '@babel/code-frame': 7.29.7 - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/generator': 7.29.8 '@babel/parser': 7.29.8 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@8.1.1) '@babel/types': 7.29.8 accepts: 2.0.0 ci-info: 2.0.0 - connect: 3.7.0 - debug: 4.4.3 + connect: 3.7.0(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) error-stack-parser: 2.1.4 flow-enums-runtime: 0.0.6 graceful-fs: 4.2.11 @@ -14065,18 +14061,18 @@ snapshots: jest-worker: 29.7.0 jsc-safe-url: 0.2.4 lodash.throttle: 4.1.1 - metro-babel-transformer: 0.83.8 - metro-cache: 0.83.8 + metro-babel-transformer: 0.83.8(supports-color@8.1.1) + metro-cache: 0.83.8(supports-color@8.1.1) metro-cache-key: 0.83.8 - metro-config: 0.83.8 + metro-config: 0.83.8(supports-color@8.1.1) metro-core: 0.83.8 - metro-file-map: 0.83.8 + metro-file-map: 0.83.8(supports-color@8.1.1) metro-resolver: 0.83.8 metro-runtime: 0.83.8 - metro-source-map: 0.83.8 + metro-source-map: 0.83.8(supports-color@8.1.1) metro-symbolicate: 0.83.8 - metro-transform-plugins: 0.83.8 - metro-transform-worker: 0.83.8 + metro-transform-plugins: 0.83.8(supports-color@8.1.1) + metro-transform-worker: 0.83.8(supports-color@8.1.1) mime-types: 3.0.2 nullthrows: 1.1.1 serialize-error: 2.1.0 @@ -14089,19 +14085,19 @@ snapshots: - supports-color - utf-8-validate - metro@0.84.5: + metro@0.84.5(supports-color@8.1.1): dependencies: '@babel/code-frame': 7.29.7 - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/generator': 7.29.8 '@babel/parser': 7.29.8 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@8.1.1) '@babel/types': 7.29.8 accepts: 2.0.0 ci-info: 2.0.0 - connect: 3.7.0 - debug: 4.4.3 + connect: 3.7.0(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) error-stack-parser: 2.1.4 flow-enums-runtime: 0.0.6 graceful-fs: 4.2.11 @@ -14110,18 +14106,18 @@ snapshots: jest-worker: 29.7.0 jsc-safe-url: 0.2.4 lodash.throttle: 4.1.1 - metro-babel-transformer: 0.84.5 - metro-cache: 0.84.5 + metro-babel-transformer: 0.84.5(supports-color@8.1.1) + metro-cache: 0.84.5(supports-color@8.1.1) metro-cache-key: 0.84.5 - metro-config: 0.84.5 + metro-config: 0.84.5(supports-color@8.1.1) metro-core: 0.84.5 - metro-file-map: 0.84.5 + metro-file-map: 0.84.5(supports-color@8.1.1) metro-resolver: 0.84.5 metro-runtime: 0.84.5 - metro-source-map: 0.84.5 + metro-source-map: 0.84.5(supports-color@8.1.1) metro-symbolicate: 0.84.5 - metro-transform-plugins: 0.84.5 - metro-transform-worker: 0.84.5 + metro-transform-plugins: 0.84.5(supports-color@8.1.1) + metro-transform-worker: 0.84.5(supports-color@8.1.1) mime-types: 3.0.2 nullthrows: 1.1.1 serialize-error: 2.1.0 @@ -14576,52 +14572,52 @@ snapshots: react-is@19.2.8: {} - react-native-gesture-handler@2.31.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + react-native-gesture-handler@2.31.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: '@egjs/hammerjs': 2.0.17 '@types/react-test-renderer': 19.1.0 hoist-non-react-statics: 3.3.2 invariant: 2.2.4 react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) - react-native-is-edge-to-edge@1.3.1(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + react-native-is-edge-to-edge@1.3.1(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) - react-native-reanimated@4.3.4(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + react-native-reanimated@4.3.4(react-native-worklets@0.8.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) - react-native-is-edge-to-edge: 1.3.1(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) + react-native-is-edge-to-edge: 1.3.1(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + react-native-worklets: 0.8.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) semver: 7.8.5 - react-native-safe-area-context@5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + react-native-safe-area-context@5.7.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) - react-native-screens@4.24.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + react-native-screens@4.24.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: react: 19.2.8 react-freeze: 1.0.4(react@19.2.8) - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) warn-once: 0.1.1 - react-native-svg@15.15.4(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + react-native-svg@15.15.4(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: css-select: 5.2.2 css-tree: 1.1.3 react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) warn-once: 0.1.1 - react-native-uitextview@2.2.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + react-native-uitextview@2.2.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) react-native-web@0.21.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: @@ -14638,48 +14634,48 @@ snapshots: transitivePeerDependencies: - encoding - react-native-webview@13.16.2(patch_hash=de6761dfa76a5491a23e49f1262566a8831cab26736a336fdffc5d4e7d05ff27)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + react-native-webview@13.16.2(patch_hash=de6761dfa76a5491a23e49f1262566a8831cab26736a336fdffc5d4e7d05ff27)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: '@typescript/native-preview': 7.0.0-dev.20260707.2 escape-string-regexp: 4.0.0 invariant: 2.2.4 react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) - react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): + react-native-worklets@0.8.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8): dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.7) - '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.7) - '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7) - '@react-native/metro-config': 0.85.2(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7(supports-color@8.1.1)) + '@react-native/metro-config': 0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) convert-source-map: 2.0.0 react: 19.2.8 - react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) + react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8) semver: 7.7.4 transitivePeerDependencies: - supports-color - react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8): + react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8): dependencies: '@jest/create-cache-key-function': 29.7.0 '@react-native/assets-registry': 0.83.10 - '@react-native/codegen': 0.83.10(@babel/core@7.29.7) - '@react-native/community-cli-plugin': 0.83.10(@react-native/metro-config@0.85.2(@babel/core@7.29.7)) + '@react-native/codegen': 0.83.10(@babel/core@7.29.7(supports-color@8.1.1)) + '@react-native/community-cli-plugin': 0.83.10(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)) '@react-native/gradle-plugin': 0.83.10 '@react-native/js-polyfills': 0.83.10 '@react-native/normalize-colors': 0.83.10 - '@react-native/virtualized-lists': 0.83.10(@types/react@19.2.14)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) + '@react-native/virtualized-lists': 0.83.10(@types/react@19.2.14)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) abort-controller: 3.0.0 anser: 1.4.10 ansi-regex: 5.0.1 - babel-jest: 29.7.0(@babel/core@7.29.7) + babel-jest: 29.7.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) babel-plugin-syntax-hermes-parser: 0.32.0 base64-js: 1.5.1 commander: 12.1.0 @@ -14690,7 +14686,7 @@ snapshots: jest-environment-node: 29.7.0 memoize-one: 5.2.1 metro-runtime: 0.83.7 - metro-source-map: 0.83.7 + metro-source-map: 0.83.7(supports-color@8.1.1) nullthrows: 1.1.1 pretty-format: 29.7.0 promise: 8.3.0 @@ -14930,7 +14926,7 @@ snapshots: send@0.19.2: dependencies: - debug: 2.6.9 + debug: 2.6.9(supports-color@8.1.1) depd: 2.0.0 destroy: 1.2.0 encodeurl: 2.0.0 @@ -15308,11 +15304,11 @@ snapshots: ts-dedent@2.3.0: {} - ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.4.0))(typescript@5.9.3): + ts-jest@29.0.5(@babel/core@7.29.7(supports-color@8.1.1))(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.4.0)(supports-color@8.1.1))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 - jest: 29.7.0(@types/node@26.4.0) + jest: 29.7.0(@types/node@26.4.0)(supports-color@8.1.1) jest-util: 29.7.0 json5: 2.2.3 lodash.memoize: 4.1.2 @@ -15321,9 +15317,9 @@ snapshots: typescript: 5.9.3 yargs-parser: 21.1.1 optionalDependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@8.1.1) '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.29.7) + babel-jest: 29.7.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) esbuild: 0.25.4 tsconfig-paths@3.15.0: @@ -15418,6 +15414,8 @@ snapshots: unicode-property-aliases-ecmascript@2.2.0: {} + unimodules-app-loader@55.0.5: {} + universalify@0.2.0: {} unpipe@1.0.0: {} @@ -15498,7 +15496,7 @@ snapshots: tsx: 4.22.4 yaml: 2.9.0 - vitest@4.1.11(@types/node@26.4.0)(happy-dom@20.11.8)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.11(@types/node@26.4.0)(happy-dom@20.11.8)(jsdom@20.0.3(supports-color@8.1.1))(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0)) @@ -15523,7 +15521,7 @@ snapshots: optionalDependencies: '@types/node': 26.4.0 happy-dom: 20.11.8 - jsdom: 20.0.3 + jsdom: 20.0.3(supports-color@8.1.1) transitivePeerDependencies: - msw diff --git a/mobile/pnpm-workspace.yaml b/mobile/pnpm-workspace.yaml index 8f29993b7b4..b6d836a230e 100644 --- a/mobile/pnpm-workspace.yaml +++ b/mobile/pnpm-workspace.yaml @@ -7,5 +7,6 @@ overrides: xcode>uuid: 11.1.1 patchedDependencies: + expo-notifications@55.0.27: patches/expo-notifications@55.0.27.patch react-native-webview@13.16.2: patches/react-native-webview@13.16.2.patch react-native@0.83.10: patches/react-native@0.83.10.patch diff --git a/mobile/src/diagnostics/troubleshoot-common-issues.tsx b/mobile/src/diagnostics/troubleshoot-common-issues.tsx index b794ad004d5..31fdb17cf85 100644 --- a/mobile/src/diagnostics/troubleshoot-common-issues.tsx +++ b/mobile/src/diagnostics/troubleshoot-common-issues.tsx @@ -1,4 +1,4 @@ -import { WifiOff, Shield, Monitor, Clock, Globe } from 'lucide-react-native' +import { WifiOff, Shield, Monitor, Clock, Globe, Bell } from 'lucide-react-native' import { colors } from '../theme/mobile-theme' export type TroubleshootSection = { @@ -9,6 +9,16 @@ export type TroubleshootSection = { } export const troubleshootCommonIssues: TroubleshootSection[] = [ + { + id: 'notifications', + icon: , + title: 'Push Notifications', + steps: [ + 'Check that system settings allow Orca notifications and that Focus or Do Not Disturb is off.', + 'Try cellular or another Wi-Fi network. If alerts arrive after switching, your network may be delaying delivery.' + ] + }, + { id: 'wifi', icon: , diff --git a/mobile/src/notifications/NotificationDeliverySection.test.tsx b/mobile/src/notifications/NotificationDeliverySection.test.tsx new file mode 100644 index 00000000000..8b7971b6086 --- /dev/null +++ b/mobile/src/notifications/NotificationDeliverySection.test.tsx @@ -0,0 +1,37 @@ +import { createElement } from 'react' +import { act, create } from 'react-test-renderer' +import { expect, it, vi } from 'vitest' +import { NotificationDeliverySection } from './NotificationDeliverySection' +import { DEFAULT_NOTIFICATION_DELIVERY } from './notification-delivery-preferences' + +vi.mock('@react-native-async-storage/async-storage', () => ({ default: {} })) +vi.mock('react-native', () => ({ + StyleSheet: { create: (value: unknown) => value }, + View: 'View', + Text: 'Text', + Switch: 'Switch' +})) + +it('shows only phone-specific controls while desktop owns category eligibility', () => { + const onChange = vi.fn() + let renderer: ReturnType + act(() => { + renderer = create( + createElement(NotificationDeliverySection, { value: DEFAULT_NOTIFICATION_DELIVERY, onChange }) + ) + }) + const switches = () => renderer.root.findAllByType('Switch' as never) + expect(switches().map((node) => node.props.accessibilityLabel)).toEqual([ + 'Only when away from desktop', + 'Notification sound', + 'Suppress while focused' + ]) + expect(JSON.stringify(renderer.toJSON())).toContain( + 'Alert types follow each paired desktop’s notification settings.' + ) + act(() => switches()[0].props.onValueChange(false)) + expect(onChange).toHaveBeenLastCalledWith( + expect.objectContaining({ onlyWhenDesktopAway: false, sound: true, suppressWhileViewing: true }) + ) + act(() => renderer.unmount()) +}) diff --git a/mobile/src/notifications/NotificationDeliverySection.tsx b/mobile/src/notifications/NotificationDeliverySection.tsx new file mode 100644 index 00000000000..df1f3dc0683 --- /dev/null +++ b/mobile/src/notifications/NotificationDeliverySection.tsx @@ -0,0 +1,72 @@ +import { StyleSheet, Switch, Text, View } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import type { NotificationDeliveryPreferences } from './notification-delivery-preferences' + +type Props = { + value: NotificationDeliveryPreferences + disabled?: boolean + onChange: (value: NotificationDeliveryPreferences) => void +} + +export function NotificationDeliverySection({ value, disabled, onChange }: Props) { + const row = (key: keyof NotificationDeliveryPreferences, label: string, hint?: string) => { + return ( + + + {label} + {hint && {hint}} + + onChange({ ...value, [key]: enabled })} + trackColor={{ false: colors.bgRaised, true: colors.textSecondary }} + thumbColor={colors.textPrimary} + /> + + ) + } + return ( + <> + + {row( + 'onlyWhenDesktopAway', + 'Only when away from desktop', + 'After 3 minutes without keyboard or mouse activity, or when locked.' + )} + {row('sound', 'Notification sound')} + {row( + 'suppressWhileViewing', + 'Suppress while focused', + 'Skip alerts for the workspace open on this phone.' + )} + + + Alert types follow each paired desktop’s notification settings. Notifications pause after 7 + days without using this app; open it and reconnect to resume. + + + ) +} + +const styles = StyleSheet.create({ + section: { + backgroundColor: colors.bgPanel, + borderRadius: radii.card, + overflow: 'hidden', + marginTop: spacing.md + }, + row: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, padding: spacing.md }, + labelGroup: { flex: 1, gap: spacing.xs }, + label: { fontSize: typography.bodySize, fontWeight: '500', color: colors.textPrimary }, + hint: { fontSize: typography.metaSize, color: colors.textMuted }, + disabled: { opacity: 0.5 }, + footer: { + fontSize: typography.metaSize, + color: colors.textMuted, + marginTop: spacing.md, + paddingHorizontal: spacing.sm + } +}) diff --git a/mobile/src/notifications/android-foreground-push.test.ts b/mobile/src/notifications/android-foreground-push.test.ts new file mode 100644 index 00000000000..cad381e7ee9 --- /dev/null +++ b/mobile/src/notifications/android-foreground-push.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import type { Notification } from 'expo-notifications' +import { startAndroidForegroundPushPresentation } from './android-foreground-push' + +const mocks = vi.hoisted(() => ({ + platform: { OS: 'android' }, + receive: (_notification: Notification) => {}, + remove: vi.fn(), + eligible: vi.fn().mockResolvedValue(true), + schedule: vi.fn().mockResolvedValue('message-1') +})) +vi.mock('./push-receive', () => ({ canPresentForegroundPush: mocks.eligible })) +vi.mock('react-native', () => ({ Platform: mocks.platform })) +vi.mock('expo-notifications', () => ({ + addNotificationReceivedListener: (listener: typeof mocks.receive) => { + mocks.receive = listener + return { remove: mocks.remove } + }, + scheduleNotificationAsync: mocks.schedule +})) + +function notification(trigger: unknown = { type: 'push', remoteMessage: { notification: null } }) { + return { + request: { + identifier: 'message-1', + trigger, + content: { + title: 'Test notification', + body: '', + sound: 'default', + data: { + hostFingerprint: 'host', + notificationId: 'event', + notificationEpoch: 'epoch', + notificationSeq: '3', + paneKey: 'pane', + channelId: 'orca-desktop' + } + } + } + } as unknown as Notification +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.eligible.mockReset().mockResolvedValue(true) + mocks.platform.OS = 'android' +}) + +it('presents a title-only data push with its original identity, routing and channel', async () => { + const stop = startAndroidForegroundPushPresentation() + const incoming = notification() + mocks.receive(incoming) + await vi.waitFor(() => expect(mocks.schedule).toHaveBeenCalledOnce()) + expect(mocks.schedule).toHaveBeenCalledWith({ + identifier: incoming.request.identifier, + content: incoming.request.content, + trigger: { channelId: 'orca-desktop' } + }) + stop() + expect(mocks.remove).toHaveBeenCalledOnce() +}) + +it('does not reschedule its own local notification or normal provider notifications', () => { + startAndroidForegroundPushPresentation() + mocks.receive(notification(null)) + mocks.receive(notification({ type: 'channel', channelId: 'orca-desktop' })) + mocks.receive(notification({ type: 'push', remoteMessage: { notification: { title: 'Test' } } })) + expect(mocks.schedule).not.toHaveBeenCalled() +}) + +it('leaves silent dismissals and unrelated messages alone', () => { + startAndroidForegroundPushPresentation() + const incoming = notification() + incoming.request.content.data.kind = 'dismiss' + mocks.receive(incoming) + incoming.request.content.data = {} + mocks.receive(incoming) + expect(mocks.schedule).not.toHaveBeenCalled() +}) + +it('leaves iOS delivery unchanged', () => { + mocks.platform.OS = 'ios' + startAndroidForegroundPushPresentation()() + expect(mocks.remove).not.toHaveBeenCalled() +}) + +it('waits for eligibility before scheduling, even if native presentation will bypass JS', async () => { + let resolve!: (eligible: boolean) => void + mocks.eligible.mockReturnValue( + new Promise((done) => { + resolve = done + }) + ) + startAndroidForegroundPushPresentation() + mocks.receive(notification()) + expect(mocks.schedule).not.toHaveBeenCalled() + // Model a dismissal arriving while the eligibility reads are in flight. + resolve(false) + await Promise.resolve() + expect(mocks.schedule).not.toHaveBeenCalled() +}) + +it('does not schedule when eligibility cannot be read', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + mocks.eligible.mockRejectedValueOnce(new Error('storage unavailable')) + startAndroidForegroundPushPresentation() + mocks.receive(notification()) + await vi.waitFor(() => expect(warn).toHaveBeenCalledOnce()) + expect(mocks.schedule).not.toHaveBeenCalled() + warn.mockRestore() +}) diff --git a/mobile/src/notifications/android-foreground-push.ts b/mobile/src/notifications/android-foreground-push.ts new file mode 100644 index 00000000000..cbff78d0bf2 --- /dev/null +++ b/mobile/src/notifications/android-foreground-push.ts @@ -0,0 +1,49 @@ +import { Platform } from 'react-native' +import * as Notifications from 'expo-notifications' +import { canPresentForegroundPush } from './push-receive' +import { readOrcaPushPayload } from './push-payload' + +export function startAndroidForegroundPushPresentation(): () => void { + if (Platform.OS !== 'android') { + return () => {} + } + + const subscription = Notifications.addNotificationReceivedListener((notification) => { + const { trigger, content, identifier } = notification.request + // Expo emits foreground data pushes but only auto-presents them in the background. + if ( + !trigger || + !('type' in trigger) || + trigger.type !== 'push' || + trigger.remoteMessage?.notification !== null + ) { + return + } + const payload = readOrcaPushPayload(content.data) + if (!payload || payload.kind === 'dismiss' || (!content.title && !content.body)) { + return + } + + void present().catch((error: unknown) => { + console.warn('[push] Foreground notification presentation failed', error) + }) + + async function present(): Promise { + if (!payload || !(await canPresentForegroundPush(payload))) { + return + } + await Notifications.scheduleNotificationAsync({ + identifier, + content: { + title: content.title, + body: content.body, + data: content.data, + sound: content.sound === 'default' ? 'default' : false + }, + trigger: + typeof content.data?.channelId === 'string' ? { channelId: content.data.channelId } : null + }) + } + }) + return () => subscription.remove() +} diff --git a/mobile/src/notifications/desktop-notification-channel.test.ts b/mobile/src/notifications/desktop-notification-channel.test.ts new file mode 100644 index 00000000000..95ff41ebe35 --- /dev/null +++ b/mobile/src/notifications/desktop-notification-channel.test.ts @@ -0,0 +1,62 @@ +import { readFileSync } from 'node:fs' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as Notifications from 'expo-notifications' +import { Platform } from 'react-native' +import { + DESKTOP_NOTIFICATION_CHANNEL_ID, + ensureDesktopNotificationChannel +} from './desktop-notification-channel' + +vi.mock('expo-notifications', () => ({ + AndroidImportance: { HIGH: 'high' }, + setNotificationChannelAsync: vi.fn() +})) + +vi.mock('react-native', () => ({ + AppState: { currentState: 'background' }, + Platform: { OS: 'android' } +})) + +beforeEach(() => { + vi.clearAllMocks() + Object.assign(Platform, { OS: 'android' }) + vi.mocked(Notifications.setNotificationChannelAsync).mockResolvedValue(null as never) +}) + +describe('ensureDesktopNotificationChannel', () => { + it('creates the channel the gateway payload names', async () => { + await ensureDesktopNotificationChannel() + + expect(Notifications.setNotificationChannelAsync).toHaveBeenCalledWith( + 'orca-desktop', + expect.objectContaining({ importance: 'high' }) + ) + expect(DESKTOP_NOTIFICATION_CHANNEL_ID).toBe('orca-desktop') + }) + + it('does nothing on iOS, which has no notification channels', () => { + Object.assign(Platform, { OS: 'ios' }) + + ensureDesktopNotificationChannel() + + expect(Notifications.setNotificationChannelAsync).not.toHaveBeenCalled() + }) + + it('reports channel failure so registration can retry', async () => { + vi.mocked(Notifications.setNotificationChannelAsync).mockRejectedValue(new Error('no channels')) + + await expect(ensureDesktopNotificationChannel()).rejects.toThrow('no channels') + }) +}) + +describe('app boot', () => { + it('creates the channel at startup, not only once a socket subscribes', () => { + // A background push can be the first thing to target 'orca-desktop', and Android + // drops a notification whose channel does not exist. Asserted against the source + // because vitest only collects src/, so app/_layout.tsx has no runtime coverage. + const layout = readFileSync(new URL('../../app/_layout.tsx', import.meta.url), 'utf8') + + expect(layout).toContain("from '../src/notifications/desktop-notification-channel'") + expect(layout).toMatch(/^void ensureDesktopNotificationChannel\(\)\.catch\(/m) + }) +}) diff --git a/mobile/src/notifications/desktop-notification-channel.ts b/mobile/src/notifications/desktop-notification-channel.ts new file mode 100644 index 00000000000..cce1f73d582 --- /dev/null +++ b/mobile/src/notifications/desktop-notification-channel.ts @@ -0,0 +1,27 @@ +import * as Notifications from 'expo-notifications' +import { Platform } from 'react-native' + +// Why an id both sides share: the gateway's FCM payload names this channel, so a +// background push can be the first thing that ever targets it. Android drops a +// notification whose channel does not exist, and the channel used to be created +// only inside subscribeToDesktopNotifications — i.e. only once a socket connected. +export const DESKTOP_NOTIFICATION_CHANNEL_ID = 'orca-desktop' + +/** Idempotent on Android (the OS updates the existing channel); a no-op elsewhere. */ +export async function ensureDesktopNotificationChannel(): Promise { + if (Platform.OS !== 'android') { + return + } + await Notifications.setNotificationChannelAsync(`${DESKTOP_NOTIFICATION_CHANNEL_ID}-silent`, { + name: 'Orca silent notifications', + importance: Notifications.AndroidImportance.HIGH, + sound: null, + enableVibrate: false + }) + await Notifications.setNotificationChannelAsync(DESKTOP_NOTIFICATION_CHANNEL_ID, { + name: 'Desktop Notifications', + importance: Notifications.AndroidImportance.HIGH, + vibrationPattern: [0, 250], + lightColor: '#6366f1' + }) +} diff --git a/mobile/src/notifications/desktop-notification-events.ts b/mobile/src/notifications/desktop-notification-events.ts new file mode 100644 index 00000000000..b6e7492b648 --- /dev/null +++ b/mobile/src/notifications/desktop-notification-events.ts @@ -0,0 +1,6 @@ +export type DismissNotificationEvent = { + type: 'dismiss' + notificationId: string + notificationSeq?: number + notificationEpoch?: string +} diff --git a/mobile/src/notifications/expo-native-token-retry.test.ts b/mobile/src/notifications/expo-native-token-retry.test.ts new file mode 100644 index 00000000000..7fabcd6555c --- /dev/null +++ b/mobile/src/notifications/expo-native-token-retry.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, expect, it, vi } from 'vitest' + +const native = vi.hoisted(() => vi.fn()) +vi.mock('expo-modules-core', () => ({ Platform: { OS: 'ios' }, UnavailabilityError: Error })) +vi.mock('expo-notifications/build/PushTokenManager', () => ({ + default: { getDevicePushTokenAsync: native } +})) +vi.mock('expo-notifications/build/warnOfExpoGoPushUsage', () => ({ + warnOfExpoGoPushUsage: () => {} +})) + +beforeEach(() => { + vi.resetModules() + native.mockReset() +}) + +it('releases a failed Expo native-token request so the next attempt can succeed', async () => { + const { getDevicePushTokenAsync } = + await import('expo-notifications/build/getDevicePushTokenAsync') + native.mockRejectedValueOnce(new Error('APNs unavailable')).mockResolvedValueOnce('device-token') + await expect(getDevicePushTokenAsync()).rejects.toThrow('APNs unavailable') + await expect(getDevicePushTokenAsync()).resolves.toEqual({ type: 'ios', data: 'device-token' }) + expect(native).toHaveBeenCalledTimes(2) +}) + +it('still shares one pending native request between concurrent callers', async () => { + const { getDevicePushTokenAsync } = + await import('expo-notifications/build/getDevicePushTokenAsync') + let resolve!: (token: string) => void + native.mockReturnValue( + new Promise((done) => { + resolve = done + }) + ) + const first = getDevicePushTokenAsync() + const second = getDevicePushTokenAsync() + expect(native).toHaveBeenCalledOnce() + resolve('device-token') + expect(await first).toEqual(await second) +}) diff --git a/mobile/src/notifications/local-notification-scheduling.ts b/mobile/src/notifications/local-notification-scheduling.ts deleted file mode 100644 index f511346250e..00000000000 --- a/mobile/src/notifications/local-notification-scheduling.ts +++ /dev/null @@ -1,191 +0,0 @@ -import * as Notifications from 'expo-notifications' -import { Platform } from 'react-native' -import { loadPushNotificationsEnabled } from '../storage/preferences' -import { buildLocalNotificationData, type DesktopNotificationSource } from './notification-routing' -import { ensureNotificationPermissions } from './notification-permissions' - -export type NotificationEvent = { - type: 'notification' - source: DesktopNotificationSource - title: string - body: string - worktreeId?: string - notificationId?: string - // Desktop-assigned seq for reconnect catch-up (#8129); optional since older runtimes may omit it. - notificationSeq?: number - // Counter lifetime the seq belongs to (#8591); absent on older runtimes. - notificationEpoch?: string -} - -export type DismissNotificationEvent = { - type: 'dismiss' - notificationId: string - notificationSeq?: number - notificationEpoch?: string -} - -type ScheduledNotificationState = { - identifier?: string - pending?: Promise - dismissAfterSchedule?: boolean -} - -const scheduledNotificationsByHostAndNotificationId = new Map() - -// Why: keys never repeat and are only freed on desktop dismiss (which remote users often miss), so bound the map to stop unbounded growth. -const MAX_SCHEDULED_NOTIFICATIONS = 256 -let maxScheduledNotifications = MAX_SCHEDULED_NOTIFICATIONS - -function getStoredNotificationKey(hostId: string, notificationId: string): string { - return `${encodeURIComponent(hostId)}:${encodeURIComponent(notificationId)}` -} - -// Evict oldest settled entries (never mid-schedule); Map iteration is insertion order so the first match is oldest. -function boundScheduledNotifications(): void { - while (scheduledNotificationsByHostAndNotificationId.size > maxScheduledNotifications) { - let evicted = false - for (const [key, state] of scheduledNotificationsByHostAndNotificationId) { - if (!state.pending) { - scheduledNotificationsByHostAndNotificationId.delete(key) - evicted = true - break - } - } - if (!evicted) { - break - } - } -} - -/** Test-only: override the cap (pass no arg to restore the default). */ -export function setScheduledNotificationsMaxForTests(max?: number): void { - maxScheduledNotifications = max ?? MAX_SCHEDULED_NOTIFICATIONS -} - -export function configureNotificationChannel(): void { - if (Platform.OS === 'android') { - void Notifications.setNotificationChannelAsync('orca-desktop', { - name: 'Desktop Notifications', - importance: Notifications.AndroidImportance.HIGH, - vibrationPattern: [0, 250], - lightColor: '#6366f1' - }) - } -} - -export async function showLocalNotification( - event: NotificationEvent, - hostId: string -): Promise { - const storedKey = event.notificationId - ? getStoredNotificationKey(hostId, event.notificationId) - : null - - if (!storedKey) { - const enabled = await loadPushNotificationsEnabled() - if (!enabled) { - return - } - - const granted = await ensureNotificationPermissions() - if (!granted) { - return - } - - await Notifications.scheduleNotificationAsync({ - content: { - title: event.title, - body: event.body, - data: buildLocalNotificationData(event, hostId), - ...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {}) - }, - trigger: null - }) - return - } - - let state = scheduledNotificationsByHostAndNotificationId.get(storedKey) - if (state?.pending) { - return - } - if (!state) { - state = {} - scheduledNotificationsByHostAndNotificationId.set(storedKey, state) - } - const notificationState = state - - const pending = (async () => { - const enabled = await loadPushNotificationsEnabled() - if (!enabled) { - return null - } - - const granted = await ensureNotificationPermissions() - if (!granted) { - return null - } - - if (notificationState.identifier) { - await Notifications.dismissNotificationAsync(notificationState.identifier).catch(() => {}) - notificationState.identifier = undefined - } - - return Notifications.scheduleNotificationAsync({ - content: { - title: event.title, - body: event.body, - data: buildLocalNotificationData(event, hostId), - ...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {}) - }, - trigger: null - }) - })() - notificationState.pending = pending - - try { - const scheduledIdentifier = await pending - if (!scheduledIdentifier) { - if (!notificationState.identifier) { - scheduledNotificationsByHostAndNotificationId.delete(storedKey) - } - return - } - if (notificationState.dismissAfterSchedule) { - notificationState.dismissAfterSchedule = false - scheduledNotificationsByHostAndNotificationId.delete(storedKey) - await Notifications.dismissNotificationAsync(scheduledIdentifier).catch(() => {}) - return - } - notificationState.identifier = scheduledIdentifier - boundScheduledNotifications() - } finally { - if (notificationState.pending === pending) { - notificationState.pending = undefined - notificationState.dismissAfterSchedule = false - } - } -} - -export async function dismissLocalNotification( - event: DismissNotificationEvent, - hostId: string -): Promise { - if (!event.notificationId) { - return - } - const storedKey = getStoredNotificationKey(hostId, event.notificationId) - const state = scheduledNotificationsByHostAndNotificationId.get(storedKey) - if (!state) { - return - } - if (state.pending) { - // Why: dismiss can arrive while the OS is still scheduling; defer it so no stale banner survives. - state.dismissAfterSchedule = true - return - } - if (!state.identifier) { - return - } - scheduledNotificationsByHostAndNotificationId.delete(storedKey) - await Notifications.dismissNotificationAsync(state.identifier).catch(() => {}) -} diff --git a/mobile/src/notifications/mobile-notifications.test.ts b/mobile/src/notifications/mobile-notifications.test.ts index d85b1363005..ba784520f98 100644 --- a/mobile/src/notifications/mobile-notifications.test.ts +++ b/mobile/src/notifications/mobile-notifications.test.ts @@ -1,969 +1,59 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import * as Notifications from 'expo-notifications' -import { Platform } from 'react-native' -import { - getNotificationPermissionState, - setScheduledNotificationsMaxForTests, - subscribeToDesktopNotifications -} from './mobile-notifications' -import AsyncStorage from '@react-native-async-storage/async-storage' -import type { RpcClient } from '../transport/rpc-client' -import { loadPushNotificationsEnabled } from '../storage/preferences' -import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup' +import { subscribeToDesktopNotifications } from './mobile-notifications' +import { dismissHostPushNotification } from './push-socket-dismissal' +import { requestNotificationCatchup } from './push-dismissal-reconciliation' -vi.mock('expo-notifications', () => ({ - AndroidImportance: { HIGH: 'high' }, - setNotificationChannelAsync: vi.fn(), - getPermissionsAsync: vi.fn(), - requestPermissionsAsync: vi.fn(), - scheduleNotificationAsync: vi.fn(), - dismissNotificationAsync: vi.fn() +vi.mock('./push-socket-dismissal', () => ({ + dismissHostPushNotification: vi.fn(async () => {}) })) - -vi.mock('react-native', () => ({ - Platform: { OS: 'ios', Version: 18 } +vi.mock('./push-dismissal-reconciliation', () => ({ + requestNotificationCatchup: vi.fn(async () => {}) })) +vi.mock('./notification-permissions', () => ({})) -// Why: mobile-notifications now persists the catch-up watermark to -// AsyncStorage. The package isn't resolvable in the node test env (other -// mobile tests mock it the same way), so we provide a no-op mock. -vi.mock('@react-native-async-storage/async-storage', () => ({ - default: { - getItem: vi.fn(async () => null), - setItem: vi.fn(async () => undefined) - } -})) +type Handler = (data: unknown) => void -vi.mock('../storage/preferences', () => ({ - loadPushNotificationsEnabled: vi.fn() -})) - -beforeEach(() => { - Object.assign(Platform, { OS: 'ios', Version: 18 }) - // Why (#8591): the reconnect watermark/seen-set now live per host at module - // scope so they survive the app's unsubscribe-on-disconnect. Reset between - // tests so each case starts from a genuine cold open. - resetHostNotificationSessionsForTests() -}) - -describe('getNotificationPermissionState', () => { - it.each([ - { os: 'android', version: 32, expected: false }, - { os: 'android', version: 33, expected: true }, - { os: 'ios', version: 18, expected: true } - ])( - 'reports whether a granted $os $version authorization reflects user choice', - async ({ os, version, expected }) => { - Object.assign(Platform, { OS: os, Version: version }) - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ - status: 'granted', - canAskAgain: true - } as never) - - await expect(getNotificationPermissionState()).resolves.toMatchObject({ - granted: true, - authorizationReflectsUserChoice: expected - }) +function client() { + let handler: Handler | undefined + return { + getState: vi.fn(() => 'connected'), + sendRequest: vi.fn(async () => ({ ok: true })), + subscribe: vi.fn((_method: string, _params: unknown, callback: Handler) => { + handler = callback + return vi.fn() + }), + emit(data: unknown) { + handler?.(data) } - ) -}) + } +} + +beforeEach(() => vi.clearAllMocks()) describe('subscribeToDesktopNotifications', () => { - beforeEach(() => { - vi.clearAllMocks() + it('never presents an OS banner for socket alert or replay events', async () => { + const rpc = client() + subscribeToDesktopNotifications(rpc as never, 'host-1') + rpc.emit({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' }) + rpc.emit({ + type: 'notification', + notificationId: 'agent-1', + title: 'Needs input', + body: 'Reply', + source: 'agent-task-complete' + }) + await Promise.resolve() + expect(requestNotificationCatchup).toHaveBeenCalledWith(rpc, 'host-1', expect.any(Function)) + expect(dismissHostPushNotification).not.toHaveBeenCalled() }) - // Why the macrotask and not N microtask ticks (#8591): deliveries now run through - // the per-host serialization queue, so a delivery is several more `await` hops deep - // than it used to be and a fixed tick count silently under-drains. Yielding to the - // macrotask queue drains whatever depth the chain happens to have. - function flushAsync(): Promise { - return new Promise((resolve) => { - setTimeout(resolve, 0) - }) - } - - function makeDeferred(): { promise: Promise; resolve: (value: T) => void } { - let resolve!: (value: T) => void - const promise = new Promise((next) => { - resolve = next - }) - return { promise, resolve } - } - - it('drops the local stream when disposed before the desktop returns ready', () => { - const unsubscribeStream = vi.fn() - const client = { - subscribe: vi.fn(() => unsubscribeStream), - getState: vi.fn(() => 'connected'), - sendRequest: vi.fn() - } as unknown as RpcClient - - const unsubscribe = subscribeToDesktopNotifications(client, 'host-1') - unsubscribe() - - expect(unsubscribeStream).toHaveBeenCalledTimes(1) - expect(client.sendRequest).not.toHaveBeenCalled() - }) - - it('stores scheduled notification identifiers, replaces duplicates, and dismisses by id', async () => { - vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ - status: 'granted', - canAskAgain: true - } as never) - vi.mocked(Notifications.scheduleNotificationAsync) - .mockResolvedValueOnce('scheduled-1') - .mockResolvedValueOnce('scheduled-2') - vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined) - let onEvent: ((data: unknown) => void) | null = null - const client = { - subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => { - onEvent = callback - return vi.fn() - }), - getState: vi.fn(() => 'connected'), - sendRequest: vi.fn() - } as unknown as RpcClient - - subscribeToDesktopNotifications(client, 'host-1') - onEvent?.({ - type: 'notification', - source: 'agent-task-complete', - title: 'Done', - body: 'Finished.', - worktreeId: 'repo::/tmp/worktree', - notificationId: 'agent:one' - }) - await flushAsync() - onEvent?.({ - type: 'notification', - source: 'agent-task-complete', - title: 'Done again', - body: 'Finished again.', - notificationId: 'agent:one' - }) - await flushAsync() - expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(2) - onEvent?.({ type: 'dismiss', notificationId: 'agent:one' }) - await flushAsync() - - expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(2) - expect(Notifications.scheduleNotificationAsync).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - content: expect.objectContaining({ - data: expect.objectContaining({ - hostId: 'host-1', - notificationId: 'agent:one', - worktreeId: 'repo::/tmp/worktree' - }) - }) - }) - ) - expect(Notifications.dismissNotificationAsync).toHaveBeenNthCalledWith(1, 'scheduled-1') - expect(Notifications.dismissNotificationAsync).toHaveBeenNthCalledWith(2, 'scheduled-2') - }) - - it('dedupes concurrent notification events with the same desktop notification id', async () => { - vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ - status: 'granted', - canAskAgain: true - } as never) - vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1') - let onEvent: ((data: unknown) => void) | null = null - const client = { - subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => { - onEvent = callback - return vi.fn() - }), - getState: vi.fn(() => 'connected'), - sendRequest: vi.fn() - } as unknown as RpcClient - - subscribeToDesktopNotifications(client, 'host-concurrent') - onEvent?.({ - type: 'notification', - source: 'agent-task-complete', - title: 'Done', - body: 'Finished.', - notificationId: 'agent:concurrent' - }) - onEvent?.({ - type: 'notification', - source: 'agent-task-complete', - title: 'Done', - body: 'Finished.', - notificationId: 'agent:concurrent' - }) - await flushAsync() - - expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(1) - }) - - it('dismisses a notification when dismiss arrives while scheduling is pending', async () => { - vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ - status: 'granted', - canAskAgain: true - } as never) - let resolveSchedule!: (identifier: string) => void - vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation( - () => - new Promise((resolve) => { - resolveSchedule = resolve - }) - ) - vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined) - let onEvent: ((data: unknown) => void) | null = null - const client = { - subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => { - onEvent = callback - return vi.fn() - }), - getState: vi.fn(() => 'connected'), - sendRequest: vi.fn() - } as unknown as RpcClient - - subscribeToDesktopNotifications(client, 'host-dismiss-race') - onEvent?.({ - type: 'notification', - source: 'agent-task-complete', - title: 'Done', - body: 'Finished.', - notificationId: 'agent:pending' - }) - await flushAsync() - onEvent?.({ type: 'dismiss', notificationId: 'agent:pending' }) - resolveSchedule('scheduled-pending') - await flushAsync() - - expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('scheduled-pending') - }) - - it('does not carry a failed pending dismiss into a future schedule', async () => { - const secondEnabled = makeDeferred() - vi.mocked(loadPushNotificationsEnabled) - .mockResolvedValueOnce(true) - .mockReturnValueOnce(secondEnabled.promise) - .mockResolvedValueOnce(true) - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ - status: 'granted', - canAskAgain: true - } as never) - vi.mocked(Notifications.scheduleNotificationAsync) - .mockResolvedValueOnce('scheduled-1') - .mockResolvedValueOnce('scheduled-2') - vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined) - let onEvent: ((data: unknown) => void) | null = null - const client = { - subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => { - onEvent = callback - return vi.fn() - }), - getState: vi.fn(() => 'connected'), - sendRequest: vi.fn() - } as unknown as RpcClient - - subscribeToDesktopNotifications(client, 'host-dismiss-failed-replacement') - onEvent?.({ - type: 'notification', - source: 'agent-task-complete', - title: 'Done', - body: 'Finished.', - notificationId: 'agent:stale-dismiss' - }) - await flushAsync() - onEvent?.({ - type: 'notification', - source: 'agent-task-complete', - title: 'Done again', - body: 'Finished again.', - notificationId: 'agent:stale-dismiss' - }) - await flushAsync() - onEvent?.({ type: 'dismiss', notificationId: 'agent:stale-dismiss' }) - secondEnabled.resolve(false) - await flushAsync() - - onEvent?.({ - type: 'notification', - source: 'agent-task-complete', - title: 'Done later', - body: 'Finished later.', - notificationId: 'agent:stale-dismiss' - }) - await flushAsync() - - expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(2) - expect(Notifications.dismissNotificationAsync).toHaveBeenCalledTimes(1) - expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('scheduled-1') - }) - - it('treats unknown dismiss events as no-ops', async () => { - vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined) - let onEvent: ((data: unknown) => void) | null = null - const client = { - subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => { - onEvent = callback - return vi.fn() - }), - getState: vi.fn(() => 'connected'), - sendRequest: vi.fn() - } as unknown as RpcClient - - subscribeToDesktopNotifications(client, 'host-unknown') - onEvent?.({ type: 'dismiss', notificationId: 'agent:missing' }) - await flushAsync() - - expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalled() - }) - - // Why: notificationId is unique per completion, so the map grew unbounded when - // the desktop never sent a dismiss (the remote-mobile case). It is now capped. - it('evicts the oldest scheduled entry once the cap is exceeded', async () => { - setScheduledNotificationsMaxForTests(1) - try { - vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ - status: 'granted', - canAskAgain: true - } as never) - vi.mocked(Notifications.scheduleNotificationAsync) - .mockResolvedValueOnce('scheduled-old') - .mockResolvedValueOnce('scheduled-new') - vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined) - let onEvent: ((data: unknown) => void) | null = null - const client = { - subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => { - onEvent = callback - return vi.fn() - }), - getState: vi.fn(() => 'connected'), - sendRequest: vi.fn() - } as unknown as RpcClient - - subscribeToDesktopNotifications(client, 'host-1') - onEvent?.({ type: 'notification', title: 't', body: 'b', notificationId: 'agent:old' }) - await flushAsync() - onEvent?.({ type: 'notification', title: 't', body: 'b', notificationId: 'agent:new' }) - await flushAsync() - - // The older entry was evicted by the cap: dismissing it is a no-op... - onEvent?.({ type: 'dismiss', notificationId: 'agent:old' }) - await flushAsync() - expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalledWith('scheduled-old') - - // ...while the most-recent entry is retained and still dismissable. - onEvent?.({ type: 'dismiss', notificationId: 'agent:new' }) - await flushAsync() - expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('scheduled-new') - } finally { - setScheduledNotificationsMaxForTests() - } - }) -}) - -// Why: #8129 catch-up. On a reconnect the live stream re-emits `ready`; the -// client must fetch missed notifications from its watermark and push exactly -// the ones it had not yet delivered — never re-pushing an already-delivered id. -describe('subscribeToDesktopNotifications — reconnect catch-up', () => { - const AsyncStorageMock = vi.mocked(AsyncStorage) - - beforeEach(() => { - vi.clearAllMocks() - AsyncStorageMock.getItem.mockResolvedValue(null) - vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined) - }) - - function flushAsync(): Promise { - return new Promise((resolve) => { - setTimeout(resolve, 10) - }) - } - - function makeClient() { - let onData: ((data: unknown) => void) | null = null - const sentRequests: { method: string; params: unknown }[] = [] - const client = { - subscribe: vi.fn((_method: string, _params: unknown, cb: (data: unknown) => void) => { - onData = cb - return vi.fn() - }), - getState: vi.fn(() => 'connected'), - sendRequest: vi.fn( - async (method: string, _params: unknown = {}) => - ({ - ok: true, - result: method === 'notifications.getMissedSince' ? { notifications: [] } : undefined - }) as never - ) - } - // Why: onData is captured live via a getter (not destructured) because the - // subscribe mock assigns it asynchronously as a side effect of - // subscribeToDesktopNotifications calling client.subscribe. - return { - client: client as unknown as RpcClient, - get onData() { - return onData - }, - sentRequests - } - } - - it('does not fetch missed notifications on the first (cold-open) ready', async () => { - vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ - status: 'granted', - canAskAgain: true - } as never) - vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1') - - const sub = makeClient() - subscribeToDesktopNotifications(sub.client, 'host-1') - // First ready = cold open. - sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' }) - await flushAsync() - - expect(sub.client.sendRequest).not.toHaveBeenCalledWith( - 'notifications.getMissedSince', - expect.anything() - ) - }) - - it('fetches only notifications after the delivered watermark (idempotent catch-up)', async () => { - vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ - status: 'granted', - canAskAgain: true - } as never) - vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1') - - const sub = makeClient() - // The desktop honours the watermark: only seq 10 (agent:missed) is returned - // because seq 11 (agent:dup) was already delivered on the live stream and - // advanced lastDeliveredSeq to 11. So the replay never re-includes it. - sub.client.sendRequest = vi.fn(async (method: string) => { - if (method === 'notifications.getMissedSince') { - return { - ok: true, - result: { - notifications: [ - { - type: 'notification', - title: 'missed', - body: 'b', - notificationId: 'agent:missed', - notificationSeq: 10 - } - ] - } - } as never - } - return { ok: true, result: undefined } as never - }) - - subscribeToDesktopNotifications(sub.client, 'host-1') - // First ready = cold open (no fetch). - sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' }) - await flushAsync() - // Live stream already delivered agent:dup (seq 11) before reap. - sub.onData?.({ - type: 'notification', - title: 'dup', - body: 'b', - notificationId: 'agent:dup', - notificationSeq: 11 - }) - await flushAsync() - // Reconnect ready → fetchMissed sends the watermark (11). - sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' }) - await flushAsync() - await flushAsync() - - // The watermark passed to getMissedSince is the delivered seq. - const missedCall = vi - .mocked(sub.client.sendRequest) - .mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince') - expect(missedCall?.[1]).toEqual({ lastSeenSeq: 11 }) - // Only agent:missed was pushed; agent:dup appears exactly once (live only). - const scheduledIds = vi - .mocked(Notifications.scheduleNotificationAsync) - .mock.calls.map( - (call) => - (call[0] as { content: { data: { notificationId: string } } }).content.data.notificationId - ) - expect(scheduledIds).toEqual(['agent:dup', 'agent:missed']) - expect(scheduledIds.filter((id) => id === 'agent:dup')).toHaveLength(1) - }) - - it('voids a persisted watermark whose epoch predates a desktop restart', async () => { - // #8591: the desktop's seq counter restarts at 0 each launch while this watermark - // is persisted. Reconnecting to a restarted desktop with seq 57 would make - // `57 >= 2` true and silently kill catch-up. The epoch on 'ready' is what tells - // the client the counter changed, so the stale watermark must be dropped. - vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ - status: 'granted', - canAskAgain: true - } as never) - vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1') - vi.mocked(AsyncStorage.getItem).mockImplementation(async (key: string) => - key.startsWith('orca:mobileNotificationsWatermark:') - ? JSON.stringify({ seq: 57, epoch: 'epoch-before-restart' }) - : null - ) - - const sub = makeClient() - subscribeToDesktopNotifications(sub.client, 'host-1') - // Cold open under the OLD desktop process, so the watermark loads as 57. - sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-before-restart' }) - await flushAsync() - await flushAsync() - - // Desktop restarts: new epoch, counter back near 0. - sub.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-after-restart' }) - await flushAsync() - await flushAsync() - - const missedCalls = vi - .mocked(sub.client.sendRequest) - .mock.calls.filter((c: unknown[]) => c[0] === 'notifications.getMissedSince') - // The cold open catches up from its stored watermark against the SAME counter — - // 57 is meaningful there, so it is the correct cut (#8591 second pass). - expect(missedCalls[0]?.[1]).toEqual({ lastSeenSeq: 57, epoch: 'epoch-before-restart' }) - // After the restart the watermark is reset to 0 and tagged with the live epoch — - // not the stale 57, which would make `57 >= 2` true and kill catch-up silently. - expect(missedCalls.at(-1)?.[1]).toEqual({ lastSeenSeq: 0, epoch: 'epoch-after-restart' }) - }) - - it('refuses to seed a stored watermark that lost the race to a newer live epoch', async () => { - // The seed read is deliberately not awaited (so subscribe doesn't block on - // AsyncStorage), which means it can land AFTER 'ready' already adopted the live - // epoch. If it seeds unconditionally it reinstates the exact stale cut #8591 is - // about — the reset having already happened doesn't help, because the seed runs - // last and wins. Only a stored epoch matching the live one may seed. - vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ - status: 'granted', - canAskAgain: true - } as never) - vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1') - - // Hold the storage read open so 'ready' is guaranteed to be processed first. - let releaseStorage: () => void = () => {} - const storageGate = new Promise((resolve) => { - releaseStorage = resolve - }) - vi.mocked(AsyncStorage.getItem).mockImplementation(async (key: string) => { - await storageGate - return key.startsWith('orca:mobileNotificationsWatermark:') - ? JSON.stringify({ seq: 57, epoch: 'epoch-before-restart' }) - : null - }) - - const sub = makeClient() - subscribeToDesktopNotifications(sub.client, 'host-1') - // Live epoch adopted while the stored one is still in flight. - sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-after-restart' }) - await flushAsync() - - releaseStorage() - await flushAsync() - - sub.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-after-restart' }) - await flushAsync() - await flushAsync() - - const missedCall = vi - .mocked(sub.client.sendRequest) - .mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince') - expect(missedCall?.[1]).toEqual({ lastSeenSeq: 0, epoch: 'epoch-after-restart' }) - }) - - it('keeps the persisted watermark when the desktop epoch is unchanged', async () => { - // The reset must be narrow: a plain socket reap with the same desktop process - // still has to send the real watermark, or every reconnect re-pushes the buffer. - vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ - status: 'granted', - canAskAgain: true - } as never) - vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1') - vi.mocked(AsyncStorage.getItem).mockImplementation(async (key: string) => - key.startsWith('orca:mobileNotificationsWatermark:') - ? JSON.stringify({ seq: 57, epoch: 'epoch-stable' }) - : null - ) - - const sub = makeClient() - subscribeToDesktopNotifications(sub.client, 'host-1') - sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-stable' }) - await flushAsync() - await flushAsync() - sub.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-stable' }) - await flushAsync() - await flushAsync() - - const missedCall = vi - .mocked(sub.client.sendRequest) - .mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince') - expect(missedCall?.[1]).toEqual({ lastSeenSeq: 57, epoch: 'epoch-stable' }) - }) - - it('drops an already-seen id if a replay re-includes it (defense-in-depth)', async () => { - vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ - status: 'granted', - canAskAgain: true - } as never) - vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s') - - const sub = makeClient() - // Simulate the bounded-buffer edge: the desktop returns seq 11 again - // (already delivered live) alongside a new seq 12. - sub.client.sendRequest = vi.fn(async (method: string) => { - if (method === 'notifications.getMissedSince') { - return { - ok: true, - result: { - notifications: [ - { - type: 'notification', - title: 'dup', - body: 'b', - notificationId: 'agent:dup', - notificationSeq: 11 - }, - { - type: 'notification', - title: 'new', - body: 'b', - notificationId: 'agent:new', - notificationSeq: 12 - } - ] - } - } as never - } - return { ok: true, result: undefined } as never - }) - - subscribeToDesktopNotifications(sub.client, 'host-1') - sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' }) - await flushAsync() - // Live stream delivered agent:dup (seq 11) before reap. - sub.onData?.({ - type: 'notification', - title: 'dup', - body: 'b', - notificationId: 'agent:dup', - notificationSeq: 11 - }) - await flushAsync() - // Reconnect replay re-includes seq 11 (must be dropped) + new seq 12. - sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' }) - await flushAsync() - await flushAsync() - - const scheduledIds = vi - .mocked(Notifications.scheduleNotificationAsync) - .mock.calls.map( - (call) => - (call[0] as { content: { data: { notificationId: string } } }).content.data.notificationId - ) - expect(scheduledIds).toEqual(['agent:dup', 'agent:new']) - expect(scheduledIds.filter((id) => id === 'agent:dup')).toHaveLength(1) - }) - - it('persists the highest delivered seq so a later reconnect resumes from it', async () => { - vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ - status: 'granted', - canAskAgain: true - } as never) - vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s') - - const sub = makeClient() - subscribeToDesktopNotifications(sub.client, 'host-1') - sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' }) - await flushAsync() - // Live stream delivers seq 5. - sub.onData?.({ - type: 'notification', - title: 't', - body: 'b', - notificationId: 'agent:live', - notificationSeq: 5 - }) - await flushAsync() - - expect(AsyncStorageMock.setItem).toHaveBeenCalledWith( - 'orca:mobileNotificationsWatermark:host-1', - JSON.stringify({ seq: 5, epoch: null }) - ) - }) - - // Why: a replay-ONLY delivery (nothing arrived live first) must still advance - // and persist the watermark. This is the exact case the seq/notificationSeq - // field mismatch broke — the desktop replay path returns `notificationSeq` - // (matching the live fan-out), so the client watermark moves and the next - // reconnect resumes from it instead of re-fetching from 0. - it('advances + persists the watermark from a replay-only delivery (#8129 field-mismatch regression)', async () => { - vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ - status: 'granted', - canAskAgain: true - } as never) - vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s') - - const sub = makeClient() - // Desktop replay returns events keyed by notificationSeq (the fixed shape). - sub.client.sendRequest = vi.fn(async (method: string) => { - if (method === 'notifications.getMissedSince') { - return { - ok: true, - result: { - notifications: [ - { - type: 'notification', - title: 'missed', - body: 'b', - notificationId: 'agent:missed', - notificationSeq: 8 - } - ] - } - } as never - } - return { ok: true, result: undefined } as never - }) - - subscribeToDesktopNotifications(sub.client, 'host-1') - sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' }) - await flushAsync() - // First reconnect → replay delivers seq 8 (no prior live delivery). - sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' }) - await flushAsync() - await flushAsync() - - // Watermark advanced to the replayed seq and was persisted. - expect(AsyncStorageMock.setItem).toHaveBeenCalledWith( - 'orca:mobileNotificationsWatermark:host-1', - JSON.stringify({ seq: 8, epoch: null }) - ) - - // Second reconnect resumes from the advanced watermark, not 0. - sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' }) - await flushAsync() - const missedCalls = vi - .mocked(sub.client.sendRequest) - .mock.calls.filter((c: unknown[]) => c[0] === 'notifications.getMissedSince') - expect(missedCalls.at(-1)?.[1]).toEqual({ lastSeenSeq: 8 }) - }) - - it('replays a terminal bell at a seq the previous desktop counter already used', async () => { - // Round-1 review finding: seen-keys are seq-derived, and terminal bells carry no - // notificationId (they key on `seq:N` alone). Epoch A delivers a bell at seq 1; - // after a restart, epoch B's first bell is ALSO seq 1. The catch-up path is the - // one that consults the seen-set, so without clearing it on epoch change the - // replayed post-restart bell is mistaken for a duplicate and silently skipped — - // #8591's silent loss again, now one notification at a time. - vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ - status: 'granted', - canAskAgain: true - } as never) - vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s') - - const sub = makeClient() - // Catch-up returns epoch B's first bell — same seq 1 the old counter used. - sub.client.sendRequest = vi.fn(async (method: string) => { - if (method === 'notifications.getMissedSince') { - return { - ok: true, - result: { - epoch: 'epoch-B', - notifications: [{ type: 'notification', title: 'bell', body: 'B', notificationSeq: 1 }] - } - } as never - } - return { ok: true, result: undefined } as never - }) - - subscribeToDesktopNotifications(sub.client, 'host-1') - sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-A' }) - await flushAsync() - // A live bell under epoch A — no notificationId, so its seen-key is `seq:1`. - sub.onData?.({ type: 'notification', title: 'bell', body: 'A', notificationSeq: 1 }) - await flushAsync() - expect(vi.mocked(Notifications.scheduleNotificationAsync).mock.calls.length).toBe(1) - - // Desktop restarts; reconnect triggers catch-up against the fresh counter. - sub.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-B' }) - await flushAsync() - await flushAsync() - - // The post-restart bell must reach the user, not be swallowed as a stale `seq:1`. - expect(vi.mocked(Notifications.scheduleNotificationAsync).mock.calls.length).toBe(2) - }) - - it('does not trust a legacy epoch-less watermark against a live counter', async () => { - // Round-1 review finding: pre-upgrade installs stored a bare seq with no epoch. - // Seeding it and then treating the first observed epoch as "nothing changed" - // leaves 57 cutting a counter it was never measured against — #8591 reached - // through the upgrade path. An unprovenanced seq may not survive epoch adoption. - vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ - status: 'granted', - canAskAgain: true - } as never) - vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s') - // Only the LEGACY key exists — exactly what an upgrading install has on disk. - vi.mocked(AsyncStorage.getItem).mockImplementation(async (key: string) => - key.startsWith('orca:mobileNotificationsLastSeq:') ? '57' : null - ) - - const sub = makeClient() - subscribeToDesktopNotifications(sub.client, 'host-1') - // Seed lands FIRST (no epoch known yet), so 57 is provisionally adopted... - await flushAsync() - await flushAsync() - // ...then the live epoch arrives for the first time. - sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-live' }) - await flushAsync() - sub.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-live' }) - await flushAsync() - await flushAsync() - - const missedCall = vi - .mocked(sub.client.sendRequest) - .mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince') - // Must not be 57: that seq was never shown to belong to this counter. - expect(missedCall?.[1]).toEqual({ lastSeenSeq: 0, epoch: 'epoch-live' }) - }) - - it('catches up on the FIRST connection after an upgrade, without a second ready', async () => { - // Round-2 review finding: catch-up hung off `connectedBefore`, which is false on - // the first 'ready' of a process. So a cold app open — post-upgrade, or after the - // OS evicted the app — adopted the epoch but never replayed. Everything between - // the stored watermark and the next live seq was then lost permanently, because - // the first live event advances the watermark past the gap. - // - // The earlier migration test masked this by emitting a SECOND 'ready'. This one - // emits exactly one, which is what a real cold open does. - vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ - status: 'granted', - canAskAgain: true - } as never) - vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s') - vi.mocked(AsyncStorage.getItem).mockImplementation(async (key: string) => - key.startsWith('orca:mobileNotificationsWatermark:') - ? JSON.stringify({ seq: 57, epoch: 'epoch-live' }) - : null - ) - - const sub = makeClient() - vi.mocked(sub.client.sendRequest).mockImplementation(async (method: string) => - method === 'notifications.getMissedSince' - ? { - ok: true, - result: { - epoch: 'epoch-live', - notifications: [ - { - type: 'notification', - notificationId: 'missed-58', - notificationSeq: 58, - notificationEpoch: 'epoch-live', - title: 'while the app was closed', - body: 'b' - } - ] - } - } - : { ok: true, result: {} } - ) - subscribeToDesktopNotifications(sub.client, 'host-1') - sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-live' }) - await flushAsync() - await flushAsync() - await flushAsync() - - const missedCall = vi - .mocked(sub.client.sendRequest) - .mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince') - // The single 'ready' must replay from the stored watermark, not skip it. - expect(missedCall?.[1]).toEqual({ lastSeenSeq: 57, epoch: 'epoch-live' }) - // And the missed notification must actually reach the user. - expect(vi.mocked(Notifications.scheduleNotificationAsync).mock.calls.length).toBe(1) - }) - - it('does not replay the desktop buffer at a first-ever pairing', async () => { - // The other side of the finding above: with nothing stored, this device has never - // delivered for this host. Catching up would push the whole retained buffer at a - // user who was never subscribed for any of it. - vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ - status: 'granted', - canAskAgain: true - } as never) - vi.mocked(AsyncStorage.getItem).mockResolvedValue(null) - - const sub = makeClient() - subscribeToDesktopNotifications(sub.client, 'host-1') - sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-live' }) - await flushAsync() - await flushAsync() - await flushAsync() - - expect( - vi - .mocked(sub.client.sendRequest) - .mock.calls.filter((c: unknown[]) => c[0] === 'notifications.getMissedSince') - ).toHaveLength(0) - }) - - it('persists seq and epoch as one value so a crash cannot split the pair', async () => { - // Round-1 review finding: written as two keys, a process death between the writes - // leaves epoch-B beside seq-57-from-A. That pair looks internally valid on the - // next launch and is therefore trusted — silently cutting B's first 57 events. - // One key means the pair is always written whole or not at all. - vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ - status: 'granted', - canAskAgain: true - } as never) - vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s') - - const sub = makeClient() - subscribeToDesktopNotifications(sub.client, 'host-1') - sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-A' }) - await flushAsync() - sub.onData?.({ - type: 'notification', - title: 't', - body: 'b', - notificationId: 'agent:x', - notificationSeq: 9 - }) - await flushAsync() - - // Every watermark write is a single key carrying both halves together. - const watermarkWrites = AsyncStorageMock.setItem.mock.calls.filter((c: unknown[]) => - String(c[0]).startsWith('orca:mobileNotifications') - ) - expect(watermarkWrites.length).toBeGreaterThan(0) - for (const [key, value] of watermarkWrites) { - expect(key).toBe('orca:mobileNotificationsWatermark:host-1') - expect(JSON.parse(String(value))).toHaveProperty('epoch') - expect(JSON.parse(String(value))).toHaveProperty('seq') - } - expect(JSON.parse(String(watermarkWrites.at(-1)?.[1]))).toEqual({ - seq: 9, - epoch: 'epoch-A' - }) + it('keeps socket dismissal processing active', async () => { + const rpc = client() + subscribeToDesktopNotifications(rpc as never, 'host-1') + rpc.emit({ type: 'ready', subscriptionId: 'sub-1' }) + const dismissal = { type: 'dismiss', notificationId: 'agent-1', notificationSeq: 4 } + rpc.emit(dismissal) + await Promise.resolve() + expect(dismissHostPushNotification).toHaveBeenCalledWith(dismissal, 'host-1') }) }) diff --git a/mobile/src/notifications/mobile-notifications.ts b/mobile/src/notifications/mobile-notifications.ts index 0043762e3ec..974c9516263 100644 --- a/mobile/src/notifications/mobile-notifications.ts +++ b/mobile/src/notifications/mobile-notifications.ts @@ -1,211 +1,22 @@ +import { requestNotificationCatchup } from './push-dismissal-reconciliation' +import { dismissHostPushNotification } from './push-socket-dismissal' +import type { DismissNotificationEvent } from './desktop-notification-events' import type { RpcClient } from '../transport/rpc-client' -// Re-exported so the existing importers (and their vi.mock paths) keep working. + export { ensureNotificationPermissions, getNotificationPermissionState, type NotificationPermissionState } from './notification-permissions' -export { setScheduledNotificationsMaxForTests } from './local-notification-scheduling' -import { - configureNotificationChannel, - dismissLocalNotification, - showLocalNotification, - type DismissNotificationEvent, - type NotificationEvent -} from './local-notification-scheduling' -import { - adoptNotificationEpoch, - catchUpWatermarkSeq, - enqueueHostDelivery, - getHostNotificationSession, - quarantineCatchUpWatermark, - releaseQueuedShowNotificationId, - resolveCatchUpQuarantine, - saveWatermark, - seedWatermarkFromStorage, - seenKeyForEvent, - shouldQueueShowForNotificationId -} from './notification-reconnect-catchup' type SubscribeResult = { type: 'ready' subscriptionId: string - // Desktop counter lifetime (#8591); absent from runtimes that predate it. - epoch?: string } -// Per-connection subscription; a reconnect `ready` triggers watermarked catch-up (#8129) so already-pushed events aren't re-sent. export function subscribeToDesktopNotifications(client: RpcClient, hostId: string): () => void { - configureNotificationChannel() - let subscriptionId: string | null = null let disposed = false - // Why (#8591): survives the unsubscribe/resubscribe the app performs on every - // socket drop, so a reconnect still knows its watermark and that it reconnected. - const session = getHostNotificationSession(hostId) - - /** - * Queue one delivery on the host chain, dropping a show whose notificationId - * already has one queued. - * - * Why the claim is taken HERE and not inside deliverLive (#8591): the point of - * the dedup is to notice a second event arriving while the first is still - * outstanding. Inside the queued task the first has already finished, so the - * overlap is no longer observable — it has to be checked before enqueueing. - */ - function queueDelivery( - type: 'notification' | 'dismiss', - event: NotificationEvent | DismissNotificationEvent - ): Promise { - if ( - type === 'notification' && - !shouldQueueShowForNotificationId(session, event.notificationId) - ) { - return Promise.resolve() - } - return enqueueHostDelivery(session, async () => { - try { - await deliverLive(type, event) - } finally { - if (type === 'notification') { - releaseQueuedShowNotificationId(session, event.notificationId) - } - } - // Why swallowed: the caller is an un-awaited handler, so a rejected show would - // surface as an unhandled rejection (a RN redbox) instead of being retried by - // the next catch-up — which is now possible, since `seen` is marked after the show. - }).catch(() => {}) - } - - async function deliverLive( - type: 'notification' | 'dismiss', - event: NotificationEvent | DismissNotificationEvent - ): Promise { - adoptNotificationEpoch(session, hostId, event.notificationEpoch) - const epochAtDelivery = session.lastDeliveredEpoch - if (type === 'notification') { - await showLocalNotification(event as NotificationEvent, hostId) - } else { - await dismissLocalNotification(event as DismissNotificationEvent, hostId) - } - // Why after the await, exactly like the watermark below: `seen` asserts this event - // reached the user (#8129). Marked before, a rejected show leaves the key behind and - // every later replay is dropped as a duplicate — loss the quarantine cannot recover, - // since the first event to drain a batch lifts it past the one never shown. - const key = seenKeyForEvent(event) - // A mid-flight epoch adoption already cleared the counter lifetime this key indexes. - if (key && session.lastDeliveredEpoch === epochAtDelivery) { - session.seen.add(key) - } - // Why after the await (#8591): the watermark is a promise that everything up - // to this seq has been shown. Advancing it before the local notification lands - // means a process death in between silently drops it — the next launch asks the - // desktop for seq greater than one the user never saw. - if (event.notificationSeq != null && event.notificationSeq > session.lastDeliveredSeq) { - session.lastDeliveredSeq = event.notificationSeq - // Why clamped: while a failed catch-up's range is still unrecovered, persisting - // the live seq would let the next catch-up ask from above the gap and the desktop - // would cut it. resolveCatchUpQuarantine writes the held-back value on success. - void saveWatermark(hostId, { - seq: catchUpWatermarkSeq(session), - epoch: session.lastDeliveredEpoch - }) - } - } - - // Claimed inline rather than via queueDelivery: the batch is already one queue - // entry, and re-enqueueing per item is what let a live event cut in. - async function deliverMissedEvent( - event: NotificationEvent | DismissNotificationEvent - ): Promise { - // No pre-marking here either: deliverLive marks the key once the show lands. - const key = seenKeyForEvent(event) - if (key && session.seen.has(key)) { - return - } - if (event.type === 'notification') { - if (!shouldQueueShowForNotificationId(session, event.notificationId)) { - return - } - try { - await deliverLive('notification', event) - } finally { - releaseQueuedShowNotificationId(session, event.notificationId) - } - return - } - if (event.type === 'dismiss') { - await deliverLive('dismiss', event) - } - } - - // Why: desktop cuts by seq > lastSeenSeq, so re-fetching from the watermark is idempotent (session.seen guards residual overlap). - async function fetchMissed(): Promise { - if (disposed) { - return - } - // Captured before the request: everything at or below it is known delivered, so - // it is the floor the watermark falls back to if this catch-up never completes. - const askFrom = catchUpWatermarkSeq(session) - const missed = await client - .sendRequest('notifications.getMissedSince', { - lastSeenSeq: askFrom, - // Why: sending the epoch lets the desktop reject a watermark from a counter - // it no longer has and return the whole retained buffer instead of nothing. - ...(session.lastDeliveredEpoch != null ? { epoch: session.lastDeliveredEpoch } : {}) - }) - .then((response) => { - if (!response.ok) { - return null - } - const result = response.result as { notifications?: unknown[]; epoch?: string } | undefined - adoptNotificationEpoch(session, hostId, result?.epoch) - return Array.isArray(result?.notifications) ? result.notifications : [] - }) - .catch(() => null) - if (missed == null) { - // Why quarantine rather than retry: the range this catch-up abandoned stays - // unrecovered until SOME later one succeeds, and a live seq persisting past it - // meanwhile would make the desktop cut it forever. - quarantineCatchUpWatermark(session, hostId, askFrom) - return - } - // Why the whole batch is ONE queue entry (#8591): awaiting per event returns to - // the event loop between replays, so a live seq 11 slots into the chain between - // seq 6 and 7 and persists a watermark past a notification still unshown. Why the - // request stays OUTSIDE the queue: sendRequest waits up to 30s, and holding the - // chain for that would stall live delivery on a slow link. - await enqueueHostDelivery(session, async () => { - // Advances only past events this batch settled, so a teardown or a failing show - // quarantines the true contiguous point instead of the range it never reached. - let contiguousSeq = askFrom - let drained = false - try { - for (const raw of missed) { - // Re-checked per event: the batch can start before a teardown and still be - // draining after it, and a torn-down host must stop pushing. - if (disposed) { - return - } - const event = raw as NotificationEvent | DismissNotificationEvent - await deliverMissedEvent(event) - contiguousSeq = event.notificationSeq ?? contiguousSeq - } - drained = true - } finally { - if (drained) { - resolveCatchUpQuarantine(session, hostId) - } else { - quarantineCatchUpWatermark(session, hostId, contiguousSeq) - } - } - // Why swallowed here: the `finally` above already recorded the contiguous point, - // and the only caller is an un-awaited 'ready' continuation — letting a failed - // show escape turns every one into an unhandled rejection (a RN redbox). - }).catch(() => {}) - } - - seedWatermarkFromStorage(session, hostId) function unsubscribeServer(id: string) { if (client.getState() === 'connected') { @@ -213,79 +24,28 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin } } - const unsubscribeStream = client.subscribe('notifications.subscribe', {}, (data: unknown) => { - const event = data as - | NotificationEvent - | DismissNotificationEvent - | SubscribeResult - | { type: 'end' } + const params = { includeDesktopSuppressed: true } + const unsubscribeStream = client.subscribe('notifications.subscribe', params, (data: unknown) => { + const event = data as DismissNotificationEvent | SubscribeResult | { type: string } if (event.type === 'ready') { subscriptionId = (event as SubscribeResult).subscriptionId - const isReconnect = session.connectedBefore - session.connectedBefore = true if (disposed) { unsubscribeServer(subscriptionId) unsubscribeStream() return } - const readyEpoch = (event as SubscribeResult).epoch - // Why (#8591) the await: on a cold app open the persisted read is still in - // flight, so deciding here would see watermarkLoaded false and skip catch-up — - // which is precisely the post-upgrade / post-process-death case that loses - // every notification between the stored watermark and the next live seq. - void (async () => { - await session.watermarkSeeded - if (disposed) { - return - } - // Why before fetchMissed: adopting the epoch here is what voids a watermark - // left over from a previous desktop lifetime, so the catch-up request carries - // a watermark that means something against the counter now answering it. - adoptNotificationEpoch(session, hostId, readyEpoch) - // A reconnect always catches up. A cold open catches up only when this device - // has delivered for this host before — a first-ever pairing must not be handed - // the desktop's whole retained buffer. - if (isReconnect || session.hadStoredWatermark) { - await fetchMissed() - } - })() + // A max watermark asks only which delivered pushes are stale; socket history + // never becomes a second OS-notification delivery route. + void requestNotificationCatchup(client, hostId, () => disposed).catch(() => {}) return } - if (event.type === 'end') { - if (disposed) { - unsubscribeStream() - } - return + if (!disposed && event.type === 'dismiss') { + void dismissHostPushNotification(event as DismissNotificationEvent, hostId).catch(() => {}) } - if (disposed) { - return - } - if (event.type !== 'notification' && event.type !== 'dismiss') { - return - } - // Why the await (#8591): deliverLive advances the watermark. A live event landing - // while the persisted read is still in flight would push it past the buffered seqs - // the catch-up is about to ask for, and getMissedSince would cut them. Ordering is - // preserved — every handler waits on the same promise, and the 'ready' continuation - // registered on it first, so catch-up still builds its request before any live seq. - const liveEvent = event - void (async () => { - await session.watermarkSeeded - if (disposed) { - return - } - // Why the queue (#8591): a live event must not overtake an in-flight - // catch-up replay, or it persists a watermark past seqs still unshown. - await queueDelivery( - liveEvent.type === 'notification' ? 'notification' : 'dismiss', - liveEvent as NotificationEvent | DismissNotificationEvent - ) - })() }) return () => { disposed = true - // Why: drop the local stream first — readiness can race unmount; don't hold the callback while a subscription id is pending. unsubscribeStream() if (subscriptionId) { unsubscribeServer(subscriptionId) diff --git a/mobile/src/notifications/mobile-push-lease-renewal.test.ts b/mobile/src/notifications/mobile-push-lease-renewal.test.ts new file mode 100644 index 00000000000..1f440e93abc --- /dev/null +++ b/mobile/src/notifications/mobile-push-lease-renewal.test.ts @@ -0,0 +1,39 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { AppState } from 'react-native' +import { startMobilePushLeaseRenewal } from './mobile-push-lease-renewal' + +let onChange: (state: string) => void +const remove = vi.fn() +vi.mock('react-native', () => ({ + AppState: { + currentState: 'active', + addEventListener: (_: string, callback: typeof onChange) => { + onChange = callback + return { remove } + } + } +})) +afterEach(() => { + vi.useRealTimers() + vi.clearAllMocks() +}) + +it('renews only while mobile is foregrounded, resumes on return, and tears down', async () => { + vi.useFakeTimers() + AppState.currentState = 'active' + const renew = vi.fn(async () => {}) + const stop = startMobilePushLeaseRenewal(renew) + await vi.advanceTimersByTimeAsync(15 * 60_000) + expect(renew).toHaveBeenCalledTimes(1) + AppState.currentState = 'background' + onChange('background') + await vi.advanceTimersByTimeAsync(8 * 24 * 60 * 60_000) + expect(renew).toHaveBeenCalledTimes(1) + AppState.currentState = 'active' + onChange('active') + expect(renew).toHaveBeenCalledTimes(2) + stop() + await vi.advanceTimersByTimeAsync(15 * 60_000) + expect(renew).toHaveBeenCalledTimes(2) + expect(remove).toHaveBeenCalledOnce() +}) diff --git a/mobile/src/notifications/mobile-push-lease-renewal.ts b/mobile/src/notifications/mobile-push-lease-renewal.ts new file mode 100644 index 00000000000..22d34b2a401 --- /dev/null +++ b/mobile/src/notifications/mobile-push-lease-renewal.ts @@ -0,0 +1,19 @@ +import { AppState } from 'react-native' + +export function startMobilePushLeaseRenewal(renew: () => Promise): () => void { + const refresh = () => { + if (AppState.currentState === 'active') { + void renew().catch(() => {}) + } + } + const subscription = AppState.addEventListener('change', (state) => { + if (state === 'active') { + refresh() + } + }) + const timer = setInterval(refresh, 15 * 60_000) + return () => { + subscription.remove() + clearInterval(timer) + } +} diff --git a/mobile/src/notifications/native-notification-data.test.ts b/mobile/src/notifications/native-notification-data.test.ts new file mode 100644 index 00000000000..2b157a5fda6 --- /dev/null +++ b/mobile/src/notifications/native-notification-data.test.ts @@ -0,0 +1,22 @@ +import { expect, it } from 'vitest' +import { readNativeNotificationData } from './native-notification-data' +import { readOrcaPushPayload } from './push-payload' + +it('reads actual Expo APNs payloads when content.data is null', () => { + const orca = { + hostFingerprint: 'qa-host', + notificationId: 'done', + notificationSeq: 4, + notificationEpoch: 'epoch' + } + const data = readNativeNotificationData({ + content: { data: null }, + trigger: { type: 'push', payload: { aps: {}, orca } } + }) + expect(readOrcaPushPayload(data)).toMatchObject(orca) +}) +it('keeps Android push and local notification data', () => { + const data = { hostId: 'host', notificationId: 'done' } + expect(readNativeNotificationData({ content: { data }, trigger: { type: 'push' } })).toBe(data) + expect(readNativeNotificationData({ content: { data }, trigger: null })).toBe(data) +}) diff --git a/mobile/src/notifications/native-notification-data.ts b/mobile/src/notifications/native-notification-data.ts new file mode 100644 index 00000000000..74d50397660 --- /dev/null +++ b/mobile/src/notifications/native-notification-data.ts @@ -0,0 +1,13 @@ +export function readNativeNotificationData(request: { + content: { data?: unknown } + trigger?: unknown +}): unknown { + const trigger = request.trigger + if (trigger && typeof trigger === 'object' && 'type' in trigger && trigger.type === 'push') { + // Expo iOS keeps raw APNs custom fields here when content.data is null. + if ('payload' in trigger && trigger.payload && typeof trigger.payload === 'object') { + return trigger.payload + } + } + return request.content.data +} diff --git a/mobile/src/notifications/native-push-dismissal.ios.ts b/mobile/src/notifications/native-push-dismissal.ios.ts new file mode 100644 index 00000000000..72e46ff9fec --- /dev/null +++ b/mobile/src/notifications/native-push-dismissal.ios.ts @@ -0,0 +1,4 @@ +import { requireNativeModule } from 'expo-modules-core' +import type { NativeDismissal } from './native-push-dismissal' + +export const nativePushDismissal = requireNativeModule('OrcaNotificationDismissal') diff --git a/mobile/src/notifications/native-push-dismissal.test.ts b/mobile/src/notifications/native-push-dismissal.test.ts new file mode 100644 index 00000000000..73c96495300 --- /dev/null +++ b/mobile/src/notifications/native-push-dismissal.test.ts @@ -0,0 +1,23 @@ +import { beforeEach, expect, it, vi } from 'vitest' + +const requireNativeModule = vi.hoisted(() => vi.fn()) +vi.mock('expo-modules-core', () => ({ requireNativeModule })) + +beforeEach(() => { + vi.resetModules() + requireNativeModule.mockReset() +}) + +it('requires the iOS ledger and surfaces a missing native module as a build defect', async () => { + requireNativeModule.mockImplementation(() => { + throw new Error('Cannot find native module OrcaNotificationDismissal') + }) + await expect(import('./native-push-dismissal.ios')).rejects.toThrow( + 'Cannot find native module OrcaNotificationDismissal' + ) +}) + +it('does not load an iOS module on the default Android/web path', async () => { + expect((await import('./native-push-dismissal')).nativePushDismissal).toBeNull() + expect(requireNativeModule).not.toHaveBeenCalled() +}) diff --git a/mobile/src/notifications/native-push-dismissal.ts b/mobile/src/notifications/native-push-dismissal.ts new file mode 100644 index 00000000000..a676c68e6dc --- /dev/null +++ b/mobile/src/notifications/native-push-dismissal.ts @@ -0,0 +1,8 @@ +import type { OrcaPushPayload } from './push-payload' + +export type NativeDismissal = { + remember(payload: OrcaPushPayload): Promise + wasDismissed(payload: OrcaPushPayload): Promise +} +// Android and web use JavaScript storage; iOS requires the native ledger. +export const nativePushDismissal: NativeDismissal | null = null diff --git a/mobile/src/notifications/notification-catchup-failure-quarantine.test.ts b/mobile/src/notifications/notification-catchup-failure-quarantine.test.ts deleted file mode 100644 index 997b9fce930..00000000000 --- a/mobile/src/notifications/notification-catchup-failure-quarantine.test.ts +++ /dev/null @@ -1,316 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import * as Notifications from 'expo-notifications' -import { subscribeToDesktopNotifications } from './mobile-notifications' -import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup' -import type { RpcClient } from '../transport/rpc-client' -import { loadPushNotificationsEnabled } from '../storage/preferences' - -vi.mock('expo-notifications', () => ({ - AndroidImportance: { HIGH: 'high' }, - setNotificationChannelAsync: vi.fn(), - getPermissionsAsync: vi.fn(), - requestPermissionsAsync: vi.fn(), - scheduleNotificationAsync: vi.fn(), - dismissNotificationAsync: vi.fn() -})) - -vi.mock('react-native', () => ({ - Platform: { OS: 'ios', Version: 18 } -})) - -const WATERMARK_KEY = 'orca:mobileNotificationsWatermark:host-1' -const storage = new Map() - -vi.mock('@react-native-async-storage/async-storage', () => ({ - default: { - getItem: vi.fn(async (key: string) => storage.get(key) ?? null), - setItem: vi.fn(async (key: string, value: string) => { - storage.set(key, value) - }) - } -})) - -vi.mock('../storage/preferences', () => ({ - loadPushNotificationsEnabled: vi.fn() -})) - -function flushAsync(): Promise { - return new Promise((resolve) => { - setTimeout(resolve, 10) - }) -} - -function persistedSeq(): number { - return (JSON.parse(storage.get(WATERMARK_KEY) ?? '{}') as { seq?: number }).seq ?? 0 -} - -type MissedOutcome = - | { kind: 'reject' } - | { kind: 'notOk' } - | { kind: 'ok'; notifications: unknown[] } - // Rejects only once `settle()` is called, so a live event can land mid-request. - | { kind: 'heldReject' } - -function makeHostClient() { - let onData: ((data: unknown) => void) | null = null - const askedFrom: number[] = [] - let outcome: MissedOutcome = { kind: 'ok', notifications: [] } - let releaseHeld: (() => void) | null = null - const client = { - subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => { - onData = cb - return vi.fn(() => { - onData = null - }) - }), - getState: vi.fn(() => 'connected'), - sendRequest: vi.fn(async (method: string, params: unknown = {}) => { - if (method !== 'notifications.getMissedSince') { - return { ok: true, result: undefined } as never - } - askedFrom.push((params as { lastSeenSeq: number }).lastSeenSeq) - if (outcome.kind === 'heldReject') { - await new Promise((resolve) => { - releaseHeld = resolve - }) - throw new Error('socket closed') - } - if (outcome.kind === 'reject') { - throw new Error('socket closed') - } - if (outcome.kind === 'notOk') { - return { ok: false, error: { message: 'timeout' } } as never - } - return { ok: true, result: { notifications: outcome.notifications } } as never - }) - } - return { - client: client as unknown as RpcClient, - get onData() { - return onData - }, - askedFrom, - setOutcome(next: MissedOutcome) { - outcome = next - }, - settleHeld() { - releaseHeld?.() - } - } -} - -function notification(seq: number) { - return { - type: 'notification', - title: `m${seq}`, - body: 'b', - notificationId: `agent:${seq}`, - notificationSeq: seq - } -} - -describe('#8591 catch-up failure quarantines the watermark', () => { - beforeEach(() => { - vi.clearAllMocks() - storage.clear() - resetHostNotificationSessionsForTests() - vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ - status: 'granted', - canAskAgain: true - } as never) - vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1') - vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined) - }) - - it('keeps asking from the abandoned range until a catch-up actually succeeds', async () => { - // The phone was offline while seqs 6-7 dispatched. The catch-up that would have - // replayed them dies (socket close / timeout / ok:false), and live traffic keeps - // flowing. If a live seq is allowed to persist past 6-7, the desktop cuts by - // `seq > lastSeenSeq` on the next catch-up and they are gone for good — and the - // window stays open until some catch-up succeeds, not for one round trip. - storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' })) - const host = makeHostClient() - host.setOutcome({ kind: 'reject' }) - - subscribeToDesktopNotifications(host.client, 'host-1') - host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' }) - await flushAsync() - expect(host.askedFrom).toEqual([5]) - - host.onData?.({ ...notification(11), notificationEpoch: 'epoch-1' }) - await flushAsync() - expect(persistedSeq()).toBe(5) - - // Second catch-up also fails; the gap is still open. - host.setOutcome({ kind: 'notOk' }) - host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' }) - await flushAsync() - host.onData?.({ ...notification(12), notificationEpoch: 'epoch-1' }) - await flushAsync() - expect(host.askedFrom).toEqual([5, 5]) - expect(persistedSeq()).toBe(5) - - // Third succeeds and replays the abandoned range. - host.setOutcome({ kind: 'ok', notifications: [notification(6), notification(7)] }) - host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' }) - await flushAsync() - - expect(host.askedFrom).toEqual([5, 5, 5]) - const titles = vi - .mocked(Notifications.scheduleNotificationAsync) - .mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title) - // Exact, not arrayContaining: a duplicate here is the double-push `seen` prevents. - // m11/m12 are the live events that kept flowing while the gap stayed open. - expect(titles).toEqual(['m11', 'm12', 'm6', 'm7']) - - // Only now may the watermark move past the recovered range. - expect(persistedSeq()).toBe(12) - host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' }) - await flushAsync() - expect(host.askedFrom).toEqual([5, 5, 5, 12]) - }) - - it('rolls back a watermark a live event stored while the catch-up was in flight', async () => { - // getMissedSince waits up to 30s, so live traffic routinely persists during it. - // Clamping only writes made AFTER the failure leaves that higher seq on disk, and - // the next launch reads it back and resumes past the range this catch-up abandoned. - storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' })) - const host = makeHostClient() - host.setOutcome({ kind: 'heldReject' }) - - subscribeToDesktopNotifications(host.client, 'host-1') - host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' }) - await flushAsync() - expect(host.askedFrom).toEqual([5]) - - host.onData?.({ ...notification(11), notificationEpoch: 'epoch-1' }) - await flushAsync() - expect(persistedSeq()).toBe(11) - - host.settleHeld() - await flushAsync() - expect(persistedSeq()).toBe(5) - }) - - it('quarantines at the last replayed seq when a teardown cuts the batch short', async () => { - // The batch can start before a teardown and still be draining after it, so the - // events past the interruption were never shown. A live seq arriving on the next - // connection must not persist over them. - storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' })) - const host = makeHostClient() - host.setOutcome({ - kind: 'ok', - notifications: [notification(6), notification(7), notification(8)] - }) - - let unsubscribe: (() => void) | null = null - vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async (request) => { - if ((request as { content: { title: string } }).content.title === 'm6') { - unsubscribe?.() - } - return 'sched-1' - }) - - unsubscribe = subscribeToDesktopNotifications(host.client, 'host-1') - host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' }) - await flushAsync() - - const titles = vi - .mocked(Notifications.scheduleNotificationAsync) - .mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title) - expect(titles).toEqual(['m6']) - - // A fresh subscription on the same module-scope session takes a live seq 20 before - // its own catch-up, then resumes from 6 rather than from 20. - vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1') - const host2 = makeHostClient() - host2.setOutcome({ kind: 'ok', notifications: [notification(7), notification(8)] }) - subscribeToDesktopNotifications(host2.client, 'host-1') - host2.onData?.({ ...notification(20), notificationEpoch: 'epoch-1' }) - await flushAsync() - expect(persistedSeq()).toBe(6) - - host2.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-1' }) - await flushAsync() - - expect(host2.askedFrom).toEqual([6]) - expect( - vi - .mocked(Notifications.scheduleNotificationAsync) - .mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title) - ).toEqual(['m6', 'm20', 'm7', 'm8']) - expect(persistedSeq()).toBe(20) - }) - - it('re-shows a replay whose show threw, instead of dropping it as already seen', async () => { - // The quarantine only holds the RANGE. If the failing event is also marked seen, - // the next catch-up re-fetches it and the dedup guard drops it — the banner is - // never shown, and the first later event to drain the batch lifts the quarantine - // past it. Silent loss with the watermark looking healthy. - storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' })) - const host = makeHostClient() - host.setOutcome({ kind: 'ok', notifications: [notification(6), notification(7)] }) - - let failNext = true - vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async (request) => { - const title = (request as { content: { title: string } }).content.title - if (title === 'm6' && failNext) { - failNext = false - throw new Error('scheduling rejected') - } - return 'sched-1' - }) - - subscribeToDesktopNotifications(host.client, 'host-1') - host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' }) - await flushAsync() - expect(persistedSeq()).toBe(5) - - host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' }) - await flushAsync() - - const titles = vi - .mocked(Notifications.scheduleNotificationAsync) - .mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title) - expect(titles).toEqual(['m6', 'm6', 'm7']) - expect(host.askedFrom).toEqual([5, 5]) - expect(persistedSeq()).toBe(7) - }) - - it('re-shows a live event whose show threw, instead of dropping it as already seen', async () => { - // The same hole without any catch-up failing: the live path marks seen before the - // show, so a rejected show leaves the key behind while the watermark stays put. - // The next catch-up dutifully re-fetches the seq and the guard eats it. - storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' })) - const host = makeHostClient() - host.setOutcome({ kind: 'ok', notifications: [] }) - - let failNext = true - vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async () => { - if (failNext) { - failNext = false - throw new Error('scheduling rejected') - } - return 'sched-1' - }) - - subscribeToDesktopNotifications(host.client, 'host-1') - host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' }) - await flushAsync() - - host.onData?.({ ...notification(6), notificationEpoch: 'epoch-1' }) - await flushAsync() - expect(persistedSeq()).toBe(5) - - host.setOutcome({ kind: 'ok', notifications: [notification(6)] }) - host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' }) - await flushAsync() - - const titles = vi - .mocked(Notifications.scheduleNotificationAsync) - .mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title) - expect(titles).toEqual(['m6', 'm6']) - expect(persistedSeq()).toBe(6) - }) -}) diff --git a/mobile/src/notifications/notification-consent-ownership.test.ts b/mobile/src/notifications/notification-consent-ownership.test.ts new file mode 100644 index 00000000000..6d729d0db02 --- /dev/null +++ b/mobile/src/notifications/notification-consent-ownership.test.ts @@ -0,0 +1,310 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import AsyncStorage from '@react-native-async-storage/async-storage' +import NotificationsScreen from '../../app/notifications' +import MobileOnboardingScreen from '../../app/mobile-onboarding' +import { shouldPresentNotificationOptIn } from './notification-opt-in-gate' +import { + attachPushRegistration, + NOTIFICATIONS_REMOTE_PUSH_CAPABILITY, + resetPushRegistrationForTests, + setRemotePushEnabled, + startPushTokenSync +} from './push-registration' +import { getDevicePushToken } from './push-token' + +const mocks = vi.hoisted(() => ({ storage: new Map(), replace: vi.fn() })) +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: vi.fn(async (key: string) => mocks.storage.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => { + mocks.storage.set(key, value) + }) + } +})) +vi.mock('react-native', () => ({ + AppState: { currentState: 'active', addEventListener: () => ({ remove: vi.fn() }) }, + AccessibilityInfo: { + addEventListener: () => ({ remove: vi.fn() }), + isReduceMotionEnabled: async () => false + }, + Animated: { Value: class {}, View: 'View', multiply: () => 0 }, + BackHandler: { addEventListener: () => ({ remove: vi.fn() }) }, + StyleSheet: { create: (styles: unknown) => styles }, + Text: 'Text', + View: 'View', + Switch: 'Switch', + ScrollView: 'ScrollView', + Pressable: 'Pressable', + Alert: { alert: vi.fn() }, + Linking: { openSettings: vi.fn() }, + useWindowDimensions: () => ({ width: 390, height: 844 }) +})) +vi.mock('expo-router', () => ({ + useFocusEffect: vi.fn(), + useLocalSearchParams: () => ({ hostId: 'host', steps: 'notifications' }), + useRouter: () => ({ replace: mocks.replace }) +})) +vi.mock('react-native-safe-area-context', () => ({ + SafeAreaView: 'View', + useSafeAreaInsets: () => ({ top: 0, bottom: 0 }) +})) +vi.mock('lucide-react-native', () => ({ ChevronLeft: 'Icon' })) +vi.mock('../components/OrcaLogo', () => ({ OrcaLogo: 'Logo' })) +vi.mock('../onboarding/MobileOnboardingPage', () => ({ MobileOnboardingPage: 'Page' })) +vi.mock('../transport/use-all-host-clients', () => ({ useAllHostClients: () => [] })) +vi.mock('../transport/host-store', () => ({ loadHostCatalog: async () => [] })) +vi.mock('./NotificationDeliverySection', () => ({ NotificationDeliverySection: 'Delivery' })) +vi.mock('./use-remote-push-capable-hosts', () => ({ useRemotePushCapableHosts: () => [] })) +vi.mock('./notification-permissions', () => ({ + ensureNotificationPermissions: async () => true, + getNotificationPermissionState: async () => ({ + granted: true, + status: 'granted', + canAskAgain: true, + authorizationReflectsUserChoice: true + }) +})) +vi.mock('./mobile-notifications', () => ({ + ensureNotificationPermissions: async () => true, + getNotificationPermissionState: async () => ({ + granted: true, + status: 'granted', + canAskAgain: true, + authorizationReflectsUserChoice: true + }) +})) +vi.mock('./desktop-notification-channel', () => ({ + ensureDesktopNotificationChannel: async () => {} +})) +vi.mock('./push-token', () => ({ + getDevicePushToken: vi.fn(), + addPushTokenListener: () => () => {} +})) + +const token = { + platform: 'ios' as const, + token: 'a'.repeat(64), + apnsEnvironment: 'sandbox' as const +} +let renderer: ReactTestRenderer | undefined +let stopSync: () => void +const records = () => JSON.parse(mocks.storage.get('orca:remotePushHostRegistrations') ?? '{}') +const drain = () => vi.advanceTimersByTimeAsync(0) +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} +function connection() { + return { + sendRequest: vi.fn(async (method: string): Promise => ({ + ok: true, + result: + method === 'status.get' + ? { capabilities: [NOTIFICATIONS_REMOTE_PUSH_CAPABILITY] } + : { registered: true, unregistered: true } + })) + } +} +async function connectedHost() { + const client = connection() + attachPushRegistration('host', client as never) + await drain() + client.sendRequest.mockClear() + return client +} +async function choose(entry: string) { + await act(async () => { + renderer = create( + createElement(entry === 'settings' ? NotificationsScreen : MobileOnboardingScreen) + ) + }) + await act(async () => { + if (entry === 'settings') { + renderer!.root.findByType('Switch').props.onValueChange(true) + } else { + renderer!.root.findByType('Page').props.onNotificationChoice('enable') + } + }) +} +function expectChoiceComplete(entry: string) { + expect(mocks.storage.get('orca:pushServiceNotificationsEnabled')).toBe('true') + if (entry === 'settings') { + expect(renderer!.root.findByType('Switch').props).toMatchObject({ + value: true, + disabled: false + }) + } + if (entry === 'onboarding') { + expect(mocks.replace).toHaveBeenCalledExactlyOnceWith('/h/host') + } +} +beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + mocks.storage.clear() + resetPushRegistrationForTests() + vi.mocked(getDevicePushToken).mockResolvedValue(token) + stopSync = startPushTokenSync() +}) +afterEach(async () => { + await act(async () => renderer?.unmount()) + renderer = undefined + stopSync() + resetPushRegistrationForTests() + vi.useRealTimers() +}) + +it.each(['true', 'false'])( + 'requires consent before registering a legacy %s user', + async (legacy) => { + mocks.storage.set('orca:pushNotificationsEnabled', legacy) + const client = await connectedHost() + await expect(shouldPresentNotificationOptIn()).resolves.toBe(true) + await drain() + expect(getDevicePushToken).not.toHaveBeenCalled() + expect(client.sendRequest).not.toHaveBeenCalled() + await choose('onboarding') + await drain() + await expect(shouldPresentNotificationOptIn()).resolves.toBe(false) + expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'notifications.registerPush' + ]) + } +) + +it('remembers Not now without registering and does not ask again', async () => { + mocks.storage.set('orca:pushNotificationsEnabled', 'true') + const client = await connectedHost() + await act(async () => { + renderer = create(createElement(MobileOnboardingScreen)) + }) + await act(async () => { + renderer!.root.findByType('Page').props.onNotificationChoice('skip') + }) + await drain() + await expect(shouldPresentNotificationOptIn()).resolves.toBe(false) + expect(mocks.storage.get('orca:pushServiceNotificationsEnabled')).toBe('false') + expect(getDevicePushToken).not.toHaveBeenCalled() + expect( + client.sendRequest.mock.calls.some(([method]) => method === 'notifications.registerPush') + ).toBe(false) +}) + +it.each(['settings', 'onboarding'])( + '%s schedules exactly one registration with token sync running', + async (entry) => { + const client = await connectedHost() + await choose(entry) + await drain() + expectChoiceComplete(entry) + expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'notifications.registerPush' + ]) + expect(records().registeredHostIds).toEqual(['host']) + } +) + +it.each(['settings', 'onboarding'])( + '%s finishes local consent while native token acquisition is pending', + async (entry) => { + const client = await connectedHost() + const pending = deferred() + vi.mocked(getDevicePushToken).mockReturnValue(pending.promise) + await choose(entry) + await drain() + expectChoiceComplete(entry) + expect(getDevicePushToken).toHaveBeenCalledOnce() + expect(client.sendRequest).not.toHaveBeenCalled() + pending.resolve(token) + await drain() + expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'notifications.registerPush' + ]) + } +) + +it.each(['settings', 'onboarding'])( + '%s finishes local consent while registration RPC is pending', + async (entry) => { + const client = await connectedHost() + const pending = deferred() + client.sendRequest.mockImplementationOnce(() => pending.promise) + await choose(entry) + await drain() + expectChoiceComplete(entry) + expect(client.sendRequest).toHaveBeenCalledOnce() + expect(records().registeredHostIds).toEqual([]) + pending.resolve({ ok: true, result: { registered: true } }) + await drain() + expect(records().registeredHostIds).toEqual(['host']) + expect(client.sendRequest).toHaveBeenCalledOnce() + } +) + +it('waits for durable local records and schedules one unregister without waiting for its RPC', async () => { + const client = await connectedHost() + await setRemotePushEnabled(true) + await drain() + client.sendRequest.mockClear() + const write = deferred() + vi.mocked(AsyncStorage.setItem) + .mockImplementationOnce(async (key, value) => { + mocks.storage.set(key, value) + }) + .mockImplementationOnce(async (key, value) => { + await write.promise + mocks.storage.set(key, value) + }) + const rpc = deferred() + client.sendRequest.mockImplementationOnce(() => rpc.promise) + const completed = vi.fn() + const disable = setRemotePushEnabled(false).then(completed) + await drain() + expect(completed).not.toHaveBeenCalled() + expect(client.sendRequest).not.toHaveBeenCalled() + write.resolve() + await disable + await drain() + expect(completed).toHaveBeenCalledOnce() + expect(records().pendingUnregisterHostIds).toEqual(['host']) + expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'notifications.unregisterPush' + ]) + rpc.resolve({ ok: true }) + await drain() + expect(records()).toEqual({ registeredHostIds: [], pendingUnregisterHostIds: [] }) + expect(client.sendRequest).toHaveBeenCalledOnce() +}) + +it('exposes a failed consent write without scheduling or changing durable consent', async () => { + const client = await connectedHost() + vi.mocked(AsyncStorage.setItem).mockRejectedValueOnce(new Error('consent write failed')) + await expect(setRemotePushEnabled(true)).rejects.toThrow('consent write failed') + await drain() + expect(mocks.storage.has('orca:pushServiceNotificationsEnabled')).toBe(false) + expect(client.sendRequest).not.toHaveBeenCalled() +}) + +it('exposes a failed records write and still schedules exactly one cleanup', async () => { + const client = await connectedHost() + await setRemotePushEnabled(true) + await drain() + client.sendRequest.mockClear() + vi.mocked(AsyncStorage.setItem) + .mockImplementationOnce(async (key, value) => { + mocks.storage.set(key, value) + }) + .mockRejectedValueOnce(new Error('records write failed')) + await expect(setRemotePushEnabled(false)).rejects.toThrow('records write failed') + await drain() + expect(mocks.storage.get('orca:pushServiceNotificationsEnabled')).toBe('false') + expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'notifications.unregisterPush' + ]) + expect(records()).toEqual({ registeredHostIds: [], pendingUnregisterHostIds: [] }) +}) diff --git a/mobile/src/notifications/notification-delivery-ordering.test.ts b/mobile/src/notifications/notification-delivery-ordering.test.ts deleted file mode 100644 index 68d64d7b3de..00000000000 --- a/mobile/src/notifications/notification-delivery-ordering.test.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import * as Notifications from 'expo-notifications' -import { subscribeToDesktopNotifications } from './mobile-notifications' -import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup' -import type { RpcClient } from '../transport/rpc-client' -import { loadPushNotificationsEnabled } from '../storage/preferences' - -vi.mock('expo-notifications', () => ({ - AndroidImportance: { HIGH: 'high' }, - setNotificationChannelAsync: vi.fn(), - getPermissionsAsync: vi.fn(), - requestPermissionsAsync: vi.fn(), - scheduleNotificationAsync: vi.fn(), - dismissNotificationAsync: vi.fn() -})) - -vi.mock('react-native', () => ({ - Platform: { OS: 'ios', Version: 18 } -})) - -const WATERMARK_KEY = 'orca:mobileNotificationsWatermark:host-1' -const storage = new Map() -let getItemImpl: (key: string) => Promise = async (key) => storage.get(key) ?? null - -vi.mock('@react-native-async-storage/async-storage', () => ({ - default: { - getItem: vi.fn((key: string) => getItemImpl(key)), - setItem: vi.fn(async (key: string, value: string) => { - storage.set(key, value) - }) - } -})) - -vi.mock('../storage/preferences', () => ({ - loadPushNotificationsEnabled: vi.fn() -})) - -function flushAsync(): Promise { - return new Promise((resolve) => { - setTimeout(resolve, 10) - }) -} - -function persistedSeq(): number { - return (JSON.parse(storage.get(WATERMARK_KEY) ?? '{}') as { seq?: number }).seq ?? 0 -} - -describe('#8591 per-host delivery ordering', () => { - beforeEach(() => { - vi.clearAllMocks() - storage.clear() - getItemImpl = async (key) => storage.get(key) ?? null - resetHostNotificationSessionsForTests() - vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ - status: 'granted', - canAskAgain: true - } as never) - vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1') - vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined) - }) - - it('never persists a watermark past a notification the catch-up has not shown', async () => { - // The watermark is a promise that everything up to that seq reached the user. - // If a live seq 11 is processed while catch-up is still showing seq 6, it - // persists 11 — and a process death before 7 is shown loses 7 forever, because - // the next launch asks the desktop for seq > 11. That is the original #8591 - // loss re-entered through concurrency rather than through a restarted counter. - let releaseFirstShow!: () => void - const firstShowBlocked = new Promise((resolve) => { - releaseFirstShow = resolve - }) - let shown = 0 - vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async () => { - shown += 1 - if (shown === 1) { - await firstShowBlocked - } - return 'sched-1' - }) - - let onData: ((data: unknown) => void) | null = null - const client = { - subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => { - onData = cb - return vi.fn() - }), - getState: vi.fn(() => 'connected'), - sendRequest: vi.fn(async (method: string) => { - if (method === 'notifications.getMissedSince') { - return { - ok: true, - result: { - notifications: [ - { - type: 'notification', - title: 'm6', - body: 'b', - notificationId: 'a:6', - notificationSeq: 6 - }, - { - type: 'notification', - title: 'm7', - body: 'b', - notificationId: 'a:7', - notificationSeq: 7 - } - ] - } - } as never - } - return { ok: true, result: undefined } as never - }) - } as unknown as RpcClient - - storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' })) - subscribeToDesktopNotifications(client, 'host-1') - onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' }) - await flushAsync() - - // Live seq 11 arrives while the replay is wedged on seq 6. - onData?.({ - type: 'notification', - title: 'live-11', - body: 'b', - notificationId: 'a:11', - notificationSeq: 11 - }) - await flushAsync() - - expect(persistedSeq()).toBeLessThan(6) - - releaseFirstShow() - await flushAsync() - - // Once the chain drains, everything is shown and the watermark catches up. - expect(persistedSeq()).toBe(11) - const titles = vi - .mocked(Notifications.scheduleNotificationAsync) - .mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title) - expect(titles).toEqual(['m6', 'm7', 'live-11']) - }) - - it('shows one banner when a replay and a live event carry the same notification id', async () => { - // Serializing deliveries removed the overlap the old dedup relied on: the - // replay's show now COMPLETES before the live duplicate starts, so nothing is - // pending for it to observe and the user gets the same notification twice. - let releaseFirstShow!: () => void - const firstShowBlocked = new Promise((resolve) => { - releaseFirstShow = resolve - }) - let shown = 0 - vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async () => { - shown += 1 - if (shown === 1) { - await firstShowBlocked - } - return `sched-${shown}` - }) - - let onData: ((data: unknown) => void) | null = null - const client = { - subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => { - onData = cb - return vi.fn() - }), - getState: vi.fn(() => 'connected'), - sendRequest: vi.fn(async (method: string) => { - if (method === 'notifications.getMissedSince') { - return { - ok: true, - result: { - notifications: [ - { - type: 'notification', - title: 'dup', - body: 'b', - notificationId: 'agent:dup', - notificationSeq: 6 - } - ] - } - } as never - } - return { ok: true, result: undefined } as never - }) - } as unknown as RpcClient - - storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' })) - subscribeToDesktopNotifications(client, 'host-1') - onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' }) - await flushAsync() - - // Same id arrives live while the replay's show is still blocked. A different - // seq, so the seen-set does not catch it — only the queued-show claim does. - onData?.({ - type: 'notification', - title: 'dup', - body: 'b', - notificationId: 'agent:dup', - notificationSeq: 7 - }) - await flushAsync() - - releaseFirstShow() - await flushAsync() - - expect(vi.mocked(Notifications.scheduleNotificationAsync)).toHaveBeenCalledTimes(1) - }) - - it('still delivers when the persisted watermark read never resolves', async () => { - // Every delivery awaits the seed, so a wedged AsyncStorage read would disable - // this host's notifications for the whole app lifetime — silently. - getItemImpl = () => new Promise(() => {}) - - let onData: ((data: unknown) => void) | null = null - const client = { - subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => { - onData = cb - return vi.fn() - }), - getState: vi.fn(() => 'connected'), - sendRequest: vi.fn(async () => ({ ok: true, result: undefined }) as never) - } as unknown as RpcClient - - vi.useFakeTimers() - try { - subscribeToDesktopNotifications(client, 'host-1') - onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' }) - onData?.({ - type: 'notification', - title: 'live-1', - body: 'b', - notificationId: 'a:1', - notificationSeq: 1 - }) - await vi.advanceTimersByTimeAsync(3100) - } finally { - vi.useRealTimers() - } - - const titles = vi - .mocked(Notifications.scheduleNotificationAsync) - .mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title) - expect(titles).toContain('live-1') - }) -}) diff --git a/mobile/src/notifications/notification-delivery-preferences.test.ts b/mobile/src/notifications/notification-delivery-preferences.test.ts new file mode 100644 index 00000000000..1e3e4b79113 --- /dev/null +++ b/mobile/src/notifications/notification-delivery-preferences.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { AppState } from 'react-native' +import { + DEFAULT_NOTIFICATION_DELIVERY, + loadNotificationDeliveryPreferences, + notificationPreferencesFilter, + saveNotificationDeliveryPreferences +} from './notification-delivery-preferences' +import { + setNotificationViewingWorkspace, + shouldSuppressNotificationWhileViewing +} from './notification-viewing-policy' + +const storage = new Map() +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: vi.fn(async (key: string) => storage.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => { + storage.set(key, value) + }) + } +})) +vi.mock('react-native', () => ({ AppState: { currentState: 'background' } })) +beforeEach(() => { + storage.clear() + setNotificationViewingWorkspace(null) + AppState.currentState = 'background' +}) + +it('persists only phone-specific delivery preferences', async () => { + expect(await loadNotificationDeliveryPreferences()).toEqual(DEFAULT_NOTIFICATION_DELIVERY) + const value = { + ...DEFAULT_NOTIFICATION_DELIVERY, + onlyWhenDesktopAway: false, + sound: false + } + await saveNotificationDeliveryPreferences(value) + expect(await loadNotificationDeliveryPreferences()).toEqual(value) + expect(notificationPreferencesFilter(value)).toEqual({ + onlyWhenDesktopAway: false, + sound: false + }) +}) + +it('ignores unrelated stored preferences', async () => { + storage.set( + 'orca:notificationDeliveryPreferences', + JSON.stringify({ + onlyWhenDesktopAway: false, + sound: false, + suppressWhileViewing: false, + unrelatedSetting: false + }) + ) + expect(await loadNotificationDeliveryPreferences()).toEqual({ + onlyWhenDesktopAway: false, + sound: false, + suppressWhileViewing: false + }) + expect(notificationPreferencesFilter(await loadNotificationDeliveryPreferences())).toEqual({ + onlyWhenDesktopAway: false, + sound: false + }) +}) + +it('suppresses only the workspace being viewed on this phone, and never while backgrounded', async () => { + const event = { source: 'terminal-bell', worktreeId: 'folder-id' } + setNotificationViewingWorkspace({ hostId: 'ssh-host', worktreeId: 'folder-id' }) + AppState.currentState = 'active' + expect(await shouldSuppressNotificationWhileViewing(event, 'ssh-host', true)).toBe(true) + expect(await shouldSuppressNotificationWhileViewing(event, 'another-host', true)).toBe(false) + expect( + await shouldSuppressNotificationWhileViewing( + { ...event, worktreeId: 'other' }, + 'ssh-host', + true + ) + ).toBe(false) + AppState.currentState = 'background' + expect(await shouldSuppressNotificationWhileViewing(event, 'ssh-host', true)).toBe(false) +}) + +it('recovers defaults from malformed stored preferences', async () => { + storage.set('orca:notificationDeliveryPreferences', '{broken') + expect(await loadNotificationDeliveryPreferences()).toEqual(DEFAULT_NOTIFICATION_DELIVERY) +}) diff --git a/mobile/src/notifications/notification-delivery-preferences.ts b/mobile/src/notifications/notification-delivery-preferences.ts new file mode 100644 index 00000000000..7388fba77db --- /dev/null +++ b/mobile/src/notifications/notification-delivery-preferences.ts @@ -0,0 +1,49 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import type { MobilePushFilter } from '../../../src/shared/mobile-push-contract' + +const KEY = 'orca:notificationDeliveryPreferences' +export type NotificationDeliveryPreferences = { + onlyWhenDesktopAway: boolean + sound: boolean + suppressWhileViewing: boolean +} + +export const DEFAULT_NOTIFICATION_DELIVERY: NotificationDeliveryPreferences = { + onlyWhenDesktopAway: true, + sound: true, + suppressWhileViewing: true +} + +export async function loadNotificationDeliveryPreferences(): Promise { + try { + const raw = await AsyncStorage.getItem(KEY) + if (!raw) { + return { ...DEFAULT_NOTIFICATION_DELIVERY } + } + const stored = JSON.parse(raw) as Record + const result = { ...DEFAULT_NOTIFICATION_DELIVERY } + for (const key of Object.keys(result) as (keyof NotificationDeliveryPreferences)[]) { + if (typeof stored?.[key] === 'boolean') { + result[key] = stored[key] + } + } + return result + } catch { + return { ...DEFAULT_NOTIFICATION_DELIVERY } + } +} + +export async function saveNotificationDeliveryPreferences( + value: NotificationDeliveryPreferences +): Promise { + await AsyncStorage.setItem(KEY, JSON.stringify(value)) +} + +export function notificationPreferencesFilter( + value: NotificationDeliveryPreferences +): MobilePushFilter { + return { + onlyWhenDesktopAway: value.onlyWhenDesktopAway, + sound: value.sound + } +} diff --git a/mobile/src/notifications/notification-opt-in-gate.test.ts b/mobile/src/notifications/notification-opt-in-gate.test.ts index ded3d7eca39..8b99423dfb4 100644 --- a/mobile/src/notifications/notification-opt-in-gate.test.ts +++ b/mobile/src/notifications/notification-opt-in-gate.test.ts @@ -1,92 +1,24 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - readPushNotificationsPreference, - savePushNotificationsEnabled -} from '../storage/preferences' -import { getNotificationPermissionState } from './mobile-notifications' +import { describe, expect, it, vi } from 'vitest' +import { readPushNotificationsPreference } from '../storage/preferences' import { shouldPresentNotificationOptIn } from './notification-opt-in-gate' vi.mock('../storage/preferences', () => ({ - readPushNotificationsPreference: vi.fn(), - savePushNotificationsEnabled: vi.fn() -})) - -vi.mock('./mobile-notifications', () => ({ - getNotificationPermissionState: vi.fn() + readPushNotificationsPreference: vi.fn() })) describe('notification opt-in gate', () => { - beforeEach(() => { - vi.mocked(readPushNotificationsPreference).mockReset() - vi.mocked(savePushNotificationsEnabled).mockReset() - vi.mocked(getNotificationPermissionState).mockReset() - }) - - it('presents only when the local preference and system decision are both unset', async () => { + it('asks for push-service consent when no choice is saved, regardless of OS permission', async () => { vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true }) - vi.mocked(getNotificationPermissionState).mockResolvedValue({ - granted: false, - status: 'undetermined', - canAskAgain: true, - authorizationReflectsUserChoice: false - }) - await expect(shouldPresentNotificationOptIn()).resolves.toBe(true) - expect(savePushNotificationsEnabled).not.toHaveBeenCalled() }) - it.each([true, false])('preserves an existing %s mobile preference', async (value) => { + it.each([true, false])('does not ask again after choosing %s', async (value) => { vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value, loaded: true }) - await expect(shouldPresentNotificationOptIn()).resolves.toBe(false) - expect(getNotificationPermissionState).not.toHaveBeenCalled() }) - it('adopts existing system authorization without prompting', async () => { - vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true }) - vi.mocked(getNotificationPermissionState).mockResolvedValue({ - granted: true, - status: 'granted', - canAskAgain: true, - authorizationReflectsUserChoice: true - }) - - await expect(shouldPresentNotificationOptIn()).resolves.toBe(false) - expect(savePushNotificationsEnabled).toHaveBeenCalledWith(true) - }) - - it('still presents when a pre-Android 13 default grant is not an opt-in decision', async () => { - vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true }) - vi.mocked(getNotificationPermissionState).mockResolvedValue({ - granted: true, - status: 'granted', - canAskAgain: true, - authorizationReflectsUserChoice: false - }) - - await expect(shouldPresentNotificationOptIn()).resolves.toBe(true) - expect(savePushNotificationsEnabled).not.toHaveBeenCalled() - }) - - it('skips the gate when iOS has already denied permission', async () => { - vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true }) - vi.mocked(getNotificationPermissionState).mockResolvedValue({ - granted: false, - status: 'denied', - canAskAgain: false, - authorizationReflectsUserChoice: false - }) - - await expect(shouldPresentNotificationOptIn()).resolves.toBe(false) - expect(savePushNotificationsEnabled).toHaveBeenCalledWith(false) - }) - - it('does not block startup when storage or permission checks fail', async () => { + it('does not prompt when the saved choice cannot be read', async () => { vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: false }) await expect(shouldPresentNotificationOptIn()).resolves.toBe(false) - - vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true }) - vi.mocked(getNotificationPermissionState).mockRejectedValue(new Error('unavailable')) - await expect(shouldPresentNotificationOptIn()).resolves.toBe(false) }) }) diff --git a/mobile/src/notifications/notification-opt-in-gate.ts b/mobile/src/notifications/notification-opt-in-gate.ts index a909fad16d7..6ffcb662d7f 100644 --- a/mobile/src/notifications/notification-opt-in-gate.ts +++ b/mobile/src/notifications/notification-opt-in-gate.ts @@ -1,36 +1,6 @@ -import { - readPushNotificationsPreference, - savePushNotificationsEnabled -} from '../storage/preferences' -import { getNotificationPermissionState } from './mobile-notifications' +import { readPushNotificationsPreference } from '../storage/preferences' export async function shouldPresentNotificationOptIn(): Promise { const preference = await readPushNotificationsPreference() - if (!preference.loaded || preference.value !== null) { - return false - } - - try { - const permission = await getNotificationPermissionState() - if (permission.granted) { - if (!permission.authorizationReflectsUserChoice) { - return true - } - // Why: an already-authorized device should inherit the useful default - // without seeing an onboarding decision it has effectively made. - await savePushNotificationsEnabled(true) - return false - } - if (permission.status === 'denied' || !permission.canAskAgain) { - // Why: iOS cannot show its authorization prompt again, so a blocking - // onboarding screen would be a dead end; Settings remains the recovery. - await savePushNotificationsEnabled(false) - return false - } - return permission.status === 'undetermined' - } catch { - // Why: permission or persistence failures must not trap startup behind a - // decision screen whose result cannot be applied reliably. - return false - } + return preference.loaded && preference.value === null } diff --git a/mobile/src/notifications/notification-reconnect-catchup.ts b/mobile/src/notifications/notification-reconnect-catchup.ts deleted file mode 100644 index de05ed69505..00000000000 --- a/mobile/src/notifications/notification-reconnect-catchup.ts +++ /dev/null @@ -1,412 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage' - -// Why: the reconnect catch-up watermark + dedup helpers for #8129, extracted -// from mobile-notifications.ts so that file stays under its max-lines budget. -// The highest desktop notification seq this device has delivered is persisted -// per-host so it survives app restarts. On reconnect we send it to -// notifications.getMissedSince as the catch-up watermark — the desktop then -// returns only notifications dispatched after it, so we never re-push a -// notification we already delivered. The in-memory seen-set is a second guard -// against double-delivery for events that arrive on both the live stream and a -// replay (e.g. a brief liveness spell before a reap). -// Why (#8591): a seq is meaningless without the counter it indexes — after a -// desktop restart that counter is gone. The epoch names the counter's lifetime so -// a reconnect can tell "nothing missed" from "different counter". -// -// Why ONE key holding both, rather than a key each: they are only meaningful as a -// pair. Written separately, a process death between the two writes leaves an epoch -// from one counter beside a seq from another — a pair that looks internally valid -// on the next launch and is therefore trusted, silently cutting real notifications. -// A single JSON value cannot tear that way. -const WATERMARK_STORAGE_KEY_PREFIX = 'orca:mobileNotificationsWatermark:' -// Pre-#8591 installs wrote the seq alone. Read once to migrate; never written. -const LEGACY_SEQ_STORAGE_KEY_PREFIX = 'orca:mobileNotificationsLastSeq:' - -function watermarkStorageKey(hostId: string): string { - return WATERMARK_STORAGE_KEY_PREFIX + encodeURIComponent(hostId) -} - -// A null epoch means "the counter this seq came from is unknown" — a legacy -// watermark, or nothing stored. It can never be assumed to be the live counter. -export type PersistedWatermark = { seq: number; epoch: string | null } -// `stored` is the record's existence, independent of its seq: it answers "has this -// device ever been subscribed to this host", which is what a cold open needs to tell -// a returning device from a first pairing. A seq of 0 is a real answer, not an absence. -export type LoadedWatermark = PersistedWatermark & { stored: boolean } - -function coerceSeq(value: unknown): number { - const parsed = typeof value === 'number' ? value : Number(value) - return Number.isFinite(parsed) && parsed > 0 ? parsed : 0 -} - -export async function loadWatermark(hostId: string): Promise { - try { - const raw = await AsyncStorage.getItem(watermarkStorageKey(hostId)) - if (raw != null) { - const parsed = JSON.parse(raw) as { seq?: unknown; epoch?: unknown } - const epoch = - typeof parsed.epoch === 'string' && parsed.epoch.length > 0 ? parsed.epoch : null - return { seq: coerceSeq(parsed.seq), epoch, stored: true } - } - } catch { - // Unreadable or malformed: fall through to the legacy key rather than throw. - } - try { - const legacy = await AsyncStorage.getItem( - LEGACY_SEQ_STORAGE_KEY_PREFIX + encodeURIComponent(hostId) - ) - return { seq: coerceSeq(legacy), epoch: null, stored: legacy != null } - } catch { - return { seq: 0, epoch: null, stored: false } - } -} - -export async function clearWatermark(hostId: string): Promise { - // Why both keys: loadWatermark falls back to the legacy one, so removing only the - // current key would let a re-paired host resurrect a pre-#8591 seq from a counter - // lifetime that is long gone — the exact stale cut this fix removes. - await Promise.all([ - AsyncStorage.removeItem(watermarkStorageKey(hostId)).catch(() => {}), - AsyncStorage.removeItem(LEGACY_SEQ_STORAGE_KEY_PREFIX + encodeURIComponent(hostId)).catch( - () => {} - ) - ]) -} - -export async function saveWatermark(hostId: string, watermark: PersistedWatermark): Promise { - try { - await AsyncStorage.setItem(watermarkStorageKey(hostId), JSON.stringify(watermark)) - } catch { - // Why: persisting the watermark is best-effort. If it fails (or lags), the - // stored value stays BELOW what we delivered, so a later cold start can - // re-fetch — and, once the in-memory seen-set is gone, re-show — an already - // delivered notification. That's the accepted at-least-once trade-off; - // within a live session the in-memory watermark is authoritative, so only - // post-restart reconnects are affected. - } -} - -// Why: bounded in-memory dedup window for notificationIds/dismiss ids observed -// on the current connection. The desktop already dedupes by seq on replay, but -// a socket that flickers background→foreground→background can deliver an event -// on the live stream and again in a replay; the seen-set guarantees each -// notificationId maps to at most one local push for the connection lifetime. -// Bounded so a long-lived session can't grow without limit — a 2x superset of -// the desktop's 256-entry replay buffer and the 256 scheduled-notification cap. -const RECENTLY_SEEN_CAP = 512 - -export function createSeenNotificationGuard(): { - has: (id: string) => boolean - add: (id: string) => void - clear: () => void -} { - const seen = new Set() - return { - has(id: string): boolean { - return seen.has(id) - }, - add(id: string): void { - seen.add(id) - if (seen.size > RECENTLY_SEEN_CAP) { - // Why: insertion order; the oldest entries are first. Drop one to stay - // bounded without disturbing the more-recently-relevant keys. - const first = seen.values().next().value - if (first !== undefined) { - seen.delete(first) - } - } - }, - clear(): void { - seen.clear() - } - } -} - -// Why (#8591): app/index.tsx tears the notification subscription down on every -// non-'connected' state and builds a fresh one on reconnect, so everything held -// in the subscription closure — the ready counter, the delivered watermark, the -// seen-set — is destroyed exactly when a reconnect needs it. Keeping it per host -// at module scope is what makes the catch-up recognise a reconnect (instead of -// mistaking it for a cold open) and keeps dedup effective across the teardown. -export type HostNotificationSession = { - // Highest desktop seq delivered for this host in this app process. Outranks - // the persisted value, which lags because saveLastSeenSeq is fire-and-forget. - lastDeliveredSeq: number - // Counter lifetime lastDeliveredSeq belongs to; null until one is known. A - // mismatch on reconnect means the desktop restarted and the watermark is void. - lastDeliveredEpoch: string | null - // Highest seq known delivered CONTIGUOUSLY, frozen here while a catch-up is - // outstanding; null when none has failed. See quarantineCatchUpWatermark. - catchUpQuarantineSeq: number | null - seen: ReturnType - // False only until the host's first subscription reaches 'ready' — a true cold open. - connectedBefore: boolean - // Why (#8591): distinguishes "this device has delivered for this host before" - // from a first-ever pairing. Only the former may catch up on a cold open — a - // brand-new pairing fetching from seq 0 would push the desktop's whole buffer - // at someone who was never subscribed for any of it. - hadStoredWatermark: boolean - // Resolves once the persisted read has landed, so the first 'ready' can wait for - // it instead of deciding catch-up against an unread watermark. - watermarkSeeded: Promise | null - // Tail of the per-host delivery chain; see enqueueHostDelivery. - deliveryTail: Promise - // notificationIds with a show queued or in flight on that chain; see - // shouldQueueShowForNotificationId. - queuedShowIds: Set -} - -const sessionsByHost = new Map() - -export function getHostNotificationSession(hostId: string): HostNotificationSession { - let session = sessionsByHost.get(hostId) - if (!session) { - session = { - lastDeliveredSeq: 0, - lastDeliveredEpoch: null, - catchUpQuarantineSeq: null, - seen: createSeenNotificationGuard(), - connectedBefore: false, - hadStoredWatermark: false, - watermarkSeeded: null, - deliveryTail: Promise.resolve(), - queuedShowIds: new Set() - } - sessionsByHost.set(hostId, session) - } - return session -} - -/** - * Run `task` after every delivery already queued for this host, and return a - * promise for its completion. - * - * Why (#8591): the watermark is persisted by whichever delivery advances it, so - * replay and live delivery running concurrently can persist out of order. A live - * seq 11 handled while catch-up is still showing seq 6 writes watermark 11, and a - * process death before 7..10 are shown loses them permanently — the next launch - * asks the desktop for seq > 11. Serializing per host makes the watermark's - * monotonic advance mean "everything up to here was actually delivered". - * - * A rejected task does not break the chain: the tail swallows the failure so a - * single bad notification cannot wedge the host's queue forever. - */ -export function enqueueHostDelivery( - session: HostNotificationSession, - task: () => Promise -): Promise { - const run = session.deliveryTail.then(task) - session.deliveryTail = run.catch(() => {}) - return run -} - -/** - * Claim a notificationId for a queued show, returning false if one is already - * queued or in flight for it. - * - * Why this exists (#8591): showLocalNotification deduped two same-id events by - * observing that the first was still pending when the second arrived. Serializing - * deliveries removed that overlap — the first now COMPLETES before the second - * starts, so the second reads no pending state and schedules a second banner for - * the same notification. The dedup has to happen where concurrency is still - * visible, which after serialization is enqueue time rather than delivery time. - * - * Only shows are tracked. A dismiss for the same id must still run: it is the - * mechanism that retires the notification the show created. - */ -export function shouldQueueShowForNotificationId( - session: HostNotificationSession, - notificationId: string | undefined -): boolean { - if (notificationId == null) { - return true - } - if (session.queuedShowIds.has(notificationId)) { - return false - } - session.queuedShowIds.add(notificationId) - return true -} - -/** Release the claim taken by shouldQueueShowForNotificationId once the show settles. */ -export function releaseQueuedShowNotificationId( - session: HostNotificationSession, - notificationId: string | undefined -): void { - if (notificationId != null) { - session.queuedShowIds.delete(notificationId) - } -} - -/** Test-only: drop per-host session state so each test starts from a cold open. */ -export function resetHostNotificationSessionsForTests(): void { - sessionsByHost.clear() -} - -/** - * Freeze the catch-up watermark at the last seq known delivered contiguously, - * after a catch-up that did not complete. - * - * Why: live delivery advances lastDeliveredSeq unconditionally, so an abandoned - * catch-up otherwise lets the NEXT one ask from above the range it gave up on — - * the desktop cuts by seq, so those notifications are never replayed and are - * gone. Lowest wins: an earlier failure's gap is still open. - */ -export function quarantineCatchUpWatermark( - session: HostNotificationSession, - hostId: string, - contiguousSeq: number -): void { - session.catchUpQuarantineSeq = - session.catchUpQuarantineSeq == null - ? contiguousSeq - : Math.min(session.catchUpQuarantineSeq, contiguousSeq) - // Why re-persist: a live event delivered while the catch-up was still in flight - // already stored a seq above the gap. Clamping only later writes would leave that - // value on disk, so a restart still resumes past the abandoned range. - void saveWatermark(hostId, { - seq: catchUpWatermarkSeq(session), - epoch: session.lastDeliveredEpoch - }) -} - -/** Lift the quarantine once a catch-up completes, persisting what it held back. */ -export function resolveCatchUpQuarantine(session: HostNotificationSession, hostId: string): void { - if (session.catchUpQuarantineSeq == null) { - return - } - session.catchUpQuarantineSeq = null - void saveWatermark(hostId, { - seq: session.lastDeliveredSeq, - epoch: session.lastDeliveredEpoch - }) -} - -/** - * The seq a catch-up may ask from and the highest seq safe to persist — the live - * watermark, clamped to any open gap. - */ -export function catchUpWatermarkSeq(session: HostNotificationSession): number { - return session.catchUpQuarantineSeq == null - ? session.lastDeliveredSeq - : Math.min(session.catchUpQuarantineSeq, session.lastDeliveredSeq) -} - -// Why (#8591): the desktop's seq counter restarts at 0 every launch, so a watermark -// from a previous lifetime indexes a counter that no longer exists. Comparing it -// against the fresh counter makes `lastSeenSeq >= seq` true for everything and -// catch-up dies silently until the new process out-dispatches the old watermark. -// Adopting the new epoch means dropping the watermark with it. -export function adoptNotificationEpoch( - session: HostNotificationSession, - hostId: string, - epoch: string | undefined -): void { - if (!epoch || epoch === session.lastDeliveredEpoch) { - return - } - // Why reset on a FIRST observation too (lastDeliveredEpoch === null): a seq seeded - // from a legacy store carries no epoch, so it cannot be shown to belong to this - // counter. Keeping it would let a pre-upgrade 57 cut the new counter's 1..57 — - // the exact #8591 failure, reached through the upgrade path instead of a restart. - session.lastDeliveredSeq = 0 - // Why clear `seen`: its keys are seq-derived, and terminal-bell notifications have - // no notificationId at all (they key on `seq:N` alone). Across a restart the new - // counter re-issues those same low seqs, so a stale `seq:1` would silently drop - // the new counter's first bell. The dedup window belongs to one counter lifetime. - session.seen.clear() - // The quarantined gap indexed the dead counter; the watermark it guarded is gone too. - session.catchUpQuarantineSeq = null - session.lastDeliveredEpoch = epoch - void saveWatermark(hostId, { seq: 0, epoch }) -} - -// Why: seed the watermark lazily so subscribe() doesn't block on an AsyncStorage read. -// Only the first subscription for a host needs it; later ones inherit the live value. -/** - * Ms the persisted read may block catch-up and live delivery before they proceed - * without it. AsyncStorage normally answers in single-digit ms; a read that has - * not landed by now is assumed wedged. - * - * Why a bound at all (#8591): every delivery awaits this promise, so a read that - * never settles silently disables notifications for the host for the whole app - * lifetime — no error, no banner, nothing to see. Proceeding unseeded is strictly - * better: the watermark stays 0, so catch-up over-fetches and the seen-set - * de-duplicates, which costs a redundant request instead of every notification. - */ -const WATERMARK_SEED_TIMEOUT_MS = 3000 - -function withTimeout(promise: Promise, ms: number): Promise { - return new Promise((resolve) => { - const timer = setTimeout(resolve, ms) - void promise.then( - () => { - clearTimeout(timer) - resolve() - }, - () => { - clearTimeout(timer) - resolve() - } - ) - }) -} - -export function seedWatermarkFromStorage(session: HostNotificationSession, hostId: string): void { - if (session.watermarkSeeded) { - return - } - const seeded = loadWatermark(hostId).then(({ seq, epoch, stored }) => { - // Why the record's existence and not `seq > 0`: adoptNotificationEpoch persists - // `{seq: 0, epoch}` when it voids a watermark, so a device that HAS delivered for - // this host reloads as seq 0. Keying on the seq would read that as a first pairing - // and skip catch-up for the whole window the epoch change was meant to recover. - if (stored) { - session.hadStoredWatermark = true - } - // Why the epoch comparison: this read can land AFTER 'ready' already adopted a - // live epoch. If the stored watermark belongs to a different (older) counter, - // applying it here would silently reinstate exactly the stale cut this fixes. - // A null stored epoch is a legacy watermark of unknown provenance — it may only - // seed while no live epoch is known, and adopting one later resets it. - if (session.lastDeliveredEpoch === null || session.lastDeliveredEpoch === epoch) { - session.lastDeliveredSeq = Math.max(session.lastDeliveredSeq, seq) - if (session.lastDeliveredEpoch === null && epoch !== null) { - session.lastDeliveredEpoch = epoch - } - } - }) - // The late seed still applies when it eventually lands; the timeout only stops it - // from holding delivery hostage. `seeded` never rejects into the awaiters. - session.watermarkSeeded = withTimeout(seeded, WATERMARK_SEED_TIMEOUT_MS) -} - -// Why (#8591): sessions live at module scope so they survive the subscription -// teardown a reconnect performs. Nothing else drops them, so a host that is removed -// and re-paired would retain its session and up to 512 seen keys until app restart. -export function forgetHostNotificationSession(hostId: string): void { - sessionsByHost.delete(hostId) -} - -// Why: key for the replay dedup guard. Uses notificationId when present, but -// disambiguates by seq so a legitimate live re-delivery of the same id at a -// NEW seq (content refresh, allowed by the existing behaviour) is NOT treated -// as a duplicate, while a replay re-returning the SAME id+seq already delivered -// live is suppressed. Replay events always carry a seq (the desktop assigns -// one), so the guard is effective on the reconnect path. -export function seenKeyForEvent(event: { - notificationId?: string - notificationSeq?: number -}): string | null { - const id = event.notificationId - if (id != null && event.notificationSeq != null) { - return `id:${id}#${event.notificationSeq}` - } - if (id != null) { - return `id:${id}` - } - if (event.notificationSeq != null) { - return `seq:${event.notificationSeq}` - } - return null -} diff --git a/mobile/src/notifications/notification-reconnect-teardown.test.ts b/mobile/src/notifications/notification-reconnect-teardown.test.ts deleted file mode 100644 index a5e7433bf0f..00000000000 --- a/mobile/src/notifications/notification-reconnect-teardown.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import * as Notifications from 'expo-notifications' -import { subscribeToDesktopNotifications } from './mobile-notifications' -import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup' -import AsyncStorage from '@react-native-async-storage/async-storage' -import type { RpcClient } from '../transport/rpc-client' -import { loadPushNotificationsEnabled } from '../storage/preferences' - -vi.mock('expo-notifications', () => ({ - AndroidImportance: { HIGH: 'high' }, - setNotificationChannelAsync: vi.fn(), - getPermissionsAsync: vi.fn(), - requestPermissionsAsync: vi.fn(), - scheduleNotificationAsync: vi.fn(), - dismissNotificationAsync: vi.fn() -})) - -vi.mock('react-native', () => ({ - Platform: { OS: 'ios', Version: 18 } -})) - -// In-memory AsyncStorage so the persisted watermark survives across the -// subscribe/unsubscribe cycles this test exercises (the real device behaviour). -const storage = new Map() -vi.mock('@react-native-async-storage/async-storage', () => ({ - default: { - getItem: vi.fn(async (k: string) => storage.get(k) ?? null), - setItem: vi.fn(async (k: string, v: string) => { - storage.set(k, v) - }) - } -})) - -vi.mock('../storage/preferences', () => ({ - loadPushNotificationsEnabled: vi.fn() -})) - -function flushAsync(): Promise { - return new Promise((resolve) => { - setTimeout(resolve, 10) - }) -} - -// Models mobile/app/index.tsx:497-537: a per-host client whose notification -// subscription is torn down on any non-'connected' state and re-created from -// scratch on the next 'connected'. -function makeHostClient() { - let onData: ((data: unknown) => void) | null = null - const getMissedCalls: { lastSeenSeq: number }[] = [] - const client = { - subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => { - onData = cb - return vi.fn(() => { - onData = null - }) - }), - getState: vi.fn(() => 'connected'), - sendRequest: vi.fn(async (method: string, params: unknown = {}) => { - if (method === 'notifications.getMissedSince') { - getMissedCalls.push(params as { lastSeenSeq: number }) - return { ok: true, result: { notifications: missedQueue } } as never - } - return { ok: true, result: undefined } as never - }) - } - let missedQueue: unknown[] = [] - return { - client: client as unknown as RpcClient, - get onData() { - return onData - }, - getMissedCalls, - setMissed(events: unknown[]) { - missedQueue = events - } - } -} - -describe('#8591 reconnect catch-up under the real app teardown lifecycle', () => { - beforeEach(() => { - vi.clearAllMocks() - storage.clear() - resetHostNotificationSessionsForTests() - vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ - status: 'granted', - canAskAgain: true - } as never) - vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1') - vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined) - vi.mocked(AsyncStorage.getItem).mockClear() - }) - - it('fetches missed notifications after a disconnect tears the subscription down', async () => { - const host = makeHostClient() - - // ── Connected: cold open, one live notification delivered (desktop seq 7). - const unsub = subscribeToDesktopNotifications(host.client, 'host-1') - host.onData?.({ type: 'ready', subscriptionId: 'sub-1' }) - await flushAsync() - host.onData?.({ - type: 'notification', - title: 'live', - body: 'b', - notificationId: 'agent:live', - notificationSeq: 7 - }) - await flushAsync() - - // ── Socket drops. app/index.tsx wireUp() calls unsubNotif() on the - // non-'connected' state, destroying the subscribeToDesktopNotifications - // closure (and with it reconnectReadyCount / lastDeliveredSeq). - unsub() - await flushAsync() - - // ── While disconnected the desktop dispatched seq 8 and 9. - host.setMissed([ - { - type: 'notification', - title: 'missed-8', - body: 'b', - notificationId: 'agent:m8', - notificationSeq: 8 - }, - { - type: 'notification', - title: 'missed-9', - body: 'b', - notificationId: 'agent:m9', - notificationSeq: 9 - } - ]) - - // ── Reconnected: app re-subscribes with a FRESH closure. - subscribeToDesktopNotifications(host.client, 'host-1') - host.onData?.({ type: 'ready', subscriptionId: 'sub-2' }) - await flushAsync() - - // The user must be told about seq 8 and 9. Nothing else can deliver them: - // the desktop only fans out live, so this catch-up is the only path. - expect(host.getMissedCalls).toHaveLength(1) - expect(host.getMissedCalls[0]).toEqual({ lastSeenSeq: 7 }) - const titles = vi - .mocked(Notifications.scheduleNotificationAsync) - .mock.calls.map((c) => (c[0] as { content: { title: string } }).content.title) - expect(titles).toContain('missed-8') - expect(titles).toContain('missed-9') - }) - - it('does not re-push a live notification the catch-up replays after a teardown', async () => { - // Why: the seen-set lives on the host session precisely so it survives the teardown. - // getMissedSince cuts by seq > lastSeenSeq, but a notification delivered live in the - // brief window before the drop is still inside the desktop's retained buffer, so the - // reconnect fetch returns it again. Only the session-scoped seen-set stops a duplicate - // banner for something the user was already shown. - const host = makeHostClient() - - const unsub = subscribeToDesktopNotifications(host.client, 'host-1') - host.onData?.({ type: 'ready', subscriptionId: 'sub-1' }) - await flushAsync() - host.onData?.({ - type: 'notification', - title: 'live-7', - body: 'b', - notificationId: 'agent:seven', - notificationSeq: 7 - }) - await flushAsync() - - unsub() - await flushAsync() - - // The desktop replays seq 7 alongside the genuinely-missed seq 8. - host.setMissed([ - { - type: 'notification', - title: 'live-7', - body: 'b', - notificationId: 'agent:seven', - notificationSeq: 7 - }, - { - type: 'notification', - title: 'missed-8', - body: 'b', - notificationId: 'agent:m8', - notificationSeq: 8 - } - ]) - - subscribeToDesktopNotifications(host.client, 'host-1') - host.onData?.({ type: 'ready', subscriptionId: 'sub-2' }) - await flushAsync() - - const titles = vi - .mocked(Notifications.scheduleNotificationAsync) - .mock.calls.map((c) => (c[0] as { content: { title: string } }).content.title) - expect(titles.filter((title) => title === 'live-7')).toHaveLength(1) - expect(titles).toContain('missed-8') - }) -}) diff --git a/mobile/src/notifications/notification-routing.test.ts b/mobile/src/notifications/notification-routing.test.ts index 779aa2425e5..9b682bc2cb7 100644 --- a/mobile/src/notifications/notification-routing.test.ts +++ b/mobile/src/notifications/notification-routing.test.ts @@ -1,29 +1,10 @@ import { describe, expect, it } from 'vitest' import { - buildLocalNotificationData, getNotificationNavigationTarget, notificationCredentialRecoveryRoute } from './notification-routing' describe('notification routing', () => { - it('includes the host id in locally scheduled notification data', () => { - expect( - buildLocalNotificationData( - { - source: 'agent-task-complete', - worktreeId: 'repo::/Users/me/orca/workspaces/feature', - notificationId: 'agent:one' - }, - 'host-1' - ) - ).toEqual({ - source: 'agent-task-complete', - hostId: 'host-1', - worktreeId: 'repo::/Users/me/orca/workspaces/feature', - notificationId: 'agent:one' - }) - }) - // Identities stay raw: the target is dispatched as navigator params, not a URL. it('routes notification taps to the worktree terminal screen', () => { expect( @@ -88,3 +69,11 @@ describe('notification routing', () => { expect(notificationCredentialRecoveryRoute(target!)).toBeNull() }) }) + +it('preserves the originating pane in the workspace route', () => { + const paneKey = 'tab-b:11111111-1111-4111-8111-111111111111' + expect( + getNotificationNavigationTarget({ hostId: 'host', worktreeId: 'folder:/work', paneKey }) + ?.sessionTarget?.params + ).toEqual({ hostId: 'host', worktreeId: 'folder:/work', paneKey }) +}) diff --git a/mobile/src/notifications/notification-routing.ts b/mobile/src/notifications/notification-routing.ts index 5f81fb3567d..d1a8b6eebf3 100644 --- a/mobile/src/notifications/notification-routing.ts +++ b/mobile/src/notifications/notification-routing.ts @@ -2,21 +2,6 @@ import type { HostStackRouteTarget } from '../navigation/host-stack-navigation' import { mobileSessionRouteTarget } from '../session/mobile-session-route' import type { HostCredentialStatus } from '../transport/types' -export type DesktopNotificationSource = 'agent-task-complete' | 'terminal-bell' | 'test' - -export type DesktopNotificationEvent = { - source: DesktopNotificationSource - worktreeId?: string - notificationId?: string -} - -export type LocalNotificationData = { - source: DesktopNotificationSource - hostId: string - worktreeId?: string - notificationId?: string -} - export type NotificationNavigationOptions = { knownHostIds?: ReadonlySet credentialStatusByHostId?: ReadonlyMap @@ -26,23 +11,6 @@ function readNonEmptyString(value: unknown): string | null { return typeof value === 'string' && value.trim().length > 0 ? value : null } -export function buildLocalNotificationData( - event: DesktopNotificationEvent, - hostId: string -): LocalNotificationData { - const data: LocalNotificationData = { - source: event.source, - hostId - } - if (event.worktreeId) { - data.worktreeId = event.worktreeId - } - if (event.notificationId) { - data.notificationId = event.notificationId - } - return data -} - /** Where a tap should land. `sessionTarget` is null for a host-only notification, whose * `/h/` push is shallow enough to need no host-stack coordination. */ export type NotificationNavigationTarget = Readonly<{ @@ -81,7 +49,13 @@ export function getNotificationNavigationTarget( const credentialStatus = options.credentialStatusByHostId?.get(hostId) return { hostId, - sessionTarget: worktreeId ? mobileSessionRouteTarget({ hostId, worktreeId }) : null, + sessionTarget: worktreeId + ? mobileSessionRouteTarget({ + hostId, + worktreeId, + paneKey: readNonEmptyString(record.paneKey) ?? undefined + }) + : null, ...(credentialStatus === 'missing' ? { credentialRecovery: 're-pair' as const } : credentialStatus === 'temporarily-unavailable' diff --git a/mobile/src/notifications/notification-viewing-policy.ts b/mobile/src/notifications/notification-viewing-policy.ts new file mode 100644 index 00000000000..ea770cf3e74 --- /dev/null +++ b/mobile/src/notifications/notification-viewing-policy.ts @@ -0,0 +1,19 @@ +import { AppState } from 'react-native' + +let viewing: { hostId: string; worktreeId: string } | null = null +export function setNotificationViewingWorkspace(value: typeof viewing): void { + viewing = value +} + +export function shouldSuppressNotificationWhileViewing( + event: { worktreeId?: string }, + hostId: string, + suppressWhileViewing: boolean +): boolean { + return ( + suppressWhileViewing && + AppState.currentState === 'active' && + viewing?.hostId === hostId && + viewing.worktreeId === event.worktreeId + ) +} diff --git a/mobile/src/notifications/notification-watermark-seed-race.test.ts b/mobile/src/notifications/notification-watermark-seed-race.test.ts deleted file mode 100644 index 742f0711982..00000000000 --- a/mobile/src/notifications/notification-watermark-seed-race.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import * as Notifications from 'expo-notifications' -import { subscribeToDesktopNotifications } from './mobile-notifications' -import { - adoptNotificationEpoch, - clearWatermark, - getHostNotificationSession, - resetHostNotificationSessionsForTests, - seedWatermarkFromStorage -} from './notification-reconnect-catchup' -import AsyncStorage from '@react-native-async-storage/async-storage' -import type { RpcClient } from '../transport/rpc-client' -import { loadPushNotificationsEnabled } from '../storage/preferences' - -vi.mock('expo-notifications', () => ({ - AndroidImportance: { HIGH: 'high' }, - setNotificationChannelAsync: vi.fn(), - getPermissionsAsync: vi.fn(), - requestPermissionsAsync: vi.fn(), - scheduleNotificationAsync: vi.fn(), - dismissNotificationAsync: vi.fn() -})) - -vi.mock('react-native', () => ({ - Platform: { OS: 'ios', Version: 18 } -})) - -// A storage whose reads can be held open, so a live event can be injected into the -// exact window a real cold open has: subscription up, persisted watermark not yet read. -const storage = new Map() -let heldReads: (() => void)[] = [] -let holdReads = false -vi.mock('@react-native-async-storage/async-storage', () => ({ - default: { - getItem: vi.fn((key: string) => { - const read = (): string | null => storage.get(key) ?? null - if (!holdReads) { - return Promise.resolve(read()) - } - return new Promise((resolve) => { - heldReads.push(() => resolve(read())) - }) - }), - setItem: vi.fn(async (key: string, value: string) => { - storage.set(key, value) - }), - removeItem: vi.fn(async (key: string) => { - storage.delete(key) - }) - } -})) - -vi.mock('../storage/preferences', () => ({ - loadPushNotificationsEnabled: vi.fn() -})) - -function flushAsync(): Promise { - return new Promise((resolve) => { - setTimeout(resolve, 10) - }) -} - -function releaseReads(): void { - const pending = heldReads - heldReads = [] - for (const resolve of pending) { - resolve() - } -} - -function makeHostClient() { - let onData: ((data: unknown) => void) | null = null - const getMissedCalls: { lastSeenSeq: number; epoch?: string }[] = [] - const client = { - subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => { - onData = cb - return vi.fn(() => { - onData = null - }) - }), - getState: vi.fn(() => 'connected'), - sendRequest: vi.fn(async (method: string, params: unknown = {}) => { - if (method === 'notifications.getMissedSince') { - getMissedCalls.push(params as { lastSeenSeq: number; epoch?: string }) - return { ok: true, result: { notifications: [] } } as never - } - return { ok: true, result: undefined } as never - }) - } - return { - client: client as unknown as RpcClient, - get onData() { - return onData - }, - getMissedCalls - } -} - -const WATERMARK_KEY = 'orca:mobileNotificationsWatermark:host-1' -const LEGACY_KEY = 'orca:mobileNotificationsLastSeq:host-1' - -describe('#8591 watermark seeding races a cold open', () => { - beforeEach(() => { - vi.clearAllMocks() - storage.clear() - heldReads = [] - holdReads = false - resetHostNotificationSessionsForTests() - vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ - status: 'granted', - canAskAgain: true - } as never) - vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1') - vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined) - }) - - it('asks for catch-up from the persisted seq even if a live event lands first', async () => { - // The window is real: app/index.tsx subscribes immediately, and the desktop's - // 'ready' plus its first live fan-out can both beat an AsyncStorage read. If the - // live seq is allowed to advance the watermark first, getMissedSince is asked to - // start from it and the desktop cuts everything the device actually missed. - storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-a' })) - holdReads = true - const host = makeHostClient() - - subscribeToDesktopNotifications(host.client, 'host-1') - host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-a' }) - host.onData?.({ - type: 'notification', - title: 'live-12', - body: 'b', - notificationId: 'agent:live', - notificationSeq: 12, - notificationEpoch: 'epoch-a' - }) - await flushAsync() - - // Nothing may be decided while the read is outstanding. - expect(host.getMissedCalls).toHaveLength(0) - - releaseReads() - await flushAsync() - - expect(host.getMissedCalls).toEqual([{ lastSeenSeq: 5, epoch: 'epoch-a' }]) - }) - - it('treats a zeroed-but-present watermark as a returning device, not a first pairing', async () => { - // adoptNotificationEpoch persists {seq: 0, epoch} when it voids a watermark from a - // dead counter. That record still proves this device has been subscribed here, so a - // cold open after it must catch up — reading it as "never paired" drops the window. - storage.set(WATERMARK_KEY, JSON.stringify({ seq: 0, epoch: 'epoch-a' })) - const host = makeHostClient() - - subscribeToDesktopNotifications(host.client, 'host-1') - host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-a' }) - await flushAsync() - - expect(host.getMissedCalls).toEqual([{ lastSeenSeq: 0, epoch: 'epoch-a' }]) - }) - - it('does not catch up on a first-ever pairing', async () => { - const host = makeHostClient() - - subscribeToDesktopNotifications(host.client, 'host-1') - host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-a' }) - await flushAsync() - - expect(host.getMissedCalls).toEqual([]) - }) - - it('a seed landing after a live epoch is adopted cannot reinstate the dead watermark', async () => { - // Ordering invariant on the exported pair, not a path subscribeToDesktopNotifications - // can currently take — 'ready' awaits watermarkSeeded before adopting, so the seed - // always resolves first today. Pinned anyway because the guard is load-bearing the - // moment any caller adopts an epoch before seeding: applying a seq 40 from a counter - // that no longer exists would let getMissedSince cut the new counter's 1..40, which - // is the original #8591 loss re-entered through the seeding path. - const session = getHostNotificationSession('host-1') - adoptNotificationEpoch(session, 'host-1', 'epoch-new') - await flushAsync() - - storage.set(WATERMARK_KEY, JSON.stringify({ seq: 40, epoch: 'epoch-old' })) - seedWatermarkFromStorage(session, 'host-1') - await session.watermarkSeeded - await flushAsync() - - expect(session.lastDeliveredEpoch).toBe('epoch-new') - expect(session.lastDeliveredSeq).toBe(0) - }) - - it('clears the legacy seq key too, so an unpaired host cannot resurrect it', async () => { - // loadWatermark falls back to the legacy key, so leaving it behind lets a re-paired - // host read a pre-#8591 seq belonging to a counter lifetime that no longer exists. - storage.set(WATERMARK_KEY, JSON.stringify({ seq: 9, epoch: 'epoch-a' })) - storage.set(LEGACY_KEY, '57') - - await clearWatermark('host-1') - - expect(vi.mocked(AsyncStorage.removeItem).mock.calls.map((call) => call[0])).toEqual( - expect.arrayContaining([WATERMARK_KEY, LEGACY_KEY]) - ) - expect(storage.has(WATERMARK_KEY)).toBe(false) - expect(storage.has(LEGACY_KEY)).toBe(false) - }) -}) diff --git a/mobile/src/notifications/push-background-dismissal.test.ts b/mobile/src/notifications/push-background-dismissal.test.ts new file mode 100644 index 00000000000..40e535e2fcf --- /dev/null +++ b/mobile/src/notifications/push-background-dismissal.test.ts @@ -0,0 +1,72 @@ +import { expect, it, vi } from 'vitest' +const state = vi.hoisted(() => ({ task: null as null | ((input: unknown) => Promise) })) +vi.mock('expo-task-manager', () => ({ + defineTask: (_name: string, task: typeof state.task) => { + state.task = task + }, + isAvailableAsync: async () => true +})) +vi.mock('expo-notifications', () => ({ + registerTaskAsync: vi.fn(), + getPresentedNotificationsAsync: vi.fn(async () => []), + dismissNotificationAsync: vi.fn() +})) +vi.mock('./push-tray-dismissal', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + dismissPresentedPushNotification: vi.fn(actual.dismissPresentedPushNotification) + } +}) +import * as Notifications from 'expo-notifications' +import { dismissPresentedPushNotification } from './push-tray-dismissal' +import { registerPushDismissalTask } from './push-background-dismissal' + +it('handles native background JSON and scopes dismissal to the originating host', async () => { + await registerPushDismissalTask() + await state.task!({ + data: { + data: { + dataString: JSON.stringify({ + kind: 'dismiss', + hostFingerprint: 'host-a', + notificationId: 'same-id' + }) + } + } + }) + expect(dismissPresentedPushNotification).toHaveBeenCalledWith( + 'same-id', + 'host-a', + expect.objectContaining({ kind: 'dismiss' }) + ) +}) + +it('does not turn ordinary alerts into dismissals', async () => { + vi.mocked(dismissPresentedPushNotification).mockClear() + await state.task!({ + data: { data: { orca: { hostFingerprint: 'host-a', notificationId: 'same-id' } } } + }) + expect(dismissPresentedPushNotification).not.toHaveBeenCalled() +}) + +it('an ID-only background dismissal preserves versioned tray alerts', async () => { + const base = { hostFingerprint: 'host-a', notificationId: 'same-id' } + vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([ + { request: { identifier: 'legacy', content: { data: base } } }, + { + request: { + identifier: 'versioned', + content: { + data: { + ...base, + notificationEpoch: 'epoch', + notificationSeq: 3 + } + } + } + } + ] as never) + await state.task!({ data: { data: { ...base, kind: 'dismiss' } } }) + expect(Notifications.dismissNotificationAsync).toHaveBeenCalledExactlyOnceWith('legacy') +}) diff --git a/mobile/src/notifications/push-background-dismissal.ts b/mobile/src/notifications/push-background-dismissal.ts new file mode 100644 index 00000000000..4689d58ef58 --- /dev/null +++ b/mobile/src/notifications/push-background-dismissal.ts @@ -0,0 +1,41 @@ +import { wasPushDismissed } from './push-dismissal-watermarks' +import * as TaskManager from 'expo-task-manager' +import * as Notifications from 'expo-notifications' +import { readOrcaPushPayload } from './push-payload' +import { dismissPresentedPushNotification } from './push-tray-dismissal' + +const TASK_NAME = 'orca-push-dismissal' + +TaskManager.defineTask( + TASK_NAME, + async ({ data, error }) => { + if (error || !data || 'actionIdentifier' in data) { + return + } + let raw: unknown = data.data + if (typeof data.data.dataString === 'string') { + try { + raw = JSON.parse(data.data.dataString) + } catch { + return + } + } + const payload = readOrcaPushPayload(raw) + if ( + payload?.notificationId && + (payload.kind === 'dismiss' || (await wasPushDismissed(payload))) + ) { + await dismissPresentedPushNotification( + payload.notificationId, + payload.hostFingerprint, + payload + ) + } + } +) + +export async function registerPushDismissalTask(): Promise { + if (await TaskManager.isAvailableAsync()) { + await Notifications.registerTaskAsync(TASK_NAME) + } +} diff --git a/mobile/src/notifications/push-dismissal-native-races.test.ts b/mobile/src/notifications/push-dismissal-native-races.test.ts new file mode 100644 index 00000000000..35a51748906 --- /dev/null +++ b/mobile/src/notifications/push-dismissal-native-races.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import AsyncStorage from '@react-native-async-storage/async-storage' +import { nativePushDismissal } from './native-push-dismissal' +import { rememberPushDismissal, wasPushDismissed } from './push-dismissal-watermarks' +import { foregroundNotificationBehavior } from './push-receive' +import { loadNotificationDeliveryPreferences } from './notification-delivery-preferences' + +const memory = vi.hoisted(() => new Map()) +const nativeLedger = vi.hoisted(() => new Map()) +vi.mock('./native-push-dismissal', () => ({ + nativePushDismissal: { + remember: vi.fn(async (payload) => { + const key = JSON.stringify([ + payload.hostFingerprint, + payload.notificationEpoch, + payload.notificationId + ]) + nativeLedger.set(key, Math.max(nativeLedger.get(key) ?? 0, payload.notificationSeq)) + }), + wasDismissed: vi.fn( + async (payload) => + (nativeLedger.get( + JSON.stringify([ + payload.hostFingerprint, + payload.notificationEpoch, + payload.notificationId + ]) + ) ?? -1) >= payload.notificationSeq + ) + } +})) +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: vi.fn(async (key: string) => memory.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => { + memory.set(key, value) + }) + } +})) +vi.mock('expo-notifications', () => ({ getPresentedNotificationsAsync: async () => [] })) +vi.mock('../transport/host-store', () => ({ loadHostCatalog: async () => [{ id: 'host' }] })) +vi.mock('./push-host-fingerprint', () => ({ resolveHostIdForFingerprint: () => 'host' })) +vi.mock('../storage/preferences', () => ({ + loadPushNotificationsEnabled: async () => true +})) +vi.mock('./notification-viewing-policy', () => ({ + shouldSuppressNotificationWhileViewing: () => false +})) +vi.mock('./notification-delivery-preferences', () => ({ + loadNotificationDeliveryPreferences: vi.fn(async () => ({ sound: true })) +})) + +const payload = { + hostFingerprint: 'abcdefghijklmnop', + notificationEpoch: 'epoch', + notificationId: 'note', + notificationSeq: 20 +} +const fence = { ...payload, notificationSeq: 21 } + +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(AsyncStorage.getItem).mockImplementation(async (key) => memory.get(key) ?? null) + memory.clear() + nativeLedger.clear() +}) + +it('uses only native storage for iOS dismissal reads and writes', async () => { + await rememberPushDismissal(fence) + expect(await wasPushDismissed(payload)).toBe(true) + expect(await wasPushDismissed({ ...payload, notificationSeq: 22 })).toBe(false) + expect(await wasPushDismissed({ ...payload, notificationEpoch: 'new-epoch' })).toBe(false) + expect(AsyncStorage.getItem).not.toHaveBeenCalled() + expect(AsyncStorage.setItem).not.toHaveBeenCalled() +}) + +it('rechecks a negative native snapshot overtaken by a dismissal', async () => { + let finish!: () => void + vi.mocked(nativePushDismissal!.wasDismissed).mockImplementationOnce(async () => { + await new Promise((resolve) => { + finish = resolve + }) + return false + }) + const pending = wasPushDismissed(payload) + await vi.waitFor(() => expect(finish).toBeDefined()) + await rememberPushDismissal(fence) + finish() + expect(await pending).toBe(true) + expect(nativePushDismissal!.wasDismissed).toHaveBeenCalledTimes(2) +}) + +it('surfaces native write failures without switching storage or poisoning later operations', async () => { + vi.mocked(nativePushDismissal!.remember).mockRejectedValueOnce(new Error('native failure')) + await expect(rememberPushDismissal(fence)).rejects.toThrow('native failure') + expect(AsyncStorage.setItem).not.toHaveBeenCalled() + await rememberPushDismissal(fence) + expect(await wasPushDismissed(payload)).toBe(true) +}) + +it('suppresses presentation when dismissal completes during the handler sound read', async () => { + let finish!: () => void + vi.mocked(loadNotificationDeliveryPreferences).mockImplementationOnce(async () => { + await new Promise((resolve) => { + finish = resolve + }) + return { sound: true } as Awaited> + }) + const pending = foregroundNotificationBehavior({ + request: { + identifier: 'foreground-alert', + trigger: null, + content: { title: null, subtitle: null, body: null, sound: null, data: { orca: payload } } + } + }) + await vi.waitFor(() => expect(finish).toBeDefined()) + await foregroundNotificationBehavior({ + request: { content: { data: { orca: { ...fence, kind: 'dismiss' } } } } + }) + expect(await wasPushDismissed(payload)).toBe(true) + finish() + expect(await pending).toEqual({ + shouldShowBanner: false, + shouldShowList: false, + shouldPlaySound: false, + shouldSetBadge: false + }) +}) diff --git a/mobile/src/notifications/push-dismissal-reconciliation.test.ts b/mobile/src/notifications/push-dismissal-reconciliation.test.ts new file mode 100644 index 00000000000..f1e1d34c606 --- /dev/null +++ b/mobile/src/notifications/push-dismissal-reconciliation.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import * as Notifications from 'expo-notifications' +import { loadHostCatalog } from '../transport/host-store' +import { deriveHostFingerprint } from './push-host-fingerprint' +import { requestNotificationCatchup } from './push-dismissal-reconciliation' +vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn() })) +vi.mock('expo-notifications', () => ({ + getPresentedNotificationsAsync: vi.fn(), + dismissNotificationAsync: vi.fn() +})) +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { getItem: async () => null, setItem: async () => {} } +})) +const publicKeyB64 = Buffer.alloc(32, 1).toString('base64') +const hostFingerprint = deriveHostFingerprint(publicKeyB64) +const id = { + notificationId: 'old-alert', + notificationEpoch: 'previous-host-process', + notificationSeq: 12 +} +function presented(identifier: string, overrides = {}) { + return { request: { identifier, content: { data: { hostFingerprint, ...id, ...overrides } } } } +} +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(loadHostCatalog).mockResolvedValue([{ id: 'host-a', publicKeyB64 }] as never) + vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([ + presented('old'), + presented('new', { notificationSeq: 14 }), + presented('other', { hostFingerprint: 'other-host' }) + ] as never) + vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined) +}) +it('clears a confirmed prior-epoch alert even with empty replay and preserves newer and other-host entries', async () => { + const sendRequest = vi.fn(async () => ({ + ok: true, + result: { notifications: [], epoch: 'new-process', dismissedPushes: [id] } + })) + await requestNotificationCatchup({ sendRequest } as never, 'host-a', () => false) + expect(sendRequest).toHaveBeenCalledWith('notifications.getMissedSince', { + lastSeenSeq: Number.MAX_SAFE_INTEGER, + deliveredPushes: [id, { ...id, notificationSeq: 14 }] + }) + expect(Notifications.dismissNotificationAsync).toHaveBeenCalledExactlyOnceWith('old') +}) +it('keeps alerts when an old host omits reconciliation or the request fails', async () => { + for (const response of [{ ok: true, result: { notifications: [] } }, { ok: false }]) { + await requestNotificationCatchup( + { sendRequest: async () => response } as never, + 'host-a', + () => false + ) + } + expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalled() +}) +it('ignores unrequested identities and a response arriving after disconnect', async () => { + let disposed = false + const sendRequest = vi.fn(async () => ({ + ok: true, + result: { + dismissedPushes: [ + { ...id, notificationSeq: 99 }, + { ...id, notificationEpoch: 'different-epoch' }, + { ...id, notificationId: 'different-alert' } + ] + } + })) + await requestNotificationCatchup({ sendRequest } as never, 'host-a', () => disposed) + sendRequest.mockImplementationOnce(async () => { + disposed = true + return { ok: true, result: { dismissedPushes: [id] } } + }) + await requestNotificationCatchup({ sendRequest } as never, 'host-a', () => disposed) + expect(sendRequest).toHaveBeenCalledTimes(2) + expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalled() +}) + +it('skips the replay RPC when the tray has no alerts for this host', async () => { + vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([ + presented('other', { hostFingerprint: 'other-host' }) + ] as never) + const sendRequest = vi.fn() + await requestNotificationCatchup({ sendRequest } as never, 'host-a', () => false) + expect(sendRequest).not.toHaveBeenCalled() +}) + +it('pages individual tray identities without requesting historical alerts', async () => { + const all = Array.from({ length: 288 }, (_, index) => ({ + hostFingerprint, + notificationId: `paged-${index}`, + notificationEpoch: 'previous-host-process', + notificationSeq: index + })) + vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue( + all.map((payload) => presented(payload.notificationId, payload)) as never + ) + const sendRequest = vi.fn(async (_method: string, params: { deliveredPushes?: typeof all }) => ({ + ok: true, + result: { notifications: [], dismissedPushes: params.deliveredPushes ?? [] } + })) + await requestNotificationCatchup({ sendRequest } as never, 'host-a', () => false) + expect(sendRequest).toHaveBeenCalledTimes(2) + expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ + lastSeenSeq: Number.MAX_SAFE_INTEGER + }) + expect(sendRequest.mock.calls[0]?.[1].deliveredPushes).toHaveLength(256) + expect(sendRequest.mock.calls[1]?.[1]).toMatchObject({ + lastSeenSeq: Number.MAX_SAFE_INTEGER, + deliveredPushes: all + .slice(256) + .map(({ notificationId, notificationEpoch, notificationSeq }) => ({ + notificationId, + notificationEpoch, + notificationSeq + })) + }) + expect(vi.mocked(Notifications.dismissNotificationAsync)).toHaveBeenCalledTimes(288) +}) + +it.each(['failure', 'disconnect'])( + 'stops after a second-page %s without removing unconfirmed alerts', + async (outcome) => { + vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue( + Array.from({ length: 513 }, (_, index) => + presented(`paged-${index}`, { notificationId: `paged-${index}`, notificationSeq: index }) + ) as never + ) + let disposed = false + let pages = 0 + const sendRequest = vi.fn( + async (_method: string, params: { deliveredPushes: (typeof id)[] }) => { + pages++ + disposed = pages === 2 && outcome === 'disconnect' + return { + ok: !(pages === 2 && outcome === 'failure'), + result: { dismissedPushes: params.deliveredPushes } + } + } + ) + await requestNotificationCatchup({ sendRequest } as never, 'host-a', () => disposed) + expect(sendRequest).toHaveBeenCalledTimes(2) + expect(Notifications.dismissNotificationAsync).toHaveBeenCalledTimes(256) + expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalledWith('paged-256') + } +) diff --git a/mobile/src/notifications/push-dismissal-reconciliation.ts b/mobile/src/notifications/push-dismissal-reconciliation.ts new file mode 100644 index 00000000000..4c39ccfeb0f --- /dev/null +++ b/mobile/src/notifications/push-dismissal-reconciliation.ts @@ -0,0 +1,81 @@ +import * as Notifications from 'expo-notifications' +import type { RpcClient } from '../transport/rpc-client' +import { loadHostCatalog } from '../transport/host-store' +import { resolveHostIdForFingerprint } from './push-host-fingerprint' +import { readNativeNotificationData } from './native-notification-data' +import { readOrcaPushPayload, type OrcaPushPayload } from './push-payload' +import { dismissRememberedPushNotifications } from './push-tray-dismissal' +import { rememberPushDismissal } from './push-dismissal-watermarks' +import { + readPushNotificationIdentity, + type PushNotificationIdentity +} from './push-notification-identity' + +const key = (item: PushNotificationIdentity) => + JSON.stringify([item.notificationId, item.notificationEpoch, item.notificationSeq]) +async function readDelivered(hostId: string): Promise> { + const selected = new Map() + try { + const [presented, hosts] = await Promise.all([ + Notifications.getPresentedNotificationsAsync(), + loadHostCatalog() + ]) + for (const notification of presented) { + const payload = readOrcaPushPayload(readNativeNotificationData(notification.request)) + if (!payload || resolveHostIdForFingerprint(payload.hostFingerprint, hosts) !== hostId) { + continue + } + const identity = readPushNotificationIdentity(payload) + if (identity && selected.size < 2048) { + selected.set(key(identity), payload) + } + if (selected.size === 2048) { + break + } + } + } catch { + // Tray inspection is best-effort; failure leaves OS banners for later reconciliation. + } + return selected +} + +export async function requestNotificationCatchup( + client: Pick, + hostId: string, + isDisposed: () => boolean +): Promise { + const entries = [...(await readDelivered(hostId)).entries()] + for (let offset = 0; offset < entries.length && !isDisposed(); offset += 256) { + const requested = new Map(entries.slice(offset, offset + 256)) + const reply = await client.sendRequest('notifications.getMissedSince', { + // Reconcile the tray without requesting historical alerts. + lastSeenSeq: Number.MAX_SAFE_INTEGER, + deliveredPushes: [...requested.values()].map((payload) => + readPushNotificationIdentity(payload)! + ) + }) + if (!reply.ok || isDisposed()) { + return + } + const result = reply.result as { dismissedPushes?: unknown } | undefined + if (!Array.isArray(result?.dismissedPushes)) { + continue + } + const confirmed: OrcaPushPayload[] = [] + for (const raw of result.dismissedPushes.slice(0, 256)) { + if (isDisposed()) { + break + } + const id = readPushNotificationIdentity(raw) + const payload = id ? requested.get(key(id)) : undefined + if (payload && id) { + await rememberPushDismissal(payload) + confirmed.push(payload) + requested.delete(key(id)) + } + } + if (confirmed.length && !isDisposed()) { + await dismissRememberedPushNotifications(confirmed[0]!.hostFingerprint, confirmed) + } + } +} diff --git a/mobile/src/notifications/push-dismissal-watermarks.test.ts b/mobile/src/notifications/push-dismissal-watermarks.test.ts new file mode 100644 index 00000000000..cef0cf3404e --- /dev/null +++ b/mobile/src/notifications/push-dismissal-watermarks.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import AsyncStorage from '@react-native-async-storage/async-storage' +const storage = vi.hoisted(() => new Map()) +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: vi.fn(async (key: string) => storage.get(key) ?? null), + setItem: async (key: string, value: string) => { + storage.set(key, value) + } + } +})) +import { rememberPushDismissal, wasPushDismissed } from './push-dismissal-watermarks' +const payload = { + hostFingerprint: 'host-a', + notificationEpoch: 'epoch-a', + notificationId: 'note', + notificationSeq: 2 +} +beforeEach(() => { + storage.clear() + vi.mocked(AsyncStorage.getItem) + .mockReset() + .mockImplementation(async (key) => storage.get(key) ?? null) + vi.useRealTimers() +}) + +it('persists dismissal through restart while preserving newer alerts and other hosts or epochs', async () => { + await rememberPushDismissal(payload) + vi.resetModules() + const restarted = await import('./push-dismissal-watermarks') + expect(await restarted.wasPushDismissed({ ...payload, notificationSeq: 1 })).toBe(true) + expect(await restarted.wasPushDismissed({ ...payload, notificationSeq: 3 })).toBe(false) + expect(await restarted.wasPushDismissed({ ...payload, hostFingerprint: 'host-b' })).toBe(false) + expect(await restarted.wasPushDismissed({ ...payload, notificationEpoch: 'epoch-b' })).toBe(false) +}) + +it('serializes concurrent dismissals and never lowers a watermark', async () => { + await Promise.all([ + rememberPushDismissal({ ...payload, notificationSeq: 5 }), + rememberPushDismissal(payload), + rememberPushDismissal({ ...payload, notificationId: 'other' }) + ]) + expect(await wasPushDismissed({ ...payload, notificationSeq: 5 })).toBe(true) + expect(await wasPushDismissed({ ...payload, notificationId: 'other' })).toBe(true) +}) + +it('expires retained metadata and ignores unversioned dismissals', async () => { + vi.useFakeTimers() + await rememberPushDismissal(payload) + vi.setSystemTime(Date.now() + 24 * 60 * 60 * 1000) + expect(await wasPushDismissed(payload)).toBe(false) + await rememberPushDismissal({ ...payload, notificationEpoch: undefined }) + expect(await wasPushDismissed(payload)).toBe(false) +}) + +it('joins an overtaking JavaScript write before retrying a delayed negative snapshot', async () => { + let finish!: () => void + vi.mocked(AsyncStorage.getItem).mockImplementationOnce(async (key) => { + const snapshot = storage.get(key) ?? null + await new Promise((resolve) => { + finish = resolve + }) + return snapshot + }) + const pending = wasPushDismissed(payload) + await vi.waitFor(() => expect(finish).toBeDefined()) + await rememberPushDismissal(payload) + finish() + expect(await pending).toBe(true) + expect(AsyncStorage.getItem).toHaveBeenCalledTimes(3) + expect(await wasPushDismissed({ ...payload, notificationSeq: 3 })).toBe(false) +}) + +it.each([1, 3])('retains live dismissals beyond 512 entries across %i hosts', async (hosts) => { + for (let index = 0; index < 520; index++) { + await rememberPushDismissal({ + ...payload, + hostFingerprint: `host-${index % hosts}`, + notificationId: `note-${index}` + }) + } + vi.resetModules() + const restarted = await import('./push-dismissal-watermarks') + for (const index of [0, 1, 519]) { + const alert = { + ...payload, + hostFingerprint: `host-${index % hosts}`, + notificationId: `note-${index}` + } + expect(await restarted.wasPushDismissed(alert)).toBe(true) + expect(await restarted.wasPushDismissed({ ...alert, notificationSeq: 3 })).toBe(false) + } +}) diff --git a/mobile/src/notifications/push-dismissal-watermarks.ts b/mobile/src/notifications/push-dismissal-watermarks.ts new file mode 100644 index 00000000000..8202af054d7 --- /dev/null +++ b/mobile/src/notifications/push-dismissal-watermarks.ts @@ -0,0 +1,104 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import type { OrcaPushPayload } from './push-payload' +import { nativePushDismissal } from './native-push-dismissal' + +const STORAGE_KEY = 'orca:pushDismissalWatermarks:v1' +// Keep every live fence: count-based eviction lets delayed alerts reappear. +const RETENTION_MS = 24 * 60 * 60 * 1000 + +type Entry = { key: string; seq: number; expiresAt: number } +let writes: Promise = Promise.resolve() + +function queueDismissalOperation(operation: () => Promise): Promise { + const pending = writes.then(operation) + writes = pending.then( + () => {}, + () => {} + ) + return pending +} + +function eventKey(payload: OrcaPushPayload): string | null { + if ( + !payload.notificationId || + !payload.notificationEpoch || + !Number.isSafeInteger(payload.notificationSeq) || + payload.notificationSeq! < 0 + ) { + return null + } + return JSON.stringify([ + payload.hostFingerprint, + payload.notificationEpoch, + payload.notificationId + ]) +} + +async function readEntries(): Promise { + try { + const raw: unknown = JSON.parse((await AsyncStorage.getItem(STORAGE_KEY)) ?? '[]') + if (!Array.isArray(raw)) { + return [] + } + return raw.filter( + (entry): entry is Entry => + entry !== null && + typeof entry === 'object' && + typeof entry.key === 'string' && + Number.isSafeInteger(entry.seq) && + entry.seq >= 0 && + Number.isFinite(entry.expiresAt) && + entry.expiresAt > Date.now() + ) + } catch { + return [] + } +} + +export async function rememberPushDismissal(payload: OrcaPushPayload): Promise { + const key = eventKey(payload) + if (!key) { + return + } + return queueDismissalOperation(async () => { + if (nativePushDismissal) { + await nativePushDismissal.remember(payload) + return + } + const entries = await readEntries() + const previous = entries.find((entry) => entry.key === key) + const entry = { + key, + seq: Math.max(previous?.seq ?? 0, payload.notificationSeq!), + expiresAt: Date.now() + RETENTION_MS + } + await AsyncStorage.setItem( + STORAGE_KEY, + JSON.stringify([...entries.filter((item) => item.key !== key), entry]) + ) + }) +} + +async function readDismissal(payload: OrcaPushPayload, key: string): Promise { + if (nativePushDismissal) { + return nativePushDismissal.wasDismissed(payload) + } + return (await readEntries()).some( + (entry) => entry.key === key && entry.seq >= payload.notificationSeq! + ) +} + +export async function wasPushDismissed(payload: OrcaPushPayload): Promise { + const key = eventKey(payload) + if (!key) { + return false + } + const precedingWrites = writes + await precedingWrites + const dismissed = await readDismissal(payload, key) + if (dismissed || writes === precedingWrites) { + return dismissed + } + // An overtaking write invalidates a negative snapshot; one queued read cannot be overtaken again. + return queueDismissalOperation(() => readDismissal(payload, key)) +} diff --git a/mobile/src/notifications/push-host-fingerprint.test.ts b/mobile/src/notifications/push-host-fingerprint.test.ts new file mode 100644 index 00000000000..2fc5b44dba1 --- /dev/null +++ b/mobile/src/notifications/push-host-fingerprint.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { sha256 } from '@noble/hashes/sha256' +import { deriveHostFingerprint, resolveHostIdForFingerprint } from './push-host-fingerprint' + +// Why Buffer here: it computes the same value through a completely different +// base64 path than the module's btoa/replace, so the vector is a real cross-check +// of the derivation the desktop and gateway independently perform. +function expectedFingerprint(publicKey: Uint8Array): string { + return Buffer.from(sha256(publicKey)).toString('base64url').slice(0, 16) +} + +const publicKey = Uint8Array.from({ length: 32 }, (_, index) => index) +const publicKeyB64 = Buffer.from(publicKey).toString('base64') + +describe('deriveHostFingerprint', () => { + it('matches base64url(sha256(publicKey)) truncated to 16 chars', () => { + const fingerprint = deriveHostFingerprint(publicKeyB64) + + expect(fingerprint).toBe(expectedFingerprint(publicKey)) + expect(fingerprint).toHaveLength(16) + }) + + it('produces url-safe characters only, so a fingerprint survives a JSON payload', () => { + // 0xff bytes are what push '+' and '/' into a standard base64 digest. + const dense = new Uint8Array(32).fill(0xff) + const fingerprint = deriveHostFingerprint(Buffer.from(dense).toString('base64')) + + expect(fingerprint).toBe(expectedFingerprint(dense)) + expect(fingerprint).toMatch(/^[A-Za-z0-9_-]{16}$/) + }) + + it.each([ + ['a key of the wrong length', Buffer.from(new Uint8Array(16)).toString('base64')], + ['text that is not base64 at all', '!!!not base64!!!'], + ['an empty key', ''] + ])('returns null for %s', (_label, value) => { + expect(deriveHostFingerprint(value)).toBeNull() + }) +}) + +describe('resolveHostIdForFingerprint', () => { + const other = Uint8Array.from({ length: 32 }, (_, index) => index + 1) + const hosts = [ + { id: 'host-corrupt', publicKeyB64: 'not-a-key' }, + { id: 'host-other', publicKeyB64: Buffer.from(other).toString('base64') }, + { id: 'host-1', publicKeyB64 } + ] + + it('maps a push fingerprint back to the paired host id', () => { + expect(resolveHostIdForFingerprint(expectedFingerprint(publicKey), hosts)).toBe('host-1') + }) + + it('returns null for a fingerprint no paired host derives', () => { + expect(resolveHostIdForFingerprint('0123456789abcdef', hosts)).toBeNull() + }) + + it('rejects a fingerprint of the wrong length before hashing anything', () => { + expect( + resolveHostIdForFingerprint(expectedFingerprint(publicKey).slice(0, 8), hosts) + ).toBeNull() + }) +}) diff --git a/mobile/src/notifications/push-host-fingerprint.ts b/mobile/src/notifications/push-host-fingerprint.ts new file mode 100644 index 00000000000..3aa8b739fba --- /dev/null +++ b/mobile/src/notifications/push-host-fingerprint.ts @@ -0,0 +1,58 @@ +import { sha256 } from '@noble/hashes/sha256' + +// Why: a push arrives from the gateway, so it can only name the host by something +// both sides derive independently — base64url(sha256(hostPublicKey)) truncated to +// 16 chars, identical to deriveRelayHostId in +// src/main/runtime/relay/relay-http-client.ts. The phone maps it back to its own +// hostId by re-deriving over each stored host's publicKeyB64. +// +// Base64 is inlined rather than imported (same call as mobile-relay-credential-hash.ts): +// the only shared encoders live in modules that drag in tweetnacl, expo-crypto, or +// the host store, none of which a pure derivation should need. + +const HOST_FINGERPRINT_LENGTH = 16 + +function decodeBase64(value: string): Uint8Array | null { + try { + const binary = atob(value) + const bytes = new Uint8Array(binary.length) + for (let index = 0; index < binary.length; index++) { + bytes[index] = binary.charCodeAt(index) + } + return bytes + } catch { + return null + } +} + +function encodeBase64Url(bytes: Uint8Array): string { + let binary = '' + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +/** Null when the stored key is unreadable, so a corrupt host entry can't shadow a real match. */ +export function deriveHostFingerprint(publicKeyB64: string): string | null { + const publicKey = decodeBase64(publicKeyB64) + if (!publicKey || publicKey.length !== 32) { + return null + } + return encodeBase64Url(sha256(publicKey)).slice(0, HOST_FINGERPRINT_LENGTH) +} + +export function resolveHostIdForFingerprint( + fingerprint: string, + hosts: readonly { readonly id: string; readonly publicKeyB64: string }[] +): string | null { + if (fingerprint.length !== HOST_FINGERPRINT_LENGTH) { + return null + } + for (const host of hosts) { + if (deriveHostFingerprint(host.publicKeyB64) === fingerprint) { + return host.id + } + } + return null +} diff --git a/mobile/src/notifications/push-notification-identity.test.ts b/mobile/src/notifications/push-notification-identity.test.ts new file mode 100644 index 00000000000..df3dfd9d152 --- /dev/null +++ b/mobile/src/notifications/push-notification-identity.test.ts @@ -0,0 +1,25 @@ +import { expect, it } from 'vitest' +import { + readPushNotificationIdentity, + type PushNotificationIdentity +} from './push-notification-identity' + +it('reads a bounded individual notification identity', () => { + const identity: PushNotificationIdentity = { + notificationId: 'agent:one', + notificationEpoch: 'epoch-1', + notificationSeq: 7 + } + expect(readPushNotificationIdentity(identity)).toEqual(identity) +}) + +it('rejects incomplete or non-integral notification identities', () => { + expect(readPushNotificationIdentity({ notificationId: 'agent:one' })).toBeNull() + expect( + readPushNotificationIdentity({ + notificationId: 'agent:one', + notificationEpoch: 'epoch-1', + notificationSeq: 1.5 + }) + ).toBeNull() +}) diff --git a/mobile/src/notifications/push-notification-identity.ts b/mobile/src/notifications/push-notification-identity.ts new file mode 100644 index 00000000000..b8e9e73a34b --- /dev/null +++ b/mobile/src/notifications/push-notification-identity.ts @@ -0,0 +1,26 @@ +export type PushNotificationIdentity = { + notificationId: string + notificationEpoch: string + notificationSeq: number +} + +export function readPushNotificationIdentity(value: unknown): PushNotificationIdentity | null { + if (!value || typeof value !== 'object') { + return null + } + const item = value as PushNotificationIdentity + return typeof item.notificationId === 'string' && + item.notificationId.length > 0 && + item.notificationId.length <= 2048 && + typeof item.notificationEpoch === 'string' && + item.notificationEpoch.length > 0 && + item.notificationEpoch.length <= 128 && + Number.isSafeInteger(item.notificationSeq) && + item.notificationSeq >= 0 + ? { + notificationId: item.notificationId, + notificationEpoch: item.notificationEpoch, + notificationSeq: item.notificationSeq + } + : null +} diff --git a/mobile/src/notifications/push-payload.ts b/mobile/src/notifications/push-payload.ts new file mode 100644 index 00000000000..bcdbf0f6073 --- /dev/null +++ b/mobile/src/notifications/push-payload.ts @@ -0,0 +1,43 @@ +// Why two shapes: APNs nests Orca's fields under `orca` beside `aps`, while FCM +// carries them flat in `data` as strings. Both reach JS as the notification's +// `content.data`, so the reader accepts either and coerces the numeric fields. +export type OrcaPushPayload = { + readonly kind?: 'alert' | 'dismiss' + readonly hostFingerprint: string + readonly notificationId?: string + readonly notificationSeq?: number + readonly notificationEpoch?: string + readonly paneKey?: string + readonly worktreeId?: string +} + +function readString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +function readSeq(value: unknown): number | undefined { + const raw = typeof value === 'number' ? value : Number(readString(value)) + return Number.isFinite(raw) ? raw : undefined +} + +export function readOrcaPushPayload(data: unknown): OrcaPushPayload | null { + if (!data || typeof data !== 'object') { + return null + } + const nested = (data as { orca?: unknown }).orca + const record = (nested && typeof nested === 'object' ? nested : data) as Record + // The fingerprint is what makes this a gateway push; locally scheduled data never has one. + const hostFingerprint = readString(record.hostFingerprint) + if (!hostFingerprint) { + return null + } + return { + hostFingerprint, + ...(record.kind === 'dismiss' || record.kind === 'alert' ? { kind: record.kind } : {}), + notificationId: readString(record.notificationId), + notificationSeq: readSeq(record.notificationSeq), + notificationEpoch: readString(record.notificationEpoch), + paneKey: readString(record.paneKey), + worktreeId: readString(record.worktreeId) + } +} diff --git a/mobile/src/notifications/push-preference-update.test.ts b/mobile/src/notifications/push-preference-update.test.ts new file mode 100644 index 00000000000..11f137fb38f --- /dev/null +++ b/mobile/src/notifications/push-preference-update.test.ts @@ -0,0 +1,86 @@ +vi.mock('./desktop-notification-channel', () => ({ + ensureDesktopNotificationChannel: vi.fn(async () => {}) +})) +import { AppState } from 'react-native' +import { beforeEach, expect, it, vi } from 'vitest' +import { + attachPushRegistration, + resetPushRegistrationForTests, + setNotificationDeliveryPreferences, + NOTIFICATIONS_REMOTE_PUSH_CAPABILITY +} from './push-registration' +import { DEFAULT_NOTIFICATION_DELIVERY } from './notification-delivery-preferences' + +const storage = new Map() +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: vi.fn(async (key: string) => storage.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => { + storage.set(key, value) + }) + } +})) +vi.mock('react-native', () => ({ + AppState: { currentState: 'active', addEventListener: vi.fn(() => ({ remove: vi.fn() })) } +})) + +vi.mock('./push-token', () => ({ + getDevicePushToken: vi.fn(async () => ({ + platform: 'ios', + token: 'a'.repeat(64), + apnsEnvironment: 'sandbox' + })), + addPushTokenListener: vi.fn() +})) + +beforeEach(() => { + AppState.currentState = 'active' + resetPushRegistrationForTests() + storage.clear() + storage.set('orca:pushServiceNotificationsEnabled', 'true') +}) + +it('replaces an in-flight registration with the latest away and sound preferences', async () => { + const calls: { method: string; params: unknown }[] = [] + let finishFirst: ((value: unknown) => void) | undefined + const client = { + sendRequest: vi.fn(async (method: string, params?: unknown) => { + calls.push({ method, params }) + if (method === 'status.get') { + return { ok: true, result: { capabilities: [NOTIFICATIONS_REMOTE_PUSH_CAPABILITY] } } + } + if (method === 'notifications.registerPush') { + if (!finishFirst) { + return new Promise((resolve) => { + finishFirst = resolve + }) + } + return { ok: true, result: { registered: true, registrationId: 'new' } } + } + return { ok: true, result: { unregistered: true } } + }) + } + const detach = attachPushRegistration('host', client as never) + await vi.waitFor(() => expect(finishFirst).toBeDefined()) + const update = setNotificationDeliveryPreferences({ + ...DEFAULT_NOTIFICATION_DELIVERY, + onlyWhenDesktopAway: false, + sound: false + }) + finishFirst!({ ok: true, result: { registered: true, registrationId: 'old' } }) + await update + await vi.waitFor(() => + expect( + calls.filter((call) => call.method === 'notifications.registerPush').length + ).toBeGreaterThan(1) + ) + const latest = calls.findLast((call) => call.method === 'notifications.registerPush') + expect(latest?.params).toMatchObject({ + filter: { + onlyWhenDesktopAway: false, + sound: false + } + }) + expect(calls.some((call) => call.method === 'notifications.unregisterPush')).toBe(true) + detach() +}) diff --git a/mobile/src/notifications/push-receive.test.ts b/mobile/src/notifications/push-receive.test.ts new file mode 100644 index 00000000000..8bcaef51e83 --- /dev/null +++ b/mobile/src/notifications/push-receive.test.ts @@ -0,0 +1,274 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { AppState } from 'react-native' +import { setNotificationViewingWorkspace } from './notification-viewing-policy' +vi.mock('./push-tray-dismissal', () => ({ dismissPresentedPushNotification: vi.fn() })) +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { sha256 } from '@noble/hashes/sha256' +import { loadHostCatalog } from '../transport/host-store' +import type { HostCatalogEntry } from '../transport/types' +import { getNotificationNavigationTarget } from './notification-routing' +import { + foregroundNotificationBehavior, + canPresentForegroundPush, + isRemotePushTrigger, + pushNotificationRouteData, + resetForegroundPushClaimsForTests +} from './push-receive' + +async function shouldSuppressForegroundPush(data: unknown): Promise { + return !(await foregroundNotificationBehavior({ request: { content: { data } } })) + .shouldShowBanner +} + +vi.mock('react-native', () => ({ AppState: { currentState: 'background' } })) +vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn() })) +const storage = vi.hoisted(() => new Map()) +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: vi.fn(async (key: string) => storage.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => storage.set(key, value)) + } +})) + +const publicKeyB64 = Buffer.alloc(32, 1).toString('base64') +const hostFingerprint = Buffer.from(sha256(Buffer.alloc(32, 1))) + .toString('base64url') + .slice(0, 16) +const hosts = [{ id: 'host-1', publicKeyB64 }] as unknown as HostCatalogEntry[] +const otherPublicKeyB64 = Buffer.alloc(32, 2).toString('base64') +const otherHostFingerprint = Buffer.from(sha256(Buffer.alloc(32, 2))) + .toString('base64url') + .slice(0, 16) + +function apnsData(orca: Record): unknown { + return { aps: { alert: { title: 'Orca', body: 'Agent needs input' } }, orca } +} +function fcmData(orca: Record): unknown { + return Object.fromEntries(Object.entries(orca).map(([key, value]) => [key, String(value)])) +} + +beforeEach(() => { + vi.clearAllMocks() + AppState.currentState = 'background' + setNotificationViewingWorkspace(null) + storage.clear() + storage.set('orca:pushServiceNotificationsEnabled', 'true') + resetForegroundPushClaimsForTests() + vi.mocked(loadHostCatalog).mockResolvedValue([ + ...hosts, + { id: 'host-2', publicKeyB64: otherPublicKeyB64 } + ] as unknown as HostCatalogEntry[]) +}) + +describe('shouldSuppressForegroundPush', () => { + const push = () => + apnsData({ + hostFingerprint, + notificationId: 'agent:one', + notificationSeq: 7, + notificationEpoch: 'epoch-1' + }) + + it('allows one eligible native push and suppresses an in-process duplicate', async () => { + await expect(shouldSuppressForegroundPush(push())).resolves.toBe(false) + await expect(shouldSuppressForegroundPush(push())).resolves.toBe(true) + }) + + it('reads flat FCM fields and allows the first native push', async () => { + await expect( + shouldSuppressForegroundPush( + fcmData({ + hostFingerprint, + notificationId: 'agent:one', + notificationSeq: 8, + notificationEpoch: 'epoch-1' + }) + ) + ).resolves.toBe(false) + }) + + it('deduplicates ID-less bells by host, epoch, and valid sequence', async () => { + const bell = (overrides: Record = {}) => + apnsData({ + hostFingerprint, + source: 'terminal-bell', + notificationSeq: 4, + notificationEpoch: 'epoch-1', + ...overrides + }) + await expect(shouldSuppressForegroundPush(bell())).resolves.toBe(false) + await expect(shouldSuppressForegroundPush(bell())).resolves.toBe(true) + await expect(shouldSuppressForegroundPush(bell({ notificationSeq: 5 }))).resolves.toBe(false) + await expect( + shouldSuppressForegroundPush(bell({ notificationEpoch: 'epoch-2' })) + ).resolves.toBe(false) + await expect( + shouldSuppressForegroundPush(bell({ hostFingerprint: otherHostFingerprint })) + ).resolves.toBe(false) + }) + + it('does not claim invalid sequence values as duplicate identities', async () => { + const invalid = apnsData({ + hostFingerprint, + source: 'plugin', + notificationSeq: 1.5, + notificationEpoch: 'epoch-1' + }) + await expect(shouldSuppressForegroundPush(invalid)).resolves.toBe(false) + await expect(shouldSuppressForegroundPush(invalid)).resolves.toBe(false) + }) + + it('suppresses pushes for an unpaired host', async () => { + vi.mocked(loadHostCatalog).mockResolvedValue([]) + await expect( + shouldSuppressForegroundPush(apnsData({ hostFingerprint, notificationSeq: 1 })) + ).resolves.toBe(true) + }) + + it('suppresses a push after a matching persisted dismissal', async () => { + const { rememberPushDismissal } = await import('./push-dismissal-watermarks') + const payload = { + hostFingerprint, + notificationId: 'dismissed', + notificationSeq: 2, + notificationEpoch: 'epoch-1' + } + await rememberPushDismissal(payload) + await expect(shouldSuppressForegroundPush(apnsData(payload))).resolves.toBe(true) + }) + + it('fails closed for recognized pushes when suppression checks throw', async () => { + const dismissals = await import('./push-dismissal-watermarks') + const dismissalSpy = vi + .spyOn(dismissals, 'wasPushDismissed') + .mockRejectedValueOnce(new Error('dismissal read failed')) + await expect( + foregroundNotificationBehavior({ request: { content: { data: push() } } }) + ).resolves.toMatchObject({ shouldShowBanner: false, shouldShowList: false }) + dismissalSpy.mockRestore() + }) + + it('keeps unrelated notifications visible when suppression checks throw', async () => { + const dismissals = await import('./push-dismissal-watermarks') + const dismissalSpy = vi + .spyOn(dismissals, 'wasPushDismissed') + .mockRejectedValue(new Error('dismissal read failed')) + await expect( + foregroundNotificationBehavior({ + request: { content: { data: { title: 'Other app notification' } } } + }) + ).resolves.toMatchObject({ shouldShowBanner: true, shouldShowList: true }) + dismissalSpy.mockRestore() + }) +}) + +describe('pushNotificationRouteData', () => { + it('routes a tap by mapping the fingerprint to the paired host id', () => { + const data = pushNotificationRouteData( + apnsData({ hostFingerprint, worktreeId: 'repo::/feature', source: 'agent-task-complete' }), + hosts + ) + expect(getNotificationNavigationTarget(data, { knownHostIds: new Set(['host-1']) })).toEqual({ + hostId: 'host-1', + sessionTarget: { + name: '[hostId]/session/[worktreeId]', + params: { hostId: 'host-1', worktreeId: 'repo::/feature' } + } + }) + }) + + it('maps a push without a worktree to the host screen', () => { + const data = pushNotificationRouteData( + fcmData({ hostFingerprint, source: 'terminal-bell' }), + hosts + ) + expect(getNotificationNavigationTarget(data)).toEqual({ hostId: 'host-1', sessionTarget: null }) + }) + + it('keeps local data untouched and rejects an unresolvable remote fingerprint', () => { + const local = { hostId: 'host-9', source: 'agent-task-complete' } + expect(pushNotificationRouteData(local, hosts)).toBe(local) + expect( + pushNotificationRouteData( + { hostId: 'host-1', orca: { hostFingerprint: 'unknown' } }, + hosts, + true + ) + ).toBeNull() + }) + + it('recognises only provider-delivered triggers', () => { + expect(isRemotePushTrigger({ type: 'push' })).toBe(true) + expect(isRemotePushTrigger({ type: 'timeInterval' })).toBe(false) + }) +}) + +it('uses one delivery snapshot for sound and viewing even when settings change during host lookup', async () => { + AppState.currentState = 'active' + setNotificationViewingWorkspace({ hostId: 'host-1', worktreeId: 'folder' }) + storage.set( + 'orca:notificationDeliveryPreferences', + JSON.stringify({ + sound: false, + suppressWhileViewing: false + }) + ) + vi.mocked(loadHostCatalog).mockImplementationOnce(async () => { + storage.set( + 'orca:notificationDeliveryPreferences', + JSON.stringify({ + sound: true, + suppressWhileViewing: true + }) + ) + return hosts + }) + const behavior = await foregroundNotificationBehavior({ + request: { + content: { + data: apnsData({ + hostFingerprint, + worktreeId: 'folder', + notificationEpoch: 'snapshot', + notificationSeq: 1 + }) + } + } + }) + expect(behavior).toMatchObject({ shouldShowBanner: true, shouldPlaySound: false }) + expect( + vi + .mocked(AsyncStorage.getItem) + .mock.calls.filter(([key]) => key === 'orca:notificationDeliveryPreferences') + ).toHaveLength(1) +}) + +it.each(['apns', 'fcm'])( + 'routes %s pane payload to the correct host, workspace and pane', + (provider) => { + const paneKey = 'tab-b:11111111-1111-4111-8111-111111111111' + const payload = { hostFingerprint, worktreeId: 'folder:/work', paneKey } + const data = provider === 'apns' ? { orca: payload } : payload + const routed = pushNotificationRouteData(data, [{ id: 'host', publicKeyB64 }], true) + expect(getNotificationNavigationTarget(routed)?.sessionTarget?.params).toEqual({ + hostId: 'host', + worktreeId: 'folder:/work', + paneKey + }) + } +) + +it('preflight does not consume the final presentation claim and observes later dismissals', async () => { + const payload = { + hostFingerprint, + notificationId: 'preflight', + notificationEpoch: 'epoch', + notificationSeq: 4 + } + await expect(canPresentForegroundPush(payload)).resolves.toBe(true) + await expect(shouldSuppressForegroundPush(apnsData(payload))).resolves.toBe(false) + const { rememberPushDismissal } = await import('./push-dismissal-watermarks') + await rememberPushDismissal(payload) + await expect(canPresentForegroundPush(payload)).resolves.toBe(false) + await expect(shouldSuppressForegroundPush(apnsData(payload))).resolves.toBe(true) +}) diff --git a/mobile/src/notifications/push-receive.ts b/mobile/src/notifications/push-receive.ts new file mode 100644 index 00000000000..b106ec555f2 --- /dev/null +++ b/mobile/src/notifications/push-receive.ts @@ -0,0 +1,152 @@ +import { wasPushDismissed } from './push-dismissal-watermarks' +import { dismissPresentedPushNotification } from './push-tray-dismissal' +import { shouldSuppressNotificationWhileViewing } from './notification-viewing-policy' +import { loadPushNotificationsEnabled } from '../storage/preferences' +import { loadHostCatalog } from '../transport/host-store' +import { resolveHostIdForFingerprint } from './push-host-fingerprint' +import { readOrcaPushPayload, type OrcaPushPayload } from './push-payload' +import type { Notification, NotificationBehavior } from 'expo-notifications' +import { readNativeNotificationData } from './native-notification-data' +import { loadNotificationDeliveryPreferences } from './notification-delivery-preferences' + +const RECENT_FOREGROUND_PUSH_CAP = 512 +const recentForegroundPushes = new Set() + +function claimForegroundPush(payload: OrcaPushPayload): boolean { + const seq = payload.notificationSeq + if ( + !payload.notificationEpoch || + typeof seq !== 'number' || + !Number.isSafeInteger(seq) || + seq < 0 + ) { + return true + } + const key = JSON.stringify([ + payload.hostFingerprint, + payload.notificationEpoch, + payload.notificationId ?? null, + seq + ]) + if (recentForegroundPushes.has(key)) { + return false + } + recentForegroundPushes.add(key) + if (recentForegroundPushes.size > RECENT_FOREGROUND_PUSH_CAP) { + const oldest = recentForegroundPushes.values().next().value + if (oldest !== undefined) { + recentForegroundPushes.delete(oldest) + } + } + return true +} + +export function resetForegroundPushClaimsForTests(): void { + recentForegroundPushes.clear() +} + +export async function foregroundNotificationBehavior( + notification: Pick +): Promise { + const data = readNativeNotificationData(notification.request) + const payload = readOrcaPushPayload(data) + const preferences = await loadNotificationDeliveryPreferences() + // Unrecognized notifications retain normal behavior; recognized pushes fail closed + // when consent, host, viewing, or dismissal checks cannot complete. + const ineligible = await shouldSuppressForegroundPush( + payload, + preferences.suppressWhileViewing + ).catch(() => payload !== null) + const suppressed = ineligible || (payload !== null && !claimForegroundPush(payload)) + return { + shouldShowBanner: !suppressed, + shouldShowList: !suppressed, + shouldPlaySound: !suppressed && preferences.sound, + shouldSetBadge: false + } +} + +export async function canPresentForegroundPush(payload: OrcaPushPayload): Promise { + const preferences = await loadNotificationDeliveryPreferences() + return !(await shouldSuppressForegroundPush(payload, preferences.suppressWhileViewing)) +} + +async function resolvePushHostId(payload: OrcaPushPayload): Promise { + const hosts = await loadHostCatalog().catch(() => []) + return resolveHostIdForFingerprint(payload.hostFingerprint, hosts) +} + +async function shouldSuppressForegroundPush( + payload: OrcaPushPayload | null, + suppressWhileViewing: boolean +): Promise { + if (!payload) { + return false + } + if (payload.kind === 'dismiss') { + if (payload.notificationId) { + await dismissPresentedPushNotification( + payload.notificationId, + payload.hostFingerprint, + payload + ) + } + return true + } + const hostId = await resolvePushHostId(payload) + // Why suppressed rather than shown: the only pushes that outlive their host are + // ones a gateway registration still holds after a removal whose unregister never + // reached the desktop. A banner naming a host this phone no longer has cannot be + // tapped anywhere, so it is noise the user cannot act on or turn off per-host. + if (!hostId) { + return true + } + if (!(await loadPushNotificationsEnabled())) { + return true + } + if (shouldSuppressNotificationWhileViewing(payload, hostId, suppressWhileViewing)) { + return true + } + // Keep this last: a socket/native dismissal may land during any preference or host read. + return wasPushDismissed(payload) +} + +/** Whether the OS says a notification came from a provider rather than this app. */ +export function isRemotePushTrigger(trigger: unknown): boolean { + return ( + typeof trigger === 'object' && + trigger !== null && + (trigger as { readonly type?: unknown }).type === 'push' + ) +} + +/** + * Notification data a tap can route with: the gateway names the host by fingerprint, + * so it is mapped back to this device's hostId. Locally scheduled data passes + * through untouched, which is what keeps its taps on their existing path. + * + * Why null and not the raw data when the fingerprint does not resolve: a gateway + * payload is attacker-adjacent input, and passing it on would let a stray `hostId` + * beside the `orca` block route a tap at a host the push never named. A remote + * push with no fingerprint at all is the same input minus the block, so it is + * unrouted too rather than handed to the local path as if this app scheduled it. + */ +export function pushNotificationRouteData( + data: unknown, + hosts: readonly { readonly id: string; readonly publicKeyB64: string }[], + remote = false +): unknown { + const payload = readOrcaPushPayload(data) + if (!payload) { + return remote ? null : data + } + const hostId = resolveHostIdForFingerprint(payload.hostFingerprint, hosts) + if (!hostId) { + return null + } + return { + hostId, + ...(payload.paneKey ? { paneKey: payload.paneKey } : {}), + ...(payload.worktreeId ? { worktreeId: payload.worktreeId } : {}) + } +} diff --git a/mobile/src/notifications/push-registration-cancellation.test.ts b/mobile/src/notifications/push-registration-cancellation.test.ts new file mode 100644 index 00000000000..92acc892886 --- /dev/null +++ b/mobile/src/notifications/push-registration-cancellation.test.ts @@ -0,0 +1,263 @@ +import { ensureDesktopNotificationChannel } from './desktop-notification-channel' +vi.mock('./desktop-notification-channel', () => ({ + ensureDesktopNotificationChannel: vi.fn(async () => {}) +})) +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { + attachPushRegistration, + resetPushRegistrationForTests, + setRemotePushEnabled, + startPushTokenSync, + unregisterPushForRemovedHost, + NOTIFICATIONS_REMOTE_PUSH_CAPABILITY +} from './push-registration' +import { addPushTokenListener, getDevicePushToken } from './push-token' +import type { MobilePushToken } from './push-token' + +import AsyncStorage from '@react-native-async-storage/async-storage' +import { removeHost } from '../transport/host-store' +import { removeHostAndCloseClient } from '../transport/host-removal-lifecycle' +vi.mock('../transport/host-store', () => ({ removeHost: vi.fn() })) +vi.mock('./mobile-push-lease-renewal', () => ({ startMobilePushLeaseRenewal: () => () => {} })) + +const storage = new Map() +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: async (key: string) => storage.get(key) ?? null, + setItem: vi.fn(async (key: string, value: string) => { + storage.set(key, value) + }) + } +})) +vi.mock('react-native', () => ({ AppState: { currentState: 'active' } })) +vi.mock('./push-token', () => ({ getDevicePushToken: vi.fn(), addPushTokenListener: vi.fn() })) +const token: MobilePushToken = { + platform: 'ios', + token: 'a'.repeat(64), + apnsEnvironment: 'sandbox' +} +const records = () => JSON.parse(storage.get('orca:remotePushHostRegistrations') ?? '{}') +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} +function client( + register: () => Promise = async () => ({ ok: true, result: { registered: true } }) +) { + return { + sendRequest: vi.fn(async (method: string) => { + if (method === 'status.get') { + return { ok: true, result: { capabilities: [NOTIFICATIONS_REMOTE_PUSH_CAPABILITY] } } + } + if (method === 'notifications.registerPush') { + return register() + } + return { ok: true, result: { unregistered: true } } + }) + } +} +beforeEach(() => { + vi.clearAllMocks() + resetPushRegistrationForTests() + storage.clear() + storage.set('orca:pushServiceNotificationsEnabled', 'true') + vi.mocked(getDevicePushToken).mockResolvedValue(token) + vi.mocked(addPushTokenListener).mockReturnValue(() => {}) + vi.mocked(removeHost).mockReset() +}) + +afterEach(() => vi.useRealTimers()) + +it('does not resurrect a removed host when its registration response arrives late', async () => { + const pending = deferred() + const connection = client(() => pending.promise) + attachPushRegistration('host', connection as never) + await vi.waitFor(() => + expect(connection.sendRequest).toHaveBeenCalledWith( + 'notifications.registerPush', + expect.anything(), + expect.anything() + ) + ) + const removal = unregisterPushForRemovedHost('host') + expect(connection.sendRequest.mock.calls.map(([method]) => method)).not.toContain( + 'notifications.unregisterPush' + ) + pending.resolve({ ok: true, result: { registered: true } }) + await removal + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(records().registeredHostIds).toEqual([]) + expect(records().pendingUnregisterHostIds).toEqual([]) +}) + +it('does not start registration after removal while native token lookup was pending', async () => { + const pending = deferred() + vi.mocked(getDevicePushToken).mockReturnValueOnce(pending.promise) + const connection = client() + attachPushRegistration('host', connection as never) + await vi.waitFor(() => expect(getDevicePushToken).toHaveBeenCalled()) + await unregisterPushForRemovedHost('host') + pending.resolve(token) + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(connection.sendRequest.mock.calls.map(([method]) => method)).not.toContain( + 'notifications.registerPush' + ) +}) + +it('does not register with stale consent after the user disables notifications during token lookup', async () => { + const pending = deferred() + vi.mocked(getDevicePushToken).mockReturnValueOnce(pending.promise) + const connection = client() + attachPushRegistration('host', connection as never) + await vi.waitFor(() => expect(getDevicePushToken).toHaveBeenCalled()) + const disabled = setRemotePushEnabled(false) + pending.resolve(token) + await disabled + await vi.waitFor(() => + expect(connection.sendRequest.mock.calls.map(([method]) => method)).toContain( + 'notifications.unregisterPush' + ) + ) + expect(connection.sendRequest.mock.calls.map(([method]) => method)).not.toContain( + 'notifications.registerPush' + ) +}) + +it('waits for the Android notification channel before registering a token', async () => { + const pending = deferred() + vi.mocked(ensureDesktopNotificationChannel).mockReturnValueOnce(pending.promise) + const connection = client() + attachPushRegistration('host', connection as never) + await vi.waitFor(() => expect(ensureDesktopNotificationChannel).toHaveBeenCalled()) + expect(getDevicePushToken).not.toHaveBeenCalled() + expect(connection.sendRequest.mock.calls.map(([method]) => method)).not.toContain( + 'notifications.registerPush' + ) + pending.resolve() + await vi.waitFor(() => + expect(connection.sendRequest.mock.calls.map(([method]) => method)).toContain( + 'notifications.registerPush' + ) + ) +}) + +it('completes disable while native token acquisition remains unresolved, and rejects late tokens', async () => { + vi.useFakeTimers() + storage.set( + 'orca:remotePushHostRegistrations', + JSON.stringify({ + registeredHostIds: ['host'], + pendingUnregisterHostIds: [] + }) + ) + const pending = deferred() + vi.mocked(getDevicePushToken).mockReturnValueOnce(pending.promise) + const connection = client() + const stop = startPushTokenSync() + attachPushRegistration('host', connection as never) + await vi.advanceTimersByTimeAsync(0) + expect(getDevicePushToken).toHaveBeenCalledOnce() + await setRemotePushEnabled(false) + expect(records().pendingUnregisterHostIds).toEqual(['host']) + expect(connection.sendRequest.mock.calls.map(([method]) => method)).not.toContain( + 'notifications.unregisterPush' + ) + await vi.advanceTimersByTimeAsync(2_000) + expect(storage.get('orca:pushServiceNotificationsEnabled')).toBe('false') + expect(records()).toEqual({ registeredHostIds: [], pendingUnregisterHostIds: [] }) + expect(connection.sendRequest.mock.calls.map(([method]) => method)).toContain( + 'notifications.unregisterPush' + ) + pending.resolve(token) + vi.mocked(addPushTokenListener).mock.calls[0]![0](token) + await vi.advanceTimersByTimeAsync(0) + expect(connection.sendRequest.mock.calls.map(([method]) => method)).not.toContain( + 'notifications.registerPush' + ) + stop() +}) + +it('restores registration without reconnect after metadata removal fails, retaining detach ownership', async () => { + const connection = client() + const detach = attachPushRegistration('host', connection as never) + await vi.waitFor(() => expect(records().registeredHostIds).toEqual(['host'])) + vi.mocked(removeHost).mockRejectedValueOnce(new Error('metadata failure')) + const close = vi.fn() + await expect(removeHostAndCloseClient('host', close)).rejects.toThrow('metadata failure') + expect(close).not.toHaveBeenCalled() + await vi.waitFor(() => expect(records().registeredHostIds).toEqual(['host'])) + expect(connection.sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'status.get', + 'notifications.registerPush', + 'notifications.unregisterPush', + 'status.get', + 'notifications.registerPush' + ]) + detach() + connection.sendRequest.mockClear() + await setRemotePushEnabled(true) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(connection.sendRequest).not.toHaveBeenCalled() +}) + +it('does not revive a connection detached while metadata removal was pending', async () => { + const connection = client() + const detach = attachPushRegistration('host', connection as never) + await vi.waitFor(() => expect(records().registeredHostIds).toEqual(['host'])) + const commit = deferred() + vi.mocked(removeHost).mockImplementationOnce(async () => { + await commit.promise + throw new Error('metadata failure') + }) + const removal = expect(removeHostAndCloseClient('host', vi.fn())).rejects.toThrow( + 'metadata failure' + ) + await vi.waitFor(() => expect(removeHost).toHaveBeenCalled()) + detach() + connection.sendRequest.mockClear() + commit.resolve() + await removal + await setRemotePushEnabled(true) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(connection.sendRequest).not.toHaveBeenCalled() +}) + +it('retires late registration ownership before a failed removal restores a fresh registration', async () => { + const oldRegister = deferred() + const newRegister = deferred() + const register = vi + .fn() + .mockReturnValueOnce(oldRegister.promise) + .mockReturnValue(newRegister.promise) + const connection = client(register) + attachPushRegistration('host', connection as never) + await vi.waitFor(() => expect(register).toHaveBeenCalledOnce()) + vi.mocked(removeHost).mockRejectedValueOnce(new Error('metadata failure')) + const removal = expect(removeHostAndCloseClient('host', vi.fn())).rejects.toThrow( + 'metadata failure' + ) + expect(connection.sendRequest.mock.calls.map(([method]) => method)).not.toContain( + 'notifications.unregisterPush' + ) + oldRegister.resolve({ ok: true, result: { registered: true } }) + await removal + await vi.waitFor(() => expect(register).toHaveBeenCalledTimes(2)) + expect(records().registeredHostIds).toEqual([]) + newRegister.resolve({ ok: true, result: { registered: true } }) + await vi.waitFor(() => expect(records().registeredHostIds).toEqual(['host'])) +}) + +it('still commits removal when unregister and cleanup storage fail', async () => { + const connection = client() + attachPushRegistration('host', connection as never) + await vi.waitFor(() => expect(records().registeredHostIds).toEqual(['host'])) + connection.sendRequest.mockRejectedValueOnce(new Error('socket closed')) + vi.mocked(AsyncStorage.setItem).mockRejectedValueOnce(new Error('disk full')) + const close = vi.fn() + await removeHostAndCloseClient('host', close) + expect(removeHost).toHaveBeenCalledWith('host') + expect(close).toHaveBeenCalledWith('host') +}) diff --git a/mobile/src/notifications/push-registration.test.ts b/mobile/src/notifications/push-registration.test.ts new file mode 100644 index 00000000000..975c840133c --- /dev/null +++ b/mobile/src/notifications/push-registration.test.ts @@ -0,0 +1,423 @@ +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { getItem: vi.fn(async () => null) } +})) +vi.mock('./desktop-notification-channel', () => ({ + ensureDesktopNotificationChannel: vi.fn(async () => {}) +})) +import { AppState } from 'react-native' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient, SendRequestOptions } from '../transport/rpc-client' +import type { RpcResponse } from '../transport/types' +import { + loadPushNotificationsEnabled, + loadRemotePushHostRegistrations, + savePushNotificationsEnabled, + saveRemotePushHostRegistrations, + type RemotePushHostRegistrations +} from '../storage/preferences' +import { addPushTokenListener, getDevicePushToken, type MobilePushToken } from './push-token' +import { + NOTIFICATIONS_REMOTE_PUSH_CAPABILITY, + attachPushRegistration, + resetPushRegistrationForTests, + setRemotePushEnabled, + startPushTokenSync, + unregisterPushForRemovedHost +} from './push-registration' + +vi.mock('../storage/preferences', () => ({ + loadPushNotificationsEnabled: vi.fn(), + savePushNotificationsEnabled: vi.fn(), + loadRemotePushHostRegistrations: vi.fn(), + saveRemotePushHostRegistrations: vi.fn() +})) + +vi.mock('react-native', () => ({ + AppState: { currentState: 'active', addEventListener: vi.fn(() => ({ remove: vi.fn() })) } +})) + +vi.mock('./push-token', () => ({ + getDevicePushToken: vi.fn(), + addPushTokenListener: vi.fn() +})) + +const IOS_TOKEN: MobilePushToken = { + platform: 'ios', + token: 'a'.repeat(64), + apnsEnvironment: 'production' +} + +// Every await in the module resolves immediately, so one macrotask drains the whole +// per-host reconcile chain no matter how many hops deep it happens to be. +function flush(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + +function ok(result: unknown): RpcResponse { + return { id: 'req', ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +type SentRequest = { method: string; params?: unknown; options?: SendRequestOptions } + +function makeClient(capabilities: readonly string[]): { + client: Pick + sent: SentRequest[] +} { + const sent: SentRequest[] = [] + const client = { + sendRequest: vi.fn(async (method: string, params?: unknown, options?: SendRequestOptions) => { + sent.push({ method, params, options }) + if (method === 'status.get') { + return ok({ capabilities: [...capabilities] }) + } + if (method === 'notifications.registerPush') { + return ok({ registered: true, registrationId: 'registration-1' }) + } + if (method === 'notifications.unregisterPush') { + return ok({ unregistered: true }) + } + return ok(null) + }) + } + return { client, sent } +} + +function methodsIn(sent: SentRequest[]): string[] { + return sent.map((request) => request.method) +} + +let enabled = false +let stored: RemotePushHostRegistrations + +beforeEach(() => { + vi.clearAllMocks() + AppState.currentState = 'active' + resetPushRegistrationForTests() + enabled = false + stored = { registeredHostIds: [], pendingUnregisterHostIds: [] } + + vi.mocked(loadPushNotificationsEnabled).mockImplementation(async () => enabled) + vi.mocked(savePushNotificationsEnabled).mockImplementation(async (value) => { + enabled = value + }) + vi.mocked(loadRemotePushHostRegistrations).mockImplementation(async () => stored) + vi.mocked(saveRemotePushHostRegistrations).mockImplementation(async (value) => { + stored = value + }) + vi.mocked(getDevicePushToken).mockResolvedValue(IOS_TOKEN) +}) + +describe('push registration capability gating', () => { + it('registers a connected host that advertises remote push', async () => { + const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY]) + await setRemotePushEnabled(true) + + attachPushRegistration('host-1', client) + await flush() + + const register = sent.find((request) => request.method === 'notifications.registerPush') + expect(register?.params).toEqual({ + platform: 'ios', + token: IOS_TOKEN.token, + apnsEnvironment: 'production', + filter: { onlyWhenDesktopAway: true, sound: true } + }) + expect(stored.registeredHostIds).toEqual(['host-1']) + }) + + it('never calls registerPush on a host without the capability', async () => { + const { client, sent } = makeClient(['some-other.v1']) + await setRemotePushEnabled(true) + + attachPushRegistration('host-legacy', client) + await flush() + + expect(methodsIn(sent)).toEqual(['status.get']) + expect(stored.registeredHostIds).toEqual([]) + }) + + it('reconciles disabled consent even without registration records', async () => { + const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY]) + + attachPushRegistration('host-1', client) + await flush() + + expect(methodsIn(sent)).toEqual(['status.get', 'notifications.unregisterPush']) + }) + + it('omits apnsEnvironment for an Android token', async () => { + vi.mocked(getDevicePushToken).mockResolvedValue({ platform: 'android', token: 'fcm-token' }) + const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY]) + await setRemotePushEnabled(true) + + attachPushRegistration('host-1', client) + await flush() + + const register = sent.find((request) => request.method === 'notifications.registerPush') + expect(register?.params).toMatchObject({ platform: 'android', token: 'fcm-token' }) + expect(register?.params).not.toHaveProperty('apnsEnvironment') + }) + + it('registers nothing when the device has no push token at all', async () => { + vi.mocked(getDevicePushToken).mockResolvedValue(null) + const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY]) + await setRemotePushEnabled(true) + + attachPushRegistration('host-simulator', client) + await flush() + + expect(methodsIn(sent)).toEqual(['status.get']) + }) + + it('asks only once when the host answers that it has no push capability', async () => { + const { client, sent } = makeClient(['some-other.v1']) + await setRemotePushEnabled(true) + attachPushRegistration('host-legacy', client) + await flush() + + await setRemotePushEnabled(true) + await flush() + + expect(methodsIn(sent)).toEqual(['status.get']) + }) + + it('re-probes a host whose first status.get never answered', async () => { + vi.useFakeTimers() + const sent: string[] = [] + let probeFails = true + const client = { + sendRequest: vi.fn(async (method: string) => { + sent.push(method) + if (method === 'status.get') { + if (probeFails) { + throw new Error('request timed out') + } + return ok({ capabilities: [NOTIFICATIONS_REMOTE_PUSH_CAPABILITY] }) + } + return ok({ registered: true, registrationId: 'registration-1' }) + }) + } + await setRemotePushEnabled(true) + attachPushRegistration('host-1', client) + await vi.advanceTimersByTimeAsync(0) + expect(sent).toEqual(['status.get']) + + // A failed probe retries while the same connection remains active. + probeFails = false + await vi.advanceTimersByTimeAsync(1_000) + await Promise.resolve() + await Promise.resolve() + + expect(sent).toEqual(['status.get', 'status.get', 'notifications.registerPush']) + vi.useRealTimers() + }) + + it('retries the device token on the next reconcile after the device had none', async () => { + vi.mocked(getDevicePushToken).mockResolvedValueOnce(null).mockResolvedValue(IOS_TOKEN) + const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY]) + await setRemotePushEnabled(true) + attachPushRegistration('host-1', client) + await flush() + expect(methodsIn(sent)).toEqual(['status.get']) + + // A token can be missing only for now — APNs registration still in flight. + await setRemotePushEnabled(true) + await flush() + + expect(methodsIn(sent)).toContain('notifications.registerPush') + }) +}) + +describe('push registration token changes', () => { + it('re-registers every connected host when the provider rolls the token', async () => { + let onTokenChange: ((token: MobilePushToken) => void) | null = null + vi.mocked(addPushTokenListener).mockImplementation((listener) => { + onTokenChange = listener + return () => {} + }) + const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY]) + await setRemotePushEnabled(true) + attachPushRegistration('host-1', client) + await flush() + const stop = startPushTokenSync() + + onTokenChange?.({ platform: 'ios', token: 'b'.repeat(64), apnsEnvironment: 'sandbox' }) + await flush() + + const registers = sent.filter((request) => request.method === 'notifications.registerPush') + expect(registers).toHaveLength(2) + expect(registers[1]?.params).toMatchObject({ + token: 'b'.repeat(64), + apnsEnvironment: 'sandbox' + }) + stop() + }) +}) + +describe('push unregistration', () => { + it('unregisters a connected host as soon as the switch goes off', async () => { + const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY]) + await setRemotePushEnabled(true) + attachPushRegistration('host-1', client) + await flush() + + await setRemotePushEnabled(false) + await flush() + + expect(methodsIn(sent)).toContain('notifications.unregisterPush') + expect(stored.registeredHostIds).toEqual([]) + expect(stored.pendingUnregisterHostIds).toEqual([]) + }) + + it('retries the unregister on a host that was offline when the switch went off', async () => { + const first = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY]) + await setRemotePushEnabled(true) + const detach = attachPushRegistration('host-1', first.client) + await flush() + detach() + + await setRemotePushEnabled(false) + await flush() + expect(methodsIn(first.sent)).not.toContain('notifications.unregisterPush') + expect(stored.pendingUnregisterHostIds).toEqual(['host-1']) + + // A fresh process: only the persisted intent survives the restart. + AppState.currentState = 'active' + resetPushRegistrationForTests() + const reconnected = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY]) + attachPushRegistration('host-1', reconnected.client) + await flush() + + // No probe first: a pending entry is a switch-off the user already performed, so + // it must not wait on a status.get that may never answer. + expect(methodsIn(reconnected.sent)).toEqual(['notifications.unregisterPush']) + expect(stored.pendingUnregisterHostIds).toEqual([]) + }) + + it('recovers an offline disable after its cleanup write fails and mobile restarts', async () => { + const first = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY]) + await setRemotePushEnabled(true) + const detach = attachPushRegistration('host-1', first.client) + await flush() + detach() + vi.mocked(saveRemotePushHostRegistrations).mockRejectedValueOnce(new Error('disk full')) + + await expect(setRemotePushEnabled(false)).rejects.toThrow('disk full') + expect(enabled).toBe(false) + expect(stored.pendingUnregisterHostIds).toEqual([]) + + resetPushRegistrationForTests() + const reconnected = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY]) + attachPushRegistration('host-1', reconnected.client) + await flush() + + expect(methodsIn(reconnected.sent)).toEqual(['status.get', 'notifications.unregisterPush']) + expect(stored).toEqual({ registeredHostIds: [], pendingUnregisterHostIds: [] }) + }) + + it('keeps the pending intent when the retry itself fails', async () => { + stored = { registeredHostIds: ['host-1'], pendingUnregisterHostIds: ['host-1'] } + const client = { + sendRequest: vi.fn(async (method: string) => + method === 'status.get' + ? ok({ capabilities: [NOTIFICATIONS_REMOTE_PUSH_CAPABILITY] }) + : Promise.reject(new Error('socket closed')) + ) + } + + attachPushRegistration('host-1', client) + await flush() + + expect(stored.pendingUnregisterHostIds).toEqual(['host-1']) + }) + + it('unregisters best-effort before a removed host loses its credentials', async () => { + const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY]) + await setRemotePushEnabled(true) + attachPushRegistration('host-1', client) + await flush() + + await unregisterPushForRemovedHost('host-1') + + expect(methodsIn(sent)).toContain('notifications.unregisterPush') + expect(stored.registeredHostIds).toEqual([]) + expect(stored.pendingUnregisterHostIds).toEqual([]) + }) + + it('drops a removed host that was never connected without any request', async () => { + stored = { registeredHostIds: ['host-gone'], pendingUnregisterHostIds: ['host-gone'] } + + await unregisterPushForRemovedHost('host-gone') + + expect(stored).toEqual({ registeredHostIds: [], pendingUnregisterHostIds: [] }) + }) + + it('unregisters a pending host even when its capability probe never answers', async () => { + stored = { registeredHostIds: ['host-1'], pendingUnregisterHostIds: ['host-1'] } + const sent: string[] = [] + const client = { + sendRequest: vi.fn(async (method: string) => { + sent.push(method) + if (method === 'status.get') { + throw new Error('request timed out') + } + return ok({ unregistered: true }) + }) + } + + attachPushRegistration('host-1', client) + await flush() + + // Gating this on the probe leaves the gateway pushing while the switch reads off. + expect(sent).toEqual(['notifications.unregisterPush']) + expect(stored.pendingUnregisterHostIds).toEqual([]) + }) + + it('re-arms the unregister when the switch goes off while a register is in flight', async () => { + const sent: string[] = [] + let releaseRegister: (() => void) | null = null + const client = { + sendRequest: vi.fn(async (method: string) => { + sent.push(method) + if (method === 'status.get') { + return ok({ capabilities: [NOTIFICATIONS_REMOTE_PUSH_CAPABILITY] }) + } + if (method === 'notifications.registerPush') { + await new Promise((resolve) => { + releaseRegister = resolve + }) + return ok({ registered: true, registrationId: 'registration-1' }) + } + return ok({ unregistered: true }) + }) + } + await setRemotePushEnabled(true) + attachPushRegistration('host-1', client) + await flush() + + // The sweep snapshots `registered` while this host is still only in flight. + const switchedOff = setRemotePushEnabled(false) + await flush() + releaseRegister?.() + await switchedOff + await flush() + + // Recording the late success would leave a live gateway registration behind a + // switch that reads off, with nothing pending to ever retract it. + expect(sent).toContain('notifications.unregisterPush') + expect(stored).toEqual({ registeredHostIds: [], pendingUnregisterHostIds: [] }) + }) +}) + +it('does not register or renew when a connected phone is in the background', async () => { + AppState.currentState = 'background' + const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY]) + await setRemotePushEnabled(true) + attachPushRegistration('background-phone', client) + await flush() + expect(methodsIn(sent)).not.toContain('notifications.registerPush') + AppState.currentState = 'active' + attachPushRegistration('background-phone', client) + await flush() + expect(methodsIn(sent)).toContain('notifications.registerPush') +}) diff --git a/mobile/src/notifications/push-registration.ts b/mobile/src/notifications/push-registration.ts new file mode 100644 index 00000000000..f5463714b7d --- /dev/null +++ b/mobile/src/notifications/push-registration.ts @@ -0,0 +1,328 @@ +import { ensureDesktopNotificationChannel } from './desktop-notification-channel' +import { AppState } from 'react-native' +import { startMobilePushLeaseRenewal } from './mobile-push-lease-renewal' +import { + loadNotificationDeliveryPreferences, + notificationPreferencesFilter, + saveNotificationDeliveryPreferences, + type NotificationDeliveryPreferences +} from './notification-delivery-preferences' +import type { + MobilePushFilter, + MobilePushRegisterInput, + MobilePushRegisterResult +} from '../../../src/shared/mobile-push-contract' +import { NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' +import type { RpcClient } from '../transport/rpc-client' +import { startRuntimeCapabilityProbe } from '../transport/runtime-capability-probe' +import { + loadPushNotificationsEnabled, + loadRemotePushHostRegistrations, + savePushNotificationsEnabled, + saveRemotePushHostRegistrations +} from '../storage/preferences' +import { addPushTokenListener, getDevicePushToken, type MobilePushToken } from './push-token' + +export const NOTIFICATIONS_REMOTE_PUSH_CAPABILITY = NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY + +type PushClient = Pick + +const REQUEST_TIMEOUT_MS = 5_000 +const REMOVAL_TIMEOUT_MS = 2_000 +const TOKEN_TIMEOUT_MS = 2_000 + +type HostPushState = { + connection: { client: PushClient | null } + // An unanswered probe is unknown, not unsupported. + supported: boolean | null + capabilityProbeStop: (() => void) | null + chain: Promise +} + +type RegistrationRecords = { registered: Set; pending: Set } + +const hostsById = new Map() +let registrationRecords: RegistrationRecords | null = null +let tokenPromise: Promise | null = null +// A late registration must not overwrite a newer preference or consent choice. +let consentGeneration = 0 + +function hostState(hostId: string): HostPushState { + let state = hostsById.get(hostId) + if (!state) { + state = { + connection: { client: null }, + supported: null, + capabilityProbeStop: null, + chain: Promise.resolve() + } + hostsById.set(hostId, state) + } + return state +} + +async function readRecords(): Promise { + if (!registrationRecords) { + const stored = await loadRemotePushHostRegistrations() + registrationRecords ??= { + registered: new Set(stored.registeredHostIds), + pending: new Set(stored.pendingUnregisterHostIds) + } + } + return registrationRecords +} + +async function mutateRecords(mutate: (value: RegistrationRecords) => void): Promise { + const value = await readRecords() + mutate(value) + await saveRemotePushHostRegistrations({ + registeredHostIds: [...value.registered], + pendingUnregisterHostIds: [...value.pending] + }) +} + +// A missing token is retried: APNs registration may still be in flight. +async function currentToken(): Promise { + await ensureDesktopNotificationChannel() + if (!tokenPromise) { + const pending: Promise = getDevicePushToken().then((token) => { + if (!token && tokenPromise === pending) { + tokenPromise = null + } + return token + }) + tokenPromise = pending + } + return tokenPromise +} + +async function sendRegister( + client: PushClient, + token: MobilePushToken, + filter: MobilePushFilter +): Promise { + const params: Omit = { + platform: token.platform, + token: token.token, + ...(token.apnsEnvironment ? { apnsEnvironment: token.apnsEnvironment } : {}), + filter + } + const response = await client + .sendRequest('notifications.registerPush', params, { + timeoutMs: REQUEST_TIMEOUT_MS, + failWhenDisconnected: true + }) + .catch(() => null) + if (!response?.ok) { + return false + } + return (response.result as MobilePushRegisterResult | null)?.registered === true +} + +async function sendUnregister(client: PushClient, timeoutMs: number): Promise { + const response = await client + .sendRequest('notifications.unregisterPush', null, { + timeoutMs, + failWhenDisconnected: true + }) + .catch(() => null) + return response?.ok === true +} + +async function reconcileHost(hostId: string): Promise { + const state = hostsById.get(hostId) + const client = state?.connection.client + if (!state || !client) { + return + } + const generation = consentGeneration + const isCurrent = () => hostsById.get(hostId) === state && state.connection.client === client + const value = await readRecords() + // Unregister intent takes priority even before the capability probe answers. + if (value.pending.has(hostId)) { + if (state.supported === false || !(await sendUnregister(client, REQUEST_TIMEOUT_MS))) { + return + } + await mutateRecords((current) => { + current.pending.delete(hostId) + current.registered.delete(hostId) + }) + // A preference change can invalidate a register without disabling push. + if (!(await loadPushNotificationsEnabled())) { + return + } + } + if (state.supported == null) { + if (!isCurrent()) { + return + } + state.capabilityProbeStop ??= startRuntimeCapabilityProbe(client, (capabilities) => { + if (!isCurrent()) { + return + } + state.supported = capabilities.includes(NOTIFICATIONS_REMOTE_PUSH_CAPABILITY) + void enqueueReconcile(hostId) + }) + return + } + if (!state.supported || !isCurrent()) { + return + } + if (!(await loadPushNotificationsEnabled())) { + // Saved consent recovers a disable even if its pending-record write failed. + if (await sendUnregister(client, REQUEST_TIMEOUT_MS)) { + await mutateRecords((current) => { + current.pending.delete(hostId) + current.registered.delete(hostId) + }) + } + return + } + if (AppState.currentState !== 'active') { + return + } + let timer: ReturnType | undefined + const token = await Promise.race([ + currentToken(), + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), TOKEN_TIMEOUT_MS) + }) + ]).finally(() => clearTimeout(timer)) + const filter = notificationPreferencesFilter(await loadNotificationDeliveryPreferences()) + if ( + !token || + !isCurrent() || + generation !== consentGeneration || + AppState.currentState !== 'active' + ) { + return + } + if (!(await sendRegister(client, token, filter)) || hostsById.get(hostId) !== state) { + return + } + if (generation !== consentGeneration) { + await mutateRecords((current) => current.pending.add(hostId)) + void enqueueReconcile(hostId) + return + } + await mutateRecords((current) => current.registered.add(hostId)) +} + +function enqueueReconcile(hostId: string): Promise { + const state = hostState(hostId) + const run = state.chain + .then(() => (hostsById.get(hostId) === state ? reconcileHost(hostId) : undefined)) + .catch(() => { + console.warn('[push] Failed to reconcile notification registration') + }) + state.chain = run + return run +} + +async function reconcileAllHosts(): Promise { + await Promise.all([...hostsById.keys()].map((hostId) => enqueueReconcile(hostId))) +} + +/** + * Track a host whose client has reached `connected`, registering (or retrying a + * pending unregister) as the current preference requires. The returned function + * detaches the client on disconnect; the host's tracked state survives it. + */ +export function attachPushRegistration(hostId: string, client: PushClient): () => void { + const state = hostState(hostId) + if (state.connection.client !== client) { + state.capabilityProbeStop?.() + state.capabilityProbeStop = null + state.connection.client = client + state.supported = null + } + void enqueueReconcile(hostId) + const connection = state.connection + return () => { + if (connection.client === client) { + connection.client = null + state.capabilityProbeStop?.() + state.capabilityProbeStop = null + state.supported = null + const current = hostsById.get(hostId) + if (current && current !== state) { + current.capabilityProbeStop?.() + current.capabilityProbeStop = null + current.supported = null + } + } + } +} + +// Consent completion covers local persistence; host reconciliation runs in the background. +export async function setRemotePushEnabled(enabled: boolean): Promise { + consentGeneration++ + await savePushNotificationsEnabled(enabled) + try { + await mutateRecords((current) => { + if (!enabled) { + for (const hostId of current.registered) { + current.pending.add(hostId) + } + return + } + current.pending.clear() + }) + } finally { + void reconcileAllHosts() + } +} + +export async function setNotificationDeliveryPreferences( + value: NotificationDeliveryPreferences +): Promise { + consentGeneration++ + await saveNotificationDeliveryPreferences(value) + await reconcileAllHosts() +} + +// Offline hosts retain the registration until unpaired or its mobile-use lease expires. +export async function unregisterPushForRemovedHost(hostId: string): Promise<() => void> { + const state = hostsById.get(hostId) + // Retire ownership before waiting for earlier RPCs to settle. + hostsById.delete(hostId) + state?.capabilityProbeStop?.() + if (state) { + state.capabilityProbeStop = null + } + await state?.chain + if (state?.connection.client && state.supported !== false) { + await sendUnregister(state.connection.client, REMOVAL_TIMEOUT_MS) + } + await mutateRecords((current) => { + current.registered.delete(hostId) + current.pending.delete(hostId) + }).catch(() => {}) + return () => { + if (state && !hostsById.has(hostId)) { + // Preserve disconnect ownership without reviving stale registration work. + hostsById.set(hostId, { ...state, supported: null, capabilityProbeStop: null }) + void enqueueReconcile(hostId) + } + } +} + +/** A rolled token stops delivering, so re-register every connected host at once. */ +export function startPushTokenSync(): () => void { + const stopLease = startMobilePushLeaseRenewal(reconcileAllHosts) + const stopToken = addPushTokenListener((token) => { + tokenPromise = Promise.resolve(token) + void reconcileAllHosts() + }) + return () => { + stopLease() + stopToken() + } +} + +export function resetPushRegistrationForTests(): void { + hostsById.clear() + registrationRecords = null + tokenPromise = null + consentGeneration = 0 +} diff --git a/mobile/src/notifications/push-socket-dismissal.test.ts b/mobile/src/notifications/push-socket-dismissal.test.ts new file mode 100644 index 00000000000..80d3534c4ca --- /dev/null +++ b/mobile/src/notifications/push-socket-dismissal.test.ts @@ -0,0 +1,73 @@ +import { expect, it, vi } from 'vitest' +import * as Notifications from 'expo-notifications' +import { loadHostCatalog } from '../transport/host-store' +import { deriveHostFingerprint } from './push-host-fingerprint' +import { dismissHostPushNotification } from './push-socket-dismissal' +vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn() })) +vi.mock('expo-notifications', () => ({ + getPresentedNotificationsAsync: vi.fn(), + dismissNotificationAsync: vi.fn() +})) +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { getItem: async () => null, setItem: async () => undefined } +})) + +it('a socket dismissal cannot clear another desktop or a newer notification', async () => { + const publicKeyB64 = Buffer.alloc(32, 1).toString('base64') + vi.mocked(loadHostCatalog).mockResolvedValue([{ id: 'host-a', publicKeyB64 }] as never) + const hostFingerprint = deriveHostFingerprint(publicKeyB64) + const event = { + type: 'dismiss' as const, + notificationId: 'same', + notificationEpoch: 'epoch', + notificationSeq: 2 + } + const presented = (identifier: string, overrides: Record) => ({ + request: { identifier, content: { data: { hostFingerprint, ...event, ...overrides } } } + }) + vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([ + presented('older', { notificationSeq: 1 }), + presented('newer', { notificationSeq: 3 }), + presented('other', { hostFingerprint: 'other-host' }), + presented('restarted', { notificationEpoch: 'new-epoch' }) + ] as never) + vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined) + await dismissHostPushNotification(event, 'host-a') + expect(vi.mocked(Notifications.dismissNotificationAsync).mock.calls).toEqual([['older']]) +}) + +it('supports ID-only legacy dismissal while preserving host isolation', async () => { + vi.clearAllMocks() + const publicKeyB64 = Buffer.alloc(32, 1).toString('base64') + vi.mocked(loadHostCatalog).mockResolvedValue([{ id: 'host-a', publicKeyB64 }] as never) + const hostFingerprint = deriveHostFingerprint(publicKeyB64) + vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([ + { + request: { + identifier: 'versioned', + content: { + data: { + hostFingerprint, + notificationId: 'same', + notificationEpoch: 'new', + notificationSeq: 3 + } + } + } + }, + { + request: { + identifier: 'legacy', + content: { data: { hostFingerprint, notificationId: 'same' } } + } + }, + { + request: { + identifier: 'foreign', + content: { data: { hostFingerprint: 'other-host', notificationId: 'same' } } + } + } + ] as never) + await dismissHostPushNotification({ type: 'dismiss', notificationId: 'same' }, 'host-a') + expect(Notifications.dismissNotificationAsync).toHaveBeenCalledExactlyOnceWith('legacy') +}) diff --git a/mobile/src/notifications/push-socket-dismissal.ts b/mobile/src/notifications/push-socket-dismissal.ts new file mode 100644 index 00000000000..93226f296b2 --- /dev/null +++ b/mobile/src/notifications/push-socket-dismissal.ts @@ -0,0 +1,22 @@ +import { loadHostCatalog } from '../transport/host-store' +import { deriveHostFingerprint } from './push-host-fingerprint' +import { dismissPresentedPushNotification } from './push-tray-dismissal' +import type { DismissNotificationEvent } from './desktop-notification-events' + +async function hostFingerprint(hostId: string): Promise { + const hosts = await loadHostCatalog().catch(() => []) + const host = hosts.find((item) => item.id === hostId) + return host ? deriveHostFingerprint(host.publicKeyB64) : null +} + +export async function dismissHostPushNotification( + event: DismissNotificationEvent, + hostId: string +): Promise { + const fingerprint = await hostFingerprint(hostId) + if (!fingerprint) { + return + } + const fence = event.notificationEpoch && event.notificationSeq !== undefined ? event : undefined + await dismissPresentedPushNotification(event.notificationId, fingerprint, fence) +} diff --git a/mobile/src/notifications/push-token.test.ts b/mobile/src/notifications/push-token.test.ts new file mode 100644 index 00000000000..2a193430ac6 --- /dev/null +++ b/mobile/src/notifications/push-token.test.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as Notifications from 'expo-notifications' +import { addPushTokenListener, getDevicePushToken } from './push-token' + +vi.mock('expo-notifications', () => ({ + getDevicePushTokenAsync: vi.fn(), + addPushTokenListener: vi.fn() +})) + +const dev = globalThis as { __DEV__?: boolean } + +beforeEach(() => { + vi.clearAllMocks() +}) + +afterEach(() => { + delete dev.__DEV__ +}) + +describe('getDevicePushToken', () => { + it.each([ + [true, 'sandbox'], + [false, 'production'] + ])('reports apnsEnvironment for a __DEV__=%s iOS build as %s', async (isDev, environment) => { + dev.__DEV__ = isDev + vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ + type: 'ios', + data: 'a'.repeat(64) + } as never) + + await expect(getDevicePushToken()).resolves.toEqual({ + platform: 'ios', + token: 'a'.repeat(64), + apnsEnvironment: environment + }) + }) + + it('omits apnsEnvironment for Android, where FCM has no environment split', async () => { + vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ + type: 'android', + data: 'fcm-registration-token' + } as never) + + await expect(getDevicePushToken()).resolves.toEqual({ + platform: 'android', + token: 'fcm-registration-token' + }) + }) + + it.each([ + ['a web push subscription', { type: 'web', data: { endpoint: 'https://example.test' } }], + ['an empty token', { type: 'ios', data: '' }] + ])('returns null for %s', async (_label, raw) => { + vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue(raw as never) + + await expect(getDevicePushToken()).resolves.toBeNull() + }) + + it('returns null when the shell cannot mint a token at all', async () => { + vi.mocked(Notifications.getDevicePushTokenAsync).mockRejectedValue(new Error('no entitlement')) + + await expect(getDevicePushToken()).resolves.toBeNull() + }) +}) + +describe('addPushTokenListener', () => { + it('forwards a rolled native token and removes the subscription on teardown', () => { + const remove = vi.fn() + let emit: ((raw: unknown) => void) | null = null + vi.mocked(Notifications.addPushTokenListener).mockImplementation((listener) => { + emit = listener as (raw: unknown) => void + return { remove } as never + }) + const seen: unknown[] = [] + + const stop = addPushTokenListener((token) => seen.push(token)) + emit?.({ type: 'android', data: 'rolled' }) + emit?.({ type: 'web', data: {} }) + stop() + + expect(seen).toEqual([{ platform: 'android', token: 'rolled' }]) + expect(remove).toHaveBeenCalledTimes(1) + }) + + it('degrades to a no-op on a shell that cannot subscribe to token changes', () => { + vi.mocked(Notifications.addPushTokenListener).mockImplementation(() => { + throw new Error('no push support') + }) + + expect(() => addPushTokenListener(() => {})()).not.toThrow() + }) +}) diff --git a/mobile/src/notifications/push-token.ts b/mobile/src/notifications/push-token.ts new file mode 100644 index 00000000000..6c93bfb1506 --- /dev/null +++ b/mobile/src/notifications/push-token.ts @@ -0,0 +1,58 @@ +import * as Notifications from 'expo-notifications' +import type { + MobilePushApnsEnvironment, + MobilePushPlatform +} from '../../../src/shared/mobile-push-contract' + +// Why: the native APNs/FCM token, not an Expo push token — Orca's own gateway +// talks to Apple and Google directly, so it needs the raw device token. + +export type MobilePushToken = { + readonly platform: MobilePushPlatform + readonly token: string + readonly apnsEnvironment?: MobilePushApnsEnvironment +} + +// Dev-client builds are debug and get sandbox APNs; TestFlight and App Store are release. +function apnsEnvironment(): MobilePushApnsEnvironment { + return typeof __DEV__ !== 'undefined' && __DEV__ ? 'sandbox' : 'production' +} + +function toMobilePushToken(raw: { type: string; data: unknown }): MobilePushToken | null { + if (typeof raw.data !== 'string' || raw.data.length === 0) { + return null + } + if (raw.type === 'ios') { + return { platform: 'ios', token: raw.data, apnsEnvironment: apnsEnvironment() } + } + // Web tokens carry an object payload and no Orca gateway path; only native counts. + return raw.type === 'android' ? { platform: 'android', token: raw.data } : null +} + +/** + * The native push token, or null if registration is unavailable or fails. + */ +export async function getDevicePushToken(): Promise { + try { + return toMobilePushToken(await Notifications.getDevicePushTokenAsync()) + } catch { + return null + } +} + +/** Providers can roll a token while the app runs; the old one stops delivering. */ +export function addPushTokenListener(listener: (token: MobilePushToken) => void): () => void { + try { + const subscription = Notifications.addPushTokenListener((raw) => { + const token = toMobilePushToken(raw) + if (token) { + listener(token) + } + }) + return () => subscription.remove() + } catch { + // A shell with no push capability cannot subscribe; the caller is a root-level + // effect, so throwing here would take the whole app down over an optional feature. + return () => {} + } +} diff --git a/mobile/src/notifications/push-tray-dismissal.test.ts b/mobile/src/notifications/push-tray-dismissal.test.ts new file mode 100644 index 00000000000..5bea8f88f72 --- /dev/null +++ b/mobile/src/notifications/push-tray-dismissal.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as Notifications from 'expo-notifications' +import { dismissPresentedPushNotification } from './push-tray-dismissal' + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { getItem: vi.fn(async () => null), setItem: vi.fn(async () => {}) } +})) + +vi.mock('expo-notifications', () => ({ + getPresentedNotificationsAsync: vi.fn(), + dismissNotificationAsync: vi.fn() +})) + +function presented(identifier: string, data: unknown): unknown { + return { request: { identifier, content: { data } } } +} + +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined) +}) + +describe('dismissPresentedPushNotification', () => { + it('dismisses only the tray entries whose push payload carries the same notification id', async () => { + vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([ + presented('tray-1', { + orca: { hostFingerprint: 'fp0123456789abcd', notificationId: 'agent:one' } + }), + presented('tray-2', { + orca: { hostFingerprint: 'fp0123456789abcd', notificationId: 'agent:two' } + }), + presented('other-host', { hostFingerprint: 'another-host', notificationId: 'agent:one' }), + // Flat FCM shape for the same notification, presented on Android. + presented('tray-3', { hostFingerprint: 'fp0123456789abcd', notificationId: 'agent:one' }) + ] as never) + + await dismissPresentedPushNotification('agent:one', 'fp0123456789abcd') + + expect(vi.mocked(Notifications.dismissNotificationAsync).mock.calls.map(([id]) => id)).toEqual([ + 'tray-1', + 'tray-3' + ]) + }) + + it('ignores notifications without a gateway identity', async () => { + vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([ + presented('tray-1', { hostId: 'host-1', notificationId: 'agent:one' }) + ] as never) + + await dismissPresentedPushNotification('agent:one', 'fp0123456789abcd') + + expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalled() + }) + + it('reports tray query failures to the caller', async () => { + vi.mocked(Notifications.getPresentedNotificationsAsync).mockRejectedValue( + new Error('unavailable') + ) + + await expect(dismissPresentedPushNotification('agent:one', 'fp0123456789abcd')).rejects.toThrow( + 'unavailable' + ) + }) +}) + +it('a delayed dismissal preserves newer alerts, other epochs, and other hosts', async () => { + const base = { hostFingerprint: 'host-a', notificationId: 'note', notificationEpoch: 'epoch-a' } + vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([ + presented('older', { ...base, notificationSeq: 1 }), + presented('equal', { ...base, notificationSeq: 2 }), + presented('newer', { ...base, notificationSeq: 3 }), + presented('restarted', { ...base, notificationSeq: 1, notificationEpoch: 'epoch-b' }), + presented('other-host', { ...base, notificationSeq: 1, hostFingerprint: 'host-b' }), + presented('legacy', base) + ] as never) + await dismissPresentedPushNotification('note', 'host-a', { + notificationEpoch: 'epoch-a', + notificationSeq: 2 + }) + expect(vi.mocked(Notifications.dismissNotificationAsync).mock.calls.map(([id]) => id)).toEqual([ + 'older', + 'equal' + ]) +}) + +it.each([undefined, {}, { notificationEpoch: 'epoch' }, { notificationSeq: 2 }])( + 'an incomplete dismissal fence %j removes only unversioned entries', + async (fence) => { + const base = { hostFingerprint: 'host-a', notificationId: 'note' } + vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([ + presented('unversioned', base), + presented('versioned', { ...base, notificationEpoch: 'epoch', notificationSeq: 2 }), + presented('epoch-only', { ...base, notificationEpoch: 'epoch' }), + presented('sequence-only', { ...base, notificationSeq: 2 }) + ] as never) + await dismissPresentedPushNotification('note', 'host-a', fence) + expect(Notifications.dismissNotificationAsync).toHaveBeenCalledExactlyOnceWith('unversioned') + } +) diff --git a/mobile/src/notifications/push-tray-dismissal.ts b/mobile/src/notifications/push-tray-dismissal.ts new file mode 100644 index 00000000000..a810430709a --- /dev/null +++ b/mobile/src/notifications/push-tray-dismissal.ts @@ -0,0 +1,63 @@ +import { readNativeNotificationData } from './native-notification-data' +import * as Notifications from 'expo-notifications' +import { readOrcaPushPayload, type OrcaPushPayload } from './push-payload' +import { rememberPushDismissal, wasPushDismissed } from './push-dismissal-watermarks' + +async function dismissMatchingPresentedPushes( + matches: (payload: OrcaPushPayload) => boolean | Promise +): Promise { + const presented = await Notifications.getPresentedNotificationsAsync() + await Promise.all( + presented.map(async (notification) => { + const payload = readOrcaPushPayload(readNativeNotificationData(notification.request)) + if (payload && (await matches(payload))) { + await Notifications.dismissNotificationAsync(notification.request.identifier) + } + }) + ) +} + +export function dismissRememberedPushNotifications( + hostFingerprint: string, + confirmed: readonly OrcaPushPayload[] +): Promise { + return dismissMatchingPresentedPushes(async (payload) => { + if (payload.hostFingerprint !== hostFingerprint) { + return false + } + return ( + confirmed.some( + (fence) => + fence.notificationId === payload.notificationId && + fence.notificationEpoch === payload.notificationEpoch && + fence.notificationSeq !== undefined && + payload.notificationSeq !== undefined && + fence.notificationSeq >= payload.notificationSeq + ) || wasPushDismissed(payload) + ) + }) +} + +// Pushes shown while Orca was closed are absent from the local scheduling registry. +export async function dismissPresentedPushNotification( + notificationId: string, + hostFingerprint: string, + fence?: { notificationEpoch?: string; notificationSeq?: number } +): Promise { + if (fence) { + await rememberPushDismissal({ hostFingerprint, notificationId, ...fence }) + } + await dismissMatchingPresentedPushes((payload) => { + if (payload.hostFingerprint !== hostFingerprint) { + return false + } + return ( + payload.notificationId === notificationId && + (fence?.notificationEpoch && fence.notificationSeq !== undefined + ? payload.notificationEpoch === fence.notificationEpoch && + payload.notificationSeq !== undefined && + payload.notificationSeq <= fence.notificationSeq + : payload.notificationEpoch === undefined && payload.notificationSeq === undefined) + ) + }) +} diff --git a/mobile/src/notifications/use-remote-push-capable-hosts.test.tsx b/mobile/src/notifications/use-remote-push-capable-hosts.test.tsx new file mode 100644 index 00000000000..9b25ca3dcf9 --- /dev/null +++ b/mobile/src/notifications/use-remote-push-capable-hosts.test.tsx @@ -0,0 +1,196 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { loadHostCatalog } from '../transport/host-store' +import type { HostCatalogEntry } from '../transport/types' +import type { RpcClient } from '../transport/rpc-client' +import { startRuntimeCapabilityProbe } from '../transport/runtime-capability-probe' +import { useAllHostClients } from '../transport/use-all-host-clients' +import { + useRemotePushCapableHosts, + type RemotePushHostSupport +} from './use-remote-push-capable-hosts' + +vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn() })) +vi.mock('../transport/use-all-host-clients', () => ({ useAllHostClients: vi.fn() })) +vi.mock('../transport/runtime-capability-probe', () => ({ + startRuntimeCapabilityProbe: vi.fn() +})) + +// The real module reaches expo-notifications and the preference store for the token +// path; only the capability string matters here. +vi.mock('./push-registration', () => ({ + NOTIFICATIONS_REMOTE_PUSH_CAPABILITY: 'notifications.remote-push.v1' +})) + +const CAPABILITY = 'notifications.remote-push.v1' + +type ClientEntry = { hostId: string; client: RpcClient; state: string } + +/** Distinct object per host, so identity changes are the thing under test. */ +function clientFor(hostId: string): RpcClient { + return { hostId } as unknown as RpcClient +} + +let renderer: ReactTestRenderer | null = null +let latest: RemotePushHostSupport = { supported: false, resolved: false } +const answerByHostId = new Map void>() +const stopProbe = vi.fn() + +function Harness(): null { + latest = useRemotePushCapableHosts() + return null +} + +async function mount(): Promise { + await act(async () => { + renderer = create(createElement(Harness)) + await Promise.resolve() + }) +} + +async function setClients(entries: readonly ClientEntry[]): Promise { + vi.mocked(useAllHostClients).mockReturnValue(entries as never) + await act(async () => { + renderer?.update(createElement(Harness)) + await Promise.resolve() + }) +} + +async function answer(hostId: string, capabilities: readonly string[]): Promise { + await act(async () => { + answerByHostId.get(hostId)?.(capabilities) + await Promise.resolve() + }) +} + +beforeEach(() => { + vi.clearAllMocks() + answerByHostId.clear() + latest = { supported: false, resolved: false } + vi.mocked(useAllHostClients).mockReturnValue([] as never) + vi.mocked(startRuntimeCapabilityProbe).mockImplementation((client, onCapabilities) => { + answerByHostId.set((client as unknown as { hostId: string }).hostId, onCapabilities) + return stopProbe + }) + vi.mocked(loadHostCatalog).mockResolvedValue([ + { id: 'host-1', publicKeyB64: 'k1' }, + { id: 'host-2', publicKeyB64: 'k2' } + ] as unknown as HostCatalogEntry[]) +}) + +afterEach(() => { + act(() => renderer?.unmount()) + renderer = null +}) + +describe('useRemotePushCapableHosts', () => { + it('stays unresolved when the host catalog cannot be read', async () => { + vi.mocked(loadHostCatalog).mockRejectedValue(new Error('keychain locked')) + + await mount() + + // Resolving here would render "Update your desktop app" at someone whose desktop + // is already current, on the strength of a catalog read that simply failed. + expect(latest).toEqual({ supported: false, resolved: false }) + }) + + it('waits for every connected host before answering', async () => { + await mount() + await setClients([ + { hostId: 'host-1', client: clientFor('host-1'), state: 'connected' }, + { hostId: 'host-2', client: clientFor('host-2'), state: 'connected' } + ]) + + await answer('host-1', [CAPABILITY]) + expect(latest.resolved).toBe(false) + + await answer('host-2', ['some-other.v1']) + expect(latest).toEqual({ supported: true, resolved: true }) + }) + + it('keeps the answer of a host that has since disconnected', async () => { + await mount() + const client = clientFor('host-1') + await setClients([{ hostId: 'host-1', client, state: 'connected' }]) + await answer('host-1', [CAPABILITY]) + + await setClients([{ hostId: 'host-1', client, state: 'connecting' }]) + + expect(latest).toEqual({ supported: true, resolved: true }) + }) + + it('resolves immediately when nothing is paired', async () => { + vi.mocked(loadHostCatalog).mockResolvedValue([]) + + await mount() + + expect(latest).toEqual({ supported: false, resolved: true }) + }) + + it('rechecks a cached answer after disconnecting and reconnecting', async () => { + await mount() + const client = clientFor('host-1') + await setClients([{ hostId: 'host-1', client, state: 'connected' }]) + await answer('host-1', [CAPABILITY]) + await setClients([{ hostId: 'host-1', client, state: 'connecting' }]) + expect(latest).toEqual({ supported: true, resolved: true }) + await setClients([{ hostId: 'host-1', client, state: 'connected' }]) + expect(latest).toEqual({ supported: false, resolved: false }) + await answer('host-1', []) + expect(latest).toEqual({ supported: false, resolved: true }) + }) + + it('leaves a running probe alone when another host changes state', async () => { + await mount() + const first = clientFor('host-1') + await setClients([{ hostId: 'host-1', client: first, state: 'connected' }]) + expect(startRuntimeCapabilityProbe).toHaveBeenCalledTimes(1) + + // useAllHostClients rebuilds its array on every connection tick, so a plain + // dependency on it would tear down and restart host-1's probe here. + await setClients([ + { hostId: 'host-1', client: first, state: 'connected' }, + { hostId: 'host-2', client: clientFor('host-2'), state: 'connecting' } + ]) + await setClients([ + { hostId: 'host-1', client: first, state: 'connected' }, + { hostId: 'host-2', client: clientFor('host-2'), state: 'connected' } + ]) + + expect(stopProbe).not.toHaveBeenCalled() + expect( + vi.mocked(startRuntimeCapabilityProbe).mock.calls.map(([client]) => client) + ).toHaveLength(2) + }) + + it('restarts the probe when a reconnect replaces the host client', async () => { + await mount() + await setClients([{ hostId: 'host-1', client: clientFor('host-1'), state: 'connected' }]) + await answer('host-1', [CAPABILITY]) + expect(latest).toEqual({ supported: true, resolved: true }) + + await setClients([{ hostId: 'host-1', client: clientFor('host-1'), state: 'connected' }]) + + expect(latest).toEqual({ supported: false, resolved: false }) + expect(stopProbe).toHaveBeenCalledTimes(1) + expect(startRuntimeCapabilityProbe).toHaveBeenCalledTimes(2) + await answer('host-1', []) + expect(latest).toEqual({ supported: false, resolved: true }) + await answer('host-1', [CAPABILITY]) + expect(latest).toEqual({ supported: true, resolved: true }) + }) + + it('ignores an answer from a host the catalog no longer lists', async () => { + await mount() + await setClients([ + { hostId: 'host-ghost', client: clientFor('host-ghost'), state: 'connected' } + ]) + + await answer('host-ghost', [CAPABILITY]) + + // An unpaired desktop cannot push to this phone, so its vote must not offer + // the switch — nor count as the answer that resolves the section. + expect(latest).toEqual({ supported: false, resolved: false }) + }) +}) diff --git a/mobile/src/notifications/use-remote-push-capable-hosts.ts b/mobile/src/notifications/use-remote-push-capable-hosts.ts new file mode 100644 index 00000000000..243bbbc6ce8 --- /dev/null +++ b/mobile/src/notifications/use-remote-push-capable-hosts.ts @@ -0,0 +1,109 @@ +import { useEffect, useRef, useState } from 'react' +import { loadHostCatalog } from '../transport/host-store' +import type { RpcClient } from '../transport/rpc-client' +import { startRuntimeCapabilityProbe } from '../transport/runtime-capability-probe' +import { useAllHostClients } from '../transport/use-all-host-clients' +import { NOTIFICATIONS_REMOTE_PUSH_CAPABILITY } from './push-registration' + +export type RemotePushHostSupport = { + /** At least one paired host advertises `notifications.remote-push.v1`. */ + supported: boolean + /** Whether the answer above is final rather than "nobody has replied yet". */ + resolved: boolean +} + +/** + * Whether background push can be offered at all. The desktop advertises the + * capability in `status.get`, so the answer needs a connected host — until one + * replies the screen must stay silent rather than tell someone to update a + * desktop that is already current. + */ +export function useRemotePushCapableHosts(): RemotePushHostSupport { + const [hostIds, setHostIds] = useState([]) + const [hostsLoaded, setHostsLoaded] = useState(false) + const [supportedByHostId, setSupportedByHostId] = useState>({}) + const probesRef = useRef(new Map void }>()) + + useEffect(() => { + let cancelled = false + void loadHostCatalog() + .then((hosts) => { + if (!cancelled) { + setHostIds(hosts.map((host) => host.id)) + setHostsLoaded(true) + } + }) + // Why nothing on failure: an unread catalog marked loaded resolves the answer as + // "no paired host supports push", which tells the user to update a current desktop. + .catch(() => {}) + return () => { + cancelled = true + } + }, []) + + const clients = useAllHostClients(hostIds) + + // Why pruned rather than left: an answer for a host that is no longer paired is a + // vote from a desktop this phone cannot receive a push from. + useEffect(() => { + setSupportedByHostId((previous) => { + const kept = Object.entries(previous).filter(([hostId]) => hostIds.includes(hostId)) + return kept.length === Object.keys(previous).length ? previous : Object.fromEntries(kept) + }) + }, [hostIds]) + + // Why diffed by client identity rather than restarted on every `clients` value: + // useAllHostClients rebuilds the array on each connection tick, so a plain + // dependency tears down and re-runs every host's probe whenever any host moves. + useEffect(() => { + const connected = new Map( + clients + .filter((entry) => entry.state === 'connected') + .map((entry) => [entry.hostId, entry.client]) + ) + const probes = probesRef.current + for (const [hostId, probe] of probes) { + if (connected.get(hostId) !== probe.client) { + probe.stop() + probes.delete(hostId) + } + } + for (const [hostId, client] of connected) { + if (!probes.has(hostId)) { + setSupportedByHostId((previous) => { + const { [hostId]: _removed, ...remaining } = previous + return remaining + }) + const stop = startRuntimeCapabilityProbe(client, (capabilities) => { + setSupportedByHostId((previous) => ({ + ...previous, + [hostId]: capabilities.includes(NOTIFICATIONS_REMOTE_PUSH_CAPABILITY) + })) + }) + probes.set(hostId, { client, stop }) + } + } + }, [clients]) + + useEffect(() => { + const probes = probesRef.current + return () => { + for (const probe of probes.values()) { + probe.stop() + } + probes.clear() + } + }, []) + + const answeredHostIds = hostIds.filter((hostId) => hostId in supportedByHostId) + return { + supported: answeredHostIds.some((hostId) => supportedByHostId[hostId]), + // A connected host that has not answered yet is exactly the case the silence is + // for, so one outstanding probe holds the whole section back. Disconnected hosts + // do not: their earlier answer stands, and one that never answered never will. + resolved: + (hostsLoaded && hostIds.length === 0) || + (answeredHostIds.length > 0 && + clients.every((entry) => entry.state !== 'connected' || entry.hostId in supportedByHostId)) + } +} diff --git a/mobile/src/onboarding/MobileOnboardingPage.tsx b/mobile/src/onboarding/MobileOnboardingPage.tsx index a5a6a07a3c6..65a19b57889 100644 --- a/mobile/src/onboarding/MobileOnboardingPage.tsx +++ b/mobile/src/onboarding/MobileOnboardingPage.tsx @@ -47,16 +47,26 @@ export function MobileOnboardingPage({ )} - {isSessionView ? 'How should sessions open?' : 'Stay updated while away'} + {isSessionView ? 'How should sessions open?' : 'Enable notifications'} {isSessionView ? 'Choose whether supported agent sessions open in the terminal or Chat UI on this device. Press and hold a session tab to switch its view, or change the default later in Settings.' - : 'Get notified on this device when an agent needs your input or finishes a task.'} + : 'Get notified when an agent finishes a task or needs your input.'} + {!isSessionView ? ( + + By default, notifications arrive after your desktop has been idle for 3 minutes. + + ) : null} + {!isSessionView ? ( + + Delivered through Orca’s push service. Change this anytime in Settings. + + ) : null} {error ? ( {error} diff --git a/mobile/src/onboarding/mobile-onboarding-screen.test.ts b/mobile/src/onboarding/mobile-onboarding-screen.test.ts index 05dcc6b9ad8..efa2f19d5ac 100644 --- a/mobile/src/onboarding/mobile-onboarding-screen.test.ts +++ b/mobile/src/onboarding/mobile-onboarding-screen.test.ts @@ -10,7 +10,7 @@ const mocks = vi.hoisted(() => ({ animatedTiming: vi.fn(), ensureNotificationPermissions: vi.fn(), saveDefaultSessionView: vi.fn(), - savePushNotificationsEnabled: vi.fn() + setRemotePushEnabled: vi.fn() })) vi.mock('react-native', () => ({ @@ -48,8 +48,8 @@ vi.mock('../notifications/mobile-notifications', () => ({ vi.mock('../storage/session-view-preferences', () => ({ saveDefaultSessionView: mocks.saveDefaultSessionView })) -vi.mock('../storage/preferences', () => ({ - savePushNotificationsEnabled: mocks.savePushNotificationsEnabled +vi.mock('../notifications/push-registration', () => ({ + setRemotePushEnabled: mocks.setRemotePushEnabled })) describe('MobileOnboardingScreen', () => { @@ -64,7 +64,7 @@ describe('MobileOnboardingScreen', () => { }) mocks.ensureNotificationPermissions.mockReset().mockResolvedValue(true) mocks.saveDefaultSessionView.mockReset().mockResolvedValue(undefined) - mocks.savePushNotificationsEnabled.mockReset().mockResolvedValue(undefined) + mocks.setRemotePushEnabled.mockReset().mockResolvedValue(undefined) }) afterEach(() => { @@ -100,7 +100,32 @@ describe('MobileOnboardingScreen', () => { await act(async () => pages()[1].props.onNotificationChoice('skip')) expect(mocks.ensureNotificationPermissions).not.toHaveBeenCalled() - expect(mocks.savePushNotificationsEnabled).toHaveBeenCalledWith(false) + expect(mocks.setRemotePushEnabled).toHaveBeenCalledWith(false) + expect(mocks.replace).toHaveBeenCalledWith('/h/paired-host') + }) + + it.each([true, false])( + 'saves permission result %s through the consent owner once', + async (granted) => { + mocks.params = { hostId: 'paired-host', steps: 'notifications' } + mocks.ensureNotificationPermissions.mockResolvedValue(granted) + await renderScreen() + await act(async () => pages()[0].props.onNotificationChoice('enable')) + expect(mocks.setRemotePushEnabled).toHaveBeenCalledExactlyOnceWith(granted) + expect(mocks.replace).toHaveBeenCalledWith('/h/paired-host') + } + ) + + it('keeps notification consent retryable when its local write fails', async () => { + mocks.params = { hostId: 'paired-host', steps: 'notifications' } + mocks.setRemotePushEnabled.mockRejectedValueOnce(new Error('disk full')) + await renderScreen() + await act(async () => pages()[0].props.onNotificationChoice('enable')) + expect(pages()[0].props.error).toBe('Notification settings could not be updated. Try again.') + expect(mocks.replace).not.toHaveBeenCalled() + await act(async () => pages()[0].props.onNotificationChoice('enable')) + expect(mocks.setRemotePushEnabled).toHaveBeenCalledTimes(2) + expect(mocks.setRemotePushEnabled).toHaveBeenLastCalledWith(true) expect(mocks.replace).toHaveBeenCalledWith('/h/paired-host') }) diff --git a/mobile/src/onboarding/mobile-onboarding-styles.ts b/mobile/src/onboarding/mobile-onboarding-styles.ts index 20f36ec0f5b..3f03bb24b95 100644 --- a/mobile/src/onboarding/mobile-onboarding-styles.ts +++ b/mobile/src/onboarding/mobile-onboarding-styles.ts @@ -90,6 +90,13 @@ export const mobileOnboardingStyles = StyleSheet.create({ alignSelf: 'center', paddingBottom: spacing.lg }, + disclosure: { + color: colors.textSecondary, + fontSize: typography.metaSize, + lineHeight: 18, + textAlign: 'center', + marginBottom: spacing.lg + }, primaryButton: { minHeight: 44, alignItems: 'center', diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts index df8aa1dd904..76435561664 100644 --- a/mobile/src/session/mobile-session-route-parity.test.ts +++ b/mobile/src/session/mobile-session-route-parity.test.ts @@ -62,8 +62,8 @@ const HOST_COMPONENT_NAMES = new Set([ 'View' ]) -const HEAD_MAIN_HOOK_SHA256 = '10071240ef9edafc2b9c8bed73be83dceaf7828e3b29f17dab55da020a7697a6' -const HEAD_HOOK_BINDING_SHA256 = '1dadb8c3dc0573ea20659ce7251629669e618dd0effaeac3a4536b29c2e865a1' +const HEAD_MAIN_HOOK_SHA256 = 'c3e33699e3e3fa7e24408f3d4946fcc451e9b9419442d985c4ccde01782e5114' +const HEAD_HOOK_BINDING_SHA256 = '7f907e028893721d662eeee0aa9002ad1e00359948f39fb148d274596cd9b3c0' const HEAD_CALLBACK_IDENTITY_SHA256 = '2a9e4825df007f6ef53b81aa5004991d6318eee7507b44d625c07e630be432eb' const HEAD_CALLBACK_BODY_SHA256 = 'af7f3c62954250d4be7ee432ecd10dc2689792aad8230fed2d1d68bbc892d776' @@ -472,7 +472,7 @@ describe('mobile session route extraction parity', () => { const contentBindings = CONTENT_COMPONENT_NAMES.flatMap( (name) => readHookFacts(name, definitions).bindings ) - expect(main.hooks).toHaveLength(266) + expect(main.hooks).toHaveLength(267) expect(hash(main.hooks)).toBe(HEAD_MAIN_HOOK_SHA256) expect(hash(main.bindings)).toBe(HEAD_HOOK_BINDING_SHA256) expect(main.callbacks).toHaveLength(77) diff --git a/mobile/src/session/mobile-session-route.ts b/mobile/src/session/mobile-session-route.ts index 66f5c66f76f..b9ec89296c7 100644 --- a/mobile/src/session/mobile-session-route.ts +++ b/mobile/src/session/mobile-session-route.ts @@ -3,6 +3,7 @@ import type { HostStackRouteTarget } from '../navigation/host-stack-navigation' export type MobileSessionRouteParams = { hostId: string worktreeId: string + paneKey?: string name?: string } @@ -11,10 +12,11 @@ export type MobileSessionRouteParams = { export function mobileSessionRouteTarget({ hostId, worktreeId, - name + name, + paneKey }: MobileSessionRouteParams): HostStackRouteTarget { return { name: '[hostId]/session/[worktreeId]', - params: name ? { hostId, worktreeId, name } : { hostId, worktreeId } + params: { hostId, worktreeId, ...(name ? { name } : {}), ...(paneKey ? { paneKey } : {}) } } } diff --git a/mobile/src/session/use-mobile-session-controller.ts b/mobile/src/session/use-mobile-session-controller.ts index f188b30b17a..9847a5a5065 100644 --- a/mobile/src/session/use-mobile-session-controller.ts +++ b/mobile/src/session/use-mobile-session-controller.ts @@ -1,3 +1,4 @@ +import { useNotificationPaneNavigation } from './use-notification-pane-navigation' import { useMobileSessionFoundation } from './use-mobile-session-foundation' import { useMobileSessionScreenState } from './use-mobile-session-screen-state' import { useMobileSessionTerminalRuntime } from './use-mobile-session-terminal-runtime' @@ -82,6 +83,7 @@ export function useMobileSessionController() { useMobileSessionStartup(keyboardState) useMobileSessionPreferenceFocus(keyboardState) const tabSwitching = Object.assign(keyboardState, useMobileSessionTabSwitching(keyboardState)) + useNotificationPaneNavigation(tabSwitching) const terminalWebview = Object.assign(tabSwitching, useMobileSessionTerminalWebview(tabSwitching)) const terminalSendActions = Object.assign( terminalWebview, diff --git a/mobile/src/session/use-notification-pane-navigation.test.tsx b/mobile/src/session/use-notification-pane-navigation.test.tsx new file mode 100644 index 00000000000..1c3af0fdc95 --- /dev/null +++ b/mobile/src/session/use-notification-pane-navigation.test.tsx @@ -0,0 +1,67 @@ +import { createElement } from 'react' +import { act, create } from 'react-test-renderer' +import { expect, it, vi } from 'vitest' +import { + notificationPaneTab, + useNotificationPaneNavigation +} from './use-notification-pane-navigation' +import type { MobileSessionTab } from './mobile-session-route-types' +const route = vi.hoisted(() => ({ paneKey: '', setParams: vi.fn() })) +vi.mock('expo-router', () => ({ + useLocalSearchParams: () => ({ paneKey: route.paneKey }), + useRouter: () => ({ setParams: route.setParams }) +})) +const leaf = '11111111-1111-4111-8111-111111111111' +const tabs: MobileSessionTab[] = [ + { + type: 'terminal', + id: 'first', + parentTabId: 'tab-a', + leafId: leaf, + title: 'first', + terminal: 'pty-a', + isActive: true + }, + { + type: 'terminal', + id: 'second', + parentTabId: 'tab-b', + leafId: leaf, + title: 'agent', + terminal: 'pty-b', + isActive: false + } +] +it('selects the originating split pane, not the first tab; closed and invalid panes fall back', () => { + expect(notificationPaneTab(tabs, `tab-b:${leaf}`)).toBe(tabs[1]) + expect(notificationPaneTab(tabs, `closed:${leaf}`)).toBeUndefined() + expect(notificationPaneTab(tabs, 'invalid')).toBeUndefined() +}) +it('waits for tabs, switches through the existing action, and consumes the navigation request', async () => { + route.paneKey = `tab-b:${leaf}` + const switchSessionTab = vi.fn() + function Probe({ loaded }: { loaded: boolean }) { + useNotificationPaneNavigation({ + sessionTabs: loaded ? tabs : [], + terminalsLoaded: loaded, + switchSessionTab + }) + return null + } + let renderer: ReturnType + await act(async () => { + renderer = create(createElement(Probe, { loaded: false })) + }) + expect(switchSessionTab).not.toHaveBeenCalled() + await act(async () => { + renderer.update(createElement(Probe, { loaded: true })) + }) + expect(switchSessionTab).toHaveBeenCalledExactlyOnceWith(tabs[1]) + expect(route.setParams).toHaveBeenCalledWith({ paneKey: '' }) + route.paneKey = '' + await act(async () => { + renderer.update(createElement(Probe, { loaded: true })) + }) + expect(switchSessionTab).toHaveBeenCalledOnce() + await act(async () => renderer.unmount()) +}) diff --git a/mobile/src/session/use-notification-pane-navigation.ts b/mobile/src/session/use-notification-pane-navigation.ts new file mode 100644 index 00000000000..f6d3bb15cd4 --- /dev/null +++ b/mobile/src/session/use-notification-pane-navigation.ts @@ -0,0 +1,40 @@ +import { useEffect } from 'react' +import { useLocalSearchParams, useRouter } from 'expo-router' +import { parsePaneKey } from '../../../src/shared/stable-pane-id' +import type { MobileSessionTab } from './mobile-session-route-types' + +export function notificationPaneTab(tabs: readonly MobileSessionTab[], paneKey: string) { + const pane = parsePaneKey(paneKey) + if (!pane) { + return undefined + } + return tabs.find((tab) => + tab.type === 'terminal' + ? (tab.parentTabId ?? tab.id) === pane.tabId && tab.leafId === pane.leafId + : tab.type === 'agent-session' && tab.id === pane.tabId + ) +} + +export function useNotificationPaneNavigation({ + sessionTabs, + terminalsLoaded, + switchSessionTab +}: { + sessionTabs: MobileSessionTab[] + terminalsLoaded: boolean + switchSessionTab: (tab: MobileSessionTab) => void +}) { + const { paneKey } = useLocalSearchParams<{ paneKey?: string }>() + const router = useRouter() + useEffect(() => { + if (!terminalsLoaded || typeof paneKey !== 'string' || !paneKey) { + return + } + const tab = notificationPaneTab(sessionTabs, paneKey) + // Consume the tap even if the pane was closed; later snapshots must not steal selection. + router.setParams({ paneKey: '' }) + if (tab) { + switchSessionTab(tab) + } + }, [paneKey, terminalsLoaded, sessionTabs, switchSessionTab, router]) +} diff --git a/mobile/src/settings/native-notification-delivery-settings.test.tsx b/mobile/src/settings/native-notification-delivery-settings.test.tsx new file mode 100644 index 00000000000..bf94b75649e --- /dev/null +++ b/mobile/src/settings/native-notification-delivery-settings.test.tsx @@ -0,0 +1,151 @@ +import { createElement, useEffect } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { NativeNotificationDeliverySettings } from './native-notification-delivery-settings' + +const mocks = vi.hoisted(() => ({ + load: vi.fn(), + save: vi.fn(), + support: { resolved: true, supported: false }, + appState: null as null | ((state: string) => void) +})) +vi.mock('react-native', () => ({ + Text: 'Text', + AppState: { + addEventListener: (_event: string, callback: (state: string) => void) => { + mocks.appState = callback + return { remove() {} } + } + } +})) +vi.mock('expo-router', () => ({ + useFocusEffect: (callback: () => void) => useEffect(callback, [callback]) +})) +vi.mock('../notifications/NotificationDeliverySection', () => ({ + NotificationDeliverySection: 'Delivery' +})) +vi.mock('../notifications/notification-delivery-preferences', () => ({ + DEFAULT_NOTIFICATION_DELIVERY: { + onlyWhenDesktopAway: true, + sound: true, + suppressWhileViewing: true + }, + loadNotificationDeliveryPreferences: mocks.load +})) +vi.mock('../notifications/push-registration', () => ({ + setNotificationDeliveryPreferences: mocks.save +})) +vi.mock('../notifications/use-remote-push-capable-hosts', () => ({ + useRemotePushCapableHosts: () => mocks.support +})) +let renderer: ReactTestRenderer +const preferences = { onlyWhenDesktopAway: false, sound: false, suppressWhileViewing: true } +beforeEach(() => { + mocks.load.mockReset().mockResolvedValue(preferences) + mocks.save.mockReset().mockResolvedValue(undefined) + mocks.support = { resolved: true, supported: false } +}) +afterEach(() => { + act(() => renderer?.unmount()) +}) +const section = () => renderer.root.findByType('Delivery').props +it('keeps stored controls visible but disabled without consent and explains an old host', async () => { + await act(async () => { + renderer = create(createElement(NativeNotificationDeliverySettings, { enabled: false })) + }) + expect(section().value).toEqual(preferences) + expect(section().disabled).toBe(true) + expect(JSON.stringify(renderer.toJSON())).toContain('Pair an updated desktop') + await act(async () => { + renderer.update(createElement(NativeNotificationDeliverySettings, { enabled: true })) + }) + expect(section().disabled).toBe(false) +}) +it('disables edits until preferences load, then waits for save and retains the prior value on failure', async () => { + let load!: (value: typeof preferences) => void + mocks.load.mockReturnValue( + new Promise((resolve) => { + load = resolve + }) + ) + await act(async () => { + renderer = create(createElement(NativeNotificationDeliverySettings, { enabled: true })) + }) + expect(section().disabled).toBe(true) + await act(async () => { + load(preferences) + }) + let reject!: (error: Error) => void + mocks.save.mockReturnValue( + new Promise((_resolve, fail) => { + reject = fail + }) + ) + await act(async () => { + section().onChange({ ...preferences, sound: true }) + }) + expect(section().disabled).toBe(true) + await act(async () => { + reject(new Error('storage unavailable')) + }) + expect(section().value).toEqual(preferences) + expect(section().disabled).toBe(false) + expect(JSON.stringify(renderer.toJSON())).toContain('Could not save delivery settings') +}) +it('does not claim an upgrade is needed while probing or when a host supports push', async () => { + mocks.support = { resolved: false, supported: false } + await act(async () => { + renderer = create(createElement(NativeNotificationDeliverySettings, { enabled: true })) + }) + expect(JSON.stringify(renderer.toJSON())).not.toContain('Pair an updated desktop') + mocks.support = { resolved: true, supported: true } + await act(async () => { + renderer.update(createElement(NativeNotificationDeliverySettings, { enabled: true })) + }) + expect(JSON.stringify(renderer.toJSON())).not.toContain('Pair an updated desktop') +}) + +it.each(['resolve', 'reject'])('ignores a pre-save refresh that later %ss', async (outcome) => { + await act(async () => { + renderer = create(createElement(NativeNotificationDeliverySettings, { enabled: true })) + }) + let resolve!: (value: typeof preferences) => void + let reject!: (error: Error) => void + mocks.load.mockReturnValue( + new Promise((ok, fail) => { + resolve = ok + reject = fail + }) + ) + await act(async () => mocks.appState!('active')) + const saved = { ...preferences, sound: true } + await act(async () => section().onChange(saved)) + await act(async () => { + if (outcome === 'resolve') { + resolve(preferences) + } else { + reject(new Error('old read failed')) + } + }) + expect(section().value).toEqual(saved) + expect(JSON.stringify(renderer.toJSON())).not.toContain('Could not load') + await act(async () => section().onChange({ ...section().value, suppressWhileViewing: false })) + expect(mocks.save).toHaveBeenLastCalledWith({ ...saved, suppressWhileViewing: false }) +}) + +it('does not refresh while a save is in flight', async () => { + await act(async () => { + renderer = create(createElement(NativeNotificationDeliverySettings, { enabled: true })) + }) + let finish!: () => void + mocks.save.mockReturnValue( + new Promise((resolve) => { + finish = resolve + }) + ) + await act(async () => section().onChange({ ...preferences, sound: true })) + await act(async () => mocks.appState!('active')) + expect(mocks.load).toHaveBeenCalledTimes(1) + await act(async () => finish()) + expect(section().value.sound).toBe(true) +}) diff --git a/mobile/src/settings/native-notification-delivery-settings.tsx b/mobile/src/settings/native-notification-delivery-settings.tsx new file mode 100644 index 00000000000..109e28c6c69 --- /dev/null +++ b/mobile/src/settings/native-notification-delivery-settings.tsx @@ -0,0 +1,97 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { AppState, Text } from 'react-native' +import { useFocusEffect } from 'expo-router' +import { NotificationDeliverySection } from '../notifications/NotificationDeliverySection' +import { + DEFAULT_NOTIFICATION_DELIVERY, + loadNotificationDeliveryPreferences, + type NotificationDeliveryPreferences +} from '../notifications/notification-delivery-preferences' +import { setNotificationDeliveryPreferences } from '../notifications/push-registration' +import { useRemotePushCapableHosts } from '../notifications/use-remote-push-capable-hosts' +import { colors, spacing, typography } from '../theme/mobile-theme' + +export function NativeNotificationDeliverySettings({ enabled }: { enabled: boolean }) { + const [delivery, setDelivery] = useState(DEFAULT_NOTIFICATION_DELIVERY) + const [loaded, setLoaded] = useState(false) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const refreshRevision = useRef(0) + const saveInProgress = useRef(false) + const support = useRemotePushCapableHosts() + const refresh = useCallback(async () => { + if (saveInProgress.current) { + return + } + const revision = ++refreshRevision.current + try { + const value = await loadNotificationDeliveryPreferences() + if (revision !== refreshRevision.current) { + return + } + setDelivery(value) + setLoaded(true) + setError(null) + } catch { + if (revision !== refreshRevision.current) { + return + } + setError('Could not load delivery settings. Reopen this screen to retry.') + } + }, []) + useFocusEffect( + useCallback(() => { + void refresh() + }, [refresh]) + ) + useEffect(() => { + const subscription = AppState.addEventListener('change', (state) => { + if (state === 'active') { + void refresh() + } + }) + return () => subscription.remove() + }, [refresh]) + const change = async (value: NotificationDeliveryPreferences) => { + if (saveInProgress.current) { + return + } + saveInProgress.current = true + refreshRevision.current += 1 + setSaving(true) + setError(null) + try { + await setNotificationDeliveryPreferences(value) + setDelivery(value) + } catch { + setError('Could not save delivery settings. Try again.') + } finally { + saveInProgress.current = false + setSaving(false) + } + } + const hintStyle = { + color: colors.textMuted, + fontSize: typography.metaSize, + marginTop: spacing.md + } + return ( + <> + void change(value)} + /> + {error && ( + + {error} + + )} + {support.resolved && !support.supported && ( + + Pair an updated desktop to receive notifications on this phone. + + )} + + ) +} diff --git a/mobile/src/settings/native-notification-settings-operations.ts b/mobile/src/settings/native-notification-settings-operations.ts index cd5da9584bf..4817a77c8a7 100644 --- a/mobile/src/settings/native-notification-settings-operations.ts +++ b/mobile/src/settings/native-notification-settings-operations.ts @@ -3,7 +3,8 @@ import { ensureNotificationPermissions, getNotificationPermissionState } from '../notifications/notification-permissions' -import { loadPushNotificationsEnabled, savePushNotificationsEnabled } from '../storage/preferences' +import { loadPushNotificationsEnabled } from '../storage/preferences' +import { setRemotePushEnabled } from '../notifications/push-registration' import type { NotificationSettingsOperations } from './notification-settings-operations' export const nativeNotificationSettingsOperations: NotificationSettingsOperations = { @@ -15,7 +16,7 @@ export const nativeNotificationSettingsOperations: NotificationSettingsOperation }, async preference(enabled) { if (enabled !== undefined) { - await savePushNotificationsEnabled(enabled) + await setRemotePushEnabled(enabled) } return { enabled: await loadPushNotificationsEnabled() } }, diff --git a/mobile/src/settings/notification-display-test.test.tsx b/mobile/src/settings/notification-display-test.test.tsx new file mode 100644 index 00000000000..ea5128c7fb5 --- /dev/null +++ b/mobile/src/settings/notification-display-test.test.tsx @@ -0,0 +1,88 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { NotificationDisplayTest } from './notification-display-test' + +const mocks = vi.hoisted(() => ({ + loadHosts: vi.fn(), + clients: [] as { state: string; client: { sendRequest: ReturnType } }[] +})) +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + Text: 'Text', + View: 'View', + StyleSheet: { create: (value: unknown) => value, absoluteFillObject: {} } +})) +vi.mock('../transport/host-store', () => ({ loadHostCatalog: mocks.loadHosts })) +vi.mock('../transport/use-all-host-clients', () => ({ useAllHostClients: () => mocks.clients })) + +let renderer: ReactTestRenderer +beforeEach(() => { + mocks.loadHosts.mockReset().mockResolvedValue([{ id: 'first' }, { id: 'second' }]) + mocks.clients = [] +}) +afterEach(() => act(() => renderer?.unmount())) + +async function send() { + await act(async () => { + renderer = create(createElement(NotificationDisplayTest, { onTroubleshoot: vi.fn() })) + }) + await act(async () => renderer.root.findAllByType('Pressable')[0].props.onPress()) +} + +it.each([ + { ok: false, error: { code: 'method_not_found' } }, + { ok: false, error: { code: 'forbidden' } }, + { ok: true, result: { accepted: false, reason: 'not_registered' } } +])('tries the next desktop after a definitive non-delivery response %j', async (response) => { + const first = vi.fn().mockResolvedValue(response) + const second = vi.fn().mockResolvedValue({ ok: true, result: { accepted: true } }) + const third = vi.fn() + mocks.clients = [first, second, third].map((sendRequest) => ({ + state: 'connected', + client: { sendRequest } + })) + await send() + expect(second).toHaveBeenCalledExactlyOnceWith('notifications.testPush', null, { + timeoutMs: 20000, + failWhenDisconnected: true + }) + expect(third).not.toHaveBeenCalled() + expect(JSON.stringify(renderer.toJSON())).toContain('Accepted by Orca’s push service') +}) + +it('does not try another desktop after an uncertain transport failure', async () => { + const second = vi.fn() + mocks.clients = [ + { + state: 'connected', + client: { sendRequest: vi.fn().mockRejectedValue(new Error('timeout')) } + }, + { state: 'connected', client: { sendRequest: second } } + ] + await send() + expect(second).not.toHaveBeenCalled() + expect(JSON.stringify(renderer.toJSON())).toContain('timeout') +}) + +it('explains when every desktop needs registration or an update', async () => { + mocks.clients = [ + { ok: true, result: { accepted: false, reason: 'not_registered' } }, + { ok: false, error: { code: 'method_not_found' } } + ].map((response) => ({ + state: 'connected', + client: { sendRequest: vi.fn().mockResolvedValue(response) } + })) + await send() + expect(JSON.stringify(renderer.toJSON())).toContain('Reconnect to register this phone') +}) + +it.each([false, true])('explains missing pairing or connection (paired=%s)', async (paired) => { + if (!paired) { + mocks.loadHosts.mockResolvedValue([]) + } + await send() + expect(JSON.stringify(renderer.toJSON())).toContain( + paired ? 'Connect a desktop' : 'Pair a desktop' + ) +}) diff --git a/mobile/src/settings/notification-display-test.tsx b/mobile/src/settings/notification-display-test.tsx new file mode 100644 index 00000000000..f00c0af5625 --- /dev/null +++ b/mobile/src/settings/notification-display-test.tsx @@ -0,0 +1,125 @@ +import { useEffect, useRef, useState } from 'react' +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { useAllHostClients } from '../transport/use-all-host-clients' +import { loadHostCatalog } from '../transport/host-store' +import type { MobilePushTestResult } from '../../../src/shared/mobile-push-contract' +import { colors, spacing, typography } from '../theme/mobile-theme' + +export function NotificationDisplayTest({ onTroubleshoot }: { onTroubleshoot: () => void }) { + const busy = useRef(false) + const [hostIds, setHostIds] = useState([]) + const [sending, setSending] = useState(false) + const [message, setMessage] = useState(null) + const clients = useAllHostClients(hostIds) + useEffect(() => { + void loadHostCatalog() + .then((hosts) => setHostIds(hosts.map((host) => host.id))) + .catch(() => setMessage('Could not load paired desktops.')) + }, []) + const run = async () => { + if (busy.current) { + return + } + busy.current = true + setSending(true) + setMessage(null) + try { + if (hostIds.length === 0) { + throw new Error('Pair a desktop and try again.') + } + const connected = clients.filter((entry) => entry.state === 'connected') + if (connected.length === 0) { + throw new Error('Connect a desktop and try again.') + } + let unavailable = 'Update your desktop to run this test.' + for (const { client } of connected) { + const response = await client.sendRequest('notifications.testPush', null, { + timeoutMs: 20000, + failWhenDisconnected: true + }) + if (!response.ok) { + const code = response.error?.code + if (code === 'forbidden' || code === 'method_not_found') { + continue + } + throw new Error('Could not reach the desktop. Try again.') + } + const result = response.result as MobilePushTestResult + if (result?.accepted) { + setMessage('Accepted by Orca’s push service. Check for the notification.') + return + } + if (result?.reason === 'not_registered') { + unavailable = 'Reconnect to register this phone for notifications.' + continue + } + throw new Error( + result?.reason === 'rate_limited' + ? 'Too many notifications. Try again later.' + : 'Could not send through Orca’s push service. Try again.' + ) + } + throw new Error(unavailable) + } catch (error) { + setMessage(error instanceof Error ? error.message : 'Could not send push test.') + } finally { + busy.current = false + setSending(false) + } + } + return ( + + Having trouble receiving alerts? + Send a test through Orca’s push service. + [styles.button, pressed && styles.pressed]} + onPress={() => void run()} + > + + + Send test notification + + + + {sending ? 'Sending…' : 'Send test notification'} + + + + + + Troubleshooting + + {message && ( + + {message} + + )} + + ) +} +const styles = StyleSheet.create({ + container: { marginTop: spacing.xl, gap: spacing.sm }, + label: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' }, + detail: { color: colors.textMuted, fontSize: typography.metaSize, lineHeight: 18 }, + button: { + alignSelf: 'flex-start', + backgroundColor: colors.bgRaised, + borderRadius: 8, + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md + }, + sizingLabel: { opacity: 0 }, + buttonLabel: { ...StyleSheet.absoluteFillObject, alignItems: 'center', justifyContent: 'center' }, + troubleshootLink: { alignSelf: 'flex-start', paddingVertical: spacing.sm }, + linkText: { + color: colors.textSecondary, + fontSize: typography.metaSize, + textDecorationLine: 'underline' + }, + pressed: { opacity: 0.6 }, + buttonText: { color: colors.textPrimary, fontSize: typography.metaSize, fontWeight: '600' } +}) diff --git a/mobile/src/settings/notification-settings-screen.tsx b/mobile/src/settings/notification-settings-screen.tsx index 7da9be8a813..a0b8251a620 100644 --- a/mobile/src/settings/notification-settings-screen.tsx +++ b/mobile/src/settings/notification-settings-screen.tsx @@ -1,5 +1,5 @@ -import { useState, useCallback, useEffect } from 'react' -import { AppState, View, Text, StyleSheet, Pressable, Switch } from 'react-native' +import { useState, useCallback, useEffect, type ReactNode } from 'react' +import { AppState, View, Text, StyleSheet, Pressable, Switch, ScrollView } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useFocusEffect } from 'expo-router' import type { NotificationSettingsOperations } from './notification-settings-operations' @@ -16,12 +16,17 @@ const DEFAULT_PERMISSION_STATE: NotificationPermissionState = { export default function NotificationsScreen({ operations, - onBack + onBack, + description, + children }: { operations: NotificationSettingsOperations onBack: () => void + description?: string + children?: (enabled: boolean) => ReactNode }) { const insets = useSafeAreaInsets() + const [saving, setSaving] = useState(false) const [error, setError] = useState(null) const [pushEnabled, setPushEnabled] = useState(false) const [permissionState, setPermissionState] = useState(DEFAULT_PERMISSION_STATE) @@ -57,6 +62,7 @@ export default function NotificationsScreen({ const togglePush = async (value: boolean) => { setError(null) + setSaving(true) try { const permission = await operations.permission(value) setPermissionState(permission) @@ -64,6 +70,8 @@ export default function NotificationsScreen({ setPushEnabled(saved.enabled) } catch { setError('Could not save notification settings. Try again.') + } finally { + setSaving(false) } } @@ -71,10 +79,17 @@ export default function NotificationsScreen({ const notificationsBlocked = permissionState.status === 'denied' const hint = notificationsBlocked ? 'Notifications are disabled in system settings.' - : 'Get notified on this device when an agent needs your input or finishes a task.' + : (description ?? + 'Get notified on this device when an agent needs your input or finishes a task.') return ( - + - Agent notifications + Enable notifications void togglePush(v)} trackColor={{ false: colors.bgRaised, true: colors.textSecondary }} thumbColor={colors.textPrimary} @@ -123,7 +138,8 @@ export default function NotificationsScreen({ )} - + {children?.(switchEnabled && !saving)} + ) } diff --git a/mobile/src/storage/preferences.test.ts b/mobile/src/storage/preferences.test.ts index b636ea12d3e..c8cfb54863a 100644 --- a/mobile/src/storage/preferences.test.ts +++ b/mobile/src/storage/preferences.test.ts @@ -278,8 +278,18 @@ describe('push notification preference', () => { vi.mocked(AsyncStorage.setItem).mockReset() }) + it.each(['true', 'false'])('requires fresh consent for legacy choice %s', async (legacy) => { + vi.mocked(AsyncStorage.getItem).mockImplementation(async (key) => + key === 'orca:pushNotificationsEnabled' ? legacy : null + ) + await expect(readPushNotificationsPreference()).resolves.toEqual({ value: null, loaded: true }) + await expect(loadPushNotificationsEnabled()).resolves.toBe(false) + }) + it('distinguishes an unset preference from an explicit disabled choice', async () => { - vi.mocked(AsyncStorage.getItem).mockResolvedValue(null) + vi.mocked(AsyncStorage.getItem).mockImplementation(async (key) => + key === 'orca:remotePushEnabled' ? 'true' : null + ) await expect(readPushNotificationsPreference()).resolves.toEqual({ value: null, loaded: true @@ -303,12 +313,17 @@ describe('push notification preference', () => { await expect(loadPushNotificationsEnabled()).resolves.toBe(false) }) - it('persists the onboarding decision in the existing mobile toggle', async () => { - await savePushNotificationsEnabled(true) - expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:pushNotificationsEnabled', 'true') - - await savePushNotificationsEnabled(false) - expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:pushNotificationsEnabled', 'false') + it('persists and reloads master consent', async () => { + const storage = new Map() + vi.mocked(AsyncStorage.getItem).mockImplementation(async (key) => storage.get(key) ?? null) + vi.mocked(AsyncStorage.setItem).mockImplementation(async (key, value) => { + storage.set(key, value) + }) + for (const enabled of [true, false]) { + await savePushNotificationsEnabled(enabled) + await expect(loadPushNotificationsEnabled()).resolves.toBe(enabled) + } + expect([...storage]).toEqual([['orca:pushServiceNotificationsEnabled', 'false']]) }) }) diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts index 5173ac5bc8a..57420469609 100644 --- a/mobile/src/storage/preferences.ts +++ b/mobile/src/storage/preferences.ts @@ -1,7 +1,8 @@ import AsyncStorage from '@react-native-async-storage/async-storage' const PINS_PREFIX = 'orca:pins:' -const NOTIF_KEY = 'orca:pushNotificationsEnabled' +// Consent to the push service is separate from the old socket notification choice. +const NOTIF_KEY = 'orca:pushServiceNotificationsEnabled' export type PushNotificationsPreference = { readonly value: boolean | null @@ -30,6 +31,43 @@ export async function savePushNotificationsEnabled(enabled: boolean): Promise { + try { + const raw = await AsyncStorage.getItem(REMOTE_PUSH_HOST_REGISTRATIONS_KEY) + if (!raw) { + return EMPTY_REMOTE_PUSH_HOST_REGISTRATIONS + } + const parsed = JSON.parse(raw) as Record + return { + registeredHostIds: stringArray(parsed.registeredHostIds), + pendingUnregisterHostIds: stringArray(parsed.pendingUnregisterHostIds) + } + } catch { + return EMPTY_REMOTE_PUSH_HOST_REGISTRATIONS + } +} + +export async function saveRemotePushHostRegistrations( + value: RemotePushHostRegistrations +): Promise { + await AsyncStorage.setItem(REMOTE_PUSH_HOST_REGISTRATIONS_KEY, JSON.stringify(value)) +} + const TEXT_SCALE_KEY = 'orca:terminalTextScale' // Why: the mobile terminal fits the desktop's full column count to the phone diff --git a/mobile/src/transport/client-context.test.ts b/mobile/src/transport/client-context.test.ts index 56b227bdff7..f1af4b87b36 100644 --- a/mobile/src/transport/client-context.test.ts +++ b/mobile/src/transport/client-context.test.ts @@ -5,6 +5,9 @@ import type { ConnectionState } from './types' import type { RpcClient } from './rpc-client' import type { MobileConnectionPath } from './stable-logical-rpc-client' +const push = vi.hoisted(() => ({ attach: vi.fn(), detach: vi.fn() })) +vi.mock('../notifications/push-registration', () => ({ attachPushRegistration: push.attach })) + const connectMock = vi.fn() const loadHostsMock = vi.fn() @@ -141,6 +144,8 @@ async function renderHarness(hostId: string): Promise { } beforeEach(() => { + push.attach.mockReset().mockReturnValue(push.detach) + push.detach.mockReset() connectMock.mockReset() loadHostsMock.mockReset() }) @@ -707,3 +712,33 @@ describe('useAllHostClients', () => { } }) }) + +it('owns push registration for a paired host without mounting the home screen', async () => { + const client = makeFakeClient('handshaking') + connectMock.mockReturnValue(client) + loadHostsMock.mockResolvedValue([HOST]) + const harness = await renderHarness(HOST.id) + expect(push.attach).not.toHaveBeenCalled() + await act(async () => client.emitState('connected')) + expect(push.attach).toHaveBeenCalledExactlyOnceWith(HOST.id, client) + await act(async () => client.emitState('connected')) + expect(push.attach).toHaveBeenCalledOnce() + await act(async () => client.emitState('disconnected')) + expect(push.detach).toHaveBeenCalledOnce() + await act(async () => client.emitState('connected')) + expect(push.attach).toHaveBeenCalledTimes(2) + await act(async () => harness.unmount()) + expect(push.detach).toHaveBeenCalledTimes(2) +}) + +it('registers an already authenticated host and detaches on explicit disconnect', async () => { + const client = makeFakeClient('connected') + connectMock.mockReturnValue(client) + loadHostsMock.mockResolvedValue([HOST]) + const harness = await renderHarness(HOST.id) + expect(push.attach).toHaveBeenCalledExactlyOnceWith(HOST.id, client) + await act(async () => harness.disconnectHost(HOST.id)) + expect(push.detach).toHaveBeenCalledOnce() + await act(async () => harness.unmount()) + expect(push.detach).toHaveBeenCalledOnce() +}) diff --git a/mobile/src/transport/host-entry-opener.ts b/mobile/src/transport/host-entry-opener.ts index 03d6363c7c7..6a22d29bf5e 100644 --- a/mobile/src/transport/host-entry-opener.ts +++ b/mobile/src/transport/host-entry-opener.ts @@ -1,3 +1,4 @@ +import { attachPushRegistration } from '../notifications/push-registration' import { connectionLogStore, recordConnectionClientSessionStart @@ -113,11 +114,21 @@ export async function openHostClientEntry( client.close() return state.store.get(hostId) ?? null } - const unsubState = client.onStateChange((next) => { + let detachPushRegistration: (() => void) | null = null + const syncPushRegistration = (next: ConnectionState): void => { + if (next === 'connected') { + detachPushRegistration ??= attachPushRegistration(hostId, client) + } else { + detachPushRegistration?.() + detachPushRegistration = null + } + } + const unsubscribeState = client.onStateChange((next) => { const current = state.store.get(hostId) if (!current) { return } + syncPushRegistration(next) current.state = next state.notifyHostState(hostId, next) }) @@ -134,11 +145,16 @@ export async function openHostClientEntry( clientId: host.deviceToken, state: client.getState(), refCount: state.pendingAcquisitions.get(hostId) ?? 0, - unsubState, + unsubState: () => { + unsubscribeState() + detachPushRegistration?.() + detachPushRegistration = null + }, unsubConnectionPath } state.pendingAcquisitions.delete(hostId) state.store.set(hostId, entry) + syncPushRegistration(entry.state) settle() const priorFailureCount = state.retryScheduler.recordSuccess(hostId) if (priorFailureCount > 0) { diff --git a/mobile/src/transport/host-open-recovery.test.tsx b/mobile/src/transport/host-open-recovery.test.tsx index 70ebc131791..90e79c13976 100644 --- a/mobile/src/transport/host-open-recovery.test.tsx +++ b/mobile/src/transport/host-open-recovery.test.tsx @@ -1,3 +1,6 @@ +vi.mock('../notifications/push-registration', () => ({ + attachPushRegistration: () => () => {} +})) import { createElement, type ReactElement } from 'react' import { act, create } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/mobile/src/transport/host-removal-lifecycle.test.ts b/mobile/src/transport/host-removal-lifecycle.test.ts index 6c96ef1c446..08313ff3780 100644 --- a/mobile/src/transport/host-removal-lifecycle.test.ts +++ b/mobile/src/transport/host-removal-lifecycle.test.ts @@ -1,91 +1,50 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const removeHostMock = vi.hoisted(() => vi.fn()) -const asyncStorage = vi.hoisted(() => ({ - getItem: vi.fn(async () => null), - setItem: vi.fn(async () => undefined), - // Why removeItem is here: clearWatermark() swallows its own failures, so a mock - // missing this method turns the persisted-watermark cleanup into a caught - // TypeError — the assertion below would pass even if the call were deleted. - removeItem: vi.fn(async () => undefined) -})) - -vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) +const unregisterPushMock = vi.hoisted(() => vi.fn(async () => vi.fn())) vi.mock('./host-store', () => ({ removeHost: (hostId: string) => removeHostMock(hostId) })) +vi.mock('../notifications/push-registration', () => ({ + unregisterPushForRemovedHost: (hostId: string) => unregisterPushMock(hostId) +})) + import { removeHostAndCloseClient } from './host-removal-lifecycle' -import { - getHostNotificationSession, - resetHostNotificationSessionsForTests -} from '../notifications/notification-reconnect-catchup' describe('host removal lifecycle', () => { beforeEach(() => { removeHostMock.mockReset() - asyncStorage.removeItem.mockClear() - resetHostNotificationSessionsForTests() + unregisterPushMock.mockClear() }) it('closes the client only after metadata removal commits', async () => { let commitRemoval: (() => void) | null = null - removeHostMock.mockReturnValue( - new Promise((resolve) => { - commitRemoval = resolve - }) - ) + removeHostMock.mockReturnValue(new Promise((resolve) => (commitRemoval = resolve))) const closeHostClient = vi.fn() - const removal = removeHostAndCloseClient('host-1', closeHostClient) expect(closeHostClient).not.toHaveBeenCalled() commitRemoval?.() await removal - expect(closeHostClient).toHaveBeenCalledWith('host-1') }) it('keeps the client open when metadata removal fails', async () => { removeHostMock.mockRejectedValue(new Error('storage unavailable')) const closeHostClient = vi.fn() - await expect(removeHostAndCloseClient('host-1', closeHostClient)).rejects.toThrow( 'storage unavailable' ) expect(closeHostClient).not.toHaveBeenCalled() }) - it('retires the notification session so a removed host leaves nothing behind', async () => { - // Round-1 review finding: the session lives at module scope (it must survive the - // subscription teardown a reconnect performs), so removal is the only thing that - // can retire it. Left behind, each remove/re-pair cycle strands a session plus up - // to 512 seen keys, and a re-paired host inherits a watermark it never earned. + it('drops the gateway push registration before the credentials it needs are gone', async () => { removeHostMock.mockResolvedValue(undefined) - const session = getHostNotificationSession('host-1') - session.lastDeliveredSeq = 42 - session.lastDeliveredEpoch = 'epoch-A' - await removeHostAndCloseClient('host-1', vi.fn()) - - // A fresh session for the same id — not the retained one. - const afterRemoval = getHostNotificationSession('host-1') - expect(afterRemoval).not.toBe(session) - expect(afterRemoval.lastDeliveredSeq).toBe(0) - expect(afterRemoval.lastDeliveredEpoch).toBeNull() - }) - - it('erases the persisted watermark, not just the in-memory session', async () => { - // Why separately from the test above: the session is process-local, the - // watermark is not. Retiring only the session lets a re-pair of the same host - // read the old seq off disk and resume against a counter it never saw — the - // catch-up would then start above the real cut and drop everything below it. - removeHostMock.mockResolvedValue(undefined) - - await removeHostAndCloseClient('host-1', vi.fn()) - // clearWatermark is fire-and-forget; let its microtask land. - await Promise.resolve() - - expect(asyncStorage.removeItem).toHaveBeenCalledWith('orca:mobileNotificationsWatermark:host-1') + expect(unregisterPushMock).toHaveBeenCalledWith('host-1') + expect(unregisterPushMock.mock.invocationCallOrder[0]).toBeLessThan( + removeHostMock.mock.invocationCallOrder[0] + ) }) }) diff --git a/mobile/src/transport/host-removal-lifecycle.ts b/mobile/src/transport/host-removal-lifecycle.ts index cd0a09cb67e..159c6bca59e 100644 --- a/mobile/src/transport/host-removal-lifecycle.ts +++ b/mobile/src/transport/host-removal-lifecycle.ts @@ -1,20 +1,20 @@ -import { - clearWatermark, - forgetHostNotificationSession -} from '../notifications/notification-reconnect-catchup' +import { unregisterPushForRemovedHost } from '../notifications/push-registration' import { removeHost } from './host-store' export async function removeHostAndCloseClient( hostId: string, forgetHostClient: (hostId: string) => void ): Promise { + // Why before removeHost: the unregister needs the still-authenticated client, and + // the desktop's own revoke path covers the case where this call cannot land. + const restorePushRegistration = await unregisterPushForRemovedHost(hostId) // Why: closing before the metadata commit can strand a still-paired host on // storage failure; closing immediately after success prevents socket leaks. - await removeHost(hostId) + try { + await removeHost(hostId) + } catch (error) { + restorePushRegistration() + throw error + } forgetHostClient(hostId) - // Why: the notification session outlives the socket by design (it must survive - // reconnects), so removal is the only thing that can retire it. Left behind, a - // re-pair of the same host would inherit a watermark for a counter it never saw. - forgetHostNotificationSession(hostId) - void clearWatermark(hostId) } diff --git a/mobile/src/transport/runtime-capability-probe.ts b/mobile/src/transport/runtime-capability-probe.ts index ef636552863..6bec0ca05bd 100644 --- a/mobile/src/transport/runtime-capability-probe.ts +++ b/mobile/src/transport/runtime-capability-probe.ts @@ -10,7 +10,7 @@ const FAILURE_RETRY_BASE_DELAY_MS = 1_000 const FAILURE_RETRY_MAX_DELAY_MS = 15_000 export function startRuntimeCapabilityProbe( - client: RpcClient, + client: Pick, onCapabilities: (capabilities: readonly string[]) => void ): () => void { let cancelled = false diff --git a/mobile/src/transport/settings-host-client-lifecycle.test.ts b/mobile/src/transport/settings-host-client-lifecycle.test.ts index be7621017ba..bd0c2048637 100644 --- a/mobile/src/transport/settings-host-client-lifecycle.test.ts +++ b/mobile/src/transport/settings-host-client-lifecycle.test.ts @@ -1,3 +1,6 @@ +vi.mock('../notifications/push-registration', () => ({ + attachPushRegistration: () => () => {} +})) import { createElement, Fragment, useEffect } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/src/main/runtime/push/desktop-push-service.test.ts b/src/main/runtime/push/desktop-push-service.test.ts index 1300b43c3fb..a9561be8487 100644 --- a/src/main/runtime/push/desktop-push-service.test.ts +++ b/src/main/runtime/push/desktop-push-service.test.ts @@ -313,3 +313,40 @@ it('renews a seven-day mobile lease only on explicit registration', async () => clock.mockRestore() } }) + +it('sends an explicit test only to the requesting registered phone and awaits gateway acceptance', async () => { + const { service, registry, deviceId, send } = createService() + await service.register({ + ...REGISTER_INPUT, + deviceId, + filter: { onlyWhenDesktopAway: true, sound: false } + }) + registry.addDevice('another phone', 'mobile') + send.mockResolvedValue({ ok: true, results: [{ registrationId: 'reg-1', status: 'queued' }] }) + await expect(service.test(deviceId)).resolves.toEqual({ accepted: true }) + expect(send).toHaveBeenCalledWith({ + registrationIds: ['reg-1'], + notification: expect.objectContaining({ + source: 'terminal-bell', + sound: false, + title: 'Test notification' + }) + }) +}) + +it('does not claim success for missing registrations or failed gateway sends', async () => { + const { service, deviceId, send } = createService() + await expect(service.test(deviceId)).resolves.toEqual({ + accepted: false, + reason: 'not_registered' + }) + expect(send).not.toHaveBeenCalled() + await service.register({ ...REGISTER_INPUT, deviceId }) + send.mockResolvedValue({ ok: false, reason: 'unreachable' }) + await expect(service.test(deviceId)).resolves.toEqual({ accepted: false, reason: 'unavailable' }) + send.mockResolvedValue({ + ok: true, + results: [{ registrationId: 'reg-1', status: 'rate_limited' }] + }) + await expect(service.test(deviceId)).resolves.toEqual({ accepted: false, reason: 'rate_limited' }) +}) diff --git a/src/main/runtime/push/desktop-push-service.ts b/src/main/runtime/push/desktop-push-service.ts index 69bf810d026..44e06dbcb2c 100644 --- a/src/main/runtime/push/desktop-push-service.ts +++ b/src/main/runtime/push/desktop-push-service.ts @@ -2,7 +2,9 @@ // registration each paired phone asked for, and the durable delete queue. Built // alongside DesktopRelayService but deliberately not gated on cloud sign-in: the // gateway authenticates with the host keypair, so accountless hosts push too. +import { randomUUID } from 'node:crypto' import type { + MobilePushTestResult, MobilePushRegisterInput, MobilePushRegisterResult } from '../../../shared/mobile-push-contract' @@ -105,6 +107,53 @@ export class DesktopPushService { this.runtime.setMobilePushRegistrar(null) } + async test(deviceId: string): Promise { + const device = this.registry.getDevice(deviceId) + const registration = device?.pushRegistration + if (device?.scope !== 'mobile' || !registration || registration.expiresAt <= Date.now()) { + return { accepted: false, reason: 'not_registered' } + } + if (this.stopped) { + return { accepted: false, reason: 'unavailable' } + } + // Explicit tests target only the caller and bypass automatic activity filters. + const result = await this.client.send({ + registrationIds: [registration.registrationId], + notification: { + source: 'terminal-bell', + agentState: null, + title: 'Test notification', + body: '', + notificationId: randomUUID(), + notificationEpoch: randomUUID(), + notificationSeq: 0, + expiresAt: Date.now() + 300_000, + sound: registration.filter.sound !== false + } + }) + if (!result.ok) { + return { + accepted: false, + reason: result.reason === 'unreachable' ? 'unavailable' : 'rejected' + } + } + const status = result.results.find( + (entry) => entry.registrationId === registration.registrationId + )?.status + if (status === 'queued') { + return { accepted: true } + } + return { + accepted: false, + reason: + status === 'rate_limited' + ? 'rate_limited' + : status === 'dead' + ? 'not_registered' + : 'rejected' + } + } + async register(input: MobilePushRegisterInput): Promise { if (this.registry.getDevice(input.deviceId)?.scope !== 'mobile') { return { registered: false, reason: 'not_mobile' } diff --git a/src/main/runtime/push/push-registration-rpc.test.ts b/src/main/runtime/push/push-registration-rpc.test.ts index 78a91a238fa..f19bfe71b4d 100644 --- a/src/main/runtime/push/push-registration-rpc.test.ts +++ b/src/main/runtime/push/push-registration-rpc.test.ts @@ -30,6 +30,7 @@ function contextFor(overrides: Partial): RpcContext { registered: true, registrationId: 'reg-1' })), + testMobilePushDevice: vi.fn(async () => ({ accepted: true })), unregisterMobilePushDevice: vi.fn(async () => ({ unregistered: true })) }, ...overrides @@ -154,3 +155,25 @@ describe('revokeMobileDevice', () => { expect(server.getPushUnregisterOutbox().pending()).toEqual([]) }) }) + +describe('notifications.testPush', () => { + it('targets the authenticated phone and returns the service result', async () => { + const ctx = contextFor({ clientKind: 'mobile', pairedDeviceId: 'device-1' }) + expect(await method('notifications.testPush').handler(null, ctx)).toEqual({ accepted: true }) + expect(ctx.runtime.testMobilePushDevice).toHaveBeenCalledWith('device-1') + }) + it('refuses callers without an authenticated mobile identity', async () => { + for (const overrides of [ + {}, + { clientKind: 'mobile' as const }, + { clientKind: 'runtime' as const, pairedDeviceId: 'device-1' } + ]) { + const ctx = contextFor(overrides) + expect(await method('notifications.testPush').handler(null, ctx)).toEqual({ + accepted: false, + reason: 'not_registered' + }) + expect(ctx.runtime.testMobilePushDevice).not.toHaveBeenCalled() + } + }) +}) diff --git a/src/main/runtime/rpc/methods/notifications.ts b/src/main/runtime/rpc/methods/notifications.ts index 8168da70cae..7df66b4acf4 100644 --- a/src/main/runtime/rpc/methods/notifications.ts +++ b/src/main/runtime/rpc/methods/notifications.ts @@ -87,6 +87,16 @@ export const NOTIFICATION_METHODS = [ return await runtime.registerMobilePushDevice({ ...params, deviceId: pairedDeviceId }) } }), + defineMethod({ + name: 'notifications.testPush', + params: null, + handler: async (_params, { runtime, clientKind, pairedDeviceId }) => { + if (clientKind !== 'mobile' || !pairedDeviceId) { + return { accepted: false, reason: 'not_registered' } + } + return await runtime.testMobilePushDevice(pairedDeviceId) + } + }), defineMethod({ name: 'notifications.unregisterPush', params: null, diff --git a/src/main/runtime/runtime-mobile-notification-controller.ts b/src/main/runtime/runtime-mobile-notification-controller.ts index 73578412618..174e897f4e1 100644 --- a/src/main/runtime/runtime-mobile-notification-controller.ts +++ b/src/main/runtime/runtime-mobile-notification-controller.ts @@ -1,6 +1,7 @@ import { reserveNotificationCooldown } from '../../shared/notification-burst-cooldown' import type { AgentStatusState } from '../../shared/agent-status-types' import type { + MobilePushTestResult, MobilePushRegisterInput, MobilePushRegisterResult } from '../../shared/mobile-push-contract' @@ -43,6 +44,7 @@ export type MobileNotificationEvent = /** The desktop push service, once it exists; absent on hosts that never started one. */ export type MobilePushRegistrar = { + test(deviceId: string): Promise register(input: MobilePushRegisterInput): Promise unregister(deviceId: string): Promise<{ unregistered: boolean }> } @@ -77,6 +79,10 @@ export class RuntimeMobileNotificationController { ) } + async testPushDevice(deviceId: string): Promise { + return (await this.pushRegistrar?.test(deviceId)) ?? { accepted: false, reason: 'unavailable' } + } + async unregisterPushDevice(deviceId: string): Promise<{ unregistered: boolean }> { return (await this.pushRegistrar?.unregister(deviceId)) ?? { unregistered: false } } diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts index ca8d326e7e7..85cf306a0e5 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts @@ -174,6 +174,7 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'notifications.getMissedSince', 'notifications.registerPush', 'notifications.subscribe', + 'notifications.testPush', 'notifications.unregisterPush', 'notifications.unsubscribe', 'pairing.getEndpoints', diff --git a/src/main/runtime/runtime-service-command-surface.ts b/src/main/runtime/runtime-service-command-surface.ts index b7811dbc073..4290fadecf1 100644 --- a/src/main/runtime/runtime-service-command-surface.ts +++ b/src/main/runtime/runtime-service-command-surface.ts @@ -33,6 +33,7 @@ export type RuntimeServiceCommandSurface = { dismissMobileNotification: RuntimeMobileNotificationController['dismiss'] dispatchPluginNotification: RuntimeMobileNotificationController['dispatchPlugin'] setMobilePushRegistrar: RuntimeMobileNotificationController['setPushRegistrar'] + testMobilePushDevice: RuntimeMobileNotificationController['testPushDevice'] registerMobilePushDevice: RuntimeMobileNotificationController['registerPushDevice'] unregisterMobilePushDevice: RuntimeMobileNotificationController['unregisterPushDevice'] setAccountServices: RuntimeAccountController['setServices'] @@ -118,6 +119,7 @@ export function installRuntimeServiceCommandSurface( dismissMobileNotification: notifications.dismiss.bind(notifications), dispatchPluginNotification: notifications.dispatchPlugin.bind(notifications), setMobilePushRegistrar: notifications.setPushRegistrar.bind(notifications), + testMobilePushDevice: notifications.testPushDevice.bind(notifications), registerMobilePushDevice: notifications.registerPushDevice.bind(notifications), unregisterMobilePushDevice: notifications.unregisterPushDevice.bind(notifications), setAccountServices: accounts.setServices.bind(accounts), diff --git a/src/main/runtime/structured-session-worktree-teardown.test.ts b/src/main/runtime/structured-session-worktree-teardown.test.ts index 84977aad4eb..bb022d4ba16 100644 --- a/src/main/runtime/structured-session-worktree-teardown.test.ts +++ b/src/main/runtime/structured-session-worktree-teardown.test.ts @@ -541,16 +541,20 @@ describe('worktree teardown and structured agent sessions', () => { records: [record('s1', WORKTREE), record('s2', WORKTREE)], closeGates: { s1: firstClose } }) - const error = await killAllProcessesForWorktree( - WORKTREE, - destructiveDeps({ timeoutMs: 5 }) - ).catch((thrown: Error) => thrown.message) - expect(error).toContain('could not confirm these closed: 2 agent sessions (claude)') - releaseFirstClose() - await new Promise((resolve) => { - setTimeout(resolve, 25) - }) - expect(host.closed).toEqual(['s1']) + vi.useFakeTimers() + try { + const outcome = killAllProcessesForWorktree( + WORKTREE, + destructiveDeps({ timeoutMs: 5 }) + ).catch((thrown: Error) => thrown.message) + await vi.advanceTimersByTimeAsync(5) + expect(await outcome).toContain('could not confirm these closed: 2 agent sessions (claude)') + releaseFirstClose() + await vi.advanceTimersByTimeAsync(0) + expect(host.closed).toEqual(['s1']) + } finally { + vi.useRealTimers() + } }) it('leaves the terminals already stopped when it refuses over a stuck session', async () => { diff --git a/src/shared/mobile-push-contract.ts b/src/shared/mobile-push-contract.ts index e18c53defb3..c9dbd426ea9 100644 --- a/src/shared/mobile-push-contract.ts +++ b/src/shared/mobile-push-contract.ts @@ -93,3 +93,7 @@ export function parseMobilePushRegistration(value: unknown): MobilePushRegistrat expiresAt: registration.expiresAt } } + +export type MobilePushTestResult = + | { accepted: true } + | { accepted: false; reason: 'not_registered' | 'unavailable' | 'rate_limited' | 'rejected' } diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 15d04a5b655..6adf1851d2f 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -943,6 +943,7 @@ export const RPC_PARAMS_BY_METHOD = { 'notifications.getMissedSince': NotificationGetMissedSinceParams, 'notifications.registerPush': NotificationRegisterPushParams, 'notifications.subscribe': NotificationsSubscribeParams, + 'notifications.testPush': null, 'notifications.unregisterPush': null, 'notifications.unsubscribe': NotificationUnsubscribeParams, 'orchestration.ask': AskParams, From 1a9a5f9bc720bebd06a5dd190a4ea0000e59ccb4 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:15:15 -0400 Subject: [PATCH 033/191] fix(cloud): tolerate sparse director errors in rollout monitor (#20238) --- .../relay-ops/src/incident-monitor.test.ts | 20 +++++++++++++++++++ cloud/apps/relay-ops/src/incident-monitor.ts | 3 ++- cloud/docs/relay-incident-monitor.md | 7 ++++++- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/cloud/apps/relay-ops/src/incident-monitor.test.ts b/cloud/apps/relay-ops/src/incident-monitor.test.ts index c1a073cde4a..be35b55f65b 100644 --- a/cloud/apps/relay-ops/src/incident-monitor.test.ts +++ b/cloud/apps/relay-ops/src/incident-monitor.test.ts @@ -273,6 +273,26 @@ describe('incident monitor evaluator', () => { ) }) + it('allows at most three unexpected director errors per five minutes without relaxing other gates', () => { + for (const errors of [1, 2, 3]) { + const sample = healthySample() + sample.sources['cloud-monitoring']!.signals['director.errors'] = signal(errors) + expect(evaluateIncidentSample(sample, startedAt).status).toBe('green') + } + const excess = healthySample() + excess.sources['cloud-monitoring']!.signals['director.errors'] = signal(4) + expect(evaluateIncidentSample(excess, startedAt).failures).toContainEqual( + expect.objectContaining({ signal: 'director.errors', observed: 4, threshold: 3 }) + ) + const auth = healthySample() + auth.sources['cloud-monitoring']!.signals['auth.errors'] = signal(1) + expect(evaluateIncidentSample(auth, startedAt).status).toBe('freeze') + const pressure = healthySample() + pressure.sources['cloud-monitoring']!.signals['director.errors'] = signal(1) + pressure.sources['cloud-monitoring']!.signals['cloud_sql.cpu'] = signal(0.81) + expect(evaluateIncidentSample(pressure, startedAt).status).toBe('freeze') + }) + it('freezes on SQL, director, relay pool, heartbeat, and migration breaches', () => { const sample = healthySample() sample.sources['cloud-monitoring']!.signals['cloud_sql.cpu'] = signal(0.81) diff --git a/cloud/apps/relay-ops/src/incident-monitor.ts b/cloud/apps/relay-ops/src/incident-monitor.ts index 0887bb2d1ee..a48e100a6d8 100644 --- a/cloud/apps/relay-ops/src/incident-monitor.ts +++ b/cloud/apps/relay-ops/src/incident-monitor.ts @@ -101,7 +101,8 @@ export const INCIDENT_MONITOR_THRESHOLDS = { directorCpuUtilization: 0.8, directorMemoryUtilization: 0.8, directorConcurrency: 64, - directorErrors: 0, + // Sparse connection timeouts must not block a healthy rollout; four/5min still freezes. + directorErrors: 3, authErrors: 0, // Why: 800 exceeded the 600 hard cap, so this could never trigger on a capped cell. 500 is // the ordinary admission limit a cell actually stops at (600 cap - 100 control-rebind reserve). diff --git a/cloud/docs/relay-incident-monitor.md b/cloud/docs/relay-incident-monitor.md index 696efb85296..a97e92949e8 100644 --- a/cloud/docs/relay-incident-monitor.md +++ b/cloud/docs/relay-incident-monitor.md @@ -112,7 +112,8 @@ durably marked consumed before mutation and cannot authorize another run. | Director instances | outside 5–6 | | Director CPU or memory | over 80% | | Director concurrency | over 64 | -| Unexpected director 5xx or auth 5xx in five minutes | over 0 | +| Unexpected director 5xx in five minutes (excludes 503) | over 3 | +| Auth 5xx in five minutes | over 0 | | Connections per cell process | over 500 | | Queued bytes per cell process | over 48 MiB | | Blocked or expired/unregistered migration | over 0 | @@ -276,3 +277,7 @@ without its segment is a compile error in relay-contract, not a silent gap. load the director's three-connection database pool. - Added private atomic state, idempotent JSONL checkpoints, and secret-safe Markdown evidence. - Added the manual production workflow. It has not been dispatched. + +### Director error allowance (2026-09-12) + +The serving-cell rollout observed three unexpected director 500 responses among approximately 33,600 responses in an hour, all two-second PostgreSQL connection timeouts. CPU remained near 30–37% and the zero-error bar repeatedly prevented any cell mutation. The five-minute allowance is now three non-503 director 5xx; four freezes. Auth errors, data freshness, active probes, SQL/pool pressure and other limits are unchanged. This is a bounded operational allowance, not a calibrated SLO or proof that intermittent failures are resolved; persistent low-frequency errors below this limit still require diagnosis. From fa3d29fa11e38fba80d77b45dcbe401edd3ec53f Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:19:41 -0400 Subject: [PATCH 034/191] feat(ai-vault-search): session search query engine over the index (#20029) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ai-vault-search): give the index a generation a page cursor can be fenced by A search page is a slice of one ranked list, so a cursor only means anything against the snapshot that produced it. The store now keeps a monotone generation in `meta`, bumped by every mutation that can change which rows a read returns, and starts a new one on open so a write whose bump never landed cannot leave a cursor pointing at content that is already gone. Schema version 2 adds the two tables the query layer needs: `messages_vocab` (fts5vocab over messages_fts, the typo repair's whole dictionary) and `search_log`. Both are pure additions and the index is a cache, so a version-1 file is dropped and rebuilt exactly as any other mismatch is. * feat(ai-vault-search): plan a query the way the index tokenized it The planner unfolds the tokenizer contract instead of asking SQLite: same boundaries as `unicode61 tokenchars '_.-/+'`, pinned against real fts5vocab output so a query can be planned without a round trip. It decides the route ladder's first rung (literal shape), strips stop words from prose but never from a literal, and fans an identifier out into its pieces for the OR fallback. Typo repair uses the index's own vocabulary as its dictionary, so it can never suggest a term the index does not hold. Its exact-match probe now joins `visible_messages`: `messages_vocab` is a view over the FTS b-tree and still lists staged and tombstoned terms, and the PR 2 read ratchet is right to demand the join. `splitAiVaultSearchQuery` is the index's reading of repo: / path:. It keeps operator case, which the panel folds and cwd_key must not; a census test pins the two parsers to the same answer about what is an operator until PR 7 moves the panel onto this one. * feat(ai-vault-search): narrow a search the way the sidebar keys a folder Every caller-supplied narrowing in one place, so retrieval, the operator-only page and the session load cannot drift apart: agents, an updated-at floor, the retention cutoff, scope paths, and the repo: / path: operators. Scope keying goes through PR 2's `cwdKey`, which is the sidebar's `folderGroupKey` without its prefix, rather than the original branch's second spelling. That drops the branch's WSL distro qualification, which PR 2 removed on purpose, and it makes the filesystem root a key that already ends in a separator, so the child-prefix range is built from the key rather than by appending one; `//` sorts below every real child and would scope the root to nothing. Engine types land here too, under src/main and not src/shared: nothing in this PR is a wire type, and PR 5 lifts what a caller may receive. * feat(ai-vault-search): rank, page and answer a session search `SessionSearchEngine.search()` over the PR 2 store: route ladder (phrase, AND, typo repair, OR), BM25 weights per corpus, one hit per session, fork folding, and a page. - `scope` picks the corpus and the engine never second-guesses it. `conversation` is user and assistant turns; `all` adds tool output and the identifier shadow column. Switching corpus while typing is PR 7's policy; an engine that widened on a miss would make a result impossible to reproduce from its own request. - Pagination is an offset into one ranked list, fenced by the index generation and by a hash of everything that changes the ranking. A cursor from another generation or another query is refused with a typed error rather than silently re-run. Ranking breaks every tie by session id, because a cursor indexes into that order and retrieval does not promise one. - Snippets and source presence are paid for by the page, not the list. A snippet past the per-hit ceiling is cut on a code point, never between `[[` and its `]]`, and flagged on the hit. - Source presence is read from the `files` table. No stat on the query path, and no `missing`: only a proven deletion may claim one, and this read cannot prove it. - `SESSION_SEARCH_CANDIDATE_LIMIT_DEFAULT` is an option, not a constant, and the result says when the limit was what cut the answer. - The engine is the only caller of `store.warm()`, on first search. Library only: no IPC, no settings, no Electron, nothing constructs it in production. * perf(ai-vault-search): measure what a query costs and what the candidate limit buys Two corpora, because they answer different questions. The 10.5 MB / 40-session corpus is what the scope split costs a reader: conversation is about 1.6x faster at p50 and 3.4x at p95 than the full corpus, which is the argument for the second FTS table being the one a keystroke can afford. The candidate limit needs more sessions than the limit before it costs anything, so it is swept over 2,500 one-turn transcripts with every session matching. Limits are interleaved sample by sample: run back to back, the first configuration pays for every page the OS cache had not seen and the ordering alone moved p95 further than the limit did. The doc says plainly what these numbers do not cover. They are cost, not relevance; the MRR figures quoted beside the BM25 weights and the identifier shadow column come from a shoot-out over real transcripts and cannot be reproduced from this repository. * refactor(ai-vault-search): key a page once per search, and report reachable pages honestly The page key was hashed twice per search, once to decode the incoming cursor and once to mint the outgoing one. The benchmark's reachable-page figure was clamped against a constant that could never bind; it is the limit over the page size and nothing else. * test(ai-vault-search): pin the two engine seams nothing was holding The warm wiring and the query-length cap were both written and neither was observable. A spy pins that a search is what warms the store, and the cap is pinned through the one input the planner's own term limit does not already bound: a single enormous token, where the cut is what decides whether the term matches the indexed one at all. * fix(ai-vault-search): fence the generation against writers this process cannot see The generation was cached in memory and moved only on this store's own writes, and the bump itself was a read-then-write outside any transaction. Two handles on one file is the normal case once PR 3 lands: the indexer writes in the scanner child while an engine reads elsewhere. A reader would see that writer's deletions while its own generation stood still, honour a stale cursor, and skip a session; two writers could mint one generation for two snapshots. `generation` now reads `meta` on every call, and the bump is a single `ON CONFLICT DO UPDATE ... + 1` inside the transaction that makes the change. That closes the crash hole the bump-on-open existed for, so opening a store no longer invalidates anyone's cursor. Only a change to what a read returns counts. Retiring a path the index never held hides nothing, and the row deletes that drain a tombstone take away rows that were already invisible: bumping there would refuse a cursor every 256 rows and leave pagination unusable for as long as indexing ran. Also drops redaction from the query log, following PR 2's decision to store transcript content as written; the module it called no longer exists. * fix(ai-vault-search): answer from a version-1 index, and keep a repaired literal whole Two smaller findings. An engine can be handed a connection to a version-1 file that another handle is still answering from, and this PR is what first makes that reachable. It now probes once for the two tables version 2 added and names what it cannot serve on every result, instead of throwing at the first query that reaches for a vocabulary that is not there. The route ladder simply skips its repair rung. Which process may unlink and rebuild the index is PR 3b's decision and is not solved here. Typo repair re-planned the corrected query from scratch, and a corrected spelling can read as prose even when what was typed was a literal: `parseJsonn(the, data)` has the punctuation, `parsejson the data` does not, so the re-plan dropped `the` as a stop word. The repaired query searched for less than was asked and `repairedTerms` reported a body nobody typed. The re-plan is now told what the original decided, because a repair changes spellings, not the query's character. `repairedTerms` is documented as the whole body the repaired plan ran, with the index's own case-folded spelling for the terms it corrected. * fix(ai-vault-search): make repo: and path: mean one thing in the list and the index The engine said `repo:` and `path:` a second time, in SQL, and SQL cannot say them. LIKE folds ASCII and nothing else, so `path:CAFÉ` missed `café`; the engine searched `cwd_key` while the panel searches the working directory and the transcript path, so `path:jsonl` matched every session in the panel and none in the index; and the engine compared one path segment where the panel compares the last two, so `repo:orca/session-search` missed. All four reproduce in both directions. So there is one definition now, not two that resemble each other. `matchesAiVaultQueryOperators` moves into the shared filter module beside the panel that already owned the semantics, the panel calls it, and the engine applies it over the rows it retrieved. SQL keeps only what it can express exactly: the `cwd_key` prefix range for `scopePaths`. `parseVaultQuery` now parses through `splitAiVaultSearchQuery`, so one parser decides what an operator is. Its existing tests pass unchanged; four degenerate shapes do answer differently and are pinned as decisions rather than left to be discovered. Two consequences worth stating. The operators are conjunctive now, because that is what the panel has always done, where the engine had been ORing within a key. And the operator-only page walks newest sessions in bounded pages applying the predicate, rather than taking one cut of the newest N and filtering it, which would have answered `repo:x` with nothing on a busy index. * docs(ai-vault-search): re-measure the query benchmark after the operator change repo: and path: moved out of SQL, so the operator-only row measures something different now and the note that it is a range seek was wrong. The rest of the table is re-measured on an idle machine: the previous run's p95 column was mostly contention, which is why conversation looked 3.4x faster at p95 rather than the 1.7x it actually is. Adds what this PR does not settle: which process may unlink and rebuild the index is PR 3b's, and PR 4 is only the first thing that makes reading it reachable. * fix(ai-vault-search): stop reporting a search that gave up as a search that finished The operator-only walk stops at a scan ceiling as well as at a full candidate set, and only the first of those reached the result. A query whose one match sat past the ceiling came back with no hits and truncated.candidates false, which is the engine claiming there is nothing to find when what happened is that it stopped looking. Retrieval now says why it stopped, because it is the only layer that knows, and the count it used to return could not distinguish the two cases. The cursor fence stays as it is: any published read moves the generation, so an outstanding cursor is refused, and that is what F11 asked for. What was wrong was the claim next to the row-delete skip that pagination stays usable through indexing. It does not, and the engine now says so. The rejection carries the generation the cursor was minted in and the one the index is at, so a caller can tell a moved index from a bad cursor and re-issue page one without showing anyone an error. The capability probe was nearly dead code, since every store opens through a function that rebuilds a stale file. It is not dead, because two handles can be open on one file, so the claim is corrected rather than the probe deleted. It now runs per search: a verdict cached in the constructor is wrong in both directions once another handle rebuilds the index. Also says why the row-delete loop may skip the bump: those messages keep batch_id NULL and stay in visible_messages, so what makes them unreachable is their session's tombstone, and the read ratchet is what keeps every reader joining the view that applies it. * fix(ai-vault-search): restore the panel's reading of a quote that does not end a word Unifying the two parsers changed panel behaviour on nine of twenty probed shapes, not the three previously pinned. Six of the nine were regressions, all from one rule: the shared parser refused a quoted span whose closing quote was not followed by a space, so `"a b"c` and `repo:"a"b` became single terms carrying their own quote characters, which match nothing. The rule was justified as what stops the apostrophes in `it's a repo:orca thing's` from swallowing the operator between them. It is not: a span only ever opens at a token start, and the quote in `it's` is not at one. Dropping the rule restores all six shapes to what the panel has always done and leaves that protection intact. Three changes remain and are kept because the old answer was worse in each: an operator with an empty quoted value is dropped rather than filtering on `""` and silently emptying the list, and a bare pair of quotes reads as an empty term rather than as the two characters. Each is pinned with a test that says which behaviour it is and why. * fix(ai-vault-search): trim operator values, and report a query the engine had to cut Three lows. `repo:" "` survived as a term and matched no label, silently emptying the list, which is the exact defect the empty-value drop exists to prevent wearing different clothes. Operator values are trimmed, and a whitespace-only one drops like an empty one. The substring matcher's copy of a free-text term is trimmed too, so `" "` reads as the empty term already does; the span kept for FTS is still the query verbatim. Two caps upstream of retrieval fired silently: the planner searches at most 48 terms, and the engine cuts the query at 512 characters. A 56-term query whose only match was the 56th came back with no hits and nothing truncated, which claims there is nothing to find. `truncated.query` now says when either fired, alongside the candidate and snippet flags that already did. Every cursor refusal now carries the generation the index is at, which the engine knows before it looks at the cursor, and the generation the cursor claimed wherever that survived parsing. The doc says exactly when each is present instead of leaving absence unexplained. * refactor(ai-vault-search): read the tables the simplified index writes, and own the fence PR 2 deleted the visibility views, the staging tables and the store's generation, so this reads `sessions` and `messages` directly and carries the three schema objects only a query needs — the vocabulary, the query log, and the triggers that move the generation — as its own extension over the store's schema. The fence is now three triggers on `files`, because every transaction the store opens that can change what a search returns writes that table and nothing else does; retention's orphan drain is the one write path that touches neither, and it is the one that must not bump. The triggers live in the file, so a writer in another process moves the generation without knowing a reader exists. A message row can now outlive its session row until the drain reaches it, so the snippet read joins `sessions` and the typo repair asks for a live posting instead of trusting the vocabulary's document count. The engine takes a connection rather than a store: PR 2's store keeps its connection private, and which process may open or rebuild the index file is PR 3b's decision, not a query engine's. * perf(ai-vault-search): price the second FTS table, and re-measure without warmup Open decision 3. A column filter over `messages_fts` returns the identical rowid set as `conversation_fts` — checked here per query rather than assumed — so the table exists for latency alone. On a 105 MB corpus at both ends of the tool-output band, the column-filtered form costs 1.16-1.42x at p95, against a bar of 2x, so the recommendation is to delete it. The shoot-out writes its own corpus because the answer turns on the one property the shared generator fixes: how much of a transcript is tool output. Half the tokens in that output are words the conversation also uses, which is deliberately generous to the table under question. The doc records the number that argues the other way. PR 2 priced the table at about a quarter of the index on a corpus whose tool output is 56% of its message text; on a tool-heavy one it is 6.7-11%, because `messages_fts` grows with the tool text and the second table does not. Page warmup is not re-added. The measurement behind it was on a 4 GB index, removing it moves this corpus by less than the run-to-run spread, and a cancellable background pass needs a lifecycle a query library does not have. * test(ai-vault-search): pin that an append moves the generation a cursor is fenced by * refactor(ai-vault-search): answer the conversation scope with a column filter PR 2 deleted `conversation_fts` on the strength of this PR's shoot-out, so the scope is a column filter over the one FTS table now. `ftsTableFor` is gone; a scope is a pair of `scopedExpression` and `scopedWeights`, and the table name no longer travels through the engine, the snippet builder or a hit. The filter is parenthesised, and that is the whole of it: `{cols}: (a AND b)` binds both terms, while `{cols}: a AND b` binds only the first and searches tool output for the rest. A test drives an AND whose second term lives only in tool output through both scopes. The snippet keeps one guard, not two. Its column list and its expression were each hiding the other's mistakes — a tool-only row was unreachable through either — so the list is the same four columns for every scope and the scoped expression is what makes a conversation snippet impossible to draw out of tool output. Dropping it now leaks that row, which a test catches. One behaviour the deleted table did not have, pinned rather than wished away: bm25 normalises by the whole row's length and has no per-column length, so two rows with identical prose score differently when one also holds tool output. The rowid set is unchanged; the order within it can move. Re-measured on the shipping schema. The conversation scope is 1.2-1.4x faster than `all` at every rung, and the index is 57 MB rather than about 150 MB at 93% tool output, because a tool row is now capped at 3,072 characters. * fix(ai-vault-search): repair a spelling inside the scope that will answer it Typo repair read `messages_vocab` and probed `messages_fts` with no column filter, so tool output decided whether a conversation-scoped query was repaired, in both directions. A tool row carrying the misspelling made the query look correctly spelled and suppressed the repair; a tool row carrying a rare word became the suggestion, naming in `repairedTerms` a string from a column the scope will never show. Both reproduced against a control index that differs by exactly that one row. The vocabulary proposes and a scoped count disposes. fts5vocab is per table and cannot be column-filtered, so every decision that reaches the plan — already spelled right, eligible, and which of two equally close candidates wins — now comes from a `messages_fts MATCH` under the same filter retrieval uses, joined to `sessions`. That also takes the vocabulary's `doc` out of the ranking, which is the half of the drain defect that belongs here: `doc` counts rows whose session a purge has already cut loose, so reclaiming them changed which word a query was repaired to. Candidates are ordered by term now, because the ordering decides which of them survive the scan limit, and ties on similarity go to the more common word counted live rather than to the vocabulary's number. The cost is one bounded count per candidate examined, at most eight per prefix, and only for a term the scope has no posting for at all. * fix(ai-vault-search): fence the rows a purge reclaims after it cuts a session loose Retention's second half deletes from `messages` alone and touched neither `files` nor `sessions`, so it moved no generation. The argument was that those rows answer nothing, which was true of retrieval and not of the engine: the typo repair's dictionary is a view over the FTS b-tree and listed them, so a drain running between two pages swapped the repair under a cursor that was still honoured, and a search that had answered stopped answering. The commit before this one fixes that at its source by counting live rows. It does not make the drain provably inert — the vocabulary still decides which candidates survive its scan limit, and reclaiming a term's last row moves where that limit cuts — so the fence is what covers the rest. A fourth trigger, on `messages`, with a `WHEN` clause that is the whole reason it is affordable: a replace and a `removeFile` delete a session's rows while its `sessions` row still stands, so neither fires, and both already bump through `files`. Only the drain deletes a row whose session is gone. The price is named rather than avoided: a cursor outstanding while a purge runs is now refused once per batch, which `SessionSearchCursorError` reports as `stale-generation` so a caller re-issues page one. The test that pinned the old contract is replaced by one for the new one, and by one proving a replace still does not fire it. * fix(ai-vault-search): tell a highlight from a transcript that contains brackets The snippet builder asked each of a row's four columns for a marked snippet and showed the first whose text contained `[[`. Transcripts contain `[[`: a bash `if [[ -f … ]]`, numpy's `[[1, 2], [3, 4]]`. A row matching only in tool output was shown its user turn instead, with nothing highlighted in it, and the any-column fallback an identifier-only match depends on was unreachable behind the same collision. Whether a column matched is now the difference between two renderings of the same text: `snippet(…, MARK, MARK, …)` beside `snippet(…, '', '', …)`. Content cannot forge a difference between those two, because it is the same content either way. The marks FTS5 inserts are private-use code points, rewritten to the public `[[` and `]]` once, at the end. That is for the other decision that has to tell a mark from content: the truncation refuses to cut between an open mark and its close, and a transcript's own bracket used to move that cut. * fix(ai-vault-search): cut a query on a code point and bind ids in batches Two small ones from the review's not-routed list. `query.slice(0, 512)` can land between the halves of a surrogate pair, leaving a lone half that matches nothing and that a caller cannot echo back. The reader already has `sliceAtCodeUnitLimit` for exactly this. `loadSessions` bound one parameter per candidate id in a single statement. The list is as long as the candidate limit, the tuning doc invites a host to raise that limit, and SQLite's `SQLITE_MAX_VARIABLE_NUMBER` is 999 on builds older than 3.32 — so one settings change away from `too many SQL variables`. Read in batches of 500, leaving room for the filter's own bound values. * docs(ai-vault-search): price the repair rung, and record what is left open Typo repair is the one rung whose cost tracks the vocabulary rather than the result, and it only runs for a term the scope has no posting for. Measured over 1.6 M distinct terms: 10 ms for one unknown term, 387 ms for a 480-character query of thirty-nine of them. The scoped-count fix made that cheaper rather than dearer, from 737 ms, because ordering the vocabulary scan by term drops the sort `doc DESC` needed and the counts it adds are at most eight bounded probes per prefix. A cap on unknown terms per query is a follow-up in the split plan, with the five other items the final review raised and did not route. * test(ai-vault-search): make each snippet mark mechanism answer for itself Two mechanisms landed together and hid each other: choosing a column by comparing a marked rendering against an unmarked one, and marking with private-use code points instead of `[[`. Either alone fixed the bracket repro, so neither had a mutation against it — the same masking the snippet's two column guards had a round ago. They do different jobs, so both stay and each gets the test that needs it. A transcript holding a private-use code point of its own is what the comparison is for; agent output carries Nerd Font glyphs from that block. A snippet past the character ceiling with a bracket after its last real mark is what the private-use marks are for, because the truncation has to find that mark by searching the text. The two one-line fixes get honest framing rather than a mutation neither can have. A lone surrogate is not a token character, so the planner drops it either way and the safe cut is hygiene. And no SQLite this stack runs refuses 1,100 bound ids — 32,766 has been the floor since 3.32 — so the batch is about owning the ceiling here rather than rescuing a reachable failure. * refactor(ai-vault-search): keep the scope's expression with the other expressions Making the typo repair ask its questions in the search's own scope put an import from retrieval into it, and retrieval already owns the repair — a cycle the native audit catches. `scopedExpression` belongs beside `phraseExpression`, `andExpression` and `orExpression` anyway: it builds a MATCH expression, and two callers now need it. The bm25 weights stay in retrieval, where the SQL that uses them is. * docs(ai-vault-search): say which delete paths fire the orphan-reclaim trigger after a replace cuts loose * test(ai-vault-search): make the trigger-restore test exercise the trigger it drops * fix(ai-vault-search): keep a scope nothing could key from widening the search * fix(ai-vault-search): read a fractional or negative cursor generation as malformed * fix(ai-vault-search): highlight only the marks FTS5 inserted, not the text's own * test(ai-vault-search): compare the whole query, so a quoted operator value survives * fix(ai-vault-search): filter routes and fence page reads; simplify query engine * refactor(ai-vault-search): narrow retrieval API and clarify page rejection --- .gitignore | 1 + .../scripts/session-search-query-benchmark.ts | 192 +++++++ .../scripts/session-search-scope-benchmark.ts | 205 ++++++++ .../session-search-tool-heavy-corpus.ts | 152 ++++++ .../agent-session-search-query-tuning.md | 219 ++++++++ .../session-search-engine-test-fixture.ts | 113 +++++ .../session-search-engine-types.ts | 150 ++++++ .../session-search-engine.test.ts | 473 ++++++++++++++++++ .../ai-vault-search/session-search-engine.ts | 259 ++++++++++ .../session-search-fts5-contract.test.ts | 172 +++++++ .../session-search-hit-ranking.test.ts | 102 ++++ .../session-search-hit-ranking.ts | 109 ++++ .../session-search-index-generation.test.ts | 329 ++++++++++++ .../session-search-index-generation.ts | 42 ++ .../session-search-orphan-rows.test.ts | 186 +++++++ .../session-search-page-cursor.ts | 99 ++++ .../session-search-paging.test.ts | 351 +++++++++++++ .../session-search-query-planner.test.ts | 84 ++++ .../session-search-query-planner.ts | 140 ++++++ .../session-search-query-schema.ts | 42 ++ .../session-search-retrieval.ts | 244 +++++++++ .../session-search-row-filter.test.ts | 147 ++++++ .../session-search-row-filter.ts | 90 ++++ .../session-search-sidebar-parity.test.ts | 146 ++++++ .../session-search-snippet-marks.test.ts | 144 ++++++ .../ai-vault-search/session-search-snippet.ts | 171 +++++++ .../session-search-source-presence.ts | 40 ++ .../session-search-typo-policy.test.ts | 80 +++ .../session-search-typo-repair.ts | 163 ++++++ .../session-search-typo-scope.test.ts | 71 +++ .../ai-vault-search-query-operators.test.ts | 119 +++++ src/shared/ai-vault-search-query-operators.ts | 90 ++++ src/shared/ai-vault-session-filters.ts | 128 ++--- 33 files changed, 4990 insertions(+), 63 deletions(-) create mode 100644 config/scripts/session-search-query-benchmark.ts create mode 100644 config/scripts/session-search-scope-benchmark.ts create mode 100644 config/scripts/session-search-tool-heavy-corpus.ts create mode 100644 docs/reference/agent-session-search-query-tuning.md create mode 100644 src/main/ai-vault-search/session-search-engine-test-fixture.ts create mode 100644 src/main/ai-vault-search/session-search-engine-types.ts create mode 100644 src/main/ai-vault-search/session-search-engine.test.ts create mode 100644 src/main/ai-vault-search/session-search-engine.ts create mode 100644 src/main/ai-vault-search/session-search-fts5-contract.test.ts create mode 100644 src/main/ai-vault-search/session-search-hit-ranking.test.ts create mode 100644 src/main/ai-vault-search/session-search-hit-ranking.ts create mode 100644 src/main/ai-vault-search/session-search-index-generation.test.ts create mode 100644 src/main/ai-vault-search/session-search-index-generation.ts create mode 100644 src/main/ai-vault-search/session-search-orphan-rows.test.ts create mode 100644 src/main/ai-vault-search/session-search-page-cursor.ts create mode 100644 src/main/ai-vault-search/session-search-paging.test.ts create mode 100644 src/main/ai-vault-search/session-search-query-planner.test.ts create mode 100644 src/main/ai-vault-search/session-search-query-planner.ts create mode 100644 src/main/ai-vault-search/session-search-query-schema.ts create mode 100644 src/main/ai-vault-search/session-search-retrieval.ts create mode 100644 src/main/ai-vault-search/session-search-row-filter.test.ts create mode 100644 src/main/ai-vault-search/session-search-row-filter.ts create mode 100644 src/main/ai-vault-search/session-search-sidebar-parity.test.ts create mode 100644 src/main/ai-vault-search/session-search-snippet-marks.test.ts create mode 100644 src/main/ai-vault-search/session-search-snippet.ts create mode 100644 src/main/ai-vault-search/session-search-source-presence.ts create mode 100644 src/main/ai-vault-search/session-search-typo-policy.test.ts create mode 100644 src/main/ai-vault-search/session-search-typo-repair.ts create mode 100644 src/main/ai-vault-search/session-search-typo-scope.test.ts create mode 100644 src/shared/ai-vault-search-query-operators.test.ts create mode 100644 src/shared/ai-vault-search-query-operators.ts diff --git a/.gitignore b/.gitignore index e5207a25015..cf2f7244eb3 100644 --- a/.gitignore +++ b/.gitignore @@ -104,6 +104,7 @@ docs/** !docs/mobile-terminal-shortcut-bar.md !docs/reference/ !docs/reference/agent-pty-transcript-capture.md +!docs/reference/agent-session-search-query-tuning.md !docs/reference/agent-status-store.md !docs/reference/antigravity-readiness-evidence.md !docs/reference/git-compatibility.md diff --git a/config/scripts/session-search-query-benchmark.ts b/config/scripts/session-search-query-benchmark.ts new file mode 100644 index 00000000000..1471a0a17bd --- /dev/null +++ b/config/scripts/session-search-query-benchmark.ts @@ -0,0 +1,192 @@ +import { rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { + createSessionParseStats, + parseAgentSessionFileCached, + resetSessionParseCacheForTests +} from '../../src/main/ai-vault/session-scanner-parse-cache' +import { resetTranscriptConsumersForTests } from '../../src/main/ai-vault/session-transcript-consumers' +import { SessionSearchEngine } from '../../src/main/ai-vault-search/session-search-engine' +import type { + SessionSearchRequest, + SessionSearchScope +} from '../../src/main/ai-vault-search/session-search-engine-types' +import { registerSessionSearchIndexConsumer } from '../../src/main/ai-vault-search/session-search-index-consumer' +import { SessionSearchStore } from '../../src/main/ai-vault-search/session-search-store' +import type SyncDatabase from '../../src/main/sqlite/sync-database' +import { + writeSyntheticTranscriptCorpus, + type SyntheticCorpus, + type SyntheticCorpusOptions +} from '../../src/main/ai-vault-search/session-search-synthetic-corpus' +import { sessionCandidate } from '../../src/main/ai-vault-search/session-search-transcript-fixtures' + +// What a query costs, and what the session candidate limit buys. Everything +// runs through the real store and the real engine over a synthetic corpus; +// never point this at a real transcript tree. + +const WARMUP = 5 +const SAMPLES = 25 + +// One query per rung the ladder can take, plus the two shapes that skip it. +const QUERIES: { name: string; request: SessionSearchRequest }[] = [ + { name: 'phrase', request: { query: '"terminal reattach"' } }, + { name: 'identifier', request: { query: 'resolveTerminalPath' } }, + { name: 'path', request: { query: 'src/main/ai-vault/session-transcript-reader.ts' } }, + { name: 'prose', request: { query: 'why is the daemon snapshot stale' } }, + { name: 'typo', request: { query: 'reattahc worktre' } }, + { name: 'common-term', request: { query: 'index' } }, + { name: 'operator-only', request: { query: 'repo:app-3' } }, + { name: 'scoped', request: { query: 'worktree', filters: { scopePaths: ['/repo/app-3'] } } } +] + +type Timing = { p50: number; p95: number } + +function percentile(sorted: readonly number[], fraction: number): number { + const at = Math.min(sorted.length - 1, Math.floor(sorted.length * fraction)) + return Math.round((sorted[at] ?? 0) * 100) / 100 +} + +function timing(samples: number[]): Timing { + const sorted = [...samples].sort((left, right) => left - right) + return { p50: percentile(sorted, 0.5), p95: percentile(sorted, 0.95) } +} + +function time(engine: SessionSearchEngine, request: SessionSearchRequest): number { + const started = performance.now() + engine.search(request) + return performance.now() - started +} + +async function indexCorpus( + options: SyntheticCorpusOptions +): Promise<{ corpus: SyntheticCorpus; db: SyncDatabase; release: () => void }> { + resetSessionParseCacheForTests() + const corpus = await writeSyntheticTranscriptCorpus(options) + const store = new SessionSearchStore(join(corpus.root, 'index.sqlite'), (error) => { + throw error + }) + const unregister = registerSessionSearchIndexConsumer(store) + const stats = createSessionParseStats() + for (const path of corpus.files) { + await parseAgentSessionFileCached( + await sessionCandidate('claude', path), + process.platform, + stats + ) + } + return { + corpus, + // The handle a composed reader gets. Every read here is one synchronous + // statement, which is the contract that comes with it. + db: store.connection, + release: () => { + unregister() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + store.close() + } + } +} + +/** Per-query and overall latency for one scope. */ +function scopeReport(db: SyncDatabase, scope: SessionSearchScope): Record { + const engine = new SessionSearchEngine(db) + const everything: number[] = [] + const perQuery: Record = {} + for (const { name, request } of QUERIES) { + const scoped = { ...request, scope } + for (let run = 0; run < WARMUP; run++) { + engine.search(scoped) + } + const samples = Array.from({ length: SAMPLES }, () => time(engine, scoped)) + everything.push(...samples) + const result = engine.search(scoped) + perQuery[name] = { ...timing(samples), hits: result.hits.length, route: result.planner.route } + } + return { ...timing(everything), perQuery } +} + +/** + * The candidate limit only costs anything once there are more matching sessions + * than the limit, so this runs over many short sessions rather than the wide + * corpus above. Limits are interleaved sample by sample: run back to back, the + * first configuration pays for every page the OS cache had not seen yet and the + * ordering alone moves p95 by more than the limit does. + */ +function candidateSweep(db: SyncDatabase, limits: readonly number[]): Record { + const request: SessionSearchRequest = { query: 'index', limit: 20 } + const engines = new Map( + limits.map((limit) => [limit, new SessionSearchEngine(db, { sessionCandidateLimit: limit })]) + ) + const samples = new Map(limits.map((limit) => [limit, [] as number[]])) + for (let run = 0; run < WARMUP; run++) { + for (const engine of engines.values()) { + engine.search(request) + } + } + for (let run = 0; run < SAMPLES; run++) { + for (const limit of limits) { + samples.get(limit)!.push(time(engines.get(limit)!, request)) + } + } + const report: Record = {} + for (const limit of limits) { + const result = engines.get(limit)!.search(request) + report[String(limit)] = { + ...timing(samples.get(limit)!), + truncated: result.truncated.candidates, + // Pages a caller could walk before the limit stops handing out sessions. + reachablePages: Math.ceil(limit / (request.limit ?? 20)) + } + } + return report +} + +const wide = await indexCorpus({ sessions: Number(process.env.SESSIONS ?? 40) }) +let report: string +try { + const scope = { + all: scopeReport(wide.db, 'all'), + conversation: scopeReport(wide.db, 'conversation') + } + wide.release() + await rm(wide.corpus.root, { recursive: true, force: true }) + + // Many short sessions: what makes the candidate limit binding is the session + // count, not the byte count. + const many = await indexCorpus({ sessions: 2500, turnsPerSession: 1, seed: 7 }) + try { + report = JSON.stringify( + { + scopeCorpus: { + sessions: wide.corpus.files.length, + transcriptMb: Math.round((wide.corpus.transcriptBytes / 1024 / 1024) * 100) / 100, + messages: wide.corpus.messageCount + }, + scope, + candidateCorpus: { + sessions: many.corpus.files.length, + transcriptMb: Math.round((many.corpus.transcriptBytes / 1024 / 1024) * 100) / 100 + }, + candidateSweep: candidateSweep(many.db, [200, 600, 1200, 2400]) + }, + null, + 2 + ) + } finally { + many.release() + await rm(many.corpus.root, { recursive: true, force: true }) + } +} catch (error) { + await rm(wide.corpus.root, { recursive: true, force: true }) + throw error +} + +// Why a file as well as stdout: a runner that intercepts console output +// (vitest does) would otherwise swallow the whole report. +const out = process.env.BENCH_OUT +if (out) { + await writeFile(out, `${report}\n`) +} +console.log(report) diff --git a/config/scripts/session-search-scope-benchmark.ts b/config/scripts/session-search-scope-benchmark.ts new file mode 100644 index 00000000000..306387cbdda --- /dev/null +++ b/config/scripts/session-search-scope-benchmark.ts @@ -0,0 +1,205 @@ +import { rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { + createSessionParseStats, + parseAgentSessionFileCached, + resetSessionParseCacheForTests +} from '../../src/main/ai-vault/session-scanner-parse-cache' +import { resetTranscriptConsumersForTests } from '../../src/main/ai-vault/session-transcript-consumers' +import { SessionSearchEngine } from '../../src/main/ai-vault-search/session-search-engine' +import type { + SessionSearchRequest, + SessionSearchScope +} from '../../src/main/ai-vault-search/session-search-engine-types' +import { registerSessionSearchIndexConsumer } from '../../src/main/ai-vault-search/session-search-index-consumer' +import { SessionSearchStore } from '../../src/main/ai-vault-search/session-search-store' +import { sessionCandidate } from '../../src/main/ai-vault-search/session-search-transcript-fixtures' +import type SyncDatabase from '../../src/main/sqlite/sync-database' +import { writeToolHeavyCorpus, type ToolHeavyCorpus } from './session-search-tool-heavy-corpus' + +// What each scope costs on an index the size of a real transcript tree. +// +// The 10.5 MB corpus in `session-search-query-benchmark.ts` sizes the route +// ladder; this one sizes the corpus. `conversation` is a column filter over the +// one FTS table rather than a second table of its own, and the whole cost of +// that decision is how much of `messages_fts` a conversation query has to read +// past — which is set by how much of a transcript is tool output. +// +// Synthetic, always: this must never be pointed at a real transcript. + +const WARMUP = 5 + +/** Conversation-shaped queries; every term is one the prose actually uses. */ +const QUERIES = [ + 'terminal reattach', + 'stale snapshot', + 'daemon cursor', + 'worktree index', + 'publish transaction', + 'relay daemon', + 'session cursor', + 'because stale', + 'terminal worktree', + 'index snapshot', + 'reattach cursor', + 'transaction relay', + 'snapshot session', + 'daemon publish', + 'worktree terminal', + 'cursor index', + 'stale relay', + 'session transaction', + 'publish snapshot', + 'reattach daemon' +] + +async function indexCorpus( + corpus: ToolHeavyCorpus +): Promise<{ db: SyncDatabase; release: () => void }> { + resetSessionParseCacheForTests() + const store = new SessionSearchStore(join(corpus.root, 'index.sqlite'), (error) => { + throw error + }) + const unregister = registerSessionSearchIndexConsumer(store) + const stats = createSessionParseStats() + for (const path of corpus.files) { + await parseAgentSessionFileCached( + await sessionCandidate('claude', path), + process.platform, + stats + ) + } + return { + // The store's own handle, which is what a composed reader gets: every + // retrieval is one synchronous statement, so nothing pins a WAL snapshot. + db: store.connection, + release: () => { + unregister() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + store.close() + } + } +} + +type Timing = { p50: number; p95: number } + +function timing(samples: readonly number[]): Timing { + const sorted = [...samples].sort((left, right) => left - right) + const at = (fraction: number): number => { + const index = Math.min(sorted.length - 1, Math.floor(sorted.length * fraction)) + return Math.round((sorted[index] ?? 0) * 100) / 100 + } + return { p50: at(0.5), p95: at(0.95) } +} + +/** + * The query sets, one per rung of the ladder the engine may take. + * + * Which rung each one reaches is not forced, it is observed: samples are + * bucketed by the route the engine reports, so the table says what was measured + * rather than what was intended, and a query that lands on a different rung + * than expected shows up as a bucket rather than as a wrong number. + */ +function queries(): string[] { + const run = (index: number, length: number): string => + Array.from({ length }, (_unused, step) => QUERIES[(index + step) % QUERIES.length]).join(' ') + return [ + // Two terms, unquoted: not literal, so straight to OR. + ...QUERIES, + // Two terms, quoted: literal, and on this corpus any two of fourteen words + // sit next to each other somewhere, so the phrase rung answers. + ...QUERIES.map((query) => `"${query}"`), + // Eight terms, quoted: an ordered run that long does not occur in 105 MB of + // draws from fourteen words, so the phrase rung misses and AND answers. + ...QUERIES.map((_query, index) => `"${run(index, 4)}"`) + ] +} + +type Bucket = { samples: number[]; hits: number } + +/** + * Both scopes over the same queries, interleaved scope by scope: run back to + * back, the first one pays for every page the OS cache had not seen and the + * ordering moves p95 more than the scope does. + */ +function scopeReport(db: SyncDatabase): Record { + const engine = new SessionSearchEngine(db) + const scopes: SessionSearchScope[] = ['all', 'conversation'] + const requests: SessionSearchRequest[] = queries().map((query) => ({ query })) + const buckets = new Map() + for (let run = 0; run < WARMUP; run++) { + for (const scope of scopes) { + for (const request of requests) { + engine.search({ ...request, scope }) + } + } + } + for (const request of requests) { + for (const scope of scopes) { + const started = performance.now() + const result = engine.search({ ...request, scope }) + const elapsed = performance.now() - started + const key = `${result.planner.route}/${scope}` + const bucket = buckets.get(key) ?? { samples: [], hits: 0 } + bucket.samples.push(elapsed) + bucket.hits += result.hits.length + buckets.set(key, bucket) + } + } + const report: Record = {} + for (const [key, bucket] of [...buckets].sort(([left], [right]) => left.localeCompare(right))) { + report[key] = { ...timing(bucket.samples), samples: bucket.samples.length, hits: bucket.hits } + } + return report +} + +/** Bytes the FTS table occupies, which is the cost the deleted second table saved. */ +function indexBytes(db: SyncDatabase): Record | { unavailable: string } { + try { + const sum = (where: string, ...values: string[]): number => + Number( + ( + db + .prepare(`SELECT COALESCE(SUM(pgsize),0) AS bytes FROM dbstat ${where}`) + .get(...values) as { bytes: number } + ).bytes + ) + return { total: sum(''), messagesFts: sum('WHERE name LIKE ?', 'messages_fts%') } + } catch { + // dbstat is a compile-time option; the latency numbers stand without it. + return { unavailable: 'no dbstat' } + } +} + +const corpus = await writeToolHeavyCorpus({ + targetBytes: Number(process.env.CORPUS_MB ?? 100) * 1024 * 1024, + toolShare: Number(process.env.TOOL_SHARE ?? 0.9) +}) +let report: string +const indexed = await indexCorpus(corpus) +try { + report = JSON.stringify( + { + corpus: { + sessions: corpus.files.length, + transcriptMb: Math.round((corpus.transcriptBytes / 1024 / 1024) * 100) / 100, + toolShareOfMessageText: + Math.round((corpus.toolBytes / (corpus.toolBytes + corpus.proseBytes)) * 1000) / 1000 + }, + indexBytes: indexBytes(indexed.db), + route: scopeReport(indexed.db) + }, + null, + 2 + ) +} finally { + indexed.release() + await rm(corpus.root, { recursive: true, force: true }) +} + +const out = process.env.BENCH_OUT +if (out) { + await writeFile(out, `${report}\n`) +} +console.log(report) diff --git a/config/scripts/session-search-tool-heavy-corpus.ts b/config/scripts/session-search-tool-heavy-corpus.ts new file mode 100644 index 00000000000..050535a00cf --- /dev/null +++ b/config/scripts/session-search-tool-heavy-corpus.ts @@ -0,0 +1,152 @@ +import { mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// The corpus the scope benchmark runs over. Written here rather than by +// `session-search-synthetic-corpus.ts` because what it costs to answer a +// conversation query out of the one FTS table turns on the property that +// generator fixes: how much of a transcript is tool output. +// +// Synthetic, always. This must never be pointed at a real transcript. + +const PROSE = [ + 'terminal', + 'reattach', + 'worktree', + 'the', + 'index', + 'cursor', + 'publish', + 'transaction', + 'relay', + 'daemon', + 'snapshot', + 'because', + 'stale', + 'session' +] +// Tool output is paths, hashes and log lines — and the same words the +// conversation uses, because a `rg` over this repository prints them. That +// overlap is what the benchmark turns on: it is what makes a conversation +// term's posting list carry rows the column filter then has to discard. A tool +// vocabulary disjoint from the prose would leave nothing to discard and measure +// the wrong thing. +const TOOL_ONLY = [ + 'src/main/ai-vault/session-transcript-reader.ts', + 'node_modules/.pnpm/typescript@5.9.2', + '0x00007ff8', + 'ENOENT', + 'drwxr-xr-x', + '2026-09-10T00:00:00.000Z', + 'sha256:9f2c1a', + 'chunk-VHQ4NWQK.js', + 'warning:', + 'resolveTerminalPath', + 'byteOffset', + 'MAX_RETRIES' +] +// Half the tool tokens are conversation words. Deliberately pessimistic: the +// more of a query term lives in `tool_text`, the more the column filter costs, +// so a number measured here holds on a real transcript tree. +const TOOL = [...PROSE, ...TOOL_ONLY] + +function mulberry32(seed: number): () => number { + let state = seed >>> 0 + return () => { + state = (state + 0x6d2b79f5) >>> 0 + let t = Math.imul(state ^ (state >>> 15), 1 | state) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +function words(random: () => number, vocabulary: readonly string[], count: number): string { + const out: string[] = [] + for (let index = 0; index < count; index++) { + out.push(vocabulary[Math.floor(random() * vocabulary.length)]!) + } + return out.join(' ') +} + +export type ToolHeavyCorpus = { + root: string + files: string[] + transcriptBytes: number + toolBytes: number + proseBytes: number +} + +/** + * Claude JSONL transcripts whose tool output is `toolShare` of the message text. + * One turn is a user question, an assistant answer, a tool call and its output; + * only the last one grows with the share. + */ +export async function writeToolHeavyCorpus(args: { + targetBytes: number + toolShare: number + seed?: number +}): Promise { + const random = mulberry32(args.seed ?? 11) + const root = await mkdtemp(join(tmpdir(), 'orca-search-convfts-')) + const files: string[] = [] + const proseWordsPerTurn = 160 + // Tool and prose words are not the same length, so the share is over bytes. + const proseBytesPerTurn = proseWordsPerTurn * 6 + const toolWordCount = Math.max( + 1, + Math.round((proseBytesPerTurn * args.toolShare) / (1 - args.toolShare) / 22) + ) + let transcriptBytes = 0 + let toolBytes = 0 + let proseBytes = 0 + for (let session = 0; transcriptBytes < args.targetBytes; session++) { + const sessionId = `00000000-0000-4000-8000-${String(session).padStart(12, '0')}` + const lines: string[] = [] + for (let turn = 0; turn < 40; turn++) { + const at = new Date(1740000000000 + turn * 60_000).toISOString() + const question = words(random, PROSE, 40) + const answer = words(random, PROSE, proseWordsPerTurn - 40) + const output = words(random, TOOL, toolWordCount) + proseBytes += Buffer.byteLength(question) + Buffer.byteLength(answer) + toolBytes += Buffer.byteLength(output) + lines.push( + JSON.stringify({ + type: 'user', + sessionId, + timestamp: at, + cwd: `/repo/app-${session % 7}`, + gitBranch: 'main', + message: { role: 'user', content: question } + }), + JSON.stringify({ + type: 'assistant', + sessionId, + timestamp: at, + message: { + role: 'assistant', + model: 'claude-fable-5', + content: [ + { type: 'text', text: answer }, + { type: 'tool_use', name: 'Bash', input: { command: 'rg needle' } } + ] + } + }), + JSON.stringify({ + type: 'user', + sessionId, + timestamp: at, + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: output }] + } + }) + ) + } + const path = join(root, `${sessionId}.jsonl`) + const body = `${lines.join('\n')}\n` + await writeFile(path, body) + transcriptBytes += Buffer.byteLength(body) + files.push(path) + } + return { root, files, transcriptBytes, toolBytes, proseBytes } +} diff --git a/docs/reference/agent-session-search-query-tuning.md b/docs/reference/agent-session-search-query-tuning.md new file mode 100644 index 00000000000..9e18097cb9e --- /dev/null +++ b/docs/reference/agent-session-search-query-tuning.md @@ -0,0 +1,219 @@ +# Agent session search: query tuning + +What a search costs, and what the knobs in `src/main/ai-vault-search/session-search-engine.ts` +buy. Every number here comes from `config/scripts/session-search-query-benchmark.ts` +over the synthetic corpus in `session-search-synthetic-corpus.ts`, except the +`conversation_fts` shoot-out, which writes its own corpus because the answer +turns on how much of a transcript is tool output. Nothing in this file was +measured against a real transcript, and neither benchmark must ever be pointed +at one. + +## Running it + +The benchmark is a top-level-await module that imports the main-process tree by +extensionless path, so it needs a bundler-backed runner rather than bare `node`: + +```sh +cat > src/main/ai-vault-search/zz-bench.test.ts <<'EOF' +import { it } from 'vitest' +it('runs', { timeout: 1_800_000 }, async () => { + await import('../../../config/scripts/session-search-query-benchmark') +}) +EOF +BENCH_OUT=/tmp/ss-query-bench.json pnpm test src/main/ai-vault-search/zz-bench.test.ts +rm src/main/ai-vault-search/zz-bench.test.ts +``` + +The `conversation_fts` shoot-out below runs the same way, importing +`config/scripts/session-search-conversation-fts-benchmark` instead, with +`CORPUS_MB` and `TOOL_SHARE` to size and shape its corpus. `config/scripts` is +not inside any typecheck project, so while that throwaway test exists `tsc` +reports TS6307 for each script it pulls in; delete it and the run is clean +again. + +`BENCH_OUT` exists because vitest intercepts `console.log`; the report is written +to that path as well as printed. + +## Scope: what the second FTS table buys a reader + +Corpus: 40 synthetic Claude transcripts, 10.5 MB, 9,600 messages, indexed through +the real store. Eight queries, one per rung of the route ladder plus the two +shapes that skip it; 5 warm-up runs and 25 samples each. Apple silicon, warm page +cache, machine otherwise idle. Milliseconds, and p95 over 25 samples moves +several milliseconds run to run if anything else is competing for the disk. + +| Scope | p50 | p95 | +| -------------- | ---- | ---- | +| `all` | 7.22 | 8.94 | +| `conversation` | 5.33 | 7.86 | + +Per query, `all` then `conversation` (p50 / p95): + +| Query | `all` | `conversation` | +| ------------------------------------------------ | ------------ | -------------- | +| `"terminal reattach"` (phrase) | 5.24 / 8.42 | 2.97 / 3.24 | +| `resolveTerminalPath` (identifier) | 7.55 / 8.94 | 6.47 / 6.72 | +| `src/main/…/session-transcript-reader.ts` (path) | 8.69 / 10.12 | 7.78 / 8.04 | +| `why is the daemon snapshot stale` (prose) | 7.84 / 8.57 | 5.90 / 7.01 | +| `reattahc worktre` (typo repair) | 7.30 / 7.39 | 5.53 / 5.89 | +| `index` (common term) | 5.45 / 5.66 | 3.81 / 4.02 | +| `repo:app-3` (operator only) | 0.12 / 0.16 | 0.10 / 0.10 | +| `worktree` scoped to one cwd | 1.47 / 1.63 | 1.25 / 1.49 | + +Reading it: + +- `conversation` is about 1.4x faster at p50 and 1.1x at p95, and it is a column + filter over the same table rather than a table of its own. Narrowing to the + two prose columns is what buys the gap: fewer postings to score. It is also + the scope where a match is something a person wrote rather than something a + tool printed. +- A `scopePaths` query is the cheapest real search on the page. It is the one + narrowing SQL can express exactly, so it seeks `sessions_cwd_key` and hands + ranking a small candidate set. +- The operator-only figure is a floor, not a typical cost. `repo:` and `path:` + are applied in JS over retrieved rows (see `session-search-row-filter` for why + they cannot be pushed into SQL), so their cost tracks how many sessions the + walk has to read before it fills a candidate set. This corpus has 40 sessions, + which is one page of that walk; an index where few sessions match the operator + will read up to the ceiling in `session-search-retrieval` instead. + +## What the conversation scope costs at real corpus size + +`conversation` was a second FTS table holding a copy of the two prose columns. +It is a column filter now — `{user_text assistant_text}: (…)` with bm25 weights +that zero the other two — and PR 2 deleted the table on the strength of the +shoot-out this section used to hold: the filter came in at 1.16-1.36x the p95 of +the dedicated table, under the 2x bar, while the table cost a tenth of the index +to maintain. What follows is what the shipped schema actually does, measured +again on the same corpus after the table went and tool rows were capped. + +Corpus: Claude transcripts from `config/scripts/session-search-tool-heavy-corpus.ts`, +105 MB, indexed through the real store, at two points in the 80-97% band a real +transcript tree sits in. Half the tokens in tool output are words the +conversation also uses, so a conversation term really does have postings the +filter must discard. Twenty queries per rung, both scopes interleaved query by +query, warm cache; `config/scripts/session-search-scope-benchmark.ts`, run twice. + +| Tool share | Rung | `all` p50 / p95 | `conversation` p50 / p95 | +| ---------- | ------ | --------------- | ------------------------ | +| 86% | phrase | 16.69 / 17.48 | 13.08 / 13.52 | +| 86% | or | 31.91 / 35.74 | 22.25 / 23.87 | +| 86% | and | 70.04 / 74.00 | 53.47 / 59.39 | +| 93% | phrase | 9.14 / 13.36 | 7.23 / 8.51 | +| 93% | or | 16.46 / 18.70 | 12.34 / 14.88 | +| 93% | and | 39.65 / 43.44 | 31.05 / 32.92 | + +Three things to read out of it. + +**The filter is a win, not a cost.** Every rung is faster narrow than wide, by +1.2x to 1.4x at p50. The shoot-out compared the filter against a table built for +exactly this query; against the wide table it replaces, it does what the second +table did, which is read fewer postings. + +**The `and` rung is where the corpus size shows.** Those queries are eight terms, +chosen so no ordered run that long occurs and the phrase rung has to miss; a +real two-term AND sits nearer the phrase row. It is also the noisiest: the +second run's p95 reached 140 ms on one bucket, which is what twenty samples of a +70 ms query buys. Read the p50 column. + +**The index is far smaller than the shoot-out's was.** 57 MB at 93% tool output +and 103 MB at 86%, against roughly 150 MB for `messages_fts` alone before PR 2 +capped an indexed tool row at 3,072 characters. Most of a tool-heavy transcript +is now not in the index at all, which moves every number above and is the larger +effect of the two. + +What is **not** measured here is relevance, and the column filter does carry one +ranking difference the deleted table did not. FTS5's bm25 normalises by the +whole row's length and has no per-column length, so two rows with identical +prose score differently when one also holds tool output. The rowid set is +unchanged, which is what the deletion was decided on; the order within it can +move. `session-search-engine.test.ts` pins the direction. + +## `sessionCandidateLimit` + +The reviewer's F13: this is a tunable default, not a constant. It bounds how many +sessions the SQL hands ranking, so it bounds both retrieval cost and how deep a +caller can page before the answer simply stops. + +The limit only costs anything once more sessions match than the limit allows, so +this is measured over a second corpus: 2,500 one-turn transcripts, 10.9 MB, every +one of them matching the query. Limits are interleaved sample by sample, because +run back to back the first configuration pays for every page the OS cache had not +seen and the ordering alone moves p95 further than the limit does. + +| Limit | p50 | p95 | Pages of 20 a caller can reach | +| ----- | ----- | ----- | ------------------------------ | +| 200 | 6.85 | 7.21 | 10 | +| 600 | 7.93 | 8.36 | 30 | +| 1200 | 9.55 | 10.53 | 60 | +| 2400 | 12.32 | 13.45 | 120 | + +600 is the default: it costs about 16% over 200 at p50 and buys three times the +reachable depth, and the curve only turns steep past 1200. A host with a much +larger index can raise it; the result's `truncated.candidates` says when the limit +was the thing that cut the answer, so a caller never has to guess. + +What is **not** measured here is relevance. These numbers say what a limit costs, +not what it retrieves. The MRR figures quoted in the BM25 weights +(`session-search-retrieval.ts`) and in the identifier shadow column +(`session-search-identifier-split.ts`) come from the original retrieval shoot-out +on real transcripts and are not reproducible from this repository. Any change to +the limit justified on relevance grounds needs an eval set, not this benchmark. + +## What typo repair costs + +The repair is the one rung whose cost tracks the size of the vocabulary rather +than the size of a result. It only runs for a term the scope has no posting for, +so an ordinary query never pays it; a query of nonsense pays it once per term. + +Measured over a synthetic vocabulary of 1.6 M distinct terms, every term in two +rows so none is filtered out: + +| Query | p50 | +| -------------------------------------- | ------ | +| one known term (no repair) | 11 ms | +| one unknown term | 10 ms | +| 39 unknown 12-character terms (480 ch) | 387 ms | +| 12 unknown 40-character terms | 99 ms | + +Two things follow. The cost is linear in unknown terms and in vocabulary size, +and `search` is synchronous, so a 512-character query of nonsense holds the +thread for a third of a second on an index that large. And the scoped-count fix +made this cheaper rather than dearer — it was 737 ms before — because ordering +the vocabulary scan by term drops the sort that ordering by `doc` required, and +the counts it added are at most eight bounded probes per prefix. A cap on +unknown terms per query is recorded as a follow-up in the split plan. + +## Page warmup, dropped + +PR 2 deferred `warm()` — a sliced read of `messages` that pulls its pages into +the OS cache before the first query — to whoever knew which pages a read +touches. It is not re-added here, for two reasons. The measurement that +justified it (first query 1.3 s to 0.45 s) was on a 4 GB index, and neither +corpus in this file is within an order of magnitude of that, so PR 4 cannot +show a win: removing the call moved the 10.5 MB corpus's p50 by less than the +run-to-run spread. And it is a cancellable background pass, which needs an owner +with a lifecycle; a query library that holds no timers has nothing to hang the +`stopped()` on, and a fire-and-forget async read from a synchronous `search` is +a rejection nothing can supervise. It belongs with the indexer in PR 3b, which +already owns starting and stopping work. + +## Not settled here + +Which process may open, unlink and rebuild the index is PR 3b's decision. A +second handle that finds an older schema version replaces the file while a live +store keeps answering from the unlinked inode, and this PR is what first makes +that reachable, because it is the first thing that reads. What PR 4 does is +refuse to make it worse. The engine restores its derived vocabulary and generation +triggers before a search. A missing `messages_fts` fails clearly; the connection +owner must rebuild the source index. There is no degraded-search capability state +or query logging. Logging can be added by a caller when an evaluation consumer exists. + +Each search checks the generation before retrieval and after its final content +read. A concurrent commit rejects the page with `stale-generation`, including a +first page without a cursor. The caller can retry from page one. No long-lived +read transaction is needed, and a mixed page is never returned as a valid snapshot. + +Repository/path operators are applied before a phrase or AND route is accepted. +Candidate truncation remains explicit, including when an earlier route reached +its cap but had no eligible sessions. diff --git a/src/main/ai-vault-search/session-search-engine-test-fixture.ts b/src/main/ai-vault-search/session-search-engine-test-fixture.ts new file mode 100644 index 00000000000..694a3d6397f --- /dev/null +++ b/src/main/ai-vault-search/session-search-engine-test-fixture.ts @@ -0,0 +1,113 @@ +import type { TranscriptMessageRole } from '../ai-vault/session-transcript-consumers' +import type SyncDatabase from '../sqlite/sync-database' +import { SessionSearchEngine, type SessionSearchEngineOptions } from './session-search-engine' +import { cwdKey } from './session-search-file-records' +import { identifierShadowText } from './session-search-identifier-split' +import { SessionSearchStore } from './session-search-store' +import { + openSessionSearchIndexFile, + type SessionSearchIndexFile +} from './session-search-index-test-fixture' + +// Synthetic index rows for the query tests. The write path has its own tests; +// driving it here would make every retrieval assertion depend on the parser. + +export type SessionSearchHarness = { + /** The engine's own connection; the store next to it keeps a second, private one. */ + db: SyncDatabase + /** A real writer on the same file, so a test can move the index under the engine. */ + store: SessionSearchStore + engine: SessionSearchEngine + close: () => Promise +} + +export async function openSessionSearchHarness( + name: string, + options: SessionSearchEngineOptions = {} +): Promise { + const index: SessionSearchIndexFile = await openSessionSearchIndexFile(name) + const store = new SessionSearchStore(index.path, (error) => { + throw error + }) + // Constructed before any row is planted, because constructing it is what + // installs the generation triggers the planted rows have to move. + const engine = new SessionSearchEngine(index.db, options) + return { + db: index.db, + store, + engine, + close: async () => { + store.close() + await index.close() + } + } +} + +export type SyntheticSession = { + id: number + cwd?: string | null + text?: string + /** Rows of `text` to write; one session with many rows is one hit. */ + rows?: number + role?: TranscriptMessageRole + /** + * Written into `tool_text` alongside `text`, which is the one row shape the + * conversation scope has to exclude while the `all` scope keeps it. + */ + toolText?: string + agent?: string + updatedAt?: string + messageCount?: number + /** Written into `files`, which is what makes the source `present`. */ + filePath?: string | null + /** `sessions.file_path`: the transcript `path:` searches alongside cwd. */ + sessionFilePath?: string +} + +/** One session and its message rows, in both FTS tables the way the writer does. */ +export function addSyntheticSession(db: SyncDatabase, session: SyntheticSession): void { + const { + id, + cwd = '/repo/app', + text = 'needle', + rows = 1, + role = 'user', + toolText = '', + agent = 'claude', + updatedAt = `2026-09-${String((id % 28) + 1).padStart(2, '0')}T00:00:00.000Z`, + messageCount = rows, + filePath = `/synthetic/${id}.jsonl`, + sessionFilePath = `/synthetic/${id}.jsonl` + } = session + db.prepare( + `INSERT INTO sessions(id,agent,session_id,file_path,title,cwd,cwd_key,updated_at,message_count,resume_command) + VALUES (?,?,?,?,'fixture',?,?,?,?,'resume')` + ).run(id, agent, String(id), sessionFilePath, cwd, cwdKey(cwd), updatedAt, messageCount) + if (filePath !== null) { + db.prepare( + 'INSERT INTO files(path,byte_offset,mtime_ms,session_row_id) VALUES (?,0,1740000000000,?)' + ).run(filePath, id) + } + for (let row = 0; row < rows; row++) { + const messageId = Number( + db + .prepare('INSERT INTO messages(session_row_id,role,ts) VALUES (?,?,?)') + .run(id, role, updatedAt).lastInsertRowid + ) + const user = role === 'user' ? text : '' + const assistant = role === 'assistant' ? text : '' + const tool = role === 'tool' ? `${text} ${toolText}`.trim() : toolText + db.prepare( + 'INSERT INTO messages_fts(rowid,user_text,assistant_text,tool_text,identifiers) VALUES (?,?,?,?,?)' + ).run(messageId, user, assistant, tool, identifierShadowText(`${text} ${toolText}`)) + } +} + +export function markFork(db: SyncDatabase, ids: readonly number[], hash: string): void { + for (const id of ids) { + db.prepare('UPDATE sessions SET content_hash = ?, content_hash_count = 8 WHERE id = ?').run( + hash, + id + ) + } +} diff --git a/src/main/ai-vault-search/session-search-engine-types.ts b/src/main/ai-vault-search/session-search-engine-types.ts new file mode 100644 index 00000000000..055bbe001af --- /dev/null +++ b/src/main/ai-vault-search/session-search-engine-types.ts @@ -0,0 +1,150 @@ +import type { AiVaultAgent } from '../../shared/ai-vault-types' +import type { TranscriptMessageRole } from '../ai-vault/session-transcript-consumers' + +// ENGINE types, deliberately not in src/shared: nothing here is a wire type. +// PR 5 owns the public contract and lifts what a caller may actually receive; +// until then a field can be added, renamed or dropped without a compat story. + +export const SESSION_SEARCH_LIMIT_DEFAULT = 20 +export const SESSION_SEARCH_LIMIT_MAX = 100 +// Longer than this is not a query, and FTS5 pays for every term it plans. +export const SESSION_SEARCH_QUERY_MAX_LENGTH = 512 + +// Snippet match markers. Why doubled: single brackets are everywhere in code +// transcripts (`arr[0]`, regex classes, markdown links) and would read as +// matches; doubled ones are rare. +export const SESSION_SEARCH_SNIPPET_MARK_OPEN = '[[' +export const SESSION_SEARCH_SNIPPET_MARK_CLOSE = ']]' + +/** + * Which corpus answers the query. + * + * - `conversation`: user and assistant turns only, as a column filter over + * `messages_fts` (see `scopedExpression`). + * - `all`: those turns plus tool calls and tool output, and the identifier + * shadow column, from `messages_fts`. + * + * The engine searches exactly the scope it is given. Switching corpus as the + * user types is a UI policy and lives in the panel (PR 7); an engine that + * second-guessed the scope would make a result impossible to reproduce from + * its own request. + */ +export type SessionSearchScope = 'conversation' | 'all' + +export type SessionSearchSort = 'relevance' | 'newest' + +export type SessionSearchFilters = { + agents?: readonly AiVaultAgent[] + /** Only sessions whose cwd is that path or inside it. */ + scopePaths?: readonly string[] + /** ISO timestamp; only sessions updated at or after it. */ + since?: string + sort?: SessionSearchSort +} + +export type SessionSearchRequest = { + query: string + /** Default `all`. */ + scope?: SessionSearchScope + limit?: number + /** From a previous response's `page.cursor`; only valid in its own generation. */ + cursor?: string + filters?: SessionSearchFilters +} + +export type SessionSearchRoute = 'phrase' | 'and' | 'or' | 'typo+phrase' | 'typo+and' | 'typo+or' + +/** + * How the query was executed. Diagnostics, not an answer: PR 5 decides which of + * these a caller ever sees (the reviewer's F5/F7 want them behind `debug`). + */ +export type SessionSearchPlannerReport = { + route: SessionSearchRoute + /** + * The whole body the repaired plan searched, in query order, when any term + * was changed. Not just the corrected terms: a caller rendering "searched + * for" needs the query it actually ran, and a repair never drops a term the + * original kept. A corrected term carries the index's own spelling, which the + * tokenizer has case-folded; untouched terms keep the case they were typed in. + */ + repairedTerms?: string[] + /** The corpus the route ran against; today always the requested scope. */ + tier: SessionSearchScope +} + +/** + * Where a source stands according to the index's own `files` table. The query + * path never stats a transcript, so it can report that the index has a live + * file record for a session or that it has none, and never that a source is + * gone: only a proven deletion may claim `missing`, and proving one is the + * indexer's job (docs/reference/ssh-execution-boundary.md). + */ +export type SessionSearchSourcePresence = 'present' | 'unverifiable' + +export type SessionSearchEvidence = { + role: TranscriptMessageRole + timestamp: string | null + /** FTS5 snippet with the matched terms wrapped in `[[` `]]`. */ + snippet: string + /** The snippet hit the engine's per-hit ceiling and was cut. */ + snippetTruncated?: boolean +} + +export type SessionSearchHit = { + agent: AiVaultAgent + sessionId: string + filePath: string + codexHome: string | null + title: string + cwd: string | null + branch: string | null + updatedAt: string | null + messageCount: number + resumeCommand: string + score: number + /** Sessions folded into this hit (forks sharing an opening prefix); absent when unique. */ + duplicateCount?: number + source: SessionSearchSourcePresence + /** Null when the operators alone put this session on the page, with no text match. */ + evidence: SessionSearchEvidence | null +} + +export type SessionSearchPage = { + /** Null when this page is the last one. */ + cursor: string | null + hasMore: boolean +} + +export type SessionSearchTruncation = { + /** + * Ranking saw only the first `sessionCandidateLimit` sessions, so a session + * past that cut cannot appear on any page of this query. + */ + candidates: boolean + /** Hits on this page whose snippet was cut. */ + snippets: number + /** + * The query itself was cut before it was searched: past the length ceiling, + * or past the number of terms the planner will plan. The terms that survived + * were searched in full, so a hit is still a hit; a miss is not proof of + * absence. + */ + query: boolean +} + +export type SessionSearchResponse = { + hits: SessionSearchHit[] + planner: SessionSearchPlannerReport + page: SessionSearchPage + truncated: SessionSearchTruncation + /** The index snapshot these hits came from; a cursor is only valid within it. */ + generation: number + durationMs: number +} + +export function resolveSessionSearchLimit(limit: number | undefined): number { + // Why clamped here and not at the caller: a non-positive limit becomes + // `slice(0, -1)`, which silently drops the last hit of every page. + const requested = Number.isInteger(limit) ? (limit as number) : SESSION_SEARCH_LIMIT_DEFAULT + return Math.min(Math.max(1, requested), SESSION_SEARCH_LIMIT_MAX) +} diff --git a/src/main/ai-vault-search/session-search-engine.test.ts b/src/main/ai-vault-search/session-search-engine.test.ts new file mode 100644 index 00000000000..6247144d4dc --- /dev/null +++ b/src/main/ai-vault-search/session-search-engine.test.ts @@ -0,0 +1,473 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { SESSION_SEARCH_QUERY_MAX_LENGTH } from './session-search-engine-types' +import type { SessionSearchRequest, SessionSearchResponse } from './session-search-engine-types' +import { planSessionSearchQuery } from './session-search-query-planner' +import { ensureSessionSearchQuerySchema } from './session-search-query-schema' +import { EMPTY_SNIPPET, sessionSearchSnippet } from './session-search-snippet' +import { + addSyntheticSession, + markFork, + openSessionSearchHarness, + type SessionSearchHarness +} from './session-search-engine-test-fixture' + +let harness: SessionSearchHarness | null = null + +afterEach(async () => { + await harness?.close() + harness = null +}) + +async function open(name: string, options = {}): Promise { + harness = await openSessionSearchHarness(name, options) + return harness +} + +function ids(result: SessionSearchResponse): string[] { + return result.hits.map((hit) => hit.sessionId) +} + +describe('the route ladder tries phrase, then AND, then repair, then OR', () => { + async function routeFor( + text: string, + request: SessionSearchRequest + ): Promise { + const { db, engine } = await open('ss-engine-route') + addSyntheticSession(db, { id: 1, text }) + return engine.search(request) + } + + it('takes the phrase route when the tokens are adjacent and in order', async () => { + const result = await routeFor('the alpha beta gamma line', { query: '"alpha beta"' }) + expect(result.planner.route).toBe('phrase') + expect(ids(result)).toEqual(['1']) + }) + + it('falls to AND when the tokens are present but not adjacent', async () => { + const result = await routeFor('beta separated alpha', { query: '"alpha beta"' }) + expect(result.planner.route).toBe('and') + expect(ids(result)).toEqual(['1']) + }) + + it('falls to OR for prose, where no phrase was ever claimed', async () => { + const result = await routeFor('the relay dropped a frame', { query: 'relay frames dropped' }) + expect(result.planner.route).toBe('or') + expect(ids(result)).toEqual(['1']) + }) + + it('repairs a typo before the OR fallback, and says which terms it changed', async () => { + const { db, engine } = await open('ss-engine-typo') + // Two copies: the repair only suggests a term the index really holds. + addSyntheticSession(db, { id: 1, text: 'the coalesces path is slow' }) + addSyntheticSession(db, { id: 2, text: 'coalesces again here' }) + const result = engine.search({ query: 'coalescs' }) + expect(result.planner.route).toBe('typo+or') + expect(result.planner.repairedTerms).toEqual(['coalesces']) + expect(ids(result).sort()).toEqual(['1', '2']) + }) + + it('keeps every term a repaired literal was typed with', async () => { + const { db, engine } = await open('ss-engine-typo-literal') + addSyntheticSession(db, { id: 1, text: 'parseJson the data' }) + addSyntheticSession(db, { id: 2, text: 'parseJson the data again' }) + // `parseJsonn(the, data)` is literal because of its punctuation; the + // corrected spelling read on its own is prose. Re-planning without carrying + // the original decision across would drop `the` and report a body that was + // never typed. + // A corrected term comes back in the index's own spelling, which unicode61 + // has folded; the terms the repair left alone keep the case they were typed. + const result = engine.search({ query: 'parseJsonn(the, data)' }) + expect(result.planner.repairedTerms).toEqual(['parsejson', 'the', 'data']) + }) + + it('does not repair a term the index already holds', async () => { + const { db, engine } = await open('ss-engine-no-typo') + addSyntheticSession(db, { id: 1, text: 'coalesces' }) + const result = engine.search({ query: 'coalesces' }) + expect(result.planner.repairedTerms).toBeUndefined() + expect(result.planner.route).toBe('or') + }) + + it('reports the scope it searched as the planner tier', async () => { + const { db, engine } = await open('ss-engine-tier') + addSyntheticSession(db, { id: 1, text: 'needle' }) + expect(engine.search({ query: 'needle' }).planner.tier).toBe('all') + expect(engine.search({ query: 'needle', scope: 'conversation' }).planner.tier).toBe( + 'conversation' + ) + }) +}) + +describe('scope picks the corpus and never switches it', () => { + async function corpus(): Promise { + const opened = await open('ss-engine-scope') + addSyntheticSession(opened.db, { id: 1, text: 'harbor pilot manifest', role: 'user' }) + addSyntheticSession(opened.db, { id: 2, text: 'harbor tool output line', role: 'tool' }) + return opened + } + + it('searches conversation turns only under `conversation`', async () => { + const { engine } = await corpus() + expect(ids(engine.search({ query: 'harbor', scope: 'conversation' }))).toEqual(['1']) + }) + + it('includes tool output under `all`, which is the default', async () => { + const { engine } = await corpus() + expect(ids(engine.search({ query: 'harbor', scope: 'all' })).sort()).toEqual(['1', '2']) + expect(ids(engine.search({ query: 'harbor' })).sort()).toEqual(['1', '2']) + }) + + it('returns nothing rather than widening when the narrow scope misses', async () => { + // The panel's two-tier typing is a UI policy (PR 7). An engine that widened + // here would make a result impossible to reproduce from its own request. + const { engine } = await corpus() + const result = engine.search({ query: 'output', scope: 'conversation' }) + expect(result.hits).toEqual([]) + expect(result.planner.tier).toBe('conversation') + }) + + it('matches an identifier through its pieces only in the full corpus', async () => { + const { db, engine } = await open('ss-engine-identifiers') + addSyntheticSession(db, { id: 1, text: 'resolveTerminalPath' }) + // The identifier shadow column lives in messages_fts alone. + expect(ids(engine.search({ query: 'terminal path' }))).toEqual(['1']) + expect(engine.search({ query: 'terminal path', scope: 'conversation' }).hits).toEqual([]) + }) +}) + +describe('the conversation scope is a column filter, and it binds the whole query', () => { + it('refuses an AND whose second term lives only in tool output', async () => { + // The filter binds to the expression it prefixes. `{cols}: (a AND b)` + // filters both terms; `{cols}: a AND b` filters only `a` and searches tool + // output for the rest, which is a conversation search answering from a + // column it promised not to read. + const { db, engine } = await open('ss-engine-scope-binding') + addSyntheticSession(db, { id: 1, text: 'alpha gamma beta' }) + addSyntheticSession(db, { id: 2, text: 'alpha gamma', toolText: 'beta' }) + // Quoted, so the query is literal; not adjacent, so the phrase rung misses + // and the AND rung is the one that answers. + const query = '"alpha" beta' + + const wide = engine.search({ query, scope: 'all' }) + expect(wide.planner.route).toBe('and') + expect(ids(wide).sort()).toEqual(['1', '2']) + + const narrowed = engine.search({ query, scope: 'conversation' }) + expect(narrowed.planner.route).toBe('and') + expect(ids(narrowed)).toEqual(['1']) + }) + + it('ranks a conversation hit down for tool output it will not show', async () => { + // The one behavioural difference the column filter carries, pinned rather + // than wished away. FTS5's bm25 normalises by the whole row's length and + // has no per-column length, so two rows with identical prose do not score + // identically when one of them also holds tool output. A dedicated + // two-column table scored them the same. The rowid set is unchanged, which + // is what the decision was measured on; the order within it can move. + const { db, engine } = await open('ss-engine-scope-weights') + addSyntheticSession(db, { id: 1, text: 'harbor pilot' }) + addSyntheticSession(db, { id: 2, text: 'harbor pilot', toolText: 'unrelated '.repeat(40) }) + const narrowed = engine.search({ query: 'harbor', scope: 'conversation' }) + expect(ids(narrowed)).toEqual(['1', '2']) + expect(narrowed.hits[0]!.score).toBeGreaterThan(narrowed.hits[1]!.score) + }) + + it('never snippets a conversation hit out of tool output', async () => { + const { db, engine } = await open('ss-engine-scope-snippet') + addSyntheticSession(db, { id: 1, text: 'harbor pilot', toolText: 'harbor tool output line' }) + const [hit] = engine.search({ query: 'harbor', scope: 'conversation' }).hits + expect(hit?.evidence?.snippet).toContain('pilot') + expect(hit?.evidence?.snippet).not.toContain('output') + // And asked for a tool-only row directly, it has nothing to show. + addSyntheticSession(db, { id: 2, text: 'harbor tool output line', role: 'tool' }) + const rowid = Number( + (db.prepare('SELECT max(id) AS id FROM messages').get() as { id: number }).id + ) + const plan = planSessionSearchQuery('harbor') + expect(sessionSearchSnippet(db, 'conversation', rowid, plan)).toEqual(EMPTY_SNIPPET) + expect(sessionSearchSnippet(db, 'all', rowid, plan).text).toContain('output') + }) +}) + +describe('a session is one hit, however many of its rows matched', () => { + it.each(['relevance', 'newest'] as const)( + 'keeps a short session on the %s page beside a 650-row session', + async (sort) => { + const { db, engine } = await open('ss-engine-aggregate', { sessionCandidateLimit: 600 }) + addSyntheticSession(db, { id: 1, rows: 650, updatedAt: '2026-09-06T00:00:00.000Z' }) + addSyntheticSession(db, { + id: 2, + text: 'needle padding', + updatedAt: '2026-09-05T00:00:00.000Z' + }) + // Collapsing to one row per session happens before the candidate limit, + // so the 650-row session cannot crowd the one-row session off the page on + // either order; which of them ranks first is the sort's business. + expect(ids(engine.search({ query: 'needle', filters: { sort } })).sort()).toEqual(['1', '2']) + } + ) + + it('folds forks the same way for an operator-only page as for a text page', async () => { + const { db, engine } = await open('ss-engine-forks') + for (const id of [1, 2, 3, 4]) { + addSyntheticSession(db, { id, updatedAt: `2026-09-0${id}T00:00:00.000Z` }) + } + markFork(db, [1, 2, 3, 4], 'shared-fork-prefix') + const operatorOnly = engine.search({ query: 'repo:app' }) + const withText = engine.search({ query: 'needle repo:app' }) + expect(ids(operatorOnly)).toEqual(['4']) + expect(operatorOnly.hits[0]?.duplicateCount).toBe(4) + expect(ids(withText)).toEqual(ids(operatorOnly)) + expect(withText.hits[0]?.duplicateCount).toBe(4) + }) + + it('answers an operator-only query with the newest sessions and no evidence', async () => { + const { db, engine } = await open('ss-engine-operator-only') + addSyntheticSession(db, { id: 1, updatedAt: '2026-09-01T00:00:00.000Z' }) + addSyntheticSession(db, { id: 2, updatedAt: '2026-09-09T00:00:00.000Z' }) + const result = engine.search({ query: 'repo:app' }) + expect(ids(result)).toEqual(['2', '1']) + expect(result.hits[0]?.evidence).toBeNull() + }) + + it('has no hits for a query with neither text nor operators', async () => { + const { db, engine } = await open('ss-engine-empty') + addSyntheticSession(db, { id: 1 }) + expect(engine.search({ query: ' ' }).hits).toEqual([]) + }) +}) + +describe('filters narrow retrieval, not just the page', () => { + it('finds a scoped match behind 600 out-of-scope rows', async () => { + const { db, engine } = await open('ss-engine-scoped') + addSyntheticSession(db, { id: 1, cwd: '/unrelated', rows: 600 }) + addSyntheticSession(db, { id: 2, cwd: '/target', text: 'needle padding' }) + expect(ids(engine.search({ query: 'needle', filters: { scopePaths: ['/target'] } }))).toEqual([ + '2' + ]) + }) + + it('falls back to a later rung when the exact hit is out of scope', async () => { + const { db, engine } = await open('ss-engine-scoped-route') + addSyntheticSession(db, { id: 1, cwd: '/unrelated', text: 'resolveTerminalPath' }) + addSyntheticSession(db, { id: 2, cwd: '/target', text: 'resolve terminal path' }) + expect( + ids(engine.search({ query: 'resolveTerminalPath', filters: { scopePaths: ['/target'] } })) + ).toEqual(['2']) + }) +}) + +describe('evidence', () => { + it('takes each snippet from that hit’s own best message', async () => { + const { db, engine } = await open('ss-engine-snippet') + // Written first, so its row owns the lowest rowid: the row a dropped rowid + // constraint would hand back for every hit. + addSyntheticSession(db, { + id: 1, + text: 'hydration marmoset appears once in a long paragraph about routing and caching', + updatedAt: '2026-09-01T00:00:00.000Z' + }) + addSyntheticSession(db, { + id: 2, + text: 'hydration capybara', + updatedAt: '2026-09-09T00:00:00.000Z' + }) + const hits = engine.search({ query: 'hydration' }).hits + expect(hits[0]?.evidence?.snippet).toContain('capybara') + expect(hits[0]?.evidence?.snippet).not.toContain('marmoset') + expect(hits.find((hit) => hit.sessionId === '1')?.evidence?.snippet).toContain('marmoset') + }) + + it('shows the prose column rather than the identifier shadow when both match', async () => { + const { db, engine } = await open('ss-engine-snippet-shadow') + addSyntheticSession(db, { + id: 1, + text: 'resolveTerminalPath is broken and the terminal never comes up for a pane, which is odd because every other pane on this host resolves its path' + }) + const snippet = engine.search({ query: 'terminal path' }).hits[0]?.evidence?.snippet ?? '' + expect(snippet).toContain('[[') + expect(snippet).not.toContain('resolve [[terminal]] [[path]]') + }) + + it('flags a snippet it had to cut, and counts it on the result', async () => { + const { db, engine } = await open('ss-engine-snippet-truncated') + // The window is twelve tokens wide, and one of them is 4000 characters, so + // the token count is no bound at all on what a hit carries. + addSyntheticSession(db, { id: 1, text: `needle ${'x'.repeat(4000)}` }) + const result = engine.search({ query: 'needle' }) + expect(result.hits[0]?.evidence?.snippetTruncated).toBe(true) + expect(result.hits[0]?.evidence?.snippet.length).toBeLessThan(600) + expect(result.truncated.snippets).toBe(1) + }) + + it('leaves an ordinary snippet unflagged', async () => { + const { db, engine } = await open('ss-engine-snippet-whole') + addSyntheticSession(db, { id: 1, text: 'needle in a short line' }) + const result = engine.search({ query: 'needle' }) + expect(result.hits[0]?.evidence?.snippetTruncated).toBeUndefined() + expect(result.truncated.snippets).toBe(0) + }) +}) + +describe('source presence comes from the files table, never a stat', () => { + it('calls a session with a live file record present', async () => { + const { db, engine } = await open('ss-engine-presence') + addSyntheticSession(db, { id: 1 }) + expect(engine.search({ query: 'needle' }).hits[0]?.source).toBe('present') + }) + + it('calls a session with no file record unverifiable, and still returns it', async () => { + // Loss of contact is never evidence of absence: the hit stays on the page. + const { db, engine } = await open('ss-engine-presence-unknown') + addSyntheticSession(db, { id: 1, filePath: null }) + const hits = engine.search({ query: 'needle' }).hits + expect(hits).toHaveLength(1) + expect(hits[0]?.source).toBe('unverifiable') + }) +}) + +describe('the engine carries its own schema and puts it back', () => { + it('installs the vocabulary over an index a writer built alone', async () => { + // The store creates none of these: PR 3's indexer can fill a whole index + // before anything opens an engine over it. + const { db, engine } = await open('ss-engine-installs') + addSyntheticSession(db, { id: 1, text: 'the coalesces path is slow' }) + addSyntheticSession(db, { id: 2, text: 'coalesces again here' }) + const result = engine.search({ query: 'coalescs' }) + expect(result.planner.route).toBe('typo+or') + expect(ids(result).sort()).toEqual(['1', '2']) + }) + + it('re-creates a vocabulary that vanished under a live engine', async () => { + const { db, engine } = await open('ss-engine-vocab-vanishes') + addSyntheticSession(db, { id: 1, text: 'coalesces here now' }) + addSyntheticSession(db, { id: 2, text: 'coalesces again here' }) + expect(engine.search({ query: 'coalescs' }).planner.route).toBe('typo+or') + + db.exec('DROP TABLE messages_vocab') + const after = engine.search({ query: 'coalescs' }) + expect(after.planner.route).toBe('typo+or') + }) + + it('fails clearly when the source index is missing', async () => { + const { db, engine } = await open('ss-engine-vocab-source-gone') + addSyntheticSession(db, { id: 1, text: 'coalesces here now', role: 'user' }) + db.exec('DROP TABLE messages_vocab; DROP TABLE messages_fts') + + expect(() => ensureSessionSearchQuerySchema(db)).toThrow('missing messages_fts') + for (const scope of ['all', 'conversation'] as const) { + expect(() => engine.search({ query: 'coalesces', scope })).toThrow(/missing messages_fts/i) + } + }) + + it('answers again after the source index is restored', async () => { + const { db, engine } = await open('ss-engine-vocab-returns') + addSyntheticSession(db, { id: 1, text: 'coalesces here now' }) + addSyntheticSession(db, { id: 2, text: 'coalesces again here' }) + const fts = ( + db.prepare("SELECT sql FROM sqlite_master WHERE name = 'messages_fts'").get() as { + sql: string + } + ).sql + db.exec('DROP TABLE messages_vocab; DROP TABLE messages_fts') + expect(() => ensureSessionSearchQuerySchema(db)).toThrow('missing messages_fts') + + db.exec(fts) + // Two, because the vocabulary only offers a term at least two rows carry. + addSyntheticSession(db, { id: 3, text: 'coalesces one more time' }) + addSyntheticSession(db, { id: 4, text: 'coalesces once again' }) + // Nothing throws on the way back up, so the recovery cannot come from the + // error path; it comes from the probe running per search. + const restored = engine.search({ query: 'coalescs' }) + expect(restored.planner.route).toBe('typo+or') + }) +}) + +describe('a query the engine had to cut says so', () => { + it('answers a query whose cap falls inside an astral character', async () => { + // The cut is on a whole code point rather than a code unit, so nothing + // downstream is handed half a surrogate pair. That is hygiene rather than a + // behaviour: the planner's tokenizer does not treat a lone surrogate as a + // token character, so it drops out of the terms either way. What this pins + // is that the boundary is answerable at all. + const { db, engine } = await open('ss-engine-surrogate-cap') + const kept = 'x'.repeat(SESSION_SEARCH_QUERY_MAX_LENGTH - 2) + addSyntheticSession(db, { id: 1, text: kept }) + const result = engine.search({ query: `${kept} 😀 tail` }) + expect(result.truncated.query).toBe(true) + expect(result.hits.map((hit) => hit.sessionId)).toEqual(['1']) + }) + + it('loads a candidate set larger than one batch of bound ids', async () => { + // The id list is as long as the candidate limit and every id is a bound + // parameter. No SQLite this stack can run refuses 1,100 of them, so this + // pins that batching returns the same answer, not that it rescues one. + const { db, engine } = await open('ss-engine-id-batching', { + sessionCandidateLimit: 1200 + }) + for (let id = 1; id <= 1100; id++) { + addSyntheticSession(db, { id, text: 'needle' }) + } + const result = engine.search({ query: 'needle', limit: 5 }) + expect(result.hits).toHaveLength(5) + expect(result.truncated.candidates).toBe(false) + }) + + it('reports truncation when the planner drops terms past its cap', async () => { + // The 56th term is the only one that matches. Without the flag this is a + // confident empty answer to a query the engine never finished reading. + const { db, engine } = await open('ss-engine-term-cap') + addSyntheticSession(db, { id: 1, text: 'onlyattheend' }) + const query = `${Array.from({ length: 55 }, (_unused, n) => `term${n}`).join(' ')} onlyattheend` + const result = engine.search({ query }) + expect(result.hits).toEqual([]) + expect(result.truncated.query).toBe(true) + }) + + it('reports truncation when the query is longer than the engine will plan', async () => { + const { db, engine } = await open('ss-engine-length-cap') + addSyntheticSession(db, { id: 1, text: 'needle' }) + const result = engine.search({ query: `needle ${'x'.repeat(SESSION_SEARCH_QUERY_MAX_LENGTH)}` }) + expect(result.truncated.query).toBe(true) + }) + + it('claims no truncation for a query that fit', async () => { + const { db, engine } = await open('ss-engine-no-cap') + addSyntheticSession(db, { id: 1, text: 'needle' }) + expect(engine.search({ query: 'needle' }).truncated.query).toBe(false) + }) +}) + +describe('a query longer than the engine will plan is cut, not refused', () => { + it('cuts one enormous token down to the cap before FTS5 ever sees it', async () => { + const { db, engine } = await open('ss-engine-long-query') + // The planner already caps how many terms it will plan, so a long query of + // ordinary words is bounded without this. What is not bounded is a single + // token: one 100 kB word is one term, and FTS5 would carry the whole thing + // into the MATCH expression. The cut is observable because the indexed + // token is exactly the capped length. + addSyntheticSession(db, { id: 1, text: 'x'.repeat(SESSION_SEARCH_QUERY_MAX_LENGTH) }) + expect(ids(engine.search({ query: 'x'.repeat(4000) }))).toEqual(['1']) + }) +}) + +describe('unicode terms survive the round trip', () => { + it.each(['café', 'C', 'R', 'x', '修復', '안녕하세요'])('searches %s', async (text) => { + const { db, engine } = await open('ss-engine-unicode') + addSyntheticSession(db, { id: 1, text }) + expect(engine.search({ query: text }).hits).toHaveLength(1) + }) +}) + +it.each(['repo:target', 'path:/work/target'])( + 'applies %s before selecting a route', + async (operator) => { + const { db, engine } = await open('ss-route-filter') + addSyntheticSession(db, { id: 1, cwd: '/work/other', text: 'alpha beta' }) + addSyntheticSession(db, { id: 2, cwd: '/work/target', text: 'alpha x beta' }) + const result = engine.search({ query: `"alpha beta" ${operator}` }) + expect(ids(result)).toEqual(['2']) + expect(result.planner.route).toBe('and') + expect(result.truncated.candidates).toBe(false) + } +) diff --git a/src/main/ai-vault-search/session-search-engine.ts b/src/main/ai-vault-search/session-search-engine.ts new file mode 100644 index 00000000000..ac7ddf9a3da --- /dev/null +++ b/src/main/ai-vault-search/session-search-engine.ts @@ -0,0 +1,259 @@ +import type SyncDatabase from '../sqlite/sync-database' +import type { TranscriptMessageRole } from '../ai-vault/session-transcript-consumers' +import { sliceAtCodeUnitLimit } from '../ai-vault/session-scanner-text-normalization' +import { + hasAiVaultSearchQueryOperators, + splitAiVaultSearchQuery, + type AiVaultSearchQuerySplit +} from '../../shared/ai-vault-search-query-operators' +import { matchesAiVaultQueryOperators } from '../../shared/ai-vault-session-filters' +import { + resolveSessionSearchLimit, + SESSION_SEARCH_QUERY_MAX_LENGTH, + type SessionSearchHit, + type SessionSearchRequest, + type SessionSearchResponse, + type SessionSearchScope, + type SessionSearchSourcePresence +} from './session-search-engine-types' +import { readIndexGeneration } from './session-search-index-generation' +import { + rankSessionHits, + type MessageRow, + type RankedSession, + type SessionRow +} from './session-search-hit-ranking' +import { + SessionSearchCursorError, + decodeSessionSearchCursor, + encodeSessionSearchCursor, + sessionSearchPageKey +} from './session-search-page-cursor' +import { planSessionSearchQuery } from './session-search-query-planner' +import { + SessionSearchRetrieval, + type RetrievalScope, + type Retrieved +} from './session-search-retrieval' +import { sessionRowFilter } from './session-search-row-filter' +import { ensureSessionSearchQuerySchema } from './session-search-query-schema' +import { EMPTY_SNIPPET, sessionSearchSnippet } from './session-search-snippet' +import { sessionSourcePresence } from './session-search-source-presence' + +/** + * Sessions retrieved before ranking cuts the page. + * + * Not a fixed constant (the reviewer's F13): it is the knob that trades page + * completeness for retrieval cost, and the right value depends on index size. + * Measurements behind this default, and what changing it costs, are in + * docs/reference/agent-session-search-query-tuning.md. + */ +export const SESSION_SEARCH_CANDIDATE_LIMIT_DEFAULT = 600 + +/** One ranked list plus what produced it; a page is a slice of `ranked`. */ +type RankedPage = { + ranked: RankedSession[] + /** Null when no text was searched, so there is nothing to snippet from. */ + retrieved: Retrieved | null + /** + * Retrieval may have missed a session: a cap ended it, not the data. True + * whether the candidate limit filled or the operator walk gave up scanning. + */ + incomplete: boolean +} + +export type SessionSearchEngineOptions = { + sessionCandidateLimit?: number + /** Oldest transcript mtime a hit may come from; PR 3 derives it from retention. */ + retentionCutoffMs?: number | null +} + +/** + * Synchronous searches use independent statements to avoid pinning the WAL. + * Generation checks bracket all content reads; concurrent writes reject the page. + * The connection's owner handles index rebuilds and engine reconstruction. + */ +export class SessionSearchEngine { + private readonly retrieval: SessionSearchRetrieval + private readonly candidateLimit: number + + constructor( + private readonly db: SyncDatabase, + private readonly options: SessionSearchEngineOptions = {} + ) { + this.candidateLimit = options.sessionCandidateLimit ?? SESSION_SEARCH_CANDIDATE_LIMIT_DEFAULT + // Installed here and not on the first search, so the generation triggers are + // watching before anything this engine will be asked to page over is + // written, and so retrieval below prepares against tables that exist. + ensureSessionSearchQuerySchema(this.db) + this.retrieval = new SessionSearchRetrieval(this.db) + } + + search(request: SessionSearchRequest): SessionSearchResponse { + const startedAt = performance.now() + ensureSessionSearchQuerySchema(this.db) + const generation = readIndexGeneration(this.db) + const scope = request.scope ?? 'all' + const sort = request.filters?.sort ?? 'relevance' + // Not a bare `slice`: cutting between a surrogate pair leaves a lone half + // that no tokenizer can match and that a caller cannot echo back. + const capped = sliceAtCodeUnitLimit(request.query, SESSION_SEARCH_QUERY_MAX_LENGTH) + const split = splitAiVaultSearchQuery(capped) + const retrievalScope: RetrievalScope = { + scope, + sort, + filter: sessionRowFilter(request.filters ?? {}, this.options.retentionCutoffMs ?? null), + matchesOperators: operatorPredicate(split), + candidateLimit: this.candidateLimit + } + // Decoded before any retrieval: a cursor the engine will refuse must not + // cost a query, and the caller has to hear about it either way. + const pageKey = sessionSearchPageKey(request) + const offset = request.cursor + ? decodeSessionSearchCursor(request.cursor, generation, pageKey) + : 0 + + const plan = planSessionSearchQuery(split.text) + const { ranked, retrieved, incomplete } = + plan.terms.length === 0 + ? this.operatorOnly(split, retrievalScope) + : this.text(plan, retrievalScope, sort) + + const limit = resolveSessionSearchLimit(request.limit) + const page = ranked.slice(offset, offset + limit) + const hits = this.hits(page, scope, retrieved) + const actualGeneration = readIndexGeneration(this.db) + if (actualGeneration !== generation) { + throw new SessionSearchCursorError('stale-generation', actualGeneration, generation) + } + const hasMore = ranked.length > offset + limit + const response: SessionSearchResponse = { + hits, + planner: { + route: retrieved?.route ?? 'or', + tier: scope, + ...(retrieved?.repairedTerms ? { repairedTerms: retrieved.repairedTerms } : {}) + }, + page: { + hasMore, + cursor: hasMore ? encodeSessionSearchCursor(generation, offset + limit, pageKey) : null + }, + truncated: { + // Decided by retrieval, which is the only layer that knows whether a cap + // ended it. Deriving it from the hits cannot work: an operator walk that + // gave up at its scan ceiling returns no hits, and so does a search that + // genuinely matched nothing. + candidates: incomplete, + snippets: hits.filter((hit) => hit.evidence?.snippetTruncated).length, + query: capped.length < request.query.length || plan.truncated + }, + generation, + durationMs: performance.now() - startedAt + } + return response + } + + /** + * Operators with no free text still name a scope, so the answer is the newest + * sessions inside it. Ranked through the same path as a text query, because + * forks must fold here exactly as they do there or the same sessions answer + * `repo:x` and `word repo:x` differently. There is no relevance signal + * without text, so the order is always newest. + */ + private operatorOnly(split: AiVaultSearchQuerySplit, scope: RetrievalScope): RankedPage { + if (!hasAiVaultSearchQueryOperators(split)) { + return { ranked: [], retrieved: null, incomplete: false } + } + const { sessions, incomplete } = this.retrieval.recent(scope) + return { ranked: rankSessionHits(sessions, new Map(), 'newest'), retrieved: null, incomplete } + } + + private text( + plan: ReturnType, + scope: RetrievalScope, + sort: 'relevance' | 'newest' + ): RankedPage { + const retrieved = this.retrieval.run(plan, scope) + // `match` already grouped to one best row per session. + const best = new Map(retrieved.rows.map((row) => [row.session_row_id, row])) + return { + ranked: rankSessionHits(retrieved.sessions, best, sort), + retrieved, + incomplete: retrieved.incomplete + } + } + + /** Snippets and source presence are paid for by the page, never by the list. */ + private hits( + page: readonly RankedSession[], + scope: SessionSearchScope, + retrieved: Retrieved | null + ): SessionSearchHit[] { + const presence = sessionSourcePresence( + this.db, + page.map((entry) => entry.session.id) + ) + return page.map((entry) => this.hit(entry, scope, retrieved, presence)) + } + + private hit( + entry: RankedSession, + scope: SessionSearchScope, + retrieved: Retrieved | null, + presence: ReadonlyMap + ): SessionSearchHit { + const { session, message } = entry + const snippet = + message && retrieved + ? sessionSearchSnippet(this.db, scope, message.rowid, retrieved.plan) + : EMPTY_SNIPPET + return { + ...sessionFields(session), + score: entry.score, + ...(entry.duplicateCount > 1 ? { duplicateCount: entry.duplicateCount } : {}), + source: presence.get(session.id) ?? 'unverifiable', + evidence: message + ? { + role: message.role as TranscriptMessageRole, + timestamp: message.ts, + snippet: snippet.text, + ...(snippet.truncated ? { snippetTruncated: true } : {}) + } + : null + } + } +} + +/** + * The one reading of `repo:` / `path:`: the sessions panel's own predicate, over + * the columns the index stores. The engine has no project map, so a session's + * repo label falls back to its folder label, which is what the panel does for + * every session it cannot resolve a project for. + */ +function operatorPredicate(split: AiVaultSearchQuerySplit): (session: SessionRow) => boolean { + if (!hasAiVaultSearchQueryOperators(split)) { + return () => true + } + return (session) => + matchesAiVaultQueryOperators( + { cwd: session.cwd, filePath: session.file_path }, + { repoTerms: split.repoTerms, pathTerms: split.pathTerms } + ) +} + +function sessionFields( + session: SessionRow +): Omit { + return { + agent: session.agent, + sessionId: session.session_id, + filePath: session.file_path, + codexHome: session.codex_home, + title: session.title, + cwd: session.cwd, + branch: session.branch, + updatedAt: session.updated_at, + messageCount: session.message_count, + resumeCommand: session.resume_command + } +} diff --git a/src/main/ai-vault-search/session-search-fts5-contract.test.ts b/src/main/ai-vault-search/session-search-fts5-contract.test.ts new file mode 100644 index 00000000000..be815623ce7 --- /dev/null +++ b/src/main/ai-vault-search/session-search-fts5-contract.test.ts @@ -0,0 +1,172 @@ +import { mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { removeTree } from '../../shared/windows-transient-lock-removal' +import type SyncDatabase from '../sqlite/sync-database' +import { indexTokens } from './session-search-query-planner' +import { ensureSessionSearchQuerySchema } from './session-search-query-schema' +import { openSessionSearchDatabase } from './session-search-schema' + +// SQLite/FTS5 behaviours the query layer depends on. Each one cost a live +// debugging session; a refactor that reintroduces the trap fails here. + +const FIRST_ROWID = 101 +const SECOND_ROWID = 202 + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => removeTree(root))) + tempRoots = [] +}) + +async function openDatabase(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-fts5-contract-')) + tempRoots.push(root) + return openSessionSearchDatabase(join(root, 'index.sqlite')) +} + +function insertMessageRow(db: SyncDatabase, rowid: number, text: string): void { + db.prepare( + `INSERT INTO messages_fts(rowid, user_text, assistant_text, tool_text, identifiers) + VALUES (?, ?, '', '', '')` + ).run(rowid, text) +} + +describe('FTS5 aux functions take the table name, never an alias', () => { + it('rejects bm25 over an aliased table and accepts the table-name form', async () => { + const db = await openDatabase() + insertMessageRow(db, FIRST_ROWID, 'alpha marmoset one') + + expect(() => + db.prepare('SELECT bm25(f) AS score FROM messages_fts f WHERE f MATCH ?').all('alpha') + ).toThrow(/no such column: f/) + + const scored = db + .prepare('SELECT bm25(messages_fts) AS score FROM messages_fts WHERE messages_fts MATCH ?') + .all('alpha') as { score: number }[] + expect(scored).toHaveLength(1) + expect(Number.isFinite(scored[0]?.score)).toBe(true) + db.close() + }) + + it('rejects snippet over an aliased table too', async () => { + const db = await openDatabase() + insertMessageRow(db, FIRST_ROWID, 'alpha marmoset one') + + expect(() => + db + .prepare( + "SELECT snippet(f, -1, '[', ']', '…', 12) AS s FROM messages_fts f WHERE f MATCH ?" + ) + .all('alpha') + ).toThrow(/no such column: f/) + db.close() + }) +}) + +describe('a rowid constraint beside MATCH is honoured only as a subselect', () => { + it('ignores `rowid = ?` and returns every match, first row first', async () => { + const db = await openDatabase() + insertMessageRow(db, FIRST_ROWID, 'alpha marmoset one') + insertMessageRow(db, SECOND_ROWID, 'alpha capybara two') + + const rows = db + .prepare('SELECT rowid FROM messages_fts WHERE messages_fts MATCH ? AND rowid = ?') + .all('alpha', SECOND_ROWID) as { rowid: number }[] + // The planner drops the constraint entirely: both rows come back. + expect(rows.map((row) => row.rowid)).toEqual([FIRST_ROWID, SECOND_ROWID]) + // A caller reading one row therefore gets the first match, not the one asked for. + const single = db + .prepare('SELECT rowid FROM messages_fts WHERE messages_fts MATCH ? AND rowid = ?') + .get('alpha', SECOND_ROWID) as { rowid: number } | undefined + expect(single?.rowid).toBe(FIRST_ROWID) + db.close() + }) + + it('ignores `rowid IN (?)` the same way', async () => { + const db = await openDatabase() + insertMessageRow(db, FIRST_ROWID, 'alpha marmoset one') + insertMessageRow(db, SECOND_ROWID, 'alpha capybara two') + + const rows = db + .prepare('SELECT rowid FROM messages_fts WHERE messages_fts MATCH ? AND rowid IN (?)') + .all('alpha', SECOND_ROWID) as { rowid: number }[] + expect(rows.map((row) => row.rowid)).toEqual([FIRST_ROWID, SECOND_ROWID]) + db.close() + }) + + it('honours `rowid IN (SELECT ?)` even with the session join on', async () => { + const db = await openDatabase() + db.prepare( + `INSERT INTO sessions(id,agent,session_id,file_path,title,resume_command) + VALUES (1,'claude','1','/synthetic/1','fixture','')` + ).run() + for (const rowid of [FIRST_ROWID, SECOND_ROWID]) { + db.prepare("INSERT INTO messages(id,session_row_id,role) VALUES (?,1,'user')").run(rowid) + } + insertMessageRow(db, FIRST_ROWID, 'alpha marmoset one') + insertMessageRow(db, SECOND_ROWID, 'alpha capybara two') + + // The shape the snippet read uses: the joins are what subtract a row whose + // session a purge cut loose, and they must not cost the rowid constraint + // its effect. + const snippet = db + .prepare( + `SELECT snippet(messages_fts, -1, '[', ']', '…', 12) AS s + FROM messages_fts + JOIN messages m ON m.id = messages_fts.rowid + JOIN sessions s ON s.id = m.session_row_id + WHERE messages_fts MATCH ? AND messages_fts.rowid IN (SELECT ?)` + ) + .get('alpha', SECOND_ROWID) as { s: string } | undefined + expect(snippet?.s).toContain('capybara') + expect(snippet?.s).not.toContain('marmoset') + db.close() + }) +}) + +describe('sessions.file_path is deliberately not unique', () => { + it('accepts two sessions sharing one store path', async () => { + const db = await openDatabase() + const insert = db.prepare( + `INSERT INTO sessions(agent, session_id, file_path, title, resume_command) + VALUES (?, ?, ?, ?, ?)` + ) + // OpenCode and Cursor keep every session in one SQLite store; files.path is the key. + const storePath = '/home/user/.local/share/opencode/storage.db' + insert.run('opencode', 'ses_one', storePath, 'first', 'opencode --session ses_one') + expect(() => + insert.run('opencode', 'ses_two', storePath, 'second', 'opencode --session ses_two') + ).not.toThrow() + + const rows = db + .prepare('SELECT session_id FROM sessions WHERE file_path = ? ORDER BY session_id') + .all(storePath) as { session_id: string }[] + expect(rows.map((row) => row.session_id)).toEqual(['ses_one', 'ses_two']) + db.close() + }) +}) + +describe('the planner tokenizer draws the same boundaries as unicode61', () => { + // unicode61 folds case and strips Latin diacritics on both index and query side. + function asIndexed(token: string): string { + return token.toLowerCase().normalize('NFD').replaceAll(/\p{M}/gu, '') + } + + it('produces exactly the terms fts5vocab reports for the same text', async () => { + const db = await openDatabase() + // The vocabulary is the engine's own object, not the store's. + ensureSessionSearchQuerySchema(db) + const corpus = + 'resolveTerminalPath src/main/foo-bar.ts a.b C++ #123 修复 café naïve MAX_TOKEN x' + insertMessageRow(db, FIRST_ROWID, corpus) + const indexed = ( + db.prepare('SELECT term FROM messages_vocab ORDER BY term').all() as { term: string }[] + ).map((row) => row.term) + + expect([...new Set(indexTokens(corpus).map(asIndexed))].sort()).toEqual(indexed) + db.close() + }) +}) diff --git a/src/main/ai-vault-search/session-search-hit-ranking.test.ts b/src/main/ai-vault-search/session-search-hit-ranking.test.ts new file mode 100644 index 00000000000..54919bace0f --- /dev/null +++ b/src/main/ai-vault-search/session-search-hit-ranking.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { rankSessionHits, type MessageRow, type SessionRow } from './session-search-hit-ranking' + +function session(id: number, overrides: Partial = {}): SessionRow { + return { + id, + agent: 'claude', + session_id: String(id), + file_path: `/synthetic/${id}.jsonl`, + codex_home: null, + title: 'fixture', + cwd: '/repo/app', + branch: null, + updated_at: '2026-09-01T00:00:00.000Z', + message_count: 1, + resume_command: 'resume', + content_hash: null, + content_hash_count: 0, + ...overrides + } +} + +function match(id: number, score: number): MessageRow { + return { rowid: id, score, session_row_id: id, role: 'user', ts: null } +} + +function matches(...rows: MessageRow[]): Map { + return new Map(rows.map((row) => [row.session_row_id, row])) +} + +describe('order', () => { + it('ranks by score under relevance and by recency under newest', () => { + const sessions = [ + session(1, { updated_at: '2026-09-01T00:00:00.000Z' }), + session(2, { updated_at: '2026-09-09T00:00:00.000Z' }) + ] + const scores = matches(match(1, 10), match(2, 1)) + expect(rankSessionHits(sessions, scores, 'relevance').map((e) => e.session.id)).toEqual([1, 2]) + expect(rankSessionHits(sessions, scores, 'newest').map((e) => e.session.id)).toEqual([2, 1]) + }) + + it.each(['relevance', 'newest'] as const)( + 'breaks a %s tie by session, whatever order retrieval handed them over in', + (sort) => { + // A cursor is an offset into this list, so two entries that tie must not + // be free to swap between pages. Retrieval hands sessions over in + // whatever order the `IN (...)` lookup produced, which SQL does not + // promise, so the order below is deliberately reversed. + const sessions = [6, 5, 4, 3, 2, 1].map((id) => session(id)) + const scores = matches(...sessions.map((entry) => match(entry.id, 5))) + expect(rankSessionHits(sessions, scores, sort).map((entry) => entry.session.id)).toEqual([ + 1, 2, 3, 4, 5, 6 + ]) + } + ) + + it('prefers the shorter session when two match equally well', () => { + // The length prior: `0.02 · ln(1 + messages)`, subtracted per session. + const sessions = [session(1, { message_count: 5000 }), session(2, { message_count: 2 })] + const ranked = rankSessionHits(sessions, matches(match(1, 5), match(2, 5)), 'relevance') + expect(ranked.map((entry) => entry.session.id)).toEqual([2, 1]) + expect(ranked[0]!.score).toBeGreaterThan(ranked[1]!.score) + }) +}) + +describe('forks fold into one answer', () => { + const fork = (id: number, updatedAt: string): SessionRow => + session(id, { + updated_at: updatedAt, + content_hash: 'shared-opening-prefix', + content_hash_count: 8 + }) + + it('keeps the newest copy and counts the rest', () => { + const sessions = [ + fork(1, '2026-09-01T00:00:00.000Z'), + fork(2, '2026-09-09T00:00:00.000Z'), + fork(3, '2026-09-05T00:00:00.000Z') + ] + const ranked = rankSessionHits( + sessions, + matches(match(1, 9), match(2, 1), match(3, 5)), + 'relevance' + ) + expect(ranked).toHaveLength(1) + expect(ranked[0]!.session.id).toBe(2) + expect(ranked[0]!.duplicateCount).toBe(3) + }) + + it('leaves sessions with no shared prefix alone', () => { + const sessions = [session(1), session(2)] + const ranked = rankSessionHits(sessions, matches(match(1, 9), match(2, 5)), 'relevance') + expect(ranked.map((entry) => entry.duplicateCount)).toEqual([1, 1]) + }) +}) + +it('scores a session that matched no text at zero, less its length prior', () => { + // The operator-only page: there is no relevance signal, only an order. + const ranked = rankSessionHits([session(1, { message_count: 9 })], new Map(), 'newest') + expect(ranked[0]!.message).toBeNull() + expect(ranked[0]!.score).toBeLessThan(0) +}) diff --git a/src/main/ai-vault-search/session-search-hit-ranking.ts b/src/main/ai-vault-search/session-search-hit-ranking.ts new file mode 100644 index 00000000000..364858ea650 --- /dev/null +++ b/src/main/ai-vault-search/session-search-hit-ranking.ts @@ -0,0 +1,109 @@ +import type { AiVaultAgent } from '../../shared/ai-vault-types' +import { isCollapsibleContentHash } from './session-search-content-hash' +import type { SessionSearchSort } from './session-search-engine-types' + +// Subtracted per session: `0.02 · ln(1 + messages)`; slightly positive on both eval sets. +const LENGTH_PRIOR = 0.02 + +export type SessionRow = { + id: number + agent: AiVaultAgent + session_id: string + file_path: string + codex_home: string | null + title: string + cwd: string | null + branch: string | null + updated_at: string | null + message_count: number + resume_command: string + content_hash: string | null + content_hash_count: number +} + +/** The one message that stands for a session: its best-scoring match. */ +export type MessageRow = { + rowid: number + score: number + session_row_id: number + role: string + ts: string | null +} + +export type RankedSession = { + session: SessionRow + /** Null on an operator-only page: the session matched no text at all. */ + message: MessageRow | null + score: number + duplicateCount: number +} + +/** + * Everything between "these sessions matched" and "this is the ranked list": + * the length prior, fork folding and the caller's order. Retrieval stays in SQL + * and nothing here touches the database. + * + * The whole list is returned, not a page: a cursor indexes into it, and slicing + * here would make page two a different ranking from page one. The engine cuts + * the page and only then pays for a snippet. + */ +export function rankSessionHits( + sessions: readonly SessionRow[], + matches: ReadonlyMap, + sort: SessionSearchSort +): RankedSession[] { + const scored = collapseForks( + sessions.map((session) => { + const message = matches.get(session.id) ?? null + return { + session, + message, + score: (message?.score ?? 0) - LENGTH_PRIOR * Math.log(1 + session.message_count), + duplicateCount: 1 + } + }) + ) + // Why a total order and not just the key: a cursor is an offset into this + // list, so two entries that tie must not be free to swap between pages. + scored.sort( + (left, right) => + (sort === 'newest' + ? (right.session.updated_at ?? '').localeCompare(left.session.updated_at ?? '') + : right.score - left.score) || left.session.id - right.session.id + ) + return scored +} + +/** + * Folds forked copies of one conversation into a single entry: same opening + * prefix, newest `updated_at` wins, the rest become `duplicateCount`. Done here + * and not at write time so index rows stay per file (cursors and deletes). + */ +function collapseForks(scored: RankedSession[]): RankedSession[] { + const groups = new Map() + for (const entry of scored) { + const { content_hash: hash, content_hash_count: count, id } = entry.session + const key = isCollapsibleContentHash(hash, count) ? `hash:${hash}` : `session:${id}` + const group = groups.get(key) + if (group) { + group.push(entry) + } else { + groups.set(key, [entry]) + } + } + const collapsed: RankedSession[] = [] + for (const group of groups.values()) { + if (group.length === 1) { + collapsed.push(group[0]!) + continue + } + const winner = group.reduce((best, entry) => (isNewer(entry, best) ? entry : best)) + collapsed.push({ ...winner, duplicateCount: group.length }) + } + return collapsed +} + +function isNewer(entry: RankedSession, best: RankedSession): boolean { + const order = (entry.session.updated_at ?? '').localeCompare(best.session.updated_at ?? '') + return order === 0 ? entry.score > best.score : order > 0 +} diff --git a/src/main/ai-vault-search/session-search-index-generation.test.ts b/src/main/ai-vault-search/session-search-index-generation.test.ts new file mode 100644 index 00000000000..f728759c0e4 --- /dev/null +++ b/src/main/ai-vault-search/session-search-index-generation.test.ts @@ -0,0 +1,329 @@ +import { appendFile, mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { removeTree } from '../../shared/windows-transient-lock-removal' +import type SyncDatabase from '../sqlite/sync-database' +import { SessionSearchEngine } from './session-search-engine' +import { readIndexGeneration } from './session-search-index-generation' +import { registerSessionSearchIndexConsumer } from './session-search-index-consumer' +import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache' +import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers' +import type { SessionSearchCursorError } from './session-search-page-cursor' +import { openSessionSearchDatabase } from './session-search-schema' +import { SessionSearchStore } from './session-search-store' +import { parseTranscript, userRecord } from './session-search-transcript-fixtures' + +let roots: string[] = [] +let handles: SyncDatabase[] = [] + +afterEach(async () => { + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + for (const handle of handles) { + handle.close() + } + handles = [] + await Promise.all(roots.map((root) => removeTree(root))) + roots = [] +}) + +async function tempRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-search-generation-')) + roots.push(root) + return root +} + +/** + * A reader's own handle on the index, with the engine's schema installed. + * + * PR 2's store keeps its connection private, so a reader opens its own — which + * is what the fence has to survive: nothing this handle does moves the + * generation, and it must still see every writer's move. + */ +function reader(path: string): SyncDatabase { + const db = openSessionSearchDatabase(path) + handles.push(db) + // Constructing an engine is what installs the triggers. + new SessionSearchEngine(db) + return db +} + +/** Indexes one transcript through the real consumer and returns its path. */ +async function indexOneTranscript(root: string, store: SessionSearchStore): Promise { + resetSessionParseCacheForTests() + const sessionId = `aaaaaaaa-0000-4000-8000-${String(roots.length).padStart(12, '0')}` + const path = join(root, `${Math.random().toString(36).slice(2)}.jsonl`) + await writeFile(path, `${userRecord(0, 'generation fixture needle', sessionId)}\n`) + const unregister = registerSessionSearchIndexConsumer(store) + try { + await parseTranscript(path) + } finally { + unregister() + } + return path +} + +it('moves the generation forward when a committed read changes what a read returns', async () => { + const root = await tempRoot() + const path = join(root, 'index.sqlite') + const db = reader(path) + const store = new SessionSearchStore(path, (error) => { + throw error + }) + try { + const before = readIndexGeneration(db) + await indexOneTranscript(root, store) + expect(readIndexGeneration(db)).toBeGreaterThan(before) + } finally { + store.close() + } +}) + +it('moves the generation forward when an append adds rows to a live session', async () => { + // The first read of a file inserts its `files` row; every read after that + // updates it. An append changes a session's rank and its message count, so a + // cursor minted before it indexes into a list that no longer exists. + const root = await tempRoot() + const path = join(root, 'index.sqlite') + const db = reader(path) + const store = new SessionSearchStore(path, (error) => { + throw error + }) + try { + const transcript = await indexOneTranscript(root, store) + const indexed = readIndexGeneration(db) + const unregister = registerSessionSearchIndexConsumer(store) + try { + resetSessionParseCacheForTests() + await appendFile(transcript, `${userRecord(1, 'a second needle turn')}\n`) + await parseTranscript(transcript) + } finally { + unregister() + } + expect(db.prepare('SELECT COUNT(*) AS c FROM messages').get()).toEqual({ c: 2 }) + expect(readIndexGeneration(db)).toBeGreaterThan(indexed) + } finally { + store.close() + } +}) + +it('moves the generation forward when a proven deletion hides a session', async () => { + const root = await tempRoot() + const path = join(root, 'index.sqlite') + const db = reader(path) + const store = new SessionSearchStore(path, (error) => { + throw error + }) + try { + const transcript = await indexOneTranscript(root, store) + const indexed = readIndexGeneration(db) + store.removeFile(transcript) + expect(readIndexGeneration(db)).toBeGreaterThan(indexed) + } finally { + store.close() + } +}) + +it('moves the generation forward when retention cuts a session loose', async () => { + // Retention deletes the session row and the file row in one transaction, then + // reclaims the messages over many. It is the first half that changes what a + // search returns, and the first half that has to move the generation. + const root = await tempRoot() + const path = join(root, 'index.sqlite') + const db = reader(path) + const store = new SessionSearchStore(path, (error) => { + throw error + }) + try { + await indexOneTranscript(root, store) + const indexed = readIndexGeneration(db) + await store.purgeOlderThan(Date.now() + 60_000) + expect(db.prepare('SELECT COUNT(*) AS c FROM sessions').get()).toEqual({ c: 0 }) + expect(readIndexGeneration(db)).toBeGreaterThan(indexed) + } finally { + store.close() + } +}) + +it('moves the generation when a purge reclaims rows nothing can reach', async () => { + // The drain writes only `messages`, and for a while that was argued to change + // no answer. Retrieval never saw those rows; the typo repair's dictionary + // did, because `messages_vocab` is a view over the FTS b-tree and lists a + // term whether or not a reader can reach it. See + // `session-search-orphan-rows.test.ts` for the answer that moved. The price + // of fencing it is a cursor refused once per batch while a purge runs. + const root = await tempRoot() + const path = join(root, 'index.sqlite') + const db = reader(path) + const store = new SessionSearchStore(path, (error) => { + throw error + }) + try { + await indexOneTranscript(root, store) + // The shape an interrupted purge leaves: rows with no session row. + db.prepare('DELETE FROM sessions').run() + const orphaned = readIndexGeneration(db) + expect(db.prepare('SELECT COUNT(*) AS c FROM messages').get()).not.toEqual({ c: 0 }) + await store.purgeOlderThan(null) + expect(db.prepare('SELECT COUNT(*) AS c FROM messages').get()).toEqual({ c: 0 }) + expect(readIndexGeneration(db)).toBeGreaterThan(orphaned) + } finally { + store.close() + } +}) + +it("leaves the generation alone when a replace swaps a session's own rows", async () => { + // The same trigger must not fire here, or every re-read of a large transcript + // would move the generation once per deleted row on top of the one bump its + // file record already makes. A replace deletes rows whose session row still + // stands, which is what the trigger's `WHEN` clause tests. + const root = await tempRoot() + const path = join(root, 'index.sqlite') + const db = reader(path) + const store = new SessionSearchStore(path, (error) => { + throw error + }) + try { + await indexOneTranscript(root, store) + const rows = db.prepare('SELECT COUNT(*) AS c FROM messages').get() as { c: number } + const indexed = readIndexGeneration(db) + db.prepare('DELETE FROM messages WHERE session_row_id IN (SELECT id FROM sessions)').run() + expect(rows.c).toBeGreaterThan(0) + expect(readIndexGeneration(db)).toBe(indexed) + } finally { + store.close() + } +}) + +it('leaves the generation alone when a removal hides nothing', async () => { + // A backfill retires paths it never held; if that moved the generation, every + // cursor would be refused for as long as indexing ran. + const root = await tempRoot() + const path = join(root, 'index.sqlite') + const db = reader(path) + const store = new SessionSearchStore(path, (error) => { + throw error + }) + try { + await indexOneTranscript(root, store) + const before = readIndexGeneration(db) + store.removeFile('/synthetic/never-indexed.jsonl') + expect(readIndexGeneration(db)).toBe(before) + } finally { + store.close() + } +}) + +it('keeps the generation across a reopen, because the bump rides its own commit', async () => { + // The bump is inside the transaction that changes visibility, so nothing can + // be lost to a crash and reopening need not invalidate anyone's cursor. + const root = await tempRoot() + const path = join(root, 'index.sqlite') + reader(path) + const first = new SessionSearchStore(path, (error) => { + throw error + }) + await indexOneTranscript(root, first) + const indexed = readIndexGeneration(reader(path)) + first.close() + + const second = new SessionSearchStore(path) + try { + expect(readIndexGeneration(reader(path))).toBe(indexed) + } finally { + second.close() + } +}) + +it('fences a reader against a writer it does not share a process with', async () => { + // The shape PR 3 creates: the indexer writes from the scanner child while an + // engine reads elsewhere. A generation cached in the reader's memory tracks + // only that reader's own writes, so it would stand still through the + // writer's deletion, honour the stale cursor, and skip a session. + const root = await tempRoot() + const path = join(root, 'index.sqlite') + const db = reader(path) + const writer = new SessionSearchStore(path, (error) => { + throw error + }) + try { + const transcripts: string[] = [] + for (let n = 0; n < 3; n++) { + transcripts.push(await indexOneTranscript(root, writer)) + } + const engine = new SessionSearchEngine(db) + const page = engine.search({ query: 'needle', limit: 1 }) + expect(page.page.cursor).not.toBeNull() + + writer.removeFile(transcripts[0]!) + + // The reader never wrote anything, and must still refuse. + try { + engine.search({ query: 'needle', limit: 1, cursor: page.page.cursor! }) + expect.unreachable('a page cursor must not survive another writer moving the index') + } catch (error) { + expect((error as SessionSearchCursorError).rejection).toBe('stale-generation') + } + } finally { + writer.close() + } +}) + +it('re-creates a fence something dropped, on the next search', async () => { + // An index whose triggers are gone cannot move its generation, so every stale + // cursor would compare equal and be honoured against a list the caller never + // saw. The engine owns those triggers, so it puts them back. + const root = await tempRoot() + const path = join(root, 'index.sqlite') + const db = reader(path) + const store = new SessionSearchStore(path, (error) => { + throw error + }) + try { + const transcript = await indexOneTranscript(root, store) + const engine = new SessionSearchEngine(db) + db.exec('DROP TRIGGER search_generation_file_update') + engine.search({ query: 'needle' }) + + expect( + db + .prepare("SELECT name FROM sqlite_master WHERE type = 'trigger' AND name = ?") + .get('search_generation_file_update') + ).toEqual({ name: 'search_generation_file_update' }) + + // An UPDATE of the row that already exists, because that is the trigger + // this dropped: re-indexing a transcript also inserts and deletes, so it + // moves the generation whether or not the dropped one came back. + const restored = readIndexGeneration(db) + db.exec(`UPDATE files SET mtime_ms = mtime_ms + 1 WHERE path = '${transcript}'`) + expect(readIndexGeneration(db)).toBeGreaterThan(restored) + } finally { + store.close() + } +}) + +it('mints a distinct generation per change even when two handles write', async () => { + const root = await tempRoot() + const path = join(root, 'index.sqlite') + const db = reader(path) + const first = new SessionSearchStore(path, (error) => { + throw error + }) + const second = new SessionSearchStore(path, (error) => { + throw error + }) + try { + const seen: number[] = [readIndexGeneration(db)] + for (const store of [first, second, first, second]) { + await indexOneTranscript(root, store) + seen.push(readIndexGeneration(db)) + } + // Read-then-write from two connections would hand out one value twice. + expect(new Set(seen).size).toBe(seen.length) + expect([...seen].sort((left, right) => left - right)).toEqual(seen) + } finally { + second.close() + first.close() + } +}) diff --git a/src/main/ai-vault-search/session-search-index-generation.ts b/src/main/ai-vault-search/session-search-index-generation.ts new file mode 100644 index 00000000000..ee10eb0e295 --- /dev/null +++ b/src/main/ai-vault-search/session-search-index-generation.ts @@ -0,0 +1,42 @@ +import type SyncDatabase from '../sqlite/sync-database' + +const GENERATION_KEY = 'index_generation' + +export const SESSION_SEARCH_GENERATION_TRIGGERS = [ + 'search_generation_file_insert', + 'search_generation_file_update', + 'search_generation_file_delete', + 'search_generation_orphan_reclaim' +] as const + +const BUMP = `INSERT INTO meta(key, value) VALUES ('${GENERATION_KEY}', '1') + ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + 1;` + +/** + * Triggers commit the generation with writes from any connection. + * Orphan reclamation also changes the vocabulary used for typo suggestions. + */ +export const SESSION_SEARCH_GENERATION_SQL = ` +CREATE TRIGGER IF NOT EXISTS search_generation_file_insert AFTER INSERT ON files BEGIN + ${BUMP} +END; +CREATE TRIGGER IF NOT EXISTS search_generation_file_update AFTER UPDATE ON files BEGIN + ${BUMP} +END; +CREATE TRIGGER IF NOT EXISTS search_generation_file_delete AFTER DELETE ON files BEGIN + ${BUMP} +END; +CREATE TRIGGER IF NOT EXISTS search_generation_orphan_reclaim AFTER DELETE ON messages +WHEN NOT EXISTS (SELECT 1 FROM sessions WHERE id = OLD.session_row_id) BEGIN + ${BUMP} +END; +` + +/** Read the committed generation on each check, including other processes' writes. */ +export function readIndexGeneration(db: SyncDatabase): number { + const row = db.prepare('SELECT value FROM meta WHERE key = ?').get(GENERATION_KEY) as + | { value: string } + | undefined + const parsed = row ? Number(row.value) : Number.NaN + return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0 +} diff --git a/src/main/ai-vault-search/session-search-orphan-rows.test.ts b/src/main/ai-vault-search/session-search-orphan-rows.test.ts new file mode 100644 index 00000000000..dfda2303104 --- /dev/null +++ b/src/main/ai-vault-search/session-search-orphan-rows.test.ts @@ -0,0 +1,186 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type SyncDatabase from '../sqlite/sync-database' +import { + addSyntheticSession, + openSessionSearchHarness, + type SessionSearchHarness +} from './session-search-engine-test-fixture' +import { identifierShadowText } from './session-search-identifier-split' +import { readIndexGeneration } from './session-search-index-generation' +import { planSessionSearchQuery } from './session-search-query-planner' +import { sessionSearchSnippet } from './session-search-snippet' +import type { SessionSearchCursorError } from './session-search-page-cursor' +import { SessionSearchTypoRepair } from './session-search-typo-repair' + +// Retention deletes a session row in one small transaction and reclaims its +// message rows in batches afterwards, so a `messages` row with no `sessions` row +// is a state every purge, every removed source and every interrupted drain +// passes through. Those rows are still in both FTS tables and still in the +// vocabulary, and nothing here may return one. +// +// A hit is a session row, and the ranked list is loaded `FROM sessions`, so the +// route ladder below cannot surface an orphan even if a join were loosened — +// those cases are a ratchet over the shape, not the proof. The two reads that +// can leak one are pinned separately and each is a real oracle: the snippet, +// which is handed a rowid and asked for its text, and the typo repair, whose +// dictionary is the FTS b-tree and lists an orphan's terms like any other. + +const ORPHAN_SESSION_ROW = 99 +const ORPHAN_TEXT = 'orphaned marmoset secret' + +let harness: SessionSearchHarness | null = null + +afterEach(async () => { + await harness?.close() + harness = null +}) + +/** Two rows in the FTS table and the vocabulary, and no session row for them. */ +function plantOrphans(db: SyncDatabase, text: string = ORPHAN_TEXT): number[] { + const rowids: number[] = [] + for (let n = 0; n < 2; n++) { + const rowid = Number( + db + .prepare("INSERT INTO messages(session_row_id,role,ts) VALUES (?,'user',?)") + .run(ORPHAN_SESSION_ROW, '2026-09-10T00:00:00.000Z').lastInsertRowid + ) + db.prepare( + 'INSERT INTO messages_fts(rowid,user_text,assistant_text,tool_text,identifiers) VALUES (?,?,?,?,?)' + ).run(rowid, text, '', '', identifierShadowText(text)) + rowids.push(rowid) + } + return rowids +} + +async function withOrphans(): Promise<{ harness: SessionSearchHarness; rowids: number[] }> { + harness = await openSessionSearchHarness('ss-orphan-rows') + addSyntheticSession(harness.db, { id: 1, text: 'the haystack line here' }) + const rowids = plantOrphans(harness.db) + // The oracle only means anything if the rows are really there to be found. + expect( + harness.db + .prepare("SELECT count(*) AS c FROM messages_fts WHERE messages_fts MATCH 'marmoset'") + .get() + ).toEqual({ c: 2 }) + expect( + harness.db.prepare("SELECT doc FROM messages_vocab WHERE term = 'marmoset'").get() + ).toEqual({ doc: 2 }) + return { harness, rowids } +} + +it.each([ + ['phrase', '"orphaned marmoset"'], + ['and', 'orphaned secret'], + ['single-token literal', 'marmoset'], + ['or', 'marmoset haystack orphaned'], + ['typo repair', 'marmosett'], + ['operator only', 'repo:app'] +])('returns no orphaned row on the %s route', async (_route, query) => { + const { harness: open } = await withOrphans() + for (const scope of ['all', 'conversation'] as const) { + const hits = open.engine.search({ query, scope }).hits + expect(hits.map((hit) => hit.sessionId)).not.toContain(String(ORPHAN_SESSION_ROW)) + expect(hits.filter((hit) => hit.evidence?.snippet.includes('marmoset'))).toEqual([]) + } +}) + +it('never repairs a term onto a spelling only orphaned rows carry', async () => { + const { harness: open } = await withOrphans() + // `marmoset` is in the vocabulary twice, which is what would make it the + // repair for `marmosett` if the repair trusted the vocabulary alone. + expect(new SessionSearchTypoRepair(open.db).correct('marmosett', 'all')).toBeNull() + expect(open.engine.search({ query: 'marmosett' }).planner.repairedTerms).toBeUndefined() +}) + +it('snippets nothing for an orphaned row, even asked for it by rowid', async () => { + const { harness: open, rowids } = await withOrphans() + const plan = planSessionSearchQuery('marmoset') + for (const scope of ['all', 'conversation'] as const) { + expect(sessionSearchSnippet(open.db, scope, rowids[0]!, plan)).toEqual({ + text: '', + truncated: false + }) + } +}) + +it('still answers for the live session beside them', async () => { + const { harness: open } = await withOrphans() + expect(open.engine.search({ query: 'haystack' }).hits.map((hit) => hit.sessionId)).toEqual(['1']) +}) + +// Reclaiming those rows is the other half. The drain deletes only from +// `messages`, so for a long time it was argued to change no answer and left +// outside the generation fence. Retrieval never saw them, but the typo repair's +// dictionary is `messages_vocab`, a view over the FTS b-tree that lists a term +// whether or not a reader can reach the rows carrying it — so the drain moved +// which word a query was repaired to, under a cursor that was still honoured. +describe('a purge reclaiming rows nothing can reach', () => { + /** A live session and a purged one that both carry `text`. */ + async function withReclaimable(): Promise { + harness = await openSessionSearchHarness('ss-orphan-drain') + // Two live rows, which is what makes `marmoset` eligible as a repair at all. + addSyntheticSession(harness.db, { id: 1, text: 'the marmoset lives here', rows: 2 }) + plantOrphans(harness.db) + return harness + } + + it('answers the same before and after, because the repair counts live rows', async () => { + const open = await withReclaimable() + const before = open.engine.search({ query: 'marmosett' }) + expect(before.planner.repairedTerms).toEqual(['marmoset']) + expect(before.hits.map((hit) => hit.sessionId)).toEqual(['1']) + + await open.store.purgeOlderThan(null) + expect(open.db.prepare('SELECT count(*) AS c FROM messages').get()).toEqual({ c: 2 }) + + const after = open.engine.search({ query: 'marmosett' }) + expect(after.planner.repairedTerms).toEqual(before.planner.repairedTerms) + expect(after.hits.map((hit) => hit.sessionId)).toEqual(before.hits.map((hit) => hit.sessionId)) + }) + + it('moves the generation anyway, so no cursor spans it', async () => { + // The repair counting live rows fixes the common case. It does not make the + // drain provably inert: `messages_vocab` still decides which candidates + // survive its scan limit, and reclaiming a term's last row changes where + // that limit cuts. The fence is what covers the rest, at the price of + // refusing a cursor once per batch while a purge runs. + const open = await withReclaimable() + // A second live session, so page one has a page two to be refused. + addSyntheticSession(open.db, { id: 2, text: 'the marmoset again', rows: 2 }) + const page = open.engine.search({ query: 'marmoset', limit: 1 }) + expect(page.page.cursor).not.toBeNull() + const before = readIndexGeneration(open.db) + + await open.store.purgeOlderThan(null) + + expect(readIndexGeneration(open.db)).toBeGreaterThan(before) + try { + open.engine.search({ query: 'marmoset', limit: 1, cursor: page.page.cursor! }) + expect.unreachable('a cursor must not span a purge') + } catch (error) { + expect((error as SessionSearchCursorError).rejection).toBe('stale-generation') + } + }) + + it('picks the same repair when an unreachable spelling was the more common one', async () => { + // Two candidates equally close to the query. `marmosetx` led on the old + // ranking only because two of its rows belonged to a session retention had + // already cut loose, so the drain swapped the repair under a live cursor. + harness = await openSessionSearchHarness('ss-orphan-drain-tie') + const db = harness.db + for (let id = 1; id <= 4; id++) { + addSyntheticSession(db, { id, text: `marmosetx session${id}` }) + } + for (let id = 5; id <= 9; id++) { + addSyntheticSession(db, { id, text: `marmosetq session${id}` }) + } + plantOrphans(db, 'marmosetx') + + const before = harness.engine.search({ query: 'marmosett' }) + expect(before.planner.repairedTerms).toEqual(['marmosetq']) + await harness.store.purgeOlderThan(null) + expect(harness.engine.search({ query: 'marmosett' }).planner.repairedTerms).toEqual( + before.planner.repairedTerms + ) + }) +}) diff --git a/src/main/ai-vault-search/session-search-page-cursor.ts b/src/main/ai-vault-search/session-search-page-cursor.ts new file mode 100644 index 00000000000..30e07fc9a6e --- /dev/null +++ b/src/main/ai-vault-search/session-search-page-cursor.ts @@ -0,0 +1,99 @@ +import { createHash } from 'node:crypto' +import type { SessionSearchRequest } from './session-search-engine-types' + +export type SessionSearchCursorRejection = 'stale-generation' | 'different-query' | 'malformed' + +/** Rejects invalid cursors or any page whose generation changes during its reads. */ +export class SessionSearchCursorError extends Error { + constructor( + readonly rejection: SessionSearchCursorRejection, + /** The generation observed when rejecting the request. */ + readonly actualGeneration: number, + /** Cursor generation, or the generation at the start of a first-page read. */ + readonly expectedGeneration?: number + ) { + super(`Search page rejected: ${rejection}`) + this.name = 'SessionSearchCursorError' + } +} + +type CursorPayload = { + /** Index generation. */ + g: number + /** + * Offset into the ranked list, not a session id. Ids are not in a cursor at + * all, so nothing here depends on `sessions.id` being unique over time — + * though it is, because PR 2 made the column AUTOINCREMENT so a purged + * session's id is never reissued to a live one. + */ + o: number + /** Query identity; see `sessionSearchPageKey`. */ + k: string +} + +/** + * Everything a page's ranking depends on except the limit. Two requests with + * the same key produce the same ranked list within one generation, so a cursor + * minted by one is meaningful to the other; the limit is left out on purpose so + * a caller may change its page size mid-pagination. + */ +export function sessionSearchPageKey(request: SessionSearchRequest): string { + const filters = request.filters ?? {} + const identity = JSON.stringify([ + request.query, + request.scope ?? 'all', + filters.sort ?? 'relevance', + filters.since ?? null, + [...(filters.agents ?? [])].sort(), + [...(filters.scopePaths ?? [])].sort() + ]) + return createHash('sha256').update(identity).digest('base64url').slice(0, 16) +} + +export function encodeSessionSearchCursor(generation: number, offset: number, key: string): string { + const payload: CursorPayload = { g: generation, o: offset, k: key } + return Buffer.from(JSON.stringify(payload), 'utf-8').toString('base64url') +} + +/** + * The offset this cursor points at, or a typed rejection. + * + * Every rejection carries `actualGeneration`, and every one that could read a + * generation out of the cursor carries `expectedGeneration` too, so a caller + * can tell "the index moved under you, ask for page one" from "this cursor is + * not ours" and act on the first without showing anyone an error. + */ +export function decodeSessionSearchCursor(cursor: string, generation: number, key: string): number { + let payload: CursorPayload + try { + payload = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf-8')) as CursorPayload + } catch { + throw new SessionSearchCursorError('malformed', generation) + } + // A generation that survived parsing is worth reporting even when the rest of + // the payload is unusable: it is what tells the caller which snapshot the + // cursor thought it was walking. + // A counter, so a fraction or a negative is forged rather than stale. + const claimed = + typeof payload?.g === 'number' && Number.isInteger(payload.g) && payload.g >= 0 + ? payload.g + : undefined + if ( + claimed === undefined || + !Number.isInteger(payload?.o) || + payload.o < 0 || + typeof payload?.k !== 'string' + ) { + throw new SessionSearchCursorError('malformed', generation, claimed) + } + // Generation first: a caller who changed the query AND waited through a + // publish should hear about the index moving, which is the condition it + // cannot fix by paging again. + if (claimed !== generation) { + throw new SessionSearchCursorError('stale-generation', generation, claimed) + } + if (payload.k !== key) { + throw new SessionSearchCursorError('different-query', generation, claimed) + } + return payload.o +} diff --git a/src/main/ai-vault-search/session-search-paging.test.ts b/src/main/ai-vault-search/session-search-paging.test.ts new file mode 100644 index 00000000000..31341853e28 --- /dev/null +++ b/src/main/ai-vault-search/session-search-paging.test.ts @@ -0,0 +1,351 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SessionSearchRequest } from './session-search-engine-types' +import { + addSyntheticSession, + openSessionSearchHarness, + type SessionSearchHarness +} from './session-search-engine-test-fixture' +import { readIndexGeneration } from './session-search-index-generation' +import { + decodeSessionSearchCursor, + encodeSessionSearchCursor, + SessionSearchCursorError, + sessionSearchPageKey +} from './session-search-page-cursor' + +let harness: SessionSearchHarness | null = null + +afterEach(async () => { + await harness?.close() + harness = null +}) + +async function open(name: string, options = {}): Promise { + harness = await openSessionSearchHarness(name, options) + return harness +} + +async function withSessions(count: number, options = {}): Promise { + harness = await openSessionSearchHarness('ss-engine-paging', options) + for (let id = 1; id <= count; id++) { + addSyntheticSession(harness.db, { + id, + text: `needle padding ${'word '.repeat(id % 5)}`, + updatedAt: `2026-09-${String(id).padStart(2, '0')}T00:00:00.000Z` + }) + } + return harness +} + +describe('a cursor walks one ranked list', () => { + it('pages through every session exactly once, in one stable order', async () => { + const { engine } = await withSessions(25) + const request: SessionSearchRequest = { query: 'needle', limit: 10 } + const seen: string[] = [] + let cursor: string | null = null + let pages = 0 + do { + const page = engine.search(cursor ? { ...request, cursor } : request) + seen.push(...page.hits.map((hit) => hit.sessionId)) + cursor = page.page.cursor + pages++ + expect(pages).toBeLessThan(10) + } while (cursor !== null) + + expect(pages).toBe(3) + expect(seen).toHaveLength(25) + expect(new Set(seen).size).toBe(25) + // The same walk, run again against the same generation, is the same walk. + expect(engine.search(request).hits.map((hit) => hit.sessionId)).toEqual(seen.slice(0, 10)) + }) + + it('closes the page when the last hit has been handed out', async () => { + const { engine } = await withSessions(3) + const page = engine.search({ query: 'needle', limit: 10 }) + expect(page.hits).toHaveLength(3) + expect(page.page.hasMore).toBe(false) + expect(page.page.cursor).toBeNull() + }) + + it('lets a caller change page size mid-walk', async () => { + const { engine } = await withSessions(12) + const first = engine.search({ query: 'needle', limit: 5 }) + const rest = engine.search({ query: 'needle', limit: 20, cursor: first.page.cursor! }) + expect(rest.hits).toHaveLength(7) + expect(rest.page.hasMore).toBe(false) + }) + + it('breaks a tie by session, so two entries cannot swap between pages', async () => { + // Same text, same timestamp: every ranking key is equal, which is exactly + // where an unstable sort would hand one session out twice and lose another. + harness = await openSessionSearchHarness('ss-engine-ties') + for (let id = 1; id <= 6; id++) { + addSyntheticSession(harness.db, { id, text: 'needle', updatedAt: '2026-09-01T00:00:00.000Z' }) + } + const first = harness.engine.search({ query: 'needle', limit: 3 }) + const second = harness.engine.search({ query: 'needle', limit: 3, cursor: first.page.cursor! }) + const seen = [...first.hits, ...second.hits].map((hit) => hit.sessionId) + expect(seen).toEqual(['1', '2', '3', '4', '5', '6']) + }) +}) + +describe('a cursor is refused rather than reinterpreted', () => { + it('rejects a cursor minted before the index moved', async () => { + const { engine, store } = await withSessions(25) + const first = engine.search({ query: 'needle', limit: 10 }) + // A proven deletion of a path this index really held hides a session, which + // is exactly the change a cursor must not be allowed to page across. + store.removeFile('/synthetic/1.jsonl') + + expect(() => engine.search({ query: 'needle', limit: 10, cursor: first.page.cursor! })).toThrow( + SessionSearchCursorError + ) + try { + engine.search({ query: 'needle', limit: 10, cursor: first.page.cursor! }) + expect.unreachable('a stale cursor must not be silently re-run') + } catch (error) { + expect((error as SessionSearchCursorError).rejection).toBe('stale-generation') + } + }) + + it('names both generations, so a caller can tell a moved index from a bad cursor', async () => { + // What a caller does about it differs: a moved index means quietly ask for + // page one again, a bad cursor means something is wrong with the caller. + const { engine, store } = await withSessions(25) + const first = engine.search({ query: 'needle', limit: 10 }) + const minted = readIndexGeneration(harness!.db) + // Any published read moves the generation, including one for a file this + // page never mentioned. That is the fence working, not a defect. + store.removeFile('/synthetic/9.jsonl') + + try { + engine.search({ query: 'needle', limit: 10, cursor: first.page.cursor! }) + expect.unreachable('the index moved') + } catch (error) { + const rejected = error as SessionSearchCursorError + expect(rejected.rejection).toBe('stale-generation') + expect(rejected.expectedGeneration).toBe(minted) + expect(rejected.actualGeneration).toBe(readIndexGeneration(harness!.db)) + expect(rejected.actualGeneration).toBeGreaterThan(rejected.expectedGeneration!) + } + }) + + it('rejects a cursor carried over to a different query', async () => { + const { engine } = await withSessions(25) + const first = engine.search({ query: 'needle', limit: 10 }) + try { + engine.search({ query: 'padding', limit: 10, cursor: first.page.cursor! }) + expect.unreachable('a cursor indexes into one ranked list, not any list') + } catch (error) { + expect((error as SessionSearchCursorError).rejection).toBe('different-query') + } + }) + + it('rejects a cursor whose filters changed, which reranks the list', async () => { + const { engine } = await withSessions(25) + const first = engine.search({ query: 'needle', limit: 10 }) + try { + engine.search({ + query: 'needle', + limit: 10, + cursor: first.page.cursor!, + filters: { sort: 'newest' } + }) + expect.unreachable('a different sort is a different ranked list') + } catch (error) { + expect((error as SessionSearchCursorError).rejection).toBe('different-query') + } + }) + + // Every field the ranked list depends on has to be in the key, and a field + // that is in the key but never pinned is a field a refactor can drop while + // the suite stays green. One case each, through the engine, so the assertion + // is about a refused page and not about a hash. + it.each([ + ['scope', { scope: 'conversation' as const }], + ['sort', { filters: { sort: 'newest' as const } }], + ['agents', { filters: { agents: ['codex' as const] } }], + ['scopePaths', { filters: { scopePaths: ['/repo/app'] } }], + ['since', { filters: { since: '2026-09-01T00:00:00.000Z' } }] + ])('rejects a cursor presented with a different %s', async (_field, changed) => { + const { engine } = await withSessions(25) + const request: SessionSearchRequest = { + query: 'needle', + limit: 10, + scope: 'all', + filters: { sort: 'relevance', agents: ['claude'], scopePaths: ['/'], since: undefined } + } + const first = engine.search(request) + expect(first.page.cursor).not.toBeNull() + try { + engine.search({ + ...request, + ...changed, + filters: { ...request.filters, ...('filters' in changed ? changed.filters : {}) }, + cursor: first.page.cursor! + }) + expect.unreachable('a narrowing the ranked list depends on must invalidate the cursor') + } catch (error) { + expect((error as SessionSearchCursorError).rejection).toBe('different-query') + } + }) + + it('rejects a cursor that is not one of ours', async () => { + const { engine } = await withSessions(3) + try { + engine.search({ query: 'needle', cursor: 'not-a-cursor' }) + expect.unreachable('a malformed cursor is not an empty one') + } catch (error) { + expect((error as SessionSearchCursorError).rejection).toBe('malformed') + } + }) +}) + +describe('cursor encoding', () => { + const request: SessionSearchRequest = { query: 'needle', filters: { scopePaths: ['/a'] } } + + it('round-trips an offset within its own generation and query', () => { + const key = sessionSearchPageKey(request) + expect(decodeSessionSearchCursor(encodeSessionSearchCursor(7, 40, key), 7, key)).toBe(40) + }) + + it('keys a request by what changes its ranking, and not by its page size', () => { + expect(sessionSearchPageKey({ ...request, limit: 5 })).toBe( + sessionSearchPageKey({ ...request, limit: 50 }) + ) + expect(sessionSearchPageKey({ ...request, scope: 'conversation' })).not.toBe( + sessionSearchPageKey(request) + ) + }) + + it('reads a filter list in any order as the same request', () => { + expect(sessionSearchPageKey({ query: 'a', filters: { agents: ['claude', 'codex'] } })).toBe( + sessionSearchPageKey({ query: 'a', filters: { agents: ['codex', 'claude'] } }) + ) + }) + + it.each([ + ['a negative offset', encodeSessionSearchCursor(1, -1, 'k'), 1], + ['a non-integer offset', Buffer.from('{"g":1,"o":1.5,"k":"k"}').toString('base64url'), 1], + ['a payload that is not an object', Buffer.from('"nope"').toString('base64url'), undefined], + ['text that is not base64url JSON', 'zzz!!', undefined], + // A generation is a counter: neither of these is a snapshot that ever + // existed, so reporting one as stale would name a generation as expected. + [ + 'a fractional generation', + Buffer.from('{"g":7.5,"o":0,"k":"k"}').toString('base64url'), + undefined + ], + [ + 'a negative generation', + Buffer.from('{"g":-1,"o":0,"k":"k"}').toString('base64url'), + undefined + ] + ])('rejects %s as malformed, still naming the index generation', (_name, cursor, claimed) => { + // The caller has to know which snapshot it was refused against whatever was + // wrong with the cursor, and the generation it claimed whenever that + // survived parsing. + try { + decodeSessionSearchCursor(cursor, 7, 'k') + expect.unreachable('a malformed cursor is not an empty one') + } catch (error) { + const rejected = error as SessionSearchCursorError + expect(rejected.rejection).toBe('malformed') + expect(rejected.actualGeneration).toBe(7) + expect(rejected.expectedGeneration).toBe(claimed) + } + }) +}) + +describe('the candidate limit is a tunable default, and says when it cut', () => { + it('does not claim truncation when every session fits', async () => { + const { engine } = await withSessions(5, { sessionCandidateLimit: 600 }) + expect(engine.search({ query: 'needle' }).truncated.candidates).toBe(false) + }) + + it('claims truncation, and ranks only what it retrieved, at the limit', async () => { + const { engine } = await withSessions(10, { sessionCandidateLimit: 4 }) + const result = engine.search({ query: 'needle', limit: 100 }) + expect(result.truncated.candidates).toBe(true) + expect(result.hits).toHaveLength(4) + }) + + it('applies the same limit to an operator-only page', async () => { + const { engine } = await withSessions(10, { sessionCandidateLimit: 4 }) + const result = engine.search({ query: 'repo:app', limit: 100 }) + expect(result.truncated.candidates).toBe(true) + expect(result.hits).toHaveLength(4) + }) + + it('says it gave up when the operator walk stopped scanning, not that it is done', async () => { + // The shape that reads as a confident empty answer: the only match sits + // past the walk's ceiling, so the walk stops having found nothing. Zero + // hits and `truncated.candidates` false would tell a caller there is + // nothing to find, which is a different claim from "I stopped looking". + // The walk reads a page at a time and gives up past a ceiling of + // `candidateLimit` x 20, so the corpus has to be deeper than one page for + // the ceiling to be what ends it. The only match is the oldest session. + const deep = 600 + const { db, engine } = await open('ss-engine-sparse-deep', { sessionCandidateLimit: 2 }) + for (let id = 1; id <= deep; id++) { + addSyntheticSession(db, { + id, + cwd: id === deep ? '/repo/needleonly' : '/repo/app', + updatedAt: new Date(Date.UTC(2026, 8, 9) - id * 60_000).toISOString() + }) + } + const result = engine.search({ query: 'repo:needleonly' }) + expect(result.hits).toHaveLength(0) + expect(result.truncated.candidates).toBe(true) + }) + + it('does not claim it gave up when the walk really did read everything', async () => { + const { db, engine } = await open('ss-engine-sparse-shallow', { sessionCandidateLimit: 600 }) + addSyntheticSession(db, { id: 1, cwd: '/repo/app' }) + const result = engine.search({ query: 'repo:nothing-here' }) + expect(result.hits).toHaveLength(0) + expect(result.truncated.candidates).toBe(false) + }) +}) + +describe('the response carries the snapshot it was built from', () => { + it('reports the index generation on every result', async () => { + const { db, engine, store } = await withSessions(3) + const before = engine.search({ query: 'needle' }).generation + expect(before).toBe(readIndexGeneration(db)) + store.removeFile('/synthetic/1.jsonl') + const after = engine.search({ query: 'needle' }).generation + expect(after).toBe(readIndexGeneration(db)) + expect(after).toBeGreaterThan(before) + }) +}) + +it.each([false, true])('rejects a write during page assembly (cursor: %s)', async (withCursor) => { + const { db, engine, store } = await open('ss-concurrent-page') + for (let id = 1; id <= 3; id++) { + addSyntheticSession(db, { id, text: 'needle' }) + } + const first = engine.search({ query: 'needle', limit: 1 }) + const prepare = db.prepare.bind(db) + let committed = false + const hook = vi.spyOn(db, 'prepare').mockImplementation((sql) => { + if (!committed && sql.includes('SELECT DISTINCT session_row_id FROM files')) { + committed = true + store.removeFile('/synthetic/1.jsonl') + } + return prepare(sql) + }) + try { + expect(() => + engine.search({ + query: 'needle', + limit: 1, + ...(withCursor ? { cursor: first.page.cursor! } : {}) + }) + ).toThrow(SessionSearchCursorError) + expect(committed).toBe(true) + expect(readIndexGeneration(db)).toBeGreaterThan(first.generation) + } finally { + hook.mockRestore() + } +}) diff --git a/src/main/ai-vault-search/session-search-query-planner.test.ts b/src/main/ai-vault-search/session-search-query-planner.test.ts new file mode 100644 index 00000000000..ac4874b1d10 --- /dev/null +++ b/src/main/ai-vault-search/session-search-query-planner.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { + andExpression, + isLiteralQuery, + orExpression, + phraseExpression, + planSessionSearchQuery, + quoteFtsTerm +} from './session-search-query-planner' + +describe('literal shape decides whether the phrase route is even tried', () => { + it.each([ + 'resolveTerminalPath', + 'src/main/foo-bar.ts', + 'MAX_RETRY_COUNT', + 'kern.tty.ptmx_max', + '#19687', + 'STA-4850', + '"exact words here"', + 'TypeError: undefined', + 'foo() {' + ])('treats %s as quoting something from a transcript', (query) => { + expect(isLiteralQuery(query)).toBe(true) + }) + + it.each(['why is the terminal slow', 'how do I resume a session', 'relay capacity'])( + 'treats %s as prose', + (query) => { + expect(isLiteralQuery(query)).toBe(false) + } + ) +}) + +describe('the body is what the phrase and AND routes see', () => { + it('drops stop words from prose so the AND route is not defeated by "the"', () => { + expect(planSessionSearchQuery('why is the relay dropping frames').body).toEqual([ + 'relay', + 'dropping', + 'frames' + ]) + }) + + it('keeps stop words inside a literal, where they are part of what was quoted', () => { + // The literal shape is `foo.ts`; dropping `the` would change what was typed. + expect(planSessionSearchQuery('the foo.ts file').body).toEqual(['the', 'foo.ts', 'file']) + }) + + it('keeps a query that is nothing but stop words rather than answering nothing', () => { + expect(planSessionSearchQuery('how do I').body).toEqual(['how', 'do', 'I']) + }) + + it('has no terms for a query with no searchable token', () => { + expect(planSessionSearchQuery(' ... ').terms).toEqual([]) + }) +}) + +describe('the OR fallback fans an identifier out into its pieces', () => { + it('adds the split pieces after the whole term, never in place of it', () => { + const plan = planSessionSearchQuery('resolveTerminalPath') + expect(plan.terms[0]).toBe('resolveTerminalPath') + expect(plan.terms).toContain('terminal') + expect(plan.terms).toContain('path') + // `resolve` is not a stop word, so the whole identifier is reachable by piece. + expect(plan.terms).toContain('resolve') + }) + + it('leaves an ordinary word alone', () => { + expect(planSessionSearchQuery('relay').terms).toEqual(['relay']) + }) +}) + +describe('FTS5 expressions quote every term', () => { + it('quotes punctuation that would otherwise be syntax', () => { + expect(quoteFtsTerm('cli.mjs')).toBe('"cli.mjs"') + expect(quoteFtsTerm('C++')).toBe('"C++"') + expect(quoteFtsTerm('say "hi"')).toBe('"say ""hi"""') + }) + + it('builds one phrase, an AND chain, and an OR chain from the same terms', () => { + expect(phraseExpression(['alpha', 'beta'])).toBe('"alpha beta"') + expect(andExpression(['alpha', 'beta'])).toBe('"alpha" AND "beta"') + expect(orExpression(['alpha', 'beta'])).toBe('"alpha" OR "beta"') + }) +}) diff --git a/src/main/ai-vault-search/session-search-query-planner.ts b/src/main/ai-vault-search/session-search-query-planner.ts new file mode 100644 index 00000000000..6c2c2f3b91c --- /dev/null +++ b/src/main/ai-vault-search/session-search-query-planner.ts @@ -0,0 +1,140 @@ +import type { SessionSearchScope } from './session-search-engine-types' +import { identifierShadowTerms } from './session-search-identifier-split' + +// Tokens exactly as the unicode61 tokenizer with `_ . - / +` tokenchars emits them. +const INDEX_TOKEN = /[\p{L}\p{N}\p{M}\p{Co}_./+-]+/gu +const STOP_WORDS = new Set( + ( + 'a an and are as at be but by for from how i if in into is it its of on or that the this to ' + + 'was were what when where which who why with you your we my me do does did not no can could ' + + 'should would about our us they them there their has have had been being so such then than ' + + "these those there's im ive dont" + ).split(' ') +) +const MAX_BODY_TERMS = 48 +const MAX_TERMS = 64 + +// A query that quotes something from a transcript: camelCase, SCREAMING_SNAKE, +// a dotted or snake_case name, a path, a filename, a PR number, a ticket, code +// punctuation, or an error word. +const LITERAL_SHAPE = + /[A-Za-z0-9_]*[a-z][A-Z][A-Za-z0-9_]*|\b[A-Z][A-Z0-9]{2,}(_[A-Z0-9]+)+\b|\b\w{2,}[._]\w{2,}\b|\b[\w.-]+\/[\w/.-]+\b|\b\w+\.(ts|tsx|js|jsx|py|rs|go|json|md|sh|yml|yaml|toml|c|cc|h|java|sql)\b|#\d{3,}|\b[A-Z]{2,6}-\d{2,}\b|[(){};=]|::|->|--\w|\b(Error|Exception|Traceback|error:|warning:)\b/ +const QUOTED = /"[^"]{3,}"|'[^']{3,}'/ + +export type SessionSearchQueryPlan = { + literal: boolean + /** + * The query had more terms than the planner will search. What is dropped is + * the tail, so a match that only the last term would have found is missed; + * the caller is told rather than handed a confident empty answer. + */ + truncated: boolean + /** Deduplicated index-faithful terms for the OR fallback, incl. identifier pieces. */ + terms: string[] + /** Query-order tokens minus stop words: the phrase / AND candidate. */ + body: string[] +} + +export function isLiteralQuery(query: string): boolean { + return QUOTED.test(query) || LITERAL_SHAPE.test(query) +} + +/** + * The tokenizer contract, unfolded: the same boundaries FTS5 draws for + * `unicode61 tokenchars '_.-/+'`. Pinned against real `fts5vocab` output in + * session-search-fts5-contract.test.ts, which is what makes it safe to plan a + * query without asking SQLite. + */ +export function indexTokens(query: string, limit = Number.POSITIVE_INFINITY): string[] { + const out: string[] = [] + for (const match of query.matchAll(INDEX_TOKEN)) { + const token = match[0] + // Separators alone (`--`, `...`) are a token to FTS5 but never a search term. + if (/[\p{L}\p{N}\p{Co}]/u.test(token)) { + out.push(token) + if (out.length >= limit) { + break + } + } + } + return out +} + +/** + * `literal` overrides the shape test. Typo repair re-plans the query it + * corrected, and a corrected spelling can look like ordinary prose even though + * what was typed was a literal: `parseJsonn(the, data)` has the punctuation that + * makes it literal, `parsejson the data` does not. Without the override the + * re-plan would drop `the` as a stop word, so the repaired query would search + * for less than the original asked for and `repairedTerms` would report a body + * the user never typed. + */ +export function planSessionSearchQuery( + query: string, + literal = isLiteralQuery(query) +): SessionSearchQueryPlan { + // One past the cap, so the plan can tell a query that just fits from one that + // was cut. `indexTokens` stops at its limit, so it cannot be asked afterwards. + const overCap = indexTokens(query, MAX_BODY_TERMS + 1) + const truncated = overCap.length > MAX_BODY_TERMS + const raw = overCap.slice(0, MAX_BODY_TERMS) + let body = literal ? raw : raw.filter((token) => !STOP_WORDS.has(token.toLowerCase())) + if (body.length < 2) { + body = raw + } + const terms = [...new Set(body)] + const extra: string[] = [] + for (const term of terms) { + for (const piece of identifierShadowTerms(term, 12)) { + if (!terms.includes(piece) && !STOP_WORDS.has(piece) && !extra.includes(piece)) { + extra.push(piece) + } + } + } + return { + literal, + truncated, + terms: [...terms, ...extra].slice(0, MAX_TERMS), + body: body.slice(0, MAX_BODY_TERMS) + } +} + +// Why: `cli.mjs`, `foo-bar`, and `C++` are all FTS5 syntax errors unquoted. +export function quoteFtsTerm(term: string): string { + return `"${term.replaceAll('"', '""')}"` +} + +export function phraseExpression(terms: readonly string[]): string { + return quoteFtsTerm(terms.join(' ')) +} + +export function andExpression(terms: readonly string[]): string { + return terms.map(quoteFtsTerm).join(' AND ') +} + +export function orExpression(terms: readonly string[]): string { + return terms.map(quoteFtsTerm).join(' OR ') +} + +/** + * What a scope is, now that there is one FTS table. + * + * `conversation` used to be a second table holding a copy of the two prose + * columns. It is a column filter instead: PR 2 measured the filter at + * 1.16-1.36x the p95 of the dedicated table on a 105 MB corpus, against a 2x + * bar, and the table cost a tenth of the index to maintain. + * + * It lives beside the other expression builders, and not with the retrieval + * that uses it, because the typo repair has to ask the same question of the + * same scope and importing it from there is a cycle. + * + * The filter binds to the whole expression, so it is applied here and nowhere + * else — `{cols}: (a AND b)` filters both terms, while a prefix pasted in front + * of a bare `a AND b` would filter only `a` and quietly search tool output for + * the rest. + */ +const CONVERSATION_COLUMNS = '{user_text assistant_text}' + +export function scopedExpression(scope: SessionSearchScope, expression: string): string { + return scope === 'all' ? expression : `${CONVERSATION_COLUMNS}: (${expression})` +} diff --git a/src/main/ai-vault-search/session-search-query-schema.ts b/src/main/ai-vault-search/session-search-query-schema.ts new file mode 100644 index 00000000000..f01c2d42d18 --- /dev/null +++ b/src/main/ai-vault-search/session-search-query-schema.ts @@ -0,0 +1,42 @@ +import type SyncDatabase from '../sqlite/sync-database' +import { + SESSION_SEARCH_GENERATION_SQL, + SESSION_SEARCH_GENERATION_TRIGGERS +} from './session-search-index-generation' + +const QUERY_SCHEMA_SQL = ` +-- The typo repair's whole dictionary. Why the index's own vocabulary and not a +-- word list: it can never suggest a term this index does not hold, and it needs +-- no model. fts5vocab is a view over the FTS5 b-tree, so it costs no extra rows. +CREATE VIRTUAL TABLE IF NOT EXISTS messages_vocab USING fts5vocab(messages_fts, 'row'); +${SESSION_SEARCH_GENERATION_SQL}` + +/** Everything the SQL above creates, so a missing one is what triggers a re-run. */ +const OWNED = ['messages_vocab', ...SESSION_SEARCH_GENERATION_TRIGGERS] + +/** + * The vocabulary's target. Creating a fts5vocab table over a missing FTS table + * succeeds and every query against it then fails, so the feature's health is + * this name's presence rather than the vocabulary's own. + */ +const VOCABULARY_SOURCE = 'messages_fts' + +const PROBED = [...OWNED, VOCABULARY_SOURCE] + +/** Restore derived objects; a missing source index requires the owner to rebuild. */ +export function ensureSessionSearchQuerySchema(db: SyncDatabase): void { + const present = presentNames(db) + if (!present.has(VOCABULARY_SOURCE)) { + throw new Error('Session search index unavailable: missing messages_fts') + } + if (OWNED.some((name) => !present.has(name))) { + db.exec(QUERY_SCHEMA_SQL) + } +} + +function presentNames(db: SyncDatabase): Set { + const rows = db + .prepare(`SELECT name FROM sqlite_master WHERE name IN (${PROBED.map(() => '?').join(',')})`) + .all(...PROBED) as { name: string }[] + return new Set(rows.map((row) => row.name)) +} diff --git a/src/main/ai-vault-search/session-search-retrieval.ts b/src/main/ai-vault-search/session-search-retrieval.ts new file mode 100644 index 00000000000..59fef23e903 --- /dev/null +++ b/src/main/ai-vault-search/session-search-retrieval.ts @@ -0,0 +1,244 @@ +import type SyncDatabase from '../sqlite/sync-database' +import type { SessionSearchRoute, SessionSearchScope } from './session-search-engine-types' +import type { MessageRow, SessionRow } from './session-search-hit-ranking' +import { + andExpression, + orExpression, + phraseExpression, + planSessionSearchQuery, + scopedExpression, + type SessionSearchQueryPlan +} from './session-search-query-planner' +import type { SessionRowFilter } from './session-search-row-filter' +import { SessionSearchTypoRepair } from './session-search-typo-repair' + +// The operator-only walk: rows per page, and how far past a full candidate set +// it will read before giving up on finding more matches. +const RECENT_PAGE_ROWS = 512 +// Ids per `loadSessions` statement, with room to spare for the filter's own +// bound values beside them. +const SESSION_ID_BATCH = 500 +const RECENT_SCAN_FACTOR = 20 + +// Measured: user 3 / assistant 2 / tool 1 / identifiers 1 (MRR 0.503 vs 0.475 flat). +const FULL_WEIGHTS = '3.0, 2.0, 1.0, 1.0' +// Tool and identifier columns do not contribute to conversation ranking. +const CONVERSATION_WEIGHTS = '3.0, 2.0, 0.0, 0.0' + +export type RetrievalScope = { + scope: SessionSearchScope + sort: 'relevance' | 'newest' + filter: SessionRowFilter + /** + * `repo:` / `path:`, which SQL cannot express. Applied over retrieved rows; + * see session-search-row-filter for why it cannot be pushed down. + */ + matchesOperators: (session: SessionRow) => boolean + /** + * Sessions retrieved before ranking cuts the page. See + * docs/reference/agent-session-search-query-tuning.md for the measurements + * behind the default; it is an option because the right value depends on how + * large an index is and no single number is right for every host. + */ + candidateLimit: number +} + +export type Retrieved = { + sessions: SessionRow[] + rows: MessageRow[] + incomplete: boolean + route: SessionSearchRoute + /** The plan the rows were actually retrieved by; snippets highlight from it. */ + plan: SessionSearchQueryPlan + repairedTerms?: string[] +} + +/** + * The bm25 weights a scope ranks with. The conversation pair stays here rather + * than beside `scopedExpression`, because weights are a property of this SQL + * and nothing else asks for them. + */ +export function scopedWeights(scope: SessionSearchScope): string { + return scope === 'all' ? FULL_WEIGHTS : CONVERSATION_WEIGHTS +} + +/** The FTS half of a search: the route ladder and the SQL each rung runs. */ +export class SessionSearchRetrieval { + private readonly typoRepair: SessionSearchTypoRepair + + constructor(private readonly db: SyncDatabase) { + this.typoRepair = new SessionSearchTypoRepair(db) + } + + /** + * The route ladder: phrase, then AND for a literal-looking query, then typo + * repair, then OR. + * + * Repair runs before the OR fallback rather than after it fails. A typo next + * to a common word would otherwise be masked: the common word alone retrieves + * plenty of rows over OR, so nothing would ever look like a miss worth + * repairing. + */ + run(plan: SessionSearchQueryPlan, scope: RetrievalScope): Retrieved { + let incomplete = false + let sessions: SessionRow[] = [] + const match = (expression: string): MessageRow[] => { + const rows = this.match(expression, scope) + incomplete ||= rows.length >= scope.candidateLimit + sessions = this.loadSessions( + rows.map((row) => row.session_row_id), + scope + ) + const eligible = new Set(sessions.map((row) => row.id)) + return rows.filter((row) => eligible.has(row.session_row_id)) + } + const exact = this.literal(plan, match) + if (exact) { + return { ...exact, plan, incomplete, sessions } + } + const repaired = this.repair(plan, scope.scope) + const effective = repaired ?? plan + const literal = repaired ? this.literal(repaired, match) : null + const found = literal ?? { + rows: match(orExpression(effective.terms)), + route: 'or' as const + } + return { + sessions, + rows: found.rows, + incomplete, + route: repaired ? (`typo+${found.route}` as SessionSearchRoute) : found.route, + plan: effective, + ...(repaired ? { repairedTerms: repaired.body } : {}) + } + } + + /** + * Newest sessions the constraints allow: what an operator-only query names. + * + * Walked in pages rather than taken in one `LIMIT`, because the operators are + * applied in JS. A single cut of the newest N would hand ranking whatever + * happened to be recent and then throw most of it away, so `repo:x` on a busy + * index could answer with nothing while plenty matched. The walk is bounded + * both ways: it stops at a full candidate set, and at a ceiling on rows read. + */ + recent(scope: RetrievalScope): { sessions: SessionRow[]; incomplete: boolean } { + const { conditions, values } = scope.filter + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' + const page = this.db.prepare( + `SELECT * FROM sessions ${where} + ORDER BY updated_at DESC, id DESC LIMIT ? OFFSET ?` + ) + const ceiling = scope.candidateLimit * RECENT_SCAN_FACTOR + const sessions: SessionRow[] = [] + let scanned = 0 + // Why the flag and not a count: both caps mean the same thing to a caller — + // a session it never saw may have matched — and only the loop knows which + // of them ended it. Reporting rows read instead let the engine infer + // completeness from a full candidate set alone, so giving up at the ceiling + // with nothing found looked exactly like a search that found nothing. + let incomplete = false + while (sessions.length < scope.candidateLimit) { + if (scanned >= ceiling) { + incomplete = true + break + } + const rows = page.all(...values, RECENT_PAGE_ROWS, scanned) as SessionRow[] + if (rows.length === 0) { + break + } + scanned += rows.length + for (const row of rows) { + if (sessions.length < scope.candidateLimit && scope.matchesOperators(row)) { + sessions.push(row) + } + } + } + return { sessions, incomplete: incomplete || sessions.length >= scope.candidateLimit } + } + + /** Bound SQL parameters independently of the configurable candidate limit. */ + private loadSessions(ids: readonly number[], scope: RetrievalScope): SessionRow[] { + const rows: SessionRow[] = [] + for (let start = 0; start < ids.length; start += SESSION_ID_BATCH) { + const batch = ids.slice(start, start + SESSION_ID_BATCH) + const conditions = [`id IN (${batch.map(() => '?').join(',')})`, ...scope.filter.conditions] + rows.push( + ...(this.db + .prepare(`SELECT * FROM sessions WHERE ${conditions.join(' AND ')}`) + .all(...batch, ...scope.filter.values) as SessionRow[]) + ) + } + return rows.filter((row) => scope.matchesOperators(row)) + } + + private repair( + plan: SessionSearchQueryPlan, + scope: SessionSearchScope + ): SessionSearchQueryPlan | null { + const typoRepair = this.typoRepair + let changed = false + const body = plan.body.map((term) => { + // Repaired inside the scope the search will run in, so a spelling only + // tool output carries neither suppresses a repair nor becomes one. + const fix = typoRepair.correct(term, scope) + if (fix && fix !== term.toLowerCase()) { + changed = true + return fix + } + return term + }) + // The repair changes spellings, not the query's character: the re-plan is + // told what the original decided so a corrected literal keeps every term it + // was typed with. + return changed ? planSessionSearchQuery(body.join(' '), plan.literal) : null + } + + /** Phrase, then AND, for literal-looking queries; null when neither matches. */ + private literal( + plan: SessionSearchQueryPlan, + match: (expression: string) => MessageRow[] + ): { rows: MessageRow[]; route: 'phrase' | 'and' } | null { + if (!plan.literal || plan.body.length === 0) { + return null + } + // A one-token literal (`resolveTerminalPath`, `src/a/b.ts`) is its own + // phrase: the tokenizer keeps it whole, so the exact token is the cheap, + // precise first try before the identifier pieces fan out over OR. + const phrase = match(phraseExpression(plan.body)) + if (phrase.length > 0) { + return { rows: phrase, route: 'phrase' } + } + if (plan.body.length < 2) { + return null + } + const and = match(andExpression(plan.body)) + return and.length > 0 ? { rows: and, route: 'and' } : null + } + + private match(expression: string, scope: RetrievalScope): MessageRow[] { + const { filter, sort, candidateLimit } = scope + const eligible = filter.conditions.length + ? ` AND m.session_row_id IN (SELECT id FROM sessions WHERE ${filter.conditions.join(' AND ')})` + : '' + const matched = `SELECT messages_fts.rowid AS rowid, + -bm25(messages_fts, ${scopedWeights(scope.scope)}) AS score, + m.session_row_id, m.role, m.ts, s.updated_at + FROM messages_fts JOIN messages m ON m.id = messages_fts.rowid + JOIN sessions s ON s.id = m.session_row_id WHERE messages_fts MATCH ?${eligible}` + // Why: collapse to one row per session BEFORE the candidate limit, on both + // sort orders, so a single long session cannot occupy the whole page. + // `max(score)` makes SQLite pick that session's best row for the bare columns. + // Cost of grouping instead of a bounded top-N sorter, measured: ~1.75x + // (49.6 vs 28.6 ms at 80k matching rows, 183.6 vs 104.1 ms at 240k) and a + // temp b-tree over every match. No inner LIMIT can bound it: the CTE has no + // order, so any cut drops whole sessions rather than their surplus rows. + const order = sort === 'newest' ? 'updated_at DESC, score DESC' : 'score DESC' + const sql = `WITH matched AS MATERIALIZED (${matched}) + SELECT rowid, max(score) AS score, session_row_id, role, ts FROM matched + GROUP BY session_row_id ORDER BY ${order} LIMIT ${candidateLimit}` + return this.db + .prepare(sql) + .all(scopedExpression(scope.scope, expression), ...filter.values) as MessageRow[] + } +} diff --git a/src/main/ai-vault-search/session-search-row-filter.test.ts b/src/main/ai-vault-search/session-search-row-filter.test.ts new file mode 100644 index 00000000000..1cec0a528f9 --- /dev/null +++ b/src/main/ai-vault-search/session-search-row-filter.test.ts @@ -0,0 +1,147 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type SyncDatabase from '../sqlite/sync-database' +import type { SessionSearchFilters } from './session-search-engine-types' +import { cwdKey } from './session-search-file-records' +import { sessionRowFilter } from './session-search-row-filter' +import { + openSessionSearchIndexFile, + type SessionSearchIndexFile +} from './session-search-index-test-fixture' + +let index: SessionSearchIndexFile | null = null + +afterEach(async () => { + await index?.close() + index = null +}) + +async function openIndex(): Promise { + index = await openSessionSearchIndexFile('ss-row-filter') + return index.db +} + +function addSession( + db: SyncDatabase, + id: number, + cwd: string | null, + overrides: { agent?: string; updatedAt?: string } = {} +): void { + db.prepare( + `INSERT INTO sessions(id,agent,session_id,file_path,title,cwd,cwd_key,updated_at,resume_command) + VALUES (?,?,?,?,'fixture',?,?,?,'')` + ).run( + id, + overrides.agent ?? 'claude', + String(id), + `/synthetic/${id}`, + cwd, + cwdKey(cwd), + overrides.updatedAt ?? '2026-09-01T00:00:00.000Z' + ) +} + +function selected(db: SyncDatabase, filters: SessionSearchFilters = {}): number[] { + const filter = sessionRowFilter(filters) + const where = filter.conditions.length > 0 ? `WHERE ${filter.conditions.join(' AND ')}` : '' + return ( + db.prepare(`SELECT id FROM sessions ${where} ORDER BY id`).all(...filter.values) as { + id: number + }[] + ).map((row) => row.id) +} + +describe('a cwd scope is the sidebar key, or anything below it', () => { + it.each([ + ['C:\\Work\\App', 'c:/work/app', true], + ['C:\\Work\\App\\src', 'c:/work/app', true], + ['/work/APP/src', '/work/app', false], + ['/work/caf\u00e9', '/work/cafe\u0301', true], + ['/work/app-other', '/work/app', false], + ['/work/a_b/src', '/work/a_b', true], + ['/work/axb/src', '/work/a_b', false], + // Roots: `/` is the one key that is already a separator, which is where a + // range bound is easiest to get wrong. A Windows key is not under POSIX `/`. + ['/', '/', true], + ['/work/app', '/', true], + ['C:\\Work\\App', '/', false], + ['C:\\', 'C:\\', true], + ['C:\\Work\\App', 'C:\\', true] + ])('scopes %s under %s: %s', async (cwd, scope, expected) => { + const db = await openIndex() + addSession(db, 1, cwd) + expect(selected(db, { scopePaths: [scope] })).toEqual(expected ? [1] : []) + }) + + it('never matches a session whose transcript recorded no cwd', async () => { + const db = await openIndex() + addSession(db, 1, null) + expect(selected(db, { scopePaths: ['/work'] })).toEqual([]) + expect(selected(db)).toEqual([1]) + }) + + it('narrows to nothing when no scope the caller gave could be keyed', async () => { + // `cwdKey` returns null for a scope it cannot key, and a scope that matches + // nothing must return nothing; dropping it would answer the whole index. + const db = await openIndex() + addSession(db, 1, '/work/app') + addSession(db, 2, '/elsewhere') + expect(selected(db, { scopePaths: [''] })).toEqual([]) + expect(selected(db, { scopePaths: ['', '/work/app'] })).toEqual([1]) + }) + + it('keeps a WSL UNC workspace distinct from the bare Linux spelling', async () => { + // PR 2 decided cwd_key does not qualify a Linux path with its distro: the + // collision is real but every SSH host has it too, and the fix is a column + // naming the execution host, not a key only some hosts spell differently. + const db = await openIndex() + addSession(db, 1, '\\\\wsl.localhost\\Ubuntu\\home\\ada\\app') + addSession(db, 2, '/home/ada/app') + expect(selected(db, { scopePaths: ['\\\\wsl$\\Ubuntu\\home\\ada'] })).toEqual([1]) + expect(selected(db, { scopePaths: ['/home/ada/app'] })).toEqual([2]) + expect(selected(db, { scopePaths: ['\\\\wsl$\\Debian\\home\\ada\\app'] })).toEqual([]) + }) +}) + +describe('caller filters', () => { + it('narrows by agent, and by updated-at floor', async () => { + const db = await openIndex() + addSession(db, 1, '/work/app', { agent: 'claude', updatedAt: '2026-09-01T00:00:00.000Z' }) + addSession(db, 2, '/work/app', { agent: 'codex', updatedAt: '2026-09-05T00:00:00.000Z' }) + expect(selected(db, { agents: ['codex'] })).toEqual([2]) + expect(selected(db, { since: '2026-09-03T00:00:00.000Z' })).toEqual([2]) + expect(selected(db, { agents: ['claude'], since: '2026-09-03T00:00:00.000Z' })).toEqual([]) + }) + + it('applies the retention cutoff through the files table', async () => { + const db = await openIndex() + addSession(db, 1, '/work/app') + addSession(db, 2, '/work/app') + db.prepare( + "INSERT INTO files(path,byte_offset,mtime_ms,session_row_id) VALUES ('a',0,100,1)" + ).run() + db.prepare( + "INSERT INTO files(path,byte_offset,mtime_ms,session_row_id) VALUES ('b',0,500,2)" + ).run() + const filter = sessionRowFilter({}, 300) + const rows = db + .prepare(`SELECT id FROM sessions WHERE ${filter.conditions.join(' AND ')}`) + .all(...filter.values) as { id: number }[] + expect(rows.map((row) => row.id)).toEqual([2]) + }) +}) + +it('plans a cwd scope as a seek on sessions_cwd_key, never a scan', async () => { + const db = await openIndex() + const filter = sessionRowFilter({ scopePaths: ['/work/app'] }) + const plan = ( + db + .prepare( + `EXPLAIN QUERY PLAN SELECT id FROM sessions WHERE ${filter.conditions.join(' AND ')}` + ) + .all(...filter.values) as { detail: string }[] + ).map((row) => row.detail) + + expect(plan.join(' | ')).toContain('sessions_cwd_key') + expect(plan.some((detail) => detail.startsWith('SEARCH'))).toBe(true) + expect(plan.some((detail) => detail.startsWith('SCAN sessions'))).toBe(false) +}) diff --git a/src/main/ai-vault-search/session-search-row-filter.ts b/src/main/ai-vault-search/session-search-row-filter.ts new file mode 100644 index 00000000000..4f5b6106e7d --- /dev/null +++ b/src/main/ai-vault-search/session-search-row-filter.ts @@ -0,0 +1,90 @@ +import { cwdKey } from './session-search-file-records' +import type { SessionSearchFilters } from './session-search-engine-types' + +/** SQL fragments for the `sessions` WHERE clause; every condition is ANDed. */ +export type SessionRowFilter = { + conditions: string[] + values: (string | number)[] +} + +// Stored identity: `cwdKey` is the sidebar's `folderGroupKey` without its prefix, +// so a scope term and an indexed session are keyed by one function, never two. +const CWD = 'cwd_key' + +/** + * The narrowings SQL can express exactly, in one place, so retrieval, the + * operator-only page and the session load cannot drift apart. These conditions + * run over `sessions` itself. Reachability is not here and is not a condition: + * it is the INNER JOIN to `sessions` that every retrieval carries, which is + * what makes a message row a purge has not reclaimed yet unreadable. + * + * `repo:` and `path:` are deliberately absent. What they mean is the predicate + * the sessions panel applies (`matchesAiVaultQueryOperators`), and SQL cannot + * express it: LIKE folds ASCII and nothing else, so `path:CAFÉ` would miss + * `café`; `path:` searches the transcript path as well as the working + * directory, so `path:jsonl` would miss every session; and `repo:` compares the + * last two path segments, not one. A second spelling that came close would be a + * query meaning different things in the list and in the index, so the engine + * applies the panel's own predicate over the rows it retrieves instead. + * + * `scopePaths` stays here because it is exact: a prefix range over the key + * `cwdKey` produces, which folds exactly where the execution host folds — + * Windows drives, never a POSIX directory name. + */ +export function sessionRowFilter( + filters: SessionSearchFilters, + cutoffMs: number | null = null +): SessionRowFilter { + const filter: SessionRowFilter = { conditions: [], values: [] } + if (cutoffMs !== null) { + filter.conditions.push('id IN (SELECT session_row_id FROM files WHERE mtime_ms >= ?)') + filter.values.push(cutoffMs) + } + if (filters.agents && filters.agents.length > 0) { + filter.conditions.push(`agent IN (${filters.agents.map(() => '?').join(',')})`) + filter.values.push(...filters.agents) + } + if (filters.since) { + filter.conditions.push('updated_at >= ?') + filter.values.push(filters.since) + } + if (filters.scopePaths && filters.scopePaths.length > 0) { + // Several scopes mean any of them; every other narrowing is ANDed on. + const present = filters.scopePaths + .map((scope) => scopeCondition(filter, scope)) + .filter((condition) => condition !== null) + // Every scope unkeyable still means a scope, so it narrows to nothing; + // pushing no condition would widen the search to every session instead. + filter.conditions.push(present.length > 0 ? `(${present.join(' OR ')})` : '0 = 1') + } + return filter +} + +/** A scope the caller could not key is a scope nothing is inside of. */ +function scopeCondition(filter: SessionRowFilter, scope: string): string | null { + const key = cwdKey(scope) + return key === null ? null : insideCondition(filter, key) +} + +/** + * `key` itself, or anything below it. Why a half-open range and not + * `substr(key, 1, length(?)) = ?`: only `>=`/`<` can seek `sessions_cwd_key`; + * the substr form scans it. The bound is the child prefix with its last byte + * incremented, so it stops at the end of that prefix and nowhere else. The two + * arms cannot merge: one range over the bare key would also swallow a sibling + * like `/work/app-other`. No wildcards, so `%`/`_` in a folder name are literal. + * + * The filesystem root is the one key that already ends in a separator, and + * appending a second one would bound the range at `//`, which sorts below every + * real child; `cwdKey` keeps it as `/` for exactly this reason. + */ +function insideCondition(filter: SessionRowFilter, key: string): string { + const children = key.endsWith('/') ? key : `${key}/` + filter.values.push(key, children, nextAfterPrefix(children)) + return `(${CWD} = ? OR (${CWD} >= ? AND ${CWD} < ?))` +} + +/** The first string that sorts after every string starting with `prefix`. */ +function nextAfterPrefix(prefix: string): string { + return prefix.slice(0, -1) + String.fromCharCode(prefix.charCodeAt(prefix.length - 1) + 1) +} diff --git a/src/main/ai-vault-search/session-search-sidebar-parity.test.ts b/src/main/ai-vault-search/session-search-sidebar-parity.test.ts new file mode 100644 index 00000000000..988ae248673 --- /dev/null +++ b/src/main/ai-vault-search/session-search-sidebar-parity.test.ts @@ -0,0 +1,146 @@ +import { afterEach, expect, it } from 'vitest' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { filterAiVaultSessions } from '../../shared/ai-vault-session-filters' +import { + addSyntheticSession, + openSessionSearchHarness, + type SessionSearchHarness +} from './session-search-engine-test-fixture' + +// `repo:` and `path:` have to mean one thing. The sessions panel and the index +// answer from different stores by different mechanisms, so the only way to keep +// them equal is for both to run the same predicate; this asserts they do, over +// the shapes where a second SQL spelling went wrong. + +let harness: SessionSearchHarness | null = null + +afterEach(async () => { + await harness?.close() + harness = null +}) + +type Fixture = { id: number; cwd: string; filePath: string; text: string } + +const SESSIONS: Fixture[] = [ + { + id: 1, + cwd: '/Users/Ada/orca/session-search', + filePath: '/Users/Ada/.claude/projects/a/one.jsonl', + text: 'harbor pilot manifest' + }, + { + id: 2, + cwd: '/Users/ada/work/café', + filePath: '/Users/ada/.codex/sessions/two.jsonl', + text: 'harbor dock crane' + }, + { + id: 3, + cwd: '/srv/other/service', + filePath: '/srv/.claude/projects/b/three.jsonl', + text: 'harbor manifest beta' + }, + { + id: 4, + cwd: 'C:\\Work\\Orca\\App', + filePath: 'C:\\Users\\Ada\\.claude\\four.jsonl', + text: 'harbor windows lane' + }, + // A space in the path, which is what a quoted operator value exists for. + { + id: 5, + cwd: '/Users/ada/My Project', + filePath: '/Users/ada/.claude/projects/c/five.jsonl', + text: 'harbor quay ledger' + } +] + +// Each of these matched in the panel and missed in the index while the engine +// tried to say `repo:` / `path:` in SQL. +const QUERIES = [ + 'harbor path:jsonl', + 'harbor repo:orca/session-search', + 'harbor path:CAFÉ', + 'harbor path:/Users/Ada/orca', + 'harbor repo:app', + 'harbor repo:Orca/App', + 'harbor path:.codex', + 'harbor path:/srv repo:other/service', + 'harbor repo:session-search path:jsonl', + 'harbor path:"/Users/ada/work"', + 'harbor repo:nothing-here', + 'harbor path:one.jsonl path:two.jsonl', + 'harbor path:"/Users/ada/My Project"', + 'harbor repo:"ada/My Project"', + 'harbor' +] + +function asSession(fixture: Fixture): AiVaultSession { + const at = '2026-09-01T00:00:00.000Z' + return { + id: String(fixture.id), + executionHostId: 'local', + agent: 'claude', + sessionId: String(fixture.id), + title: 'fixture', + cwd: fixture.cwd, + branch: null, + model: null, + filePath: fixture.filePath, + codexHome: null, + createdAt: at, + updatedAt: at, + modifiedAt: at, + messageCount: 1, + totalTokens: 0, + previewMessages: [{ role: 'user', text: fixture.text }], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: '', + subagent: null + } as AiVaultSession +} + +/** + * The panel's own answer. The whole query, not the operators cut out of it: a + * whitespace split would cut a quoted value in half, and every fixture's preview + * holds `harbor`, so the free text the panel also applies selects all of them. + */ +function sidebarIds(query: string): string[] { + return filterAiVaultSessions(SESSIONS.map(asSession), { + query, + agents: ['claude'], + scope: 'all', + sort: 'updated', + activeWorktreePaths: [], + hideEmptySessions: false + }) + .map((session) => session.sessionId) + .sort() +} + +it.each(QUERIES)('answers %s the way the sessions panel does', async (query) => { + harness = await openSessionSearchHarness('ss-sidebar-parity') + for (const fixture of SESSIONS) { + addSyntheticSession(harness.db, { + id: fixture.id, + cwd: fixture.cwd, + text: fixture.text, + filePath: fixture.filePath, + sessionFilePath: fixture.filePath + }) + } + const engineIds = harness.engine + .search({ query, limit: 100 }) + .hits.map((hit) => hit.sessionId) + .sort() + expect(engineIds).toEqual(sidebarIds(query)) +}) + +it('is not vacuous: these queries do select, and reject, real sessions', () => { + // A parity suite where every query matched everything, or nothing, would pass + // against any predicate at all. + const answers = QUERIES.map((query) => sidebarIds(query).length) + expect(answers.some((count) => count > 0 && count < SESSIONS.length)).toBe(true) + expect(answers.some((count) => count === 0)).toBe(true) +}) diff --git a/src/main/ai-vault-search/session-search-snippet-marks.test.ts b/src/main/ai-vault-search/session-search-snippet-marks.test.ts new file mode 100644 index 00000000000..d4237ce8b6f --- /dev/null +++ b/src/main/ai-vault-search/session-search-snippet-marks.test.ts @@ -0,0 +1,144 @@ +import { afterEach, expect, it } from 'vitest' +import { + SESSION_SEARCH_SNIPPET_MARK_CLOSE, + SESSION_SEARCH_SNIPPET_MARK_OPEN +} from './session-search-engine-types' +import { + addSyntheticSession, + openSessionSearchHarness, + type SessionSearchHarness +} from './session-search-engine-test-fixture' + +// A snippet has to name which of a row's four columns matched, and the marks +// FTS5 wraps a match in are the only signal. Searching the marked text for the +// public `[[` reads a transcript's own brackets as a highlight — and transcripts +// are full of them, because a bash `[[ -f x ]]` and numpy's `[[1, 2]]` are +// exactly the sort of thing an agent session holds. Whether a column matched is +// the difference between two renderings of the same text instead. + +let harness: SessionSearchHarness | null = null + +afterEach(async () => { + await harness?.close() + harness = null +}) + +const BASH = 'run this: if [[ -f /home/me/.aws/credentials ]]; then cat it; fi' +const TOOL = 'zebrafish appears only in the tool output here' + +it('shows the column that matched, not the one that happens to contain brackets', async () => { + harness = await openSessionSearchHarness('ss-snippet-marks') + // Session 1's match is in tool output while its user turn holds a bash test + // expression; session 2 is the same match with no brackets anywhere. + addSyntheticSession(harness.db, { id: 1, text: BASH, toolText: TOOL }) + addSyntheticSession(harness.db, { id: 2, text: 'run this script please', toolText: TOOL }) + + const hits = harness.engine.search({ query: 'zebrafish' }).hits + expect(hits).toHaveLength(2) + for (const hit of hits) { + expect(hit.evidence?.snippet).toContain( + `${SESSION_SEARCH_SNIPPET_MARK_OPEN}zebrafish${SESSION_SEARCH_SNIPPET_MARK_CLOSE}` + ) + expect(hit.evidence?.snippet).not.toContain('credentials') + } +}) + +it('falls back to any column for an identifier-only match, brackets or not', async () => { + // `zebra` reaches this row only through the identifier shadow column, which is + // what column -1 exists for. The user turn holds numpy output, so a bracket + // scan would have stopped at it and shown a column with no match in it. + harness = await openSessionSearchHarness('ss-snippet-marks-fallback') + addSyntheticSession(harness.db, { + id: 1, + text: 'numpy printed [[1, 2], [3, 4]] before the call', + toolText: 'zebra-fish-count = 4' + }) + + const [hit] = harness.engine.search({ query: 'zebra' }).hits + expect(hit?.evidence?.snippet).toContain( + `${SESSION_SEARCH_SNIPPET_MARK_OPEN}zebra${SESSION_SEARCH_SNIPPET_MARK_CLOSE}` + ) + expect(hit?.evidence?.snippet).not.toContain('numpy') +}) + +it('leaves a transcript’s own brackets in the text it shows', async () => { + // The marks are rewritten from private-use code points at the very end, so a + // row that both matches and contains `[[` keeps its own characters. + harness = await openSessionSearchHarness('ss-snippet-marks-literal') + addSyntheticSession(harness.db, { id: 1, text: `zebrafish ${BASH}` }) + + const [hit] = harness.engine.search({ query: 'zebrafish' }).hits + expect(hit?.evidence?.snippet).toContain( + `${SESSION_SEARCH_SNIPPET_MARK_OPEN}zebrafish${SESSION_SEARCH_SNIPPET_MARK_CLOSE}` + ) + expect(hit?.evidence?.snippet).toContain('[[ -f') +}) + +it('picks by comparison, so a private-use code point in content cannot pose as a mark', async () => { + // The marks are private-use code points, and a transcript may hold one: + // agent output carries Nerd Font glyphs, which live in the same block. So the + // column is chosen by comparing a marked rendering against an unmarked one, + // not by looking for a mark in the text. + harness = await openSessionSearchHarness('ss-snippet-marks-private-use') + addSyntheticSession(harness.db, { + id: 1, + text: 'the \uE000 glyph a font printed here', + toolText: TOOL + }) + + const [hit] = harness.engine.search({ query: 'zebrafish' }).hits + expect(hit?.evidence?.snippet).toContain('zebrafish') + expect(hit?.evidence?.snippet).not.toContain('glyph') +}) + +it('truncates on the last real mark, not on a bracket the transcript wrote', async () => { + // Over the character ceiling the snippet is cut, and it must not cut between + // an open mark and its close. Finding that open mark by searching for `[[` + // stops at the transcript's own bracket instead and throws away everything + // after it. + harness = await openSessionSearchHarness('ss-snippet-marks-truncation') + const long = (letter: string): string => + Array.from({ length: 5 }, () => `${letter.repeat(55)}/tail`).join(' ') + addSyntheticSession(harness.db, { + id: 1, + text: `zebrafish ${long('p')} [[ ${long('q')}` + }) + + const snippet = harness.engine.search({ query: 'zebrafish' }).hits[0]?.evidence?.snippet ?? '' + expect(snippet).toContain('[[zebrafish]]') + // The cut is the character ceiling, so the text after the transcript's own + // bracket survives up to it. + expect(snippet).toContain('qqqqq') +}) + +it('marks only what FTS5 marked, so a glyph in the text stays a glyph', async () => { + // The marked and plain renderings are compared character by character, so a + // private-use code point the transcript wrote has a counterpart in both and + // is text; replacing every one of them would show it as a highlight. + harness = await openSessionSearchHarness('ss-snippet-marks-literal-private-use') + addSyntheticSession(harness.db, { id: 1, text: 'a \uE000 glyph then zebrafish and \uE001 after' }) + + const snippet = harness.engine.search({ query: 'zebrafish' }).hits[0]?.evidence?.snippet ?? '' + expect(snippet).toContain( + `${SESSION_SEARCH_SNIPPET_MARK_OPEN}zebrafish${SESSION_SEARCH_SNIPPET_MARK_CLOSE}` + ) + expect(snippet).toContain('a \uE000 glyph') + expect(snippet).toContain('\uE001 after') + // One highlight, and only one: the literals are not a second pair. + expect(snippet.split(SESSION_SEARCH_SNIPPET_MARK_OPEN)).toHaveLength(2) +}) + +it('does not cut a snippet at a private-use code point the transcript wrote', async () => { + // The balance check looks for the last open mark, and a content glyph is not + // one; treating it as one throws away every character after it. + harness = await openSessionSearchHarness('ss-snippet-marks-literal-truncation') + const long = (letter: string): string => + Array.from({ length: 5 }, () => `${letter.repeat(55)}/tail`).join(' ') + addSyntheticSession(harness.db, { id: 1, text: `zebrafish ${long('p')} \uE000 ${long('q')}` }) + + const snippet = harness.engine.search({ query: 'zebrafish' }).hits[0]?.evidence?.snippet ?? '' + expect(snippet).toContain( + `${SESSION_SEARCH_SNIPPET_MARK_OPEN}zebrafish${SESSION_SEARCH_SNIPPET_MARK_CLOSE}` + ) + expect(snippet).toContain('qqqqq') +}) diff --git a/src/main/ai-vault-search/session-search-snippet.ts b/src/main/ai-vault-search/session-search-snippet.ts new file mode 100644 index 00000000000..1204cdd10a8 --- /dev/null +++ b/src/main/ai-vault-search/session-search-snippet.ts @@ -0,0 +1,171 @@ +import type SyncDatabase from '../sqlite/sync-database' +import { + SESSION_SEARCH_SNIPPET_MARK_CLOSE, + SESSION_SEARCH_SNIPPET_MARK_OPEN +} from './session-search-engine-types' +import { + orExpression, + scopedExpression, + type SessionSearchQueryPlan +} from './session-search-query-planner' +import type { SessionSearchScope } from './session-search-engine-types' + +// What FTS5 wraps a match in before this module rewrites it to the public +// marks. Private-use code points, and not `[[`, because two different jobs here +// have to tell a mark from content: choosing the column to show, and refusing +// to cut a snippet between an open mark and its close. Transcripts contain +// `[[` — a bash `[[ -f x ]]`, numpy's `[[1, 2]]` — and a mark the content can +// forge makes both of those decisions wrong on real text. +const MARK_OPEN = '\uE000' +const MARK_CLOSE = '\uE001' + +const SNIPPET_TOKENS = 12 +// Why a ceiling on top of the token count: a transcript chunk can be 8000 +// characters with no separator in it, which FTS5 reports as one token, so +// "twelve tokens" is not by itself a bound on what a hit carries. +const SNIPPET_MAX_CHARS = 512 + +export type SessionSearchSnippet = { + text: string + truncated: boolean +} + +export const EMPTY_SNIPPET: SessionSearchSnippet = { text: '', truncated: false } + +/** + * The window of one message that shows why it matched. + * + * The expression is the plan's OR form rather than the route's, so a hit found + * through typo repair is marked with the repaired terms it was actually + * retrieved by, and a phrase hit still marks each of its words. + */ +export function sessionSearchSnippet( + db: SyncDatabase, + scope: SessionSearchScope, + rowid: number, + plan: SessionSearchQueryPlan +): SessionSearchSnippet { + // Why: the identifier shadow column is word soup; a hit that also matches in a + // prose column should be shown from there. Column -1 (any column) is the + // fallback for rows that only matched through the shadow column. + // + // The same four for every scope, because the scope is already in the + // expression below. A conversation snippet cannot come out of `tool_text` for + // the reason the search could not: the row has to match + // `{user_text assistant_text}: …` before any of these columns is read, and a + // row that matches under that filter carries its mark in column 0 or 1. A + // second list here would be a guard with nothing left to guard, and the two + // would mask each other's mistakes. + const columns = [0, 1, 2, -1] + // Each column twice: once marked, once with empty marks. Whether a column + // matched is then the difference between two renderings of the same text, + // which content cannot forge — searching the marked one for a mark reads a + // transcript's own `[[` as a highlight and shows a column that matched + // nothing. + const select = columns + .flatMap((column, index) => [ + `snippet(messages_fts, ${column}, '${MARK_OPEN}', '${MARK_CLOSE}', '…', ${SNIPPET_TOKENS}) AS c${index}`, + `snippet(messages_fts, ${column}, '', '', '…', ${SNIPPET_TOKENS}) AS p${index}` + ]) + .join(', ') + try { + // Why the subselect: a bound `rowid = ?` or `rowid IN (?)` next to MATCH is + // silently ignored by the FTS5 planner, which then returns the first match + // in the table. Why the join to `sessions`: retrieval proved this rowid + // belonged to a live session, but a purge can commit between that statement + // and this one, and a message row outlives its session row until the drain + // reaches it. INNER, never LEFT — this is the last read before content is + // returned to a caller. + const row = db + .prepare( + `SELECT ${select} FROM messages_fts + JOIN messages m ON m.id = messages_fts.rowid + JOIN sessions s ON s.id = m.session_row_id + WHERE messages_fts MATCH ? AND messages_fts.rowid IN (SELECT ?)` + ) + .get(scopedExpression(scope, orExpression(plan.terms)), rowid) as + | Record + | undefined + if (!row) { + return EMPTY_SNIPPET + } + // A snippet with nothing highlighted tells the user nothing; omit it. + const index = columns.findIndex( + (_column, at) => row[`c${at}`] !== undefined && row[`c${at}`] !== row[`p${at}`] + ) + if (index === -1) { + return EMPTY_SNIPPET + } + const pieces = splitMarks(row[`c${index}`]!, row[`p${index}`]!) + return pieces === null ? EMPTY_SNIPPET : renderSnippet(pieces) + } catch { + return EMPTY_SNIPPET + } +} + +/** One run of the snippet's own text, or one mark FTS5 put between two runs. */ +type SnippetPiece = { kind: 'text'; value: string } | { kind: 'mark'; value: string } + +/** + * The marked rendering as its text and the marks FTS5 inserted into it. + * + * A mark is a private-use character the marked rendering has where the plain one + * has something else, so a Nerd Font glyph the transcript itself wrote stays + * text — replacing every private-use character would hand the renderer a + * highlight the content forged. Null when the two renderings differ for any + * other reason, which is not a difference this can attribute. + */ +function splitMarks(marked: string, plain: string): SnippetPiece[] | null { + const pieces: SnippetPiece[] = [] + const rest = [...plain] + let at = 0 + let run = '' + for (const point of marked) { + if (point === rest[at]) { + run += point + at++ + continue + } + if (point !== MARK_OPEN && point !== MARK_CLOSE) { + return null + } + pieces.push({ kind: 'text', value: run }, { kind: 'mark', value: point }) + run = '' + } + if (at !== rest.length) { + return null + } + pieces.push({ kind: 'text', value: run }) + return pieces +} + +/** + * The public marks, and the character ceiling. + * + * Cut on a code-point boundary, and never between a mark and its close: an open + * mark with no close hands the renderer something it can never close. The + * ceiling counts the snippet's own characters, so the marks cost the caller + * nothing and a transcript's own private-use character costs it one. + */ +function renderSnippet(pieces: SnippetPiece[]): SessionSearchSnippet { + let text = '' + let shown = 0 + let openedAt: number | null = null + for (const piece of pieces) { + if (piece.kind === 'mark') { + const open = piece.value === MARK_OPEN + openedAt = open ? text.length : null + text += open ? SESSION_SEARCH_SNIPPET_MARK_OPEN : SESSION_SEARCH_SNIPPET_MARK_CLOSE + continue + } + const points = [...piece.value] + if (shown + points.length <= SNIPPET_MAX_CHARS) { + shown += points.length + text += piece.value + continue + } + text += points.slice(0, SNIPPET_MAX_CHARS - shown).join('') + return { text: openedAt === null ? text : text.slice(0, openedAt), truncated: true } + } + return { text, truncated: false } +} diff --git a/src/main/ai-vault-search/session-search-source-presence.ts b/src/main/ai-vault-search/session-search-source-presence.ts new file mode 100644 index 00000000000..cc7155fccc8 --- /dev/null +++ b/src/main/ai-vault-search/session-search-source-presence.ts @@ -0,0 +1,40 @@ +import type SyncDatabase from '../sqlite/sync-database' +import type { SessionSearchSourcePresence } from './session-search-engine-types' + +/** + * Where each session's source stands, read from the index's own `files` table. + * + * Why not a stat: a search page of 20 hits would be 20 filesystem round trips + * on the query path, and on an SSH or WSL host each one can block for as long + * as the connection takes to answer — the reviewer's F11. The index already + * records what discovery last proved about every file it read, so the query + * path reads that instead of asking the disk again. + * + * The vocabulary is deliberately short of `missing`. A row here means the index + * holds a live file record for the session, which is `present`. No row means + * this read cannot tell whether the source is gone or merely unrecorded, and + * loss of contact is never evidence of absence + * (docs/reference/ssh-execution-boundary.md), so it is `unverifiable`. Proving + * a deletion is the indexer's job and it retires the session's rows outright. + */ +export function sessionSourcePresence( + db: SyncDatabase, + sessionRowIds: readonly number[] +): Map { + const presence = new Map( + sessionRowIds.map((id) => [id, 'unverifiable' as const]) + ) + if (sessionRowIds.length === 0) { + return presence + } + const rows = db + .prepare( + `SELECT DISTINCT session_row_id FROM files + WHERE session_row_id IN (${sessionRowIds.map(() => '?').join(',')})` + ) + .all(...sessionRowIds) as { session_row_id: number }[] + for (const row of rows) { + presence.set(row.session_row_id, 'present') + } + return presence +} diff --git a/src/main/ai-vault-search/session-search-typo-policy.test.ts b/src/main/ai-vault-search/session-search-typo-policy.test.ts new file mode 100644 index 00000000000..938c1e1fc3e --- /dev/null +++ b/src/main/ai-vault-search/session-search-typo-policy.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import type SyncDatabase from '../sqlite/sync-database' +import { openSessionSearchIndexFile } from './session-search-index-test-fixture' +import { ensureSessionSearchQuerySchema } from './session-search-query-schema' +import { SessionSearchTypoRepair } from './session-search-typo-repair' + +/** A session row the planted messages below hang off, so a repair can see them. */ +function addSession(db: SyncDatabase, id: number): void { + db.prepare( + `INSERT INTO sessions(id,agent,session_id,file_path,title,resume_command) + VALUES (?, 'claude', ?, '/synthetic/fixture', 'typo fixture', '')` + ).run(id, String(id)) +} + +function addTerm(db: SyncDatabase, sessionRowId: number, term: string): void { + const rowid = db + .prepare("INSERT INTO messages(session_row_id, role) VALUES (?, 'user')") + .run(sessionRowId).lastInsertRowid + db.prepare('INSERT INTO messages_fts(rowid, user_text) VALUES (?, ?)').run(Number(rowid), term) +} + +describe('typo repair policy', () => { + it.each([ + { input: 'coalesces', candidate: 'coalesced', copies: 2, exact: true, expected: null }, + { input: 'coalescs', candidate: 'coalesces', copies: 1, exact: false, expected: null }, + { input: 'coalescs', candidate: 'coalesces', copies: 2, exact: false, expected: 'coalesces' }, + { input: 'café', candidate: 'cafe', copies: 1, exact: false, expected: null }, + { input: 'car', candidate: 'cars', copies: 2, exact: false, expected: null }, + { input: 'calm', candidate: 'clam', copies: 2, exact: false, expected: null } + ])( + 'repairs $input to $expected with $copies postings (exact=$exact)', + async ({ input, candidate, copies, exact, expected }) => { + const index = await openSessionSearchIndexFile('ss-typo-policy') + try { + ensureSessionSearchQuerySchema(index.db) + addSession(index.db, 1) + for (let i = 0; i < copies; i++) { + addTerm(index.db, 1, candidate) + } + if (exact) { + addTerm(index.db, 1, input) + } + expect(new SessionSearchTypoRepair(index.db).correct(input, 'all')).toBe(expected) + } finally { + await index.close() + } + } + ) + + // A purge cuts a session loose in one transaction and reclaims its rows over + // many, so the vocabulary can still list a term whose only rows nothing can + // reach. Abandoning the prefix at that term would lose a repair the rest of + // the index can already serve. + it('falls through to the best candidate a reader can still reach', async () => { + const index = await openSessionSearchIndexFile('ss-typo-orphaned') + try { + const { db } = index + ensureSessionSearchQuerySchema(db) + addSession(db, 1) + // `coalesces` scores higher against `coalescs` than `coalesced` does, and + // shares its prefix, so only the fall-through can reach the reachable one. + // Session 2 is never created: these rows are what an unfinished purge + // leaves behind, and the vocabulary counts them all the same. + for (const [term, session] of [ + ['coalesces', 2], + ['coalesces', 2], + ['coalesced', 1], + ['coalesced', 1] + ] as const) { + addTerm(db, session, term) + } + expect(db.prepare("SELECT doc FROM messages_vocab WHERE term='coalesces'").get()).toEqual({ + doc: 2 + }) + expect(new SessionSearchTypoRepair(db).correct('coalescs', 'all')).toBe('coalesced') + } finally { + await index.close() + } + }) +}) diff --git a/src/main/ai-vault-search/session-search-typo-repair.ts b/src/main/ai-vault-search/session-search-typo-repair.ts new file mode 100644 index 00000000000..1aed90e2991 --- /dev/null +++ b/src/main/ai-vault-search/session-search-typo-repair.ts @@ -0,0 +1,163 @@ +import type SyncDatabase from '../sqlite/sync-database' +import type { SessionSearchScope } from './session-search-engine-types' +import { quoteFtsTerm, scopedExpression } from './session-search-query-planner' + +// Why: a query term with zero postings is usually a typo. The index's own +// vocabulary (fts5vocab) is the dictionary, so repair needs no model and can +// never suggest a word the index does not contain. Measured MRR 0.553 → 0.566. +const MIN_TERM_LENGTH = 4 +const MAX_TERM_LENGTH = 40 +const LENGTH_SLACK = 2 +const MIN_DOC_FREQUENCY = 2 +const MIN_SIMILARITY = 0.82 +const MAX_CANDIDATES = 4000 +// Candidates counted against live rows per prefix before giving up on it. Only +// reached for a term the scope has no posting for, which is the rare case. +const MAX_VISIBILITY_PROBES = 8 +// How far a live count walks before it stops caring. It exists to break ties +// between candidates of equal similarity, and the difference between a term in +// sixty-four rows and one in six thousand does not change which is the better +// repair — but reading either in full would. +const MAX_COUNTED_ROWS = 64 + +// Longest common subsequence length; the indel distance is len(a)+len(b)-2·LCS. +function commonSubsequenceLength(a: string, b: string): number { + let previous = Array.from({ length: b.length + 1 }).fill(0) + let current = Array.from({ length: b.length + 1 }).fill(0) + for (let i = 1; i <= a.length; i += 1) { + for (let j = 1; j <= b.length; j += 1) { + current[j] = + a.charCodeAt(i - 1) === b.charCodeAt(j - 1) + ? previous[j - 1] + 1 + : Math.max(previous[j], current[j - 1]) + } + ;[previous, current] = [current, previous] + } + return previous[b.length] +} + +/** Normalized indel similarity in [0, 1], the scale rapidfuzz's `fuzz.ratio` uses. */ +function similarity(a: string, b: string): number { + const total = a.length + b.length + return total === 0 ? 1 : (2 * commonSubsequenceLength(a, b)) / total +} + +/** + * Spelling repair over the index's own vocabulary. + * + * The vocabulary proposes and a scoped count disposes. `messages_vocab` is a + * view over the whole FTS b-tree: it has no column filter, because fts5vocab is + * per table, and it counts rows whose session a purge already cut loose. So + * every decision that reaches the plan — whether a term is already spelled + * right, whether a candidate is eligible, and which of two equally close + * candidates wins — is taken from a `messages_fts MATCH` under the same column + * filter retrieval uses, joined to `sessions`. + * + * That is not tidiness. Reading the vocabulary directly made the repair depend + * on rows the search could never return: tool output suppressed a + * conversation-scope repair and supplied suggestions the scope would never + * show, and retention's orphan drain silently changed which word a query was + * repaired to. + * + * The cost is one bounded count per candidate examined, at most + * `MAX_VISIBILITY_PROBES` per prefix, and only for a term the scope has no + * posting for. See docs/reference/agent-session-search-query-tuning.md. + */ +export class SessionSearchTypoRepair { + private readonly liveRows: ReturnType + private readonly candidatesByPrefix: ReturnType + + constructor(db: SyncDatabase) { + this.liveRows = db.prepare( + `SELECT count(*) AS rows FROM ( + SELECT m.id FROM messages_fts + JOIN messages m ON m.id = messages_fts.rowid + JOIN sessions s ON s.id = m.session_row_id + WHERE messages_fts MATCH ? LIMIT ${MAX_COUNTED_ROWS})` + ) + // fts5vocab is ordered by term, so a prefix range plus a length band is a + // bounded scan and no sort. Ordered by term rather than by `doc`: the + // ordering decides which candidates survive the limit, and `doc` counts + // rows no reader can see, so the drain reclaiming them moved the cut. + this.candidatesByPrefix = db.prepare( + `SELECT term FROM messages_vocab + WHERE term >= ? AND term < ? AND length(term) BETWEEN ? AND ? + ORDER BY term LIMIT ?` + ) + } + + /** Live rows carrying this term inside `scope`, counted no further than it matters. */ + private countRows(term: string, scope: SessionSearchScope): number { + const row = this.liveRows.get(scopedExpression(scope, quoteFtsTerm(term))) as { rows: number } + return row.rows + } + + /** Whether a live row inside `scope` holds this term. */ + hasPostings(term: string, scope: SessionSearchScope): boolean { + return this.countRows(term, scope) > 0 + } + + /** Returns the closest indexed term, or null when `term` exists or nothing is close enough. */ + correct(term: string, scope: SessionSearchScope): string | null { + const lowered = term.toLowerCase() + if (lowered.length < MIN_TERM_LENGTH || lowered.length > MAX_TERM_LENGTH) { + return null + } + if (this.hasPostings(lowered, scope)) { + return null + } + // Two-letter prefix first (a typo rarely hits both), then the transposed + // pair, then the bare first letter as the wide fallback. + const prefixes = [lowered.slice(0, 2), lowered[1] + lowered[0], lowered[0]] + for (const prefix of prefixes) { + const best = this.bestVisible(lowered, prefix, scope) + if (best) { + return best + } + } + return null + } + + /** + * The closest candidate at `prefix` that this scope can actually answer with. + * + * Ranking is pure CPU, so the walk is bounded rather than the count: the + * closest term can be one the scope never shows, and abandoning the prefix + * there would lose a repair the rest of the index can serve. Ties on + * similarity go to the more common word, which is the same prior the + * vocabulary's `doc` used to supply — counted live here so the answer does + * not move when a purge reclaims rows nothing could reach. + */ + private bestVisible(lowered: string, prefix: string, scope: SessionSearchScope): string | null { + const counted = this.ranked(lowered, prefix) + .slice(0, MAX_VISIBILITY_PROBES) + .map((candidate) => ({ ...candidate, rows: this.countRows(candidate.term, scope) })) + .filter((candidate) => candidate.rows >= MIN_DOC_FREQUENCY) + if (counted.length === 0) { + return null + } + // Already sorted by similarity; a stable sort keeps that and orders the ties. + return counted.sort((left, right) => right.score - left.score || right.rows - left.rows)[0]! + .term + } + + /** Candidates similar enough to be a repair, closest first. */ + private ranked(lowered: string, prefix: string): { term: string; score: number }[] { + return this.candidates(prefix, lowered.length) + .map((row) => ({ term: row.term, score: similarity(lowered, row.term) })) + .filter((candidate) => candidate.score >= MIN_SIMILARITY) + .sort((left, right) => right.score - left.score || (left.term < right.term ? -1 : 1)) + } + + private candidates(prefix: string, length: number): { term: string }[] { + const last = prefix.charCodeAt(prefix.length - 1) + const upper = prefix.slice(0, -1) + String.fromCharCode(last + 1) + return this.candidatesByPrefix.all( + prefix, + upper, + Math.max(MIN_TERM_LENGTH - 1, length - LENGTH_SLACK), + length + LENGTH_SLACK, + MAX_CANDIDATES + ) as { term: string }[] + } +} diff --git a/src/main/ai-vault-search/session-search-typo-scope.test.ts b/src/main/ai-vault-search/session-search-typo-scope.test.ts new file mode 100644 index 00000000000..98e3938178e --- /dev/null +++ b/src/main/ai-vault-search/session-search-typo-scope.test.ts @@ -0,0 +1,71 @@ +import { afterEach, expect, it } from 'vitest' +import { + addSyntheticSession, + openSessionSearchHarness, + type SessionSearchHarness +} from './session-search-engine-test-fixture' + +// Typo repair used to read `messages_vocab` and probe `messages_fts` with no +// column filter, so tool output decided whether a conversation-scoped query was +// repaired — in both directions. A tool row carrying the misspelling made the +// query look correctly spelled and suppressed the repair; a tool row carrying a +// rare word offered it as the suggestion, naming in `repairedTerms` a string +// from a column the scope will never show. + +let harness: SessionSearchHarness | null = null +let control: SessionSearchHarness | null = null + +afterEach(async () => { + await harness?.close() + await control?.close() + harness = null + control = null +}) + +it('repairs a conversation query the same way with or without a tool row', async () => { + harness = await openSessionSearchHarness('ss-typo-scope-suppress') + addSyntheticSession(harness.db, { id: 1, text: 'we changed resolveTerminalPath today', rows: 2 }) + // A second session whose tool output happens to contain the misspelling. + addSyntheticSession(harness.db, { + id: 2, + text: 'ran the linter', + toolText: 'warning: unknown symbol resolveterminalpth in build log', + rows: 2, + role: 'assistant' + }) + + // The same index without that one tool row. + control = await openSessionSearchHarness('ss-typo-scope-control') + addSyntheticSession(control.db, { id: 1, text: 'we changed resolveTerminalPath today', rows: 2 }) + addSyntheticSession(control.db, { id: 2, text: 'ran the linter' }) + + const request = { query: 'resolveterminalpth', scope: 'conversation' } as const + const withTool = harness.engine.search(request) + const clean = control.engine.search(request) + + expect(clean.planner.repairedTerms).toEqual(['resolveterminalpath']) + expect(clean.hits.map((hit) => hit.sessionId)).toEqual(['1']) + expect(withTool.planner.repairedTerms).toEqual(clean.planner.repairedTerms) + expect(withTool.hits.map((hit) => hit.sessionId)).toEqual(clean.hits.map((hit) => hit.sessionId)) +}) + +it('never repairs a conversation query onto a word only tool output holds', async () => { + harness = await openSessionSearchHarness('ss-typo-scope-leak') + addSyntheticSession(harness.db, { + id: 1, + text: 'ran the deploy', + toolText: 'AWS_SESSION_TOKEN=quicksilverfox expired', + rows: 2, + role: 'assistant' + }) + addSyntheticSession(harness.db, { id: 2, text: 'ordinary prose about nothing' }) + + const narrowed = harness.engine.search({ query: 'quicksilverfx', scope: 'conversation' }) + expect(narrowed.planner.repairedTerms).toBeUndefined() + expect(narrowed.hits).toEqual([]) + // The same query over the whole corpus still finds it, which is the scope + // doing its job rather than the repair being broken. + const wide = harness.engine.search({ query: 'quicksilverfx', scope: 'all' }) + expect(wide.planner.repairedTerms).toEqual(['quicksilverfox']) + expect(wide.hits.map((hit) => hit.sessionId)).toEqual(['1']) +}) diff --git a/src/shared/ai-vault-search-query-operators.test.ts b/src/shared/ai-vault-search-query-operators.test.ts new file mode 100644 index 00000000000..0bb5cbc463b --- /dev/null +++ b/src/shared/ai-vault-search-query-operators.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' +import { parseVaultQuery } from './ai-vault-session-filters' +import { + hasAiVaultSearchQueryOperators, + splitAiVaultSearchQuery +} from './ai-vault-search-query-operators' + +describe('what counts as an operator', () => { + it('splits repo: and path: out of the free text', () => { + const split = splitAiVaultSearchQuery('relay capacity repo:orca path:/work/app') + expect(split.text).toBe('relay capacity') + expect(split.terms).toEqual(['relay', 'capacity']) + expect(split.repoTerms).toEqual(['orca']) + expect(split.pathTerms).toEqual(['/work/app']) + expect(hasAiVaultSearchQueryOperators(split)).toBe(true) + }) + + it('keeps a value that only looks like an operator as ordinary text', () => { + const split = splitAiVaultSearchQuery('myrepo:x https://host/path:y') + expect(split.repoTerms).toEqual([]) + expect(split.pathTerms).toEqual([]) + expect(split.text).toBe('myrepo:x https://host/path:y') + }) + + it('reads a quoted operator value whole, including its spaces', () => { + expect(splitAiVaultSearchQuery('path:"/Users/ada/My Project" needle').pathTerms).toEqual([ + '/Users/ada/My Project' + ]) + }) + + it('does not let an apostrophe in prose swallow the operator between quotes', () => { + const split = splitAiVaultSearchQuery("it's a repo:orca thing's") + expect(split.repoTerms).toEqual(['orca']) + }) + + it('preserves operator case, which the panel folds and the index must not', () => { + // cwd_key keeps execution-host case, so folding here would lose a POSIX + // directory whose name differs only in case. + expect(splitAiVaultSearchQuery('path:/Work/App').pathTerms).toEqual(['/Work/App']) + expect(parseVaultQuery('path:/Work/App').pathTerms).toEqual(['/work/app']) + }) + + it('has no operators when the query is plain text', () => { + expect(hasAiVaultSearchQueryOperators(splitAiVaultSearchQuery('relay capacity'))).toBe(false) + }) +}) + +// The panel parses through this module now, so the two cannot disagree by +// construction. What is worth pinning is the handful of shapes where the +// panel's old hand-rolled tokenizer answered differently, so the change of +// behaviour is a decision on the record rather than a surprise. +describe('the shapes where the panel parser used to answer differently', () => { + it.each([ + ['repo:"" x', 'repoTerms'], + ['path:"" x', 'pathTerms'] + ] as const)('drops the empty operator value in %s instead of filtering on `""`', (query, key) => { + // The old tokenizer kept the quote characters as the value, so `repo:""` + // filtered on a label no session has and silently emptied the list. An + // operator with nothing in it is not a narrowing. + expect(splitAiVaultSearchQuery(query)[key]).toEqual([]) + expect(parseVaultQuery(query)[key]).toEqual([]) + }) + + it.each([ + ['repo:" " x', 'repoTerms'], + ['path:" " x', 'pathTerms'] + ] as const)('drops the whitespace-only operator value in %s too', (query, key) => { + // Same defect as `repo:""` wearing a different hat: an untrimmed `" "` + // survives as a term, matches no label, and empties the list. + expect(splitAiVaultSearchQuery(query)[key]).toEqual([]) + expect(parseVaultQuery(query)[key]).toEqual([]) + }) + + it('trims a quoted operator value rather than searching for the spaces', () => { + expect(splitAiVaultSearchQuery('repo:" session-search "').repoTerms).toEqual(['session-search']) + }) + + it.each(['"" empty', "'' empty", '" " empty'])( + 'reads the empty quotes in %s as an empty term', + (query) => { + // Same reason one level up: the old parser searched for the two characters + // and found nothing, where an empty term matches everything and leaves the + // rest of the query to do the work. + expect(parseVaultQuery(query).terms).toEqual(['', 'empty']) + } + ) + + it.each([ + ['"foo"bar', { terms: ['foo', 'bar'], repoTerms: [], pathTerms: [] }], + ['"a b"c', { terms: ['a b', 'c'], repoTerms: [], pathTerms: [] }], + ['repo:"a"b', { terms: ['b'], repoTerms: ['a'], pathTerms: [] }], + ['path:"a"b', { terms: ['b'], repoTerms: [], pathTerms: ['a'] }], + ['repo:"a b"c d', { terms: ['c', 'd'], repoTerms: ['a b'], pathTerms: [] }] + ])('reads %s exactly as the panel always has', (query, expected) => { + // A closing quote does not have to end a word. Requiring it turned each of + // these into one term carrying its own quote characters, which matches + // nothing; the apostrophe case below is protected by the token start, not + // by that rule. + expect(parseVaultQuery(query)).toEqual(expected) + }) +}) + +describe('agrees with the sessions panel parser on operator recognition', () => { + it.each([ + 'relay capacity', + 'repo:orca needle', + 'path:/work/app needle', + 'myrepo:x', + 'needle repo:orca path:/work/app', + 'path:"/Users/ada/My Project"', + 'https://host/path:y' + ])('reads the same operators out of %s', (query) => { + const split = splitAiVaultSearchQuery(query) + const parsed = parseVaultQuery(query) + const fold = (values: readonly string[]): string[] => values.map((v) => v.toLowerCase()).sort() + expect(fold(split.repoTerms)).toEqual(fold(parsed.repoTerms)) + expect(fold(split.pathTerms)).toEqual(fold(parsed.pathTerms)) + }) +}) diff --git a/src/shared/ai-vault-search-query-operators.ts b/src/shared/ai-vault-search-query-operators.ts new file mode 100644 index 00000000000..a75da8c769e --- /dev/null +++ b/src/shared/ai-vault-search-query-operators.ts @@ -0,0 +1,90 @@ +/** Anchored at a token start only, so `myrepo:x` and `https://h/path:x` stay literal. */ +const OPERATOR = /(repo|path):/iy + +export type AiVaultSearchQuerySplit = { + /** Query minus the operator tokens, quoting intact; what FTS sees. */ + text: string + /** The same free text as tokens with quotes stripped; what a substring matcher wants. */ + terms: readonly string[] + /** Operator values as typed apart from surrounding space: the panel folds case, the index does not. */ + repoTerms: readonly string[] + pathTerms: readonly string[] +} + +/** + * The one reading of `repo:` / `path:` in the product: the sessions panel and the + * search index must agree on what is an operator and what is ordinary text. + */ +export function splitAiVaultSearchQuery(query: string): AiVaultSearchQuerySplit { + const spans: string[] = [] + const terms: string[] = [] + const repoTerms: string[] = [] + const pathTerms: string[] = [] + let index = 0 + while (index < query.length) { + if (isBoundary(query[index])) { + index += 1 + continue + } + OPERATOR.lastIndex = index + const operator = OPERATOR.exec(query) + if (operator) { + const at = index + operator[0].length + const quoted = readQuoted(query, at) + const value = quoted?.value ?? readBare(query, at) + index = quoted ? quoted.end : at + value.length + // Trimmed for the same reason an empty value is dropped: `repo:" "` is + // not a narrowing anyone typed on purpose, and an untrimmed one matches + // no label at all, which silently empties the list. + const operand = value.trim() + if (operand) { + ;(operator[1]!.toLowerCase() === 'repo' ? repoTerms : pathTerms).push(operand) + } + continue + } + const quoted = readQuoted(query, index) + const value = quoted?.value ?? readBare(query, index) + const end = quoted ? quoted.end : index + value.length + spans.push(query.slice(index, end)) + // The span keeps the query verbatim for FTS; only the substring matcher's + // copy is trimmed, so `" "` reads as the empty term `""` already does + // rather than as a term no session's text contains. + terms.push(value.trim()) + index = end + } + return { text: spans.join(' '), terms, repoTerms, pathTerms } +} + +export function hasAiVaultSearchQueryOperators(split: AiVaultSearchQuerySplit): boolean { + return split.repoTerms.length > 0 || split.pathTerms.length > 0 +} + +function isBoundary(char: string | undefined): boolean { + return char === undefined || /\s/.test(char) +} + +/** + * A quoted span, or null when this is not one. + * + * What keeps the apostrophes in `it's a repo:orca thing's` from opening a span + * that swallows the operator is the caller: this only ever runs at a token + * start, and the quote in `it's` is not at one. The closing quote is then just + * the next one, wherever it falls, so `"a b"c` reads as the panel has always + * read it — the span, then the rest as its own token. + */ +function readQuoted(query: string, at: number): { value: string; end: number } | null { + const quote = query[at] + if (quote !== '"' && quote !== "'") { + return null + } + const close = query.indexOf(quote, at + 1) + return close === -1 ? null : { value: query.slice(at + 1, close), end: close + 1 } +} + +function readBare(query: string, at: number): string { + let end = at + while (end < query.length && !isBoundary(query[end])) { + end += 1 + } + return query.slice(at, end) +} diff --git a/src/shared/ai-vault-session-filters.ts b/src/shared/ai-vault-session-filters.ts index 7a0708151ed..39aedaf4626 100644 --- a/src/shared/ai-vault-session-filters.ts +++ b/src/shared/ai-vault-session-filters.ts @@ -8,6 +8,7 @@ import { normalizeRuntimePathSeparators } from './cross-platform-path' import { isClipboardTextByteLengthOverLimit } from './clipboard-text' +import { splitAiVaultSearchQuery } from './ai-vault-search-query-operators' import { parseWslUncPath } from './wsl-paths' import type { AiVaultAgent, @@ -179,31 +180,61 @@ export function agentLabel(agent: AiVaultAgent): string { return aiVaultAgentLabel(agent) } +/** + * One reading of `repo:` / `path:` for the whole product. + * + * Delegates to `splitAiVaultSearchQuery`, which the search index also plans + * from, so a query cannot mean one thing in this list and another in the index. + * The values come back folded because everything this file compares is folded; + * the index keeps the unfolded form, which is why the split itself does not. + */ export function parseVaultQuery(query: string): ParsedQuery { - const terms: string[] = [] - const repoTerms: string[] = [] - const pathTerms: string[] = [] - - for (const rawToken of tokenizeQuery(query)) { - const token = rawToken.toLowerCase() - if (token.startsWith('repo:')) { - const value = token.slice('repo:'.length) - if (value) { - repoTerms.push(value) - } - continue - } - if (token.startsWith('path:')) { - const value = token.slice('path:'.length) - if (value) { - pathTerms.push(value) - } - continue - } - terms.push(token) + const split = splitAiVaultSearchQuery(query) + const fold = (values: readonly string[]): string[] => values.map((value) => value.toLowerCase()) + return { + terms: fold(split.terms), + repoTerms: fold(split.repoTerms), + pathTerms: fold(split.pathTerms) } +} - return { terms, repoTerms, pathTerms } +/** What `repo:` and `path:` are compared against for one session. */ +export type AiVaultQueryOperatorTarget = { + cwd: string | null + filePath: string + /** + * What `repo:` matches. The panel passes a resolved project label when it has + * one; everything else falls back to the last two path segments. + */ + repoLabel?: string +} + +/** + * Whether one session satisfies every `repo:` and `path:` term. + * + * The single definition of what those operators mean. The search index applies + * this over its retrieved rows rather than expressing it in SQL, because SQL + * cannot: LIKE folds ASCII and nothing else, and `path:` searches the transcript + * path as well as the working directory. Both keys are conjunctive, matching + * the qualifier semantics the panel has always had. + */ +export function matchesAiVaultQueryOperators( + target: AiVaultQueryOperatorTarget, + operators: { repoTerms: readonly string[]; pathTerms: readonly string[] } +): boolean { + if (operators.repoTerms.length > 0) { + const repoLabel = (target.repoLabel ?? folderLabel(target.cwd)).toLowerCase() + if (operators.repoTerms.some((term) => !repoLabel.includes(term.toLowerCase()))) { + return false + } + } + if (operators.pathTerms.length > 0) { + const pathSearch = `${target.cwd ?? ''} ${target.filePath}`.toLowerCase() + if (operators.pathTerms.some((term) => !pathSearch.includes(term.toLowerCase()))) { + return false + } + } + return true } function matchesQuery( @@ -229,25 +260,18 @@ function matchesQuery( return false } } - if (parsed.repoTerms.length > 0) { - const sessionProject = filters.sessionProjectById?.get(session.id) - const repoLabel = ( - sessionProject?.kind === 'repo' - ? (filters.projectLabelByKey?.get(sessionProject.key) ?? sessionProject.label) - : folderLabel(session.cwd) - ).toLowerCase() - if (parsed.repoTerms.some((term) => !repoLabel.includes(term))) { - return false - } - } - if (parsed.pathTerms.length > 0) { - const pathSearch = `${session.cwd ?? ''} ${session.filePath}`.toLowerCase() - if (parsed.pathTerms.some((term) => !pathSearch.includes(term))) { - return false - } - } - - return true + const sessionProject = filters.sessionProjectById?.get(session.id) + return matchesAiVaultQueryOperators( + { + cwd: session.cwd, + filePath: session.filePath, + repoLabel: + sessionProject?.kind === 'repo' + ? (filters.projectLabelByKey?.get(sessionProject.key) ?? sessionProject.label) + : undefined + }, + parsed + ) } function sessionSortTime(session: AiVaultSession, sort: AiVaultSort): number { @@ -291,25 +315,3 @@ function createAiVaultWorkspaceMatcher(workspacePath: string): (normalizedCwd: s const matchesLinux = createNormalizedPathInsideOrEqualMatcher(workspaceWslPath.linuxPath) return (cwd) => matches(cwd) || matchesLinux(cwd) } - -function tokenizeQuery(query: string): string[] { - const tokens: string[] = [] - // Why: keep quoted operator values (repo:/path:) intact so labels and paths - // containing spaces still match — e.g. path:"/Users/ada/My Project". - const pattern = /(repo|path):"([^"]+)"|(repo|path):'([^']+)'|"([^"]+)"|'([^']+)'|(\S+)/gi - let match: RegExpExecArray | null - while ((match = pattern.exec(query)) !== null) { - const operator = match[1] ?? match[3] - const operatorValue = match[2] ?? match[4] - if (operator && operatorValue?.trim()) { - tokens.push(`${operator.toLowerCase()}:${operatorValue.trim()}`) - continue - } - - const token = match[5] ?? match[6] ?? match[7] - if (token?.trim()) { - tokens.push(token.trim()) - } - } - return tokens -} From eedd35645e585e9d257d702e5f8a90733953d600 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:50:41 -0400 Subject: [PATCH 035/191] feat(mobile): add typed RPC operations and fence raw requests (#20018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mobile): add the RpcOperation descriptor, send, and barrier interpretation An operation family declares its method, compatible reader, acceptance policy and interpretation barrier once. The send classifies only a fulfilled envelope; transport rejection stays on the promise channel as the original error object, so the cutover and delivery-unknown predicates keep working and a Promise.all group still fails fast. Multi-request families go through a post-barrier combinator that awaits every raw request and then interprets in declared order. No production call site is migrated: this lands as self-contained machinery so runtime behaviour is provably untouched. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): require a reader for RPC result variants * refactor(mobile): fence the raw RPC request port behind an inventoried boundary The raw sender takes an unchecked method string and returns an envelope whose result is `unknown`; 153 non-test files still reach it and each re-decides acceptance and decoding for itself. The type system cannot close that today — `RpcClient` structurally carries `sendRequest` and ~190 files hold a client — so move the port's declaration into its own module, name it unvalidated, and hold the boundary as a ratcheted inventory instead. `SendRequestOptions` is re-exported from rpc-client.ts so the move touches no call site, and rpc-operation.ts now asks for the port rather than the whole client: it is the one module allowed to cross it. Two ratchets, both AST-based: - the port inventory fails on an unlisted file, a stale entry, and a listed file whose reference count went up, so the list only shrinks; - the cast fence bans `as`, `any` and `@ts-` suppressions in the operation region, which is computed from the imports rather than listed, so step 4's operation modules land inside it automatically. Zero runtime change: no wire change, no call site touched. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * merge: incorporate closed boundary and send-side types * fix(mobile): preserve RPC decoding invariants across the combined boundary * fix(mobile): consolidate RPC operation test imports * refactor(mobile): simplify RPC descriptors and fence the contract module * fix(mobile): baseline landed notification RPC callers --- .../scripts/generate-rpc-params-catalog.mjs | 7 +- mobile/src/transport/rpc-client.ts | 27 +- .../transport/rpc-incompatible-reply-error.ts | 27 ++ .../transport/rpc-operation-barrier.test.ts | 138 ++++++++ .../rpc-operation-cast-fence.test.ts | 256 +++++++++++++ .../transport/rpc-operation-compile-fence.ts | 191 ++++++++++ .../src/transport/rpc-operation-contract.ts | 155 ++++++++ .../rpc-operation-result-reader.test.ts | 145 ++++++++ .../transport/rpc-operation-result-reader.ts | 89 +++++ .../transport/rpc-operation-test-families.ts | 99 ++++++ mobile/src/transport/rpc-operation.test.ts | 335 ++++++++++++++++++ mobile/src/transport/rpc-operation.ts | 282 +++++++++++++++ mobile/src/transport/rpc-params-contract.ts | 4 + ...alidated-rpc-request-port-boundary.test.ts | 267 ++++++++++++++ .../unvalidated-rpc-request-port-inventory.ts | 229 ++++++++++++ .../transport/unvalidated-rpc-request-port.ts | 31 ++ .../rpc-params-catalog.generated.ts | 7 +- src/shared/rpc-contract/rpc-send-params.ts | 69 ++++ 18 files changed, 2333 insertions(+), 25 deletions(-) create mode 100644 mobile/src/transport/rpc-incompatible-reply-error.ts create mode 100644 mobile/src/transport/rpc-operation-barrier.test.ts create mode 100644 mobile/src/transport/rpc-operation-cast-fence.test.ts create mode 100644 mobile/src/transport/rpc-operation-compile-fence.ts create mode 100644 mobile/src/transport/rpc-operation-contract.ts create mode 100644 mobile/src/transport/rpc-operation-result-reader.test.ts create mode 100644 mobile/src/transport/rpc-operation-result-reader.ts create mode 100644 mobile/src/transport/rpc-operation-test-families.ts create mode 100644 mobile/src/transport/rpc-operation.test.ts create mode 100644 mobile/src/transport/rpc-operation.ts create mode 100644 mobile/src/transport/unvalidated-rpc-request-port-boundary.test.ts create mode 100644 mobile/src/transport/unvalidated-rpc-request-port-inventory.ts create mode 100644 mobile/src/transport/unvalidated-rpc-request-port.ts create mode 100644 src/shared/rpc-contract/rpc-send-params.ts diff --git a/config/scripts/generate-rpc-params-catalog.mjs b/config/scripts/generate-rpc-params-catalog.mjs index acf31aae6a1..e42ea39beb1 100644 --- a/config/scripts/generate-rpc-params-catalog.mjs +++ b/config/scripts/generate-rpc-params-catalog.mjs @@ -197,9 +197,10 @@ ${uncataloged.map((name) => ` '${name}'`).join(',\n')} export type RpcMethodName = keyof typeof RPC_PARAMS_BY_METHOD -// Why: z.output is the post-parse shape the handler receives. z.input is not a -// send-side type here — requiredString is z.unknown().transform(...), so its input -// admits any value and loses optional/default semantics. +// Why: z.output is the post-parse shape the handler receives, which is not what a +// client may send — a .default() field reads as required. z.input is not the answer +// either: requiredString is z.unknown().transform(...), so its input admits any value. +// Senders use RpcSendParams from ./rpc-send-params, which is derived from this map. export type RpcParams = (typeof RPC_PARAMS_BY_METHOD)[Method] extends z.ZodType ? z.output<(typeof RPC_PARAMS_BY_METHOD)[Method]> diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 10f586d2780..38941483c4d 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -1,19 +1,11 @@ import type { BrowserScreencastFrame } from './browser-screencast-protocol' import { DirectRpcClient } from './direct-rpc-client' -import type { - ConnectionLogSink, - ConnectionState, - ForegroundNudgeReason, - RpcResponse -} from './types' +import type { ConnectionLogSink, ConnectionState, ForegroundNudgeReason } from './types' +import type { UnvalidatedRpcRequestPort } from './unvalidated-rpc-request-port' -export type SendRequestOptions = { - timeoutMs?: number - /** Include the connect wait in the caller's timeout budget. */ - budgetSpansConnect?: boolean - /** Reject instead of replaying the request after reconnect. */ - failWhenDisconnected?: boolean -} +// Re-export shim: the options type moved to the port module with the sender it belongs to, +// and re-exporting is what keeps that move from touching every importer. +export type { SendRequestOptions } from './unvalidated-rpc-request-port' type SubscribeOptions = { onBinaryFrame?: (frame: BrowserScreencastFrame) => void @@ -21,12 +13,9 @@ type SubscribeOptions = { type StreamingListener = (result: unknown) => void -export type RpcClient = { - sendRequest: ( - method: string, - params?: unknown, - options?: SendRequestOptions - ) => Promise +// Still structurally carries the raw sender, so holding a client is still holding the port — +// which is why the boundary is inventoried rather than merely declared. +export type RpcClient = UnvalidatedRpcRequestPort & { subscribe: ( method: string, params: unknown, diff --git a/mobile/src/transport/rpc-incompatible-reply-error.ts b/mobile/src/transport/rpc-incompatible-reply-error.ts new file mode 100644 index 00000000000..026732ca3b5 --- /dev/null +++ b/mobile/src/transport/rpc-incompatible-reply-error.ts @@ -0,0 +1,27 @@ +import type { RpcDecodeIssue } from './rpc-operation-contract' + +const INCOMPATIBLE_REPLY_MESSAGE_PREFIX = 'incompatible_reply: ' + +// Why: a reply the operation's reader cannot read says nothing about what the host did. +// On a mutation it is NOT evidence the mutation failed and authorizes no retry — only a +// host-negotiated idempotency capability inside its dedupe window does (see +// tasks/worktree-create-retry.ts). So this error is deliberately neither marked +// delivery-unknown nor shaped like the cutover error the retry loops replay on. +export class RpcIncompatibleReplyError extends Error { + constructor( + readonly operationName: string, + readonly method: string, + readonly issues: readonly RpcDecodeIssue[] + ) { + super(`${INCOMPATIBLE_REPLY_MESSAGE_PREFIX}${operationName} (${method})`) + } +} + +// Why: instanceof can miss across bundle copies, so also match by message, mirroring +// isLogicalClientCutoverError. +export function isRpcIncompatibleReplyError(error: unknown): boolean { + return ( + error instanceof RpcIncompatibleReplyError || + (error instanceof Error && error.message.startsWith(INCOMPATIBLE_REPLY_MESSAGE_PREFIX)) + ) +} diff --git a/mobile/src/transport/rpc-operation-barrier.test.ts b/mobile/src/transport/rpc-operation-barrier.test.ts new file mode 100644 index 00000000000..43c74198b44 --- /dev/null +++ b/mobile/src/transport/rpc-operation-barrier.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' +import type { RpcResponse } from './types' +import { FakeSession } from './mobile-endpoint-supervisor-test-fakes' +import { isRpcDeliveryUnknown, markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' +import { interpretAtRpcBarrier, startRpcOperation } from './rpc-operation' +import { + rpcRefusal, + rpcSuccess, + terminalListAtBarrier, + workspaceListAtBarrier, + worktreePsProbeAtBarrier +} from './rpc-operation-test-families' + +const rows = { worktrees: [{ id: 'w1' }] } + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +function replying(response: RpcResponse): FakeSession { + const session = new FakeSession('connected') + session.sendRequest.mockResolvedValue(response) + return session +} + +function settleAfter(milliseconds: number): Promise<'still waiting'> { + return new Promise((resolve) => setTimeout(() => resolve('still waiting'), milliseconds)) +} + +describe('the post-barrier combinator', () => { + it('starts every request before anything is awaited', () => { + const first = replying(rpcSuccess(rows)) + const second = replying(rpcSuccess({ terminals: [] })) + + startRpcOperation(first, workspaceListAtBarrier, {}) + startRpcOperation(second, terminalListAtBarrier, {}) + + expect(first.sendRequest).toHaveBeenCalledTimes(1) + expect(second.sendRequest).toHaveBeenCalledTimes(1) + }) + + it('yields one verdict per operation, in declared order', async () => { + const verdicts = await interpretAtRpcBarrier([ + startRpcOperation(replying(rpcSuccess(rows)), workspaceListAtBarrier, {}), + startRpcOperation(replying(rpcSuccess({ terminals: [] })), terminalListAtBarrier, {}), + startRpcOperation(replying(rpcSuccess(rows)), worktreePsProbeAtBarrier, {}) + ]) + + expect(verdicts).toEqual([rows, { terminals: [] }, false]) + }) + + // The bug class this exists to remove: whichever peer lost the race used to decide which + // error the user saw. Here the second request fails first in time and the first one refuses + // afterwards, and the declaration still decides. + it('interprets in declared order rather than completion order', async () => { + const lateRefusal = deferred() + const refusing = new FakeSession('connected') + refusing.sendRequest.mockReturnValue(lateRefusal.promise) + const dropped = new FakeSession('connected') + dropped.sendRequest.mockRejectedValue(new Error('socket closed first')) + + const barrier = interpretAtRpcBarrier([ + startRpcOperation(refusing, workspaceListAtBarrier, {}), + startRpcOperation(dropped, terminalListAtBarrier, {}) + ]) + lateRefusal.resolve(rpcRefusal('method_not_found', 'no such method')) + + await expect(barrier).rejects.toThrow('method_not_found: no such method') + }) + + it('keeps the middle operation error when a later one also fails', async () => { + const barrier = interpretAtRpcBarrier([ + startRpcOperation(replying(rpcSuccess(rows)), workspaceListAtBarrier, {}), + startRpcOperation( + replying(rpcRefusal('conflict', 'middle refused')), + workspaceListAtBarrier, + {} + ), + startRpcOperation( + replying(rpcRefusal('runtime_error', 'later refused')), + workspaceListAtBarrier, + {} + ) + ]) + + await expect(barrier).rejects.toThrow('conflict: middle refused') + }) + + it('does not interpret until every raw request has settled', async () => { + const refusing = replying(rpcRefusal('runtime_error', 'boom')) + const pending = deferred() + const stalled = new FakeSession('connected') + stalled.sendRequest.mockReturnValue(pending.promise) + + const barrier = interpretAtRpcBarrier([ + startRpcOperation(refusing, workspaceListAtBarrier, {}), + startRpcOperation(stalled, terminalListAtBarrier, {}) + ]) + const raced = await Promise.race([ + barrier.then( + () => 'resolved' as const, + () => 'rejected' as const + ), + settleAfter(50) + ]) + expect(raced).toBe('still waiting') + + pending.resolve(rpcSuccess({ terminals: [] })) + await expect(barrier).rejects.toThrow('runtime_error: boom') + }) + + it('rethrows a captured transport rejection as the original error object', async () => { + const error = markRpcDeliveryUnknown(new Error('socket closed before response')) + const dropped = new FakeSession('connected') + dropped.sendRequest.mockRejectedValue(error) + + const caught = await interpretAtRpcBarrier([ + startRpcOperation(replying(rpcSuccess(rows)), workspaceListAtBarrier, {}), + startRpcOperation(dropped, terminalListAtBarrier, {}) + ]).catch((thrown: unknown) => thrown) + + expect(caught).toBe(error) + expect(isRpcDeliveryUnknown(caught)).toBe(true) + }) + + it('applies the policy each family declared, at the barrier', async () => { + const verdicts = await interpretAtRpcBarrier([ + startRpcOperation(replying(rpcRefusal('runtime_error')), terminalListAtBarrier, {}), + startRpcOperation(replying(rpcRefusal('method_not_found')), worktreePsProbeAtBarrier, {}) + ]) + + expect(verdicts).toEqual([null, true]) + }) +}) diff --git a/mobile/src/transport/rpc-operation-cast-fence.test.ts b/mobile/src/transport/rpc-operation-cast-fence.test.ts new file mode 100644 index 00000000000..83d76eb87ab --- /dev/null +++ b/mobile/src/transport/rpc-operation-cast-fence.test.ts @@ -0,0 +1,256 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { extname, join, relative, resolve } from 'node:path' +import ts from 'typescript' +import { describe, expect, it } from 'vitest' + +/** + * Bans the escapes that would make the typed boundary decorative. + * + * An operation's whole claim is that a reply arrives as a declared type because a reader + * decoded it. `as`, `any` and a `@ts-` suppression each produce the same declared type without + * the decode, so one of them anywhere in an operation implementation buys back exactly the + * drift the contract removed — and it buys it silently, since the code still compiles and the + * types still read as validated. + * + * The fenced region includes the operation API, contract and result-reader factory, plus + * non-test files importing them and files that re-export a + * file that is (transitively). Step 4's operation modules therefore land inside the fence the + * moment they are written, with nothing to remember. + * + * What this does NOT catch, all accepted: + * - A lying reader. `z.unknown()` or a schema looser than the reply decodes anything, and no + * syntax check can tell a permissive schema from a wrong one. + * - Structural laundering: a helper in an unfenced module that returns the wrong type + * honestly, which the operation then consumes without a cast. + * - `!` non-null assertions, and the widening that an untyped intermediate variable gives + * you for free. + * - A screen. Screens are outside the region by design until they hold an operation; the + * raw-port inventory is what governs them. + */ + +const mobileRoot = fileURLToPath(new URL('../..', import.meta.url)) +const scannedRoots = ['app', 'src'].map((directory) => join(mobileRoot, directory)) +const sourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx']) +const transportRoot = join(mobileRoot, 'src', 'transport') + +/** Importing any of these is what makes a file an operation implementation. */ +const REGION_SEEDS = new Set( + ['rpc-operation', 'rpc-operation-contract', 'rpc-operation-result-reader'].map((name) => + join(transportRoot, name) + ) +) + +export type RpcOperationEscape = 'assertion' | 'any' | 'suppression' + +type CastFenceException = { + readonly file: string + readonly allows: readonly RpcOperationEscape[] +} + +/** + * The modules that own the `unknown` → declared-type transition, so the erasure has to land + * somewhere. Held as data, per escape kind, so an exception cannot quietly widen into the + * others. Every entry is also checked for staleness. + */ +const CAST_FENCE_EXCEPTIONS: readonly CastFenceException[] = [ + // The interpreter. Its casts re-apply type parameters that `AnyRpcOperation` erased on the + // way in; none of them invents a shape the reader did not already produce. + { file: 'src/transport/rpc-operation.ts', allows: ['assertion'] }, + // The reader factory. `safeParse` returns the schema's own output type as `unknown`. + { file: 'src/transport/rpc-operation-result-reader.ts', allows: ['assertion'] }, + // Nothing but suppressions: every directive in it is an assertion that tsc still rejects + // the thing above it, which is the compile fence's entire mechanism. + { file: 'src/transport/rpc-operation-compile-fence.ts', allows: ['suppression'] } +] + +// Text, not AST: a suppression is a comment, and comments are not nodes. A directive spelled +// inside a string literal therefore reads as one — which fails closed. +const SUPPRESSION = /@ts-(?:expect-error|ignore|nocheck)\b/ + +function sourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name) + if (entry.isDirectory()) { + return entry.name === 'node_modules' ? [] : sourceFiles(path) + } + return [path] + }) +} + +function parse(path: string, source: string): ts.SourceFile { + const extension = extname(path) + return ts.createSourceFile( + path, + source, + ts.ScriptTarget.Latest, + true, + extension === '.tsx' || extension === '.jsx' ? ts.ScriptKind.TSX : ts.ScriptKind.TS + ) +} + +function resolvedSpecifier(path: string, node: ts.Node | undefined): string | null { + if (!node || !ts.isStringLiteral(node) || !node.text.startsWith('.')) { + return null + } + return resolve(path, '..', node.text) +} + +/** `as const` narrows a literal; it declares nothing the value was not already. */ +function isConstAssertion(node: ts.AsExpression): boolean { + return ( + ts.isTypeReferenceNode(node.type) && + ts.isIdentifier(node.type.typeName) && + node.type.typeName.text === 'const' + ) +} + +export function rpcOperationEscapes(path: string, source: string): RpcOperationEscape[] { + const found: RpcOperationEscape[] = [] + const visit = (node: ts.Node): void => { + if ( + (ts.isAsExpression(node) && !isConstAssertion(node)) || + ts.isTypeAssertionExpression(node) + ) { + found.push('assertion') + } + if (node.kind === ts.SyntaxKind.AnyKeyword) { + found.push('any') + } + ts.forEachChild(node, visit) + } + visit(parse(path, source)) + if (SUPPRESSION.test(source)) { + found.push('suppression') + } + return [...new Set(found)].sort() +} + +/** Imports and re-exports that make the importer part of the operation region. */ +function moduleEdges(path: string, source: string): { imports: string[]; reExports: string[] } { + const imports: string[] = [] + const reExports: string[] = [] + for (const statement of parse(path, source).statements) { + if (ts.isImportDeclaration(statement)) { + const target = resolvedSpecifier(path, statement.moduleSpecifier) + if (target) { + imports.push(target) + } + continue + } + if (ts.isExportDeclaration(statement) && statement.moduleSpecifier) { + const target = resolvedSpecifier(path, statement.moduleSpecifier) + if (target) { + imports.push(target) + reExports.push(target) + } + } + } + return { imports, reExports } +} + +const scanned = scannedRoots + .flatMap(sourceFiles) + .filter((path) => sourceExtensions.has(extname(path))) + .filter((path) => !/\.test\.tsx?$/.test(path)) + +const sources = new Map(scanned.map((path) => [path, readFileSync(path, 'utf8')] as const)) +const edges = new Map([...sources].map(([path, source]) => [path, moduleEdges(path, source)])) + +/** Modules are keyed without their extension, the way a relative specifier resolves. */ +function moduleKey(path: string): string { + return path.replace(/\.[jt]sx?$/, '') +} + +const region = new Set(scanned.filter((path) => REGION_SEEDS.has(moduleKey(path)))) +for (const [path, { imports }] of edges) { + if (imports.some((target) => REGION_SEEDS.has(target))) { + region.add(path) + } +} +// Fixpoint over re-export edges: a barrel that re-exports an operation module is in the fence +// too, which is where a cast would otherwise sit unwatched between definition and screen. +for (let changed = true; changed;) { + changed = false + const members = new Set([...region].map(moduleKey)) + for (const [path, { reExports }] of edges) { + if (!region.has(path) && reExports.some((target) => members.has(target))) { + region.add(path) + changed = true + } + } +} + +const relativeRegion = [...region].map((path) => + relative(mobileRoot, path).split(/[/\\]/).join('/') +) + +describe('RPC operation cast fence', () => { + const probe = join(mobileRoot, 'src', 'transport', 'probe.ts') + + it('recognizes each escape and leaves honest code alone', () => { + expect(rpcOperationEscapes(probe, 'const v = raw as WorkspaceRows')).toEqual(['assertion']) + expect(rpcOperationEscapes(probe, 'const v = raw as unknown as WorkspaceRows')).toEqual([ + 'assertion' + ]) + expect(rpcOperationEscapes(probe, 'const v: any = raw')).toEqual(['any']) + expect(rpcOperationEscapes(probe, 'function f(raw: any) {}')).toEqual(['any']) + expect(rpcOperationEscapes(probe, 'const v = raw as any')).toEqual(['any', 'assertion']) + expect(rpcOperationEscapes(probe, '// @ts-expect-error\nconst v = raw')).toEqual([ + 'suppression' + ]) + expect(rpcOperationEscapes(probe, '// @ts-ignore\nconst v = raw')).toEqual(['suppression']) + expect(rpcOperationEscapes(probe, "const v = ['a'] as const")).toEqual([]) + expect(rpcOperationEscapes(probe, 'const v = read(raw)')).toEqual([]) + expect(rpcOperationEscapes(probe, 'const v = raw satisfies WorkspaceRows')).toEqual([]) + expect(rpcOperationEscapes(probe, 'const v = value!')).toEqual([]) + }) + + it('puts every operation module in the fenced region', () => { + for (const file of [ + 'src/transport/rpc-operation.ts', + 'src/transport/rpc-operation-contract.ts', + 'src/transport/rpc-operation-test-families.ts', + 'src/transport/rpc-operation-compile-fence.ts', + 'src/transport/rpc-operation-result-reader.ts', + 'src/transport/rpc-incompatible-reply-error.ts' + ]) { + expect(relativeRegion, `${file} must be fenced`).toContain(file) + } + // A screen that only holds a client is governed by the raw-port inventory, not by this. + expect(relativeRegion).not.toContain('src/transport/rpc-client.ts') + }) + + it('has no operation module casting, widening or suppressing its way to a type', () => { + const allowed = new Map(CAST_FENCE_EXCEPTIONS.map((entry) => [entry.file, entry.allows])) + const offenders = [...region] + .map((path) => { + const file = relative(mobileRoot, path).split(/[/\\]/).join('/') + const escapes = rpcOperationEscapes(path, sources.get(path) ?? '') + const permitted = allowed.get(file) ?? [] + return { file, escapes: escapes.filter((escape) => !permitted.includes(escape)) } + }) + .filter((entry) => entry.escapes.length > 0) + .map((entry) => `${entry.file}: ${entry.escapes.join(', ')}`) + .sort() + + expect( + offenders, + 'Decode the reply with a reader instead. An operation that asserts its own result type is not typed.' + ).toEqual([]) + }) + + it('has no stale cast-fence exception', () => { + const stale = CAST_FENCE_EXCEPTIONS.flatMap((entry) => { + const path = join(mobileRoot, entry.file) + if (!region.has(path)) { + return [`${entry.file}: no longer in the fenced region`] + } + const escapes = rpcOperationEscapes(path, sources.get(path) ?? '') + return entry.allows + .filter((escape) => !escapes.includes(escape)) + .map((escape) => `${entry.file}: no longer uses '${escape}'`) + }) + expect(stale, 'Narrow or delete the exception in rpc-operation-cast-fence.test.ts.').toEqual([]) + }) +}) diff --git a/mobile/src/transport/rpc-operation-compile-fence.ts b/mobile/src/transport/rpc-operation-compile-fence.ts new file mode 100644 index 00000000000..3188774743c --- /dev/null +++ b/mobile/src/transport/rpc-operation-compile-fence.ts @@ -0,0 +1,191 @@ +import type { RpcClient } from './rpc-client' +import type { RpcMethodName, RpcParams, RpcSendParams } from './rpc-params-contract' +import { defineRpcOperation, runRpcOperation, startRpcOperation } from './rpc-operation' +import { rpcResultVariants } from './rpc-operation-result-reader' +import { + workspaceListAtBarrier, + workspaceListOrNull, + workspaceRowsReader, + worktreePsProbe, + type WorkspaceRows +} from './rpc-operation-test-families' +import type { + CapabilityProbeRpcDefinition, + ObjectResultRpcDefinition, + RequireResultRpcDefinition, + RpcAcceptanceName, + RpcCompatibleReader, + RpcOperation +} from './rpc-operation-contract' + +// Why this file exists: the descriptor's whole point is that a call site cannot pick the +// acceptance policy, the interpretation barrier, or the send-side params for itself. Every +// expect-error directive below is that claim as an assertion — tsc fails on a directive that +// stops catching an error, so `pnpm --dir mobile typecheck` is the gate. Nothing here runs and +// no app code imports it. + +declare const client: RpcClient + +// @ts-expect-error a variant reader combinator must have at least one reader +const _fenceEmptyVariantReaders = rpcResultVariants([]) + +export const fenceProbeWithReader: CapabilityProbeRpcDefinition<'worktree.ps', 'on-settle'> = { + name: 'fence.probeWithReader', + method: 'worktree.ps', + acceptance: 'method-not-found-refusal', + barrier: 'on-settle', + // @ts-expect-error a refusal-code probe reads no payload, so it cannot carry a reader + read: workspaceRowsReader +} + +// @ts-expect-error 'require-result-or-throw' has no value to return without a reader +export const fenceDecodingWithoutReader: RequireResultRpcDefinition< + 'worktree.ps', + 'rows', + WorkspaceRows, + 'on-settle' +> = { + name: 'fence.decodingWithoutReader', + method: 'worktree.ps', + acceptance: 'require-result-or-throw', + barrier: 'on-settle' +} + +// @ts-expect-error the four policies in rpc-acceptance-policies.ts are the whole vocabulary +export const fenceInventedPolicy: RpcAcceptanceName = 'no-error-means-fine' + +// A reader for a payload no acceptance policy here admits, i.e. one belonging to some other +// family's shape. +const fenceTextReader: RpcCompatibleReader = (raw) => ({ + compatible: true, + variant: 'text', + value: raw, + salvage: { droppedPaths: [], droppedCount: 0 } +}) + +export const fenceObjectPolicyWrongReader: ObjectResultRpcDefinition< + 'worktree.ps', + 'text', + string, + 'on-settle' +> = { + name: 'fence.objectPolicyWrongReader', + method: 'worktree.ps', + acceptance: 'object-result-or-null', + barrier: 'on-settle', + // @ts-expect-error the policy admits a non-null object, not the string this reader expects + read: fenceTextReader +} + +export const fenceDefineRejectsMismatch = defineRpcOperation({ + name: 'fence.defineRejectsMismatch', + method: 'worktree.ps', + // @ts-expect-error no overload of defineRpcOperation pairs a probe with a payload reader + acceptance: 'method-not-found-refusal', + barrier: 'on-settle', + // @ts-expect-error ... and the reader it would need is exactly what the probe overload bans + read: workspaceRowsReader +}) + +// @ts-expect-error only generated catalog method names are addressable +export const fenceUnknownMethod: RpcMethodName = 'worktree.nope' + +// The send-side params type. z.output (what the handler receives) and z.input (what the +// coercing builders admit) are both wrong for a sender in opposite directions, so these pin +// the two failures a regression to either one would reintroduce. + +// `query` and `limit` carry .default(), so a sender may leave them out. Under z.output both +// read as required and this line stops compiling. +export const fenceOmitsDefaultedField: RpcSendParams<'files.searchPaths'> = { worktree: 'w' } + +export const fenceRejectsWrongFieldType: RpcSendParams<'files.searchPaths'> = { + // @ts-expect-error z.input of a z.unknown().transform builder admits any value; this does not + worktree: 42 +} + +// @ts-expect-error `worktree` has neither a default nor an optional marker +export const fenceKeepsRequiredField: RpcSendParams<'files.searchPaths'> = { query: 'x' } + +// Catalog-wide: anything a handler could have been handed is something a sender may write. +// A method that ever resolves tighter than its parsed shape lands in this union. +declare const fenceTighterThanParsed: { + [Method in RpcMethodName]: RpcParams extends RpcSendParams ? never : Method +}[RpcMethodName] & {} +export const fenceNoTighterMethod: never = fenceTighterThanParsed + +// z.input collapses every coercing builder to `unknown`. Only plugins.panelAction may be +// unknown, because its schema is literally z.unknown(). +declare const fenceUnknownParams: { + [Method in RpcMethodName]: unknown extends RpcSendParams ? Method : never +}[RpcMethodName] & {} +export const fenceOnlyDeclaredUnknown: 'plugins.panelAction' = fenceUnknownParams + +export async function fenceBarrierAndParams(): Promise { + await runRpcOperation( + client, + // @ts-expect-error this family interprets after all requests, so it has no on-settle run + workspaceListAtBarrier, + {} + ) + startRpcOperation( + client, + // @ts-expect-error an on-settle family must not be parked behind someone else's barrier + worktreePsProbe, + {} + ) + await runRpcOperation( + client, + workspaceListOrNull, + // @ts-expect-error worktree.ps takes a numeric limit + { limit: 'ten' } + ) +} + +export async function fenceVerdictTypes(): Promise { + // @ts-expect-error the probe's policy yields a boolean, not the other family's rows + const rows: WorkspaceRows = await runRpcOperation(client, worktreePsProbe, {}) + void rows +} + +// @ts-expect-error the public descriptor also requires decoding, even without the factory +export const fenceManualWithoutReader: RpcOperation< + 'worktree.ps', + 'require-result-or-throw', + 'rows', + WorkspaceRows, + 'on-settle' +> = { + name: 'fence.manual', + method: 'worktree.ps', + acceptance: 'require-result-or-throw', + barrier: 'on-settle' +} + +// @ts-expect-error widening the policy cannot disconnect it from its required reader +export const fenceBroadWithoutReader: RpcOperation< + 'worktree.ps', + RpcAcceptanceName, + 'rows', + WorkspaceRows, + 'on-settle' +> = { + name: 'fence.broad', + method: 'worktree.ps', + acceptance: 'require-result-or-throw', + barrier: 'on-settle', + read: undefined +} + +// @ts-expect-error object acceptance must decode, just like require-result acceptance +export const fenceObjectWithoutReader: RpcOperation< + 'worktree.ps', + 'object-result-or-null', + 'rows', + WorkspaceRows, + 'on-settle' +> = { + name: 'fence.object', + method: 'worktree.ps', + acceptance: 'object-result-or-null', + barrier: 'on-settle' +} diff --git a/mobile/src/transport/rpc-operation-contract.ts b/mobile/src/transport/rpc-operation-contract.ts new file mode 100644 index 00000000000..581506a97a7 --- /dev/null +++ b/mobile/src/transport/rpc-operation-contract.ts @@ -0,0 +1,155 @@ +import type { RpcMethodName } from './rpc-params-contract' +import type { RpcFailure, RpcResponse, RpcSuccess } from './types' + +// An operation descriptor fixes the method, the acceptance policy and the interpretation +// barrier at definition time. Per-call freedom over those three is what produced acceptance +// drift and settlement-order drift across mobile's RPC call sites, so none of them is a +// parameter of any send helper. + +/** One of the named policies in rpc-acceptance-policies.ts, chosen per operation family. */ +export type RpcAcceptanceName = + | 'require-result-or-throw' + | 'object-result-or-null' + | 'method-not-found-refusal' + | 'streaming-opener' + +/** Where a settled reply may become a value or a throw. */ +export type RpcInterpretationBarrier = 'on-settle' | 'after-all-requests' + +export type RpcDecodeIssue = { readonly path: string; readonly message: string } + +/** Bounded salvage diagnostics for a reply that decoded with parts dropped. */ +export type RpcSalvageReport = { + readonly droppedPaths: readonly string[] + readonly droppedCount: number +} + +export type RpcReadResult = + | { + readonly compatible: true + readonly variant: Variant + readonly value: Value + readonly salvage: RpcSalvageReport + } + | { readonly compatible: false; readonly issues: readonly RpcDecodeIssue[] } + +/** Reads the payload its acceptance policy admits into one declared semantic variant. */ +export type RpcCompatibleReader = ( + raw: Raw +) => RpcReadResult + +export type RpcStreamOpenerReply = RpcSuccess & { streaming: true } + +// Only a fulfilled outer envelope is classified. Transport rejection stays on the promise +// channel, so an operation in a Promise.all still fails the group immediately instead of +// waiting for a peer and letting a later policy surface a different error. +export type RpcRequestOutcome = + | { + readonly kind: 'outer-refused' + readonly error: RpcFailure['error'] + readonly raw: RpcResponse + } + | { + readonly kind: 'decoded' + readonly variant: Variant + readonly value: Value + readonly raw: RpcResponse + readonly salvage: RpcSalvageReport + } + | { + readonly kind: 'incompatible' + readonly raw: RpcResponse + readonly issues: readonly RpcDecodeIssue[] + } + +export type RpcOperation< + Method extends RpcMethodName, + Acceptance extends RpcAcceptanceName, + Variant extends string, + Value, + Barrier extends RpcInterpretationBarrier +> = { + /** Family name, not the method: two families may share a method with different acceptance. */ + readonly name: string + readonly method: Method + readonly barrier: Barrier +} & { + [Policy in RpcAcceptanceName]: { + readonly acceptance: Policy + readonly read: Policy extends 'require-result-or-throw' | 'object-result-or-null' + ? RpcCompatibleReader + : undefined + } +}[Acceptance] + +// Internal interpreter view; public send APIs retain the policy/reader correlation. +export type AnyRpcOperation = Pick< + RpcOperation, + 'name' | 'method' | 'acceptance' | 'barrier' +> & { readonly read: RpcCompatibleReader | undefined } + +/** The verdict the declared policy yields. Not a per-call choice. */ +export type RpcVerdict< + Acceptance extends RpcAcceptanceName, + Value +> = Acceptance extends 'require-result-or-throw' + ? Value + : Acceptance extends 'object-result-or-null' + ? Value | null + : Acceptance extends 'method-not-found-refusal' + ? boolean + : Acceptance extends 'streaming-opener' + ? RpcStreamOpenerReply | null + : never + +export type RpcOperationSettlement = + | { readonly status: 'fulfilled'; readonly outcome: RpcRequestOutcome } + | { readonly status: 'rejected'; readonly error: unknown } + +type RpcOperationDefinition< + Method extends RpcMethodName, + Barrier extends RpcInterpretationBarrier +> = { + name: string + method: Method + barrier: Barrier +} + +export type RequireResultRpcDefinition< + Method extends RpcMethodName, + Variant extends string, + Value, + Barrier extends RpcInterpretationBarrier +> = RpcOperationDefinition & { + acceptance: 'require-result-or-throw' + read: RpcCompatibleReader +} + +export type ObjectResultRpcDefinition< + Method extends RpcMethodName, + Variant extends string, + Value, + Barrier extends RpcInterpretationBarrier +> = RpcOperationDefinition & { + acceptance: 'object-result-or-null' + // Raw is the non-null object rpcObjectResultOrNull admits; anything else is incompatible. + read: RpcCompatibleReader, Variant, Value> +} + +export type CapabilityProbeRpcDefinition< + Method extends RpcMethodName, + Barrier extends RpcInterpretationBarrier +> = RpcOperationDefinition & { + acceptance: 'method-not-found-refusal' + /** A probe answers from the refusal code alone, so a reader would have nothing to read. */ + read?: never +} + +export type StreamOpenerRpcDefinition< + Method extends RpcMethodName, + Barrier extends RpcInterpretationBarrier +> = RpcOperationDefinition & { + acceptance: 'streaming-opener' + /** The opener's value is the reply itself; frames arrive on the subscription, not here. */ + read?: never +} diff --git a/mobile/src/transport/rpc-operation-result-reader.test.ts b/mobile/src/transport/rpc-operation-result-reader.test.ts new file mode 100644 index 00000000000..0a5393352b3 --- /dev/null +++ b/mobile/src/transport/rpc-operation-result-reader.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { salvagingArray } from '../../../src/shared/zod-salvage' +import { FakeSession } from './mobile-endpoint-supervisor-test-fakes' +import { captureRpcOperationSettlement, defineRpcOperation } from './rpc-operation' +import { rpcResultVariant, rpcResultVariants } from './rpc-operation-result-reader' +import { + WORKSPACE_ROWS_SCHEMA, + rpcSuccess, + workspaceRowsReader +} from './rpc-operation-test-families' + +const SALVAGING_ROWS_SCHEMA = z.object({ + worktrees: salvagingArray(z.object({ id: z.string() })) +}) + +const salvagingReader = rpcResultVariant('rows', SALVAGING_ROWS_SCHEMA) + +describe('a single-variant reader', () => { + it('decodes a matching payload and reports nothing dropped', () => { + expect(workspaceRowsReader({ worktrees: [{ id: 'w1' }] })).toEqual({ + compatible: true, + variant: 'rows', + value: { worktrees: [{ id: 'w1' }] }, + salvage: { droppedPaths: [], droppedCount: 0 } + }) + }) + + it('reports dotted issue paths for a payload it cannot read', () => { + const result = workspaceRowsReader({ worktrees: [{ id: 1 }] }) + + expect(result.compatible).toBe(false) + if (result.compatible) { + throw new Error('expected an incompatible read') + } + expect(result.issues).toEqual([{ path: 'worktrees.0.id', message: expect.any(String) }]) + }) + + it('carries the salvage report when the schema drops an element', () => { + const result = salvagingReader({ worktrees: [{ id: 'w1' }, { id: 7 }] }) + + expect(result).toEqual({ + compatible: true, + variant: 'rows', + value: { worktrees: [{ id: 'w1' }] }, + // zod-salvage reports the path relative to the salvaging container, not the envelope. + salvage: { droppedPaths: ['1'], droppedCount: 1 } + }) + }) + + // zod-salvage keeps its collector at module level, so a leak here would blame the next + // reply for the previous one's drops. + it('does not leak drop diagnostics into the next read', () => { + salvagingReader({ worktrees: [{ id: 7 }] }) + + expect(salvagingReader({ worktrees: [{ id: 'w1' }] })).toMatchObject({ + salvage: { droppedPaths: [], droppedCount: 0 } + }) + }) + + it('bounds the issues it reports and says how many it dropped', () => { + const wide = { worktrees: Array.from({ length: 25 }, () => ({ id: 1 })) } + const result = workspaceRowsReader(wide) + + if (result.compatible) { + throw new Error('expected an incompatible read') + } + expect(result.issues).toHaveLength(21) + expect(result.issues[20]).toEqual({ path: '', message: '5 further issues omitted' }) + }) + + // The caveat that comes with zod-salvage: it wraps a synchronous parse only. An async + // schema must read as incompatible rather than leaking a promise into the outcome. + it('reads an async schema as incompatible instead of leaking a promise', () => { + const asyncReader = rpcResultVariant( + 'rows', + z.object({ id: z.string() }).refine(async () => true) + ) + + const result = asyncReader({ id: 'w1' }) + + expect(result.compatible).toBe(false) + if (result.compatible) { + throw new Error('expected an incompatible read') + } + expect(result.issues[0].message).toContain('synchronous parse') + }) +}) + +describe('a multi-variant reader', () => { + const reader = rpcResultVariants<'rows' | 'legacy-array', unknown>([ + workspaceRowsReader, + rpcResultVariant('legacy-array', z.array(z.object({ id: z.string() }))) + ]) + + it('takes the first declared variant that reads', () => { + expect(reader({ worktrees: [{ id: 'w1' }] })).toMatchObject({ variant: 'rows' }) + }) + + it('falls through to a later variant', () => { + expect(reader([{ id: 'w1' }])).toMatchObject({ + variant: 'legacy-array', + value: [{ id: 'w1' }] + }) + }) + + it('tags every variant it tried when none of them reads', () => { + const result = reader('neither shape') + + if (result.compatible) { + throw new Error('expected an incompatible read') + } + expect(result.issues.map((issue) => issue.path)).toEqual(['rows', 'legacy-array']) + }) +}) + +describe('salvage through a descriptor', () => { + const salvagingList = defineRpcOperation({ + name: 'test.salvagingWorkspaceList', + method: 'worktree.ps', + acceptance: 'require-result-or-throw', + barrier: 'on-settle', + read: salvagingReader + }) + + it('reports the dropped paths on the decoded outcome', async () => { + const session = new FakeSession('connected') + session.sendRequest.mockResolvedValue(rpcSuccess({ worktrees: [{ id: 'w1' }, { id: 7 }] })) + + const settlement = await captureRpcOperationSettlement(session, salvagingList, {}) + + expect(settlement.status === 'fulfilled' && settlement.outcome).toMatchObject({ + kind: 'decoded', + value: { worktrees: [{ id: 'w1' }] }, + salvage: { droppedPaths: ['1'], droppedCount: 1 } + }) + }) +}) + +describe('the shared workspace schema', () => { + it('is the strict shape the salvaging variant relaxes', () => { + expect(WORKSPACE_ROWS_SCHEMA.safeParse({ worktrees: [{ id: 7 }] }).success).toBe(false) + expect(SALVAGING_ROWS_SCHEMA.safeParse({ worktrees: [{ id: 7 }] }).success).toBe(true) + }) +}) diff --git a/mobile/src/transport/rpc-operation-result-reader.ts b/mobile/src/transport/rpc-operation-result-reader.ts new file mode 100644 index 00000000000..b9341e0e8bc --- /dev/null +++ b/mobile/src/transport/rpc-operation-result-reader.ts @@ -0,0 +1,89 @@ +import { z } from 'zod' +import { collectSalvageDrops } from '../../../src/shared/zod-salvage' +import type { RpcCompatibleReader, RpcDecodeIssue } from './rpc-operation-contract' + +const MAX_REPORTED_DECODE_ISSUES = 20 + +/** A reader that names the semantic variant it decodes, so a combinator can tag its issues. */ +export type NamedRpcResultReader = RpcCompatibleReader< + unknown, + Variant, + Value +> & { readonly variant: Variant } + +/** Builds a compatible reader for one semantic variant of a reply payload. */ +export function rpcResultVariant( + variant: Variant, + schema: Schema +): NamedRpcResultReader> { + const read: RpcCompatibleReader> = (raw) => { + try { + // Why: zod-salvage holds module-level collector state and wraps a *synchronous* + // parse only; safeParse throws on an async schema, which reads as incompatible. + const parsed = collectSalvageDrops(() => schema.safeParse(raw)) + if (!parsed.value.success) { + return { compatible: false, issues: decodeIssues(parsed.value.error) } + } + return { + compatible: true, + variant, + value: parsed.value.data as z.output, + salvage: { droppedPaths: parsed.droppedPaths, droppedCount: parsed.droppedCount } + } + } catch (error) { + return { compatible: false, issues: [{ path: '', message: describeThrow(error) }] } + } + } + return Object.assign(read, { variant }) +} + +/** Tries each variant in declared order and takes the first that reads. */ +export function rpcResultVariants( + readers: readonly [ + NamedRpcResultReader, + ...NamedRpcResultReader[] + ] +): RpcCompatibleReader { + return (raw) => { + const issues: RpcDecodeIssue[] = [] + for (const reader of readers) { + const result = reader(raw) + if (result.compatible) { + return result + } + for (const issue of result.issues) { + issues.push({ path: joinPath(reader.variant, issue.path), message: issue.message }) + } + } + return { compatible: false, issues: boundIssues(issues) } + } +} + +function decodeIssues(error: z.ZodError): RpcDecodeIssue[] { + return boundIssues( + error.issues.map((issue) => ({ + path: issue.path.map((segment) => String(segment)).join('.'), + message: issue.message + })) + ) +} + +// Why: a hostile or very foreign reply can issue per element; report a bounded sample and +// say how many were dropped rather than letting the diagnostic grow with the payload. +function boundIssues(issues: readonly RpcDecodeIssue[]): RpcDecodeIssue[] { + if (issues.length <= MAX_REPORTED_DECODE_ISSUES) { + return [...issues] + } + return [ + ...issues.slice(0, MAX_REPORTED_DECODE_ISSUES), + { path: '', message: `${issues.length - MAX_REPORTED_DECODE_ISSUES} further issues omitted` } + ] +} + +function joinPath(variant: string, path: string): string { + return path ? `${variant}.${path}` : variant +} + +function describeThrow(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/mobile/src/transport/rpc-operation-test-families.ts b/mobile/src/transport/rpc-operation-test-families.ts new file mode 100644 index 00000000000..73554b7a4f8 --- /dev/null +++ b/mobile/src/transport/rpc-operation-test-families.ts @@ -0,0 +1,99 @@ +import { z } from 'zod' +import type { RpcResponse } from './types' +import type { RpcCompatibleReader } from './rpc-operation-contract' +import { rpcResultVariant, rpcResultVariants } from './rpc-operation-result-reader' +import { defineRpcOperation } from './rpc-operation' + +// Operation families used by the rpc-operation suites and by the compile fence. Kept in one +// place so the tests and the fence assert against the same descriptors, and so tsc sees them +// (the app tsconfig excludes *.test.ts). No app code imports this module. + +export const WORKSPACE_ROWS_SCHEMA = z.object({ + worktrees: z.array(z.object({ id: z.string() })) +}) + +const LEGACY_WORKSPACE_ROWS_SCHEMA = z.array(z.object({ id: z.string() })) + +export type WorkspaceRows = z.output +export type LegacyWorkspaceRows = z.output + +export const workspaceRowsReader = rpcResultVariant('rows', WORKSPACE_ROWS_SCHEMA) + +/** Two semantic variants: the modern envelope, then a host that answered a bare array. */ +export const workspaceRowsOrLegacyReader: RpcCompatibleReader< + unknown, + 'rows' | 'legacy-array', + WorkspaceRows | LegacyWorkspaceRows +> = rpcResultVariants<'rows' | 'legacy-array', WorkspaceRows | LegacyWorkspaceRows>([ + workspaceRowsReader, + rpcResultVariant('legacy-array', LEGACY_WORKSPACE_ROWS_SCHEMA) +]) + +export const workspaceListOrThrow = defineRpcOperation({ + name: 'test.workspaceListOrThrow', + method: 'worktree.ps', + acceptance: 'require-result-or-throw', + barrier: 'on-settle', + read: workspaceRowsOrLegacyReader +}) + +// Same method, a different family: main's callers disagreed about acceptance, so both rules +// stay named rather than being unified behind one descriptor. +export const workspaceListOrNull = defineRpcOperation({ + name: 'test.workspaceListOrNull', + method: 'worktree.ps', + acceptance: 'object-result-or-null', + barrier: 'on-settle', + read: workspaceRowsReader +}) + +export const worktreePsProbe = defineRpcOperation({ + name: 'test.worktreePsProbe', + method: 'worktree.ps', + acceptance: 'method-not-found-refusal', + barrier: 'on-settle' +}) + +export const terminalStreamOpener = defineRpcOperation({ + name: 'test.terminalStreamOpener', + method: 'terminal.subscribe', + acceptance: 'streaming-opener', + barrier: 'on-settle' +}) + +export const workspaceListAtBarrier = defineRpcOperation({ + name: 'test.workspaceListAtBarrier', + method: 'worktree.ps', + acceptance: 'require-result-or-throw', + barrier: 'after-all-requests', + read: workspaceRowsReader +}) + +export const terminalListAtBarrier = defineRpcOperation({ + name: 'test.terminalListAtBarrier', + method: 'terminal.list', + acceptance: 'object-result-or-null', + barrier: 'after-all-requests', + read: rpcResultVariant('terminals', z.object({ terminals: z.array(z.unknown()) })) +}) + +export const worktreePsProbeAtBarrier = defineRpcOperation({ + name: 'test.worktreePsProbeAtBarrier', + method: 'worktree.ps', + acceptance: 'method-not-found-refusal', + barrier: 'after-all-requests' +}) + +export function rpcSuccess(result: unknown, streaming?: true): RpcResponse { + return { + id: 'rpc-1', + ok: true, + result, + _meta: { runtimeId: 'runtime-1' }, + ...(streaming ? { streaming } : {}) + } +} + +export function rpcRefusal(code: string, message = 'Nope'): RpcResponse { + return { id: 'rpc-1', ok: false, error: { code, message }, _meta: { runtimeId: 'runtime-1' } } +} diff --git a/mobile/src/transport/rpc-operation.test.ts b/mobile/src/transport/rpc-operation.test.ts new file mode 100644 index 00000000000..91542b48528 --- /dev/null +++ b/mobile/src/transport/rpc-operation.test.ts @@ -0,0 +1,335 @@ +import { describe, expect, it } from 'vitest' +import type { RpcResponse } from './types' +import { FakeSession } from './mobile-endpoint-supervisor-test-fakes' +import { + createStableLogicalRpcClient, + isLogicalClientCutoverError +} from './stable-logical-rpc-client' +import { isRpcDeliveryUnknown, markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' +import { + RpcIncompatibleReplyError, + isRpcIncompatibleReplyError +} from './rpc-incompatible-reply-error' +import { captureRpcOperationSettlement, runRpcOperation } from './rpc-operation' +import { + rpcRefusal, + rpcSuccess, + terminalListAtBarrier, + terminalStreamOpener, + workspaceListOrNull, + workspaceListOrThrow, + worktreePsProbe +} from './rpc-operation-test-families' + +function connectedSession(response?: RpcResponse): FakeSession { + const session = new FakeSession('connected') + if (response) { + session.sendRequest.mockResolvedValue(response) + } + return session +} + +const rows = { worktrees: [{ id: 'w1' }] } + +describe('request classification', () => { + it('decodes a compatible reply, naming the variant and keeping the raw envelope', async () => { + const response = rpcSuccess(rows) + const settlement = await captureRpcOperationSettlement( + connectedSession(response), + workspaceListOrThrow, + {} + ) + + expect(settlement).toEqual({ + status: 'fulfilled', + outcome: { + kind: 'decoded', + variant: 'rows', + value: rows, + raw: response, + salvage: { droppedPaths: [], droppedCount: 0 } + } + }) + }) + + it('names the legacy variant when the host answered the older shape', async () => { + const settlement = await captureRpcOperationSettlement( + connectedSession(rpcSuccess([{ id: 'w1' }])), + workspaceListOrThrow, + {} + ) + + expect(settlement.status === 'fulfilled' && settlement.outcome.kind).toBe('decoded') + expect(settlement.status === 'fulfilled' && settlement.outcome).toMatchObject({ + variant: 'legacy-array', + value: [{ id: 'w1' }] + }) + }) + + it('classifies a refusal as outer-refused rather than throwing', async () => { + const response = rpcRefusal('runtime_error', 'boom') + const settlement = await captureRpcOperationSettlement( + connectedSession(response), + workspaceListOrThrow, + {} + ) + + expect(settlement).toEqual({ + status: 'fulfilled', + outcome: { + kind: 'outer-refused', + error: { code: 'runtime_error', message: 'boom' }, + raw: response + } + }) + }) + + it('classifies a reply the reader cannot read as incompatible, with bounded issues', async () => { + const response = rpcSuccess({ worktrees: [{ id: 7 }] }) + const settlement = await captureRpcOperationSettlement( + connectedSession(response), + workspaceListOrThrow, + {} + ) + + expect(settlement.status).toBe('fulfilled') + if (settlement.status !== 'fulfilled' || settlement.outcome.kind !== 'incompatible') { + throw new Error('expected an incompatible outcome') + } + expect(settlement.outcome.raw).toBe(response) + expect(settlement.outcome.issues.map((issue) => issue.path)).toContain('rows.worktrees.0.id') + }) + + it('treats a reply that is not an object as incompatible for the nullable family', async () => { + const settlement = await captureRpcOperationSettlement( + connectedSession(rpcSuccess('not an object')), + workspaceListOrNull, + {} + ) + + expect(settlement.status === 'fulfilled' && settlement.outcome.kind).toBe('incompatible') + }) + + it('treats a throwing reader as incompatible, never as a transport failure', async () => { + const exploding = { + ...workspaceListOrThrow, + read: () => { + throw new Error('reader exploded') + } + } + const settlement = await captureRpcOperationSettlement( + connectedSession(rpcSuccess(rows)), + exploding, + {} + ) + + expect(settlement.status === 'fulfilled' && settlement.outcome.kind).toBe('incompatible') + }) +}) + +describe('transport rejection stays on the promise channel', () => { + it('rejects with the original error object', async () => { + const error = new Error('socket closed') + const session = new FakeSession('connected') + session.sendRequest.mockRejectedValue(error) + + await expect(runRpcOperation(session, workspaceListOrThrow, {})).rejects.toBe(error) + }) + + it('keeps a delivery-unknown mark readable through the descriptor', async () => { + const error = markRpcDeliveryUnknown(new Error('socket closed before response')) + const session = new FakeSession('connected') + session.sendRequest.mockRejectedValue(error) + + const caught = await runRpcOperation(session, workspaceListOrThrow, {}).catch( + (thrown: unknown) => thrown + ) + + expect(caught).toBe(error) + expect(isRpcDeliveryUnknown(caught)).toBe(true) + }) + + // The cutover predicate matches class or exact message because instanceof misses across + // bundle copies; a clone from another copy must still read as a cutover through the descriptor. + it('keeps a cutover error from another bundle copy recognisable', async () => { + class ForeignBundleCutoverError extends Error {} + const error = new ForeignBundleCutoverError('RPC interrupted by connection migration') + const session = new FakeSession('connected') + session.sendRequest.mockRejectedValue(error) + + const caught = await runRpcOperation(session, workspaceListOrThrow, {}).catch( + (thrown: unknown) => thrown + ) + + expect(caught).toBe(error) + expect(isLogicalClientCutoverError(caught)).toBe(true) + }) + + it('fails a Promise.all group immediately instead of waiting for a stalled peer', async () => { + const error = new Error('socket closed') + const failing = new FakeSession('connected') + failing.sendRequest.mockRejectedValue(error) + const stalled = new FakeSession('connected') + stalled.sendRequest.mockReturnValue(new Promise(() => {})) + + const group = Promise.all([ + runRpcOperation(failing, workspaceListOrThrow, {}), + runRpcOperation(stalled, workspaceListOrThrow, {}) + ]) + const raced = await Promise.race([ + group.then( + () => 'resolved' as const, + (caught: unknown) => caught + ), + new Promise((resolve) => setTimeout(() => resolve('still waiting'), 50)) + ]) + + expect(raced).toBe(error) + }) + + it('only captures a rejection when a caller names the all-settled helper', async () => { + const error = new Error('socket closed') + const session = new FakeSession('connected') + session.sendRequest.mockRejectedValue(error) + + await expect(captureRpcOperationSettlement(session, workspaceListOrThrow, {})).resolves.toEqual( + { status: 'rejected', error } + ) + }) +}) + +describe('the send path', () => { + it('carries the worktree.ps capability stamp, because it goes through the logical client', async () => { + const session = connectedSession(rpcSuccess(rows)) + const logical = createStableLogicalRpcClient(session, 'lan') + + await runRpcOperation(logical, workspaceListOrThrow, { limit: 500 }) + + expect(session.sendRequest).toHaveBeenCalledWith( + 'worktree.ps', + { limit: 500, supportsWorktreeVisibilitySourceDefaults: true }, + undefined + ) + }) + + it('sends the caller params object untouched for a method with no projection', async () => { + const session = connectedSession(rpcSuccess({ terminals: [] })) + const logical = createStableLogicalRpcClient(session, 'lan') + const params = { worktree: 'w1' } + + await captureRpcOperationSettlement(logical, terminalListAtBarrier, params, { + timeoutMs: 1234 + }) + + expect(session.sendRequest.mock.calls[0][0]).toBe('terminal.list') + expect(session.sendRequest.mock.calls[0][1]).toBe(params) + expect(session.sendRequest.mock.calls[0][2]).toEqual({ timeoutMs: 1234 }) + }) +}) + +describe('acceptance is a property of the family', () => { + const refusal = rpcRefusal('method_not_found', 'no such method') + + it('surfaces the refusal as a coded error for the throwing family', async () => { + await expect( + runRpcOperation(connectedSession(refusal), workspaceListOrThrow, {}) + ).rejects.toThrow('method_not_found: no such method') + }) + + it('answers null to the same refusal for the nullable family', async () => { + await expect( + runRpcOperation(connectedSession(refusal), workspaceListOrNull, {}) + ).resolves.toBeNull() + }) + + it('answers true to the same refusal for the capability probe', async () => { + await expect(runRpcOperation(connectedSession(refusal), worktreePsProbe, {})).resolves.toBe( + true + ) + }) + + it('keeps the probe false for another refusal code and for a success', async () => { + await expect( + runRpcOperation(connectedSession(rpcRefusal('runtime_error')), worktreePsProbe, {}) + ).resolves.toBe(false) + await expect( + runRpcOperation(connectedSession(rpcSuccess(rows)), worktreePsProbe, {}) + ).resolves.toBe(false) + }) + + it('returns the decoded value for the throwing family', async () => { + await expect( + runRpcOperation(connectedSession(rpcSuccess(rows)), workspaceListOrThrow, {}) + ).resolves.toEqual(rows) + }) + + it('returns the reply itself only when it opened a stream', async () => { + const opener = rpcSuccess({ subscriptionId: 's1' }, true) + await expect( + runRpcOperation(connectedSession(opener), terminalStreamOpener, { terminal: 't1' }) + ).resolves.toBe(opener) + await expect( + runRpcOperation( + connectedSession(rpcSuccess({ subscriptionId: 's1' })), + terminalStreamOpener, + { + terminal: 't1' + } + ) + ).resolves.toBeNull() + await expect( + runRpcOperation(connectedSession(rpcRefusal('runtime_error')), terminalStreamOpener, { + terminal: 't1' + }) + ).resolves.toBeNull() + }) +}) + +describe('an incompatible reply', () => { + const incompatible = rpcSuccess({ worktrees: [{ id: 7 }] }) + + it('throws a named incompatible-reply error for the throwing family', async () => { + const caught = await runRpcOperation( + connectedSession(incompatible), + workspaceListOrThrow, + {} + ).catch((thrown: unknown) => thrown) + + expect(caught).toBeInstanceOf(RpcIncompatibleReplyError) + expect(isRpcIncompatibleReplyError(caught)).toBe(true) + expect((caught as RpcIncompatibleReplyError).method).toBe('worktree.ps') + expect((caught as RpcIncompatibleReplyError).operationName).toBe('test.workspaceListOrThrow') + expect((caught as RpcIncompatibleReplyError).issues.length).toBeGreaterThan(0) + }) + + // A reply nobody can read says nothing about what the host did, so it must not look like + // either of the two errors the mutation retry loops replay on. + it('authorizes no retry', async () => { + const caught = await runRpcOperation( + connectedSession(incompatible), + workspaceListOrThrow, + {} + ).catch((thrown: unknown) => thrown) + + expect(isRpcDeliveryUnknown(caught)).toBe(false) + expect(isLogicalClientCutoverError(caught)).toBe(false) + }) + + it('answers null for the nullable family', async () => { + await expect( + runRpcOperation(connectedSession(incompatible), workspaceListOrNull, {}) + ).resolves.toBeNull() + }) +}) + +describe('a descriptor', () => { + it('cannot have its policy or barrier swapped at runtime', () => { + expect(Object.isFrozen(workspaceListOrThrow)).toBe(true) + expect(() => { + ;(workspaceListOrThrow as { acceptance: string }).acceptance = 'object-result-or-null' + }).toThrow(TypeError) + expect(() => { + ;(workspaceListOrThrow as { barrier: string }).barrier = 'after-all-requests' + }).toThrow(TypeError) + }) +}) diff --git a/mobile/src/transport/rpc-operation.ts b/mobile/src/transport/rpc-operation.ts new file mode 100644 index 00000000000..def072dac81 --- /dev/null +++ b/mobile/src/transport/rpc-operation.ts @@ -0,0 +1,282 @@ +import type { UnvalidatedRpcRequestPort, SendRequestOptions } from './unvalidated-rpc-request-port' +import type { RpcMethodName, RpcSendParams } from './rpc-params-contract' +import type { RpcResponse } from './types' +import { + isMethodNotFoundRefusal, + isStreamingOpenerReply, + requireRpcResultOrThrowCodedError, + rpcObjectResultOrNull +} from './rpc-acceptance-policies' +import { RpcIncompatibleReplyError } from './rpc-incompatible-reply-error' +import type { + AnyRpcOperation, + CapabilityProbeRpcDefinition, + ObjectResultRpcDefinition, + RpcAcceptanceName, + RpcCompatibleReader, + RpcDecodeIssue, + RpcInterpretationBarrier, + RpcOperation, + RpcOperationSettlement, + RpcRequestOutcome, + RpcSalvageReport, + RequireResultRpcDefinition, + StreamOpenerRpcDefinition, + RpcVerdict +} from './rpc-operation-contract' + +const NOTHING_SALVAGED: RpcSalvageReport = { droppedPaths: [], droppedCount: 0 } + +type RpcOperationDefinitionInput = + | RequireResultRpcDefinition + | ObjectResultRpcDefinition + | CapabilityProbeRpcDefinition + | StreamOpenerRpcDefinition + +export function defineRpcOperation< + Method extends RpcMethodName, + Variant extends string, + Value, + Barrier extends RpcInterpretationBarrier +>( + definition: RequireResultRpcDefinition +): RpcOperation +export function defineRpcOperation< + Method extends RpcMethodName, + Variant extends string, + Value, + Barrier extends RpcInterpretationBarrier +>( + definition: ObjectResultRpcDefinition +): RpcOperation +export function defineRpcOperation< + Method extends RpcMethodName, + Barrier extends RpcInterpretationBarrier +>( + definition: CapabilityProbeRpcDefinition +): RpcOperation +export function defineRpcOperation< + Method extends RpcMethodName, + Barrier extends RpcInterpretationBarrier +>( + definition: StreamOpenerRpcDefinition +): RpcOperation +export function defineRpcOperation(definition: RpcOperationDefinitionInput): AnyRpcOperation { + // Why: frozen so no call site can swap the policy or the barrier on a shared descriptor. + return Object.freeze({ + name: definition.name, + method: definition.method, + acceptance: definition.acceptance, + barrier: definition.barrier, + // Why: classifyReply only ever hands a reader the payload its own policy admitted, so + // the object policy's narrower parameter is sound to store as unknown. + read: definition.read as RpcCompatibleReader | undefined + }) +} + +/** Sends the operation without interpreting it; transport rejection stays on the promise. */ +async function request( + client: UnvalidatedRpcRequestPort, + operation: AnyRpcOperation, + params: unknown, + options?: SendRequestOptions +): Promise> { + // Why: no try/catch here. A transport failure must reach the caller as the original error + // object — isLogicalClientCutoverError and isRpcDeliveryUnknown both die on a wrapper — + // and an always-settled send would make Promise.all wait for a peer where today the group + // fails immediately, letting a later policy surface a different error. + const response = await client.sendRequest(operation.method, params, options) + return classifyReply(operation, response) +} + +type AdmittedPayload = + | { readonly admitted: true; readonly value: unknown } + | { readonly admitted: false; readonly issues: readonly RpcDecodeIssue[] } + +// The payload the operation's own acceptance policy admits from a fulfilled success. +function admitPayload(operation: AnyRpcOperation, response: RpcResponse): AdmittedPayload { + switch (operation.acceptance) { + case 'object-result-or-null': { + const object = rpcObjectResultOrNull(response) + return object === null + ? { admitted: false, issues: [{ path: 'result', message: 'not a non-null object' }] } + : { admitted: true, value: object } + } + case 'streaming-opener': + return isStreamingOpenerReply(response) + ? { admitted: true, value: response } + : { admitted: false, issues: [{ path: 'streaming', message: 'reply opened no stream' }] } + default: + // Reuses the policy rather than reading `.result` again; a success never throws here. + return { admitted: true, value: requireRpcResultOrThrowCodedError(response) } + } +} + +const READERLESS_VARIANTS: Record = { + 'method-not-found-refusal': 'accepted', + 'streaming-opener': 'stream-opened' +} + +function classifyReply( + operation: AnyRpcOperation, + response: RpcResponse +): RpcRequestOutcome { + if (!response.ok) { + return { kind: 'outer-refused', error: response.error, raw: response } + } + const payload = admitPayload(operation, response) + if (!payload.admitted) { + return { kind: 'incompatible', raw: response, issues: payload.issues } + } + const read = operation.read + if (!read) { + return { + kind: 'decoded', + variant: READERLESS_VARIANTS[operation.acceptance] ?? 'accepted', + value: payload.value, + raw: response, + salvage: NOTHING_SALVAGED + } + } + let result: ReturnType + try { + result = read(payload.value) + } catch (error) { + // A reader that throws is an incompatible reply, never a transport failure. + return { + kind: 'incompatible', + raw: response, + issues: [{ path: '', message: error instanceof Error ? error.message : String(error) }] + } + } + if (!result.compatible) { + return { kind: 'incompatible', raw: response, issues: result.issues } + } + return { + kind: 'decoded', + variant: result.variant, + value: result.value, + raw: response, + salvage: result.salvage + } +} + +// Applies the operation's declared acceptance policy. Private on purpose: there is no +// free-standing callOrThrow, so no call site can pick a different rule for the same reply. +function interpret( + operation: AnyRpcOperation, + settled: RpcRequestOutcome +): unknown { + const acceptance: RpcAcceptanceName = operation.acceptance + switch (acceptance) { + case 'require-result-or-throw': + if (settled.kind === 'outer-refused') { + // Reuses the policy so the thrown `code: message` text cannot drift from main's. + return requireRpcResultOrThrowCodedError(settled.raw) + } + if (settled.kind === 'incompatible') { + throw new RpcIncompatibleReplyError(operation.name, operation.method, settled.issues) + } + return settled.value + case 'object-result-or-null': + return settled.kind === 'decoded' ? settled.value : null + case 'method-not-found-refusal': + return settled.kind === 'outer-refused' ? isMethodNotFoundRefusal(settled.raw) : false + case 'streaming-opener': + return settled.kind === 'decoded' && isStreamingOpenerReply(settled.raw) ? settled.raw : null + } +} + +function interpretSettlement( + operation: AnyRpcOperation, + settlement: RpcOperationSettlement +): unknown { + if (settlement.status === 'rejected') { + // Why: rethrow the original object — isRpcDeliveryUnknown is a WeakSet on identity and + // isLogicalClientCutoverError matches class or exact message; a wrapper loses both. + throw settlement.error + } + return interpret(operation, settlement.outcome) +} + +/** Sends and interprets at the operation's own barrier. Only for barrier 'on-settle'. */ +export async function runRpcOperation< + Method extends RpcMethodName, + Acceptance extends RpcAcceptanceName, + Variant extends string, + Value +>( + client: UnvalidatedRpcRequestPort, + operation: RpcOperation, + params: RpcSendParams, + options?: SendRequestOptions +): Promise> { + const outcome = await request(client, operation, params, options) + return interpret(operation, outcome) as RpcVerdict +} + +/** The named opt-in to all-settled semantics. Yields an outcome, never a verdict: the + * verdict still comes only from the declared policy, at the declared barrier. */ +export async function captureRpcOperationSettlement< + Method extends RpcMethodName, + Acceptance extends RpcAcceptanceName, + Variant extends string, + Value, + Barrier extends RpcInterpretationBarrier +>( + client: UnvalidatedRpcRequestPort, + operation: RpcOperation, + params: RpcSendParams, + options?: SendRequestOptions +): Promise> { + try { + const outcome = await request(client, operation, params, options) + return { status: 'fulfilled', outcome: outcome as RpcRequestOutcome } + } catch (error) { + return { status: 'rejected', error } + } +} + +export type PendingRpcOperation = { + readonly operation: Op + readonly settlement: Promise> +} + +/** Starts a request whose interpretation is deferred to the barrier it declared. */ +export function startRpcOperation< + Method extends RpcMethodName, + Acceptance extends RpcAcceptanceName, + Variant extends string, + Value +>( + client: UnvalidatedRpcRequestPort, + operation: RpcOperation, + params: RpcSendParams, + options?: SendRequestOptions +): PendingRpcOperation> { + return { + operation, + settlement: captureRpcOperationSettlement(client, operation, params, options) + } +} + +type RpcBarrierVerdicts[]> = { + [Index in keyof Pending]: Pending[Index] extends PendingRpcOperation< + RpcOperation + > + ? RpcVerdict + : never +} + +/** Awaits every raw request, then interprets in declared order. */ +export async function interpretAtRpcBarrier< + Pending extends readonly PendingRpcOperation[] +>(pending: Pending): Promise> { + // Why: interpreting as each request lands would let whichever peer failed first decide the + // error the user sees and how long the screen spins. Declared order makes that a property + // of the definition instead of a race. + const settlements = await Promise.all(pending.map((entry) => entry.settlement)) + return pending.map((entry, index) => + interpretSettlement(entry.operation, settlements[index]) + ) as RpcBarrierVerdicts +} diff --git a/mobile/src/transport/rpc-params-contract.ts b/mobile/src/transport/rpc-params-contract.ts index fcf3303e965..883a921c079 100644 --- a/mobile/src/transport/rpc-params-contract.ts +++ b/mobile/src/transport/rpc-params-contract.ts @@ -2,7 +2,11 @@ // The schemas behind these types must never reach the bundle: requiredString is // z.unknown().transform(...), so a client-side parse coerces a non-string to '' // instead of rejecting it, silently changing the bytes on the wire. +// +// RpcSendParams is the outgoing type; RpcParams is the shape the handler sees after +// parsing, which is not what a sender may write (see rpc-send-params.ts). export type { RpcMethodName, RpcParams } from '../../../src/shared/rpc-contract/rpc-params-catalog.generated' +export type { RpcSendParams } from '../../../src/shared/rpc-contract/rpc-send-params' diff --git a/mobile/src/transport/unvalidated-rpc-request-port-boundary.test.ts b/mobile/src/transport/unvalidated-rpc-request-port-boundary.test.ts new file mode 100644 index 00000000000..422b2925689 --- /dev/null +++ b/mobile/src/transport/unvalidated-rpc-request-port-boundary.test.ts @@ -0,0 +1,267 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { extname, join, relative, resolve } from 'node:path' +import ts from 'typescript' +import { describe, expect, it } from 'vitest' +import { + UNVALIDATED_RPC_REQUEST_PORT_OWNERS, + UNVALIDATED_RPC_REQUEST_PORT_PENDING, + type UnvalidatedRpcRequestPortEntry +} from './unvalidated-rpc-request-port-inventory' + +/** + * Ratchet for the raw RPC request port. + * + * `sendRequest` takes an unchecked method string and returns an envelope whose `result` is + * `unknown`. Every screen that reaches it re-decides acceptance and decoding for itself, which + * is the drift the RpcOperation contract exists to end. The port cannot be made unreachable by + * the type system today: `RpcClient` structurally carries it, and ~190 files hold a client. So + * the boundary is held as an inventory instead, and this test is what makes the inventory bind. + * + * Three failures, all of which mean "edit the list": + * - a file reaches the port and is on neither list, + * - a listed file no longer reaches it (stale entry — how allow-lists rot), + * - a listed file's reference count went up. + * + * What this does NOT catch, all accepted: + * - Reach laundered through a function type. A listed file can hand `client.sendRequest` to an + * unlisted one as a bare `(method: string) => Promise` and the receiver never + * names the port. Only two senders are named here; a third wrapper needs adding by hand. + * - Computed access — `client['send' + 'Request']` is not a literal in the AST. + * - Which method a listed file sends, or what it does with the reply. The count is a ceiling + * on how many times it reaches, nothing more. + * - Test files. `*.test.ts(x)` is not scanned: faking the port is how these suites work, and a + * test does not ship. A non-test file that fakes it (tsconfig excludes tests, so some do) is + * scanned and listed. + * A compile-time fence would catch the first two. That needs `RpcClient` to stop carrying the + * port, which needs the call sites migrated first — the thing this list is counting down. + */ + +const mobileRoot = fileURLToPath(new URL('../..', import.meta.url)) +const scannedRoots = ['app', 'src'].map((directory) => join(mobileRoot, directory)) +const sourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx']) +const portModule = join(mobileRoot, 'src', 'transport', 'unvalidated-rpc-request-port') + +/** The port and its own inventory are not offenders; the ratchet does not police itself. */ +const SELF_FILES = new Set([ + 'src/transport/unvalidated-rpc-request-port.ts', + 'src/transport/unvalidated-rpc-request-port-inventory.ts' +]) + +/** The coalescing second sender: same unchecked string in, same unread envelope out. */ +const SECOND_SENDER = 'sendSingleFlightRequest' + +function sourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name) + if (entry.isDirectory()) { + return entry.name === 'node_modules' ? [] : sourceFiles(path) + } + return [path] + }) +} + +function parse(path: string, source: string): ts.SourceFile { + const extension = extname(path) + return ts.createSourceFile( + path, + source, + ts.ScriptTarget.Latest, + true, + extension === '.tsx' || extension === '.jsx' ? ts.ScriptKind.TSX : ts.ScriptKind.TS + ) +} + +function targetsPortModule(path: string, node: ts.Node | undefined): boolean { + if (!node || !ts.isStringLiteral(node) || !node.text.startsWith('.')) { + return false + } + return resolve(path, '..', node.text) === portModule +} + +/** `client['sendRequest']` is one reach, not two: the element access already counted it. */ +function isCountedElementAccessArgument(node: ts.Node): boolean { + const parent: ts.Node | undefined = node.parent + return ( + parent !== undefined && + ts.isElementAccessExpression(parent) && + parent.argumentExpression === node + ) +} + +function declaresPortMember(node: ts.Node): boolean { + if ( + !ts.isPropertySignature(node) && + !ts.isMethodSignature(node) && + !ts.isMethodDeclaration(node) && + !ts.isPropertyDeclaration(node) && + !ts.isPropertyAssignment(node) && + !ts.isShorthandPropertyAssignment(node) + ) { + return false + } + const name = node.name + return (ts.isIdentifier(name) || ts.isStringLiteral(name)) && name.text === 'sendRequest' +} + +/** How many times this file reaches the raw port directly. Comments never count: this is AST. */ +export function rawRequestPortReferences(path: string, source: string): number { + let references = 0 + const visit = (node: ts.Node): void => { + if ( + (ts.isPropertyAccessExpression(node) && node.name.text === 'sendRequest') || + (ts.isElementAccessExpression(node) && + ts.isStringLiteral(node.argumentExpression) && + node.argumentExpression.text === 'sendRequest') || + declaresPortMember(node) || + (ts.isStringLiteral(node) && + node.text === 'sendRequest' && + !isCountedElementAccessArgument(node)) || + (ts.isIdentifier(node) && node.text === SECOND_SENDER) + ) { + references += 1 + } + if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { + references += targetsPortModule(path, node.moduleSpecifier) ? 1 : 0 + } + if ( + ts.isCallExpression(node) && + (node.expression.kind === ts.SyntaxKind.ImportKeyword || + (ts.isIdentifier(node.expression) && node.expression.text === 'require')) && + targetsPortModule(path, node.arguments[0]) + ) { + references += 1 + } + ts.forEachChild(node, visit) + } + visit(parse(path, source)) + return references +} + +const inventory: readonly UnvalidatedRpcRequestPortEntry[] = [ + ...UNVALIDATED_RPC_REQUEST_PORT_OWNERS, + ...UNVALIDATED_RPC_REQUEST_PORT_PENDING +] + +const scanned = scannedRoots + .flatMap(sourceFiles) + .filter((path) => sourceExtensions.has(extname(path))) + .filter((path) => !/\.test\.tsx?$/.test(path)) + .map((path) => relative(mobileRoot, path).split(/[/\\]/).join('/')) + .filter((file) => !SELF_FILES.has(file)) + +const observed = new Map( + scanned + .map( + (file) => + [ + file, + rawRequestPortReferences( + join(mobileRoot, file), + readFileSync(join(mobileRoot, file), 'utf8') + ) + ] as const + ) + .filter(([, references]) => references > 0) +) + +describe('unvalidated RPC request port boundary', () => { + const probe = join(mobileRoot, 'src', 'transport', 'probe.ts') + + it('counts every shape that reaches the port', () => { + expect(rawRequestPortReferences(probe, 'await client.sendRequest("worktree.ps", {})')).toBe(1) + expect(rawRequestPortReferences(probe, 'const send = client.sendRequest')).toBe(1) + expect(rawRequestPortReferences(probe, 'client["sendRequest"]("x")')).toBe(1) + expect(rawRequestPortReferences(probe, "type A = Pick")).toBe(1) + expect(rawRequestPortReferences(probe, "type A = RpcClient['sendRequest']")).toBe(1) + expect(rawRequestPortReferences(probe, 'const c = { sendRequest: async () => reply }')).toBe(1) + expect(rawRequestPortReferences(probe, 'interface C { sendRequest(m: string): void }')).toBe(1) + expect(rawRequestPortReferences(probe, "if (name === 'sendRequest') { }")).toBe(1) + expect( + rawRequestPortReferences(probe, 'await sendSingleFlightRequest(c, h, "worktree.ps")') + ).toBe(1) + expect( + rawRequestPortReferences( + probe, + "import { sendSingleFlightRequest } from './request-single-flight'" + ) + ).toBe(1) + expect( + rawRequestPortReferences( + probe, + "import type { UnvalidatedRpcRequestPort } from './unvalidated-rpc-request-port'" + ) + ).toBe(1) + expect( + rawRequestPortReferences( + probe, + "export type { SendRequestOptions } from './unvalidated-rpc-request-port'" + ) + ).toBe(1) + expect( + rawRequestPortReferences(probe, "const m = await import('./unvalidated-rpc-request-port')") + ).toBe(1) + expect(rawRequestPortReferences(probe, 'a.sendRequest(1); b.sendRequest(2)')).toBe(2) + }) + + it('does not count prose or an unrelated sender', () => { + expect(rawRequestPortReferences(probe, '// calls sendRequest under the hood')).toBe(0) + expect(rawRequestPortReferences(probe, '/* sendRequest */ export const x = 1')).toBe(0) + expect(rawRequestPortReferences(probe, 'await client.subscribe("terminal.stream", {})')).toBe(0) + expect(rawRequestPortReferences(probe, "import type { RpcClient } from './rpc-client'")).toBe(0) + expect(rawRequestPortReferences(probe, 'await runRpcOperation(client, op, {})')).toBe(0) + }) + + it('scans a plausible number of files', () => { + // A broken root or extension filter would make every check below vacuously pass. + expect(scanned.length).toBeGreaterThan(400) + expect(observed.size).toBeGreaterThan(50) + }) + + it('lists each file once', () => { + const seen = inventory.map((entry) => entry.file) + expect(seen.filter((file, index) => seen.indexOf(file) !== index)).toEqual([]) + }) + + it('has no unlisted file reaching the raw request port', () => { + const listed = new Set(inventory.map((entry) => entry.file)) + const unlisted = [...observed.keys()].filter((file) => !listed.has(file)) + expect( + unlisted, + 'New code must send through an RpcOperation. Nothing may be added to unvalidated-rpc-request-port-inventory.ts.' + ).toEqual([]) + }) + + it('has no stale inventory entry', () => { + const stale = inventory.filter((entry) => !observed.has(entry.file)) + expect( + stale.map((entry) => entry.file), + 'File no longer reaches the raw port — delete its line from unvalidated-rpc-request-port-inventory.ts.' + ).toEqual([]) + }) + + it('has no inventory entry whose file gained references', () => { + const grown = inventory + .filter((entry) => (observed.get(entry.file) ?? 0) > entry.references) + .map( + (entry) => `${entry.file}: listed ${entry.references}, found ${observed.get(entry.file)}` + ) + expect(grown, 'The counts are a ceiling. Send the new call through an RpcOperation.').toEqual( + [] + ) + }) + + it('reports a count that has fallen so the entry can be lowered', () => { + const overstated = inventory + .filter( + (entry) => observed.has(entry.file) && (observed.get(entry.file) ?? 0) < entry.references + ) + .map( + (entry) => `${entry.file}: listed ${entry.references}, found ${observed.get(entry.file)}` + ) + expect( + overstated, + 'Fewer references than listed — lower the count so the ratchet holds.' + ).toEqual([]) + }) +}) diff --git a/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts b/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts new file mode 100644 index 00000000000..82317f272fc --- /dev/null +++ b/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts @@ -0,0 +1,229 @@ +/** + * Every file that still reaches mobile's raw RPC request port, held as data. + * + * A reference is any direct reach for the port: a `.sendRequest` access or declaration, a + * `'sendRequest'` selector such as `Pick`, a call to the coalescing + * second sender `sendSingleFlightRequest`, or an import of unvalidated-rpc-request-port.ts. + * The count is per file and is a ceiling, not a target: unvalidated-rpc-request-port-boundary.test.ts + * fails on a file that is not listed, on a listed file that no longer reaches the port, and on a + * listed file whose count went up. Both lists only shrink. + * + * The owners are permanent — they implement, route or validate the port. The pending list is the + * step-4 migration backlog and shares one reason, stated once here instead of 144 times: + * the call site predates the typed contract and still picks its own method string, its own + * acceptance rule and its own decoding. Replacing one with an RpcOperation deletes its line. + */ +export type UnvalidatedRpcRequestPortEntry = { + readonly file: string + readonly references: number +} + +/** Modules whose job is the port. These do not shrink to zero. */ +export const UNVALIDATED_RPC_REQUEST_PORT_OWNERS: readonly UnvalidatedRpcRequestPortEntry[] = [ + // Implements the port over the device-to-host websocket. + { file: 'src/transport/direct-rpc-client.ts', references: 3 }, + // Fakes the port for the supervisor suites; a non-test file only because tsconfig excludes tests. + { file: 'src/transport/mobile-endpoint-supervisor-test-fakes.ts', references: 2 }, + // Implements the port over a relay channel. + { file: 'src/transport/mobile-relay-physical-client.ts', references: 2 }, + // Supplies the port for one relay session. + { file: 'src/transport/mobile-relay-rpc-session.ts', references: 1 }, + // A second raw sender: string method in, unread envelope out. Its callers are fenced too. + { file: 'src/transport/request-single-flight.ts', references: 3 }, + // Owns connect-wait, timeout and replay bookkeeping for every raw request. + { file: 'src/transport/rpc-client-request-tracker.ts', references: 1 }, + // Composes the port into RpcClient, which is why every holder of a client still carries it. + { file: 'src/transport/rpc-client.ts', references: 2 }, + // The typed boundary itself — the one module that turns a reply into a declared type. + { file: 'src/transport/rpc-operation.ts', references: 2 }, + // Forwards the port across a physical-client cutover. + { file: 'src/transport/stable-logical-rpc-client.ts', references: 2 } +] + +/** Call sites awaiting migration to a typed operation. Grouped by the feature area that owns them. */ +export const UNVALIDATED_RPC_REQUEST_PORT_PENDING: readonly UnvalidatedRpcRequestPortEntry[] = [ + // app/h/[hostId]/ — Expo route screens + { file: 'app/h/[hostId]/accounts.tsx', references: 2 }, + + // app/ — Expo route screens + { file: 'app/terminal-settings.tsx', references: 3 }, + + // src/agent-history/ — agent history loads + { file: 'src/agent-history/MobileAgentSessionHistoryPanel.tsx', references: 7 }, + { file: 'src/agent-history/use-mobile-agent-history-state.ts', references: 2 }, + + // src/browser/ — hosted browser control + { file: 'src/browser/use-mobile-browser-commands.ts', references: 5 }, + { file: 'src/browser/use-mobile-browser-request.ts', references: 1 }, + + // src/components/ — shared widgets that fetch their own data + { file: 'src/components/codex-reset-credit-capability.ts', references: 2 }, + { file: 'src/components/codex-reset-credit.ts', references: 3 }, + { file: 'src/components/use-new-workspace-create-submit.ts', references: 1 }, + { file: 'src/components/use-new-workspace-execution-target.ts', references: 4 }, + { file: 'src/components/use-new-workspace-repositories.ts', references: 1 }, + { file: 'src/components/use-new-workspace-runtime-context.ts', references: 4 }, + { file: 'src/components/use-new-workspace-setup-script.ts', references: 1 }, + + // src/dictation/ — dictation session control + { file: 'src/dictation/mobile-dictation-setup.ts', references: 10 }, + + // src/files/ — file read, write and preview + { file: 'src/files/mobile-file-mutation-ownership.ts', references: 3 }, + { file: 'src/files/mobile-file-preview-request.ts', references: 6 }, + { file: 'src/files/mobile-file-tab-doc.ts', references: 4 }, + { file: 'src/files/mobile-terminal-artifact-grant-refresh.ts', references: 2 }, + { file: 'src/files/MobileFileExplorerPanel.tsx', references: 2 }, + + // src/home/ — home screen host reads + { file: 'src/home/mobile-home-host-requests.ts', references: 6 }, + + // src/hooks/ — cross-screen data hooks + { file: 'src/hooks/mobile-dictation-audio-chunk.ts', references: 1 }, + { file: 'src/hooks/mobile-dictation-desktop-start.ts', references: 4 }, + { file: 'src/hooks/use-mobile-dictation.ts', references: 4 }, + + // src/host-screen/ — host screen catalog and actions + { file: 'src/host-screen/host-screen-overlays.tsx', references: 1 }, + { file: 'src/host-screen/use-host-repo-metadata.ts', references: 2 }, + { file: 'src/host-screen/use-host-view-settings.ts', references: 2 }, + { file: 'src/host-screen/use-host-worktree-actions.ts', references: 3 }, + + // src/notifications/ — push registration and delivery + { file: 'src/notifications/mobile-notifications.ts', references: 1 }, + { file: 'src/notifications/push-dismissal-reconciliation.ts', references: 2 }, + { file: 'src/notifications/push-registration.ts', references: 3 }, + + // src/session/ — session screen: chat, diff review, PR actions, tabs + { file: 'src/session/ai-vault-resume-launch.ts', references: 3 }, + { file: 'src/session/ai-vault-resume-preparation.ts', references: 2 }, + { file: 'src/session/github-pr-mutations.ts', references: 16 }, + { file: 'src/session/github-pr-rpc.ts', references: 9 }, + { file: 'src/session/mobile-clipboard-image.ts', references: 7 }, + { file: 'src/session/mobile-diff-review-loaders.ts', references: 5 }, + { file: 'src/session/mobile-file-tap-open.ts', references: 3 }, + { file: 'src/session/mobile-image-attachment.ts', references: 2 }, + { file: 'src/session/mobile-native-chat-image-attachment.ts', references: 1 }, + { file: 'src/session/mobile-native-chat-image-send.ts', references: 2 }, + { file: 'src/session/mobile-native-chat-send.ts', references: 2 }, + { file: 'src/session/mobile-native-chat-session-option-persistence.ts', references: 1 }, + { file: 'src/session/mobile-native-chat-stale-input.ts', references: 1 }, + { file: 'src/session/mobile-new-tab-agent-loader.ts', references: 5 }, + { file: 'src/session/mobile-session-tab-activation.ts', references: 3 }, + { file: 'src/session/mobile-session-tabs-stream-health.ts', references: 1 }, + { file: 'src/session/mobile-structured-agent-session-launch.ts', references: 3 }, + { file: 'src/session/mobile-structured-agent-session-rpc.ts', references: 1 }, + { file: 'src/session/pr-ai-triage-launch.ts', references: 3 }, + { file: 'src/session/use-live-worktree-name.ts', references: 1 }, + { file: 'src/session/use-mobile-diff-review-comment-actions.ts', references: 1 }, + { file: 'src/session/use-mobile-diff-review-git-actions.ts', references: 2 }, + { file: 'src/session/use-mobile-diff-review-interactions.ts', references: 1 }, + { file: 'src/session/use-mobile-diff-review-send-actions.ts', references: 3 }, + { file: 'src/session/use-mobile-file-tap-handlers.ts', references: 1 }, + { file: 'src/session/use-mobile-native-chat-file-search.ts', references: 2 }, + { file: 'src/session/use-mobile-native-chat-readability.ts', references: 1 }, + { file: 'src/session/use-mobile-native-chat-session.ts', references: 1 }, + { file: 'src/session/use-mobile-native-chat-stop.ts', references: 1 }, + { file: 'src/session/use-mobile-pr-actions.ts', references: 1 }, + { file: 'src/session/use-mobile-pr-branch-context.ts', references: 2 }, + { file: 'src/session/use-mobile-pr-comment-actions.ts', references: 1 }, + { file: 'src/session/use-mobile-pr-title-action.ts', references: 1 }, + { file: 'src/session/use-mobile-session-accessory-selection.ts', references: 1 }, + { file: 'src/session/use-mobile-session-close-actions.ts', references: 3 }, + { file: 'src/session/use-mobile-session-content-create-actions.ts', references: 4 }, + { file: 'src/session/use-mobile-session-diff-comments.ts', references: 2 }, + { file: 'src/session/use-mobile-session-document-readers.ts', references: 2 }, + { file: 'src/session/use-mobile-session-markdown-actions.ts', references: 1 }, + { file: 'src/session/use-mobile-session-startup.ts', references: 2 }, + { file: 'src/session/use-mobile-session-terminal-create-actions.ts', references: 2 }, + { file: 'src/session/use-mobile-session-terminal-input.ts', references: 2 }, + { file: 'src/session/use-mobile-session-terminal-list.ts', references: 1 }, + { file: 'src/session/use-mobile-session-terminal-send-actions.ts', references: 2 }, + { file: 'src/session/use-mobile-session-terminal-stream-display.ts', references: 1 }, + { file: 'src/session/use-mobile-terminal-paste.ts', references: 1 }, + { file: 'src/session/use-pr-bot-author-overrides.ts', references: 1 }, + { file: 'src/session/use-quick-commands.ts', references: 2 }, + + // src/settings/ — settings screen actions + { file: 'src/settings/native-voice-settings-operations.ts', references: 1 }, + + // src/settings/ — notification display probe + { file: 'src/settings/notification-display-test.tsx', references: 1 }, + + // src/source-control/ — source control: review, commit, branch + { file: 'src/source-control/mobile-branch-base-ref.ts', references: 3 }, + { file: 'src/source-control/mobile-commit-message-ai.ts', references: 4 }, + { file: 'src/source-control/mobile-git-history.ts', references: 2 }, + { file: 'src/source-control/mobile-hosted-review-create-intent-runner.ts', references: 1 }, + { file: 'src/source-control/mobile-hosted-review-create-intent.ts', references: 3 }, + { file: 'src/source-control/mobile-hosted-review-git-preparation.ts', references: 6 }, + { file: 'src/source-control/mobile-hosted-review-remote-prerequisite.ts', references: 1 }, + { file: 'src/source-control/mobile-hosted-review-service.ts', references: 8 }, + { file: 'src/source-control/mobile-pr-link.ts', references: 8 }, + { file: 'src/source-control/MobileGitHistoryList.tsx', references: 1 }, + { file: 'src/source-control/reveal-mobile-source-control-session-diff.ts', references: 2 }, + { file: 'src/source-control/use-mobile-git-requests.ts', references: 1 }, + { file: 'src/source-control/use-mobile-source-control-loaders.ts', references: 2 }, + { file: 'src/source-control/use-mobile-source-control-openers.ts', references: 3 }, + + // src/tasks/ — task lists, filters and mutations + { file: 'src/tasks/composer-source-base-resolve.ts', references: 2 }, + { file: 'src/tasks/mobile-tasks-filter-pickers.tsx', references: 1 }, + { file: 'src/tasks/mobile-tasks-source-family.test-support.ts', references: 1 }, + { file: 'src/tasks/setup-hook-trust.ts', references: 1 }, + { file: 'src/tasks/smart-source-paste-intent.ts', references: 4 }, + { file: 'src/tasks/smart-source-search-requests.ts', references: 5 }, + { file: 'src/tasks/use-mobile-tasks-client-settings-actions.tsx', references: 6 }, + { file: 'src/tasks/use-mobile-tasks-github-check-file-actions.tsx', references: 5 }, + { file: 'src/tasks/use-mobile-tasks-github-reply-merge-actions.tsx', references: 5 }, + { file: 'src/tasks/use-mobile-tasks-gitlab-github-status-actions.tsx', references: 3 }, + { file: 'src/tasks/use-mobile-tasks-hosted-comment-review-actions.tsx', references: 4 }, + { file: 'src/tasks/use-mobile-tasks-hosted-metadata-actions.tsx', references: 2 }, + { file: 'src/tasks/use-mobile-tasks-item-detail-loading.tsx', references: 4 }, + { file: 'src/tasks/use-mobile-tasks-item-detail-metadata-effects.tsx', references: 2 }, + { file: 'src/tasks/use-mobile-tasks-linear-item-actions.tsx', references: 3 }, + { file: 'src/tasks/use-mobile-tasks-list-and-detail-effects.tsx', references: 2 }, + { file: 'src/tasks/use-mobile-tasks-project-detail-loading.tsx', references: 1 }, + { file: 'src/tasks/use-mobile-tasks-project-file-merge-actions.tsx', references: 4 }, + { file: 'src/tasks/use-mobile-tasks-project-loading-actions.tsx', references: 4 }, + { file: 'src/tasks/use-mobile-tasks-project-metadata-actions.tsx', references: 3 }, + { file: 'src/tasks/use-mobile-tasks-project-metadata-loading.tsx', references: 3 }, + { file: 'src/tasks/use-mobile-tasks-project-repository-resolution.tsx', references: 1 }, + { file: 'src/tasks/use-mobile-tasks-project-review-check-actions.tsx', references: 4 }, + { file: 'src/tasks/use-mobile-tasks-project-thread-reply-actions.tsx', references: 4 }, + { file: 'src/tasks/use-mobile-tasks-project-workspace-comment-actions.tsx', references: 3 }, + { file: 'src/tasks/use-mobile-tasks-provider-load-actions.tsx', references: 5 }, + { file: 'src/tasks/use-mobile-tasks-route-and-item-state.tsx', references: 1 }, + { file: 'src/tasks/use-mobile-tasks-runtime-hydration.tsx', references: 5 }, + { file: 'src/tasks/use-mobile-tasks-task-create-actions.tsx', references: 3 }, + { file: 'src/tasks/use-mobile-tasks-task-list-loading.tsx', references: 4 }, + { file: 'src/tasks/use-mobile-tasks-task-pagination-actions.tsx', references: 1 }, + { file: 'src/tasks/use-mobile-tasks-workspace-create-actions.tsx', references: 4 }, + { file: 'src/tasks/use-mobile-tasks-workspace-source-effects.tsx', references: 2 }, + { file: 'src/tasks/use-mobile-tasks-workspace-sparse-actions.tsx', references: 2 }, + { file: 'src/tasks/use-mobile-tasks-workspace-ssh-state.tsx', references: 5 }, + { file: 'src/tasks/worktree-create-capability.ts', references: 1 }, + { file: 'src/tasks/worktree-create-retry.ts', references: 1 }, + + // src/terminal/ — terminal input, viewport and queries + { file: 'src/terminal/mobile-terminal-query-reply.ts', references: 2 }, + { file: 'src/terminal/terminal-live-accessory-raw-send.ts', references: 2 }, + { file: 'src/terminal/terminal-viewport-refit.ts', references: 1 }, + { file: 'src/terminal/worker-terminal-takeover-report.ts', references: 2 }, + + // src/transport/ — pairing, endpoint probing and capability reads + { file: 'src/transport/host-status-gates.ts', references: 1 }, + { file: 'src/transport/mobile-relay-credential-rotation.ts', references: 2 }, + { file: 'src/transport/mobile-relay-direct-upgrade.ts', references: 2 }, + { file: 'src/transport/mobile-relay-pairing-recovery.ts', references: 2 }, + { file: 'src/transport/mobile-runtime-capability-negotiation.ts', references: 2 }, + { file: 'src/transport/pairing-candidate-race.ts', references: 1 }, + { file: 'src/transport/pairing-relay-candidate.ts', references: 4 }, + { file: 'src/transport/pre-profile-pairing-coordinator.ts', references: 2 }, + { file: 'src/transport/runtime-capability-probe.ts', references: 2 }, + + // src/worktree/ — worktree activation and resume + { file: 'src/worktree/home-host-worktree-fetch.ts', references: 2 }, + { file: 'src/worktree/use-retired-worktree-names.ts', references: 1 }, + { file: 'src/worktree/worktree-catalog-snapshot-client.ts', references: 1 } +] diff --git a/mobile/src/transport/unvalidated-rpc-request-port.ts b/mobile/src/transport/unvalidated-rpc-request-port.ts new file mode 100644 index 00000000000..b626b290da6 --- /dev/null +++ b/mobile/src/transport/unvalidated-rpc-request-port.ts @@ -0,0 +1,31 @@ +import type { RpcResponse } from './types' + +// The raw request port, kept in its own module so that reaching it is a visible act. +// +// Nothing on this path is checked against the host contract: `method` is an unconstrained +// string, `params` is `unknown`, and the reply's `result` stays `unknown`. A value that came +// back through here has been parsed as JSON and nothing more, so it is NOT validated and must +// not be annotated as though it were. The typed boundary — defineRpcOperation and the send +// helpers in rpc-operation.ts — is the only path that turns a reply into a declared type, and +// rpc-operation.ts is the only module here that should be importing this one for that purpose. +// +// Every other file that still reaches this port is inventoried in +// unvalidated-rpc-request-port-inventory.ts and fenced by +// unvalidated-rpc-request-port-boundary.test.ts. That list only shrinks. + +export type SendRequestOptions = { + timeoutMs?: number + /** Include the connect wait in the caller's timeout budget. */ + budgetSpansConnect?: boolean + /** Reject instead of replaying the request after reconnect. */ + failWhenDisconnected?: boolean +} + +/** Unvalidated: an arbitrary method name in, an unread envelope out. */ +export type UnvalidatedRpcRequestPort = { + sendRequest: ( + method: string, + params?: unknown, + options?: SendRequestOptions + ) => Promise +} diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 6adf1851d2f..57ed4a5b5a5 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -1159,9 +1159,10 @@ export const RPC_METHODS_WITHOUT_SHARED_PARAMS: readonly string[] = [ export type RpcMethodName = keyof typeof RPC_PARAMS_BY_METHOD -// Why: z.output is the post-parse shape the handler receives. z.input is not a -// send-side type here — requiredString is z.unknown().transform(...), so its input -// admits any value and loses optional/default semantics. +// Why: z.output is the post-parse shape the handler receives, which is not what a +// client may send — a .default() field reads as required. z.input is not the answer +// either: requiredString is z.unknown().transform(...), so its input admits any value. +// Senders use RpcSendParams from ./rpc-send-params, which is derived from this map. export type RpcParams = (typeof RPC_PARAMS_BY_METHOD)[Method] extends z.ZodType ? z.output<(typeof RPC_PARAMS_BY_METHOD)[Method]> diff --git a/src/shared/rpc-contract/rpc-send-params.ts b/src/shared/rpc-contract/rpc-send-params.ts new file mode 100644 index 00000000000..fcb6d66a359 --- /dev/null +++ b/src/shared/rpc-contract/rpc-send-params.ts @@ -0,0 +1,69 @@ +import type { z } from 'zod' +import type { RPC_PARAMS_BY_METHOD, RpcMethodName } from './rpc-params-catalog.generated' + +// Why this exists: neither of zod's two inferred types describes an outgoing request. +// z.output is what the handler receives *after* parsing, so a `.default(x)` field reads as +// required and a sender that legitimately omits it fails to typecheck. z.input is worse here +// — the params builders parse with z.unknown() so a hostile client cannot crash the +// dispatcher, which collapses every requiredString/OptionalString field to `unknown`. +// +// So take each channel where it is honest: key optionality from zod's own `optin` marker +// (the z.input rule, which is the one that understands .default and .optional), and value +// types from z.output (the post-coercion contract the builders declare in their pipe target). +// Derived from the generated catalog, so it cannot drift from the dispatcher. +// +// Type-level only. Never import the schema *values* into a client: requiredString is +// z.unknown().transform(...), so a client-side parse coerces a non-string to '' instead of +// rejecting it, silently changing the bytes on the wire. + +type Prettify = { [K in keyof T]: T[K] } & {} + +/** zod's own input-side key-optionality rule, copied from $InferObjectInput. */ +type SendOptionalSchema = { _zod: { optin: 'optional' | 'defaulted' } } + +type SendShape = Prettify< + { + -readonly [K in keyof Shape as Shape[K] extends SendOptionalSchema ? never : K]: RpcSendInput< + Shape[K] + > + } & { + -readonly [K in keyof Shape as Shape[K] extends SendOptionalSchema ? K : never]?: RpcSendInput< + Shape[K] + > + } +> + +/** + * The value a sender may put on the wire for one schema. Wrappers not listed here (record, + * tuple, lazy, intersection) fall through to z.output, which is what shipped before. + */ +export type RpcSendInput = + Schema extends z.ZodOptional + ? RpcSendInput | undefined + : Schema extends z.ZodDefault + ? RpcSendInput | undefined + : Schema extends z.ZodPrefault + ? RpcSendInput | undefined + : Schema extends z.ZodNullable + ? RpcSendInput | null + : Schema extends z.ZodArray + ? RpcSendInput[] + : // ZodObject is the only schema carrying a `shape`, and matching on it keeps + // .strict()/.extend()/.superRefine() results in this branch. + Schema extends { shape: infer Shape } + ? keyof Shape extends never + ? // Mirrors $InferObjectOutput: a no-field object admits no properties. + Record + : SendShape + : // ZodDiscriminatedUnion extends ZodUnion, so both land here. + Schema extends z.ZodUnion + ? RpcSendInput + : Schema extends z.ZodType + ? z.output + : never + +/** The params a client may send for `Method`; `void` for the methods that take none. */ +export type RpcSendParams = + (typeof RPC_PARAMS_BY_METHOD)[Method] extends z.ZodType + ? RpcSendInput<(typeof RPC_PARAMS_BY_METHOD)[Method]> + : void From ef6eeab26e4a2ba817b19e8d68b37546f58d293e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:13:05 -0700 Subject: [PATCH 036/191] fix(native-chat): shrink skill pill text (#20254) Co-authored-by: Merge Sim --- .../components/native-chat/NativeChatPromptEditor.test.tsx | 5 ++++- .../src/components/native-chat/NativeChatSkillPill.tsx | 4 ++-- .../components/native-chat/native-chat-prompt-document.ts | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatPromptEditor.test.tsx b/src/renderer/src/components/native-chat/NativeChatPromptEditor.test.tsx index 3934cf7be23..78d8da92530 100644 --- a/src/renderer/src/components/native-chat/NativeChatPromptEditor.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPromptEditor.test.tsx @@ -34,7 +34,10 @@ describe('native chat skill editor', () => { it('renders only picker insertions as pills and serializes the exact invocation', () => { const { input, container } = setup('Please $rev') act(() => input.insertSkill!(7, 11, '$review')) - expect(container.querySelector('[data-native-chat-skill]')?.textContent).toBe('Review') + const pill = container.querySelector('[data-native-chat-skill]') + expect(pill?.textContent).toBe('Review') + expect(pill?.classList.contains('text-xs')).toBe(true) + expect(pill?.classList.contains('text-sm')).toBe(false) expect(input.value).toBe('Please $review ') expect(input.selectionStart).toBe(15) act(() => { diff --git a/src/renderer/src/components/native-chat/NativeChatSkillPill.tsx b/src/renderer/src/components/native-chat/NativeChatSkillPill.tsx index fee86dd0f98..9bde1a5a1c9 100644 --- a/src/renderer/src/components/native-chat/NativeChatSkillPill.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSkillPill.tsx @@ -18,9 +18,9 @@ export function NativeChatSkillPill({ node, selected }: NodeViewProps): React.JS - + {skillLabel(token)} diff --git a/src/renderer/src/components/native-chat/native-chat-prompt-document.ts b/src/renderer/src/components/native-chat/native-chat-prompt-document.ts index 8bfce7ed0b0..7ed5fd8c130 100644 --- a/src/renderer/src/components/native-chat/native-chat-prompt-document.ts +++ b/src/renderer/src/components/native-chat/native-chat-prompt-document.ts @@ -17,7 +17,7 @@ export const NativeChatSkill = Node.create({ 'data-native-chat-skill': node.attrs.token, contenteditable: 'false', class: - 'inline-flex items-center gap-1 rounded-full border border-border bg-muted px-1.5 text-sm font-medium text-muted-foreground align-baseline select-none' + 'inline-flex items-center gap-1 rounded-full border border-border bg-muted px-1.5 text-xs font-medium text-muted-foreground align-baseline select-none' }, ['span', { 'aria-hidden': 'true' }, 'ϟ'], ['span', {}, String(node.attrs.token).slice(1)] From a05e2139d6529cc3aea3d1fd5f2fc5323d7fa3d9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:45:45 -0700 Subject: [PATCH 037/191] fix(claude): refuse a structured model the provider does not list (#19946) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(claude): refuse a structured model the provider does not list setClaudeStructuredOption applied a model to a live Claude structured session with no check that the provider lists it, while pre-flighting `effort` against the same catalog a few lines above. Measured on Claude Code 2.1.260: set_model resolves for an unlisted id, list_models never gains a row for it, and every later turn returns is_error with empty modelUsage and zero tokens — a session that looks alive and produces nothing. Nothing undoes the write, so the refusal has to precede it. Two paths reach it: restore replays a stored pick the provider may since have retired, which needs no user error at all, and any caller can send an arbitrary id mid-session. An absent, failed or empty list deliberately refuses nothing, mirroring the null rule the effort guard already applies: no catalog identifies no model, and a CLI predating list_models would otherwise have every model refused under it — silently, since restore swallows the rejection into restoreSkippedOptions. * refactor(claude): keep the model pre-flight's permissive case in the authority claudeCatalogAdmitsModel now answers the question outright instead of handing back a nullable id set the caller had to interpret. The rule that an unidentified catalog refuses nothing lives inside the function, so a second caller cannot get it wrong by omission — and getting it wrong is silent, because restore swallows the rejection into restoreSkippedOptions. The refusal message names the model the user asked for, since it reaches them as the chat error row. --------- Co-authored-by: Merge Sim --- .../claude-structured-model-preflight.test.ts | 130 ++++++++++++++++++ .../claude/claude-structured-options.test.ts | 7 +- src/main/claude/claude-structured-options.ts | 8 ++ ...e-structured-session-adapter-turns.test.ts | 5 +- .../claude-structured-session-options.ts | 22 +++ 5 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 src/main/claude/claude-structured-model-preflight.test.ts diff --git a/src/main/claude/claude-structured-model-preflight.test.ts b/src/main/claude/claude-structured-model-preflight.test.ts new file mode 100644 index 00000000000..e4f5d00b196 --- /dev/null +++ b/src/main/claude/claude-structured-model-preflight.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' +import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' +import { + restoreClaudeStructuredSessionOptions, + setClaudeStructuredOption +} from './claude-structured-options' +import type { ClaudeSession } from './claude-structured-session-state' + +/** Verbatim row shapes from Claude Code 2.1.260's list_models response. */ +const DEFAULT_ROW = { value: 'default', resolvedModel: 'claude-opus-5', displayName: 'Default' } +const SONNET = { value: 'sonnet', resolvedModel: 'claude-sonnet-5', displayName: 'Sonnet' } +const HAIKU = { + value: 'haiku', + resolvedModel: 'claude-haiku-4-5-20251001', + displayName: 'Haiku' +} + +function sessionWith(catalog: readonly Record[] | 'unavailable') { + const calls: string[] = [] + return { + session: { + options: new Map(), + reportedOptions: {} as { model?: string; effort?: string }, + optionMutationSequence: 0, + reportedModelMutation: 0, + confirmedOptions: new Set(), + restoreSkippedOptions: new Set(), + connection: { + supportedModels: async () => { + calls.push('list_models') + if (catalog === 'unavailable') { + throw new Error('this CLI predates list_models') + } + return [...catalog] + }, + setModel: async (model: string) => { + calls.push(`set_model:${model}`) + } + } + } as unknown as ClaudeSession, + calls + } +} + +describe('Claude model pre-flight against the catalog the CLI listed', () => { + it('refuses a model the provider does not list', async () => { + const { session, calls } = sessionWith([DEFAULT_ROW, SONNET, HAIKU]) + + await expect( + setClaudeStructuredOption(session, { key: 'model', value: 'not-a-real-model-xyz' }, undefined) + ).rejects.toBeInstanceOf(AgentSessionOptionRejectedError) + // Measured on Claude Code 2.1.260: set_model resolves for an unlisted id and + // every later turn returns is_error with zero tokens. Nothing undoes the + // write, so the refusal has to land before it. + expect(calls).toEqual(['list_models']) + expect(session.options.has('model')).toBe(false) + }) + + it('refuses an unlisted model replayed by restore, and skips it', async () => { + // Needs no user error: a model valid when it was persisted can be retired. + const { session, calls } = sessionWith([DEFAULT_ROW, SONNET]) + session.options.set('model', 'claude-opus-4-retired') + + await restoreClaudeStructuredSessionOptions(session, undefined) + + expect(calls).toEqual(['list_models']) + expect(session.options.has('model')).toBe(false) + expect([...session.restoreSkippedOptions]).toEqual(['model']) + }) + + it('applies a model the provider lists', async () => { + const { session, calls } = sessionWith([DEFAULT_ROW, SONNET, HAIKU]) + + await expect( + setClaudeStructuredOption(session, { key: 'model', value: 'haiku' }, undefined) + ).resolves.toEqual({ model: 'haiku' }) + expect(calls).toEqual(['list_models', 'set_model:haiku']) + }) + + it('applies a resolved model id the catalog carries only under its alias', async () => { + const { session, calls } = sessionWith([DEFAULT_ROW, SONNET]) + + await expect( + setClaudeStructuredOption(session, { key: 'model', value: 'claude-sonnet-5' }, undefined) + ).resolves.toEqual({ model: 'claude-sonnet-5' }) + expect(calls).toEqual(['list_models', 'set_model:claude-sonnet-5']) + }) + + it('refuses nothing when list_models is unavailable', async () => { + // A CLI predating list_models would otherwise have every model refused, and + // restore swallows the rejection, so the user's pick would vanish silently. + const { session, calls } = sessionWith('unavailable') + + await expect( + setClaudeStructuredOption(session, { key: 'model', value: 'sonnet' }, undefined) + ).resolves.toEqual({ model: 'sonnet' }) + expect(calls).toEqual(['list_models', 'set_model:sonnet']) + }) + + it('refuses nothing when the listed catalog is empty', async () => { + // An empty answer identifies no model, so it is not evidence against one. + const { session, calls } = sessionWith([]) + + await expect( + setClaudeStructuredOption(session, { key: 'model', value: 'sonnet' }, undefined) + ).resolves.toEqual({ model: 'sonnet' }) + expect(calls).toEqual(['list_models', 'set_model:sonnet']) + }) + + it('refuses nothing when the catalog carries only the synthetic default row', async () => { + // listedModels drops that row, leaving a list that identifies no model. + const { session, calls } = sessionWith([DEFAULT_ROW]) + + await expect( + setClaudeStructuredOption(session, { key: 'model', value: 'sonnet' }, undefined) + ).resolves.toEqual({ model: 'sonnet' }) + expect(calls).toEqual(['list_models', 'set_model:sonnet']) + }) + + it('leaves a restored model the provider lists in place', async () => { + const { session, calls } = sessionWith([DEFAULT_ROW, SONNET]) + session.options.set('model', 'sonnet') + + await restoreClaudeStructuredSessionOptions(session, undefined) + + expect(calls).toEqual(['list_models', 'set_model:sonnet']) + expect(session.options.get('model')).toBe('sonnet') + expect([...session.restoreSkippedOptions]).toEqual([]) + }) +}) diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts index 2375df12d93..1bfae10b592 100644 --- a/src/main/claude/claude-structured-options.test.ts +++ b/src/main/claude/claude-structured-options.test.ts @@ -6,7 +6,12 @@ import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSession { return { - connection: { setModel } as ClaudeSession['connection'], + // An empty catalog identifies no model, so the pre-flight refuses nothing and + // this stays a test about fencing. + connection: { + setModel, + supportedModels: async (): Promise => [] + } as ClaudeSession['connection'], providerSessionId: 'provider-session', claudeConfigDir: '/accounts/claude', leafUuid: null, diff --git a/src/main/claude/claude-structured-options.ts b/src/main/claude/claude-structured-options.ts index 3d1377b12c6..7f607755563 100644 --- a/src/main/claude/claude-structured-options.ts +++ b/src/main/claude/claude-structured-options.ts @@ -5,6 +5,7 @@ import { isAgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' import { + claudeCatalogAdmitsModel, readClaudeCurrentModel, readClaudeModelEffortLevels, readClaudeSettingsEffort @@ -66,6 +67,13 @@ export async function setClaudeStructuredOption( ) } } + // set_model resolves for a model the provider never lists and the session then + // fails every turn with zero tokens, so the acceptance proves nothing and only + // the catalog does. Restore replays a pick the provider may since have retired, + // which reaches here with no user error at all. + if (input.key === 'model' && !(await claudeCatalogAdmitsModel(session, input.value, timeoutMs))) { + throw new AgentSessionOptionRejectedError(`claude does not list a model named ${input.value}`) + } const modelWasConfirmed = readClaudeCurrentModel(session).confirmed const mutationSequence = ++session.optionMutationSequence // Only a model write can stale the model report — an effort or permission-mode diff --git a/src/main/claude/claude-structured-session-adapter-turns.test.ts b/src/main/claude/claude-structured-session-adapter-turns.test.ts index fc5eaa6b66e..00248b09f12 100644 --- a/src/main/claude/claude-structured-session-adapter-turns.test.ts +++ b/src/main/claude/claude-structured-session-adapter-turns.test.ts @@ -80,8 +80,11 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => { await expect( adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'sonnet', fence: 7 }) ).resolves.toEqual({ model: 'sonnet' }) - expect(claude.connections[0].calls.slice(-2)).toEqual([ + // The model write pre-flights the catalog first; this CLI lists nothing, which + // identifies no model and so refuses none. + expect(claude.connections[0].calls.slice(-3)).toEqual([ { subtype: 'interrupt', params: {} }, + { subtype: 'list_models' }, { subtype: 'set_model', params: { model: 'sonnet' } } ]) diff --git a/src/main/claude/claude-structured-session-options.ts b/src/main/claude/claude-structured-session-options.ts index afb4fd65076..2385f362c2a 100644 --- a/src/main/claude/claude-structured-session-options.ts +++ b/src/main/claude/claude-structured-session-options.ts @@ -149,6 +149,28 @@ export async function readClaudeModelEffortLevels( } } +/** + * Whether the catalog admits the model, matched by alias or resolved id so a pick + * stored as either one is found. The permissive case lives here rather than at the + * call site: every caller must treat an unidentified catalog the same way, and one + * that forgot would refuse every model on a CLI that cannot answer. + */ +export async function claudeCatalogAdmitsModel( + session: ClaudeSession, + modelId: string, + timeoutMs: number | undefined +): Promise { + const catalog = await session.connection.supportedModels({ timeoutMs }).catch(() => null) + const models = listedModels(catalog ? { models: catalog } : null) + // An empty list identifies no model, so it is not evidence against one — a live + // CLI predating `list_models` would otherwise have every model refused under it. + // Do not turn this into a refusal. + return ( + models.length === 0 || + models.some((model) => model.id === modelId || model.resolvedModel === modelId) + ) +} + export async function readClaudeStructuredSessionOptions( session: ClaudeSession, timeoutMs: number | undefined From 719c4341a2de717945c217fe89d60bb35cb44630 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 12 Sep 2026 07:01:46 +0000 Subject: [PATCH 038/191] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index dce3559fd10..68c938d4238 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 48m + + downloads: 49m @@ -15,7 +15,7 @@ downloads downloads - 48m - 48m + 49m + 49m From 923858e0981667216826788ab7156294dfc5c755 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 12 Sep 2026 00:52:05 -0700 Subject: [PATCH 039/191] perf: defer closed menus to speed up cold worktree switching (#20311) --- .../tab-bar/tab-bar-static-create-menu.tsx | 2 +- .../components/tab-bar/tab-bar-surface.tsx | 43 +- .../CloseTerminalDialog.test.tsx | 21 + .../terminal-pane/CloseTerminalDialog.tsx | 161 ++++--- .../TerminalContextMenu.test.tsx | 22 +- .../terminal-pane/TerminalContextMenu.tsx | 418 +++++++++--------- 6 files changed, 369 insertions(+), 298 deletions(-) diff --git a/src/renderer/src/components/tab-bar/tab-bar-static-create-menu.tsx b/src/renderer/src/components/tab-bar/tab-bar-static-create-menu.tsx index 9a7a19a9186..45c39c2da55 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-static-create-menu.tsx +++ b/src/renderer/src/components/tab-bar/tab-bar-static-create-menu.tsx @@ -13,7 +13,7 @@ import { import type { TabBarProps } from './tab-bar-props' import { resolveWindowsShellLaunchTarget } from './windows-shell-launch' -export function renderTabBarStaticCreateMenu({ +export function TabBarStaticCreateMenu({ terminalOnly, mobileEmulatorEnabled, managedBrowserCreationEnabled, diff --git a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx index dcf22611227..0e9540870e3 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx +++ b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx @@ -22,7 +22,7 @@ import type { TabBarCreateMenuController } from './use-tab-bar-create-menu-contr import type { TabBarItemProjection } from './use-tab-bar-item-projection' import type { TabBarItem } from './tab-bar-item-model' import { renderTabBarItems } from './tab-bar-item-surface' -import { renderTabBarStaticCreateMenu } from './tab-bar-static-create-menu' +import { TabBarStaticCreateMenu } from './tab-bar-static-create-menu' import ClientHostedBrowserTabRows from './ClientHostedBrowserTabRows' import type { ClientHostedBrowserRow } from '../../../../shared/client-hosted-browser-rows' @@ -99,24 +99,6 @@ export function renderTabBarSurface({ activeClientHostedBrowserRowId, togglePinned }) - const standardCreateMenuItems = renderTabBarStaticCreateMenu({ - props, - terminalOnly, - mobileEmulatorEnabled, - managedBrowserCreationEnabled, - mobileEmulatorCreationEnabled, - workspaceHasSimulatorTab, - showMobileEmulatorIntroCallout, - windowsShellEntries, - defaultWindowsPowerShellImplementation, - pwshAvailable: windowsTerminalCapabilities.pwshAvailable, - newTerminalShortcut, - newBrowserShortcut, - newSimulatorShortcut, - newFileShortcut, - openMarkdownShortcut, - queueNewActiveTerminalFocusAfterNewTabMenuClose - }) return (
: null} ) : null} - {showStaticCreateMenuItems ? standardCreateMenuItems : null} + {showStaticCreateMenuItems ? ( + + ) : null} {showStaticCreateMenuItems && showAgentLaunchItems ? ( <> diff --git a/src/renderer/src/components/terminal-pane/CloseTerminalDialog.test.tsx b/src/renderer/src/components/terminal-pane/CloseTerminalDialog.test.tsx index d2b8efd3efa..0531720fe47 100644 --- a/src/renderer/src/components/terminal-pane/CloseTerminalDialog.test.tsx +++ b/src/renderer/src/components/terminal-pane/CloseTerminalDialog.test.tsx @@ -4,6 +4,11 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' import CloseTerminalDialog from './CloseTerminalDialog' +import { translate } from '@/i18n/i18n' + +vi.mock('@/i18n/i18n', () => ({ + translate: vi.fn((_key: string, fallback: string) => fallback) +})) const mountedRoots: Root[] = [] @@ -49,6 +54,22 @@ describe('CloseTerminalDialog', () => { document.body.innerHTML = '' }) + it('does no dialog-copy work while closed, then builds the opened confirmation', async () => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + mountedRoots.push(root) + const props = { onCancel: vi.fn(), onConfirm: vi.fn() } + vi.mocked(translate).mockClear() + + await act(async () => root.render()) + expect(translate).not.toHaveBeenCalled() + + await act(async () => root.render()) + expect(document.body.textContent).toContain('Stop running command?') + expect(translate).toHaveBeenCalled() + }) + it('renders running command copy and confirms without skipping by default', async () => { const onConfirm = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/CloseTerminalDialog.tsx b/src/renderer/src/components/terminal-pane/CloseTerminalDialog.tsx index c4da5244457..9edbca8b22e 100644 --- a/src/renderer/src/components/terminal-pane/CloseTerminalDialog.tsx +++ b/src/renderer/src/components/terminal-pane/CloseTerminalDialog.tsx @@ -70,71 +70,104 @@ export default function CloseTerminalDialog({ }} > - - - {isAgent - ? translate( - 'auto.components.terminal.pane.CloseTerminalDialog.stop_agent_title', - 'Stop this agent?' - ) - : translate( - 'auto.components.terminal.pane.CloseTerminalDialog.stop_command_title', - 'Stop running command?' - )} - - - {isAgent - ? translate( - 'auto.components.terminal.pane.CloseTerminalDialog.stop_agent_description', - "Closing this terminal will stop the agent's current work." - ) - : translate( - 'auto.components.terminal.pane.CloseTerminalDialog.stop_command_description', - 'Closing this terminal will stop the command running inside it.' - )} - - - {trimmedTabLabel ? ( -

- {trimmedTabLabel} -

- ) : null} -
- setDontAskAgain(checked === true)} - /> - -
- - - - +
) } + +// Keep translation and element construction behind the dialog portal's mount boundary. +function CloseTerminalDialogBody({ + isAgent, + trimmedTabLabel, + checkboxId, + dontAskAgain, + setDontAskAgain, + onCancel, + onConfirm +}: { + isAgent: boolean + trimmedTabLabel: string | undefined + checkboxId: string + dontAskAgain: boolean + setDontAskAgain: (value: boolean) => void + onCancel: () => void + onConfirm: (dontAskAgain: boolean) => void +}): React.JSX.Element { + return ( + <> + + + {isAgent + ? translate( + 'auto.components.terminal.pane.CloseTerminalDialog.stop_agent_title', + 'Stop this agent?' + ) + : translate( + 'auto.components.terminal.pane.CloseTerminalDialog.stop_command_title', + 'Stop running command?' + )} + + + {isAgent + ? translate( + 'auto.components.terminal.pane.CloseTerminalDialog.stop_agent_description', + "Closing this terminal will stop the agent's current work." + ) + : translate( + 'auto.components.terminal.pane.CloseTerminalDialog.stop_command_description', + 'Closing this terminal will stop the command running inside it.' + )} + + + {trimmedTabLabel ? ( +

+ {trimmedTabLabel} +

+ ) : null} +
+ setDontAskAgain(checked === true)} + /> + +
+ + + + + + ) +} diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx index 58a39f28a50..bd25abcce74 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx @@ -2,6 +2,7 @@ import React from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import TerminalContextMenu from './TerminalContextMenu' +import { translate } from '@/i18n/i18n' import type { KeybindingOverrides } from '../../../../shared/keybindings' type ItemProps = { onSelect?: () => void; children?: React.ReactNode } @@ -13,9 +14,12 @@ vi.mock('@/components/ui/dropdown-menu', async () => { const React_ = await import('react') const passthrough = ({ children }: { children?: React.ReactNode }) => React_.createElement(React_.Fragment, null, children) + const OpenContext = React_.createContext(false) return { - DropdownMenu: passthrough, - DropdownMenuContent: passthrough, + DropdownMenu: ({ open, children }: { open: boolean; children?: React.ReactNode }) => + React_.createElement(OpenContext.Provider, { value: open }, children), + DropdownMenuContent: ({ children }: { children?: React.ReactNode }) => + React_.useContext(OpenContext) ? passthrough({ children }) : null, DropdownMenuLabel: passthrough, DropdownMenuSeparator: () => null, DropdownMenuShortcut: ({ children }: { children?: React.ReactNode }) => { @@ -36,7 +40,7 @@ vi.mock('@/components/ui/dropdown-menu', async () => { } } }) -vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/i18n/i18n', () => ({ translate: vi.fn((_key: string, fallback: string) => fallback) })) vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => null })) vi.mock('./terminal-context-menu-dismiss', () => ({ shouldIgnoreTerminalMenuPointerDownOutside: () => false @@ -104,6 +108,7 @@ function renderMenu(overrides: Record = {}): string { describe('TerminalContextMenu', () => { beforeEach(() => { + vi.mocked(translate).mockClear() items.list = [] shortcuts.list = [] vi.stubGlobal('navigator', { userAgent: 'Linux' }) @@ -113,6 +118,16 @@ describe('TerminalContextMenu', () => { vi.unstubAllGlobals() }) + it('does no menu-copy work while closed, then builds the opened menu', () => { + renderMenu({ open: false }) + expect(translate).not.toHaveBeenCalled() + expect(items.list).toHaveLength(0) + + renderMenu() + expect(translate).toHaveBeenCalled() + expect(items.list.length).toBeGreaterThan(0) + }) + it('renders a "Copy Context" item that triggers onCopyAgentSessionContext (issue #5020)', () => { const onCopyAgentSessionContext = vi.fn() const onForkAgentSession = vi.fn() @@ -167,6 +182,7 @@ describe('TerminalContextMenu', () => { item?.onSelect?.() expect(onCopyAgentSessionId).toHaveBeenCalledTimes(1) + vi.mocked(translate).mockClear() items.list = [] renderMenu({ canCopyAgentSessionId: false }) expect( diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx index 2cc76cd7164..5236d61d1a6 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx @@ -78,11 +78,59 @@ type TerminalContextMenuProps = { onCopyAgentSessionId: () => void } -export default function TerminalContextMenu({ - open, +export default function TerminalContextMenu(props: TerminalContextMenuProps): React.JSX.Element { + const { open, onOpenChange, menuPoint, menuOpenedAtRef } = props + return ( + { + if (!nextOpen && Date.now() - menuOpenedAtRef.current < 100) { + return + } + onOpenChange(nextOpen) + }} + modal={false} + > + + {state === 'starred' && menuOpen && ( -
+