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 001/140] 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 002/140] 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 003/140] 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 004/140] 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 005/140] 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 006/140] 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 - 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 007/140] 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 008/140] 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 009/140] 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 && ( -
+