From 47b6c756f0b5fb0c8110ca60250eb34c43a21741 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:26:14 -0400 Subject: [PATCH 01/17] fix: prompt unexpectedly signed-out Cloud users once per version (#19966) * fix: prompt unexpectedly signed-out Cloud users once per version * fix: align sign-in card English catalog with runtime defaults * fix: stack notification cards by their rendered height * fix: wait for fresh auth before showing signout card * fix: require verified auth before signout recovery * fix: retry transient auth readiness failures --- .../profile-cloud-auth-status.test.ts | 106 +++++++ .../runtime/rpc/methods/client-ui-schemas.ts | 1 + .../runtime/rpc/methods/client-ui.test.ts | 2 + .../src/app-shell/AppRootSurfaces.tsx | 27 +- .../src/components/NotificationCardStack.tsx | 9 + src/renderer/src/components/StarNagCard.tsx | 17 +- .../src/components/UnexpectedSignoutCard.tsx | 269 ++++++++++++++++++ .../components/UpdateCard.error-card.test.tsx | 7 +- src/renderer/src/components/UpdateCard.tsx | 5 +- .../unexpected-signout-card.test.tsx | 152 ++++++++++ .../unexpected-signout-visibility.test.ts | 150 ++++++++++ .../unexpected-signout-visibility.ts | 22 ++ src/renderer/src/i18n/locales/en.json | 15 + .../store/slices/ui-notice-dismissals.test.ts | 78 +++++ .../ui/ui-slice-contract-preferences.ts | 4 + .../slices/ui/ui-slice-hydration-actions.ts | 2 + .../ui/ui-slice-hydration-sanitizers.ts | 13 + .../slices/ui/ui-slice-update-actions.ts | 12 + src/shared/constants.ts | 1 + src/shared/persisted-ui-state-types.ts | 2 + 20 files changed, 866 insertions(+), 28 deletions(-) create mode 100644 src/main/orca-profiles/profile-cloud-auth-status.test.ts create mode 100644 src/renderer/src/components/NotificationCardStack.tsx create mode 100644 src/renderer/src/components/UnexpectedSignoutCard.tsx create mode 100644 src/renderer/src/components/unexpected-signout/unexpected-signout-card.test.tsx create mode 100644 src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.test.ts create mode 100644 src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.ts diff --git a/src/main/orca-profiles/profile-cloud-auth-status.test.ts b/src/main/orca-profiles/profile-cloud-auth-status.test.ts new file mode 100644 index 00000000000..7a28783e9a9 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-auth-status.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ActiveOrcaProfileState } from './profile-index-store' +import type { OrcaCloudSessionReadResult } from './profile-cloud-session-store' +import { getOrcaProfileAuthStatusFromProfile } from './profile-cloud-auth-status' + +const { readSession, configuration } = vi.hoisted(() => ({ + readSession: vi.fn<() => OrcaCloudSessionReadResult>(), + configuration: { configured: true } +})) + +vi.mock('./profile-cloud-session-store', () => ({ readOrcaCloudSession: readSession })) +vi.mock('./profile-cloud-auth-config', () => ({ + getOrcaCloudAuthConfig: () => configuration, + isOrcaCloudDevAuthEnabled: () => false +})) + +function activeProfile(linked: boolean): ActiveOrcaProfileState { + const profile: ActiveOrcaProfileState['profile'] = { + id: 'profile-1', + name: 'Personal', + avatar: { kind: 'initials', initials: 'P', color: 'neutral' }, + kind: linked ? 'cloud-linked' : 'local', + createdAt: 0, + updatedAt: 0, + lastOpenedAt: 0, + ...(linked + ? { + cloud: { + cloudProfileId: 'cloud-1', + userId: 'user-1', + email: 'a@example.com', + linkedAt: 0 + } + } + : {}) + } + return { + profile, + index: { schemaVersion: 1, activeProfileId: profile.id, profiles: [profile] }, + dataFile: '', + profileDirectory: '' + } +} + +const absentSessions: OrcaCloudSessionReadResult[] = [ + { status: 'missing', persistence: 'none' }, + { status: 'decrypt-failed', persistence: 'none', error: 'Cannot decrypt' }, + { status: 'unreadable', persistence: 'none', error: 'Permission denied' } +] + +describe('unexpected sign-out auth evidence', () => { + beforeEach(() => { + readSession.mockReset() + configuration.configured = true + }) + + it.each(absentSessions)('requires a preserved cloud link for $status credentials', (session) => { + readSession.mockReturnValue(session) + const linked = activeProfile(true) + expect(getOrcaProfileAuthStatusFromProfile(linked, '')).toMatchObject({ + state: 'reconnect-required', + cloud: linked.profile.cloud, + persistence: 'none', + credentialError: 'error' in session ? session.error : undefined + }) + readSession.mockClear() + const signedOut = getOrcaProfileAuthStatusFromProfile(activeProfile(false), '') + expect(signedOut.state).toBe('local') + expect(signedOut.cloud).toBeUndefined() + expect(readSession).not.toHaveBeenCalled() + }) + + it.each(absentSessions)( + 'keeps unconfigured linked profiles out of reconnect for $status', + (session) => { + configuration.configured = false + readSession.mockReturnValue(session) + expect(getOrcaProfileAuthStatusFromProfile(activeProfile(true), '').state).toBe( + 'unconfigured' + ) + expect(getOrcaProfileAuthStatusFromProfile(activeProfile(false), '').state).toBe( + 'unconfigured' + ) + } + ) + + it('treats a live memory-only session as connected, then reconnects after its loss', () => { + readSession.mockReturnValue({ + status: 'found', + persistence: 'memory-only', + session: { + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: Date.now() + 60_000, + capabilities: { flags: {}, refreshedAt: 0 } + } + }) + const linked = activeProfile(true) + expect(getOrcaProfileAuthStatusFromProfile(linked, '')).toMatchObject({ + state: 'connected', + persistence: 'memory-only' + }) + readSession.mockReturnValue({ status: 'missing', persistence: 'none' }) + expect(getOrcaProfileAuthStatusFromProfile(linked, '').state).toBe('reconnect-required') + }) +}) diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index 943d0081fdf..32e62e105d7 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -171,6 +171,7 @@ const UiUpdateFields = z usagePercentageDisplay: z.enum(['used', 'remaining']).optional(), statusBarUsageMode: z.enum(['verbose', 'compact']).optional(), dismissedUpdateVersion: NullableString.optional(), + dismissedUnexpectedSignoutVersion: NullableString.optional(), lastUpdateCheckAt: z.number().finite().nullable().optional(), pendingUpdateNudgeId: NullableString.optional(), dismissedUpdateNudgeId: NullableString.optional(), diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 39048611155..3a26b1c615d 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -607,6 +607,8 @@ describe('client UI RPC methods', () => { ], ['taskResumeState.jiraPreset', { taskResumeState: { jiraPreset: 'assigned' } }], ['taskResumeState.jiraQuery', { taskResumeState: { jiraQuery: 'ENG' } }], + ['dismissedUnexpectedSignoutVersion', { dismissedUnexpectedSignoutVersion: '1.2.3' }], + ['dismissedUnexpectedSignoutVersion null', { dismissedUnexpectedSignoutVersion: null }], ['activeView', { activeView: 'tasks' }], ['showDotfilesByWorktree', { showDotfilesByWorktree: { 'repo::/worktree': true } }], ['setupGuideSidebarDismissed', { setupGuideSidebarDismissed: true }], diff --git a/src/renderer/src/app-shell/AppRootSurfaces.tsx b/src/renderer/src/app-shell/AppRootSurfaces.tsx index 202c99df4d2..31de9b1ce8d 100644 --- a/src/renderer/src/app-shell/AppRootSurfaces.tsx +++ b/src/renderer/src/app-shell/AppRootSurfaces.tsx @@ -1,3 +1,4 @@ +import { NotificationCardStack } from '../components/NotificationCardStack' import { Suspense } from 'react' import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import { translate } from '@/i18n/i18n' @@ -58,6 +59,11 @@ const SshPassphraseDialog = lazy(() => const UpdateCard = lazy(() => import('../components/UpdateCard').then((module) => ({ default: module.UpdateCard })) ) +const UnexpectedSignoutCard = lazy(() => + import('../components/UnexpectedSignoutCard').then((module) => ({ + default: module.UnexpectedSignoutCard + })) +) const RemoteServerUpdateDialog = lazy( () => import('../components/settings/RemoteServerUpdateDialog') ) @@ -273,16 +279,23 @@ export function AppRootSurfaces(props: { ) : null} - {shouldMountUpdateCard ? ( + + {shouldMountUpdateCard ? ( + + + + + + ) : null} - - + + - ) : null} - - - + + + + diff --git a/src/renderer/src/components/NotificationCardStack.tsx b/src/renderer/src/components/NotificationCardStack.tsx new file mode 100644 index 00000000000..9d3d5cddf3c --- /dev/null +++ b/src/renderer/src/components/NotificationCardStack.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from 'react' + +export function NotificationCardStack({ children }: { children: ReactNode }): React.JSX.Element { + return ( +
+ {children} +
+ ) +} diff --git a/src/renderer/src/components/StarNagCard.tsx b/src/renderer/src/components/StarNagCard.tsx index f0e184143fc..0bf3312463e 100644 --- a/src/renderer/src/components/StarNagCard.tsx +++ b/src/renderer/src/components/StarNagCard.tsx @@ -2,7 +2,6 @@ import { useCallback, useEffect, useState } from 'react' import { ExternalLink, Star, X } from 'lucide-react' import { Card } from './ui/card' import { Button } from './ui/button' -import { useAppStore } from '../store' import { useMountedRef } from '@/hooks/useMountedRef' import { translate } from '@/i18n/i18n' @@ -25,12 +24,6 @@ export function StarNagCard(): React.JSX.Element | null { const [busy, setBusy] = useState(false) const [mode, setMode] = useState('gh') const mountedRef = useMountedRef() - // Why: UpdateCard lives at the same bottom-right slot. When it is visible - // (any non-idle / non-not-available state), stack the star-nag card above - // it instead of overlapping — we must not cover a pending update prompt - // because that's a higher-priority action. - const updateStatus = useAppStore((s) => s.updateStatus) - const updateCardVisible = updateStatus.state !== 'idle' && updateStatus.state !== 'not-available' useEffect(() => { const unsubscribeShow = window.api.starNag.onShow((payload) => { @@ -147,15 +140,7 @@ export function StarNagCard(): React.JSX.Element | null { } return ( -
+
diff --git a/src/renderer/src/components/UnexpectedSignoutCard.tsx b/src/renderer/src/components/UnexpectedSignoutCard.tsx new file mode 100644 index 00000000000..ee0e8949ec3 --- /dev/null +++ b/src/renderer/src/components/UnexpectedSignoutCard.tsx @@ -0,0 +1,269 @@ +import { useEffect, useRef, useState } from 'react' +import { BookOpen, ChevronDown, CircleUserRound, Files, Smartphone, X } from 'lucide-react' +import { useAppStore } from '../store' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { Button } from './ui/button' +import { Card } from './ui/card' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible' +import { shouldShowUnexpectedSignoutCard } from './unexpected-signout/unexpected-signout-visibility' + +function readPreviewFlag(): boolean { + if (!import.meta.env.DEV) { + return false + } + try { + if (new URLSearchParams(window.location.search).get('showSignoutCard') === '1') { + return true + } + return window.localStorage.getItem('orca-debug-show-signout-card') === '1' + } catch { + return false + } +} + +function FeatureRow({ + icon: Icon, + title, + description +}: { + icon: typeof Files + title: string + description: string +}): React.JSX.Element { + return ( +
+ +
+

{title}

+

{description}

+
+
+ ) +} + +export function UnexpectedSignoutCard(): React.JSX.Element | null { + const authStatus = useAppStore((s) => s.orcaProfileAuthStatus) + const persistedUIReady = useAppStore((s) => s.persistedUIReady) + const persistedDismissedVersion = useAppStore((s) => s.dismissedUnexpectedSignoutVersion) + const dismissedVersions = useAppStore((s) => s.unexpectedSignoutDismissedVersions) + const dismissForVersion = useAppStore((s) => s.dismissUnexpectedSignoutCard) + const connecting = useAppStore((s) => s.orcaProfileConnecting) + const connect = useAppStore((s) => s.connectCurrentOrcaProfile) + const [appVersion, setAppVersion] = useState(null) + const [authRefreshReady, setAuthRefreshReady] = useState(false) + const [expanded, setExpanded] = useState(false) + const [preview] = useState(readPreviewFlag) + const [previewDismissed, setPreviewDismissed] = useState(false) + const reconnectingProfile = useRef(null) + + useEffect(() => { + let cancelled = false + let attempts = 0 + const refresh = (): void => { + attempts += 1 + void useAppStore + .getState() + .fetchOrcaProfileAuthStatus() + .then((status) => { + if (cancelled) { + return + } + if (status != null) { + setAuthRefreshReady(true) + } else if (attempts < 3) { + window.setTimeout(refresh, 500) + } + }) + } + refresh() + return () => { + cancelled = true + } + }, []) + + useEffect(() => { + let cancelled = false + void window.api.updater + .getVersion() + .then((version) => { + if (!cancelled) { + setAppVersion(version) + } + }) + .catch(() => { + if (!cancelled) { + setAppVersion(null) + } + }) + return () => { + cancelled = true + } + }, []) + + const dismissedVersion = + appVersion && dismissedVersions.includes(appVersion) ? appVersion : persistedDismissedVersion + const eligible = shouldShowUnexpectedSignoutCard({ + authStatus, + persistedUIReady, + appVersion, + dismissedVersion + }) + + const visible = preview ? persistedUIReady && !previewDismissed : authRefreshReady && eligible + + // Observe recovery independently of visibility and asynchronous version/hydration reads. + useEffect(() => { + if (preview || !authRefreshReady) { + return + } + if (authStatus?.state === 'reconnect-required' && authStatus.configured && authStatus.cloud) { + reconnectingProfile.current = authStatus.activeProfileId + } else if (authStatus?.state === 'connected') { + if ( + reconnectingProfile.current === authStatus.activeProfileId && + persistedUIReady && + appVersion + ) { + reconnectingProfile.current = null + if (dismissedVersion !== appVersion) { + dismissForVersion(appVersion) + } + } + } else { + reconnectingProfile.current = null + } + }, [ + preview, + authRefreshReady, + authStatus, + persistedUIReady, + appVersion, + dismissedVersion, + dismissForVersion + ]) + + if (!visible) { + return null + } + + const email = authStatus?.cloud?.email?.trim() || null + const canConnect = authStatus?.configured === true + + const handleDismiss = (): void => { + if (preview) { + setPreviewDismissed(true) + } else if (appVersion) { + dismissForVersion(appVersion) + } + } + + return ( +
+ +
+
+
+ +

+ {translate( + 'auto.components.UnexpectedSignoutCard.9f2c1a4b7d', + "You've been signed out" + )} +

+
+ +
+ +

+ {email + ? translate( + 'auto.components.UnexpectedSignoutCard.7b4d9e1f2a', + 'Sign in again as {{value0}} to restore Artifact sharing, Orca Relay, and skill sharing.', + { value0: email } + ) + : translate( + 'auto.components.UnexpectedSignoutCard.5a1c8d3e6f', + 'Sign in again to restore Artifact sharing, Orca Relay, and skill sharing.' + )} +

+ + + + + + + + + + + + +
+ +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/UpdateCard.error-card.test.tsx b/src/renderer/src/components/UpdateCard.error-card.test.tsx index 1e6da6879f7..186a285b26d 100644 --- a/src/renderer/src/components/UpdateCard.error-card.test.tsx +++ b/src/renderer/src/components/UpdateCard.error-card.test.tsx @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { LinuxPackageInstallRecovery, UpdateStatus } from '../../../shared/update-status-types' import { useAppStore } from '../store' import { UpdateCard } from './UpdateCard' +import { NotificationCardStack } from './NotificationCardStack' const openUrl = vi.fn() const download = vi.fn() @@ -31,7 +32,11 @@ function renderWithInitialStatus(updateStatus: UpdateStatus): RenderResult { updateCardCollapsed: false, updateReassuranceSeen: true }) - return render() + return render( + + + + ) } function renderAfterAvailableStatus(): RenderResult { diff --git a/src/renderer/src/components/UpdateCard.tsx b/src/renderer/src/components/UpdateCard.tsx index 4ccf95ff252..e2023ae21e1 100644 --- a/src/renderer/src/components/UpdateCard.tsx +++ b/src/renderer/src/components/UpdateCard.tsx @@ -239,10 +239,7 @@ export function UpdateCard(): React.JSX.Element | null { !reassuranceSeen && ((status.state === 'available' && !status.externallyManaged) || status.state === 'downloading') return ( -
+
{showReassurance && (
diff --git a/src/renderer/src/components/unexpected-signout/unexpected-signout-card.test.tsx b/src/renderer/src/components/unexpected-signout/unexpected-signout-card.test.tsx new file mode 100644 index 00000000000..f220d803a01 --- /dev/null +++ b/src/renderer/src/components/unexpected-signout/unexpected-signout-card.test.tsx @@ -0,0 +1,152 @@ +// @vitest-environment happy-dom +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '../../store' +import { getDefaultUIState } from '../../../../shared/constants' +import { UnexpectedSignoutCard } from '../UnexpectedSignoutCard' +import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles' + +const status: OrcaProfileAuthStatus = { + activeProfileId: 'profile-1', + configured: true, + state: 'reconnect-required', + persistence: 'none', + cloud: { + cloudProfileId: 'cloud-1', + userId: 'user-1', + email: 'user@example.com', + displayName: 'User', + linkedAt: 0 + } +} +const persist = vi.fn().mockResolvedValue(undefined) + +beforeEach(() => { + vi.stubEnv('DEV', false) + persist.mockClear() + window.localStorage.clear() + window.history.replaceState({}, '', '/') + Object.defineProperty(window, 'api', { + configurable: true, + value: { + ui: { set: persist }, + updater: { getVersion: vi.fn().mockResolvedValue('1.4.197') } + } + }) + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ + orcaProfileAuthStatus: status, + persistedUIReady: true, + fetchOrcaProfileAuthStatus: vi.fn().mockResolvedValue(status) + }) +}) +afterEach(() => { + cleanup() + vi.unstubAllEnvs() +}) + +async function showCard(): Promise { + render() + await screen.findByRole('complementary') +} + +describe('unexpected signout lifecycle', () => { + it('stamps successful sign-in and never re-arms in the same version', async () => { + await showCard() + act(() => useAppStore.setState({ orcaProfileAuthStatus: { ...status, state: 'connected' } })) + await waitFor(() => + expect(persist).toHaveBeenCalledWith({ dismissedUnexpectedSignoutVersion: '1.4.197' }) + ) + act(() => useAppStore.setState({ orcaProfileAuthStatus: status })) + expect(screen.queryByRole('complementary')).toBeNull() + cleanup() + render() + await act(async () => {}) + expect(screen.queryByRole('complementary')).toBeNull() + expect(persist).toHaveBeenCalledTimes(1) + }) + + it('does not treat a cached connected status as a successful re-sign-in', async () => { + useAppStore.setState({ orcaProfileAuthStatus: { ...status, state: 'connected' } }) + render() + await act(async () => {}) + act(() => useAppStore.setState({ orcaProfileAuthStatus: status })) + expect(screen.queryByRole('complementary')).not.toBeNull() + expect(persist).not.toHaveBeenCalled() + }) + + it('waits for hydration before stamping an already recovered session', async () => { + useAppStore.setState({ persistedUIReady: false }) + render() + await act(async () => {}) + act(() => useAppStore.setState({ orcaProfileAuthStatus: { ...status, state: 'connected' } })) + expect(persist).not.toHaveBeenCalled() + act(() => useAppStore.setState({ persistedUIReady: true })) + await waitFor(() => expect(persist).toHaveBeenCalledTimes(1)) + act(() => useAppStore.setState({ orcaProfileAuthStatus: { ...status, state: 'connected' } })) + expect(persist).toHaveBeenCalledTimes(1) + }) + + it('keeps a failed sign-in available without stamping dismissal', async () => { + useAppStore.setState({ connectCurrentOrcaProfile: vi.fn().mockResolvedValue(undefined) }) + await showCard() + fireEvent.click(screen.getByRole('button', { name: 'Sign in to Orca' })) + await act(async () => {}) + expect(screen.queryByRole('complementary')).not.toBeNull() + expect(persist).not.toHaveBeenCalled() + }) + + it('retains a reopened dismissal through stale UI sync and a renderer remount', async () => { + useAppStore.getState().hydratePersistedUI( + { + ...getDefaultUIState(), + dismissedUnexpectedSignoutVersion: '1.4.197' + }, + 'startup' + ) + render() + await act(async () => {}) + act(() => useAppStore.getState().hydratePersistedUI(getDefaultUIState())) + expect(screen.queryByRole('complementary')).toBeNull() + cleanup() + render() + await act(async () => {}) + expect(screen.queryByRole('complementary')).toBeNull() + expect(persist).not.toHaveBeenCalledWith( + expect.objectContaining({ dismissedUnexpectedSignoutVersion: expect.anything() }) + ) + }) + + it('persists X dismissal and stays hidden after remount', async () => { + await showCard() + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })) + expect(persist).toHaveBeenCalledWith({ dismissedUnexpectedSignoutVersion: '1.4.197' }) + cleanup() + render() + await act(async () => {}) + expect(screen.queryByRole('complementary')).toBeNull() + }) + + it.each(['storage', 'query'])('ignores the %s preview flag in production', async (source) => { + if (source === 'storage') { + window.localStorage.setItem('orca-debug-show-signout-card', '1') + } else { + window.history.replaceState({}, '', '/?showSignoutCard=1') + } + useAppStore.setState({ orcaProfileAuthStatus: { ...status, state: 'local', cloud: undefined } }) + render() + await act(async () => {}) + expect(screen.queryByRole('complementary')).toBeNull() + expect(persist).not.toHaveBeenCalled() + }) + + it('dismisses a development preview without writing real dismissal state', async () => { + vi.stubEnv('DEV', true) + window.localStorage.setItem('orca-debug-show-signout-card', '1') + useAppStore.setState({ orcaProfileAuthStatus: { ...status, state: 'local', cloud: undefined } }) + await showCard() + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })) + expect(screen.queryByRole('complementary')).toBeNull() + expect(persist).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.test.ts b/src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.test.ts new file mode 100644 index 00000000000..c1b8459204f --- /dev/null +++ b/src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest' +import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles' +import { shouldShowUnexpectedSignoutCard } from './unexpected-signout-visibility' + +function reconnectRequired(): OrcaProfileAuthStatus { + return { + activeProfileId: 'profile-1', + configured: true, + state: 'reconnect-required', + persistence: 'none', + cloud: { + cloudProfileId: 'cloud-1', + userId: 'user-1', + email: 'user@example.com', + displayName: 'User', + linkedAt: 0 + } + } +} + +describe('shouldShowUnexpectedSignoutCard', () => { + it.each([ + { name: 'unknown auth', auth: null, visible: false }, + { + name: 'missing cloud link', + auth: { ...reconnectRequired(), cloud: undefined }, + visible: false + }, + { + name: 'unconfigured linked profile', + auth: { ...reconnectRequired(), configured: false, state: 'unconfigured' }, + visible: false + }, + { + name: 'unconfigured reconnect status', + auth: { ...reconnectRequired(), configured: false }, + visible: false + }, + { + name: 'local with stale cloud metadata', + auth: { ...reconnectRequired(), state: 'local' }, + visible: false + }, + { + name: 'live memory-only session', + auth: { ...reconnectRequired(), state: 'connected', persistence: 'memory-only' }, + visible: false + }, + { + name: 'decrypt failure with retained link', + auth: { ...reconnectRequired(), credentialError: 'Cannot decrypt' }, + visible: true + }, + { + name: 'unreadable session with retained link', + auth: { ...reconnectRequired(), credentialError: 'Permission denied' }, + visible: true + } + ] satisfies { name: string; auth: OrcaProfileAuthStatus | null; visible: boolean }[])( + '$name', + ({ auth, visible }) => { + expect( + shouldShowUnexpectedSignoutCard({ + authStatus: auth, + persistedUIReady: true, + appVersion: '1.4.197', + dismissedVersion: null + }) + ).toBe(visible) + } + ) + + it('shows when linked but the session is gone', () => { + expect( + shouldShowUnexpectedSignoutCard({ + authStatus: reconnectRequired(), + persistedUIReady: true, + appVersion: '1.4.197', + dismissedVersion: null + }) + ).toBe(true) + }) + + it('hides after an explicit sign-out (link removed)', () => { + expect( + shouldShowUnexpectedSignoutCard({ + authStatus: { + activeProfileId: 'profile-1', + configured: true, + state: 'local', + persistence: 'none' + }, + persistedUIReady: true, + appVersion: '1.4.197', + dismissedVersion: null + }) + ).toBe(false) + }) + + it('hides when connected', () => { + const status = reconnectRequired() + status.state = 'connected' + expect( + shouldShowUnexpectedSignoutCard({ + authStatus: status, + persistedUIReady: true, + appVersion: '1.4.197', + dismissedVersion: null + }) + ).toBe(false) + }) + + it('shows only once per app version', () => { + expect( + shouldShowUnexpectedSignoutCard({ + authStatus: reconnectRequired(), + persistedUIReady: true, + appVersion: '1.4.197', + dismissedVersion: '1.4.197' + }) + ).toBe(false) + expect( + shouldShowUnexpectedSignoutCard({ + authStatus: reconnectRequired(), + persistedUIReady: true, + appVersion: '1.4.198', + dismissedVersion: '1.4.197' + }) + ).toBe(true) + }) + + it('waits for hydration and version', () => { + expect( + shouldShowUnexpectedSignoutCard({ + authStatus: reconnectRequired(), + persistedUIReady: false, + appVersion: '1.4.197', + dismissedVersion: null + }) + ).toBe(false) + expect( + shouldShowUnexpectedSignoutCard({ + authStatus: reconnectRequired(), + persistedUIReady: true, + appVersion: null, + dismissedVersion: null + }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.ts b/src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.ts new file mode 100644 index 00000000000..a9a47b2d98b --- /dev/null +++ b/src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.ts @@ -0,0 +1,22 @@ +import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles' + +export type UnexpectedSignoutGate = { + authStatus: OrcaProfileAuthStatus | null + persistedUIReady: boolean + appVersion: string | null + dismissedVersion: string | null +} + +export function shouldShowUnexpectedSignoutCard(gate: UnexpectedSignoutGate): boolean { + if (!gate.persistedUIReady || gate.appVersion === null) { + return false + } + if (gate.dismissedVersion === gate.appVersion) { + return false + } + return ( + gate.authStatus?.configured === true && + gate.authStatus.state === 'reconnect-required' && + gate.authStatus.cloud != null + ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index fe4e115ae77..607359d1b71 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -16883,6 +16883,21 @@ "HostedReviewUnlinkMenuItem": { "label": "Unlink {{value0}} from workspace", "description": "Orca will hide {{value0}} {{value1}} details for this workspace. The {{value0}} and branch on {{value2}} won’t be changed." + }, + "UnexpectedSignoutCard": { + "9f2c1a4b7d": "You've been signed out", + "3e8f5c2a91": "Dismiss", + "7b4d9e1f2a": "Sign in again as {{value0}} to restore Artifact sharing, Orca Relay, and skill sharing.", + "5a1c8d3e6f": "Sign in again to restore Artifact sharing, Orca Relay, and skill sharing.", + "1f6b2c9d4e": "What you get back", + "8d2e4f7a1b": "Artifact sharing", + "2c9a5b6e8d": "Publish HTML and Markdown files and manage every shared link from Orca.", + "6e3f1a9c5b": "Orca Relay", + "4b7d2e8f1a": "Connect Orca Mobile to this desktop across cellular or any Wi-Fi.", + "9a4c6b2d7e": "Skill sharing", + "3d8e5f1b9c": "Share skills behind an unlisted link and install them on any machine you use.", + "7e1a9c4d2f": "Signing in…", + "c5b3e8a17d": "Sign in to Orca" } }, "i18n": { diff --git a/src/renderer/src/store/slices/ui-notice-dismissals.test.ts b/src/renderer/src/store/slices/ui-notice-dismissals.test.ts index f17c77c78e3..180f96e427a 100644 --- a/src/renderer/src/store/slices/ui-notice-dismissals.test.ts +++ b/src/renderer/src/store/slices/ui-notice-dismissals.test.ts @@ -280,3 +280,81 @@ describe('createUISlice clearOsc52ClipboardDefaultOnNotice', () => { expect(setUI).toHaveBeenCalledWith({ osc52ClipboardDefaultOnNoticePending: false }) }) }) + +describe('unexpected sign-out dismissal persistence', () => { + it('persists a dismissal once even when effects repeat', () => { + const setUI = vi.fn(() => Promise.resolve()) + vi.stubGlobal('window', { api: { ui: { set: setUI } } }) + const store = createUIStore() + + store.getState().dismissUnexpectedSignoutCard('1.2.3') + store.getState().dismissUnexpectedSignoutCard('1.2.3') + + expect(store.getState().dismissedUnexpectedSignoutVersion).toBe('1.2.3') + expect(setUI).toHaveBeenCalledExactlyOnceWith({ dismissedUnexpectedSignoutVersion: '1.2.3' }) + }) + + it.each([undefined, null, '1.2.2'])( + 'does not re-arm a local dismissal from stale hydration (%s)', + (dismissedUnexpectedSignoutVersion) => { + vi.stubGlobal('window', { api: { ui: { set: vi.fn(() => Promise.resolve()) } } }) + const store = createUIStore() + store.getState().dismissUnexpectedSignoutCard('1.2.3') + + store.getState().hydratePersistedUI(makePersistedUI({ dismissedUnexpectedSignoutVersion })) + + expect(store.getState().unexpectedSignoutDismissedVersions).toContain('1.2.3') + } + ) + + it('defaults legacy profiles and restores dismissal on reopen', () => { + const store = createUIStore() + expect(store.getState().dismissedUnexpectedSignoutVersion).toBeNull() + store + .getState() + .hydratePersistedUI( + makePersistedUI({ dismissedUnexpectedSignoutVersion: undefined }), + 'startup' + ) + expect(store.getState().dismissedUnexpectedSignoutVersion).toBeNull() + const reopened = createUIStore() + reopened + .getState() + .hydratePersistedUI( + makePersistedUI({ dismissedUnexpectedSignoutVersion: '1.2.3' }), + 'startup' + ) + expect(reopened.getState().dismissedUnexpectedSignoutVersion).toBe('1.2.3') + }) + + it('accepts another window dismissal after hydrating an older version', () => { + const store = createUIStore() + store + .getState() + .hydratePersistedUI( + makePersistedUI({ dismissedUnexpectedSignoutVersion: '1.2.2' }), + 'startup' + ) + store + .getState() + .hydratePersistedUI(makePersistedUI({ dismissedUnexpectedSignoutVersion: '1.2.3' })) + expect(store.getState().dismissedUnexpectedSignoutVersion).toBe('1.2.3') + }) +}) + +describe('unexpected sign-out hydrated dismissal history', () => { + it.each([undefined, null, '1.2.2', '1.2.4'])( + 'retains an observed dismissal after a different version sync (%s)', + (dismissedUnexpectedSignoutVersion) => { + const store = createUIStore() + store + .getState() + .hydratePersistedUI( + makePersistedUI({ dismissedUnexpectedSignoutVersion: '1.2.3' }), + 'startup' + ) + store.getState().hydratePersistedUI(makePersistedUI({ dismissedUnexpectedSignoutVersion })) + expect(store.getState().unexpectedSignoutDismissedVersions).toContain('1.2.3') + } + ) +}) diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts b/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts index dff5f50c7e3..d913f998d2c 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts @@ -175,6 +175,10 @@ export type UISlicePersistence = { dismissedUpdateVersion: string | null dismissUpdate: (versionOverride?: string) => void clearDismissedUpdateVersion: () => void + /** App version that dismissed the unexpected-sign-out card; null = never dismissed. */ + dismissedUnexpectedSignoutVersion: string | null + unexpectedSignoutDismissedVersions: string[] + dismissUnexpectedSignoutCard: (version: string) => void /** Dev-only channel override; null follows the running build's own channel. */ releaseChannelOverride: ReleaseChannel | null setReleaseChannelOverride: (channel: ReleaseChannel | null) => void diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts index a5daf8a500d..6ca8d702bd3 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts @@ -52,6 +52,7 @@ import { } from '../persisted-ui-write-baseline' import { hydrateTrustedOrcaHooks, + hydrateUnexpectedSignoutDismissal, normalizeHydratedVisibleWorkspaceHostIds, preserveStringArrayIdentity, sanitizeHydratedActiveView, @@ -226,6 +227,7 @@ export function createUiHydrationActions(set: UISliceSet, _get: UISliceGet): Par return DEFAULT_PET_ID })(), dismissedUpdateVersion: ui.dismissedUpdateVersion ?? null, + ...hydrateUnexpectedSignoutDismissal(s, ui.dismissedUnexpectedSignoutVersion), // Why: a persisted value from a build that knew a different channel set // would otherwise survive as-is; activeChannel only falls back on null, // so an unknown string reaches listBuilds and the segmented control. diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts index bfed25a75cc..9737a575f06 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts @@ -238,3 +238,16 @@ export function migrateStatusBarItems(items: readonly string[] | undefined): Sta } return out as StatusBarItem[] } + +export function hydrateUnexpectedSignoutDismissal( + state: Pick, + version: string | null | undefined +): Pick { + const observed = state.unexpectedSignoutDismissedVersions + return { + dismissedUnexpectedSignoutVersion: version ?? null, + // A later sync must never undo any dismissal observed in this session. + unexpectedSignoutDismissedVersions: + typeof version === 'string' && !observed.includes(version) ? [...observed, version] : observed + } +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts index e4beadf4c6c..942ff9610a5 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts @@ -43,6 +43,18 @@ export function createUiUpdateActions(set: UISliceSet, get: UISliceGet): Partial updateChangelog: null, updateUserInitiatedCycle: false, dismissedUpdateVersion: null, + dismissedUnexpectedSignoutVersion: null, + unexpectedSignoutDismissedVersions: [], + dismissUnexpectedSignoutCard: (version) => { + if (get().unexpectedSignoutDismissedVersions.includes(version)) { + return + } + set({ + dismissedUnexpectedSignoutVersion: version, + unexpectedSignoutDismissedVersions: [...get().unexpectedSignoutDismissedVersions, version] + }) + void window.api.ui.set({ dismissedUnexpectedSignoutVersion: version }).catch(console.error) + }, clearDismissedUpdateVersion: () => { set({ dismissedUpdateVersion: null }) }, diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 7a06e11dba3..6840d92adc9 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -296,6 +296,7 @@ export function getDefaultUIState(): PersistedUIState { usagePercentageDisplay: DEFAULT_USAGE_PERCENTAGE_DISPLAY, statusBarUsageMode: DEFAULT_STATUS_BAR_USAGE_MODE, dismissedUpdateVersion: null, + dismissedUnexpectedSignoutVersion: null, lastUpdateCheckAt: null, trustedOrcaHooks: {}, setupScriptPromptDismissedRepoIds: [], diff --git a/src/shared/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts index 943813d7255..2a3eb411a0e 100644 --- a/src/shared/persisted-ui-state-types.ts +++ b/src/shared/persisted-ui-state-types.ts @@ -125,6 +125,8 @@ export type PersistedUIState = { /** Client-side footer presentation; verbose preserves the pre-roster all-window default. */ statusBarUsageMode?: StatusBarUsageMode dismissedUpdateVersion: string | null + /** App version that last dismissed the unexpected-sign-out card; null = never. Re-arms on each new version while still signed out. */ + dismissedUnexpectedSignoutVersion?: string | null lastUpdateCheckAt: number | null /** Dev-only update channel override; absent means the build's own channel. */ releaseChannelOverride?: ReleaseChannel | null From 2c984bcdaa7950e628a18180aad9537243e1a92f Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:28:39 -0400 Subject: [PATCH 02/17] feat(ai-vault-search): session search index as a transcript reader consumer (#19687) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ai-vault-search): add the session search index schema and row modules The FTS5 index that PR 2 folds the transcript reader's message stream into: two FTS tables behind visibility views, the identifier shadow column, the resumable fork digest, redaction at the row-insert choke point, and the bounded compaction, warm-up and retention lanes. Schema version starts at 1: this branch drops the query log and the fts5vocab table, which the query engine reintroduces with its own bump. * feat(ai-vault-search): stage index writes and publish them atomically The writer stages a read's rows against an unpublished session row and a batch id, then flips the whole read visible in one transaction: both FTS tables are written together per row, and publish nulls the batch pointer before dropping the batch so a recycled rowid can never name a later in-flight write. Buffering replaces the branch's streamed producer. The transcript reader pushes messages synchronously, so a staged write cannot make it wait; rows are held to 128 of them or 256 KB and then flushed in one transaction, which bounds both the retained bytes and one stall. The store owns the database and the set of files the index is behind on. It carries no query, coverage or scheduling: draining that set is the indexer's. * feat(ai-vault-search): register the index as a transcript reader consumer The index keeps its own per-file cursor in the `files` table and never consults the parse cache; the branch's AsyncLocalStorage capture scope is gone. Three refusals, each leaving the cursor where it was and recording the file for a later whole re-read: an append whose predecessor offset is not this index's own cursor (or whose dev/ino changed) is declined in `beginRead`, a staging failure ends the read's rows without throwing back at the reader, and an `incomplete` outcome never publishes. A null session drops the file's rows and still advances the cursor, because the file was read through. Nothing in production registers it: the indexer decides when the index is live. * test(ai-vault-search): measure the index's disk and latency cost The write benchmark indexes a synthetic corpus through the real reader and reports rows/s, per-table bytes per transcript MB, write amplification and rebuild time; the retention benchmark compares the batched purge against a whole-file delete. The corpus generator ships with them, so the numbers are reproducible on any host and no real transcript is ever read. * fix(ai-vault-search): decline a source the message channel cannot reach An OpenCode SQLite candidate decodes inside a worker, so the reader reports every read of it as incomplete. Staging a batch first meant one staging session and one tombstone per scan of every such session, forever. Declining in `beginRead` also keeps it out of the re-read set, because no re-read helps: the index cannot cover that source until its capture path lands. * fix(ai-vault-search): redact a message before it is cut into rows A credential is a shape, and a shape split across two chunks matches neither half: an 8000-char boundary landing inside a PEM block or a JWT indexed the key material in full, identifier shadow terms included. Redaction moves ahead of chunking, and the row type is branded so only `searchMessageRows` can mint a row an insert site accepts. `upsertFile` stops erasing dev/ino when a candidate carries none. A host that cannot stat identity (SSH, WSL, a degraded Windows stat) was overwriting a proven identity with NULL, which made the next rename-replace of that path undetectable. * test(ai-vault-search): pin each index guard on its own Three guards were each shadowed by another, so mutating any one of them left every test green. Driving the store directly separates the writer's own predecessor-offset check from the consumer's cursor check, and a stubbed store proves `beginRead` refuses before the writer is ever asked. `publishable` gets the case it never had: a second read of the same path publishes first, and the stale stage is tombstoned rather than resurrected. It covers the overlap the parse file lane normally prevents, so the invalidation slot being per-path is not load-bearing on its own. Two ratchets: every provider fingerprint needs a fixture that reaches past `SECRET_ANCHOR`, and every FTS read in the index modules must subtract staged rows through a visibility view. * fix(ai-vault-search): rebuild an index that cannot be trusted, and index retention Opening self-heals. A file too torn to open, or one whose recovery fails, is unlinked with its sidecars and reopened once; a second failure throws. Before this a torn index broke open permanently, even though the index is a cache over the transcripts and throwing it away costs only a re-scan. A `meta` table with no readable version row is now `stale`, not `fresh`. Seeding the current version over it would have kept whatever rows the old schema left. `fresh` means no `meta` table at all, and no longer triggers a rebuild: the first open of a new profile was doing create-remove-create. Recovery moves inside open, because retiring a batch that outlived its writer is part of opening the index rather than of using it. Its append case gets the test it never had: 200 rows staged onto a live session and abandoned leave the published generation intact and nothing else. Also an index on files(mtime_ms), so the retention pass seeks the expiring end of the list instead of scanning and sorting it, a pragma read-back test, and a sessions.file_path comment that says what is actually true of OpenCode. * fix(ai-vault-search): key a session's cwd the way the sidebar already does `cwd_key` had its own normalizer, which qualified a WSL cwd with its distro: `/home/me/repo` was stored as `//wsl/ubuntu/home/me/repo` while the sidebar's `folderGroupKey` stored `/home/me/repo`. Any later join between an indexed hit and a sidebar group would have returned nothing for exactly the WSL users the qualification was meant to help. The key is now the shared normalizer verbatim, pinned against `folderGroupKey` for POSIX, Windows drive, /mnt/c, both WSL UNC aliases, a Linux path, and the root. The collision it guarded against is real: two distros both spell `/home/me/repo`. It is also a collision every SSH host has, neither key qualifies for SSH, and the honest fix is a column naming the execution host rather than a path key that only some hosts spell differently. `/` now keys as `/` instead of the empty string. An empty key cannot be told apart from a session with no cwd, and the scope filter builds its child prefix as `key + '/'`, which would have been `//`. * test(ai-vault-search): widen the FTS visibility ratchet past whole literals Concatenating or interpolating a table name hid it from the per-literal scan, so `'SELECT rowid FROM ' + table` passed. The unit is now the file, the scan covers src/main and src/relay rather than one directory, and both bypass shapes are checker unit tests. Exemptions carry a reason and the census fails on one that has stopped being needed, which is how the schema module lost its. * fix(ai-vault-search): keep a failed rebuild's real cause, and read per statement Closing the stale handle before the unlink left `db` dangling: if the unlink or the reopen threw, the failure path closed it a second time and node:sqlite's ERR_INVALID_STATE replaced the real cause. Nothing classifies that as worth retrying, so an index a virus scanner or a second Orca was holding would never rebuild. The handle is nulled while none is open. The visibility ratchet moves from the file to the statement. A file is far too coarse for what it exists to guard: the query module will name both views somewhere, and that whitelisted every raw read in it. Statements come from literals and their concatenation chains, split on `;`, and a table name assembled at runtime counts as a read in any file that names an FTS table. The roots now cover cli, shared and preload as well. Half a recorded identity is now stated to be no identity. `remote-session-file-stat` spreads dev and ino independently and the COALESCE preserves whichever half a host could prove, so one number cannot tell a rename-replace from a same-file re-read; comparing it would decline healthy resumes on a coincidence. `discard` clearing the staging slot gets a test: without it the map grew one entry per path for the store's life. * fix(ai-vault-search): give a behind consumer a way to get the read it needs `markStale` promised a whole re-read that nothing could deliver. The reader picks append or replace from the session list's resume point, so with an empty index and a warm parse cache every read arrives as `append`, the consumer declines every one, and nothing is ever indexed. That is the state on first enablement inside a running app. `requestWholeTranscriptRead` in the reader drops that path's resume point, which is the one lever that changes the next read's mode, and it lives with the cache that owns it rather than with the consumer that wants it. `takeStale` documents that its paths need it before a scan is re-dispatched. Pausing no longer empties the re-read set. `acceptsCandidate` answers whether a write may start now and was being used for both jobs, so reconfiguring retention while paused dropped every entry and a declined read during a pause was never recorded at all. Retention alone prunes; a paused decline is recorded, bounded, with the oldest dropped and the drops counted, because a set that silently forgets is worse than one that says it is incomplete. A read that decodes no session now tombstones its staged rows before the store schedules cleanup. The cleanup lane reads the tombstone table the moment it is scheduled, so the old order left that batch on disk until some later write, and for the last read before a shutdown that is never. * fix(ai-vault-search): create the directory the index lives in Nothing made `/ai-vault-search/`, and SQLite's failure for a missing parent is `unable to open database file`, which is correctly not classified as corruption — so the retry never fired and the feature stranded on any profile that had never held an index. * refactor(ai-vault-search): store transcript content as written Decision: the index does not redact. A secret in a transcript is already plaintext under the user's home directory and is treated as compromised, so the index is a second copy of content the user already holds, not a new exposure. The reference agent-session-search products do not redact either. What a snippet may carry once it leaves this machine is a transport policy and belongs where the wire is, not in the write path. Removes the redaction module and its census, the branded row type that existed to prove redaction had run before an insert, and the two chunk-boundary tests. `insertSearchMessage` takes a plain chunk again and identifier shadow terms come off the raw text. `src/main/observability/redactor.ts` goes back to what main has, so this PR no longer touches it at all. Chunking and the fork digest are unchanged; the digest always folded raw message text, ahead of chunking, so its tests hold as written. Cost: redaction was 14 ms per 10.5 MB of transcript, about 3% of a rebuild and below the benchmark's run-to-run spread, so the write numbers are unchanged at roughly 22-25k rows/s and 22-27 MB/s. * fix(ai-vault-search): hide a tombstoned session's messages, not only its row `visible_sessions` already subtracted session-keyed tombstones; the message half filtered on the batch pointer alone. A published row outlives its session row until the cleanup lane reaches it, so between a `replace` publish and that drain both generations answered, and a removed file kept answering after its session was gone. Both views now subtract the same set, over a partial index so neither read scans the tombstone table. * fix(ai-vault-search): drain the rows a read that never published staged Only `writePublished` and `removeFile` scheduled the cleanup lane. A read that staged rows and then declined to publish them tombstoned its batch and told nobody, so 256 rows of a 300-message incomplete read sat in `messages` and both FTS tables until some unrelated write happened to schedule a pass. Open-time recovery had the same hole: it wrote the tombstones and drained none. The abandoned branch now schedules the drain the published branch already got, and opening schedules one pass for what recovery just tombstoned. Recovery no longer adds a batch tombstone when the session-keyed one already covers those rows, which it did once more on every reopen. * fix(ai-vault-search): continue the cursor of a file that decoded no session A read that decodes no session — an excluded Codex worker transcript — advances the cursor and leaves `files.session_row_id` null. Every later append was then declined, so a file that only ever grows would be re-read whole on every pass for the rest of its life. An append now only has to continue this index's own cursor; it creates the session row when there is none to resume. That also collapses the `append` flag, which meant both "resume this session" and "do not own it", into the resumed row id itself. * refactor(ai-vault-search): drop the index path module nothing calls No caller in this PR or the two that follow it: the composition root that would capture the userData path is PR 3b's. * refactor(ai-vault-search): read the schema version as stale or not Only the stale answer was ever acted on; the fresh/current split named two ways of being fine. * fix(ai-vault-search): read the resumed session id off an optional row * refactor(ai-vault-search): leave page warmup and the freshness check to PR 4 `warm()` and `session-search-page-warmup.ts` have their first caller in the query engine, which is what knows which pages are worth warming; the module itself went with the previous commit. `isSessionSearchFileCurrent` has its first caller in the reconciler. `session-search-file-cursor.ts` stays, because `fileIdentity` and both its types are load-bearing here. * refactor(ai-vault-search): stop pruning the re-read set on a retention change The prune walked the whole set to drop what the next `beginRead` would refuse anyway: `acceptsCandidate` applies the window when the re-read is dispatched, and `markStale` applies it again before recording anything. The bound stays. * fix(ai-vault-search): keep a batch tombstone from outliving its batch row Draining a session-keyed tombstone deletes the session's batch rows, but left any batch-keyed tombstone naming them in place. `search_write_batches` empties on every publish, so ids restart low and the next read takes the freed rowid; the stale tombstone then deleted that live batch's staged rows and the session published missing messages, with no re-read to fill the gap. The session drain now clears those tombstones in the same transaction. * refactor(ai-vault-search): commit a file's rows and its cursor in one transaction The staged-publish model is replaced by one SQLite transaction per file in WAL mode. A read buffers its decoded rows and writes them, its session and its cursor together; a reader on another handle sees the last committed state, which is the "never a torn session" guarantee the staging machinery was built to provide. A crash rolls the whole file back and it is re-read. Gone with it: `search_write_batches`, `search_pending_deletes`, `messages.batch_id`, `sessions.index_ready`, both `visible_*` views, the open-time recovery pass, the cleanup lane and `settled()`, and the ratchet test that made every query site read through a view. Rounds 2 through 6 were all seams between those pieces. A file whose rows exceed `SESSION_SEARCH_COMMIT_CHARS` is cut into chunks. The reader only hands out a byte offset when a read finishes, so a chunk records one no append can continue from: its rows answer searches as a coherent prefix of the session, and the next whole read replaces them. Retention keeps no record of unfinished work. It cuts a session loose from its file in one small transaction, which is what stops it answering, then reclaims its rows in bounded batches and hands the freed pages back as it goes. Rows whose session row is gone are the record of what an interrupted purge left. Deleted for want of a caller in PR 2, 3 or 4: the WAL budget (a buffered write has no staging window to grow one across), the compaction module (folded into the drain), the `sessions_content_hash` index (fork folding runs in JS over rows PR 4 already holds), `lastWriteAt`, `failures`, `SessionSearchStoreOptions`, `openStageCount` and `chunkMessageText`. * test(ai-vault-search): price a per-file commit and the ceiling that bounds it The write benchmark now times every transaction, because with one per file that is the whole stall a file costs. A second phase indexes a single synthetic 100 MB transcript, which is what the commit ceiling is chosen against. * fix(ai-vault-search): stop a fenced write reopening a transaction per message A chunk write that finds the file record moved under it — a `removeFile`, or an overlapping read of the same path — can never land anything afterwards. It kept buffering, so every remaining message re-entered `BEGIN IMMEDIATE` only to roll back, once per message for the rest of a file that may be a hundred megabytes. It now stops at the first refusal and drops what it holds. * fix(ai-vault-search): never hand a live session the id of a purged one `sessions.id` was a plain rowid alias, so SQLite reissued it as max+1. That id names rows in `messages` for far longer than the row itself lives: retention cuts a session loose in one transaction and reclaims its messages over many, and a session created inside that window was handed a freed id and adopted whatever of the purged conversation the drain had not reached. The drain then skipped those rows for good, because their session row exists again, and a search answered for a purged transcript under a live session's name. Reachable with no crash at all: an append of a growing transcript the parser decoded no session from creates its session row mid-purge. AUTOINCREMENT is the class fix; the schema version goes to 3 so a file written by an earlier commit of this branch rebuilds rather than keeping a plain-rowid table under a `CREATE TABLE IF NOT EXISTS`. `messages.id` was checked and is not exposed to the same window: a row and its two FTS entries always go in one transaction. Two smaller ones in the same pass. `removeFile` did not fence a read of a path this index had never written: `current()` compared a cursor, and on an unknown path the absent row and the absent expectation are both undefined, so the write recreated the source after its owner had proven it gone. The writer now counts removals per path and a write compares that count, which is a positive fence rather than an inferred one. And `drainOrphanedMessages`, `CONTENT_HASH_MESSAGE_LIMIT` and `CONTENT_HASH_MIN_MESSAGES` are no longer exported: nothing outside their own modules reads them. * fix(ai-vault-search): report a half-written file as held, not as unknown `indexedFile` returned null for a file a chunked read left partway through, the same answer it gives for a path the index has never seen. A caller asking "do you hold this file" therefore read a half-written one as new, asked for whatever read the parse cache offered, and the reader picked append — which the consumer then declined. Only the stale set healed it, a cycle later. It now reports the record with a null `byteOffset`, and `requiresWholeRead(indexed)` names that state. Null rather than an added flag because the mtime and size on that record are the file's real ones: a freshness check comparing only those would call a half-written file current, and a boolean is easy to not read, while every site that does arithmetic on the offset has to say what null means at compile time. The test also found the writer accepting an append that passed the partial sentinel back as its predecessor offset. Nothing in the reader produces a negative offset, but the sentinel is not a byte offset and no caller should be able to continue it; `beginWrite` now refuses it outright. * fix(ai-vault-search): cut a chunk at whitespace, never mid-token The 8,000-char chunker backed up only to a newline, and only when one sat in the second half of the window. A wrapped paragraph, a CJK transcript or a minified log has no newline there, so the cut landed inside whatever word straddled the target: the user's term was filed as two halves and matched neither. It now backs up to the last Unicode whitespace in the second half, and falls through to the target when there is none, because 4,000 characters without a space is not a word. A phrase that straddles a chunk boundary is still not matched. Chunks are separate FTS rows and FTS5 cannot span them; that is stated at the chunker. * feat(ai-vault-search): cap an indexed tool row at 3,072 characters Tool output is 80-97 % of a transcript's bytes and a single message may be a quarter of a megabyte, so without a cap the index, the buffer a read holds and the transaction it commits are all sized by how much a tool printed rather than by how much is worth searching. A `tool` message now becomes one row of at most 3,072 characters, kept from the head, where the command and the first lines of its output are. User and assistant text is never capped. Measured on the write benchmark, 40 sessions with tool output at 95 % of message text (the real band), per transcript MB: index 1,334,126 -> 353,973 bytes, write amplification 1.27x -> 0.34x, rows 18,804 -> 9,600, rebuild 1,676 -> 379 ms, largest transaction 49.7 -> 12.1 ms. On the default corpus, whose tool results are 1.4 KB and so under the cap, nothing changes at all: 2,167,887 bytes per transcript MB on both arms. The corpus generator takes `toolResultWords` and the benchmark reads ORCA_SEARCH_BENCH_TOOL_WORDS so both arms are the same harness. * fix(ai-vault-search): check the commit ceiling per row, not per message The buffered-chars check ran after a whole message had been folded into rows, so a single message could carry a transaction as far past the ceiling as it was large. A conversation turn is one message and can be megabytes; the ceiling exists to bound how long one commit holds the process and how large a WAL it produces, and neither bound survived a message that overshot it. * style(ai-vault-search): undo a stray reformat of untouched assertions Three assertions in the file-write test were expanded by a formatter run that was not the repo's, and the expansion survived because a multi-line object literal is preserved once it exists. Restored to what they were. * feat(ai-vault-search): write a session's identity with its first commit A chunked read created its session row with an empty session id, an empty title, an empty resume command and null cwd and timestamps, and only filled them in on the final commit. Those chunks answer searches the moment they land, so until the read ended every hit they produced named a session nothing could identify — and a crash between chunks left it that way for good, since the re-read that heals it is the same read starting over. The reader already knows the id, cwd and timestamps from a transcript's opening lines; it just had nowhere to put them. `TranscriptReadStart` now carries an optional `identity()` the consumer calls during the read, backed by an optional `identity()` on `ResumableSessionParseState`: one line in the shared accumulator fold (which covers cursor, copilot, droid, gemini, antigravity and the graph parsers) and one each in the Claude and Codex folds, which keep their own state. A chunk commit writes what it returns; the final commit overwrites it from the decoded session, so the mid-read title stays provisional. Two `cwd` guards in the Codex fold collapse into the `?? ` form the `branch` line beside one of them already used. `extractString` never returns an empty string, so it is the same assignment in one line instead of four, and it is what keeps the file under the 300-line cap with the identity accessor added. * refactor(ai-vault-search): drop conversation_fts for a column filter The second FTS table existed for query latency alone: a column-filtered `messages_fts MATCH '{user_text assistant_text}: q'` returns the identical rowid set, which PR 2's round 5 review proved and PR 4's benchmark re-checked per query. PR 4 then measured the filter at 1.16-1.36x the p95 of the second table on a 105 MB corpus across two points in the tool-output band, under the 2x bar the decision was set at, and closes the stack's open decision 3. It cost a quarter of the index on a corpus whose tool output is half the message text. With the tool-row cap it is a smaller saving than the decision was framed around, but it is still a table, a write per conversational row and a delete per reclaimed one. Schema version 4: `CREATE TABLE IF NOT EXISTS` is a no-op over an existing index, so only the bump makes a file that still carries the table go. * feat(ai-vault-search): expose the index handle a composed reader queries PR 4's engine reads the index this store owns. One getter lets it compose over the store's handle instead of opening a second connection to the same file, and it carries the two rules this PR measured: never hold a read transaction across an await, and no `.iterate()` outliving its statement. Either pins a read snapshot, and a checkpoint cannot pass one, so the WAL grows without bound for as long as it is held (10 MB to 266 MB on the write benchmark). * fix(ai-vault-search): cut a chunk at punctuation, not only whitespace The 8,000-character chunker backed up to the last whitespace in the second half of the window and fell through to the target when it found none. A minified tool result has none to find: valid minified JSON runs past 8,000 characters without a space, so the cut landed inside whatever word straddled the target, and a search for `pericardium` matched neither `perica` nor `rdium`. That is the shape most likely to be chunked in the first place, because tool output is 80-97 % of a transcript's bytes. The backoff now takes any character that cannot be inside a token — anything outside a letter, digit or underscore — and still falls through to the target when the window holds none, because 4,000 characters without one is not a word. Surrogates are excluded from the class so a cut never lands between the halves of an astral character. A few of the tokenizer's own `tokenchars` (`. - / +`) are cut on even though unicode61 keeps them inside a token. That costs the joined form of a path, and only in a window with no whitespace anywhere, which is a far smaller loss than the torn word this exists to prevent. * fix(ai-vault-search): never chunk a read that cannot name its session `updateProvisionalSession` is fed by the resumable readers alone. Claude and Codex carry an `identity()` off their fold; `readWholeTranscript` supplies none, because a format rewritten in place has no resumable state to ask. So a Grok, Cursor, Gemini or OpenCode file large enough to pass the commit ceiling published its chunks under a session with an empty id, an empty title and a null cwd — rows that answer searches at once and that an interrupted read leaves behind for good, since the re-read that heals them is the same read starting over. `add` now commits a chunk only while the read can name what it is writing. A read with no identity, or one whose parser has decoded no id yet, keeps buffering and commits whole at `finish`. The whole-file formats are the ones with nothing to give and they are small — the largest on this machine is 5 MB — so buffering one to the end costs nothing, and chunking stays reserved for the readers that can say which session a prefix belongs to. `updateProvisionalSession` takes a non-null identity now, so the invariant is the type rather than a guard that silently wrote nothing. * perf(ai-vault-search): cut a replaced generation loose instead of deleting it The first transaction of a replace deleted every row of the old session before inserting its own bounded chunk, so the transaction was sized by the history being replaced rather than by the rows being written. On the synthetic 100 MB transcript the longest replace transaction was 1,255-1,283 ms against 668-672 ms for the same file read fresh, and the gap grows with the session. A replace now mints a new session row, points the `files` row at it and deletes the one old `sessions` row, all in the same transaction. Every retrieval joins `sessions`, so the old generation stops answering the moment that commits — the same thing retention's first transaction does — and its messages are reclaimed afterwards by `drainOrphanedMessages`, the bounded batch loop that already exists for exactly this set. `sessions.id` is AUTOINCREMENT, so the freed id is never handed to another session while those rows still name it. The longest replace transaction is now 746-848 ms, the same band as a fresh read. The trade is stated plainly: the pass does more total work, 3.7 s against 3.1 s, because the reclaim is 360 bounded transactions with an incremental_vacuum step each instead of one large delete. It yields between them, so none of it holds the process, and returning the pages per batch also takes the index from 173.6 MB to 162.0 MB. The store schedules the drain off the committing stack: an async function runs synchronously to its first `await`, so calling it inline would put the first batch back inside the transaction's own call. One drain at a time, with a repeat flag for a replace that commits while one is running. * fix(ai-vault-search): preserve search tokens and index Codex tools * fix(ai-vault-search): match SQLite marks and Codex file-change records * fix(ai-vault-search): preserve stat pairs and validate benchmark results --- .../session-search-retention-benchmark.ts | 148 +++++ .../scripts/session-search-write-benchmark.ts | 266 ++++++++ .../session-search-content-hash.test.ts | 48 ++ .../session-search-content-hash.ts | 45 ++ .../session-search-cwd-key.test.ts | 37 ++ .../session-search-file-cursor.ts | 41 ++ .../session-search-file-records.ts | 134 ++++ .../session-search-file-write.test.ts | 614 ++++++++++++++++++ .../session-search-identifier-split.test.ts | 29 + .../session-search-identifier-split.ts | 54 ++ .../session-search-index-consumer.test.ts | 373 +++++++++++ .../session-search-index-consumer.ts | 118 ++++ .../session-search-index-test-fixture.ts | 124 ++++ .../session-search-index-writer.test.ts | 228 +++++++ .../session-search-index-writer.ts | 359 ++++++++++ .../session-search-live-transcript.test.ts | 208 ++++++ .../session-search-message-rows.test.ts | 249 +++++++ .../session-search-message-rows.ts | 135 ++++ .../session-search-retention-delete.test.ts | 188 ++++++ .../session-search-retention-delete.ts | 102 +++ .../session-search-row-identity.test.ts | 116 ++++ .../session-search-schema.test.ts | 350 ++++++++++ .../ai-vault-search/session-search-schema.ts | 198 ++++++ .../ai-vault-search/session-search-store.ts | 253 ++++++++ .../session-search-synthetic-corpus.test.ts | 44 ++ .../session-search-synthetic-corpus.ts | 154 +++++ .../session-search-transcript-fixtures.ts | 118 ++++ .../ai-vault/session-scanner-accumulator.ts | 29 +- .../session-scanner-codex-message-records.ts | 9 + .../ai-vault/session-scanner-codex-parser.ts | 19 +- .../session-scanner-codex-record-fast-path.ts | 14 +- ...session-scanner-codex-tool-records.test.ts | 125 ++++ .../session-scanner-codex-tool-records.ts | 95 +++ ...ession-scanner-omp-subagent-transcripts.ts | 1 + .../session-scanner-primary-parsers.ts | 2 + src/main/ai-vault/session-scanner-types.ts | 8 +- .../ai-vault/session-transcript-consumers.ts | 22 + .../ai-vault/session-transcript-reader.ts | 28 +- 38 files changed, 5070 insertions(+), 15 deletions(-) create mode 100644 config/scripts/session-search-retention-benchmark.ts create mode 100644 config/scripts/session-search-write-benchmark.ts create mode 100644 src/main/ai-vault-search/session-search-content-hash.test.ts create mode 100644 src/main/ai-vault-search/session-search-content-hash.ts create mode 100644 src/main/ai-vault-search/session-search-cwd-key.test.ts create mode 100644 src/main/ai-vault-search/session-search-file-cursor.ts create mode 100644 src/main/ai-vault-search/session-search-file-records.ts create mode 100644 src/main/ai-vault-search/session-search-file-write.test.ts create mode 100644 src/main/ai-vault-search/session-search-identifier-split.test.ts create mode 100644 src/main/ai-vault-search/session-search-identifier-split.ts create mode 100644 src/main/ai-vault-search/session-search-index-consumer.test.ts create mode 100644 src/main/ai-vault-search/session-search-index-consumer.ts create mode 100644 src/main/ai-vault-search/session-search-index-test-fixture.ts create mode 100644 src/main/ai-vault-search/session-search-index-writer.test.ts create mode 100644 src/main/ai-vault-search/session-search-index-writer.ts create mode 100644 src/main/ai-vault-search/session-search-live-transcript.test.ts create mode 100644 src/main/ai-vault-search/session-search-message-rows.test.ts create mode 100644 src/main/ai-vault-search/session-search-message-rows.ts create mode 100644 src/main/ai-vault-search/session-search-retention-delete.test.ts create mode 100644 src/main/ai-vault-search/session-search-retention-delete.ts create mode 100644 src/main/ai-vault-search/session-search-row-identity.test.ts create mode 100644 src/main/ai-vault-search/session-search-schema.test.ts create mode 100644 src/main/ai-vault-search/session-search-schema.ts create mode 100644 src/main/ai-vault-search/session-search-store.ts create mode 100644 src/main/ai-vault-search/session-search-synthetic-corpus.test.ts create mode 100644 src/main/ai-vault-search/session-search-synthetic-corpus.ts create mode 100644 src/main/ai-vault-search/session-search-transcript-fixtures.ts create mode 100644 src/main/ai-vault/session-scanner-codex-tool-records.test.ts create mode 100644 src/main/ai-vault/session-scanner-codex-tool-records.ts diff --git a/config/scripts/session-search-retention-benchmark.ts b/config/scripts/session-search-retention-benchmark.ts new file mode 100644 index 00000000000..095eb5f29b0 --- /dev/null +++ b/config/scripts/session-search-retention-benchmark.ts @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict' +import { mkdtemp, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { setImmediate as yieldToEventLoop } from 'node:timers/promises' +import { + syntheticCandidate, + syntheticSession, + userMessages +} from '../../src/main/ai-vault-search/session-search-index-test-fixture' +import { SessionSearchStore } from '../../src/main/ai-vault-search/session-search-store' +import SyncDatabase from '../../src/main/sqlite/sync-database' + +// Bundle with esbuild --bundle --platform=node, then run on the host under test. +// Every mode seeds through SessionSearchStore so the three arms are comparable; +// only `whole-file` leaves the shipped path, because it is the baseline the +// batched purge exists to replace. + +const ROWS = 60_000 + +/** The purge yields with `setImmediate` between chunks, so a peer chain samples each gap. */ +async function sampleLoopStalls(running: () => boolean, intervals: number[]): Promise { + let previous = performance.now() + while (running()) { + await yieldToEventLoop() + const now = performance.now() + intervals.push(now - previous) + previous = now + } +} + +/** What a search would still return: rows whose session row is still there. */ +function visibleRows(db: SyncDatabase): number { + return ( + db + .prepare(`SELECT count(*) AS n FROM messages m JOIN sessions s ON s.id = m.session_row_id`) + .get() as { n: number } + ).n +} + +const root = await mkdtemp(join(tmpdir(), 'orca-search-retention-bench-')) +try { + for (const mode of ['whole-file', 'batched', 'batched-pinned-reader']) { + const path = join(root, `${mode}.sqlite`) + const errors: unknown[] = [] + const store = new SessionSearchStore(path, (error) => errors.push(error)) + let reader: SyncDatabase | null = null + try { + const write = store.beginWrite(syntheticCandidate(), 'replace', 0)! + for (const message of userMessages( + 'synthetic benchmark needle repeated context for a representative coding conversation with commands and paths src/example.ts', + ROWS + )) { + write.add(message) + } + assert.equal( + write.commit({ + session: syntheticSession(), + byteOffset: 4096, + incomplete: false + }), + true + ) + assert.deepEqual(errors, []) + // Truncating first is what makes walBytes below the purge's own growth. + const checkpoint = new SyncDatabase(path) + checkpoint.pragma('wal_checkpoint(TRUNCATE)') + checkpoint.close() + if (mode === 'batched-pinned-reader') { + reader = new SyncDatabase(path, { readonly: true }) + reader.exec('BEGIN') + reader.prepare('SELECT count(*) FROM messages').get() + } + const probe = new SyncDatabase(path, { readonly: true }) + const intervals: number[] = [] + const started = performance.now() + if (mode === 'whole-file') { + const raw = new SyncDatabase(path) + try { + raw.exec('BEGIN IMMEDIATE') + const ids = raw.prepare('SELECT id FROM messages').all() as { + id: number + }[] + for (const { id } of ids) { + raw.prepare('DELETE FROM messages_fts WHERE rowid=?').run(id) + } + raw.exec('DELETE FROM messages; DELETE FROM sessions; DELETE FROM files; COMMIT') + } finally { + raw.close() + } + intervals.push(performance.now() - started) + } else { + let purging = true + const purge = store.purgeOlderThan(Date.now() + 60_000) + // Hiding is immediate: cutting the session loose from its file is the + // first transaction, so a read one turn in already sees nothing, long + // before the rows are gone. + const hiddenEarly = yieldToEventLoop().then(() => visibleRows(probe)) + const sampler = sampleLoopStalls(() => purging, intervals) + await purge + purging = false + await sampler + assert.equal(await hiddenEarly, 0) + assert.deepEqual(errors, []) + } + const wallMs = performance.now() - started + probe.close() + reader?.exec('COMMIT') + reader?.close() + reader = null + const after = new SyncDatabase(path, { readonly: true }) + try { + for (const table of ['messages_fts']) { + assert.equal( + ( + after.prepare(`SELECT count(*) AS n FROM ${table}`).get() as { + n: number + } + ).n, + 0 + ) + } + } finally { + after.close() + } + const walBytes = (await stat(`${path}-wal`)).size + intervals.sort((a, b) => a - b) + console.log( + JSON.stringify({ + mode, + platform: process.platform, + node: process.version, + rows: ROWS, + wallMs: Math.round(wallMs), + samples: intervals.length, + maxStepMs: Math.round(intervals.at(-1) ?? 0), + p95StepMs: Math.round(intervals[Math.floor(intervals.length * 0.95)] ?? 0), + walBytes + }) + ) + } finally { + reader?.close() + store.close() + } + } +} finally { + await rm(root, { recursive: true, force: true }) +} diff --git a/config/scripts/session-search-write-benchmark.ts b/config/scripts/session-search-write-benchmark.ts new file mode 100644 index 00000000000..db09275cd98 --- /dev/null +++ b/config/scripts/session-search-write-benchmark.ts @@ -0,0 +1,266 @@ +import assert from 'node:assert/strict' +import { rm, stat } from 'node:fs/promises' +import { join } from 'node:path' +import { setImmediate as yieldToEventLoop } from 'node:timers/promises' +import { + createSessionParseStats, + parseAgentSessionFileCached, + resetSessionParseCacheForTests +} from '../../src/main/ai-vault/session-scanner-parse-cache' +import { resetTranscriptConsumersForTests } from '../../src/main/ai-vault/session-transcript-consumers' +import { requestWholeTranscriptRead } from '../../src/main/ai-vault/session-transcript-reader' +import { registerSessionSearchIndexConsumer } from '../../src/main/ai-vault-search/session-search-index-consumer' +import { SessionSearchStore } from '../../src/main/ai-vault-search/session-search-store' +import { writeSyntheticTranscriptCorpus } from '../../src/main/ai-vault-search/session-search-synthetic-corpus' +import { sessionCandidate } from '../../src/main/ai-vault-search/session-search-transcript-fixtures' +import SyncDatabase from '../../src/main/sqlite/sync-database' + +// Measures the real transcript reader and search store over a synthetic corpus. +// Never point this at a real transcript tree. + +/** + * How long the longest single transaction held the process. + * + * With one transaction per file that is the whole stall a file costs, so it is + * the number the commit ceiling exists to bound. Measured by wrapping `exec`, + * because the writer's transactions are the only ones this benchmark runs. + */ +function recordTransactionDurations(durations: number[]): () => void { + const exec = SyncDatabase.prototype.exec + let started = 0 + SyncDatabase.prototype.exec = function (this: SyncDatabase, sql: string): void { + if (sql === 'BEGIN IMMEDIATE') { + started = performance.now() + } + exec.call(this, sql) + if (sql === 'COMMIT' && started > 0) { + durations.push(performance.now() - started) + started = 0 + } + } + return () => { + SyncDatabase.prototype.exec = exec + } +} + +/** The writer commits synchronously, so a peer chain samples the gap each read leaves. */ +async function sampleLoopStalls(running: () => boolean, stalls: number[]): Promise { + let previous = performance.now() + while (running()) { + await yieldToEventLoop() + const now = performance.now() + stalls.push(now - previous) + previous = now + } +} + +function tableBytes(db: SyncDatabase): Record { + const rows = db.prepare('SELECT name, sum(pgsize) AS bytes FROM dbstat GROUP BY name').all() as { + name: string + bytes: number + }[] + const group = (prefix: string): number => + rows + .filter((row) => row.name === prefix || row.name.startsWith(`${prefix}_`)) + .reduce((sum, row) => sum + row.bytes, 0) + return { + messagesFts: group('messages_fts'), + messages: group('messages') - group('messages_fts'), + sessions: group('sessions'), + total: rows.reduce((sum, row) => sum + row.bytes, 0) + } +} + +function assertIndexedMessages(db: SyncDatabase, expected: number): number { + const { n } = db + .prepare('SELECT count(*) AS n FROM messages m JOIN sessions s ON s.id = m.session_row_id') + .get() as { n: number } + assert.equal(n, expected, 'indexed message count') + return n +} + +async function checkpointedFileBytes(db: SyncDatabase, path: string): Promise { + // Flush committed WAL pages before reporting the final database footprint. + const [checkpoint] = db.pragma('wal_checkpoint(TRUNCATE)') as { busy: number }[] + assert.equal(checkpoint?.busy, 0, 'storage measurement requires a completed checkpoint') + return (await stat(path)).size +} + +// The default corpus puts tool output at about half the message text; set this +// far higher to price the tool-row cap against the real 80-97 % band. +const toolResultWords = Number(process.env.ORCA_SEARCH_BENCH_TOOL_WORDS ?? 200) +const corpus = await writeSyntheticTranscriptCorpus({ toolResultWords }) +const indexPath = join(corpus.root, 'index.sqlite') +try { + const errors: unknown[] = [] + const store = new SessionSearchStore(indexPath, (error) => errors.push(error)) + const unregister = registerSessionSearchIndexConsumer(store) + const stalls: number[] = [] + const transactions: number[] = [] + const restoreExec = recordTransactionDurations(transactions) + let indexing = true + try { + const stats = createSessionParseStats() + const started = performance.now() + const sampler = sampleLoopStalls(() => indexing, stalls) + for (const path of corpus.files) { + await parseAgentSessionFileCached( + await sessionCandidate('claude', path), + process.platform, + stats + ) + } + indexing = false + await sampler + restoreExec() + const rebuildMs = performance.now() - started + assert.deepEqual(errors, []) + + const reader = new SyncDatabase(indexPath, { readonly: true }) + try { + const rows = assertIndexedMessages(reader, corpus.messageCount) + const sessions = ( + reader.prepare('SELECT count(*) AS n FROM sessions').get() as { + n: number + } + ).n + assert.equal(sessions, corpus.files.length) + const bytes = tableBytes(reader) + const perMb = (value: number): number => + Math.round((value / (corpus.transcriptBytes / (1024 * 1024))) * 10) / 10 + const fileBytes = await checkpointedFileBytes(store.connection, indexPath) + stalls.sort((a, b) => a - b) + transactions.sort((a, b) => a - b) + console.log( + JSON.stringify( + { + platform: process.platform, + node: process.version, + transcriptMb: Math.round((corpus.transcriptBytes / (1024 * 1024)) * 100) / 100, + toolResultWords, + sessions, + rows, + rebuildMs: Math.round(rebuildMs), + rowsPerSecond: Math.round(rows / (rebuildMs / 1000)), + transcriptMbPerSecond: + Math.round((corpus.transcriptBytes / (1024 * 1024) / (rebuildMs / 1000)) * 100) / 100, + bytesPerTranscriptMb: { + messagesFts: perMb(bytes.messagesFts), + messages: perMb(bytes.messages), + sessions: perMb(bytes.sessions), + total: perMb(bytes.total) + }, + writeAmplification: Math.round((bytes.total / corpus.transcriptBytes) * 100) / 100, + fileWriteAmplification: Math.round((fileBytes / corpus.transcriptBytes) * 100) / 100, + transactions: transactions.length, + maxTransactionMs: Math.round((transactions.at(-1) ?? 0) * 100) / 100, + maxLoopStallMs: Math.round(stalls.at(-1) ?? 0), + p95LoopStallMs: Math.round(stalls[Math.floor(stalls.length * 0.95)] ?? 0), + loopStallSamples: stalls.length, + parseStats: stats + }, + null, + 2 + ) + ) + } finally { + reader.close() + } + } finally { + indexing = false + restoreExec() + unregister() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + store.close() + } +} finally { + await rm(corpus.root, { recursive: true, force: true }) +} + +// Phase two: one transcript far larger than any real one, to price the ceiling +// that decides whether a file commits once or in chunks. +const largeTurns = Number(process.env.ORCA_SEARCH_BENCH_LARGE_TURNS ?? 23_000) +const large = await writeSyntheticTranscriptCorpus({ + sessions: 1, + turnsPerSession: largeTurns, + seed: 2 +}) +const largeIndexPath = join(large.root, 'index.sqlite') +try { + const errors: unknown[] = [] + const store = new SessionSearchStore(largeIndexPath, (error) => errors.push(error)) + const unregister = registerSessionSearchIndexConsumer(store) + const transactions: number[] = [] + const restoreExec = recordTransactionDurations(transactions) + try { + const stats = createSessionParseStats() + const started = performance.now() + await parseAgentSessionFileCached( + await sessionCandidate('claude', large.files[0]!), + process.platform, + stats + ) + const indexMs = performance.now() - started + restoreExec() + assert.deepEqual(errors, []) + assertIndexedMessages(store.connection, large.messageCount) + transactions.sort((a, b) => a - b) + + // The same file again, over a generation the index already holds. That is + // the pass a growing transcript really costs, and the one whose transaction + // used to be sized by the old session rather than by the chunk being + // written. The drain that reclaims the cut-loose generation runs after the + // commit, so its bounded batches are in `replaceTransactions` too. + const replaceTransactions: number[] = [] + const restoreReplaceExec = recordTransactionDurations(replaceTransactions) + requestWholeTranscriptRead(large.files[0]!) + const replaceStarted = performance.now() + await parseAgentSessionFileCached( + await sessionCandidate('claude', large.files[0]!), + process.platform, + stats + ) + const replaceMs = performance.now() - replaceStarted + // Finishes whatever the scheduled drain has not reached, so the reclaim is + // priced rather than left half done under the next measurement. + const reclaimStarted = performance.now() + await store.purgeOlderThan(null) + const reclaimMs = performance.now() - reclaimStarted + restoreReplaceExec() + assert.deepEqual(errors, []) + assertIndexedMessages(store.connection, large.messageCount) + replaceTransactions.sort((a, b) => a - b) + + console.log( + JSON.stringify( + { + phase: 'single-large-file', + transcriptMb: Math.round((large.transcriptBytes / (1024 * 1024)) * 100) / 100, + indexMs: Math.round(indexMs), + transactions: transactions.length, + maxTransactionMs: Math.round(transactions.at(-1) ?? 0), + replaceMs: Math.round(replaceMs), + replaceTransactions: replaceTransactions.length, + maxReplaceTransactionMs: Math.round(replaceTransactions.at(-1) ?? 0), + reclaimMs: Math.round(reclaimMs), + indexMb: + Math.round( + ((await checkpointedFileBytes(store.connection, largeIndexPath)) / (1024 * 1024)) * + 100 + ) / 100 + }, + null, + 2 + ) + ) + } finally { + restoreExec() + unregister() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + store.close() + } +} finally { + await rm(large.root, { recursive: true, force: true }) +} diff --git a/src/main/ai-vault-search/session-search-content-hash.test.ts b/src/main/ai-vault-search/session-search-content-hash.test.ts new file mode 100644 index 00000000000..1e7232fc855 --- /dev/null +++ b/src/main/ai-vault-search/session-search-content-hash.test.ts @@ -0,0 +1,48 @@ +import { expect, it } from 'vitest' +import { + EMPTY_CONTENT_HASH, + foldContentHash, + isCollapsibleContentHash +} from './session-search-content-hash' +import { userMessages } from './session-search-index-test-fixture' + +it('reaches the same digest whether the prefix arrives whole or in two appends', () => { + const messages = userMessages('turn', 5) + const whole = foldContentHash(EMPTY_CONTENT_HASH, messages) + const resumed = foldContentHash( + foldContentHash(EMPTY_CONTENT_HASH, messages.slice(0, 2)), + messages.slice(2) + ) + + expect(resumed).toEqual(whole) + expect(whole.count).toBe(5) +}) + +it('freezes once the prefix limit is reached so later appends cannot move it', () => { + // Found rather than imported: the limit is the module's business, and a test + // that reads it off the export cannot notice the fold ignoring it. + const capped = foldContentHash(EMPTY_CONTENT_HASH, userMessages('turn', 64)) + expect(capped.count).toBeLessThan(64) + expect(foldContentHash(capped, userMessages('later', 20))).toEqual(capped) +}) + +it('separates two conversations that share an opening prompt', () => { + const shared = userMessages('same opening', 1) + const first = foldContentHash(EMPTY_CONTENT_HASH, [ + ...shared, + { role: 'user', text: 'left', timestamp: null } + ]) + const second = foldContentHash(EMPTY_CONTENT_HASH, [ + ...shared, + { role: 'user', text: 'right', timestamp: null } + ]) + expect(first.hash).not.toBe(second.hash) +}) + +it('refuses to collapse on a prefix too short to mean anything', () => { + const one = foldContentHash(EMPTY_CONTENT_HASH, userMessages('only turn', 1)) + expect(isCollapsibleContentHash(one.hash, one.count)).toBe(false) + const two = foldContentHash(EMPTY_CONTENT_HASH, userMessages('two turns', 2)) + expect(isCollapsibleContentHash(two.hash, two.count)).toBe(true) + expect(isCollapsibleContentHash(null, 9)).toBe(false) +}) diff --git a/src/main/ai-vault-search/session-search-content-hash.ts b/src/main/ai-vault-search/session-search-content-hash.ts new file mode 100644 index 00000000000..a9d2ab229da --- /dev/null +++ b/src/main/ai-vault-search/session-search-content-hash.ts @@ -0,0 +1,45 @@ +import { createHash } from 'node:crypto' +import type { TranscriptMessage } from '../ai-vault/session-transcript-consumers' + +// Why: Claude `--resume` and Codex fork copy the parent transcript into a new +// file under a new session id, so one conversation lands N times in results. +// The shared opening prefix is what identifies the copy; the tail diverges. +const CONTENT_HASH_MESSAGE_LIMIT = 8 +// One shared opening prompt is not evidence of a fork; two turns is. +const CONTENT_HASH_MIN_MESSAGES = 2 + +export type SessionContentHash = { hash: string | null; count: number } + +export const EMPTY_CONTENT_HASH: SessionContentHash = { hash: null, count: 0 } + +/** + * Chained digest over the first `CONTENT_HASH_MESSAGE_LIMIT` messages. Chaining + * (rather than hashing one joined string) makes it resumable, so an `append` + * can finish a prefix a short `replace` started; once the limit is reached the + * value is frozen and later appends leave it untouched. + */ +export function foldContentHash( + previous: SessionContentHash, + messages: readonly TranscriptMessage[] +): SessionContentHash { + let { hash, count } = previous + for (const message of messages) { + if (count >= CONTENT_HASH_MESSAGE_LIMIT) { + break + } + hash = createHash('sha256') + .update(hash ?? '') + .update('\0') + .update(message.role) + .update('\0') + .update(message.text) + .digest('hex') + count += 1 + } + return { hash, count } +} + +/** Sessions collapse only on a hash that covers enough turns to mean anything. */ +export function isCollapsibleContentHash(hash: string | null, count: number): hash is string { + return hash !== null && count >= CONTENT_HASH_MIN_MESSAGES +} diff --git a/src/main/ai-vault-search/session-search-cwd-key.test.ts b/src/main/ai-vault-search/session-search-cwd-key.test.ts new file mode 100644 index 00000000000..24d915eedc9 --- /dev/null +++ b/src/main/ai-vault-search/session-search-cwd-key.test.ts @@ -0,0 +1,37 @@ +import { expect, it } from 'vitest' +import { folderGroupKey } from '../../shared/ai-vault-session-filters' +import { cwdKey } from './session-search-file-records' + +// The sidebar groups sessions by `folderGroupKey`, which is the shared +// normalizer under a `folder:` prefix. A hit's `cwd_key` has to be the same +// string, or joining an indexed hit to a sidebar group returns nothing. +const CASES: [name: string, cwd: string][] = [ + ['a POSIX path', '/repo/app'], + ['a trailing slash', '/repo/app/'], + ['a Windows drive', 'C:\\Users\\me\\repo'], + ['a WSL interop mount', '/mnt/c/Users/me/repo'], + ['a WSL UNC path', '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo'], + ['the wsl$ alias for the same path', '//wsl$/Ubuntu/home/me/repo'], + ['a Linux path from inside WSL', '/home/me/repo'], + ['the filesystem root', '/'] +] + +it.each(CASES)('keys %s exactly as the sidebar does', (_name, cwd) => { + expect(`folder:${cwdKey(cwd)}`).toBe(folderGroupKey(cwd)) +}) + +it('keeps the root as a path rather than collapsing it to nothing', () => { + // An empty key is indistinguishable from "no cwd", and the scope filter builds + // its child prefix as `key + '/'`, which would be `//` for an empty key. + expect(cwdKey('/')).toBe('/') +}) + +it('has no key for a session whose cwd the transcript never recorded', () => { + expect(cwdKey(null)).toBeNull() +}) + +it('folds the two WSL UNC aliases onto one key', () => { + expect(cwdKey('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo')).toBe( + cwdKey('//wsl$/ubuntu/home/me/repo') + ) +}) diff --git a/src/main/ai-vault-search/session-search-file-cursor.ts b/src/main/ai-vault-search/session-search-file-cursor.ts new file mode 100644 index 00000000000..2ba978b00ae --- /dev/null +++ b/src/main/ai-vault-search/session-search-file-cursor.ts @@ -0,0 +1,41 @@ +import type { FileWithMtime } from '../ai-vault/session-scanner-types' + +// Why the index keeps its own cursor: the parse cache's cursor answers "what +// does the session list already show", which is a different question from "what +// bytes of this file are already rows". They diverge the moment either side +// declines a read, so neither may consult the other. + +/** Filesystem identity, when discovery could prove it. */ +export type SessionSearchFileIdentity = { dev: number; ino: number } | null + +/** + * What the index holds for one transcript. + * + * A null `byteOffset` is a file the index holds rows for and cannot continue: + * a chunked read committed a prefix, and the reader only hands out an offset + * when a read finishes. Null rather than a flag because every caller that does + * arithmetic on the offset then has to say what it means here, at compile time, + * instead of ignoring a boolean it did not know to read. + */ +export type SessionSearchIndexedFile = { + byteOffset: number | null + mtimeMs: number + sizeBytes: number | null +} + +/** + * Whether this file has to be read from the start, whatever its stat says. + * + * The mtime and size are the real ones, so a freshness check that compares only + * those would call a half-written file current and never re-read it. Every such + * check must start here. + */ +export function requiresWholeRead(indexed: SessionSearchIndexedFile | null): boolean { + return indexed !== null && indexed.byteOffset === null +} + +export function fileIdentity(file: FileWithMtime): SessionSearchFileIdentity { + return typeof file.dev === 'number' && typeof file.ino === 'number' + ? { dev: file.dev, ino: file.ino } + : null +} diff --git a/src/main/ai-vault-search/session-search-file-records.ts b/src/main/ai-vault-search/session-search-file-records.ts new file mode 100644 index 00000000000..2ee79cbdc13 --- /dev/null +++ b/src/main/ai-vault-search/session-search-file-records.ts @@ -0,0 +1,134 @@ +import { fileIdentity } from './session-search-file-cursor' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { TranscriptSessionIdentity } from '../ai-vault/session-transcript-consumers' +import type { SessionFileCandidate } from '../ai-vault/session-scanner-types' +import type SyncDatabase from '../sqlite/sync-database' +import { EMPTY_CONTENT_HASH, type SessionContentHash } from './session-search-content-hash' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' + +/** + * The stored comparison key for a session's working directory. + * + * Why the shared normalizer verbatim: the sidebar already groups sessions by + * `folderGroupKey`, which is this function under a prefix. A second spelling + * here means any later join between an indexed hit and a sidebar group returns + * nothing. An earlier version qualified a WSL cwd with its distro so two + * distros could not collide at `/home/me/repo`; that is a real collision, but it + * is one every SSH host has too, neither key qualifies for SSH, and the fix for + * it is a column that names the execution host, not a path key that only some + * hosts spell differently. + */ +export function cwdKey(cwd: string | null): string | null { + return cwd ? normalizeRuntimePathForComparison(cwd) : null +} + +export class SessionSearchFileRecords { + constructor(private readonly db: SyncDatabase) {} + /** + * The row a read hangs its messages off, before the parser has said what the + * session is. The same transaction fills it in: from the decoded session when + * the read finished, and from `updateProvisionalSession` when this is a chunk + * of one that has not. + */ + createSessionRow(candidate: SessionFileCandidate): number { + return Number( + this.db + .prepare( + `INSERT INTO sessions(agent,session_id,file_path,title,resume_command) + VALUES (?,'',?,'','')` + ) + .run(candidate.agent, candidate.file.path).lastInsertRowid + ) + } + + /** + * Writes what the parser knows so far onto a session a chunk is committing. + * + * Rows a chunk commits answer searches the moment they land, so the session + * they hang off has to be nameable before the read producing it ends — and it + * may never end, because a crash between chunks leaves exactly this row. That + * is why the identity is required rather than optional: a read that has none + * does not chunk at all. The final commit overwrites all of it from the + * decoded session; until then the title in particular is provisional. + */ + updateProvisionalSession(rowId: number, identity: TranscriptSessionIdentity): void { + this.db + .prepare( + `UPDATE sessions SET session_id = ?, title = ?, cwd = ?, cwd_key = ?, + created_at = ?, updated_at = ? WHERE id = ?` + ) + .run( + identity.sessionId, + identity.title ?? '', + identity.cwd, + cwdKey(identity.cwd), + identity.createdAt, + identity.updatedAt, + rowId + ) + } + + contentHash(rowId: number): SessionContentHash { + const row = this.db + .prepare('SELECT content_hash, content_hash_count FROM sessions WHERE id = ?') + .get(rowId) as { content_hash: string | null; content_hash_count: number } | undefined + return row ? { hash: row.content_hash, count: row.content_hash_count } : EMPTY_CONTENT_HASH + } + + updateSession(session: AiVaultSession, rowId: number, contentHash: SessionContentHash): void { + const values = [ + session.agent, + session.sessionId, + session.filePath, + session.codexHome, + session.title, + session.cwd, + cwdKey(session.cwd), + session.branch, + session.createdAt, + session.updatedAt, + session.messageCount, + session.resumeCommand, + contentHash.hash, + contentHash.count + ] + this.db + .prepare( + `UPDATE sessions SET agent = ?, session_id = ?, file_path = ?, codex_home = ?, title = ?, + cwd = ?, cwd_key = ?, branch = ?, created_at = ?, updated_at = ?, message_count = ?, resume_command = ?, + content_hash = ?, content_hash_count = ? WHERE id = ?` + ) + .run(...values, rowId) + } + + upsertFile( + candidate: SessionFileCandidate, + byteOffset: number, + sessionRowId: number | null + ): void { + const { file } = candidate + const identity = fileIdentity(file) + this.db + .prepare( + `INSERT INTO files(path, dev, ino, byte_offset, mtime_ms, size_bytes, session_row_id) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(path) DO UPDATE SET + -- Partial observations must never create a pair that no stat proved. + dev = CASE WHEN excluded.dev IS NOT NULL AND excluded.ino IS NOT NULL + THEN excluded.dev ELSE files.dev END, + ino = CASE WHEN excluded.dev IS NOT NULL AND excluded.ino IS NOT NULL + THEN excluded.ino ELSE files.ino END, + byte_offset = excluded.byte_offset, mtime_ms = excluded.mtime_ms, + size_bytes = excluded.size_bytes, session_row_id = excluded.session_row_id` + ) + .run( + file.path, + identity?.dev ?? null, + identity?.ino ?? null, + byteOffset, + file.mtimeMs, + file.sizeBytes ?? null, + sessionRowId + ) + } +} diff --git a/src/main/ai-vault-search/session-search-file-write.test.ts b/src/main/ai-vault-search/session-search-file-write.test.ts new file mode 100644 index 00000000000..942565e18d6 --- /dev/null +++ b/src/main/ai-vault-search/session-search-file-write.test.ts @@ -0,0 +1,614 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers' +import SyncDatabase from '../sqlite/sync-database' +import { registerSessionSearchIndexConsumer } from './session-search-index-consumer' +import { cwdKey } from './session-search-file-records' +import { requiresWholeRead } from './session-search-file-cursor' +import { SessionSearchIndexWriter } from './session-search-index-writer' +import { deleteExpiredSearchFiles } from './session-search-retention-delete' +import { + openSessionSearchIndexFile, + replayTranscriptRead, + syntheticCandidate, + syntheticSession, + SYNTHETIC_TRANSCRIPT, + userMessages, + type SessionSearchIndexFile +} from './session-search-index-test-fixture' +import { SessionSearchStore } from './session-search-store' + +// Every assertion here reads through `index.db`, a second connection to the same +// file. That is the whole consistency model: one transaction per file in WAL +// mode, so another handle sees the last committed state and never a session part +// way through being rewritten. + +let index: SessionSearchIndexFile +let store: SessionSearchStore +let errors: unknown[] + +beforeEach(async () => { + index = await openSessionSearchIndexFile('ss-file-write') + errors = [] + store = new SessionSearchStore(index.path, (error) => errors.push(error)) + registerSessionSearchIndexConsumer(store) +}) + +afterEach(async () => { + vi.restoreAllMocks() + resetTranscriptConsumersForTests() + store.close() + await index.close() +}) + +function matches(db: SyncDatabase, table: string, term: string): number { + return ( + db + .prepare( + `SELECT count(*) AS n FROM ${table} JOIN messages m ON m.id = ${table}.rowid + JOIN sessions s ON s.id = m.session_row_id WHERE ${table} MATCH ?` + ) + .get(term) as { n: number } + ).n +} + +/** Fails the nth statement matching `pick`, wherever the writer prepares it. */ +function failOnStatement(pick: (sql: string) => boolean, nth: number): void { + const prepare = SyncDatabase.prototype.prepare + let seen = 0 + vi.spyOn(SyncDatabase.prototype, 'prepare').mockImplementation(function ( + this: SyncDatabase, + sql: string + ) { + if (pick(sql) && ++seen === nth) { + throw new Error('index write crashed mid transaction') + } + return prepare.call(this, sql) + }) +} + +function counts(db: SyncDatabase): Record { + const one = (sql: string): number => (db.prepare(sql).get() as { n: number }).n + return { + sessions: one('SELECT count(*) AS n FROM sessions'), + messages: one('SELECT count(*) AS n FROM messages'), + files: one('SELECT count(*) AS n FROM files'), + full: one('SELECT count(*) AS n FROM messages_fts') + } +} + +it('writes a whole read in one transaction', () => { + replayTranscriptRead({ messages: userMessages('needle text', 300) }) + + const after = counts(index.db) + expect(after.sessions).toBe(1) + expect(after.messages).toBe(300) + expect(after.full).toBe(300) + expect(errors).toEqual([]) +}) + +it('files every row in one FTS table, under the column its role owns', () => { + replayTranscriptRead({ + messages: [ + { role: 'user', text: 'alpha question', timestamp: null }, + { role: 'assistant', text: 'beta answer', timestamp: null }, + { role: 'tool', text: 'gamma tool output', timestamp: null } + ] + }) + + // One table carries all three; the conversation scope is a column filter over + // it, which is what the second table used to be. + expect(counts(index.db).full).toBe(3) + expect(matches(index.db, 'messages_fts', 'gamma')).toBe(1) + expect(matches(index.db, 'messages_fts', '{user_text assistant_text}: gamma')).toBe(0) + expect(matches(index.db, 'messages_fts', '{user_text assistant_text}: beta')).toBe(1) +}) + +it('leaves the index exactly as it found it when a read never finishes', () => { + const write = store.beginWrite(syntheticCandidate(), 'replace', 0)! + for (const message of userMessages('neverfinished', 200)) { + write.add(message) + } + // The process dies here: the rows only ever existed in this buffer. + expect(counts(index.db)).toMatchObject({ + sessions: 0, + messages: 0, + files: 0 + }) +}) + +it('rolls a whole file back when a write throws part way through its transaction', () => { + replayTranscriptRead({ + messages: userMessages('firstgeneration', 3), + outcome: { byteOffset: 40 } + }) + const before = counts(index.db) + + failOnStatement((sql) => sql.startsWith('INSERT INTO messages('), 50) + replayTranscriptRead({ + messages: userMessages('crashedgeneration', 100), + outcome: { byteOffset: 900 } + }) + vi.restoreAllMocks() + + // Not one of the 49 rows that were already inserted survived, the previous + // generation is untouched, and the cursor still describes what is really here. + expect(counts(index.db)).toEqual(before) + expect(matches(index.db, 'messages_fts', 'crashedgeneration')).toBe(0) + expect(matches(index.db, 'messages_fts', 'firstgeneration')).toBe(3) + expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset).toBe(40) + expect(errors).toHaveLength(1) + // The file is owed a re-read, which is the only reason anything was lost. + expect(store.takeStale().map((candidate) => candidate.file.path)).toEqual([SYNTHETIC_TRANSCRIPT]) + + // And the connection is usable again: a transaction left open by the failure + // would take down every write after it, not just the one that threw. + replayTranscriptRead({ + messages: userMessages('afterthecrash', 2), + outcome: { byteOffset: 900 } + }) + expect(matches(index.db, 'messages_fts', 'afterthecrash')).toBe(2) + expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset).toBe(900) +}) + +it('takes the rows back when recording the cursor is what fails', () => { + replayTranscriptRead({ + messages: userMessages('firstgeneration', 3), + outcome: { byteOffset: 40 } + }) + + // The cursor is written last, so this is the crash point that would leave rows + // no cursor describes: a later append would continue from an offset those rows + // already cover, and index the same span twice. + failOnStatement((sql) => sql.startsWith('INSERT INTO files('), 1) + replayTranscriptRead({ + messages: userMessages('crashedgeneration', 5), + outcome: { byteOffset: 900 } + }) + vi.restoreAllMocks() + + expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 3 }) + expect(matches(index.db, 'messages_fts', 'firstgeneration')).toBe(3) + expect(matches(index.db, 'messages_fts', 'crashedgeneration')).toBe(0) + expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset).toBe(40) +}) + +it('shows a reader on another handle one generation or the other, never a mixture', async () => { + replayTranscriptRead({ + messages: userMessages('firstgeneration', 3), + outcome: { byteOffset: 40 } + }) + expect(counts(index.db).messages).toBe(3) + + const write = store.beginWrite(syntheticCandidate(), 'replace', 0)! + for (const message of userMessages('secondgeneration', 7)) { + write.add(message) + // Every point at which the other handle could issue a query mid-read. + expect(counts(index.db).messages).toBe(3) + expect(matches(index.db, 'messages_fts', 'secondgeneration')).toBe(0) + } + expect( + write.commit({ + session: syntheticSession(), + byteOffset: 900, + incomplete: false + }) + ).toBe(true) + + expect(matches(index.db, 'messages_fts', 'firstgeneration')).toBe(0) + expect(matches(index.db, 'messages_fts', 'secondgeneration')).toBe(7) + // The old three are cut loose, not deleted, so they are still on disk and + // already unreachable; the drain the store scheduled hands them back. + expect(counts(index.db).messages).toBe(10) + await vi.waitFor(() => { + expect(counts(index.db).messages).toBe(7) + }) +}) + +// Four of these fill the 400-char ceiling the two tests below construct. +const CHUNKED_MESSAGE = `chunkedneedle ${'filler '.repeat(12)}nd` + +const PROVISIONAL_IDENTITY = { + sessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', + cwd: '/repo/app', + title: 'provisional title', + createdAt: '2026-05-01T10:00:00.000Z', + updatedAt: '2026-05-01T10:05:00.000Z' +} + +// Only a read that can name its session chunks at all, so every test below that +// wants a chunk has to supply one. +const named = (): typeof PROVISIONAL_IDENTITY => PROVISIONAL_IDENTITY + +it('leaves the session consistent after every chunk of a file too large for one transaction', () => { + expect(CHUNKED_MESSAGE.length).toBe(100) + const writer = new SessionSearchIndexWriter(index.db, 400) + const write = writer.beginWrite(syntheticCandidate(), 'replace', 0, named)! + for (const [position, message] of userMessages(CHUNKED_MESSAGE, 10).entries()) { + write.add(message) + const rows = counts(index.db).messages + // Four messages per chunk, and nothing else reaches the file between them. + expect(rows).toBe(Math.floor((position + 1) / 4) * 4) + // Whatever landed is a coherent prefix of this session and answers searches. + expect(matches(index.db, 'messages_fts', 'chunkedneedle')).toBe(rows) + if (rows > 0) { + // The cursor a chunk leaves refuses every append rather than inventing an + // offset the reader never gave it. + expect(requiresWholeRead(writer.indexedFile(SYNTHETIC_TRANSCRIPT, null))).toBe(true) + expect(writer.beginWrite(syntheticCandidate(), 'append', 0)).toBeNull() + } + } + expect(counts(index.db).messages).toBe(8) + + expect( + write.commit({ + session: syntheticSession(), + byteOffset: 4096, + incomplete: false + }) + ).toBe(true) + expect(counts(index.db)).toMatchObject({ + sessions: 1, + messages: 10, + full: 10 + }) + expect(writer.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset).toBe(4096) +}) + +it('holds the ceiling against a single message larger than it', () => { + const writer = new SessionSearchIndexWriter(index.db, 8000) + const write = writer.beginWrite(syntheticCandidate(), 'replace', 0, named)! + const exec = SyncDatabase.prototype.exec + let opened = 0 + vi.spyOn(SyncDatabase.prototype, 'exec').mockImplementation(function ( + this: SyncDatabase, + sql: string + ) { + if (sql === 'BEGIN IMMEDIATE') { + opened += 1 + } + exec.call(this, sql) + }) + + // One conversation turn, three times the ceiling. Checked once per message, + // this commits all 24,000 characters in a single transaction — the ceiling + // bounds nothing that a message can exceed on its own. + write.add({ role: 'assistant', text: 'a'.repeat(24_000), timestamp: null }) + vi.restoreAllMocks() + + expect(opened).toBe(3) + expect(counts(index.db).messages).toBe(3) + expect( + write.commit({ + session: syntheticSession(), + byteOffset: 4096, + incomplete: false + }) + ).toBe(true) + expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 3 }) +}) + +it('names a session on its first chunk, not only when the read ends', () => { + const writer = new SessionSearchIndexWriter(index.db, 400) + const write = writer.beginWrite(syntheticCandidate(), 'replace', 0, named)! + for (const message of userMessages(CHUNKED_MESSAGE, 10)) { + write.add(message) + } + + // The chunks that landed already answer searches, so the session they hang + // off has to be nameable on another handle before the read ends. This is also + // the whole record a crash between chunks leaves behind. + expect(counts(index.db).messages).toBe(8) + expect( + index.db.prepare('SELECT session_id, cwd, cwd_key, title, created_at FROM sessions').get() + ).toEqual({ + session_id: PROVISIONAL_IDENTITY.sessionId, + cwd: '/repo/app', + cwd_key: cwdKey('/repo/app'), + title: 'provisional title', + created_at: '2026-05-01T10:00:00.000Z' + }) + + // And the decoded session still wins at the end: the mid-read title is + // provisional, never a value the final commit has to defer to. + expect( + write.commit({ + session: syntheticSession({ title: 'the settled title' }), + byteOffset: 4096, + incomplete: false + }) + ).toBe(true) + expect(index.db.prepare('SELECT title FROM sessions').get()).toEqual({ + title: 'the settled title' + }) +}) + +it('commits a whole-file read over the ceiling in one transaction, never a chunk', () => { + // The whole-file readers (Grok, Cursor, Gemini, OpenCode) pass no identity: + // their formats are rewritten in place and have no resumable state to ask. + const writer = new SessionSearchIndexWriter(index.db, 400) + const write = writer.beginWrite(syntheticCandidate(), 'replace', 0)! + const exec = SyncDatabase.prototype.exec + let opened = 0 + vi.spyOn(SyncDatabase.prototype, 'exec').mockImplementation(function ( + this: SyncDatabase, + sql: string + ) { + if (sql === 'BEGIN IMMEDIATE') { + opened += 1 + } + exec.call(this, sql) + }) + + for (const message of userMessages(CHUNKED_MESSAGE, 10)) { + write.add(message) + // Chunking here would publish rows under a session with an empty id, an + // empty title and a null cwd, and an interrupted read would leave that + // prefix answering searches for good. + expect(counts(index.db)).toMatchObject({ sessions: 0, messages: 0, files: 0 }) + } + expect(write.commit({ session: syntheticSession(), byteOffset: 4096, incomplete: false })).toBe( + true + ) + vi.restoreAllMocks() + + expect(opened).toBe(1) + expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 10, full: 10 }) + // And a real cursor, not the partial sentinel a chunk would have left. + expect(writer.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset).toBe(4096) +}) + +it('starts chunking only once the parser has an id to name the session with', () => { + const writer = new SessionSearchIndexWriter(index.db, 400) + let decoded: typeof PROVISIONAL_IDENTITY | null = null + const write = writer.beginWrite(syntheticCandidate(), 'replace', 0, () => decoded)! + for (const message of userMessages(CHUNKED_MESSAGE, 4)) { + write.add(message) + } + // Past the ceiling, but the parser has decoded nothing: the buffer keeps + // growing rather than naming a session it cannot name. + expect(counts(index.db).messages).toBe(0) + + decoded = PROVISIONAL_IDENTITY + write.add(userMessages(CHUNKED_MESSAGE, 1)[0]!) + + // Everything held goes with the first chunk that can say what it is. + expect(counts(index.db).messages).toBe(5) + expect(index.db.prepare('SELECT session_id, cwd FROM sessions').get()).toEqual({ + session_id: PROVISIONAL_IDENTITY.sessionId, + cwd: '/repo/app' + }) +}) + +it('reports a chunk-partial file as held, and as one that must be read whole', () => { + const writer = new SessionSearchIndexWriter(index.db, 400) + const write = writer.beginWrite(syntheticCandidate(), 'replace', 0, named)! + for (const message of userMessages(CHUNKED_MESSAGE, 10)) { + write.add(message) + } + + // Held, with no cursor to continue. Reporting nothing here reads as "never + // indexed", so a caller asks for whatever read the parse cache offers, the + // reader picks append, and only a decline heals it a cycle later. + const held = writer.indexedFile(SYNTHETIC_TRANSCRIPT, null) + expect(held).not.toBeNull() + expect(held?.byteOffset).toBeNull() + expect(requiresWholeRead(held)).toBe(true) + expect(held?.mtimeMs).toBe(syntheticCandidate().file.mtimeMs) + + // A file this index has never seen is still the other answer, so the two + // states a caller has to tell apart are distinguishable. + expect(writer.indexedFile('/never-seen.jsonl', null)).toBeNull() + expect(requiresWholeRead(null)).toBe(false) + + // And no offset continues it, including the one the chunk recorded. + for (const offset of [0, -1, 400, 1000]) { + expect(writer.beginWrite(syntheticCandidate(), 'append', offset)).toBeNull() + } +}) + +it('re-reads a chunked file whole when its writer died between chunks', async () => { + const writer = new SessionSearchIndexWriter(index.db, 400) + const abandoned = writer.beginWrite(syntheticCandidate(), 'replace', 0, named)! + for (const message of userMessages(CHUNKED_MESSAGE, 10)) { + abandoned.add(message) + } + expect(counts(index.db).messages).toBe(8) + + // Nothing can continue that prefix, so the only way forward is a whole re-read, + // and that replaces every row the dead writer left. + expect(requiresWholeRead(writer.indexedFile(SYNTHETIC_TRANSCRIPT, null))).toBe(true) + const replacement = writer.beginWrite(syntheticCandidate(), 'replace', 0)! + replacement.add(userMessages('wholereread', 1)[0]!) + expect( + replacement.commit({ + session: syntheticSession(), + byteOffset: 4096, + incomplete: false + }) + ).toBe(true) + // The eight stranded rows stop answering the moment the replace commits, and + // the drain hands them back after it rather than inside it. + expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 9 }) + expect(matches(index.db, 'messages_fts', 'chunkedneedle')).toBe(0) + await deleteExpiredSearchFiles(index.db, null, () => false) + expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 1 }) +}) + +it('stops a chunked read whose file was removed between its chunks', () => { + const writer = new SessionSearchIndexWriter(index.db, 400) + const write = writer.beginWrite(syntheticCandidate(), 'replace', 0, named)! + const messages = userMessages(CHUNKED_MESSAGE, 10) + for (const message of messages.slice(0, 4)) { + write.add(message) + } + expect(counts(index.db).messages).toBe(4) + + writer.removeFile(SYNTHETIC_TRANSCRIPT) + const exec = SyncDatabase.prototype.exec + let opened = 0 + vi.spyOn(SyncDatabase.prototype, 'exec').mockImplementation(function ( + this: SyncDatabase, + sql: string + ) { + if (sql === 'BEGIN IMMEDIATE') { + opened += 1 + } + exec.call(this, sql) + }) + for (const message of messages.slice(4)) { + write.add(message) + } + expect(write.commit({ session: syntheticSession(), byteOffset: 4096, incomplete: false })).toBe( + false + ) + vi.restoreAllMocks() + + // Not one row of the removed source came back. The read stopped at the first + // refusal rather than reopening a transaction it already knows will roll back, + // once for every message left in a file that may be a hundred megabytes. + expect(opened).toBe(1) + expect(counts(index.db)).toMatchObject({ sessions: 0, messages: 0, files: 0, full: 0 }) +}) + +it('fences a first-ever read whose file was removed before it committed', () => { + const candidate = syntheticCandidate({ path: '/never-indexed.jsonl' }) + const write = store.beginWrite(candidate, 'replace', 0)! + for (const message of userMessages('removedbeforefirstcommit', 3)) { + write.add(message) + } + // The path was never indexed, so there is no cursor for the removal to move. + // PR 3's retirement sweep removes exactly these: paths the index deferred over + // budget and never wrote, while the registered consumer is fed concurrently. + store.removeFile('/never-indexed.jsonl') + + expect(write.commit({ session: syntheticSession(), byteOffset: 300, incomplete: false })).toBe( + false + ) + expect(counts(index.db)).toMatchObject({ sessions: 0, messages: 0, files: 0, full: 0 }) +}) + +it('replaces the previous generation without ever showing both', async () => { + replayTranscriptRead({ messages: userMessages('firstgeneration', 10) }) + replayTranscriptRead({ messages: userMessages('secondgeneration', 10) }) + + expect(matches(index.db, 'messages_fts', 'firstgeneration')).toBe(0) + expect(matches(index.db, 'messages_fts', 'secondgeneration')).toBe(10) + await vi.waitFor(() => { + expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 10, full: 10 }) + }) +}) + +it('replaces a generation by cutting the old one loose, not by deleting it inline', async () => { + const writer = new SessionSearchIndexWriter(index.db) + const first = writer.beginWrite(syntheticCandidate(), 'replace', 0)! + for (const message of userMessages('firstgeneration', 200)) { + first.add(message) + } + expect(first.commit({ session: syntheticSession(), byteOffset: 100, incomplete: false })).toBe( + true + ) + const before = index.db.prepare('SELECT id FROM sessions').get() as { id: number } + expect(counts(index.db).messages).toBe(200) + + const second = writer.beginWrite(syntheticCandidate(), 'replace', 0)! + for (const message of userMessages('secondgeneration', 3)) { + second.add(message) + } + expect(second.commit({ session: syntheticSession(), byteOffset: 200, incomplete: false })).toBe( + true + ) + + // The transaction inserted three rows and deleted one, rather than deleting + // two hundred: all 203 are still on disk, and the old 200 already answer + // nothing, because every retrieval joins `sessions`. + expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 203, full: 203 }) + expect(matches(index.db, 'messages_fts', 'firstgeneration')).toBe(0) + expect(matches(index.db, 'messages_fts', 'secondgeneration')).toBe(3) + + // A new session row, with `files` repointed at it in that same transaction. + // AUTOINCREMENT never hands the freed id back while orphans still name it. + const after = index.db.prepare('SELECT id FROM sessions').get() as { id: number } + expect(after.id).toBeGreaterThan(before.id) + expect(index.db.prepare('SELECT session_row_id FROM files').get()).toEqual({ + session_row_id: after.id + }) + + await deleteExpiredSearchFiles(index.db, null, () => false) + expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 3, full: 3 }) +}) + +it('drains what a replace cut loose without being asked', async () => { + replayTranscriptRead({ messages: userMessages('firstgeneration', 200) }) + replayTranscriptRead({ messages: userMessages('secondgeneration', 3) }) + + // The store schedules the reclaim the way it schedules retention's. Hiding a + // generation and never reclaiming it would grow the file by every re-read. + expect(matches(index.db, 'messages_fts', 'firstgeneration')).toBe(0) + await vi.waitFor(() => { + expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 3, full: 3 }) + }) + expect(errors).toEqual([]) +}) + +it('continues a session across an append rather than replaying it', () => { + replayTranscriptRead({ + messages: userMessages('openingturn', 3), + outcome: { byteOffset: 40 } + }) + replayTranscriptRead({ + messages: userMessages('laterturn', 2), + mode: 'append', + previousByteOffset: 40, + outcome: { byteOffset: 90 } + }) + + expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 5 }) + expect(matches(index.db, 'messages_fts', 'openingturn')).toBe(3) + expect(matches(index.db, 'messages_fts', 'laterturn')).toBe(2) + expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset).toBe(90) +}) + +it('stops answering for a removed file the moment it is removed', () => { + replayTranscriptRead({ messages: userMessages('removedneedle', 3) }) + store.removeFile(SYNTHETIC_TRANSCRIPT) + + expect(counts(index.db)).toMatchObject({ + sessions: 0, + messages: 0, + files: 0, + full: 0 + }) + expect(matches(index.db, 'messages_fts', 'removedneedle')).toBe(0) +}) + +it('writes nothing for an incomplete read and owes the file a whole re-read', () => { + replayTranscriptRead({ + messages: userMessages('incompleteread', 300), + outcome: { incomplete: true } + }) + + expect(counts(index.db)).toMatchObject({ + sessions: 0, + messages: 0, + files: 0, + full: 0 + }) + expect(store.pendingFileCount).toBe(1) + expect(errors).toEqual([]) +}) + +it('exposes the handle a composed reader queries through', () => { + replayTranscriptRead({ messages: userMessages('composedreader', 3) }) + + // PR 4's engine reads through this rather than opening a second connection, + // so it sees a write the moment the transaction commits. + expect(store.connection.prepare('SELECT count(*) AS n FROM messages').get()).toEqual({ n: 3 }) +}) + +it('closes twice without turning the second call into an error', () => { + store.close() + // node:sqlite throws ERR_INVALID_STATE on a second close of one handle, and a + // store is closed both by whoever owns it and by a teardown that cannot know. + expect(() => store.close()).not.toThrow() + store = new SessionSearchStore(index.path, (error) => errors.push(error)) +}) diff --git a/src/main/ai-vault-search/session-search-identifier-split.test.ts b/src/main/ai-vault-search/session-search-identifier-split.test.ts new file mode 100644 index 00000000000..24f8a67d5c3 --- /dev/null +++ b/src/main/ai-vault-search/session-search-identifier-split.test.ts @@ -0,0 +1,29 @@ +import { expect, it } from 'vitest' +import { identifierShadowTerms, identifierShadowText } from './session-search-identifier-split' + +it('splits a camel-case symbol into its pieces and keeps the whole', () => { + expect(identifierShadowTerms('call resolveTerminalPath here')).toEqual([ + 'resolveterminalpath', + 'resolve', + 'terminal', + 'path' + ]) +}) + +it('splits a path into its segments and extension', () => { + // The whole path already tokenizes on its own; only the pieces need shadowing. + expect(identifierShadowText('src/main/foo-bar.ts')).toBe('src main foo bar ts') +}) + +it('leaves ordinary prose alone', () => { + expect(identifierShadowTerms('the quick brown fox')).toEqual([]) +}) + +it('shadows a screaming-case constant', () => { + expect(identifierShadowTerms('MAX_RETRIES')).toEqual(['max', 'retries']) +}) + +it('stops at the term limit rather than growing with the message', () => { + const text = Array.from({ length: 50 }, (_unused, index) => `alpha_beta${index}`).join(' ') + expect(identifierShadowTerms(text, 10)).toHaveLength(10) +}) diff --git a/src/main/ai-vault-search/session-search-identifier-split.ts b/src/main/ai-vault-search/session-search-identifier-split.ts new file mode 100644 index 00000000000..e2df1822cfa --- /dev/null +++ b/src/main/ai-vault-search/session-search-identifier-split.ts @@ -0,0 +1,54 @@ +// Identifier shadow terms: `resolveTerminalPath` → `resolve terminal path`, +// `src/main/foo-bar.ts` → `src main foo bar ts`. Stored in a separate FTS5 +// column so a partial identifier still matches; the largest single accuracy +// win measured in the retrieval shoot-out (MRR 0.50 → 0.55). + +const RAW_TOKEN = /[A-Za-z0-9_./-]+/g +const CAMEL_PIECE = /[A-Z]+(?![a-z])|[A-Z][a-z0-9]*|[a-z0-9]+/g +const SEPARATOR = /[_./-]+/ +// Worth shadowing: has a separator, a camel boundary, or is SCREAMING_CASE. +const INTERESTING = /[_./-]|[a-z0-9][A-Z]|^[A-Z]{2,}[0-9_]*$/ +const MIN_TOKEN = 3 +const MAX_TOKEN = 120 +const MIN_PIECE = 2 + +function hasMixedCase(piece: string): boolean { + return /[a-z]/.test(piece) && /[A-Z]/.test(piece) +} + +export function identifierShadowTerms(text: string, limit = 4000): string[] { + const out: string[] = [] + const seen = new Set() + for (const match of text.matchAll(RAW_TOKEN)) { + const token = match[0] + if (token.length < MIN_TOKEN || token.length > MAX_TOKEN || !INTERESTING.test(token)) { + continue + } + const parts: string[] = [] + for (const piece of token.split(SEPARATOR)) { + if (!piece) { + continue + } + parts.push(piece) + if (hasMixedCase(piece)) { + parts.push(...(piece.match(CAMEL_PIECE) ?? [])) + } + } + for (const part of parts) { + const lowered = part.toLowerCase() + if (lowered.length < MIN_PIECE || seen.has(lowered)) { + continue + } + seen.add(lowered) + out.push(lowered) + if (out.length >= limit) { + return out + } + } + } + return out +} + +export function identifierShadowText(text: string, limit?: number): string { + return identifierShadowTerms(text, limit).join(' ') +} diff --git a/src/main/ai-vault-search/session-search-index-consumer.test.ts b/src/main/ai-vault-search/session-search-index-consumer.test.ts new file mode 100644 index 00000000000..ca02333cf0b --- /dev/null +++ b/src/main/ai-vault-search/session-search-index-consumer.test.ts @@ -0,0 +1,373 @@ +import { afterEach, beforeEach, expect, it } from 'vitest' +import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers' +import { registerSessionSearchIndexConsumer } from './session-search-index-consumer' +import { + openSessionSearchIndexFile, + replayTranscriptRead, + syntheticCandidate, + syntheticSession, + SYNTHETIC_TRANSCRIPT, + userMessages, + type SessionSearchIndexFile +} from './session-search-index-test-fixture' +import { SessionSearchStore, STALE_PATH_LIMIT } from './session-search-store' + +let index: SessionSearchIndexFile +let store: SessionSearchStore +let errors: unknown[] + +beforeEach(async () => { + index = await openSessionSearchIndexFile('ss-index-consumer') + errors = [] + store = new SessionSearchStore(index.path, (error) => errors.push(error)) + registerSessionSearchIndexConsumer(store) +}) + +afterEach(async () => { + resetTranscriptConsumersForTests() + store.close() + await index.close() +}) + +function indexedMessages(): number { + return ( + index.db.prepare('SELECT count(*) AS n FROM messages').get() as { + n: number + } + ).n +} + +function cursor(): number | null | undefined { + return store.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset +} + +it('appends onto its own cursor and carries the content hash forward', async () => { + replayTranscriptRead({ + messages: userMessages('first half', 3), + outcome: { byteOffset: 100 } + }) + const first = index.db + .prepare('SELECT content_hash AS hash, content_hash_count AS count FROM sessions') + .get() as { hash: string; count: number } + + replayTranscriptRead({ + mode: 'append', + previousByteOffset: 100, + messages: userMessages('second half', 2), + outcome: { byteOffset: 220 } + }) + + expect(indexedMessages()).toBe(5) + expect(cursor()).toBe(220) + const second = index.db + .prepare('SELECT content_hash AS hash, content_hash_count AS count FROM sessions') + .get() as { hash: string; count: number } + expect(second.count).toBe(first.count + 2) + expect(second.hash).not.toBe(first.hash) + expect(store.takeStale()).toEqual([]) +}) + +it('appends onto a file it read through and decoded no session from', async () => { + // An excluded Codex worker transcript: read through, nothing to index, and + // still growing. Its cursor is sound, so a re-read of the whole file every + // pass buys nothing. + replayTranscriptRead({ + messages: userMessages('excluded span', 3), + outcome: { session: null, byteOffset: 100 } + }) + expect(cursor()).toBe(100) + expect(store.takeStale()).toEqual([]) + + replayTranscriptRead({ + mode: 'append', + previousByteOffset: 100, + messages: userMessages('decoded at last', 2), + outcome: { byteOffset: 220 } + }) + + expect(indexedMessages()).toBe(2) + expect(cursor()).toBe(220) + expect(store.takeStale()).toEqual([]) +}) + +it('declines an append that starts past its own cursor and records the file', async () => { + replayTranscriptRead({ + messages: userMessages('indexed span', 3), + outcome: { byteOffset: 100 } + }) + + // The session list read further than this index did, so the appended span + // continues from bytes the index never saw. + replayTranscriptRead({ + mode: 'append', + previousByteOffset: 900, + messages: userMessages('unseen span', 4), + outcome: { byteOffset: 1200 } + }) + + expect(indexedMessages()).toBe(3) + expect(cursor()).toBe(100) + expect(store.takeStale().map((candidate) => candidate.file.path)).toEqual([SYNTHETIC_TRANSCRIPT]) +}) + +it('declines a file whose identity changed under the same path', async () => { + const original = syntheticCandidate({ dev: 1, ino: 10 }) + replayTranscriptRead({ + candidate: original, + messages: userMessages('original file', 2), + outcome: { byteOffset: 100 } + }) + + replayTranscriptRead({ + candidate: syntheticCandidate({ dev: 1, ino: 77 }), + mode: 'append', + previousByteOffset: 100, + messages: userMessages('replacement file', 2), + outcome: { byteOffset: 200 } + }) + + expect(indexedMessages()).toBe(2) + expect(store.takeStale()).toHaveLength(1) +}) + +it('never advances the cursor for an incomplete read', async () => { + replayTranscriptRead({ + messages: userMessages('complete span', 3), + outcome: { byteOffset: 100 } + }) + + replayTranscriptRead({ + mode: 'append', + previousByteOffset: 100, + messages: userMessages('partial span', 5), + outcome: { byteOffset: 400, incomplete: true } + }) + + expect(indexedMessages()).toBe(3) + expect(cursor()).toBe(100) + expect( + ( + index.db.prepare('SELECT count(*) AS n FROM messages').get() as { + n: number + } + ).n + ).toBe(3) + expect(store.takeStale()).toHaveLength(1) +}) + +it('indexes nothing at all from a read that was incomplete from the start', async () => { + replayTranscriptRead({ + messages: userMessages('unreachable', 4), + outcome: { byteOffset: 0, incomplete: true } + }) + + expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({ + n: 0 + }) + expect(index.db.prepare('SELECT count(*) AS n FROM messages').get()).toEqual({ + n: 0 + }) + expect(cursor()).toBeUndefined() +}) + +it('drops a file whose parser returned no session', async () => { + replayTranscriptRead({ + messages: userMessages('was indexed', 3), + outcome: { byteOffset: 100 } + }) + + replayTranscriptRead({ + messages: userMessages('now rejected', 2), + outcome: { session: null, byteOffset: 300 } + }) + + expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({ + n: 0 + }) + expect(index.db.prepare('SELECT count(*) AS n FROM messages').get()).toEqual({ + n: 0 + }) + // The file is still read through, so a later scan does not re-read it. + expect(cursor()).toBe(300) +}) + +it('writes nothing for a source whose parser cannot reach the channel', async () => { + // An OpenCode SQLite candidate decodes in a worker, so every read of it is + // incomplete, and no re-read would help. + const candidate = { + ...syntheticCandidate({ path: '/opencode/opencode.db#session-1' }), + agent: 'opencode' as const + } + replayTranscriptRead({ + candidate, + messages: [], + outcome: { byteOffset: 0, incomplete: true } + }) + + expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({ + n: 0 + }) + expect(store.takeStale()).toEqual([]) +}) + +it('ignores a candidate older than the retention cutoff', async () => { + store.setRetentionCutoffMs(Date.now()) + replayTranscriptRead({ messages: userMessages('too old', 3) }) + + expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({ + n: 0 + }) + expect(store.takeStale()).toEqual([]) +}) + +it('stops writing while the store refuses writes, but remembers what it skipped', async () => { + store.setAcceptingWrites(false) + replayTranscriptRead({ messages: userMessages('paused', 3) }) + + expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({ + n: 0 + }) + expect(errors).toEqual([]) + // A pause is exactly the window in which every read is declined. Forgetting + // them would leave the whole paused span unindexed with nothing to replay it. + expect(store.takeStale().map((candidate) => candidate.file.path)).toEqual([SYNTHETIC_TRANSCRIPT]) +}) + +it('keeps the paused re-read set when the retention window is reconfigured', async () => { + store.setAcceptingWrites(false) + replayTranscriptRead({ messages: userMessages('paused', 2) }) + expect(store.pendingFileCount).toBe(1) + + // The set records what still has to be read, not what is worth keeping. A + // window that now excludes this file is enforced where the re-read is + // dispatched, so nothing is written and the file leaves the set there. + store.setRetentionCutoffMs(Date.now()) + expect(store.pendingFileCount).toBe(1) + + store.setAcceptingWrites(true) + expect(store.takeStale()).toHaveLength(1) + replayTranscriptRead({ messages: userMessages('outside the window now', 2) }) + expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({ + n: 0 + }) + expect(store.pendingFileCount).toBe(0) +}) + +it('drops the oldest record rather than growing without a bound, and says so', () => { + store.setAcceptingWrites(false) + for (let index = 0; index < STALE_PATH_LIMIT + 5; index++) { + store.markStale(syntheticCandidate({ path: `/transcript-${index}.jsonl` })) + } + + expect(store.pendingFileCount).toBe(STALE_PATH_LIMIT) + expect(store.droppedPendingFileCount).toBe(5) + const kept = store.takeStale().map((candidate) => candidate.file.path) + expect(kept).not.toContain('/transcript-0.jsonl') + expect(kept).toContain(`/transcript-${STALE_PATH_LIMIT + 4}.jsonl`) +}) + +it('keeps the session list running when the index write fails', async () => { + replayTranscriptRead({ + messages: userMessages('healthy', 2), + outcome: { byteOffset: 100 } + }) + index.db.exec('DROP TABLE messages_fts') + + expect(() => + replayTranscriptRead({ + mode: 'append', + previousByteOffset: 100, + messages: userMessages('broken', 400), + outcome: { byteOffset: 500 } + }) + ).not.toThrow() + expect(errors.length).toBeGreaterThan(0) + expect(store.takeStale()).toHaveLength(1) +}) + +it('unregisters cleanly, leaving later reads unindexed', async () => { + resetTranscriptConsumersForTests() + replayTranscriptRead({ messages: userMessages('after unregister', 3) }) + + expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({ + n: 0 + }) +}) + +it('drops a removed source and keeps its cursor gone', async () => { + replayTranscriptRead({ + messages: userMessages('present', 3), + outcome: { byteOffset: 100 } + }) + store.removeFile(SYNTHETIC_TRANSCRIPT) + + expect(cursor()).toBeUndefined() + expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({ + n: 0 + }) + expect(index.db.prepare('SELECT count(*) AS n FROM messages').get()).toEqual({ + n: 0 + }) +}) + +it('writes the session metadata the read decoded', async () => { + replayTranscriptRead({ + messages: userMessages('metadata', 1), + outcome: { + session: syntheticSession({ + sessionId: 'abc-123', + title: 'a titled session', + cwd: '/repo/app', + branch: 'main', + messageCount: 1, + resumeCommand: 'claude --resume abc-123' + }), + byteOffset: 42 + } + }) + + expect( + index.db + .prepare('SELECT session_id, title, cwd, cwd_key, branch, resume_command FROM sessions') + .get() + ).toEqual({ + session_id: 'abc-123', + title: 'a titled session', + cwd: '/repo/app', + cwd_key: '/repo/app', + branch: 'main', + resume_command: 'claude --resume abc-123' + }) +}) + +it('keeps a proven file identity when a later read cannot stat it', async () => { + const withIdentity = syntheticCandidate({ dev: 1, ino: 10 }) + replayTranscriptRead({ + candidate: withIdentity, + messages: userMessages('first', 2), + outcome: { byteOffset: 100 } + }) + + // A host that cannot prove identity re-reads the same file. + replayTranscriptRead({ + candidate: syntheticCandidate(), + mode: 'append', + previousByteOffset: 100, + messages: userMessages('second', 2), + outcome: { byteOffset: 200 } + }) + expect(indexedMessages()).toBe(4) + + // The stored identity survived, so a rename-replace is still detectable. + replayTranscriptRead({ + candidate: syntheticCandidate({ dev: 1, ino: 99 }), + mode: 'append', + previousByteOffset: 200, + messages: userMessages('replacement', 2), + outcome: { byteOffset: 300 } + }) + + expect(indexedMessages()).toBe(4) + expect(cursor()).toBe(200) + expect(store.takeStale()).toHaveLength(1) +}) diff --git a/src/main/ai-vault-search/session-search-index-consumer.ts b/src/main/ai-vault-search/session-search-index-consumer.ts new file mode 100644 index 00000000000..e4fa6da4a8e --- /dev/null +++ b/src/main/ai-vault-search/session-search-index-consumer.ts @@ -0,0 +1,118 @@ +import { parserPublishesMessages } from '../ai-vault/session-scanner-agent-parser' +import { + registerTranscriptConsumer, + type TranscriptConsumer, + type TranscriptMessage, + type TranscriptReadConsumer, + type TranscriptReadOutcome, + type TranscriptReadStart +} from '../ai-vault/session-transcript-consumers' +import { fileIdentity } from './session-search-file-cursor' +import type { SessionSearchFileWrite } from './session-search-index-writer' +import type { SessionSearchStore } from './session-search-store' + +/** + * The search index as a consumer of the transcript reader. + * + * It keeps its own cursor in the `files` table and never consults the parse + * cache: the two answer different questions and diverge the moment either + * declines a read. Three refusals, each of which leaves the cursor where it + * was and records the file for a later whole re-read: + * + * - `beginRead` returns null when this index's cursor is behind the offset an + * `append` continues from, or when the file's identity changed. + * - a buffering failure stops the read's rows without failing the session list. + * - an `incomplete` outcome never commits; those rows are not the whole span. + */ +export class SessionSearchIndexConsumer implements TranscriptConsumer { + constructor(private readonly store: SessionSearchStore) {} + + beginRead(start: TranscriptReadStart): TranscriptReadConsumer | null { + const { candidate } = start + if (!this.store.acceptsCandidate(candidate)) { + // A pause is a reason not to write now, not a reason to forget the read. + // `markStale` applies the retention rule itself, so a candidate that is + // out of scope rather than merely paused is still dropped here. + this.store.markStale(candidate) + return null + } + // A parser that decodes where the channel cannot reach it reports every read + // as incomplete. Declining here is not the same as being behind: no re-read + // would help, so the file is not recorded either. + if (!parserPublishesMessages(candidate)) { + return null + } + if (start.mode === 'append') { + const cursor = this.store.indexedFile(candidate.file.path, fileIdentity(candidate.file)) + if (!cursor || cursor.byteOffset !== start.previousByteOffset) { + // This index never saw the span before `previousByteOffset`; appending + // here would leave a hole no later read can fill. A null cursor is the + // file a chunked read left half written, which no offset continues. + this.store.markStale(candidate) + return null + } + } + const write = this.store.beginWrite( + candidate, + start.mode, + start.previousByteOffset, + start.identity + ) + if (!write) { + this.store.markStale(candidate) + return null + } + return new SessionSearchReadConsumer(this.store, start, write) + } +} + +class SessionSearchReadConsumer implements TranscriptReadConsumer { + private failed = false + + constructor( + private readonly store: SessionSearchStore, + private readonly start: TranscriptReadStart, + private readonly write: SessionSearchFileWrite + ) {} + + message(message: TranscriptMessage): void { + if (this.failed) { + return + } + try { + this.write.add(message) + } catch (error) { + // Never throws back into the reader: the channel would drop this consumer + // for the rest of the read and `finish` would never run. Failing here + // keeps the whole read on one path — the buffer is dropped and the file is + // re-read. + this.failed = true + this.store.reportWriteFailure(error) + } + } + + finish(outcome: TranscriptReadOutcome): void { + const { candidate } = this.start + let committed = false + try { + // An incomplete read's rows are not the whole span, so the cursor must not + // move past them; the file is re-read whole instead. + committed = !this.failed && !outcome.incomplete && this.write.commit(outcome) + } catch (error) { + this.store.reportWriteFailure(error) + } + if (committed) { + this.store.writeCommitted(candidate) + return + } + this.store.markStale(candidate) + } +} + +/** + * Registers the index with the reader and returns the unregister function. + * Nothing in production calls this yet: PR 3 owns when the index is live. + */ +export function registerSessionSearchIndexConsumer(store: SessionSearchStore): () => void { + return registerTranscriptConsumer(new SessionSearchIndexConsumer(store)) +} diff --git a/src/main/ai-vault-search/session-search-index-test-fixture.ts b/src/main/ai-vault-search/session-search-index-test-fixture.ts new file mode 100644 index 00000000000..baa3e2976fe --- /dev/null +++ b/src/main/ai-vault-search/session-search-index-test-fixture.ts @@ -0,0 +1,124 @@ +import { mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { removeTree } from '../../shared/windows-transient-lock-removal' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { SessionFileCandidate } from '../ai-vault/session-scanner-types' +import { TranscriptMessageChannel } from '../ai-vault/session-transcript-channel' +import type { + TranscriptMessage, + TranscriptReadOutcome, + TranscriptReadStart +} from '../ai-vault/session-transcript-consumers' +import type SyncDatabase from '../sqlite/sync-database' +import { openSessionSearchDatabase } from './session-search-schema' + +export const SYNTHETIC_TRANSCRIPT = 'synthetic-transcript' + +export function syntheticCandidate( + overrides: Partial = {} +): SessionFileCandidate { + const at = new Date(1740000000000) + return { + agent: 'claude', + codexHome: null, + file: { + path: SYNTHETIC_TRANSCRIPT, + mtimeMs: at.getTime(), + modifiedAt: at.toISOString(), + sizeBytes: 4096, + ...overrides + } + } +} + +export function syntheticSession(overrides: Partial = {}): AiVaultSession { + const at = new Date(1740000000000).toISOString() + return { + id: 'fixture', + executionHostId: 'local', + agent: 'claude', + sessionId: 'fixture', + title: 'fixture session', + cwd: '/fixture', + branch: null, + model: null, + filePath: SYNTHETIC_TRANSCRIPT, + codexHome: null, + createdAt: at, + updatedAt: at, + modifiedAt: at, + messageCount: 0, + totalTokens: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: '', + subagent: null, + ...overrides + } +} + +export function userMessages(text: string, count: number): TranscriptMessage[] { + return Array.from({ length: count }, (_unused, index) => ({ + role: 'user' as const, + text, + timestamp: new Date(1740000000000 + index * 1000).toISOString() + })) +} + +/** + * Drives one read through the real fan-out channel, so a test exercises the + * registration path the transcript reader uses rather than the consumer alone. + */ +export function replayTranscriptRead(args: { + candidate?: SessionFileCandidate + mode?: TranscriptReadStart['mode'] + previousByteOffset?: number + messages: TranscriptMessage[] + outcome?: Partial +}): void { + const candidate = args.candidate ?? syntheticCandidate() + const mode = args.mode ?? 'replace' + const channel = new TranscriptMessageChannel() + channel.beginRead({ + candidate, + mode, + previousByteOffset: args.previousByteOffset ?? 0 + }) + for (const message of args.messages) { + channel.push(message) + } + channel.finishRead({ + session: syntheticSession(), + byteOffset: 4096, + incomplete: false, + ...args.outcome + }) +} + +export type SessionSearchIndexFile = { + path: string + /** The store keeps its own connection private, so row assertions need this one. */ + db: SyncDatabase + close: () => Promise +} + +/** An on-disk index: `:memory:` is per-connection, so a second reader needs a real file. */ +export async function openSessionSearchIndexFile(name: string): Promise { + const root = await mkdtemp(join(tmpdir(), `${name}-`)) + const path = join(root, 'index.sqlite') + const db = openSessionSearchDatabase(path) + let open = true + return { + path, + db, + close: async () => { + if (open) { + open = false + db.close() + } + await removeTree(root) + } + } +} diff --git a/src/main/ai-vault-search/session-search-index-writer.test.ts b/src/main/ai-vault-search/session-search-index-writer.test.ts new file mode 100644 index 00000000000..46d147dcce0 --- /dev/null +++ b/src/main/ai-vault-search/session-search-index-writer.test.ts @@ -0,0 +1,228 @@ +import { afterEach, beforeEach, expect, it } from 'vitest' +import { SessionSearchIndexConsumer } from './session-search-index-consumer' +import { + openSessionSearchIndexFile, + syntheticCandidate, + syntheticSession, + SYNTHETIC_TRANSCRIPT, + userMessages, + type SessionSearchIndexFile +} from './session-search-index-test-fixture' +import { SessionSearchStore } from './session-search-store' + +// The store is driven directly here. Every guard below is also shadowed by the +// consumer's own check, so a test that goes through the consumer proves nothing +// about which of the two is holding. + +let index: SessionSearchIndexFile +let store: SessionSearchStore +let errors: unknown[] + +beforeEach(async () => { + index = await openSessionSearchIndexFile('ss-index-writer') + errors = [] + store = new SessionSearchStore(index.path, (error) => errors.push(error)) +}) + +afterEach(async () => { + store.close() + await index.close() +}) + +function count(table: string): number { + return ( + index.db.prepare(`SELECT count(*) AS n FROM ${table}`).get() as { + n: number + } + ).n +} + +function indexRead(previousByteOffset: number, byteOffset: number, text: string): boolean { + const write = store.beginWrite( + syntheticCandidate(), + previousByteOffset === 0 ? 'replace' : 'append', + previousByteOffset + ) + if (!write) { + return false + } + for (const message of userMessages(text, 2)) { + write.add(message) + } + return write.commit({ + session: syntheticSession(), + byteOffset, + incomplete: false + }) +} + +it('refuses an append whose predecessor offset is not the committed cursor', () => { + expect(indexRead(0, 100, 'first')).toBe(true) + + expect(store.beginWrite(syntheticCandidate(), 'append', 900)).toBeNull() + expect(store.beginWrite(syntheticCandidate(), 'append', 99)).toBeNull() + // The one offset that does continue the committed span is accepted. + expect(store.beginWrite(syntheticCandidate(), 'append', 100)).not.toBeNull() +}) + +it('refuses to commit a write whose cursor moved underneath it', () => { + const stale = store.beginWrite(syntheticCandidate(), 'replace', 0)! + for (const message of userMessages('stalegeneration', 40)) { + stale.add(message) + } + // A second read of the same path finishes first. Without the parse file lane + // this is the overlap that would otherwise resurrect the stale rows. + expect(indexRead(0, 200, 'winninggeneration')).toBe(true) + + expect( + stale.commit({ + session: syntheticSession(), + byteOffset: 100, + incomplete: false + }) + ).toBe(false) + expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset).toBe(200) + expect(count('sessions')).toBe(1) + expect(count('messages')).toBe(2) + expect(errors).toEqual([]) +}) + +it('refuses to commit a write whose file was removed mid-read', () => { + expect(indexRead(0, 100, 'firstgeneration')).toBe(true) + const write = store.beginWrite(syntheticCandidate(), 'append', 100)! + for (const message of userMessages('afterremoval', 10)) { + write.add(message) + } + store.removeFile(SYNTHETIC_TRANSCRIPT) + + // Committing here would put a source back that its owner proved was deleted. + expect( + write.commit({ + session: syntheticSession(), + byteOffset: 300, + incomplete: false + }) + ).toBe(false) + expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, null)).toBeNull() + expect(count('sessions')).toBe(0) + expect(count('messages')).toBe(0) + expect(count('files')).toBe(0) +}) + +it('declines a behind cursor in beginRead before it ever reaches the store', () => { + const attempted: number[] = [] + const stub = { + acceptsCandidate: () => true, + indexedFile: () => ({ byteOffset: 100, mtimeMs: 1, sizeBytes: 1 }), + beginWrite: (_candidate: unknown, _mode: unknown, previousByteOffset: number) => { + attempted.push(previousByteOffset) + return { add: () => undefined, commit: () => true } + }, + markStale: () => undefined + } as unknown as SessionSearchStore + const consumer = new SessionSearchIndexConsumer(stub) + + expect( + consumer.beginRead({ + candidate: syntheticCandidate(), + mode: 'append', + previousByteOffset: 900 + }) + ).toBeNull() + // The store was never asked, so the writer's own guard cannot be what refused. + expect(attempted).toEqual([]) + expect( + consumer.beginRead({ + candidate: syntheticCandidate(), + mode: 'append', + previousByteOffset: 100 + }) + ).not.toBeNull() + expect(attempted).toEqual([100]) +}) + +it("hands the read's identity accessor to the store", () => { + const captured: unknown[] = [] + const stub = { + acceptsCandidate: () => true, + indexedFile: () => null, + beginWrite: ( + _candidate: unknown, + _mode: unknown, + _previousByteOffset: unknown, + identity: unknown + ) => { + captured.push(identity) + return { add: () => undefined, commit: () => true } + }, + markStale: () => undefined + } as unknown as SessionSearchStore + const identity = (): null => null + + new SessionSearchIndexConsumer(stub).beginRead({ + candidate: syntheticCandidate(), + mode: 'replace', + previousByteOffset: 0, + identity + }) + + // Dropped here, a chunked read writes rows under a session with no id and no + // cwd for as long as the read lasts, and for ever if it crashes first. + expect(captured).toEqual([identity]) +}) + +it('treats half a recorded identity as no identity at all', () => { + // New partial observations are not stored as identities. + const partial = { + ...syntheticCandidate({ dev: 7 }), + agent: 'claude' as const + } + const write = store.beginWrite(partial, 'replace', 0)! + for (const message of userMessages('halfidentity', 2)) { + write.add(message) + } + write.commit({ + session: syntheticSession(), + byteOffset: 100, + incomplete: false + }) + expect(index.db.prepare('SELECT dev, ino FROM files').get()).toEqual({ + dev: null, + ino: null + }) + // Older indexes may still carry a half-pair. + index.db.exec('UPDATE files SET dev = 7') + + // One matching number is not proof of sameness, and one mismatching number is + // not proof of replacement. Neither compares, so neither declines. + expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, { dev: 7, ino: 99 })?.byteOffset).toBe(100) + expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, { dev: 8, ino: 99 })?.byteOffset).toBe(100) + expect(store.beginWrite(syntheticCandidate({ dev: 8, ino: 99 }), 'append', 100)).not.toBeNull() +}) + +it.each([ + [null, { dev: null, ino: null }], + [ + { dev: 7, ino: 11 }, + { dev: 7, ino: 11 } + ] +])('never combines partial stats with the previous identity %j', (initial, expected) => { + const observations = [initial ?? {}, { dev: 9 }, { ino: 13 }, { dev: 17, ino: 19 }] + for (const [position, identity] of observations.entries()) { + const write = store.beginWrite( + syntheticCandidate(identity), + position ? 'append' : 'replace', + position * 100 + )! + expect( + write.commit({ + session: syntheticSession(), + byteOffset: (position + 1) * 100, + incomplete: false + }) + ).toBe(true) + expect(index.db.prepare('SELECT dev, ino FROM files').get()).toEqual( + position === 3 ? { dev: 17, ino: 19 } : expected + ) + } +}) diff --git a/src/main/ai-vault-search/session-search-index-writer.ts b/src/main/ai-vault-search/session-search-index-writer.ts new file mode 100644 index 00000000000..a5c981b5bb2 --- /dev/null +++ b/src/main/ai-vault-search/session-search-index-writer.ts @@ -0,0 +1,359 @@ +import type SyncDatabase from '../sqlite/sync-database' +import type { SessionFileCandidate } from '../ai-vault/session-scanner-types' +import type { + TranscriptMessage, + TranscriptReadOutcome, + TranscriptSessionIdentity +} from '../ai-vault/session-transcript-consumers' +import { EMPTY_CONTENT_HASH, foldContentHash } from './session-search-content-hash' +import type { + SessionSearchFileIdentity, + SessionSearchIndexedFile +} from './session-search-file-cursor' +import { SessionSearchFileRecords } from './session-search-file-records' +import { + deleteSearchMessages, + insertSearchMessage, + searchMessageRows +} from './session-search-message-rows' + +/** + * How much decoded text one transaction may carry. + * + * A file's rows are buffered in memory and written in one transaction, so the + * whole read is either in the index or not. The ceiling is what keeps that + * promise affordable: at the measured 26 MB of transcript per second it caps a + * single commit near a second and the WAL it produces near 64 MB, and it is far + * above the largest real transcript (the 40-session benchmark corpus is 10.5 MB + * in total), so an ordinary file never reaches it. Above the ceiling the read is + * cut into chunks that each leave the index consistent — but only a read that + * can name its session chunks at all. See `add`. + */ +export const SESSION_SEARCH_COMMIT_CHARS = 32 * 1024 * 1024 + +/** + * The cursor of a file whose rows are a prefix, written by a chunk of a read + * that has not reached the end of the file. + * + * The reader hands out byte offsets only when a read finishes, so a chunk has + * no honest offset to record. This one is unusable on purpose: `indexedFile` + * reports no cursor for it, so an append is declined and the file is re-read + * whole. The rows are still a coherent prefix of that session and answer + * searches until the re-read replaces them. + */ +const PARTIAL_FILE_CURSOR = -1 + +type FileRow = { + dev: number | null + ino: number | null + byte_offset: number + mtime_ms: number + size_bytes: number | null + session_row_id: number | null +} + +type FileCursor = Pick + +export type SessionSearchFileWrite = { + /** + * Buffers one message, committing a chunk when the buffer reaches the ceiling + * — and only while this read can name the session it is writing. + * + * A chunk's rows answer searches the moment they land, so a read with no + * `identity` would publish them under a session with an empty id, an empty + * title and a null cwd, and an interrupted read would leave that prefix + * behind for good. The readers that supply no identity are the whole-file + * ones (Grok, Cursor, Gemini, OpenCode), whose formats are rewritten in place + * and have no resumable state to ask; they are also small — the largest on + * the author's machine is 5 MB — so buffering one to the end and committing + * it whole costs nothing. Chunking stays reserved for the readers that can + * say which session this is before the read ends. + */ + add(message: TranscriptMessage): void + /** + * Writes this file's rows, its session and its cursor in one transaction. + * False when the file's record changed under this read — it was removed, or + * another writer moved the cursor these rows continue from. A read that never + * calls this leaves the index exactly as it found it, unless it chunked. + */ + commit(outcome: TranscriptReadOutcome): boolean +} + +export class SessionSearchIndexWriter { + private readonly records: SessionSearchFileRecords + // Removals per path, so a write can prove its source was not dropped under it + // rather than infer it from the cursor. In memory is enough: one process owns + // the index, and a removal only has to fence writes this process opened. + private readonly removals = new Map() + + constructor( + private readonly db: SyncDatabase, + private readonly commitChars: number = SESSION_SEARCH_COMMIT_CHARS, + /** + * Called after a transaction that left a session's messages with no session + * row, so the owner can start the bounded drain that reclaims them. + * Synchronous work here would put the cost back where it was taken from. + */ + private readonly onOrphanedRows: () => void = () => undefined + ) { + this.records = new SessionSearchFileRecords(db) + } + + /** + * What the index holds for this file, or null when it holds nothing usable: + * an unknown path, or one whose recorded identity no longer matches. + * + * A file a chunked read left half written is reported, with a null cursor. + * Reporting nothing for it would read as "never indexed", so the caller would + * ask for whatever read the parse cache offers, the reader would pick append, + * and the decline would be the only thing that ever forced the whole read. + */ + indexedFile(path: string, identity: SessionSearchFileIdentity): SessionSearchIndexedFile | null { + const row = this.db + .prepare( + 'SELECT dev, ino, byte_offset, mtime_ms, size_bytes, session_row_id FROM files WHERE path = ?' + ) + .get(path) as FileRow | undefined + if (!row) { + return null + } + // Older indexes can carry half-pairs; only a complete identity can prove replacement. + if (identity && row.dev !== null && row.ino !== null) { + if (row.dev !== identity.dev || row.ino !== identity.ino) { + return null + } + } + return { + byteOffset: row.byte_offset === PARTIAL_FILE_CURSOR ? null : row.byte_offset, + mtimeMs: row.mtime_ms, + sizeBytes: row.size_bytes + } + } + + /** + * Opens a buffered write for one read, or returns null when the read cannot + * extend what the index holds: an `append` whose predecessor byte offset is + * not this index's own cursor covers a span the index never saw. + */ + beginWrite( + candidate: SessionFileCandidate, + mode: 'replace' | 'append', + previousByteOffset: number, + identity?: () => TranscriptSessionIdentity | null + ): SessionSearchFileWrite | null { + const path = candidate.file.path + const cursor = this.cursor(path) + if (mode === 'append') { + // The partial sentinel is not a byte offset, so nothing continues it — + // including a caller that reads it back off the row and passes it in. + if (cursor === undefined || cursor.byte_offset === PARTIAL_FILE_CURSOR) { + return null + } + if (cursor.byte_offset !== previousByteOffset) { + return null + } + } + // A file the index read through and decoded no session from still has a + // cursor worth continuing: it has no session row to hang new rows off, so + // this read makes one. Declining instead would force a whole re-read of + // that file on every pass for as long as it grows. + return this.buffered(candidate, cursor, mode === 'append', identity) + } + + /** + * Drops a source: its session, its rows and its file record, in one + * transaction. Unbounded on purpose — the caller has proven this one file is + * gone and expects it out of results when the call returns, and a read of it + * that is still in flight is fenced by the cursor its commit re-reads. + */ + removeFile(path: string): void { + this.removals.set(path, (this.removals.get(path) ?? 0) + 1) + const cursor = this.cursor(path) + this.db.exec('BEGIN IMMEDIATE') + try { + this.dropSession(cursor?.session_row_id ?? null) + this.db.prepare('DELETE FROM files WHERE path = ?').run(path) + this.db.exec('COMMIT') + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + + private cursor(path: string): FileCursor | undefined { + return this.db + .prepare('SELECT session_row_id,byte_offset FROM files WHERE path = ?') + .get(path) as FileCursor | undefined + } + + private buffered( + candidate: SessionFileCandidate, + opened: FileCursor | undefined, + append: boolean, + identity?: () => TranscriptSessionIdentity | null + ): SessionSearchFileWrite { + const db = this.db + const path = candidate.file.path + const buffer: TranscriptMessage[] = [] + let bufferedChars = 0 + // What this write believes the file record holds. Re-read inside every + // transaction: a `removeFile` or another writer between two chunks means + // these rows no longer continue anything, and committing on top of that + // would resurrect a deleted source or duplicate a span. + let expected = opened + const removalsAtStart = this.removals.get(path) ?? 0 + // The session row is reused across re-reads of one file, so a `replace` + // swaps a session's rows rather than minting a second generation of it. + let session = opened?.session_row_id ?? null + let hash = append && session !== null ? this.records.contentHash(session) : EMPTY_CONTENT_HASH + // A replace owns the session's whole row set, so the old generation goes in + // the same transaction as the first of the new one. Chunk two onwards must + // not repeat it. + // + // It goes by being cut loose, not by being deleted. Deleting every old row + // inline sizes the transaction by the session being replaced rather than by + // the chunk being written: 1,286 ms against 720 ms fresh on the 100 MB + // corpus, and it grows with the history. Instead the first transaction + // mints a new session row, points `files` at it and deletes the one old + // `sessions` row. Every retrieval joins `sessions`, so the old generation + // stops answering the moment that commits, and its messages are reclaimed + // afterwards by the same bounded drain retention uses — which is where the + // old rows would have ended up had the process died here anyway. + // `sessions.id` is AUTOINCREMENT, so the freed id is never handed to + // another session while those rows still name it (round 8). + let replaced = append + // Set by the transaction that cut a generation loose; read once it commits. + let orphaned = false + // Set when the file record moved under this read. Nothing this write holds + // can land after that, so it stops buffering rather than reopening a + // transaction it already knows will roll back, once per remaining message. + let fenced = false + + // Why a counter and not the cursor alone: on a path this index never wrote, + // `expected` and the absent row are both undefined, so the cursor compare + // reads a removal as no change and the write recreates the source. + const current = (): boolean => { + if ((this.removals.get(path) ?? 0) !== removalsAtStart) { + return false + } + const row = this.cursor(path) + return ( + row?.session_row_id === expected?.session_row_id && + row?.byte_offset === expected?.byte_offset + ) + } + + /** + * `outcome` is null for a chunk of a read that has not reached the file's + * end, and `named` is what that chunk writes onto its session row. + */ + const write = ( + outcome: TranscriptReadOutcome | null, + named: TranscriptSessionIdentity | null + ): boolean => { + const decoded = outcome?.session ?? null + db.exec('BEGIN IMMEDIATE') + try { + if (!current()) { + db.exec('ROLLBACK') + return false + } + if (outcome && !decoded) { + // Read through, but nothing to search: the cursor advances so the file + // is not re-read whole on every pass, and whatever generation was here + // — including this read's own committed chunks — goes with it. + this.dropSession(session) + session = null + this.records.upsertFile(candidate, outcome.byteOffset, null) + } else { + if (replaced) { + session ??= this.records.createSessionRow(candidate) + } else { + const previous = session + session = this.records.createSessionRow(candidate) + if (previous !== null) { + db.prepare('DELETE FROM sessions WHERE id = ?').run(previous) + orphaned = true + } + replaced = true + } + for (const row of buffer) { + insertSearchMessage(db, session, row) + } + if (decoded) { + this.records.updateSession(decoded, session, hash) + } else if (named) { + // A chunk's rows answer searches as soon as they land, so the + // session they hang off is written with whatever the parser has + // decoded rather than left empty until a read that may never end. + // `add` refuses to chunk without this, so it is never absent here. + this.records.updateProvisionalSession(session, named) + } + this.records.upsertFile( + candidate, + outcome ? outcome.byteOffset : PARTIAL_FILE_CURSOR, + session + ) + } + db.exec('COMMIT') + } catch (error) { + db.exec('ROLLBACK') + throw error + } + // After the transaction that cut them loose is durable, never before: a + // rollback leaves the old session row standing and nothing to reclaim. + if (orphaned) { + orphaned = false + this.onOrphanedRows() + } + expected = { + session_row_id: session, + byte_offset: outcome ? outcome.byteOffset : PARTIAL_FILE_CURSOR + } + buffer.length = 0 + bufferedChars = 0 + return true + } + + return { + add: (message) => { + if (fenced) { + return + } + hash = foldContentHash(hash, [message]) + // The ceiling is checked per row, not per message: one message is a whole + // conversation turn and may be megabytes, so checking it after the whole + // message had been buffered let a single one carry a transaction as far + // past the ceiling as it was large. + for (const row of searchMessageRows([message])) { + buffer.push(row) + bufferedChars += row.text.length + if (bufferedChars < this.commitChars) { + continue + } + // Publishing a chunk under a session nothing can identify is worse + // than holding the buffer: the rows answer searches at once, and an + // interrupted read leaves that prefix for good. A read with nothing + // to name it keeps buffering and commits whole at `finish`. + const named = identity?.() ?? null + if (named && !write(null, named)) { + fenced = true + buffer.length = 0 + bufferedChars = 0 + return + } + } + }, + commit: (outcome) => !fenced && write(outcome, null) + } + } + + /** Caller's transaction: drops a session and every row that hangs off it. */ + private dropSession(sessionRowId: number | null): void { + if (sessionRowId === null) { + return + } + deleteSearchMessages(this.db, sessionRowId) + this.db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionRowId) + } +} diff --git a/src/main/ai-vault-search/session-search-live-transcript.test.ts b/src/main/ai-vault-search/session-search-live-transcript.test.ts new file mode 100644 index 00000000000..7f37ca662cb --- /dev/null +++ b/src/main/ai-vault-search/session-search-live-transcript.test.ts @@ -0,0 +1,208 @@ +import { mkdtemp, rm, writeFile, appendFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache' +import { + registerTranscriptConsumer, + resetTranscriptConsumersForTests, + type TranscriptSessionIdentity +} from '../ai-vault/session-transcript-consumers' +import { requestWholeTranscriptRead } from '../ai-vault/session-transcript-reader' +import SyncDatabase from '../sqlite/sync-database' +import { registerSessionSearchIndexConsumer } from './session-search-index-consumer' +import { SessionSearchStore } from './session-search-store' +import { + assistantRecord, + CLAUDE_SESSION_ID as SESSION_ID, + CODEX_ROLLOUT_FILE, + CODEX_SESSION_ID, + codexRolloutLines, + parseTranscript, + userRecord +} from './session-search-transcript-fixtures' + +let tempRoots: string[] = [] +let store: SessionSearchStore +// The store keeps its connection private, so row assertions need a second one. +let reader: SyncDatabase +let errors: unknown[] + +beforeEach(async () => { + resetSessionParseCacheForTests() + resetTranscriptConsumersForTests() + errors = [] + const path = join(await makeTempDir(), 'index.sqlite') + store = new SessionSearchStore(path, (error) => errors.push(error)) + registerSessionSearchIndexConsumer(store) + reader = new SyncDatabase(path, { readonly: true }) +}) + +afterEach(async () => { + resetTranscriptConsumersForTests() + reader.close() + store.close() + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +async function makeTempDir(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-session-search-live-')) + tempRoots.push(root) + return root +} + +/** Sessions a query would return for one FTS term, read on a second handle. */ +function sessionsMatching(term: string): string[] { + return ( + reader + .prepare( + `SELECT DISTINCT s.session_id AS 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 ? ORDER BY s.session_id` + ) + .all(term) as { id: string }[] + ).map((row) => row.id) +} + +it('indexes a Claude transcript through the reader and resumes on append', async () => { + const root = await makeTempDir() + const path = join(root, `${SESSION_ID}.jsonl`) + await writeFile( + path, + `${[ + userRecord(0, 'find the flaky terminal reattach'), + assistantRecord(1, 'look at resolveTerminalPath first') + ].join('\n')}\n` + ) + await parseTranscript(path) + expect(errors).toEqual([]) + expect(sessionsMatching('reattach')).toEqual([SESSION_ID]) + // The identifier column shadows a camel-case symbol into its pieces. + expect(sessionsMatching('terminal')).toEqual([SESSION_ID]) + + await appendFile(path, `${assistantRecord(2, 'the zygomorphic follow-up landed')}\n`) + const resumed = await parseTranscript(path) + // The reader resumed, so the index saw an `append`, not a whole re-read. + expect(resumed.stats).toMatchObject({ incremental: 1, fullParses: 0 }) + expect(errors).toEqual([]) + expect(sessionsMatching('zygomorphic')).toEqual([SESSION_ID]) + // An append extends one session rather than creating a second. + expect(reader.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({ + n: 1 + }) +}) + +it('keeps a tool result searchable but out of the conversation half', async () => { + const root = await makeTempDir() + const codexHome = await makeTempDir() + const path = join(root, CODEX_ROLLOUT_FILE) + await writeFile( + path, + `${codexRolloutLines( + ['rg', 'pericardium'], + `outputonly ${'padding '.repeat(600)}tailonly`, + 'promptonly search for the module' + ).join('\n')}\n` + ) + await parseTranscript(path, 'codex', codexHome) + expect(errors).toEqual([]) + + expect(sessionsMatching('pericardium')).toHaveLength(1) + // The prompt is conversation; the command output is not, and the column + // filter is what tells them apart. + expect(sessionsMatching('outputonly')).toHaveLength(1) + expect(sessionsMatching('tailonly')).toHaveLength(0) + expect(sessionsMatching('rg')).toHaveLength(1) + expect(sessionsMatching('{user_text assistant_text}: promptonly')).toHaveLength(1) + expect(sessionsMatching('{user_text assistant_text}: outputonly')).toHaveLength(0) + expect(sessionsMatching('{user_text assistant_text}: rg')).toHaveLength(0) +}) + +/** What `start.identity()` returns at each message of one read. */ +function recordIdentityPerMessage(): (TranscriptSessionIdentity | null)[] { + const seen: (TranscriptSessionIdentity | null)[] = [] + registerTranscriptConsumer({ + beginRead: (start) => ({ + message: () => { + seen.push(start.identity?.() ?? null) + }, + finish: () => undefined + }) + }) + return seen +} + +it('names the session mid-read, before the reader has finished the file', async () => { + const root = await makeTempDir() + const path = join(root, `${SESSION_ID}.jsonl`) + await writeFile( + path, + `${[ + userRecord(0, 'find the flaky terminal reattach'), + assistantRecord(1, 'look at resolveTerminalPath first') + ].join('\n')}\n` + ) + const seen = recordIdentityPerMessage() + await parseTranscript(path) + + // A chunked read commits partway through a file this size or larger, so what + // it can name the session with is exactly this. + expect(seen.length).toBeGreaterThan(0) + expect(seen[0]).toMatchObject({ + sessionId: SESSION_ID, + cwd: '/repo/app', + createdAt: expect.any(String) + }) +}) + +it('names a Codex session mid-read from its own opening record', async () => { + const root = await makeTempDir() + const codexHome = await makeTempDir() + const path = join(root, CODEX_ROLLOUT_FILE) + await writeFile( + path, + `${codexRolloutLines(['rg', 'pericardium'], 'src/main/pericardium.ts:12: match', 'search for the pericardium module').join('\n')}\n` + ) + const seen = recordIdentityPerMessage() + await parseTranscript(path, 'codex', codexHome) + + // Codex builds its own resumable state rather than the shared accumulator + // fold, so it is the other half of the surface a chunked commit depends on. + expect(seen[0]).toMatchObject({ + sessionId: CODEX_SESSION_ID, + cwd: '/repo/app' + }) +}) + +it('indexes a file the session list already read past, once a whole read is asked for', async () => { + const root = await makeTempDir() + const path = join(root, `${SESSION_ID}.jsonl`) + await writeFile(path, `${userRecord(0, 'the opening prompt')}\n`) + + // The state on first enablement inside a running app: the session list has + // read this file, so the parse cache is warm, while the index is empty. + resetTranscriptConsumersForTests() + await parseTranscript(path) + registerSessionSearchIndexConsumer(store) + + await appendFile(path, `${assistantRecord(1, 'a zygomorphic reply')}\n`) + const appended = await parseTranscript(path) + expect(appended.stats).toMatchObject({ incremental: 1, fullParses: 0 }) + // The append continued from a byte offset the index never saw, so it declined. + expect(sessionsMatching('zygomorphic')).toEqual([]) + + const behind = store.takeStale() + expect(behind.map((candidate) => candidate.file.path)).toEqual([path]) + for (const candidate of behind) { + requestWholeTranscriptRead(candidate.file.path) + } + + const reread = await parseTranscript(path) + expect(reread.stats).toMatchObject({ incremental: 0, fullParses: 1 }) + expect(errors).toEqual([]) + expect(sessionsMatching('zygomorphic')).toEqual([SESSION_ID]) + expect(sessionsMatching('opening')).toEqual([SESSION_ID]) + expect(store.takeStale()).toEqual([]) +}) diff --git a/src/main/ai-vault-search/session-search-message-rows.test.ts b/src/main/ai-vault-search/session-search-message-rows.test.ts new file mode 100644 index 00000000000..d2cf2fbca61 --- /dev/null +++ b/src/main/ai-vault-search/session-search-message-rows.test.ts @@ -0,0 +1,249 @@ +import { expect, it } from 'vitest' +import type { TranscriptMessage } from '../ai-vault/session-transcript-consumers' +import { insertSearchMessage, searchMessageRows } from './session-search-message-rows' +import { + openSessionSearchIndexFile, + type SessionSearchIndexFile +} from './session-search-index-test-fixture' + +/** Every column of the FTS table, so an assertion cannot miss the shadow terms. */ +async function indexedColumns( + index: SessionSearchIndexFile, + message: TranscriptMessage +): Promise { + for (const row of searchMessageRows([message])) { + insertSearchMessage(index.db, 1, row) + } + const full = index.db + .prepare('SELECT user_text, assistant_text, tool_text, identifiers FROM messages_fts') + .all() as Record[] + return full.flatMap((row) => Object.values(row)) +} + +it('splits an oversized message on a line boundary and keeps every character', () => { + const line = `${'padding '.repeat(11)}word\n` + const text = line.repeat(400) + const chunks = [...searchMessageRows([{ role: 'user', text, timestamp: null }])].map( + (row) => row.text + ) + + expect(chunks.length).toBeGreaterThan(1) + expect(chunks.join('')).toBe(text) + for (const chunk of chunks) { + expect(chunk.length).toBeLessThanOrEqual(8000) + expect(chunk.endsWith('\n')).toBe(true) + } +}) + +it('cuts at whitespace rather than through the word on the boundary', async () => { + const index = await openSessionSearchIndexFile('ss-rows-whitespace') + try { + // The 8,000th character lands inside `pericardium`. Cutting at the target + // would file `per` under one row and `icardium` under another, and the word + // the user types would match neither. + const text = `${' '.repeat(7997)}pericardium` + const chunks = [...searchMessageRows([{ role: 'user', text, timestamp: null }])] + expect(chunks.map((row) => row.text).join('')).toBe(text) + for (const row of chunks) { + insertSearchMessage(index.db, 1, row) + } + + expect( + index.db + .prepare('SELECT count(*) AS n FROM messages_fts WHERE messages_fts MATCH ?') + .get('pericardium') + ).toEqual({ n: 1 }) + } finally { + await index.close() + } +}) + +it.each(['/repo/pericardium.ts', 'PROJ-12345', 'C++', 'cafe\u0301ine'])( + 'preserves the exact FTS token %s at a chunk boundary', + async (token) => { + const index = await openSessionSearchIndexFile('ss-rows-tokenchars') + try { + const text = ' '.repeat(7998) + token + const chunks = [...searchMessageRows([{ role: 'user', text, timestamp: null }])] + expect(chunks.map((row) => row.text).join('')).toBe(text) + for (const row of chunks) { + insertSearchMessage(index.db, 1, row) + } + expect( + index.db + .prepare('SELECT count(*) AS n FROM messages_fts WHERE messages_fts MATCH ?') + .get(`"${token}"`) + ).toEqual({ n: 1 }) + } finally { + await index.close() + } + } +) + +it.each(['\u0305', '\u030d', '\u0332'])( + 'cuts at a combining mark unicode61 treats as a separator: %s', + async (mark) => { + const index = await openSessionSearchIndexFile('ss-rows-unicode-separator') + try { + const text = `${'x'.repeat(7997)}${mark}pericardium` + for (const row of searchMessageRows([{ role: 'user', text, timestamp: null }])) { + insertSearchMessage(index.db, 1, row) + } + expect( + index.db + .prepare("SELECT count(*) AS n FROM messages_fts WHERE messages_fts MATCH 'pericardium'") + .get() + ).toEqual({ n: 1 }) + } finally { + await index.close() + } + } +) + +it('backs up to any whitespace, not only a newline', () => { + // An ideographic space separates words in a CJK transcript exactly as a + // space does here, and a newline-only backoff tears the token after it. + const text = `${'\u4e00'.repeat(7000)}\u3000${'\u4e8c'.repeat(2000)}` + const chunks = [...searchMessageRows([{ role: 'user', text, timestamp: null }])].map( + (row) => row.text + ) + + expect(chunks[0]).toBe(`${'\u4e00'.repeat(7000)}\u3000`) + expect(chunks.join('')).toBe(text) +}) + +it('cuts at punctuation when the window holds no whitespace at all', async () => { + const index = await openSessionSearchIndexFile('ss-rows-minified') + try { + // Valid minified JSON, the shape a tool result carries: 8,000 characters + // without a single space. The 8,000th lands inside `pericardium`, and a + // whitespace-only backoff has nothing in the window to back up to, so it + // files `perica` under one row and `rdium` under the next. + const text = `{"pad":"${'x'.repeat(7976)}","note":"pericardium"}` + expect(JSON.parse(text)).toEqual({ pad: 'x'.repeat(7976), note: 'pericardium' }) + expect(text.slice(7994, 8005)).toBe('pericardium') + expect(/\s/.test(text)).toBe(false) + + const chunks = [...searchMessageRows([{ role: 'user', text, timestamp: null }])] + expect(chunks.map((row) => row.text).join('')).toBe(text) + for (const row of chunks) { + insertSearchMessage(index.db, 1, row) + } + + expect( + index.db + .prepare('SELECT count(*) AS n FROM messages_fts WHERE messages_fts MATCH ?') + .get('pericardium') + ).toEqual({ n: 1 }) + } finally { + await index.close() + } +}) + +it('keeps a 9,000-character identifier whole rather than cutting at its underscores', () => { + // `_` sits inside a token for this tokenizer, so it is not a boundary. A + // snake_case name that long holds none at all, and the target itself is the + // honest cut — backing up to every `_` would file the name in pieces. + const text = 'ab_'.repeat(3000) + expect(text.length).toBe(9000) + const chunks = [...searchMessageRows([{ role: 'user', text, timestamp: null }])].map( + (row) => row.text + ) + + expect(chunks.map((chunk) => chunk.length)).toEqual([8000, 1000]) + expect(chunks.join('')).toBe(text) +}) + +it('still chunks a message that holds no whitespace at all', () => { + // A 20,000-character token is not a word, so the target itself is the cut and + // the message is still bounded. + const chunks = [ + ...searchMessageRows([{ role: 'user', text: 'a'.repeat(20_000), timestamp: null }]) + ] + expect(chunks.map((row) => row.text.length)).toEqual([8000, 8000, 4000]) +}) + +it('leaves a message that fits as a single row', () => { + const rows = [...searchMessageRows([{ role: 'user', text: 'short enough', timestamp: null }])] + expect(rows.map((row) => row.text)).toEqual(['short enough']) +}) + +it('caps a tool row at its head and never caps the conversation', async () => { + const index = await openSessionSearchIndexFile('ss-rows-tool-cap') + try { + // The reader hands over untruncated text (its own bound is 256 KB per + // message and a consumer may be handed more); the cap is this module's. + const output = `pericardium ${'padding '.repeat(140_000)}` + expect(output.length).toBeGreaterThan(1024 * 1024) + + const toolRows = [...searchMessageRows([{ role: 'tool', text: output, timestamp: null }])] + expect(toolRows).toHaveLength(1) + expect(toolRows[0]!.text.length).toBe(3072) + // The head is what identifies what ran, so it is what survives. + expect(toolRows[0]!.text.startsWith('pericardium ')).toBe(true) + + // The same text as an assistant turn is conversation, and keeps every byte. + const assistantRows = [ + ...searchMessageRows([{ role: 'assistant', text: output, timestamp: null }]) + ] + expect(assistantRows.map((row) => row.text).join('')).toBe(output) + expect(assistantRows.length).toBeGreaterThan(100) + + for (const row of toolRows) { + insertSearchMessage(index.db, 1, row) + } + expect( + index.db + .prepare('SELECT count(*) AS n FROM messages_fts WHERE messages_fts MATCH ?') + .get('pericardium') + ).toEqual({ n: 1 }) + } finally { + await index.close() + } +}) + +it('files a tool row under the tool column alone', async () => { + const index = await openSessionSearchIndexFile('ss-message-rows-tool') + try { + for (const row of searchMessageRows([ + { role: 'tool', text: 'rg pericardium', timestamp: null } + ])) { + insertSearchMessage(index.db, 1, row) + } + expect(index.db.prepare('SELECT count(*) AS n FROM messages_fts').get()).toEqual({ n: 1 }) + // What makes a conversation-scoped search exclude it: the column filter, not + // a second table. + expect( + index.db + .prepare('SELECT count(*) AS n FROM messages_fts WHERE messages_fts MATCH ?') + .get('{user_text assistant_text}: pericardium') + ).toEqual({ n: 0 }) + expect( + index.db + .prepare('SELECT count(*) AS n FROM messages_fts WHERE messages_fts MATCH ?') + .get('{tool_text}: pericardium') + ).toEqual({ n: 1 }) + } finally { + await index.close() + } +}) + +it('stores a chunk exactly as the transcript wrote it', async () => { + const index = await openSessionSearchIndexFile('ss-rows-verbatim') + try { + const text = 'deploy with AKIAIOSFODNN7EXAMPLE and the resolveTerminalPath fix' + const stored = await indexedColumns(index, { + role: 'assistant', + text, + timestamp: null + }) + + // The index is a second copy of content the user already holds in plaintext, + // so it neither rewrites nor drops any of it. + expect(stored).toContain(text) + // Identifier shadow terms come off that same raw chunk. + expect(stored.some((column) => column.includes('resolve terminal path'))).toBe(true) + } finally { + await index.close() + } +}) diff --git a/src/main/ai-vault-search/session-search-message-rows.ts b/src/main/ai-vault-search/session-search-message-rows.ts new file mode 100644 index 00000000000..21254c183aa --- /dev/null +++ b/src/main/ai-vault-search/session-search-message-rows.ts @@ -0,0 +1,135 @@ +import type SyncDatabase from '../sqlite/sync-database' +import type { TranscriptMessage } from '../ai-vault/session-transcript-consumers' +import { sliceAtCodeUnitLimit } from '../ai-vault/session-scanner-text-normalization' +import { identifierShadowText } from './session-search-identifier-split' + +const CHUNK_TARGET_CHARS = 8000 + +/** + * How much of one tool output is indexed. Its head: a command, its arguments and + * the first lines of what it printed are what a user searches for, while the + * tail is the padding that makes these messages large in the first place. + * + * Tool output is 80-97 % of a transcript's bytes, and a single one can be a + * quarter of a megabyte (the reader's own per-message bound). Without this the + * index, the in-memory buffer a read holds and the transaction it commits are + * all sized by how much a tool printed rather than by how much is worth + * searching. 3 KB was the accuracy/size sweet spot in the original design + * measurement. User and assistant text is never capped: it is the conversation, + * and it is small. + */ +const TOOL_ROW_CHARS = 3072 + +// Keep unicode61's tokenchars intact, including before an available space. +// SQLite ext/fts5/fts5_unicode2.c: sqlite3Fts5UnicodeIsdiacritic, with remove_diacritics=1. +const FOLDED_DIACRITIC = + /[\u0300-\u0304\u0306-\u030c\u030f\u0311\u031b\u0323-\u0328\u032d-\u032e\u0330-\u0331]/ +const TOKEN_BOUNDARY = /[^\p{L}\p{N}\p{Co}_.\-/+\uD800-\uDFFF]/u + +/** + * Index just past the last token boundary in `[floor, end)`, or -1 when the + * window holds none. Not only a newline: a wrapped paragraph, a CJK transcript + * separated by ideographic spaces and a minified log all chunk on a boundary a + * tokenizer would have picked anyway. + */ +function lastTokenBoundaryEnd(text: string, floor: number, end: number): number { + for (let at = end - 1; at >= floor; at--) { + if (TOKEN_BOUNDARY.test(text[at]!) && !FOLDED_DIACRITIC.test(text[at]!)) { + return at + 1 + } + } + return -1 +} + +/** + * Splits an oversized message into rows of at most `CHUNK_TARGET_CHARS`, cutting + * on a token boundary so no token is torn in half and every word stays + * searchable. A phrase that straddles two chunks is not matched: chunks are + * separate FTS rows and FTS5 cannot span them. + */ +function* textChunks(text: string): Generator { + if (text.length <= CHUNK_TARGET_CHARS) { + yield text + return + } + let start = 0 + while (start < text.length) { + let end = Math.min(text.length, start + CHUNK_TARGET_CHARS) + if (end < text.length) { + // Only the second half of the window: backing up further would trade a + // torn token for chunks half the size. No boundary at all in 4,000 + // characters is not a word, so the target itself is the honest cut. + const split = lastTokenBoundaryEnd(text, start + CHUNK_TARGET_CHARS / 2, end) + if (split > start) { + end = split + } + } + yield text.slice(start, end) + start = end + } +} + +/** + * The row policy for one message: a `tool` message becomes one capped row, and + * anything else becomes N chunks, because FTS5 ranks a short row far better + * than a huge one. + */ +export function* searchMessageRows( + messages: Iterable +): Generator { + for (const message of messages) { + if (message.role === 'tool') { + yield { + ...message, + text: sliceAtCodeUnitLimit(message.text, TOOL_ROW_CHARS) + } + continue + } + for (const text of textChunks(message.text)) { + yield { ...message, text } + } + } +} + +/** + * Writes one row into `messages` and `messages_fts` in the caller's + * transaction, so a message is never present in one and absent from the other. + * A conversation-scoped query filters the columns rather than reading a second + * table (see the schema). + */ +export function insertSearchMessage( + db: SyncDatabase, + sessionId: number, + message: TranscriptMessage +): void { + const text = message.text + const id = db + .prepare('INSERT INTO messages(session_row_id, role, ts) VALUES (?, ?, ?)') + .run(sessionId, message.role, message.timestamp).lastInsertRowid + const user = message.role === 'user' ? text : '' + const assistant = message.role === 'assistant' ? text : '' + const tool = message.role === 'tool' ? text : '' + db.prepare( + 'INSERT INTO messages_fts(rowid,user_text,assistant_text,tool_text,identifiers) VALUES (?,?,?,?,?)' + ).run(id, user, assistant, tool, identifierShadowText(text)) +} + +/** + * Deletes up to `limit` of a session's rows from `messages` and `messages_fts`, + * in the caller's transaction, and reports how many went. Bounded + * because a retention sweep must not hold one transaction over a whole + * session; a replace passes no limit, since its rows and their replacements + * have to land together. + */ +export function deleteSearchMessages(db: SyncDatabase, sessionId: number, limit = -1): number { + const ids = db + .prepare('SELECT id FROM messages WHERE session_row_id = ? LIMIT ?') + .all(sessionId, limit) as { id: number }[] + const full = db.prepare('DELETE FROM messages_fts WHERE rowid = ?') + const message = db.prepare('DELETE FROM messages WHERE id = ?') + for (const { id } of ids) { + full.run(id) + message.run(id) + } + return ids.length +} diff --git a/src/main/ai-vault-search/session-search-retention-delete.test.ts b/src/main/ai-vault-search/session-search-retention-delete.test.ts new file mode 100644 index 00000000000..c21c8d7fe2b --- /dev/null +++ b/src/main/ai-vault-search/session-search-retention-delete.test.ts @@ -0,0 +1,188 @@ +import { expect, it } from 'vitest' +import type SyncDatabase from '../sqlite/sync-database' +import { + deleteExpiredSearchFiles, + RETENTION_DELETE_ROWS_PER_STEP +} from './session-search-retention-delete' +import { openSessionSearchIndexFile } from './session-search-index-test-fixture' +import { SessionSearchStore } from './session-search-store' + +function seed(db: SyncDatabase, id: number, rows: number, mtime: number): void { + db.prepare( + `INSERT INTO sessions(id,agent,session_id,file_path,title,cwd,cwd_key,resume_command) + VALUES (?, 'claude', ?, ?, 'synthetic retention', '/fixture', '/fixture', '')` + ).run(id, String(id), String(id)) + db.prepare('INSERT INTO files(path,byte_offset,mtime_ms,session_row_id) VALUES (?,1,?,?)').run( + String(id), + mtime, + id + ) + db.exec('BEGIN') + for (let i = 0; i < rows; i++) { + const row = db + .prepare("INSERT INTO messages(session_row_id,role) VALUES (?,'user')") + .run(id).lastInsertRowid + db.prepare('INSERT INTO messages_fts(rowid,user_text) VALUES (?,?)').run(row, 'retentionneedle') + } + db.exec('COMMIT') +} + +/** + * Sessions a search would still return. Every retrieval joins a message to its + * session, which is what makes cutting the session loose enough to hide the + * whole thing while its rows are still being reclaimed. + */ +function visibleSessionIds(db: SyncDatabase): string[] { + return ( + db + .prepare( + `SELECT DISTINCT s.session_id AS 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 'retentionneedle' ORDER BY s.session_id` + ) + .all() as { id: string }[] + ).map((row) => row.id) +} + +function count(db: SyncDatabase, table: string): number { + return (db.prepare(`SELECT count(*) AS n FROM ${table}`).get() as { n: number }).n +} + +it('seeks the expiring end of the file list instead of scanning it', async () => { + const index = await openSessionSearchIndexFile('ss-retention-plan') + try { + seed(index.db, 1, 1, 1) + const plan = ( + index.db + .prepare('EXPLAIN QUERY PLAN SELECT path FROM files WHERE mtime_ms < ? ORDER BY mtime_ms') + .all(100) as { detail: string }[] + ) + .map((row) => row.detail) + .join(' ') + // Without files_mtime this is "SCAN files" plus a "USE TEMP B-TREE FOR ORDER BY". + expect(plan).toContain('files_mtime') + expect(plan).not.toContain('TEMP B-TREE') + } finally { + await index.close() + } +}) + +it('hides an expiring session at once, then reclaims its rows in bounded steps', async () => { + const index = await openSessionSearchIndexFile('ss-retention-yield') + seed(index.db, 1, 1025, 1) + seed(index.db, 2, 1, 200) + let previous = 1025 + const steps: number[] = [] + try { + await deleteExpiredSearchFiles( + index.db, + 100, + () => false, + async () => { + const left = count(index.db, 'messages WHERE session_row_id=1') + steps.push(previous - left) + previous = left + // Cut loose in the very first transaction, so no query ever sees it with + // some of its messages already gone. + expect(visibleSessionIds(index.db)).toEqual(['2']) + } + ) + // The file transaction, then one bounded batch per step until the rows are gone. + expect(steps).toEqual([0, RETENTION_DELETE_ROWS_PER_STEP, 256, 256, 256, 1]) + expect(count(index.db, 'messages_fts')).toBe(1) + expect(count(index.db, 'sessions')).toBe(1) + } finally { + await index.close() + } +}) + +it('finishes an interrupted deletion after reopening', async () => { + const index = await openSessionSearchIndexFile('ss-retention-resume') + let store = new SessionSearchStore(index.path) + let closed = false + let steps = 0 + try { + seed(index.db, 1, 513, 1) + await deleteExpiredSearchFiles( + index.db, + 100, + () => closed, + async () => { + if (++steps === 2) { + store.close() + closed = true + } + } + ) + // Some rows went, the rest did not, and nothing recorded that anywhere. + const stranded = count(index.db, 'messages') + expect(stranded).toBeGreaterThan(0) + expect(stranded).toBeLessThan(513) + expect(visibleSessionIds(index.db)).toEqual([]) + + store = new SessionSearchStore(index.path) + closed = false + // Rows nothing points at are the whole record of unfinished work, so the + // rest goes even with retention now unlimited. + await store.purgeOlderThan(null) + expect(count(index.db, 'messages')).toBe(0) + expect(count(index.db, 'messages_fts')).toBe(0) + } finally { + if (!closed) { + store.close() + } + await index.close() + } +}) + +it('cancels retention between batches and resumes without exposing a partial session', async () => { + const index = await openSessionSearchIndexFile('ss-retention-cancel') + const store = new SessionSearchStore(index.path) + try { + seed(index.db, 1, 1025, 1) + const controller = new AbortController() + const purge = store.purgeOlderThan(100, controller.signal) + setImmediate(() => controller.abort()) + await purge + const remaining = count(index.db, 'messages') + expect(remaining).toBeGreaterThan(0) + expect(remaining).toBeLessThan(1025) + expect(visibleSessionIds(index.db)).toEqual([]) + await store.purgeOlderThan(null) + expect(count(index.db, 'messages')).toBe(0) + } finally { + store.close() + await index.close() + } +}) + +it('keeps a file a read refreshed after the expiry list was taken', async () => { + const index = await openSessionSearchIndexFile('ss-retention-refreshed') + try { + seed(index.db, 1, 2, 1) + seed(index.db, 2, 2, 2) + let refreshed = false + // The scan of `files` happens once, up front. A read of the second transcript + // lands while the first is being deleted, which makes it new enough to keep. + await deleteExpiredSearchFiles( + index.db, + 100, + () => false, + async () => { + if (!refreshed) { + refreshed = true + index.db.prepare('UPDATE files SET mtime_ms = 500 WHERE path = ?').run('2') + } + } + ) + + // Only the per-file transaction re-reading the mtime it is about to act on + // keeps that session; the list it came from says both should go. + expect(count(index.db, 'files')).toBe(1) + expect(visibleSessionIds(index.db)).toEqual(['2']) + expect(count(index.db, 'messages')).toBe(2) + } finally { + await index.close() + } +}) diff --git a/src/main/ai-vault-search/session-search-retention-delete.ts b/src/main/ai-vault-search/session-search-retention-delete.ts new file mode 100644 index 00000000000..e8d407f8be9 --- /dev/null +++ b/src/main/ai-vault-search/session-search-retention-delete.ts @@ -0,0 +1,102 @@ +import { setImmediate as yieldToEventLoop } from 'node:timers/promises' +import type SyncDatabase from '../sqlite/sync-database' +import { deleteSearchMessages } from './session-search-message-rows' + +export const RETENTION_DELETE_ROWS_PER_STEP = 256 +// Why in step with the deletes rather than one sweep at the end: `auto_vacuum = +// INCREMENTAL` holds every freed page until something asks for it back, and +// asking for a whole purge's worth at once is one long stall (40 ms per 22 MB +// freed, measured) instead of many short ones. +const RECLAIM_PAGES_PER_STEP = 2000 + +/** + * Drops every file older than the cutoff, then hands its rows back in bounded + * steps. + * + * The two halves are separate on purpose. Cutting a session loose from its file + * is one small transaction, and it is what makes the session stop answering + * searches — every read joins `sessions`, so a row whose session is gone is + * already unreachable. Reclaiming those rows is the expensive half, and it can + * be paused, interrupted or resumed at any point without a reader ever seeing a + * session that is half deleted. A crash in the middle leaves rows nothing + * points at, and `drainOrphanedMessages` finds them on the next pass. + */ +export async function deleteExpiredSearchFiles( + db: SyncDatabase, + cutoffMs: number | null, + closed: () => boolean, + yieldStep: () => Promise = yieldToEventLoop +): Promise { + if (cutoffMs !== null) { + const expired = db + .prepare('SELECT path FROM files WHERE mtime_ms < ? ORDER BY mtime_ms') + .all(cutoffMs) as { path: string }[] + for (const { path } of expired) { + if (closed()) { + return + } + db.exec('BEGIN IMMEDIATE') + try { + // Re-read under the lock: a read of this file may have landed since the + // list was taken, which makes it new enough to keep. + const file = db + .prepare('SELECT session_row_id FROM files WHERE path = ? AND mtime_ms < ?') + .get(path, cutoffMs) as { session_row_id: number | null } | undefined + if (file) { + db.prepare('DELETE FROM sessions WHERE id = ?').run(file.session_row_id) + db.prepare('DELETE FROM files WHERE path = ?').run(path) + } + db.exec('COMMIT') + } catch (error) { + db.exec('ROLLBACK') + throw error + } + await yieldStep() + } + } + await drainOrphanedMessages(db, closed, yieldStep) +} + +/** + * Deletes rows whose session no longer exists, a bounded batch per transaction. + * + * That set is exactly what retention, a replace that cut its old generation + * loose, a removed source and an interrupted earlier drain leave behind, so the + * index needs no record of unfinished work beyond the rows themselves. + * + * Exported for the store, which runs it after a replace commits for the same + * reason retention runs it after its own small transaction: cutting a session + * loose is what hides it, and reclaiming its rows is the half that must not + * hold one transaction. + */ +export async function drainOrphanedMessages( + db: SyncDatabase, + closed: () => boolean, + yieldStep: () => Promise = yieldToEventLoop +): Promise { + // Ordered by session so one call to this walks a session's rows to the end + // before paying for the scan that finds the next one. + const nextOrphan = db.prepare( + `SELECT session_row_id FROM messages + WHERE session_row_id NOT IN (SELECT id FROM sessions) LIMIT 1` + ) + let orphan = (nextOrphan.get() as { session_row_id: number } | undefined)?.session_row_id + while (orphan !== undefined && !closed()) { + db.exec('BEGIN IMMEDIATE') + let deleted = 0 + try { + deleted = deleteSearchMessages(db, orphan, RETENTION_DELETE_ROWS_PER_STEP) + db.exec('COMMIT') + } catch (error) { + db.exec('ROLLBACK') + throw error + } + db.pragma(`incremental_vacuum(${RECLAIM_PAGES_PER_STEP})`) + if (deleted < RETENTION_DELETE_ROWS_PER_STEP) { + orphan = (nextOrphan.get() as { session_row_id: number } | undefined)?.session_row_id + } + await yieldStep() + } + // A `removeFile` frees its pages outside this loop and may leave none to drain. + db.pragma(`incremental_vacuum(${RECLAIM_PAGES_PER_STEP})`) +} diff --git a/src/main/ai-vault-search/session-search-row-identity.test.ts b/src/main/ai-vault-search/session-search-row-identity.test.ts new file mode 100644 index 00000000000..4adc655b584 --- /dev/null +++ b/src/main/ai-vault-search/session-search-row-identity.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, expect, it } from 'vitest' +import type SyncDatabase from '../sqlite/sync-database' +import { deleteExpiredSearchFiles } from './session-search-retention-delete' +import { + openSessionSearchIndexFile, + syntheticCandidate, + syntheticSession, + userMessages, + type SessionSearchIndexFile +} from './session-search-index-test-fixture' +import { SessionSearchStore } from './session-search-store' + +// A session row id outlives the row: it names the rows in `messages` until a +// retention drain has walked all of them, which takes many transactions. These +// tests are about what may be handed that id in the meantime. + +let index: SessionSearchIndexFile +let store: SessionSearchStore +let errors: unknown[] + +beforeEach(async () => { + index = await openSessionSearchIndexFile('ss-row-identity') + errors = [] + store = new SessionSearchStore(index.path, (error) => errors.push(error)) +}) + +afterEach(async () => { + store.close() + await index.close() +}) + +const OLD_MTIME = 1_000 +const LIVE_MTIME = 1_000_000 +const LIVE_PATH = '/live.jsonl' + +function count(db: SyncDatabase, table: string): number { + return (db.prepare(`SELECT count(*) AS n FROM ${table}`).get() as { n: number }).n +} + +/** Rows a search would return for a term: the join every retrieval makes. */ +function matches(db: SyncDatabase, term: string): number { + return ( + db + .prepare( + `SELECT count(*) AS n 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 ?` + ) + .get(term) as { n: number } + ).n +} + +function indexFile(path: string, mtimeMs: number, text: string, rows: number): void { + const write = store.beginWrite(syntheticCandidate({ path, mtimeMs }), 'replace', 0)! + for (const message of userMessages(text, rows)) { + write.add(message) + } + expect(write.commit({ session: syntheticSession(), byteOffset: 50, incomplete: false })).toBe( + true + ) +} + +it('never hands a live session the rows of a purged one', async () => { + // Two expiring transcripts, each large enough that reclaiming their rows takes + // several transactions, and one live transcript the parser decoded no session + // from — so it holds a cursor and no session row of its own. + indexFile('/old-a.jsonl', OLD_MTIME, 'purgedneedle', 400) + indexFile('/old-b.jsonl', OLD_MTIME, 'purgedneedle', 400) + const live = syntheticCandidate({ path: LIVE_PATH, mtimeMs: LIVE_MTIME }) + const opening = store.beginWrite(live, 'replace', 0)! + opening.add(userMessages('excluded', 1)[0]!) + expect(opening.commit({ session: null, byteOffset: 50, incomplete: false })).toBe(true) + + let appended = false + await deleteExpiredSearchFiles( + index.db, + LIVE_MTIME, + () => false, + async () => { + // The window: both expiring sessions are cut loose, most of their rows are + // still on disk, and the live transcript grows. The append is legitimate — + // it continues this index's own cursor — and it needs a session row. + if (appended || count(index.db, 'sessions') > 0) { + return + } + appended = true + const write = store.beginWrite(live, 'append', 50)! + for (const message of userMessages('liveneedle', 2)) { + write.add(message) + } + expect( + write.commit({ session: syntheticSession(), byteOffset: 120, incomplete: false }) + ).toBe(true) + } + ) + + expect(appended).toBe(true) + // Reusing a freed id would adopt whatever of that session's rows the drain had + // not reached, and put them behind a live session no purge will visit again. + expect(matches(index.db, 'purgedneedle')).toBe(0) + expect(matches(index.db, 'liveneedle')).toBe(2) + expect(count(index.db, 'messages')).toBe(2) + expect(errors).toEqual([]) +}) + +it('never reissues a session row id a delete freed', () => { + for (const path of ['/a.jsonl', '/b.jsonl', '/c.jsonl']) { + indexFile(path, OLD_MTIME, 'seeded', 1) + } + const before = (index.db.prepare('SELECT max(id) AS id FROM sessions').get() as { id: number }).id + index.db.exec('DELETE FROM sessions') + + indexFile('/d.jsonl', OLD_MTIME, 'seeded', 1) + expect((index.db.prepare('SELECT id FROM sessions').get() as { id: number }).id).toBeGreaterThan( + before + ) +}) diff --git a/src/main/ai-vault-search/session-search-schema.test.ts b/src/main/ai-vault-search/session-search-schema.test.ts new file mode 100644 index 00000000000..b23f36cde55 --- /dev/null +++ b/src/main/ai-vault-search/session-search-schema.test.ts @@ -0,0 +1,350 @@ +import type * as NodeFs from 'node:fs' +import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + removeTree, + WINDOWS_RM_MAX_RETRIES, + WINDOWS_RM_RETRY_DELAY_MS +} from '../../shared/windows-transient-lock-removal' +import SyncDatabase from '../sqlite/sync-database' +import { + SESSION_SEARCH_SCHEMA_VERSION, + openSessionSearchDatabase, + removeSessionSearchDatabase +} from './session-search-schema' + +const recordedRmSync = vi.hoisted(() => vi.fn()) +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + rmSync: (...args: Parameters) => { + recordedRmSync(...args) + return actual.rmSync(...args) + } + } +}) + +let roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.map((root) => removeTree(root))) + roots = [] +}) + +async function tempDatabasePath(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-session-search-schema-')) + roots.push(root) + return join(root, 'index.sqlite') +} + +function schemaVersion(db: SyncDatabase): string | undefined { + return ( + db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get() as + | { value: string } + | undefined + )?.value +} + +describe('openSessionSearchDatabase', () => { + it('keeps a current-version index and its rows', async () => { + const path = await tempDatabasePath() + const first = openSessionSearchDatabase(path) + first.prepare("INSERT INTO files(path,byte_offset,mtime_ms) VALUES ('a',1,1)").run() + first.close() + + const second = openSessionSearchDatabase(path) + expect(schemaVersion(second)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION)) + expect(second.prepare('SELECT COUNT(*) AS c FROM files').get()).toEqual({ + c: 1 + }) + second.close() + }) + + it('carries one FTS table and throws away an index that carries two', async () => { + const path = await tempDatabasePath() + const fresh = openSessionSearchDatabase(path) + const tables = (): string[] => + ( + fresh + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE '%_fts'") + .all() as { name: string }[] + ).map((row) => row.name) + expect(tables()).toEqual(['messages_fts']) + + // What an index written before this bump looks like: the second table, and + // rows in it. `CREATE TABLE IF NOT EXISTS` would leave both in place, so + // only the version bump makes that file go. + fresh.exec('CREATE VIRTUAL TABLE conversation_fts USING fts5(user_text, assistant_text)') + fresh.prepare("INSERT INTO files(path,byte_offset,mtime_ms) VALUES ('a',1,1)").run() + fresh.prepare("UPDATE meta SET value = '3' WHERE key = 'schema_version'").run() + fresh.close() + + const rebuilt = openSessionSearchDatabase(path) + expect(schemaVersion(rebuilt)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION)) + expect( + rebuilt + .prepare("SELECT count(*) AS n FROM sqlite_master WHERE name = 'conversation_fts'") + .get() + ).toEqual({ n: 0 }) + expect(rebuilt.prepare('SELECT COUNT(*) AS c FROM files').get()).toEqual({ c: 0 }) + rebuilt.close() + }) + + it('replaces the file on a version mismatch instead of dropping tables in place', async () => { + const path = await tempDatabasePath() + const stale = openSessionSearchDatabase(path) + stale.prepare("INSERT INTO files(path,byte_offset,mtime_ms) VALUES ('a',1,1)").run() + stale + .prepare("UPDATE meta SET value = ? WHERE key = 'schema_version'") + .run(String(SESSION_SEARCH_SCHEMA_VERSION + 1)) + stale.close() + // Why: a stale sidecar must go with the main file, or SQLite replays it into the new one. + await writeFile(`${path}-wal`, 'stale wal bytes') + const before = await stat(path) + + const fresh = openSessionSearchDatabase(path) + expect(schemaVersion(fresh)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION)) + expect(fresh.prepare('SELECT COUNT(*) AS c FROM files').get()).toEqual({ + c: 0 + }) + fresh.close() + // Why not inode: ext4 hands a freed inode straight back to the next create. + // The planted sidecar is gone (a fresh WAL is checkpointed away on close). + await expect(stat(`${path}-wal`)).rejects.toMatchObject({ code: 'ENOENT' }) + expect((await stat(path)).mtimeMs).toBeGreaterThanOrEqual(before.mtimeMs) + }) + + it('removes the database with every sidecar', async () => { + const path = await tempDatabasePath() + openSessionSearchDatabase(path).close() + await writeFile(`${path}-shm`, '') + removeSessionSearchDatabase(path) + for (const suffix of ['', '-wal', '-shm']) { + await expect(stat(`${path}${suffix}`)).rejects.toMatchObject({ + code: 'ENOENT' + }) + } + }) +}) + +it('rebuilds a file too corrupt to open instead of refusing forever', async () => { + const path = await tempDatabasePath() + const healthy = openSessionSearchDatabase(path) + healthy.prepare("INSERT INTO files(path,byte_offset,mtime_ms) VALUES ('a',1,1)").run() + healthy.close() + // A torn page, not a truncation: SQLite opens the header and fails on the read. + const bytes = await readFile(path) + bytes.fill(0x7f, 4096, Math.min(bytes.length, 12_288)) + await writeFile(path, bytes) + + const rebuilt = openSessionSearchDatabase(path) + try { + expect(schemaVersion(rebuilt)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION)) + expect(rebuilt.prepare('SELECT COUNT(*) AS c FROM files').get()).toEqual({ + c: 0 + }) + } finally { + rebuilt.close() + } +}) + +it('rebuilds a file that is not a database at all', async () => { + const path = await tempDatabasePath() + await writeFile(path, 'not a SQLite database') + + const rebuilt = openSessionSearchDatabase(path) + try { + expect(schemaVersion(rebuilt)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION)) + } finally { + rebuilt.close() + } +}) + +it('gives up rather than looping when a fresh file still cannot be opened', async () => { + const path = await tempDatabasePath() + await writeFile(path, 'not a SQLite database') + // Every open of this path fails, so the one permitted retry is exhausted. + const open = vi.spyOn(SyncDatabase.prototype, 'pragma').mockImplementation(() => { + throw Object.assign(new Error('database disk image is malformed'), { + code: 'SQLITE_CORRUPT' + }) + }) + try { + expect(() => openSessionSearchDatabase(path)).toThrow(/malformed/) + } finally { + open.mockRestore() + } +}) + +it('surfaces the unlink failure itself when a stale index cannot be removed', async () => { + const path = await tempDatabasePath() + const stale = openSessionSearchDatabase(path) + stale + .prepare("UPDATE meta SET value = ? WHERE key = 'schema_version'") + .run(String(SESSION_SEARCH_SCHEMA_VERSION + 1)) + stale.close() + recordedRmSync.mockReset() + recordedRmSync.mockImplementation(() => { + throw Object.assign(new Error('EPERM: operation not permitted, unlink'), { + code: 'EPERM' + }) + }) + try { + // The stale handle is closed before the unlink, so the failure path must not + // close it again: ERR_INVALID_STATE would bury the cause and would not be + // classified as worth a rebuild. + expect(() => openSessionSearchDatabase(path)).toThrow(/EPERM/) + expect(() => openSessionSearchDatabase(path)).not.toThrow(/not open/) + } finally { + recordedRmSync.mockReset() + } +}) + +it('creates the directory the index lives in', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-session-search-mkdir-')) + roots.push(root) + // The real layout: `/ai-vault-search/index.sqlite`, where nothing + // has made that folder yet. SQLite would fail with `unable to open database + // file`, which is correctly not treated as corruption, so it never retries. + const db = openSessionSearchDatabase(join(root, 'ai-vault-search', 'index.sqlite')) + try { + expect(schemaVersion(db)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION)) + } finally { + db.close() + } +}) + +it('rebuilds a newer index rather than reading a schema it does not know', async () => { + const path = await tempDatabasePath() + const newer = openSessionSearchDatabase(path) + newer.prepare("INSERT INTO files(path,byte_offset,mtime_ms) VALUES ('a',1,1)").run() + newer + .prepare("UPDATE meta SET value = ? WHERE key = 'schema_version'") + .run(String(SESSION_SEARCH_SCHEMA_VERSION + 1)) + newer.close() + + const rebuilt = openSessionSearchDatabase(path) + try { + expect(schemaVersion(rebuilt)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION)) + expect(rebuilt.prepare('SELECT COUNT(*) AS c FROM files').get()).toEqual({ + c: 0 + }) + } finally { + rebuilt.close() + } +}) + +it('rebuilds when meta exists but its version row is gone', async () => { + const path = await tempDatabasePath() + const damaged = openSessionSearchDatabase(path) + damaged.prepare("INSERT INTO files(path,byte_offset,mtime_ms) VALUES ('a',1,1)").run() + // A meta table with no version is a damaged index, never a fresh one: seeding + // the current version over it would keep whatever the old schema left behind. + damaged.prepare("DELETE FROM meta WHERE key = 'schema_version'").run() + damaged.close() + + const rebuilt = openSessionSearchDatabase(path) + try { + expect(schemaVersion(rebuilt)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION)) + expect(rebuilt.prepare('SELECT COUNT(*) AS c FROM files').get()).toEqual({ + c: 0 + }) + } finally { + rebuilt.close() + } +}) + +it('opens with the pragmas the write path depends on', async () => { + const db = openSessionSearchDatabase(await tempDatabasePath()) + try { + // auto_vacuum=2 is INCREMENTAL, and only takes on an empty file: without it + // a purge cannot hand pages back in bounded steps. + expect(Number(db.pragma('auto_vacuum', { simple: true }))).toBe(2) + expect(String(db.pragma('journal_mode', { simple: true })).toLowerCase()).toBe('wal') + expect(Number(db.pragma('synchronous', { simple: true }))).toBe(1) + // A WAL with no size limit never hands its space back after a large write. + expect(Number(db.pragma('journal_size_limit', { simple: true }))).toBe(8388608) + // Zero here turns every contended write into an immediate SQLITE_BUSY. + expect(Number(db.pragma('busy_timeout', { simple: true }))).toBe(5000) + } finally { + db.close() + } +}) + +it("walks a session's rows through an index rather than scanning the table", async () => { + const db = openSessionSearchDatabase(await tempDatabasePath()) + try { + // The replace delete and the orphan drain both take this path, once per file. + const plan = ( + db + .prepare('EXPLAIN QUERY PLAN SELECT id FROM messages WHERE session_row_id = ? LIMIT ?') + .all(1, 1) as { detail: string }[] + ) + .map((row) => row.detail) + .join(' ') + expect(plan).toContain('messages_session') + } finally { + db.close() + } +}) + +it('keeps only the session indexes a retrieval query can seek', async () => { + const db = openSessionSearchDatabase(await tempDatabasePath()) + try { + const names = ( + db + .prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='sessions'") + .all() as { name: string }[] + ) + .map((row) => row.name) + .sort() + // One per shape PR 4's retrieval seeks: the agent filter, the newest-first + // order and date window, and the folder-prefix range scan. Fork folding reads + // `content_hash` off rows it already holds, so that column is not indexed. + expect(names).toEqual(['sessions_agent', 'sessions_cwd_key', 'sessions_updated_at']) + } finally { + db.close() + } +}) + +it("retries a Windows lock that outlives rmSync's own retries", async () => { + const path = await tempDatabasePath() + openSessionSearchDatabase(path).close() + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + recordedRmSync.mockReset() + const locked = Object.assign(new Error('EPERM: operation not permitted'), { + code: 'EPERM' + }) + recordedRmSync.mockImplementationOnce(() => { + throw locked + }) + try { + expect(() => removeSessionSearchDatabase(path)).not.toThrow() + expect(recordedRmSync.mock.calls.length).toBe(5) + await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' }) + } finally { + recordedRmSync.mockReset() + vi.restoreAllMocks() + } +}) + +it('gives Windows the shared retry options for a late handle release', async () => { + const path = await tempDatabasePath() + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + recordedRmSync.mockClear() + try { + removeSessionSearchDatabase(path) + expect(recordedRmSync).toHaveBeenCalled() + for (const [, options] of recordedRmSync.mock.calls) { + expect(options).toMatchObject({ + maxRetries: WINDOWS_RM_MAX_RETRIES, + retryDelay: WINDOWS_RM_RETRY_DELAY_MS + }) + } + } finally { + vi.restoreAllMocks() + } +}) diff --git a/src/main/ai-vault-search/session-search-schema.ts b/src/main/ai-vault-search/session-search-schema.ts new file mode 100644 index 00000000000..2da1e64bc11 --- /dev/null +++ b/src/main/ai-vault-search/session-search-schema.ts @@ -0,0 +1,198 @@ +import { mkdirSync } from 'node:fs' +import { dirname } from 'node:path' +import SyncDatabase from '../sqlite/sync-database' +import { removeTreeSync } from '../../shared/windows-transient-lock-removal' + +// The index stores transcript content as written, with no redaction. A secret in +// a transcript is already plaintext under the user's home directory and is +// treated as compromised; this is a second copy of content the user already +// holds. What a snippet may carry once it leaves this machine is a transport +// policy, decided where the wire is. + +// Bump to drop and rebuild: the index is a cache over the transcripts, never a source. +export const SESSION_SEARCH_SCHEMA_VERSION = 5 + +// unicode61 keeps `_ . - /` inside tokens so paths and identifiers match exactly; +// the `identifiers` column carries the split form (see session-search-identifier-split). +// Why: `+` keeps `C++` a token of its own instead of the letter `c`; `#` is +// left out so `#123` still answers a search for `123`. +const TOKENIZER = `tokenize="unicode61 tokenchars '_.-/+'"` + +const SCHEMA_SQL = ` +CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS sessions( + -- AUTOINCREMENT, because this id names rows in the messages table for longer + -- than the row itself lives: retention cuts a session loose in one + -- transaction and reclaims its messages over many. A plain rowid is reissued + -- as max+1, so a session created inside that window would be handed a freed + -- id and adopt whatever of the purged conversation the drain had not reached, + -- behind a live session no later purge visits. + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent TEXT NOT NULL, + session_id TEXT NOT NULL, + -- The transcript this session was decoded from. Not unique: OpenCode's SQLite + -- sessions all report the store's own path here, while files.path holds the + -- synthetic db#sessionId candidate that really is one per session. + file_path TEXT NOT NULL, + codex_home TEXT, + title TEXT NOT NULL, + cwd TEXT, + cwd_key TEXT, + branch TEXT, + created_at TEXT, + updated_at TEXT, + message_count INTEGER NOT NULL DEFAULT 0, + resume_command TEXT NOT NULL, + -- Chained digest of the first N messages; forks of one conversation share it. + content_hash TEXT, + content_hash_count INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS sessions_agent ON sessions(agent); +CREATE INDEX IF NOT EXISTS sessions_updated_at ON sessions(updated_at); +CREATE INDEX IF NOT EXISTS sessions_cwd_key ON sessions(cwd_key); +CREATE TABLE IF NOT EXISTS files( + path TEXT PRIMARY KEY, + dev INTEGER, + ino INTEGER, + byte_offset INTEGER NOT NULL, + mtime_ms REAL NOT NULL, + size_bytes INTEGER, + session_row_id INTEGER +); +-- Retention walks the expiring end of this column; without it that is a full scan and a sort. +CREATE INDEX IF NOT EXISTS files_mtime ON files(mtime_ms); +CREATE TABLE IF NOT EXISTS messages( + id INTEGER PRIMARY KEY, + session_row_id INTEGER NOT NULL, + role TEXT NOT NULL, + ts TEXT +); +-- Both the replace delete and the orphan drain walk a session's rows through this. +CREATE INDEX IF NOT EXISTS messages_session ON messages(session_row_id); +-- One FTS table, not two. A conversation-scoped search is a column filter on +-- this one — 'MATCH {user_text assistant_text}: q' with bm25 weights that zero +-- the other two — and PR 4 measured that at 1.16-1.36x the p95 of a dedicated +-- second table on a 105 MB corpus, under the 2x bar the decision was set at. +CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( + user_text, assistant_text, tool_text, identifiers, ${TOKENIZER}, detail=full +); +` + +/** + * Opens the index, rebuilding it whenever what is on disk cannot be trusted: + * a different schema version, a version SQLite cannot report, or a file torn + * badly enough that opening or recovery fails. The index is a cache over the + * transcripts, so throwing away a bad one costs a re-scan and nothing else; + * refusing to open would strand the feature until a human deleted the file. + */ +export function openSessionSearchDatabase(path: string): SyncDatabase { + // SQLite will not create the directory, and its failure is `unable to open + // database file`, which is correctly not corruption — so without this the + // feature strands on a profile that has never held an index. + if (path !== ':memory:') { + mkdirSync(dirname(path), { recursive: true }) + } + try { + return openExisting(path) + } catch (error) { + if (!isUnusableDatabaseError(error)) { + throw error + } + // One retry only: a second failure on a file we just created is not corruption. + removeSessionSearchDatabase(path) + return openExisting(path) + } +} + +function openExisting(path: string): SyncDatabase { + // Nulled while no handle is open, because closing an already-closed handle + // throws ERR_INVALID_STATE, which would replace whatever really failed — + // an unlink refused by a virus scanner or a second Orca holding the file — + // with an error nothing classifies as worth rebuilding for. + let db: SyncDatabase | null = openWithPragmas(path) + try { + if (isStaleSchema(db)) { + // Why: DROP TABLE on a multi-GB FTS index takes minutes and runs inside the + // scanner service's init, past its ready timeout; unlinking is instant. + db.close() + db = null + removeSessionSearchDatabase(path) + db = openWithPragmas(path) + } + db.exec(SCHEMA_SQL) + db.prepare('INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)').run( + 'schema_version', + String(SESSION_SEARCH_SCHEMA_VERSION) + ) + return db + } catch (error) { + db?.close() + throw error + } +} + +// SQLite reports a torn file at the first statement that has to read a page, so +// this has to match on the message as well as the code. +const UNUSABLE_DATABASE = + /SQLITE_CORRUPT|SQLITE_NOTADB|file is not a database|database disk image is malformed/i + +function isUnusableDatabaseError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + const code = (error as { code?: unknown }).code + return ( + (typeof code === 'string' && UNUSABLE_DATABASE.test(code)) || + UNUSABLE_DATABASE.test(error.message) + ) +} + +function openWithPragmas(path: string): SyncDatabase { + const db = new SyncDatabase(path) + try { + // Why: only takes effect on an empty file; it is what lets a purge hand pages + // back in bounded steps instead of a full VACUUM. Set before any table exists. + db.pragma('auto_vacuum = INCREMENTAL') + // The whole consistency model: a file's rows and its cursor land in one + // transaction, and a reader on another handle sees the last committed state + // of the index rather than a session half way through being rewritten. + db.pragma('journal_mode = WAL') + db.pragma('synchronous = NORMAL') + db.pragma('journal_size_limit = 8388608') + db.pragma('busy_timeout = 5000') + return db + } catch (error) { + db?.close() + throw error + } +} + +export function removeSessionSearchDatabase(path: string): void { + if (path === ':memory:') { + return + } + for (const suffix of ['', '-wal', '-shm', '-journal']) { + removeTreeSync(`${path}${suffix}`) + } +} + +/** + * Whether what is on disk has to be thrown away. No `meta` table at all is a + * file with nothing in it to throw away, and removing it would make the first + * open of every new profile a create-remove-create. A meta table whose version + * row is missing or unparseable is a damaged index rather than a new one: + * seeding the current version over it would keep whatever rows the old schema + * left. + */ +function isStaleSchema(db: SyncDatabase): boolean { + const table = db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'meta'") + .get() + if (!table) { + return false + } + const row = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get() as + | { value: string } + | undefined + return (row ? Number(row.value) : Number.NaN) !== SESSION_SEARCH_SCHEMA_VERSION +} diff --git a/src/main/ai-vault-search/session-search-store.ts b/src/main/ai-vault-search/session-search-store.ts new file mode 100644 index 00000000000..da9f4d6f731 --- /dev/null +++ b/src/main/ai-vault-search/session-search-store.ts @@ -0,0 +1,253 @@ +import type SyncDatabase from '../sqlite/sync-database' +import type { SessionFileCandidate } from '../ai-vault/session-scanner-types' +import type { TranscriptSessionIdentity } from '../ai-vault/session-transcript-consumers' +import type { + SessionSearchFileIdentity, + SessionSearchIndexedFile +} from './session-search-file-cursor' +import { + SESSION_SEARCH_COMMIT_CHARS, + SessionSearchIndexWriter, + type SessionSearchFileWrite +} from './session-search-index-writer' +import { deleteExpiredSearchFiles, drainOrphanedMessages } from './session-search-retention-delete' +import { openSessionSearchDatabase } from './session-search-schema' + +// A paused store keeps recording what it declined, so the set needs a ceiling. +// Above it the oldest record goes and the drop is counted, because a re-read set +// that silently forgets is worse than one that says it is incomplete. +export const STALE_PATH_LIMIT = 20_000 + +/** + * Owns the index database. PR 2 scope: the write half only — the transcript + * consumer writes through it and nothing reads from it yet. Lifecycle (who + * indexes, when, and how the re-read set is drained) belongs to the service. + */ +export class SessionSearchStore { + private readonly db: SyncDatabase + private readonly writer: SessionSearchIndexWriter + private closed = false + private acceptingWrites = true + private retentionCutoffMs: number | null = null + // Files this index knows it is behind on. Filled by a declined or abandoned + // read; PR 3's indexer drains it. Nothing here schedules the re-read. + private readonly stale = new Map() + private droppedStalePaths = 0 + // One drain at a time. A replace that commits while one is running asks for + // another pass rather than starting a second walk of the same rows. + private draining = false + private drainRequested = false + + constructor( + path: string, + private readonly onError: (error: unknown) => void = (error) => + console.warn( + '[ai-vault-search] index write failed:', + error instanceof Error ? error.name : 'IndexError' + ) + ) { + this.db = openSessionSearchDatabase(path) + this.writer = new SessionSearchIndexWriter(this.db, SESSION_SEARCH_COMMIT_CHARS, () => + this.scheduleOrphanDrain() + ) + } + + /** + * Reclaims the rows a replace cut loose, once its transaction has committed. + * + * The same split retention makes, for the same reason: deleting the old + * session row is what stops it answering, because every retrieval joins + * `sessions`, and handing its messages back is the expensive half that must + * not hold one transaction. Nothing records the work: rows whose session row + * is gone are the whole record, so a crash before or during a drain is found + * by the next one. + */ + private scheduleOrphanDrain(): void { + this.drainRequested = true + if (this.draining || this.closed) { + return + } + this.draining = true + // Off the committing stack. An async function runs synchronously up to its + // first `await`, so calling the drain here would put its first batch back + // inside the call that committed the replace — the cost this took out. + void Promise.resolve().then(() => this.runOrphanDrain()) + } + + private async runOrphanDrain(): Promise { + try { + while (this.drainRequested && !this.closed) { + this.drainRequested = false + await drainOrphanedMessages(this.db, () => this.closed) + } + } catch (error) { + if (!this.closed) { + this.onError(error) + } + } finally { + this.draining = false + } + } + + /** + * The index handle, for a reader composed over this store (PR 4's engine). + * + * Two rules come with it, both measured in this PR. **Never hold a read + * transaction across an `await`**: a checkpoint cannot pass an open read + * snapshot, so a paginated read that opened `BEGIN` and yielded between pages + * takes the WAL from 10 MB to 266 MB and it does not come back. And **no + * `.iterate()` that outlives its statement**, which is the same pin by + * another name. Every retrieval a single synchronous statement is the whole + * contract. + */ + get connection(): SyncDatabase { + return this.db + } + + setAcceptingWrites(accept: boolean): void { + this.acceptingWrites = accept + } + + /** The oldest transcript mtime worth indexing; PR 3 derives it from the retention setting. */ + setRetentionCutoffMs(cutoffMs: number | null): void { + this.retentionCutoffMs = cutoffMs + } + + /** Whether this candidate is new enough to be worth holding rows for at all. */ + private withinRetention(candidate: SessionFileCandidate): boolean { + return this.retentionCutoffMs === null || candidate.file.mtimeMs >= this.retentionCutoffMs + } + + /** Whether a write for this candidate may start right now. */ + acceptsCandidate(candidate: SessionFileCandidate): boolean { + return !this.closed && this.acceptingWrites && this.withinRetention(candidate) + } + + indexedFile(path: string, identity: SessionSearchFileIdentity): SessionSearchIndexedFile | null { + try { + return this.writer.indexedFile(path, identity) + } catch (error) { + this.onError(error) + return null + } + } + + /** Null when this read cannot extend the index, or when the store refuses writes. */ + beginWrite( + candidate: SessionFileCandidate, + mode: 'replace' | 'append', + previousByteOffset: number, + identity?: () => TranscriptSessionIdentity | null + ): SessionSearchFileWrite | null { + if (!this.acceptsCandidate(candidate)) { + return null + } + try { + return this.writer.beginWrite(candidate, mode, previousByteOffset, identity) + } catch (error) { + this.reportWriteFailure(error) + return null + } + } + + writeCommitted(candidate: SessionFileCandidate): void { + // Why: a list scan queues every file the backfill has not reached yet; once + // one lands, a later pass must not re-read the whole queue. + this.stale.delete(candidate.file.path) + } + + reportWriteFailure(error: unknown): void { + this.onError(error) + } + + /** + * Records a file whose content the index is behind on, for a later whole + * re-read. Recorded while paused too: a pause is exactly the window in which + * reads are declined, so refusing to remember them would lose every file the + * pause covered. + */ + markStale(candidate: SessionFileCandidate): void { + if (this.closed || !this.withinRetention(candidate)) { + return + } + // Re-inserting moves the path to the end, so the oldest record is the one + // dropped when a long pause overruns the bound. + this.stale.delete(candidate.file.path) + this.stale.set(candidate.file.path, candidate) + while (this.stale.size > STALE_PATH_LIMIT) { + const oldest = this.stale.keys().next() + if (oldest.done) { + break + } + this.stale.delete(oldest.value) + this.droppedStalePaths += 1 + } + } + + /** + * Files the index knew it was behind on and could not keep a record of. A + * non-zero count means the re-read set is incomplete, so coverage cannot be + * reported as whole until a full pass runs. + */ + get droppedPendingFileCount(): number { + return this.droppedStalePaths + } + + /** + * Hands the re-read set to its scheduler and clears it. + * + * These paths are behind, not merely dirty: the index declined their last read + * because it covered a span the index never saw. Re-dispatching a scan is not + * enough on its own, because the reader picks `append` from the session list's + * resume point and the consumer will decline again. The caller must pass each + * path to `requestWholeTranscriptRead` first. + */ + takeStale(): SessionFileCandidate[] { + const candidates = [...this.stale.values()] + this.stale.clear() + return candidates + } + + get pendingFileCount(): number { + return this.stale.size + } + + /** + * Drops a source's rows. Only a proven deletion may call this: an unreadable + * source is `unverifiable`, not `missing`, and keeps its rows + * (docs/reference/ssh-execution-boundary.md). + */ + removeFile(path: string): void { + this.stale.delete(path) + try { + this.writer.removeFile(path) + } catch (error) { + this.onError(error) + } + } + + /** Cuts expired sessions loose at once, then reclaims their rows in resumable batches. */ + async purgeOlderThan(cutoffMs: number | null, signal?: AbortSignal): Promise { + try { + await deleteExpiredSearchFiles( + this.db, + cutoffMs, + () => this.closed || signal?.aborted === true + ) + } catch (error) { + if (!this.closed) { + this.onError(error) + } + } + } + + close(): void { + // node:sqlite throws ERR_INVALID_STATE on a second close, and a store is + // closed both by its owner and by a test's teardown. + if (this.closed) { + return + } + this.closed = true + this.db.close() + } +} diff --git a/src/main/ai-vault-search/session-search-synthetic-corpus.test.ts b/src/main/ai-vault-search/session-search-synthetic-corpus.test.ts new file mode 100644 index 00000000000..9b6a48beb4e --- /dev/null +++ b/src/main/ai-vault-search/session-search-synthetic-corpus.test.ts @@ -0,0 +1,44 @@ +import { rm } from 'node:fs/promises' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache' +import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers' +import { registerSessionSearchIndexConsumer } from './session-search-index-consumer' +import { SessionSearchStore } from './session-search-store' +import { writeSyntheticTranscriptCorpus } from './session-search-synthetic-corpus' +import { parseTranscript } from './session-search-transcript-fixtures' + +it.each([Infinity, -Infinity, Number.NaN, -1, 1.5])( + 'rejects invalid corpus loop bounds: %s', + async (value) => { + for (const field of ['sessions', 'turnsPerSession', 'toolResultWords']) { + await expect(writeSyntheticTranscriptCorpus({ [field]: value })).rejects.toThrow(RangeError) + } + } +) + +it.each([0, 200, 2000])( + 'counts the indexed messages with %s tool words', + async (toolResultWords) => { + const corpus = await writeSyntheticTranscriptCorpus({ + sessions: 1, + turnsPerSession: 1, + toolResultWords + }) + const store = new SessionSearchStore(join(corpus.root, 'index.sqlite')) + const unregister = registerSessionSearchIndexConsumer(store) + try { + await parseTranscript(corpus.files[0]!) + expect(corpus.messageCount).toBe(toolResultWords === 0 ? 3 : 4) + expect(store.connection.prepare('SELECT count(*) AS n FROM messages').get()).toEqual({ + n: corpus.messageCount + }) + } finally { + unregister() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + store.close() + await rm(corpus.root, { recursive: true, force: true }) + } + } +) diff --git a/src/main/ai-vault-search/session-search-synthetic-corpus.ts b/src/main/ai-vault-search/session-search-synthetic-corpus.ts new file mode 100644 index 00000000000..14e8a27fe5f --- /dev/null +++ b/src/main/ai-vault-search/session-search-synthetic-corpus.ts @@ -0,0 +1,154 @@ +import { mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// Why synthetic and in-repo: the cost model has to be reproducible on any host +// and must never read a real transcript. The shapes here mirror what a Claude +// JSONL transcript actually holds — prose turns, a pasted diff, tool calls and +// their output — because the index's disk cost tracks the mix, not the size. + +const WORDS = [ + 'terminal', + 'reattach', + 'worktree', + 'resolveTerminalPath', + 'src/main/ai-vault/session-transcript-reader.ts', + 'the', + 'index', + 'cursor', + 'byteOffset', + 'publish', + 'staged', + 'transaction', + 'MAX_RETRIES', + 'relay', + 'daemon', + 'pty', + 'snapshot', + 'because' +] + +/** Deterministic: the same seed gives the same corpus on every host and run. */ +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, count: number): string { + const out: string[] = [] + for (let index = 0; index < count; index++) { + out.push(WORDS[Math.floor(random() * WORDS.length)]) + } + return out.join(' ') +} + +export type SyntheticCorpus = { + root: string + files: string[] + /** Total bytes of transcript written, the denominator of write amplification. */ + transcriptBytes: number + messageCount: number +} + +export type SyntheticCorpusOptions = { + sessions?: number + turnsPerSession?: number + seed?: number + /** + * Words per tool result. The default keeps tool output at about half the + * message text; the real distribution is 80-97 %, which is what prices the + * tool-row cap, so the benchmark runs a second arm well above the default. + */ + toolResultWords?: number +} + +/** Writes a corpus of Claude JSONL transcripts and reports what it cost on disk. */ +export async function writeSyntheticTranscriptCorpus( + options: SyntheticCorpusOptions = {} +): Promise { + const sessions = options.sessions ?? 40 + const turns = options.turnsPerSession ?? 60 + const toolWords = options.toolResultWords ?? 200 + for (const [name, value] of Object.entries({ + sessions, + turnsPerSession: turns, + toolResultWords: toolWords + })) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError(`${name} must be a finite non-negative safe integer`) + } + } + const random = mulberry32(options.seed ?? 1) + const root = await mkdtemp(join(tmpdir(), 'orca-search-corpus-')) + const files: string[] = [] + let transcriptBytes = 0 + let messageCount = 0 + + for (let session = 0; session < sessions; session++) { + const sessionId = `00000000-0000-4000-8000-${String(session).padStart(12, '0')}` + const lines: string[] = [] + for (let turn = 0; turn < turns; turn++) { + const at = new Date(1740000000000 + turn * 60_000).toISOString() + lines.push( + JSON.stringify({ + type: 'user', + sessionId, + timestamp: at, + cwd: `/repo/app-${session % 7}`, + gitBranch: 'main', + message: { role: 'user', content: words(random, 40) } + }) + ) + lines.push( + JSON.stringify({ + type: 'assistant', + sessionId, + timestamp: at, + message: { + role: 'assistant', + model: 'claude-fable-5', + content: [ + { type: 'text', text: words(random, 120) }, + { + type: 'tool_use', + name: 'Bash', + input: { command: `rg ${words(random, 3)}` } + } + ] + } + }) + ) + lines.push( + JSON.stringify({ + type: 'user', + sessionId, + timestamp: at, + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'toolu_1', + content: words(random, toolWords) + } + ] + } + }) + ) + // Empty tool results emit no searchable message. + messageCount += toolWords === 0 ? 3 : 4 + } + 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, messageCount } +} diff --git a/src/main/ai-vault-search/session-search-transcript-fixtures.ts b/src/main/ai-vault-search/session-search-transcript-fixtures.ts new file mode 100644 index 00000000000..bc7eda9a8ff --- /dev/null +++ b/src/main/ai-vault-search/session-search-transcript-fixtures.ts @@ -0,0 +1,118 @@ +import { stat } from 'node:fs/promises' +import { + createSessionParseStats, + parseAgentSessionFileCached, + type SessionParseStats +} from '../ai-vault/session-scanner-parse-cache' +import type { SessionFileCandidate } from '../ai-vault/session-scanner-types' + +// Transcript builders shared by the session-search store tests; each file owns +// its temp directories, this module only shapes records and drives the parser. + +export const CLAUDE_SESSION_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' +export const CODEX_SESSION_ID = '019f0000-1111-7222-8333-444444444444' +export const CODEX_ROLLOUT_FILE = `rollout-2026-05-01T10-00-00-${CODEX_SESSION_ID}.jsonl` + +const RECORD_EPOCH_MS = 1740000000000 + +export function recordTimestamp(index: number): string { + return new Date(RECORD_EPOCH_MS + index * 60_000).toISOString() +} + +export function userRecord( + index: number, + content: unknown, + sessionId = CLAUDE_SESSION_ID, + cwd = '/repo/app' +): string { + return JSON.stringify({ + type: 'user', + sessionId, + timestamp: recordTimestamp(index), + cwd, + gitBranch: 'main', + message: { role: 'user', content } + }) +} + +export function assistantRecord( + index: number, + content: unknown, + sessionId = CLAUDE_SESSION_ID +): string { + return JSON.stringify({ + type: 'assistant', + sessionId, + timestamp: recordTimestamp(index), + message: { role: 'assistant', model: 'claude-fable-5', content } + }) +} + +export async function sessionCandidate( + agent: SessionFileCandidate['agent'], + path: string, + codexHome: string | null = null +): Promise { + const fileStat = await stat(path) + return { + agent, + codexHome, + file: { + path, + mtimeMs: fileStat.mtimeMs, + modifiedAt: fileStat.mtime.toISOString(), + sizeBytes: fileStat.size, + dev: fileStat.dev, + ino: fileStat.ino + } + } +} + +export async function parseTranscript( + path: string, + agent: SessionFileCandidate['agent'] = 'claude', + codexHome: string | null = null +): Promise<{ stats: SessionParseStats }> { + const stats = createSessionParseStats() + await parseAgentSessionFileCached( + await sessionCandidate(agent, path, codexHome), + process.platform, + stats + ) + return { stats } +} + +function codexLine(record: Record): string { + return JSON.stringify(record) +} + +/** Minimal Codex rollout: meta, one user message, one completed shell command. */ +export function codexRolloutLines(command: string[], output: string, prompt: string): string[] { + return [ + codexLine({ + timestamp: recordTimestamp(0), + type: 'session_meta', + payload: { id: CODEX_SESSION_ID, cwd: '/repo/app', git: { branch: 'main' } } + }), + codexLine({ + timestamp: recordTimestamp(1), + type: 'response_item', + payload: { type: 'message', role: 'user', content: prompt } + }), + codexLine({ + timestamp: recordTimestamp(2), + type: 'response_item', + payload: { + type: 'function_call', + call_id: 'call-1', + name: 'shell', + arguments: JSON.stringify({ command }) + } + }), + codexLine({ + timestamp: recordTimestamp(3), + type: 'response_item', + payload: { type: 'function_call_output', call_id: 'call-1', output } + }) + ] +} diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts index 88e09627eb7..18f273d6be3 100644 --- a/src/main/ai-vault/session-scanner-accumulator.ts +++ b/src/main/ai-vault/session-scanner-accumulator.ts @@ -23,7 +23,11 @@ import { normalizePreviewText, timestampMs } from './session-scanner-values' -import { NO_TRANSCRIPT_MESSAGES, type TranscriptMessageSink } from './session-transcript-consumers' +import { + NO_TRANSCRIPT_MESSAGES, + type TranscriptMessageSink, + type TranscriptSessionIdentity +} from './session-transcript-consumers' import { boundedText, transcriptMessageRole, @@ -64,6 +68,28 @@ export function createAccumulator(args: { } } +/** + * The session identity a fold holds right now. Null until it has an id, which + * every supported format writes in the opening lines of the transcript. + */ +export function accumulatorSessionIdentity( + accumulator: SessionAccumulator +): TranscriptSessionIdentity | null { + const sessionId = accumulator.sessionId.trim() + if (!sessionId) { + return null + } + return { + sessionId, + cwd: accumulator.cwd, + // The generated fallback is `finalizeSession`'s, not this one's: a title + // that is still absent mid-read is better said to be absent. + title: accumulator.title ?? accumulator.fallbackTitle, + createdAt: accumulator.createdAt, + updatedAt: accumulator.updatedAt + } +} + export function cloneSessionAccumulator(accumulator: SessionAccumulator): SessionAccumulator { return { ...accumulator, previewMessages: [...accumulator.previewMessages] } } @@ -77,6 +103,7 @@ export function accumulatorFoldResumeState( ): ResumableSessionParseState { return { consumeLine: (line) => consumeRecordLine(accumulator, line), + identity: () => accumulatorSessionIdentity(accumulator), clone: () => accumulatorFoldResumeState(cloneSessionAccumulator(accumulator), consumeRecordLine), touchFile: (file) => { diff --git a/src/main/ai-vault/session-scanner-codex-message-records.ts b/src/main/ai-vault/session-scanner-codex-message-records.ts index 5aa739275ff..a8a5c3c9d13 100644 --- a/src/main/ai-vault/session-scanner-codex-message-records.ts +++ b/src/main/ai-vault/session-scanner-codex-message-records.ts @@ -1,3 +1,7 @@ +import { + publishCodexResponseTool, + publishCodexCompletedTool +} from './session-scanner-codex-tool-records' import { normalizePromptField } from '../../shared/agent-status-field-normalization' import { addPreviewContent } from './session-scanner-accumulator' import type { SessionAccumulator } from './session-scanner-types' @@ -8,6 +12,10 @@ export function consumeCodexResponseMessage( payload: Record, timestamp: unknown ): boolean { + publishCodexResponseTool(accumulator, payload, timestamp) + if (payload.type !== 'message') { + return false + } accumulator.messageCount++ const role = payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'unknown' @@ -24,6 +32,7 @@ export function consumeCodexCompletedMessage( payload: Record, timestamp: unknown ): boolean { + publishCodexCompletedTool(accumulator, payload, timestamp) const item = asRecord(payload.item) if (!item) { return false diff --git a/src/main/ai-vault/session-scanner-codex-parser.ts b/src/main/ai-vault/session-scanner-codex-parser.ts index 02a385400de..b3571d4a337 100644 --- a/src/main/ai-vault/session-scanner-codex-parser.ts +++ b/src/main/ai-vault/session-scanner-codex-parser.ts @@ -4,6 +4,7 @@ import type { AiVaultSession } from '../../shared/ai-vault-types' import { readCodexSessionIndexTitle } from './session-scanner-codex-title-index' import type { ExecutionHostId } from '../../shared/execution-host' import { + accumulatorSessionIdentity, cloneSessionAccumulator, createAccumulator, finalizeSession, @@ -153,19 +154,13 @@ function consumeCodexRecordLine(state: CodexSessionParseState, line: string): vo accumulator.title = metadataTitle state.titleSource = 'meta' } - const cwd = extractString(payload.cwd) - if (cwd) { - accumulator.cwd = cwd - } + accumulator.cwd = extractString(payload.cwd) ?? accumulator.cwd accumulator.branch = extractGitBranch(payload.git) ?? accumulator.branch return } if (record.type === 'turn_context' && payload) { - const cwd = extractString(payload.cwd) - if (cwd) { - accumulator.cwd = cwd - } + accumulator.cwd = extractString(payload.cwd) ?? accumulator.cwd const model = extractModel(payload) if (model) { accumulator.model = model @@ -177,7 +172,7 @@ function consumeCodexRecordLine(state: CodexSessionParseState, line: string): vo return } - if (record.type === 'response_item' && payload.type === 'message') { + if (record.type === 'response_item') { if (state.historyMode === 'paginated') { return } @@ -285,7 +280,10 @@ function codexResumeStateFromParseState( return { consumeLine: (line) => consumeCodexRecordLine(state, line), consumeLineBytes: (line) => { - const timelineOnlyRecord = readCodexTimelineOnlyRecord(line) + const timelineOnlyRecord = readCodexTimelineOnlyRecord( + line, + state.accumulator.messages.active && state.historyMode !== 'paginated' + ) if (timelineOnlyRecord) { updateTimeline(state.accumulator, timelineOnlyRecord.timestamp) } else { @@ -293,6 +291,7 @@ function codexResumeStateFromParseState( } }, shouldStop: () => state.rejectedWorkerSession, + identity: () => accumulatorSessionIdentity(state.accumulator), clone: () => codexResumeStateFromParseState(cloneCodexParseState(state), codexHome, titleReader), touchFile: (file) => { diff --git a/src/main/ai-vault/session-scanner-codex-record-fast-path.ts b/src/main/ai-vault/session-scanner-codex-record-fast-path.ts index 1c4322f89f6..852ec174e95 100644 --- a/src/main/ai-vault/session-scanner-codex-record-fast-path.ts +++ b/src/main/ai-vault/session-scanner-codex-record-fast-path.ts @@ -1,3 +1,5 @@ +import { CODEX_TOOL_RESPONSE_TYPES } from './session-scanner-codex-tool-records' + // Records below this size are decoded and parsed exactly: JSON.parse on a // kilobyte costs less than the risk of a prefix heuristic, and the scan cost // this path exists to remove is entirely in megabyte-scale records. @@ -22,7 +24,10 @@ const PARSED_EVENT_TYPES = new Set([ ]) /** Returns the timestamp only when the record cannot affect other visible session fields. */ -export function readCodexTimelineOnlyRecord(line: Buffer): { timestamp: string } | null { +export function readCodexTimelineOnlyRecord( + line: Buffer, + includeTools = false +): { timestamp: string } | null { if (line.length <= CODEX_RECORD_PREFIX_LIMIT) { return null } @@ -41,6 +46,13 @@ export function readCodexTimelineOnlyRecord(line: Buffer): { timestamp: string } if (!payloadType) { return null } + if ( + includeTools && + recordType === 'response_item' && + CODEX_TOOL_RESPONSE_TYPES.has(payloadType) + ) { + return null + } const parsedPayloadTypes = recordType === 'response_item' ? PARSED_RESPONSE_ITEM_TYPES : PARSED_EVENT_TYPES return parsedPayloadTypes.has(payloadType) ? null : { timestamp } diff --git a/src/main/ai-vault/session-scanner-codex-tool-records.test.ts b/src/main/ai-vault/session-scanner-codex-tool-records.test.ts new file mode 100644 index 00000000000..a5aa909bfa1 --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-tool-records.test.ts @@ -0,0 +1,125 @@ +import { expect, it } from 'vitest' +import { createCodexSessionResumeState } from './session-scanner-codex-parser' +import type { TranscriptMessage } from './session-transcript-consumers' +import { readCodexTimelineOnlyRecord } from './session-scanner-codex-record-fast-path' + +const timestamp = '2026-05-01T10:00:00.000Z' +const file = { + path: '/fixture/rollout.jsonl', + mtimeMs: Date.parse(timestamp), + modifiedAt: timestamp +} +const record = (type: string, payload: Record): Buffer => + Buffer.from(JSON.stringify({ timestamp, type, payload })) + +it.each(['function_call_output', 'custom_tool_call_output'])( + 'reads large %s records only when a consumer needs them', + (type) => { + const line = record('response_item', { type, output: 'outputonly '.repeat(300) }) + expect(readCodexTimelineOnlyRecord(line)).toEqual({ timestamp }) + expect(readCodexTimelineOnlyRecord(line, true)).toBeNull() + const messages: TranscriptMessage[] = [] + const state = createCodexSessionResumeState(file, null, { + active: true, + push: (message) => messages.push(message) + }) + state.consumeLineBytes!(line) + expect(messages).toEqual([{ role: 'tool', text: 'outputonly '.repeat(300), timestamp }]) + } +) + +it.each([false, true])( + 'uses one tool representation across append when paginated=%s', + async (paginated) => { + const messages: TranscriptMessage[] = [] + let state = createCodexSessionResumeState(file, null, { + active: true, + push: (message) => messages.push(message) + }) + const consume = (type: string, payload: Record) => + state.consumeLineBytes!(record(type, payload)) + consume('session_meta', { id: 'session-1', history_mode: paginated ? 'paginated' : 'full' }) + consume('response_item', { type: 'message', role: 'user', content: 'promptonly' }) + consume('event_msg', { + type: 'item_completed', + item: { type: 'UserMessage', content: [{ type: 'text', text: 'promptonly' }] } + }) + consume('response_item', { + type: 'function_call', + name: 'shell', + arguments: '{"command":"commandonly"}' + }) + // The next scan resumes between the call and its output. + state = state.clone() + consume('response_item', { type: 'function_call_output', output: 'outputonly' }) + consume('event_msg', { + type: 'item_completed', + item: { type: 'CommandExecution', command: ['commandonly'], aggregated_output: 'outputonly' } + }) + expect(messages.filter((message) => message.text.includes('commandonly'))).toHaveLength(1) + expect(messages.filter((message) => message.text === 'outputonly')).toEqual([ + { role: 'tool', text: 'outputonly', timestamp } + ]) + expect(messages.filter((message) => message.role === 'user')).toHaveLength(1) + expect(await state.finalize(process.platform)).toMatchObject({ messageCount: 1 }) + } +) + +it.each([ + { type: 'add', content: '+ addedneedle' }, + { type: 'delete', content: '+ addedneedle' }, + { type: 'update', unified_diff: '+ addedneedle', move_path: null } +])('publishes paginated $type file changes', (change) => { + const messages: TranscriptMessage[] = [] + const state = createCodexSessionResumeState(file, null, { + active: true, + push: (message) => messages.push(message) + }) + state.consumeLineBytes!(record('session_meta', { id: 'session-1', history_mode: 'paginated' })) + state.consumeLineBytes!( + record('event_msg', { + type: 'item_completed', + item: { type: 'FileChange', changes: { 'src/changed.ts': change } } + }) + ) + expect(messages.map((message) => [message.role, message.text])).toEqual([ + ['tool', 'apply_patch: src/changed.ts'], + ['tool', '+ addedneedle'] + ]) +}) + +it('normalizes custom calls and structured results through the existing content reader', () => { + const messages: TranscriptMessage[] = [] + const state = createCodexSessionResumeState(file, null, { + active: true, + push: (message) => messages.push(message) + }) + state.consumeLineBytes!( + record('response_item', { type: 'custom_tool_call', name: 'apply_patch', input: 'patchneedle' }) + ) + state.consumeLineBytes!( + record('response_item', { + type: 'custom_tool_call_output', + output: { content: [{ type: 'text', text: 'resultneedle' }] } + }) + ) + expect(messages.map((message) => message.text)).toEqual([ + 'apply_patch: patchneedle', + 'resultneedle' + ]) +}) + +it('keeps local shell argv searchable', () => { + const messages: TranscriptMessage[] = [] + const state = createCodexSessionResumeState(file, null, { + active: true, + push: (message) => messages.push(message) + }) + state.consumeLineBytes!( + record('response_item', { + type: 'local_shell_call', + action: { type: 'exec', command: ['rg', 'argvneedle'] } + }) + ) + expect(messages.map((message) => message.text)).toEqual(['tool: rg argvneedle']) +}) diff --git a/src/main/ai-vault/session-scanner-codex-tool-records.ts b/src/main/ai-vault/session-scanner-codex-tool-records.ts new file mode 100644 index 00000000000..976d5eff36d --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-tool-records.ts @@ -0,0 +1,95 @@ +import { timestampIso } from './session-scanner-accumulator' +import { asRecord } from './session-scanner-record-value' +import type { SessionAccumulator } from './session-scanner-types' +import { transcriptMessagesFromContent } from './session-transcript-message-content' + +export const CODEX_TOOL_RESPONSE_TYPES = new Set([ + 'function_call', + 'local_shell_call', + 'custom_tool_call', + 'function_call_output', + 'custom_tool_call_output' +]) + +function publishToolContent( + accumulator: SessionAccumulator, + content: unknown, + timestamp: unknown +): void { + for (const message of transcriptMessagesFromContent('tool', content, timestampIso(timestamp))) { + accumulator.messages.push(message) + } +} + +export function publishCodexResponseTool( + accumulator: SessionAccumulator, + payload: Record, + timestamp: unknown +): void { + if (!accumulator.messages.active || !CODEX_TOOL_RESPONSE_TYPES.has(String(payload.type))) { + return + } + if (payload.type === 'function_call_output' || payload.type === 'custom_tool_call_output') { + const output = asRecord(payload.output) + publishToolContent( + accumulator, + [{ type: 'tool_result', content: output?.content ?? output?.output ?? payload.output }], + timestamp + ) + return + } + const input = payload.arguments ?? payload.input ?? payload.action + const action = asRecord(input) + const normalizedInput = + action && Array.isArray(action.command) + ? { ...action, command: action.command.filter((part) => typeof part === 'string').join(' ') } + : input + publishToolContent( + accumulator, + [ + { + type: 'tool_use', + name: payload.name ?? 'tool', + input: normalizedInput + } + ], + timestamp + ) +} + +export function publishCodexCompletedTool( + accumulator: SessionAccumulator, + payload: Record, + timestamp: unknown +): void { + if (!accumulator.messages.active) { + return + } + const item = asRecord(payload.item) + if (item?.type === 'CommandExecution' || item?.type === 'command_execution') { + const command = Array.isArray(item.command) + ? item.command.filter((part) => typeof part === 'string').join(' ') + : item.command + publishToolContent( + accumulator, + [ + { type: 'tool_use', name: 'shell', input: command }, + { type: 'tool_result', content: item.aggregated_output ?? item.aggregatedOutput } + ], + timestamp + ) + } else if (item?.type === 'FileChange' || item?.type === 'file_change') { + const changes = asRecord(item.changes) ?? {} + for (const [path, value] of Object.entries(changes)) { + const change = asRecord(value) + publishToolContent( + accumulator, + [ + { type: 'tool_use', name: 'apply_patch', input: { path } }, + { type: 'tool_result', content: change?.unified_diff ?? change?.content } + ], + timestamp + ) + } + } +} diff --git a/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts b/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts index 57cadebeee0..07f3646b537 100644 --- a/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts +++ b/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts @@ -101,6 +101,7 @@ export function withOmpSubagentTranscriptCount( ): ResumableSessionParseState { return { consumeLine: (line) => state.consumeLine(line), + identity: () => state.identity?.() ?? null, clone: () => withOmpSubagentTranscriptCount(state.clone(), transcriptFilePath), touchFile: (file) => state.touchFile(file), finalize: async (platform, options) => { diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts index 9783f8a0520..62149920b5a 100644 --- a/src/main/ai-vault/session-scanner-primary-parsers.ts +++ b/src/main/ai-vault/session-scanner-primary-parsers.ts @@ -12,6 +12,7 @@ import type { } from './session-scanner-types' import type { TranscriptMessageSink } from './session-transcript-consumers' import { + accumulatorSessionIdentity, addPreviewContent, createAccumulator, finalizeSession, @@ -205,6 +206,7 @@ function claudeResumeStateFromParseState( ): ResumableSessionParseState { return { consumeLine: (line) => consumeClaudeSessionLine(state, line), + identity: () => accumulatorSessionIdentity(state.accumulator), clone: () => claudeResumeStateFromParseState(cloneClaudeSessionParseState(state)), touchFile: (file) => { state.accumulator.modifiedAt = file.modifiedAt diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index b1d480aa944..f4b60270544 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -5,7 +5,10 @@ import type { AiVaultSessionPreviewMessage } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' -import type { TranscriptMessageSink } from './session-transcript-consumers' +import type { + TranscriptMessageSink, + TranscriptSessionIdentity +} from './session-transcript-consumers' import type { SessionSidecarObservation } from './session-sidecar-stat' export type AiVaultScanOptions = { @@ -103,6 +106,9 @@ export type ResumableSessionParseState = { consumeLineBytes?(line: Buffer): void // Lets a parser terminate an excluded transcript without draining the file. shouldStop?(): boolean + // What the fold knows about the session right now, for a consumer that has to + // commit before the read ends (see TranscriptSessionIdentity). + identity?(): TranscriptSessionIdentity | null clone(): ResumableSessionParseState // Refresh per-scan file metadata (mtime display string) without re-parsing. touchFile(file: FileWithMtime): void diff --git a/src/main/ai-vault/session-transcript-consumers.ts b/src/main/ai-vault/session-transcript-consumers.ts index 6707b298586..7aff8dcf87b 100644 --- a/src/main/ai-vault/session-transcript-consumers.ts +++ b/src/main/ai-vault/session-transcript-consumers.ts @@ -27,12 +27,34 @@ export const NO_TRANSCRIPT_MESSAGES: TranscriptMessageSink = { push: () => undefined } +/** + * What a parser has decoded about the session so far, mid-read. + * + * Provisional by construction: it is read before the file ends, so a title can + * still change and a timestamp can still move. Every field the transcript + * formats put in their opening lines, which is what a consumer that has to + * commit before the read finishes needs to name what it is holding. + */ +export type TranscriptSessionIdentity = { + sessionId: string + cwd: string | null + title: string | null + createdAt: string | null + updatedAt: string | null +} + export type TranscriptReadStart = { candidate: SessionFileCandidate /** `replace`: the whole file is being re-read; `append`: a resumed read. */ mode: 'replace' | 'append' /** Byte offset the messages of this read continue from. */ previousByteOffset: number + /** + * The session identity decoded so far, or null before the parser has an id. + * Called during the read, never here: nothing is decoded yet when a read + * begins. Absent when the read has no resumable parse state to ask. + */ + identity?: () => TranscriptSessionIdentity | null } export type TranscriptReadOutcome = { diff --git a/src/main/ai-vault/session-transcript-reader.ts b/src/main/ai-vault/session-transcript-reader.ts index 228b0c832a3..3fc0ddcc146 100644 --- a/src/main/ai-vault/session-transcript-reader.ts +++ b/src/main/ai-vault/session-transcript-reader.ts @@ -3,7 +3,10 @@ import type { AiVaultSession } from '../../shared/ai-vault-types' import { parseAgentSessionFile, parserPublishesMessages } from './session-scanner-agent-parser' import { consumeCompleteJsonlLines } from './session-scanner-jsonl-reader' import type { ResumableSessionParseState, SessionFileCandidate } from './session-scanner-types' -import type { SessionParseResumePoint } from './session-parse-cache-store' +import { + invalidateSessionParseCacheEntry, + type SessionParseResumePoint +} from './session-parse-cache-store' import { TranscriptMessageChannel } from './session-transcript-channel' const NEWLINE_BYTE = 0x0a @@ -29,6 +32,24 @@ export type ResumableTranscriptRead = { resume: SessionParseResumePoint } +/** + * Ask for the next read of `path` to be a whole-file `replace`. + * + * Why this lives here: a consumer never chooses its own mode. The reader picks + * `append` or `replace` from the resume point the session list left behind, so a + * consumer that declined an append has no way to get the span it missed — with + * an empty index and a warm parse cache, every read arrives as `append`, every + * one is declined, and nothing is ever indexed. Dropping the resume point is the + * one lever that changes the next read's mode, and only the reader's own cache + * owns it. + * + * The cost is a re-parse for the session list too. That is the honest price of a + * second consumer being behind, and it is paid once per file rather than per scan. + */ +export function requestWholeTranscriptRead(path: string): void { + invalidateSessionParseCacheEntry(path) +} + /** * Read an append-only transcript, resuming from `resume` when the file only * grew and the recorded offset still sits on a line boundary. Anything else @@ -70,7 +91,10 @@ export async function readResumableTranscript(args: { channel.beginRead({ candidate: args.candidate, mode: canResume ? 'append' : 'replace', - previousByteOffset: startOffset + previousByteOffset: startOffset, + // Read by a consumer during the read, not here: the fold has decoded + // nothing yet at this point of a whole-file read. + identity: () => state.identity?.() ?? null }) try { const readResult = await consumeCompleteJsonlLines({ From ecd7b19ad42ab78d190b37ca22cd57ab783103fb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:50:40 -0700 Subject: [PATCH 03/17] fix(native-chat): pass agent-implemented slash commands through to the agent (#19929) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(native-chat): pass agent-implemented slash commands through to the agent Claim what the host implements; pass through what the agent implements. Claude's harness expands a slash command out of the message text, so the host claimed catalog commands it had no way to run and answered "/init is not available in chat sessions" for commands Claude does run. Codex's app-server has no slash parser at all, so its catalog stays claimed — except /goal, which the model carries out through its own goal tools. * fix(native-chat): offer the agent-run commands in the structured picker Codex reports no command catalog, so its structured `/` menu is the host fallback -- which listed only the host's own commands and hid `/goal`, the one command the model itself acts on. The picker now appends the profile's text-driven commands, described from the curated catalog, so a command that passes through is discoverable and not merely typable. The menu invariant holds either way: a pick is answered by the host or run by the agent, never refused with "not available in chat sessions". * fix mobile structured command reconciliation * fix(mobile): keep native chat controller within lint budget * fix mobile controller lint budget --------- Co-authored-by: Merge Sim --- .../src/session/MobileNativeChatComposer.tsx | 2 +- .../use-mobile-native-chat-controller.ts | 7 +- ...structured-native-chat-send-bridge.test.ts | 88 +++++++++++++ ...bile-structured-native-chat-send-bridge.ts | 15 +-- .../native-chat/NativeChatComposer.test.tsx | 14 +- .../use-native-chat-composer-catalog.test.tsx | 20 +++ .../use-native-chat-composer-catalog.ts | 2 +- ...ive-chat-structured-composer-send.test.tsx | 82 ++++++++++++ src/shared/native-chat-agent-profiles.test.ts | 31 ++++- src/shared/native-chat-agent-profiles.ts | 48 ++++++- .../structured-agent-session-composer.test.ts | 123 +++++++++++++++++- .../structured-agent-session-composer.ts | 32 ++++- 12 files changed, 426 insertions(+), 38 deletions(-) create mode 100644 mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx diff --git a/mobile/src/session/MobileNativeChatComposer.tsx b/mobile/src/session/MobileNativeChatComposer.tsx index c16b69ced89..20c43aa6c8b 100644 --- a/mobile/src/session/MobileNativeChatComposer.tsx +++ b/mobile/src/session/MobileNativeChatComposer.tsx @@ -130,7 +130,7 @@ export function MobileNativeChatComposer({ if (trigger.kind === 'slash') { const commands = structuredCommands !== undefined - ? structuredSlashCommands(structuredCommands) + ? structuredSlashCommands(structuredCommands, agent) : agent ? getVerifiedNativeChatCommands(agent) : [] diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index d3d68b85032..5b941367451 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -140,7 +140,7 @@ export function useMobileNativeChatController(args: { ) const { permission: legacyNativeChatPermission, - question: legacyNativeChatQuestion, + question: legacyQuestion, detectedAsk: nativeChatDetectedAsk, ask: nativeChatAskPrompt } = useMobileNativeChatPrompts({ @@ -242,6 +242,7 @@ export function useMobileNativeChatController(args: { }) const structuredNativeChatSend = useMobileStructuredNativeChatSendBridge({ + agent: activeChatResolution?.agent === 'claude' ? 'claude' : 'codex', sendStructured: structuredNativeChat.sendWithOutcome, captureSendOrigin, clearDraftForSend, @@ -309,9 +310,7 @@ export function useMobileNativeChatController(args: { nativeChatPermission: activeChatStructured ? structuredNativeChat.permission : legacyNativeChatPermission, - nativeChatQuestion: activeChatStructured - ? structuredNativeChat.question - : legacyNativeChatQuestion, + nativeChatQuestion: activeChatStructured ? structuredNativeChat.question : legacyQuestion, nativeChatAsk: !activeChatStructured && showNativeChatAsk ? nativeChatAskPrompt : null, nativeChatAskKey, dismissNativeChatAsk, diff --git a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts new file mode 100644 index 00000000000..3cf432381c2 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts @@ -0,0 +1,88 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionHandleProvider } from '../../../src/shared/agent-session-provider-handle' +import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import type { MobileNativeChatSendOrigin } from './use-mobile-native-chat-drafts' +import { useMobileStructuredNativeChatSendBridge } from './use-mobile-structured-native-chat-send-bridge' + +const ORIGIN: MobileNativeChatSendOrigin = { + draftKey: 'draft', + draftEditGeneration: 0, + pendingKey: 'pending', + normalizedText: 'command', + baselineOccurrences: 0, + baselineTailMessageId: null, + baselineResolved: true +} + +describe('useMobileStructuredNativeChatSendBridge', () => { + let renderer: ReactTestRenderer | null = null + let sendWithOutcome: (text: string) => Promise + const acceptSend = vi.fn() + const captureSendOrigin = vi.fn(() => ORIGIN) + const clearDraftForSend = vi.fn() + const holdUnconfirmedSend = vi.fn() + const onSendError = vi.fn() + const restoreRejectedDraft = vi.fn() + const sendStructured = vi.fn() + + function Harness({ agent }: { agent: AgentSessionHandleProvider }): null { + sendWithOutcome = useMobileStructuredNativeChatSendBridge({ + agent, + acceptSend, + captureSendOrigin, + clearDraftForSend, + holdUnconfirmedSend, + onSendError, + restoreRejectedDraft, + sendStructured + }).sendWithOutcome + return null + } + + function mount(agent: AgentSessionHandleProvider): void { + act(() => { + renderer = create(createElement(Harness, { agent })) + }) + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it('optimistically echoes accepted commands owned by the active agent', async () => { + sendStructured.mockResolvedValue('accepted') + mount('claude') + + await expect(sendWithOutcome('/init')).resolves.toBe('accepted') + + expect(acceptSend).toHaveBeenCalledWith(ORIGIN, '/init', undefined) + expect(restoreRejectedDraft).not.toHaveBeenCalled() + }) + + it('holds unknown delivery for commands owned by the active agent', async () => { + sendStructured.mockResolvedValue('unknown') + mount('claude') + + await expect(sendWithOutcome('/review')).resolves.toBe('unknown') + + expect(holdUnconfirmedSend).toHaveBeenCalledWith(ORIGIN, '/review', expect.any(Function)) + expect(restoreRejectedDraft).not.toHaveBeenCalled() + }) + + it('keeps host-command reconciliation for Codex', async () => { + sendStructured.mockResolvedValue('unknown') + mount('codex') + + await expect(sendWithOutcome('/review')).resolves.toBe('unknown') + + expect(restoreRejectedDraft).toHaveBeenCalledWith(ORIGIN, '/review') + expect(holdUnconfirmedSend).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts index 70261e9df9b..d1cfe3d42f4 100644 --- a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts +++ b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts @@ -1,4 +1,5 @@ import { useCallback } from 'react' +import type { AgentSessionHandleProvider } from '../../../src/shared/agent-session-provider-handle' import { isStructuredAgentSessionComposerCommand } from '../../../src/shared/structured-agent-session-composer' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import type { MobileNativeChatSendOrigin } from './use-mobile-native-chat-drafts' @@ -10,6 +11,7 @@ type StructuredNativeChatAttachment = { } export function useMobileStructuredNativeChatSendBridge(args: { + agent: AgentSessionHandleProvider sendStructured: ( text: string, images?: string[], @@ -37,6 +39,7 @@ export function useMobileStructuredNativeChatSendBridge(args: { } { const { acceptSend, + agent, captureSendOrigin, clearDraftForSend, holdUnconfirmedSend, @@ -56,6 +59,7 @@ export function useMobileStructuredNativeChatSendBridge(args: { onSendError('Message not sent (disconnected)') return 'rejected' } + const isHostCommand = isStructuredAgentSessionComposerCommand(text, agent) clearDraftForSend(origin, text) const outcome = attachments !== undefined @@ -66,19 +70,13 @@ export function useMobileStructuredNativeChatSendBridge(args: { ? await sendStructured(text, images) : await sendStructured(text) if (outcome === 'accepted') { - if ( - !isStructuredAgentSessionComposerCommand(text, 'codex') && - !isStructuredAgentSessionComposerCommand(text, 'claude') - ) { + if (!isHostCommand) { acceptSend(origin, text.trimEnd(), images) } return 'accepted' } if (outcome === 'unknown') { - if ( - isStructuredAgentSessionComposerCommand(text, 'codex') || - isStructuredAgentSessionComposerCommand(text, 'claude') - ) { + if (isHostCommand) { restoreRejectedDraft(origin, text) return 'unknown' } @@ -92,6 +90,7 @@ export function useMobileStructuredNativeChatSendBridge(args: { }, [ acceptSend, + agent, captureSendOrigin, clearDraftForSend, holdUnconfirmedSend, diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index 45d95140f9e..055230c1a4e 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -306,12 +306,14 @@ describe('NativeChatComposer', () => { expect(mocks.setDraft).toHaveBeenCalledWith('') }) - // The structured menu offers only what the dispatcher can carry out. Listing the - // agent's TUI catalog here answered every pick with "not available in chat sessions". + // The structured menu offers only what a pick can carry out: the host's own + // commands, plus the ones the agent itself runs from message text (Codex `/goal`). + // Listing the agent's whole TUI catalog here answered every pick with + // "not available in chat sessions". it.each([ - ['claude', 'compact'], - ['codex', 'vim'] - ] as const)('offers %s only actionable structured slash commands', (agent, withheld) => { + ['claude', 'compact', ['model', 'effort']], + ['codex', 'vim', ['model', 'effort', 'goal']] + ] as const)('offers %s only actionable structured slash commands', (agent, withheld, offered) => { mocks.draft = '/' render( { const names = (mocks.fieldProps?.autocomplete?.items ?? []) .filter((item) => item.kind === 'command') .map((item) => item.name) - expect(names).toEqual(['model', 'effort']) + expect(names).toEqual([...offered]) expect(names).not.toContain(withheld) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx index 9e7b3abc5b2..427a56c739f 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx @@ -7,6 +7,7 @@ import { EMPTY_HISTORY } from './native-chat-composer-state' import { useNativeChatComposerCatalog } from './use-native-chat-composer-catalog' import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types' import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles' +import { sessionSlashCommandSuggestions } from '../../../../shared/native-chat-slash-commands' import { structuredSlashCommands } from '../../../../shared/structured-agent-session-composer' function transport(sessionCommands?: NativeChatStructuredComposerTransport['sessionCommands']) { @@ -47,6 +48,25 @@ describe('composer catalog authority', () => { 'clear' ]) }) + // Codex reports no catalog, so the hook's fallback is its entire `/` menu; the + // agent has to reach structuredSlashCommands or `/goal` is invisible there. + it('offers Codex the commands its model runs from message text', () => { + const { result } = renderHook(() => useNativeChatComposerCatalog('codex', transport())) + expect(result.current.agentCommands).toEqual(structuredSlashCommands([], 'codex')) + expect(result.current.agentCommands.map(({ name }) => name)).toContain('goal') + }) + it('leaves the Claude route on its own commands, reported or not', () => { + const reported = [{ name: 'init', kind: 'command' as const }] + const withReport = renderHook(() => useNativeChatComposerCatalog('claude', transport(reported))) + expect(withReport.result.current.agentCommands).toEqual( + sessionSlashCommandSuggestions('claude', reported) + ) + const withoutReport = renderHook(() => useNativeChatComposerCatalog('claude', transport())) + expect(withoutReport.result.current.agentCommands.map(({ name }) => name)).toEqual([ + 'model', + 'effort' + ]) + }) it('respects empty catalogs and command-only catalogs without reviving disk skills', () => { const { result, rerender } = renderHook( ({ reported }) => useNativeChatComposerCatalog('claude', transport(reported)), diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts index 273e9fbfa60..d2e0b93ac40 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts @@ -34,7 +34,7 @@ export function useNativeChatComposerCatalog( ? getVerifiedNativeChatCommands(agent) : reported !== undefined ? sessionSlashCommandSuggestions(agent, reported) - : structuredSlashCommands(conversationCommands), + : structuredSlashCommands(conversationCommands, agent), [agent, conversationCommands, reported, structured] ) const sessionSkillNames = useMemo( diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx new file mode 100644 index 00000000000..82cd0f0cb03 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx @@ -0,0 +1,82 @@ +// @vitest-environment happy-dom +import { renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { dispatchStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer' +import type { AgentType } from '../../../../shared/agent-status-types' +import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types' +import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' +import { useNativeChatStructuredComposerSend } from './use-native-chat-structured-composer-send' + +vi.mock('@/lib/native-chat-telemetry', () => ({ emitNativeChatMessageSent: vi.fn() })) +vi.mock('@/lib/worker-terminal-takeover-report', () => ({ + reportStructuredSessionUserInput: vi.fn() +})) + +const ATTACHMENT = { id: 'a1', path: '/tmp/shot.png' } as NativeChatComposerImageAttachment + +function harness(agent: AgentType) { + const structuredTransport = { + send: vi.fn(() => true), + dispatchCommand: (text: string) => + dispatchStructuredAgentSessionComposerCommand(text, { + agent, + snapshot: [], + invokeAction: async () => true, + setOption: async () => true, + conversationCommands: ['clear', 'compact'], + runConversationCommand: async () => ({ accepted: true, error: null }) + }), + optionSnapshot: [], + onError: vi.fn(), + runtime: 'local', + sessionId: 'session-test', + runtimeEnvironmentId: null + } as unknown as NativeChatStructuredComposerTransport + const { result } = renderHook(() => + useNativeChatStructuredComposerSend({ + agent, + draft: '', + imageAttachments: [ATTACHMENT], + structuredTransport, + clearImageAttachments: vi.fn(), + clearSkillOrigin: vi.fn(), + setHistory: vi.fn(), + setDraft: vi.fn(), + setCaret: vi.fn() + }) + ) + return { send: result.current, structuredTransport } +} + +// The guard exists because a host command sends no message, so its attachments +// would be dropped without a word. A pass-through command IS the message, so the +// attachments ride along with it. +describe('attachment guard follows what the host claims', () => { + it.each([ + ['claude', '/clear'], + ['claude', '/model'], + ['codex', '/permissions'] + ] as const)('refuses attachments on the host-claimed %s command %s', (agent, text) => { + const { send, structuredTransport } = harness(agent) + send(text) + expect(structuredTransport.onError).toHaveBeenCalledWith( + 'Remove attachments before using a chat-session command.' + ) + expect(structuredTransport.send).not.toHaveBeenCalled() + }) + + it.each([ + ['claude', '/init'], + ['claude', '/review'], + ['codex', '/goal ship the fix'] + ] as const)('sends %s attachments along with the passed-through %s', async (agent, text) => { + const { send, structuredTransport } = harness(agent) + send(text) + await vi.waitFor(() => + expect(structuredTransport.send).toHaveBeenCalledWith(text, [ATTACHMENT]) + ) + expect(structuredTransport.onError).not.toHaveBeenCalledWith( + 'Remove attachments before using a chat-session command.' + ) + }) +}) diff --git a/src/shared/native-chat-agent-profiles.test.ts b/src/shared/native-chat-agent-profiles.test.ts index 4d64e283c01..546a4bf9db0 100644 --- a/src/shared/native-chat-agent-profiles.test.ts +++ b/src/shared/native-chat-agent-profiles.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { getNativeChatAgentProfile } from './native-chat-agent-profiles' +import { + getHostClaimedNativeChatCommands, + getNativeChatAgentProfile, + getVerifiedNativeChatCommands +} from './native-chat-agent-profiles' describe('native chat agent picker profiles', () => { // The composer types the same `/` for every agent; skillPrefix is only the @@ -27,3 +31,28 @@ describe('native chat agent picker profiles', () => { expect(getNativeChatAgentProfile('custom-agent')).toBeNull() }) }) + +describe('host-claimed native chat commands', () => { + function names(agent: string): string[] { + return getHostClaimedNativeChatCommands(agent).map((command) => command.name) + } + + // Claude's harness expands a slash command out of the message body, so claiming + // its catalog only answered "/init is not available" for commands that do run. + it('claims nothing from the Claude-family catalog', () => { + expect(names('claude')).toEqual([]) + expect(names('openclaude')).toEqual([]) + }) + + it('keeps the Codex catalog claimed except the model-driven /goal', () => { + expect(names('codex')).toContain('permissions') + expect(names('codex')).toContain('vim') + expect(names('codex')).not.toContain('goal') + expect(getVerifiedNativeChatCommands('codex').map((command) => command.name)).toContain('goal') + }) + + it('claims the whole catalog for agents with no pass-through policy', () => { + expect(names('custom-agent')).toEqual(['clear', 'help']) + expect(names('grok')).toEqual([]) + }) +}) diff --git a/src/shared/native-chat-agent-profiles.ts b/src/shared/native-chat-agent-profiles.ts index 6f86313d17d..82fe88e9ac3 100644 --- a/src/shared/native-chat-agent-profiles.ts +++ b/src/shared/native-chat-agent-profiles.ts @@ -5,20 +5,31 @@ export type NativeChatAgentProfile = { skillPrefix: '$' | '/' /** OpenClaude reads Claude-owned roots, so this can differ from the agent. */ skillSourceOwner: AgentType + /** The agent's own harness expands a slash command out of the message text, so + * the chat host claims only the commands it implements itself. */ + expandsSlashCommandsFromText?: true + /** Catalog commands the model acts on when they arrive as prose, even though + * the runtime has no slash parser of its own. */ + textDrivenCommands?: readonly string[] } const NATIVE_CHAT_AGENT_PROFILES: Partial> = { codex: { skillPrefix: '$', - skillSourceOwner: 'codex' + skillSourceOwner: 'codex', + // The app-server has no slash parser, but the model owns goal tools and + // calls create_goal itself when `/goal ` reaches it as prose. + textDrivenCommands: ['goal'] }, claude: { skillPrefix: '/', - skillSourceOwner: 'claude' + skillSourceOwner: 'claude', + expandsSlashCommandsFromText: true }, openclaude: { skillPrefix: '/', - skillSourceOwner: 'claude' + skillSourceOwner: 'claude', + expandsSlashCommandsFromText: true }, grok: { skillPrefix: '/', @@ -38,3 +49,34 @@ export function getNativeChatAgentProfile( export function getVerifiedNativeChatCommands(agent: AgentType): readonly SlashCommandSuggestion[] { return agent === 'grok' ? [] : getAgentSlashCommands(agent) } + +/** The mirror of the claimed set: catalog commands this agent acts on when they + * arrive as message text. The picker offers these too, so a command the agent + * implements is discoverable and not merely typable. */ +export function getTextDrivenNativeChatCommands( + agent: AgentType | null | undefined +): readonly SlashCommandSuggestion[] { + if (!agent) { + return [] + } + const names = new Set(getNativeChatAgentProfile(agent)?.textDrivenCommands ?? []) + return names.size === 0 + ? [] + : getVerifiedNativeChatCommands(agent).filter((command) => names.has(command.name)) +} + +/** Catalog commands the chat host answers itself. Whatever is left over reaches + * the agent as ordinary text, which is only correct where the agent implements + * the command — so an agent unclaims a command only via the profile above. + * Claiming stays the default: it is what stops a hand-typed `/clear` from being + * sent to the model as literal prompt text. */ +export function getHostClaimedNativeChatCommands( + agent: AgentType +): readonly SlashCommandSuggestion[] { + const profile = getNativeChatAgentProfile(agent) + if (profile?.expandsSlashCommandsFromText) { + return [] + } + const passedThrough = new Set(profile?.textDrivenCommands ?? []) + return getVerifiedNativeChatCommands(agent).filter((command) => !passedThrough.has(command.name)) +} diff --git a/src/shared/structured-agent-session-composer.test.ts b/src/shared/structured-agent-session-composer.test.ts index ec3b301a8e2..b51de0396be 100644 --- a/src/shared/structured-agent-session-composer.test.ts +++ b/src/shared/structured-agent-session-composer.test.ts @@ -6,15 +6,59 @@ import { } from './structured-agent-session-composer' describe('structuredSlashCommands', () => { - // The composer menu and the dispatcher read this one list. When they disagreed, + const hostController = { + snapshot: [], + invokeAction: async () => true, + setOption: async () => true, + conversationCommands: ['clear', 'compact'] as const, + runConversationCommand: async () => ({ accepted: true, error: null }) + } + const REFUSAL = /is not available in chat sessions/ + + // The composer menu and the dispatcher read the same policy. When they disagreed, // a Claude session was offered Codex-only tokens that missed the command guard - // and reached the model as literal prompt text instead of erroring. - it.each(['codex', 'claude'] as const)('offers %s only commands it also accepts', (agent) => { - const offered = structuredSlashCommands() - expect(offered.length).toBeGreaterThan(0) - for (const command of offered) { - expect(isStructuredAgentSessionComposerCommand(`/${command.name}`, agent)).toBe(true) + // and reached the model as literal prompt text instead of erroring. A row is + // honored either way now: the host answers it, or it passes through to the agent. + it.each(['codex', 'claude'] as const)( + 'offers %s only commands the host answers or the agent runs', + async (agent) => { + const offered = structuredSlashCommands(['clear', 'compact'], agent) + expect(offered.length).toBeGreaterThan(0) + for (const command of offered) { + const outcome = await dispatchStructuredAgentSessionComposerCommand(`/${command.name}`, { + ...hostController, + agent + }) + expect(outcome.error ?? '').not.toMatch(REFUSAL) + } } + ) + + // Codex reports no catalog of its own, so this fallback is its whole `/` menu — + // without the row, a command that now works is impossible to discover. + it('offers Codex the /goal the model acts on, described from the catalog', () => { + const offered = structuredSlashCommands(['clear', 'compact'], 'codex') + expect(offered.map((command) => command.name)).toEqual([ + 'model', + 'effort', + 'clear', + 'compact', + 'goal' + ]) + expect(offered.find((command) => command.name === 'goal')?.description).toBe( + 'Set or view the goal' + ) + // Picking it must reach the model, not the host's refusal. + expect(isStructuredAgentSessionComposerCommand('/goal', 'codex')).toBe(false) + }) + + it('adds nothing for an agent whose own harness expands its commands', () => { + expect(structuredSlashCommands(['clear', 'compact'], 'claude').map((c) => c.name)).toEqual([ + 'model', + 'effort', + 'clear', + 'compact' + ]) }) it('offers only the commands a chat session can carry out', () => { @@ -98,3 +142,68 @@ describe('dispatchStructuredAgentSessionComposerCommand', () => { expect(runConversationCommand).not.toHaveBeenCalled() }) }) + +describe('agent-implemented commands pass through to the agent', () => { + const controller = { + snapshot: [], + invokeAction: async () => true, + setOption: async () => true + } + const PASSED_THROUGH = { handled: false, accepted: false, error: null } + + // Claude's harness runs a slash command it finds in the message text, so + // claiming these answered "not available" for commands that do work. + it.each(['init', 'review', 'help'] as const)( + 'sends /%s on to the Claude harness instead of refusing it', + async (name) => { + expect(isStructuredAgentSessionComposerCommand(`/${name}`, 'claude')).toBe(false) + expect( + await dispatchStructuredAgentSessionComposerCommand(`/${name}`, { + ...controller, + agent: 'claude' + }) + ).toEqual(PASSED_THROUGH) + } + ) + + it.each(['clear', 'compact', 'model', 'effort'] as const)( + 'still claims the host-owned /%s on Claude', + async (name) => { + expect(isStructuredAgentSessionComposerCommand(`/${name}`, 'claude')).toBe(true) + expect( + ( + await dispatchStructuredAgentSessionComposerCommand(`/${name}`, { + ...controller, + agent: 'claude' + }) + ).handled + ).toBe(true) + } + ) + + // Codex's app-server has no slash parser, but the model owns goal tools and + // creates a real goal from `/goal ` arriving as prose. + it('passes /goal through on Codex, arguments and all', async () => { + expect(isStructuredAgentSessionComposerCommand('/goal', 'codex')).toBe(false) + expect( + await dispatchStructuredAgentSessionComposerCommand('/goal ship the fix', { + ...controller, + agent: 'codex' + }) + ).toEqual(PASSED_THROUGH) + }) + + it('keeps refusing a Codex command the model cannot carry out', async () => { + expect(isStructuredAgentSessionComposerCommand('/permissions', 'codex')).toBe(true) + expect( + await dispatchStructuredAgentSessionComposerCommand('/permissions', { + ...controller, + agent: 'codex' + }) + ).toMatchObject({ + handled: true, + error: + '/permissions is not available in chat sessions. Use the slash menu to see available commands.' + }) + }) +}) diff --git a/src/shared/structured-agent-session-composer.ts b/src/shared/structured-agent-session-composer.ts index 70d10c34cfa..093d588effb 100644 --- a/src/shared/structured-agent-session-composer.ts +++ b/src/shared/structured-agent-session-composer.ts @@ -1,4 +1,7 @@ -import { getVerifiedNativeChatCommands } from './native-chat-agent-profiles' +import { + getHostClaimedNativeChatCommands, + getTextDrivenNativeChatCommands +} from './native-chat-agent-profiles' import type { AgentType } from './agent-status-types' import type { SessionOptionDescriptor, SessionOptionValue } from './native-chat-session-options' import type { SlashCommandSuggestion } from './native-chat-slash-commands' @@ -50,30 +53,45 @@ function commandParts(text: string): { name: string; argument: string } | null { return match ? { name: match[1]!.toLowerCase(), argument: match[2]?.trim() ?? '' } : null } -/** The commands the composer menu offers. Strictly what the dispatcher honors, - * so a menu pick is never answered with "not available". */ +/** The commands the composer menu offers when the host reports no catalog of its + * own: the host's own commands, plus the ones this agent acts on from message + * text. Both are honored — the first here, the second by the agent — so a menu + * pick is never answered with "not available". */ export function structuredSlashCommands( - commands: readonly AgentSessionConversationCommand[] = [] + commands: readonly AgentSessionConversationCommand[] = [], + agent?: AgentType | null ): readonly SlashCommandSuggestion[] { - return [ + const hostOwned = [ ...STRUCTURED_AGENT_SESSION_SLASH_COMMANDS, ...CONVERSATION_COMMANDS.filter((entry) => commands.includes(entry.name as AgentSessionConversationCommand) ) ] + // Why: a host with no catalog to report would otherwise hide the commands the + // agent itself implements, e.g. Codex's `/goal`. + return [ + ...hostOwned, + ...getTextDrivenNativeChatCommands(agent).filter( + (entry) => !hostOwned.some((offered) => offered.name === entry.name) + ) + ] } /** Wider than the offered menu on purpose: a TUI-only command still has to be * claimed here and answered, or a hand-typed `/clear` reaches the model as - * literal prompt text. */ + * literal prompt text. Commands the agent itself implements are deliberately + * absent — the profile unclaims those so they pass through as text. */ function structuredRecognizedCommands(agent: AgentType): readonly SlashCommandSuggestion[] { return [ ...STRUCTURED_AGENT_SESSION_SLASH_COMMANDS, ...CONVERSATION_COMMANDS, - ...getVerifiedNativeChatCommands(agent) + ...getHostClaimedNativeChatCommands(agent) ] } +/** Whether the chat host, rather than the agent, owns this command. Callers also + * use it to refuse attachments: a host command sends no message, so attachments + * would be silently dropped, whereas a pass-through command is a real send. */ export function isStructuredAgentSessionComposerCommand( text: string, agent: AgentType = 'codex' From cdf41df37c6c16acae89b06795f0ee5267182632 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:09:04 -0700 Subject: [PATCH 04/17] perf(terminal): stop rebuilding per-workspace collections on a worktree switch (#19975) Two allocations scale with the workspace count and are rebuilt on inputs that cannot change their result. `workspaceSurfaces` took `renderedActiveWorktreeId` as a memo dep, but `projectWorkspaceSurfaces` reads that id only behind a truthy `activeWorkspaceResolvedHostId` (the folder-collision tie-break), which is null unless the active workspace is itself a folder workspace. Every git-worktree switch therefore re-projected every surface and re-derived the id array to reach an identical answer. Gate the id on the host so the memo holds. The parked-watcher sync built a fresh empty `Set` for every workspace surface, even though only a mounted workspace can park a tab and the sync only reads the set. Share one empty instance for the rest. Both keep every effect firing on exactly the inputs it fired on before. --- ...minal-parked-watcher-sync-entries.test.tsx | 110 ++++++++++++++++++ .../terminal-workspace-surface-ids.test.tsx | 79 +++++++++++++ .../use-terminal-watcher-effects.ts | 14 ++- .../use-terminal-workspace-foundation.ts | 8 +- .../workspace-surface-projection.test.ts | 18 +++ .../workspace-surface-projection.ts | 1 + 6 files changed, 224 insertions(+), 6 deletions(-) create mode 100644 src/renderer/src/components/terminal-parked-watcher-sync-entries.test.tsx diff --git a/src/renderer/src/components/terminal-parked-watcher-sync-entries.test.tsx b/src/renderer/src/components/terminal-parked-watcher-sync-entries.test.tsx new file mode 100644 index 00000000000..e14faedbc18 --- /dev/null +++ b/src/renderer/src/components/terminal-parked-watcher-sync-entries.test.tsx @@ -0,0 +1,110 @@ +// @vitest-environment happy-dom +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useTerminalWatcherEffects } from './use-terminal-watcher-effects' +import type { ParkedTerminalTabWatcherSyncEntry } from './terminal-pane/terminal-parked-tab-watchers' +import type { TerminalColdActivationController } from './terminal-cold-activation' + +const mocks = vi.hoisted(() => ({ + sync: vi.fn(), + prune: vi.fn() +})) +vi.mock('@/store', () => ({ + useAppStore: Object.assign(() => 'unverifiable', { + getState: () => ({ activeWorktreeId: null }) + }) +})) +vi.mock('@/lib/workspace-terminal-host-authority', () => ({ + createWorkspaceTerminalHostAuthoritySelector: () => () => 'unverifiable' +})) +vi.mock('./terminal-pane/terminal-parked-tab-watchers', () => ({ + canWatcherCoverParkedTerminalTab: () => true, + disposeAllParkedTerminalWatchers: vi.fn(), + pruneParkedTerminalWatchers: mocks.prune, + syncParkedTerminalTabWatchersForWorkspaces: mocks.sync, + terminalWatcherLiveWorkspaceIds: (ids: Iterable) => new Set(ids) +})) +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const SURFACE_COUNT = 423 +const PARKED_WORKTREE_ID = 'repo-1::/worktree-0' +const surfaceIds = Array.from({ length: SURFACE_COUNT }, (_, index) => `repo-1::/worktree-${index}`) + +let root: Root | undefined +afterEach(async () => { + await act(async () => root?.unmount()) + vi.clearAllMocks() +}) + +function renderWatcherEffects(): Promise { + function Watcher(): null { + useTerminalWatcherEffects({ + activationDeferredMountTabIdsByWorktreeRef: { current: new Map() }, + activeTabId: null, + activeTabIdByWorktree: {}, + activeView: 'terminal', + activeWorktreeId: null, + activityTerminalPortals: [], + anyMountedWorktreeHasLayout: false, + backgroundMountRevision: 0, + effectiveParkedTerminalWorktreeIds: new Set([PARKED_WORKTREE_ID]), + evictionExemptTerminalTabIds: new Set(['tab-exempt']), + getEffectiveLayoutForWorktree: () => null, + groupsByWorktree: {}, + hydrationSucceeded: false, + measurableBackgroundWorktreeIdsRef: { current: new Set() }, + mountedWorktreeIdsRef: { current: new Set([PARKED_WORKTREE_ID]) }, + pendingStartupByTabId: {}, + // Another workspace is on screen, so the mounted one is hidden and parks. + renderedActiveWorktreeId: 'repo-1::/worktree-9', + tabsByWorktree: { + [PARKED_WORKTREE_ID]: [{ id: 'tab-parked' }, { id: 'tab-exempt' }] + }, + terminalParkingEnabled: true, + terminalStartupRestorationReady: false, + terminalTitleSnapshotAuthorityEnabled: true, + workspaceSessionReady: false, + workspaceSurfaceIds: surfaceIds + } as unknown as TerminalColdActivationController) + return null + } + root = createRoot(document.createElement('div')) + return act(async () => root?.render()) +} + +function lastSyncEntries(): Map { + return mocks.sync.mock.calls.at(-1)?.[0] as Map +} + +describe('parked terminal watcher sync entries', () => { + it('publishes an entry for every surface so closed-tab disposal still sees it', async () => { + await renderWatcherEffects() + + const entries = lastSyncEntries() + expect(entries.size).toBe(SURFACE_COUNT) + expect([...entries.keys()]).toEqual(surfaceIds) + expect(mocks.prune).toHaveBeenCalledWith(new Set(surfaceIds)) + }) + + it('parks the hidden mounted workspace tabs and exempts the eviction-exempt tab', async () => { + await renderWatcherEffects() + + const parkedEntry = lastSyncEntries().get(PARKED_WORKTREE_ID) + expect([...(parkedEntry?.parkedTabIds ?? [])]).toEqual(['tab-parked']) + }) + + it('does not allocate a parked-tab-id set per unmounted surface', async () => { + await renderWatcherEffects() + + const entries = lastSyncEntries() + const unmountedSets = new Set( + [...entries] + .filter(([workspaceId]) => workspaceId !== PARKED_WORKTREE_ID) + .map(([, entry]) => entry.parkedTabIds) + ) + // Pre-fix this was one empty Set per surface (422 of them) on every fire. + expect(unmountedSets.size).toBe(1) + expect([...unmountedSets][0]?.size).toBe(0) + }) +}) diff --git a/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx b/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx index 770b1cfc47b..59984d7fe06 100644 --- a/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx +++ b/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx @@ -8,6 +8,7 @@ import { makeRepo, makeWorktree } from './worktree-jump-palette-test-fixtures' import { useTerminalWorkspaceFoundation } from './use-terminal-workspace-foundation' import { applyTerminalColdActivation } from './terminal-cold-activation' import { collectTerminalParkingPassCandidates } from './terminal-parking-pass-candidates' +import type { FolderWorkspace } from '../../../shared/folder-workspace-types' import type { WorkspaceSurface } from './workspace-surface-projection' import type { TerminalParkingFoundation } from './use-terminal-parking-foundation' @@ -147,6 +148,84 @@ describe('workspace surface ids', () => { ]) expect(hiddenSince.has('repo::/stale')).toBe(false) }) + it('keeps the surface projection stable across a git-worktree switch', () => { + const repo = makeRepo() + const worktrees = Array.from({ length: 423 }, (_, index) => + makeWorktree(`repo-1::/worktree-${index}`, `Workspace ${index}`) + ) + useAppStore.setState({ + worktreesByRepo: { [repo.id]: worktrees }, + activeWorktreeId: worktrees[0].id + }) + + const { result } = renderHook(() => useTerminalWorkspaceFoundation()) + const surfaces = result.current.workspaceSurfaces + const ids = result.current.workspaceSurfaceIds + const idSet = result.current.workspaceSurfaceIdSet + expect(surfaces).toHaveLength(423) + + // Pre-fix every switch re-ran the projection (423 surface objects) and re-derived + // the id array, because the active id was a dep even with no folder host to tie-break. + for (let index = 1; index < 10; index += 1) { + act(() => { + useAppStore.setState({ activeWorktreeId: worktrees[index].id }) + }) + expect(result.current.renderedActiveWorktreeId).toBe(worktrees[index].id) + expect(result.current.workspaceSurfaces).toBe(surfaces) + expect(result.current.workspaceSurfaceIds).toBe(ids) + expect(result.current.workspaceSurfaceIdSet).toBe(idSet) + } + }) + + it('still re-projects when the active folder workspace owns the collision tie-break', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const localFolder: FolderWorkspace = { + id: 'folder-shared', + projectGroupId: 'group-shared', + name: 'orca', + folderPath: '/work/orca-local', + connectionId: null, + executionHostId: 'local', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 + } + const runtimeFolder: FolderWorkspace = { + ...localFolder, + folderPath: '/remote/orca', + executionHostId: 'runtime:env-1' + } + useAppStore.setState({ + folderWorkspaces: [localFolder, runtimeFolder], + activeWorktreeId: 'folder:folder-shared', + activeWorkspaceExecutionHostId: 'runtime:env-1' + }) + + const { result } = renderHook(() => useTerminalWorkspaceFoundation()) + expect(result.current.workspaceSurfaces).toEqual([ + { id: 'folder:folder-shared', path: '/remote/orca' } + ]) + + // Leaving the folder workspace drops the tie-break, so the projection must re-run + // and fall back to first-wins rather than mount the previous host's path. + act(() => { + useAppStore.setState({ + activeWorktreeId: 'repo-1::/worktree-0', + activeWorkspaceExecutionHostId: null + }) + }) + expect(result.current.workspaceSurfaces).toEqual([ + { id: 'folder:folder-shared', path: '/work/orca-local' } + ]) + warn.mockRestore() + }) + it('stops idle worktree writes from re-firing surface-keyed terminal effects', () => { const repo = makeRepo() const worktrees = Array.from({ length: 20 }, (_, index) => diff --git a/src/renderer/src/components/use-terminal-watcher-effects.ts b/src/renderer/src/components/use-terminal-watcher-effects.ts index 3c6a89fb323..fc44352c942 100644 --- a/src/renderer/src/components/use-terminal-watcher-effects.ts +++ b/src/renderer/src/components/use-terminal-watcher-effects.ts @@ -17,6 +17,10 @@ import { getStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-l import { AGENT_SESSION_PROVIDER_HANDLE_PROVIDERS } from '../../../shared/agent-session-provider-handle' import type { TerminalColdActivationController } from './terminal-cold-activation' +// Why shared: only a mounted workspace can park a tab, and the watcher sync only reads +// this set, so every other surface would otherwise allocate its own empty one per fire. +const NO_PARKED_TAB_IDS: ReadonlySet = new Set() + export function useTerminalWatcherEffects(controller: TerminalColdActivationController): void { const { activationDeferredMountTabIdsByWorktreeRef, @@ -58,9 +62,11 @@ export function useTerminalWatcherEffects(controller: TerminalColdActivationCont continue } const tabs = tabsByWorktree[workspaceId] ?? [] - const parkedTabIds = new Set() + let parkedTabIds: ReadonlySet = NO_PARKED_TAB_IDS let deferredTabIds: ReadonlySet | null = null if (!anyMountedWorktreeHasLayout && mountedWorktreeIdsRef.current.has(workspaceId)) { + const mountedParkedTabIds = new Set() + parkedTabIds = mountedParkedTabIds const isVisible = activeView === 'terminal' && workspaceId === renderedActiveWorktreeId const shouldMeasureHiddenWorktree = !isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspaceId) @@ -75,7 +81,7 @@ export function useTerminalWatcherEffects(controller: TerminalColdActivationCont tabId: tab.id }) if (!activityTerminalPortal && !evictionExemptTerminalTabIds.has(tab.id)) { - parkedTabIds.add(tab.id) + mountedParkedTabIds.add(tab.id) } } } @@ -83,14 +89,14 @@ export function useTerminalWatcherEffects(controller: TerminalColdActivationCont for (const tab of tabs) { if ( deferredTabIds?.has(tab.id) && - !parkedTabIds.has(tab.id) && + !mountedParkedTabIds.has(tab.id) && canWatcherCoverParkedTerminalTab(workspaceId, tab) && !findActivityTerminalPortal(activityTerminalPortals, { worktreeId: workspaceId, tabId: tab.id }) ) { - parkedTabIds.add(tab.id) + mountedParkedTabIds.add(tab.id) } } } diff --git a/src/renderer/src/components/use-terminal-workspace-foundation.ts b/src/renderer/src/components/use-terminal-workspace-foundation.ts index 61726fa9643..d68c5104da2 100644 --- a/src/renderer/src/components/use-terminal-workspace-foundation.ts +++ b/src/renderer/src/components/use-terminal-workspace-foundation.ts @@ -37,15 +37,19 @@ export function useTerminalWorkspaceFoundation() { parseWorkspaceKey(renderedActiveWorktreeId ?? '')?.type === 'folder' ? activeWorktreeDeferralHostId : null + // Why gate the id on the host: the projection reads `activeWorkspaceId` only behind a + // truthy resolved host, so without one every active id yields the same surfaces — and a + // worktree switch must not re-project (and re-identify) every surface to rediscover that. + const activeFolderSurfaceId = activeFolderSurfaceHostId ? renderedActiveWorktreeId : null const workspaceSurfaces = useMemo( () => projectWorkspaceSurfaces({ worktreesById, folderWorkspaces, - activeWorkspaceId: renderedActiveWorktreeId, + activeWorkspaceId: activeFolderSurfaceId, activeWorkspaceResolvedHostId: activeFolderSurfaceHostId }), - [worktreesById, folderWorkspaces, renderedActiveWorktreeId, activeFolderSurfaceHostId] + [worktreesById, folderWorkspaces, activeFolderSurfaceId, activeFolderSurfaceHostId] ) // Why split the ids out: every mount/park/activation pass reads only `.id`, but // the surface array is re-identified on any worktree write. Reusing the previous diff --git a/src/renderer/src/components/workspace-surface-projection.test.ts b/src/renderer/src/components/workspace-surface-projection.test.ts index d5226bc5662..7f7c1709ca9 100644 --- a/src/renderer/src/components/workspace-surface-projection.test.ts +++ b/src/renderer/src/components/workspace-surface-projection.test.ts @@ -118,6 +118,24 @@ describe('projectWorkspaceSurfaces', () => { expect(surfaces).toEqual([{ id: 'folder:folder-shared', path: '/remote/orca' }]) }) + it('ignores the active workspace id entirely when no resolved host disambiguates', () => { + // The terminal foundation memo relies on this: with no resolved folder host it passes + // `null` instead of the active id, so a worktree switch cannot change the projection. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const folderWorkspaces = [localFolder, runtimeFolder] + const worktrees = [localWorktree] + + const withoutActiveId = project({ worktrees, folderWorkspaces, activeWorkspaceId: null }) + for (const activeWorkspaceId of [ + 'folder:folder-shared', + 'folder:other-workspace', + SHARED_WORKTREE_ID + ]) { + expect(project({ worktrees, folderWorkspaces, activeWorkspaceId })).toEqual(withoutActiveId) + } + warn.mockRestore() + }) + it('keeps the first row when no resolved host disambiguates the folder collision', () => { const surfaces = project({ folderWorkspaces: [runtimeFolder, localFolder] diff --git a/src/renderer/src/components/workspace-surface-projection.ts b/src/renderer/src/components/workspace-surface-projection.ts index 4a3d28ea17f..9b0a5731be2 100644 --- a/src/renderer/src/components/workspace-surface-projection.ts +++ b/src/renderer/src/components/workspace-surface-projection.ts @@ -49,6 +49,7 @@ export function projectWorkspaceSurfaces({ }: { worktreesById: ReadonlyMap> folderWorkspaces: readonly FolderWorkspaceSurfaceRow[] + /** Read only when `activeWorkspaceResolvedHostId` is set; inert otherwise. */ activeWorkspaceId: string | null /** Resolved (not user-selected) host of the active workspace; the folder tie-break. */ activeWorkspaceResolvedHostId: ExecutionHostId | null From c84007c541d86c12616ea588c98ea0232fbf4406 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:18:39 -0400 Subject: [PATCH 05/17] feat(rpc): generate a shared params catalog from the host registry, gated on parse parity (#19961) --- .gitattributes | 3 + .github/workflows/pr.yml | 3 + .oxlintrc.json | 1 + .../scripts/generate-rpc-params-catalog.mjs | 249 ++++ config/scripts/oxc-cli-invocation.mjs | 23 + config/scripts/oxc-cli-invocation.test.mjs | 40 + config/scripts/oxlint-cli-invocation.mjs | 21 +- ...params-contract-type-only-boundary.test.ts | 144 ++ mobile/src/transport/rpc-params-contract.ts | 8 + package.json | 4 +- .../git/command-runner/git-exec-options.ts | 5 +- src/main/runtime/rpc/methods/accounts.ts | 91 +- src/main/runtime/rpc/methods/agent-hooks.ts | 15 +- src/main/runtime/rpc/methods/agent-session.ts | 185 +-- src/main/runtime/rpc/methods/ai-vault.ts | 79 +- src/main/runtime/rpc/methods/artifacts.ts | 49 +- .../runtime/rpc/methods/automation-schemas.ts | 201 +-- src/main/runtime/rpc/methods/browser-core.ts | 7 +- .../runtime/rpc/methods/browser-extras.ts | 15 +- .../runtime/rpc/methods/browser-schemas.ts | 407 +----- .../runtime/rpc/methods/browser-screencast.ts | 6 +- .../rpc/methods/browser-tab-create-schema.ts | 27 +- src/main/runtime/rpc/methods/client-events.ts | 9 +- .../rpc/methods/client-settings-schemas.ts | 127 +- .../runtime/rpc/methods/client-ui-schemas.ts | 247 +--- .../client-ui-workspace-filter-fields.ts | 12 +- src/main/runtime/rpc/methods/clipboard.ts | 74 +- .../runtime/rpc/methods/computer-schemas.ts | 241 +--- src/main/runtime/rpc/methods/computer.ts | 9 +- src/main/runtime/rpc/methods/emulator.ts | 171 +-- .../rpc/methods/files-mutation-methods.ts | 94 +- .../rpc/methods/files-target-schemas.ts | 16 +- .../files-terminal-artifact-methods.ts | 23 +- src/main/runtime/rpc/methods/files.ts | 114 +- .../runtime/rpc/methods/folder-workspace.ts | 91 +- .../rpc/methods/git-admission-tier-schema.ts | 12 +- src/main/runtime/rpc/methods/git-params.ts | 296 +---- .../rpc/methods/github-issue-methods.ts | 33 +- .../rpc/methods/github-issue-update-schema.ts | 14 +- .../rpc/methods/github-project-methods.ts | 143 +- .../methods/github-pull-request-methods.ts | 90 +- .../github-pull-request-update-methods.ts | 88 +- .../rpc/methods/github-repo-target-schemas.ts | 15 +- .../methods/github-repo-work-item-methods.ts | 47 +- src/main/runtime/rpc/methods/gitlab.ts | 169 +-- src/main/runtime/rpc/methods/hosted-review.ts | 52 +- src/main/runtime/rpc/methods/jira.ts | 115 +- .../rpc/methods/linear-agent-access.ts | 159 +-- .../linear-issue-attribute-filter-schema.ts | 36 +- .../rpc/methods/linear-issue-list-method.ts | 40 +- .../rpc/methods/linear-project-create.ts | 20 +- src/main/runtime/rpc/methods/linear.ts | 141 +- src/main/runtime/rpc/methods/native-chat.ts | 55 +- src/main/runtime/rpc/methods/notifications.ts | 68 +- .../federation/federation-control.ts | 25 +- .../federation/federation-relay.ts | 52 +- .../federation/federation-start-schema.ts | 32 +- .../rpc/methods/orchestration/gates/gates.ts | 41 +- .../runs/mutation-request-show.ts | 5 +- .../rpc/methods/orchestration/runs/runs.ts | 28 +- .../rpc/methods/orchestration/schemas.ts | 170 +-- .../orchestration/worker/worker-control.ts | 13 +- .../worker/worker-release-schemas.ts | 30 +- .../orchestration/worker/worker-release.ts | 13 +- .../worker/worker-start-schema.ts | 65 +- .../orchestration/worker/worker-stop.ts | 5 +- src/main/runtime/rpc/methods/plugins.ts | 26 +- src/main/runtime/rpc/methods/preflight.ts | 16 +- .../methods/project-runtime-rpc-methods.ts | 103 +- .../runtime/rpc/methods/repo-update-schema.ts | 80 +- src/main/runtime/rpc/methods/repo.ts | 121 +- .../methods/runtime-client-capabilities.ts | 8 +- .../rpc/methods/session-tabs-schemas.ts | 243 +--- src/main/runtime/rpc/methods/session-tabs.ts | 8 +- src/main/runtime/rpc/methods/skills.ts | 15 +- src/main/runtime/rpc/methods/speech.ts | 55 +- src/main/runtime/rpc/methods/ssh.ts | 6 +- .../structured-agent-session-schemas.ts | 265 +--- .../rpc/methods/task-resume-state-schema.ts | 35 +- .../runtime/rpc/methods/terminal-orphan.ts | 105 +- .../terminal-quick-command-rpc-schema.ts | 74 +- .../rpc/methods/terminal/stream-schemas.ts | 43 +- .../terminal/terminal-viewport-methods.ts | 4 +- .../rpc/methods/terminal/unary-schemas.ts | 244 +--- .../rpc/methods/terminal/viewport-schemas.ts | 57 +- .../rpc/methods/ui-update-value-tolerance.ts | 29 +- src/main/runtime/rpc/methods/updater.ts | 7 +- .../methods/workspace-cleanup-ui-schema.ts | 31 +- .../runtime/rpc/methods/workspace-ports.ts | 16 +- .../rpc/methods/worktree-create-schemas.ts | 158 +-- .../runtime/rpc/methods/worktree-schemas.ts | 233 +--- .../worktree-visibility-defaults-schema.ts | 20 +- src/main/runtime/rpc/schemas.ts | 96 +- src/shared/rpc-contract/accounts-params.ts | 81 ++ src/shared/rpc-contract/agent-hooks-params.ts | 14 + .../rpc-contract/agent-session-params.ts | 189 +++ src/shared/rpc-contract/ai-vault-params.ts | 73 ++ src/shared/rpc-contract/artifacts-params.ts | 43 + src/shared/rpc-contract/automation-params.ts | 198 +++ .../rpc-contract/browser-core-params.ts | 5 + .../rpc-contract/browser-extras-params.ts | 14 + src/shared/rpc-contract/browser-params.ts | 355 +++++ .../rpc-contract/browser-screencast-params.ts | 5 + .../rpc-contract/browser-tab-create-params.ts | 23 + .../rpc-contract/client-events-params.ts | 8 + .../rpc-contract/client-settings-params.ts | 123 ++ src/shared/rpc-contract/client-ui-params.ts | 257 ++++ ...lient-ui-workspace-filter-fields-params.ts | 11 + src/shared/rpc-contract/clipboard-params.ts | 67 + src/shared/rpc-contract/computer-params.ts | 5 + .../rpc-contract/computer-schemas-params.ts | 227 ++++ src/shared/rpc-contract/emulator-params.ts | 154 +++ .../rpc-contract/files-mutation-params.ts | 84 ++ src/shared/rpc-contract/files-params.ts | 99 ++ .../rpc-contract/files-target-params.ts | 15 + .../files-terminal-artifact-params.ts | 19 + .../rpc-contract/folder-workspace-params.ts | 85 ++ .../rpc-contract/git-admission-tier-params.ts | 14 + src/shared/rpc-contract/git-params.ts | 271 ++++ .../rpc-contract/github-issue-params.ts | 27 + .../github-issue-update-params.ts | 13 + .../rpc-contract/github-project-params.ts | 128 ++ .../github-pull-request-params.ts | 79 ++ .../github-pull-request-update-params.ts | 76 ++ .../rpc-contract/github-repo-target-params.ts | 14 + .../github-repo-work-item-params.ts | 39 + src/shared/rpc-contract/gitlab-params.ts | 149 +++ .../rpc-contract/hosted-review-params.ts | 47 + src/shared/rpc-contract/jira-params.ts | 101 ++ .../linear-agent-access-params.ts | 146 +++ .../linear-issue-attribute-filter-params.ts | 35 + .../rpc-contract/linear-issue-list-params.ts | 38 + src/shared/rpc-contract/linear-params.ts | 124 ++ .../linear-project-create-params.ts | 20 + src/shared/rpc-contract/native-chat-params.ts | 50 + .../rpc-contract/notifications-params.ts | 61 + ...orchestration-federation-control-params.ts | 22 + .../orchestration-federation-relay-params.ts | 47 + .../orchestration-federation-start-params.ts | 29 + .../orchestration-gates-params.ts | 34 + .../rpc-contract/orchestration-params.ts | 153 +++ ...ation-runs-mutation-request-show-params.ts | 4 + .../rpc-contract/orchestration-runs-params.ts | 23 + .../orchestration-worker-control-params.ts | 11 + .../orchestration-worker-release-params.ts | 12 + ...estration-worker-release-schemas-params.ts | 25 + .../orchestration-worker-start-params.ts | 61 + .../orchestration-worker-stop-params.ts | 4 + src/shared/rpc-contract/plugins-params.ts | 20 + src/shared/rpc-contract/preflight-params.ts | 13 + .../rpc-contract/project-runtime-params.ts | 95 ++ src/shared/rpc-contract/repo-params.ts | 100 ++ src/shared/rpc-contract/repo-update-params.ts | 77 ++ .../rpc-contract/rpc-param-primitives.ts | 84 ++ .../rpc-params-catalog.generated.ts | 1167 +++++++++++++++++ .../runtime-client-capabilities-params.ts | 7 + .../rpc-contract/session-tabs-params.ts | 7 + .../session-tabs-schemas-params.ts | 230 ++++ src/shared/rpc-contract/skills-params.ts | 12 + src/shared/rpc-contract/speech-params.ts | 54 + src/shared/rpc-contract/ssh-params.ts | 5 + .../structured-agent-session-params.ts | 246 ++++ .../rpc-contract/task-resume-state-params.ts | 32 + .../rpc-contract/terminal-orphan-params.ts | 105 ++ .../terminal-quick-command-params.ts | 73 ++ .../rpc-contract/terminal-stream-params.ts | 40 + .../rpc-contract/terminal-unary-params.ts | 222 ++++ .../terminal-viewport-methods-params.ts | 3 + .../terminal-viewport-schemas-params.ts | 51 + .../ui-update-value-tolerance-params.ts | 25 + src/shared/rpc-contract/updater-params.ts | 6 + .../workspace-cleanup-ui-params.ts | 28 + .../rpc-contract/workspace-ports-params.ts | 12 + .../rpc-contract/worktree-create-params.ts | 154 +++ src/shared/rpc-contract/worktree-params.ts | 215 +++ .../worktree-visibility-defaults-params.ts | 19 + 176 files changed, 8220 insertions(+), 5874 deletions(-) create mode 100644 config/scripts/generate-rpc-params-catalog.mjs create mode 100644 config/scripts/oxc-cli-invocation.mjs create mode 100644 config/scripts/oxc-cli-invocation.test.mjs create mode 100644 mobile/src/rpc-params-contract-type-only-boundary.test.ts create mode 100644 mobile/src/transport/rpc-params-contract.ts create mode 100644 src/shared/rpc-contract/accounts-params.ts create mode 100644 src/shared/rpc-contract/agent-hooks-params.ts create mode 100644 src/shared/rpc-contract/agent-session-params.ts create mode 100644 src/shared/rpc-contract/ai-vault-params.ts create mode 100644 src/shared/rpc-contract/artifacts-params.ts create mode 100644 src/shared/rpc-contract/automation-params.ts create mode 100644 src/shared/rpc-contract/browser-core-params.ts create mode 100644 src/shared/rpc-contract/browser-extras-params.ts create mode 100644 src/shared/rpc-contract/browser-params.ts create mode 100644 src/shared/rpc-contract/browser-screencast-params.ts create mode 100644 src/shared/rpc-contract/browser-tab-create-params.ts create mode 100644 src/shared/rpc-contract/client-events-params.ts create mode 100644 src/shared/rpc-contract/client-settings-params.ts create mode 100644 src/shared/rpc-contract/client-ui-params.ts create mode 100644 src/shared/rpc-contract/client-ui-workspace-filter-fields-params.ts create mode 100644 src/shared/rpc-contract/clipboard-params.ts create mode 100644 src/shared/rpc-contract/computer-params.ts create mode 100644 src/shared/rpc-contract/computer-schemas-params.ts create mode 100644 src/shared/rpc-contract/emulator-params.ts create mode 100644 src/shared/rpc-contract/files-mutation-params.ts create mode 100644 src/shared/rpc-contract/files-params.ts create mode 100644 src/shared/rpc-contract/files-target-params.ts create mode 100644 src/shared/rpc-contract/files-terminal-artifact-params.ts create mode 100644 src/shared/rpc-contract/folder-workspace-params.ts create mode 100644 src/shared/rpc-contract/git-admission-tier-params.ts create mode 100644 src/shared/rpc-contract/git-params.ts create mode 100644 src/shared/rpc-contract/github-issue-params.ts create mode 100644 src/shared/rpc-contract/github-issue-update-params.ts create mode 100644 src/shared/rpc-contract/github-project-params.ts create mode 100644 src/shared/rpc-contract/github-pull-request-params.ts create mode 100644 src/shared/rpc-contract/github-pull-request-update-params.ts create mode 100644 src/shared/rpc-contract/github-repo-target-params.ts create mode 100644 src/shared/rpc-contract/github-repo-work-item-params.ts create mode 100644 src/shared/rpc-contract/gitlab-params.ts create mode 100644 src/shared/rpc-contract/hosted-review-params.ts create mode 100644 src/shared/rpc-contract/jira-params.ts create mode 100644 src/shared/rpc-contract/linear-agent-access-params.ts create mode 100644 src/shared/rpc-contract/linear-issue-attribute-filter-params.ts create mode 100644 src/shared/rpc-contract/linear-issue-list-params.ts create mode 100644 src/shared/rpc-contract/linear-params.ts create mode 100644 src/shared/rpc-contract/linear-project-create-params.ts create mode 100644 src/shared/rpc-contract/native-chat-params.ts create mode 100644 src/shared/rpc-contract/notifications-params.ts create mode 100644 src/shared/rpc-contract/orchestration-federation-control-params.ts create mode 100644 src/shared/rpc-contract/orchestration-federation-relay-params.ts create mode 100644 src/shared/rpc-contract/orchestration-federation-start-params.ts create mode 100644 src/shared/rpc-contract/orchestration-gates-params.ts create mode 100644 src/shared/rpc-contract/orchestration-params.ts create mode 100644 src/shared/rpc-contract/orchestration-runs-mutation-request-show-params.ts create mode 100644 src/shared/rpc-contract/orchestration-runs-params.ts create mode 100644 src/shared/rpc-contract/orchestration-worker-control-params.ts create mode 100644 src/shared/rpc-contract/orchestration-worker-release-params.ts create mode 100644 src/shared/rpc-contract/orchestration-worker-release-schemas-params.ts create mode 100644 src/shared/rpc-contract/orchestration-worker-start-params.ts create mode 100644 src/shared/rpc-contract/orchestration-worker-stop-params.ts create mode 100644 src/shared/rpc-contract/plugins-params.ts create mode 100644 src/shared/rpc-contract/preflight-params.ts create mode 100644 src/shared/rpc-contract/project-runtime-params.ts create mode 100644 src/shared/rpc-contract/repo-params.ts create mode 100644 src/shared/rpc-contract/repo-update-params.ts create mode 100644 src/shared/rpc-contract/rpc-param-primitives.ts create mode 100644 src/shared/rpc-contract/rpc-params-catalog.generated.ts create mode 100644 src/shared/rpc-contract/runtime-client-capabilities-params.ts create mode 100644 src/shared/rpc-contract/session-tabs-params.ts create mode 100644 src/shared/rpc-contract/session-tabs-schemas-params.ts create mode 100644 src/shared/rpc-contract/skills-params.ts create mode 100644 src/shared/rpc-contract/speech-params.ts create mode 100644 src/shared/rpc-contract/ssh-params.ts create mode 100644 src/shared/rpc-contract/structured-agent-session-params.ts create mode 100644 src/shared/rpc-contract/task-resume-state-params.ts create mode 100644 src/shared/rpc-contract/terminal-orphan-params.ts create mode 100644 src/shared/rpc-contract/terminal-quick-command-params.ts create mode 100644 src/shared/rpc-contract/terminal-stream-params.ts create mode 100644 src/shared/rpc-contract/terminal-unary-params.ts create mode 100644 src/shared/rpc-contract/terminal-viewport-methods-params.ts create mode 100644 src/shared/rpc-contract/terminal-viewport-schemas-params.ts create mode 100644 src/shared/rpc-contract/ui-update-value-tolerance-params.ts create mode 100644 src/shared/rpc-contract/updater-params.ts create mode 100644 src/shared/rpc-contract/workspace-cleanup-ui-params.ts create mode 100644 src/shared/rpc-contract/workspace-ports-params.ts create mode 100644 src/shared/rpc-contract/worktree-create-params.ts create mode 100644 src/shared/rpc-contract/worktree-params.ts create mode 100644 src/shared/rpc-contract/worktree-visibility-defaults-params.ts diff --git a/.gitattributes b/.gitattributes index 736d59473f6..1aa7969e805 100644 --- a/.gitattributes +++ b/.gitattributes @@ -34,3 +34,6 @@ # Generated runtime English subset: compared byte-for-byte by # verify:localization-runtime-catalog, so a CRLF checkout would fail the gate. /src/renderer/src/i18n/en-runtime-required.json linguist-generated=true text eol=lf +# Generated method->params catalog: compared byte-for-byte by +# verify:rpc-params-catalog, so a CRLF checkout would fail the gate. +/src/shared/rpc-contract/rpc-params-catalog.generated.ts linguist-generated=true text eol=lf diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 268b6ad66e3..c49091ab148 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -203,6 +203,9 @@ jobs: - name: Boot orcad and round-trip a terminal run: pnpm run smoke:orcad-terminal + - name: Verify the generated RPC params catalog + run: pnpm run verify:rpc-params-catalog + - name: Verify bundled skill guides run: pnpm run verify:bundled-skill-guides diff --git a/.oxlintrc.json b/.oxlintrc.json index 03cc659f494..55758560478 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -180,6 +180,7 @@ } ], "ignorePatterns": [ + "src/shared/rpc-contract/rpc-params-catalog.generated.ts", "**/node_modules", "**/dist", "**/out", diff --git a/config/scripts/generate-rpc-params-catalog.mjs b/config/scripts/generate-rpc-params-catalog.mjs new file mode 100644 index 00000000000..acf31aae6a1 --- /dev/null +++ b/config/scripts/generate-rpc-params-catalog.mjs @@ -0,0 +1,249 @@ +// Why: the host registry is the only place that binds a method name to its params +// schema. Reading it back — instead of hand-listing 600 methods — is what keeps the +// shared catalog and the dispatcher from drifting apart. +import { execFileSync } from 'node:child_process' +import { + existsSync, + globSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' +import { createRequire } from 'node:module' +import path from 'node:path' +import process from 'node:process' +import * as esbuild from 'esbuild' +import { resolveOxcCliInvocation } from './oxc-cli-invocation.mjs' + +const REPO_ROOT = path.resolve(import.meta.dirname, '..', '..') +const SHARED_DIR = path.join(REPO_ROOT, 'src', 'shared') +const CONTRACT_DIR = path.join(SHARED_DIR, 'rpc-contract') +const RPC_DIR = path.join(REPO_ROOT, 'src', 'main', 'runtime', 'rpc') +const REGISTRY_ENTRY = path.join(RPC_DIR, 'methods', 'index.ts') +const OUTPUT_PATH = path.join(CONTRACT_DIR, 'rpc-params-catalog.generated.ts') + +// Why mkdirSync first: out/ is gitignored and absent on a fresh checkout, so +// mkdtempSync threw ENOENT and took `pnpm lint` down with it. Why not os.tmpdir(): +// the bundle keeps its node_modules deps external and oxfmt reads .oxfmtrc.json by +// walking up, so both scratch files have to sit under the repo to resolve at all. +function scratchDir(prefix) { + const root = path.join(REPO_ROOT, 'out') + mkdirSync(root, { recursive: true }) + return mkdtempSync(path.join(root, prefix)) +} + +const posix = (value) => value.split(path.sep).join('/') +const repoPath = (absolute) => posix(path.relative(REPO_ROOT, absolute)) + +// Every module the catalog may import from: the extracted params modules plus the +// pre-existing src/shared schemas the RPC methods already bind directly. +function indexableModules() { + const modules = new Set( + globSync('*.ts', { cwd: CONTRACT_DIR }).map((name) => path.join(CONTRACT_DIR, name)) + ) + modules.delete(OUTPUT_PATH) + for (const file of globSync('**/*.ts', { cwd: RPC_DIR })) { + if (file.endsWith('.test.ts')) { + continue + } + const source = readFileSync(path.join(RPC_DIR, file), 'utf8') + for (const [, specifier] of source.matchAll(/from\s+'(\.[^']+)'/g)) { + const resolved = `${path.resolve(path.dirname(path.join(RPC_DIR, file)), specifier)}.ts` + if (resolved.startsWith(`${SHARED_DIR}${path.sep}`) && existsSync(resolved)) { + modules.add(resolved) + } + } + } + return [...modules].sort() +} + +// Why: one bundle keeps the registry and the shared modules on the same module +// instances, so schema object identity is what maps a method to its export. +function loadRegistryAndSchemas(modules) { + const buildDir = scratchDir('rpc-params-catalog-') + try { + const entry = path.join(buildDir, 'entry.ts') + const importOf = (file) => JSON.stringify(posix(path.relative(buildDir, file))) + writeFileSync( + entry, + [ + `export { ALL_RPC_METHODS } from ${importOf(REGISTRY_ENTRY)}`, + 'export const SCHEMA_MODULES = {', + ...modules.map( + (file) => ` ${JSON.stringify(repoPath(file))}: require(${importOf(file)}),` + ), + '}' + ].join('\n') + ) + const outfile = path.join(buildDir, 'bundle.cjs') + esbuild.buildSync({ + entryPoints: [entry], + bundle: true, + platform: 'node', + format: 'cjs', + outfile, + logLevel: 'error', + packages: 'external' + }) + const loaded = createRequire(import.meta.url)(outfile) + return { methods: loaded.ALL_RPC_METHODS, schemaModules: loaded.SCHEMA_MODULES } + } finally { + rmSync(buildDir, { recursive: true, force: true }) + } +} + +// Why: schema objects are compared by identity, not by shape — two structurally +// identical schemas are still two different wire contracts. +function buildSchemaIndex(schemaModules) { + const index = new Map() + for (const [modulePath, moduleExports] of Object.entries(schemaModules)) { + for (const [exportName, value] of Object.entries(moduleExports)) { + if (!value || typeof value !== 'object' || typeof value.safeParse !== 'function') { + continue + } + if (index.has(value)) { + continue + } + index.set(value, { modulePath, exportName }) + } + } + return index +} + +function localNameFor(origin, taken) { + if (!taken.has(origin.exportName)) { + return origin.exportName + } + const hint = path + .basename(origin.modulePath, '.ts') + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join('') + let candidate = `${origin.exportName}Of${hint}` + let suffix = 2 + while (taken.has(candidate)) { + candidate = `${origin.exportName}Of${hint}${suffix++}` + } + return candidate +} + +function render({ methods, schemaModules }) { + const index = buildSchemaIndex(schemaModules) + const entries = [] + const uncataloged = [] + const imports = new Map() + const taken = new Set() + + for (const method of [...methods].sort((left, right) => (left.name < right.name ? -1 : 1))) { + if (method.params === null) { + entries.push(` '${method.name}': null`) + continue + } + const origin = index.get(method.params) + if (!origin) { + uncataloged.push(method.name) + continue + } + const key = `${origin.modulePath}#${origin.exportName}` + let local = imports.get(key) + if (!local) { + local = localNameFor(origin, taken) + taken.add(local) + imports.set(key, local) + } + entries.push(` '${method.name}': ${local}`) + } + + const byModule = new Map() + for (const [key, local] of imports) { + const [modulePath, exportName] = key.split('#') + if (!byModule.has(modulePath)) { + byModule.set(modulePath, []) + } + byModule.get(modulePath).push(local === exportName ? exportName : `${exportName} as ${local}`) + } + const importLines = [...byModule] + .sort(([left], [right]) => (left < right ? -1 : 1)) + .map(([modulePath, names]) => { + let specifier = posix(path.relative(CONTRACT_DIR, path.join(REPO_ROOT, modulePath))).replace( + /\.ts$/, + '' + ) + if (!specifier.startsWith('.')) { + specifier = `./${specifier}` + } + return `import { ${names.sort().join(', ')} } from '${specifier}'` + }) + + return `// GENERATED by config/scripts/generate-rpc-params-catalog.mjs. Do not edit; +// run \`pnpm run generate:rpc-params-catalog\`. +import type { z } from 'zod' +${importLines.join('\n')} + +// Why: the host parses params with these schemas, so a client that matches this map +// matches the dispatcher. Clients must import it for types only — parsing a params +// schema client-side runs the coercing transforms and rewrites the wire bytes. +export const RPC_PARAMS_BY_METHOD = { +${entries.join(',\n')} +} as const + +// Why: these methods bind a schema the shared contract cannot hold because its value +// graph reaches into src/main. Listing them keeps the gap visible instead of absent. +export const RPC_METHODS_WITHOUT_SHARED_PARAMS: readonly string[] = [ +${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. +export type RpcParams = + (typeof RPC_PARAMS_BY_METHOD)[Method] extends z.ZodType + ? z.output<(typeof RPC_PARAMS_BY_METHOD)[Method]> + : void +` +} + +// Why: the drift gate compares bytes, so the generator must emit exactly what the +// formatter would produce or every run would look like drift. +function formatted(source) { + const buildDir = scratchDir('rpc-params-catalog-fmt-') + try { + const file = path.join(buildDir, 'rpc-params-catalog.generated.ts') + writeFileSync(file, source) + const { command, prefixArgs } = resolveOxcCliInvocation('oxfmt', 'oxfmt', REPO_ROOT) + execFileSync(command, [...prefixArgs, '--write', file], { + stdio: 'ignore', + windowsHide: true + }) + return readFileSync(file, 'utf8') + } finally { + rmSync(buildDir, { recursive: true, force: true }) + } +} + +function main() { + const check = process.argv.includes('--check') + const generated = formatted(render(loadRegistryAndSchemas(indexableModules()))) + const current = existsSync(OUTPUT_PATH) ? readFileSync(OUTPUT_PATH, 'utf8') : null + if (generated === current) { + if (!check) { + console.log(`rpc params catalog already up to date: ${repoPath(OUTPUT_PATH)}`) + } + return + } + if (check) { + console.error( + `${repoPath(OUTPUT_PATH)} is out of date. Run \`pnpm run generate:rpc-params-catalog\`.` + ) + process.exitCode = 1 + return + } + writeFileSync(OUTPUT_PATH, generated) + console.log(`wrote ${repoPath(OUTPUT_PATH)}`) +} + +main() diff --git a/config/scripts/oxc-cli-invocation.mjs b/config/scripts/oxc-cli-invocation.mjs new file mode 100644 index 00000000000..4bf17b5c994 --- /dev/null +++ b/config/scripts/oxc-cli-invocation.mjs @@ -0,0 +1,23 @@ +import { createRequire } from 'node:module' +import path from 'node:path' +import process from 'node:process' + +// Why not `pnpm exec ` / `node_modules/.bin/.cmd`: both land on a Windows +// .cmd shim, and Node >= 20 refuses to spawn one without `shell: true` (the +// CVE-2024-27980 mitigation), so every gate that took that route died with EINVAL +// before doing any work. The oxc bins are plain Node scripts, so run them under this +// process's own node — no shim, no shell, no quoting question. +export function resolveOxcCliInvocation(packageName, binName, root = process.cwd()) { + const requireFromRoot = createRequire(path.join(root, 'package.json')) + // The oxc packages' "exports" hide ./bin, so read the manifest and walk to its bin entry. + const manifestPath = requireFromRoot.resolve(`${packageName}/package.json`) + const binField = requireFromRoot(`${packageName}/package.json`).bin + const binEntry = typeof binField === 'string' ? binField : binField?.[binName] + if (!binEntry) { + throw new Error(`${packageName} package.json declares no "${binName}" bin entry.`) + } + return { + command: process.execPath, + prefixArgs: [path.resolve(path.dirname(manifestPath), binEntry)] + } +} diff --git a/config/scripts/oxc-cli-invocation.test.mjs b/config/scripts/oxc-cli-invocation.test.mjs new file mode 100644 index 00000000000..5776580dbfe --- /dev/null +++ b/config/scripts/oxc-cli-invocation.test.mjs @@ -0,0 +1,40 @@ +import { spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { describe, expect, it } from 'vitest' +import { resolveOxcCliInvocation } from './oxc-cli-invocation.mjs' + +const repoRoot = path.resolve(import.meta.dirname, '..', '..') + +describe('resolveOxcCliInvocation', () => { + it('runs oxfmt under this process node, never through a shim', () => { + const { command, prefixArgs } = resolveOxcCliInvocation('oxfmt', 'oxfmt', repoRoot) + + expect(command).toBe(process.execPath) + expect(prefixArgs).toHaveLength(1) + // The params-catalog generator spawned node_modules/.bin/oxfmt, which is a .cmd on + // Windows — Node >= 20 refuses it without shell:true and dies with EINVAL. + expect(prefixArgs[0]).not.toMatch(/\.(cmd|bat)$/i) + expect(existsSync(prefixArgs[0])).toBe(true) + }) + + it('spawns oxfmt without a shell', () => { + const { command, prefixArgs } = resolveOxcCliInvocation('oxfmt', 'oxfmt', repoRoot) + const result = spawnSync(command, [...prefixArgs, '--help'], { + cwd: repoRoot, + encoding: 'utf8', + shell: false, + windowsHide: true + }) + + expect(result.error).toBeUndefined() + expect(result.stdout).toContain('oxfmt') + }) + + it('names the package and bin it could not find', () => { + expect(() => resolveOxcCliInvocation('oxfmt', 'nope', repoRoot)).toThrow( + 'oxfmt package.json declares no "nope" bin entry.' + ) + }) +}) diff --git a/config/scripts/oxlint-cli-invocation.mjs b/config/scripts/oxlint-cli-invocation.mjs index 605aa33c686..92e6f55fb95 100644 --- a/config/scripts/oxlint-cli-invocation.mjs +++ b/config/scripts/oxlint-cli-invocation.mjs @@ -1,23 +1,6 @@ -import { createRequire } from 'node:module' -import path from 'node:path' import process from 'node:process' +import { resolveOxcCliInvocation } from './oxc-cli-invocation.mjs' -// Why not `pnpm exec oxlint` / `node_modules/.bin/oxlint.cmd`: both land on a -// Windows .cmd shim, and Node >= 20 refuses to spawn one without `shell: true` -// (the CVE-2024-27980 mitigation), so every lint gate died with EINVAL before -// linting anything. Oxlint's bin is a plain Node script, so run it under this -// process's own node — no shim, no shell, no quoting question. export function resolveOxlintInvocation(root = process.cwd()) { - const requireFromRoot = createRequire(path.join(root, 'package.json')) - // Oxlint's "exports" hides ./bin, so read the manifest and walk to its bin entry. - const manifestPath = requireFromRoot.resolve('oxlint/package.json') - const binField = requireFromRoot('oxlint/package.json').bin - const binEntry = typeof binField === 'string' ? binField : binField?.oxlint - if (!binEntry) { - throw new Error('oxlint package.json declares no "oxlint" bin entry.') - } - return { - command: process.execPath, - prefixArgs: [path.resolve(path.dirname(manifestPath), binEntry)] - } + return resolveOxcCliInvocation('oxlint', 'oxlint', root) } diff --git a/mobile/src/rpc-params-contract-type-only-boundary.test.ts b/mobile/src/rpc-params-contract-type-only-boundary.test.ts new file mode 100644 index 00000000000..bc9088bea0b --- /dev/null +++ b/mobile/src/rpc-params-contract-type-only-boundary.test.ts @@ -0,0 +1,144 @@ +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' + +// Why: src/shared/rpc-contract/*-params.ts hold the host's zod schemas. Bundling one +// into the app would let client code call parse(), and requiredString is +// z.unknown().transform(...) — it coerces a non-string to '' instead of rejecting it, +// silently changing the bytes the phone puts on the wire. Types only, never values. +const mobileRoot = fileURLToPath(new URL('..', import.meta.url)) +const contractRoot = resolve(mobileRoot, '..', 'src', 'shared', 'rpc-contract') +const scannedRoots = ['app', 'src'].map((directory) => join(mobileRoot, directory)) +const sourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx']) + +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 targetsContract(path: string, specifier: string): boolean { + if (!specifier.startsWith('.')) { + return false + } + const resolved = resolve(path, '..', specifier) + return resolved === contractRoot || resolved.startsWith(`${contractRoot}/`) +} + +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 + ) +} + +// Returns the specifiers that would pull contract *values* into the bundle. +export function contractValueImports(path: string, source: string): string[] { + const sourceFile = parse(path, source) + const offenders: string[] = [] + const visit = (node: ts.Node): void => { + if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { + const specifier = node.moduleSpecifier.text + if (targetsContract(path, specifier)) { + const clause = node.importClause + const everyNamedIsType = + clause?.isTypeOnly === true || + (clause?.namedBindings !== undefined && + ts.isNamedImports(clause.namedBindings) && + clause.namedBindings.elements.every((element) => element.isTypeOnly)) + // A bare `import './x'` has no clause at all and still emits a require. + if (!everyNamedIsType) { + offenders.push(specifier) + } + } + } + if ( + ts.isExportDeclaration(node) && + node.moduleSpecifier && + ts.isStringLiteral(node.moduleSpecifier) + ) { + const specifier = node.moduleSpecifier.text + if (targetsContract(path, specifier)) { + const everyNamedIsType = + node.isTypeOnly || + (node.exportClause !== undefined && + ts.isNamedExports(node.exportClause) && + node.exportClause.elements.every((element) => element.isTypeOnly)) + if (!everyNamedIsType) { + offenders.push(specifier) + } + } + } + if (ts.isCallExpression(node)) { + const callee = node.expression + const isDynamic = callee.kind === ts.SyntaxKind.ImportKeyword + const isRequire = ts.isIdentifier(callee) && callee.text === 'require' + const argument = node.arguments[0] + if ( + (isDynamic || isRequire) && + argument && + ts.isStringLiteral(argument) && + targetsContract(path, argument.text) + ) { + offenders.push(argument.text) + } + } + ts.forEachChild(node, visit) + } + visit(sourceFile) + return offenders +} + +describe('RPC params contract boundary', () => { + it('flags every shape that would emit a runtime require', () => { + const path = join(mobileRoot, 'src', 'probe.ts') + const contract = '../../src/shared/rpc-contract/repo-params' + expect(contractValueImports(path, `import { RepoSelector } from '${contract}'`)).toEqual([ + contract + ]) + expect(contractValueImports(path, `import '${contract}'`)).toEqual([contract]) + expect(contractValueImports(path, `export { RepoSelector } from '${contract}'`)).toEqual([ + contract + ]) + expect(contractValueImports(path, `const s = require('${contract}')`)).toEqual([contract]) + expect(contractValueImports(path, `const s = await import('${contract}')`)).toEqual([contract]) + expect(contractValueImports(path, `import type { RepoSelector } from '${contract}'`)).toEqual( + [] + ) + expect(contractValueImports(path, `import { type RepoSelector } from '${contract}'`)).toEqual( + [] + ) + expect(contractValueImports(path, `export type { RepoSelector } from '${contract}'`)).toEqual( + [] + ) + expect( + contractValueImports( + path, + `import type { GitHubWorkItem } from '../../src/shared/github/work-item-types'` + ) + ).toEqual([]) + }) + + it('keeps every mobile import of the params contract type-only', () => { + const offenders = scannedRoots + .flatMap(sourceFiles) + .filter((path) => sourceExtensions.has(extname(path))) + .flatMap((path) => + contractValueImports(path, readFileSync(path, 'utf8')).map( + (specifier) => `${relative(mobileRoot, path)} -> ${specifier}` + ) + ) + + expect(offenders).toEqual([]) + }) +}) diff --git a/mobile/src/transport/rpc-params-contract.ts b/mobile/src/transport/rpc-params-contract.ts new file mode 100644 index 00000000000..fcf3303e965 --- /dev/null +++ b/mobile/src/transport/rpc-params-contract.ts @@ -0,0 +1,8 @@ +// Why: mobile's only entry to the host's params contract, and type-only on purpose. +// 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. +export type { + RpcMethodName, + RpcParams +} from '../../../src/shared/rpc-contract/rpc-params-catalog.generated' diff --git a/package.json b/package.json index 0536f4fd606..9feaad74882 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "audit:perf": "oxlint --config config/oxlint-performance-audit.json --format json src", "test:perf:contracts": "vitest run --config config/vitest.performance.config.ts", "format": "oxfmt --write .", - "lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-runtime-catalog && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage", + "lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run verify:rpc-params-catalog && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-runtime-catalog && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage", "audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor", "audit:code-quality:native": "oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings", "audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings", @@ -38,6 +38,8 @@ "smoke:orcad-terminal": "node config/scripts/ensure-native-runtime.mjs --runtime=node && pnpm run build:cli && pnpm run build:orcad && node config/scripts/runtime-serve-terminal-smoke.mjs --target orcad", "smoke:serve-terminal": "node config/scripts/runtime-serve-terminal-smoke.mjs", "check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs", + "generate:rpc-params-catalog": "node config/scripts/generate-rpc-params-catalog.mjs", + "verify:rpc-params-catalog": "node config/scripts/generate-rpc-params-catalog.mjs --check", "generate:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --write", "verify:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --check", "generate:skill-bundle-manifest": "node config/scripts/generate-skill-bundle-manifest.mjs --write", diff --git a/src/main/git/command-runner/git-exec-options.ts b/src/main/git/command-runner/git-exec-options.ts index 4390d84658d..75ced0d3030 100644 --- a/src/main/git/command-runner/git-exec-options.ts +++ b/src/main/git/command-runner/git-exec-options.ts @@ -1,7 +1,10 @@ // Why: cap execFile output to prevent an uncatchable V8 string overflow; match relay MAX_GIT_BUFFER. export const DEFAULT_GIT_MAX_BUFFER = 10 * 1024 * 1024 -export type GitAdmissionTier = 'interactive' | 'status' | 'background' +// Why: the admission tier is a wire value, so it is declared with its params schema. +import type { GitAdmissionTier } from '../../../shared/rpc-contract/git-admission-tier-params' + +export type { GitAdmissionTier } export type GitExecOptions = { cwd: string diff --git a/src/main/runtime/rpc/methods/accounts.ts b/src/main/runtime/rpc/methods/accounts.ts index 41d82965d00..328276518d7 100644 --- a/src/main/runtime/rpc/methods/accounts.ts +++ b/src/main/runtime/rpc/methods/accounts.ts @@ -1,5 +1,14 @@ -import { z } from 'zod' import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' +import { + AccountsUnsubscribeParams, + AddClaudeFromConfigDirParams, + AddCodexFromHomeParams, + ConsumeCodexResetCreditParams, + ListAccountsParams, + RemoveAccountParams, + SelectAccountParams, + SelectCodexAccountForTargetParams +} from '../../../../shared/rpc-contract/accounts-params' // Why: monotonically increasing per-process counter avoids the Date.now() // collision that fired when two near-simultaneous accounts.subscribe calls @@ -7,86 +16,6 @@ import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' // registerSubscriptionCleanup's existing-key eviction path. let accountsSubscriptionSeq = 0 -const CodexResetTarget = z.discriminatedUnion('runtime', [ - z.object({ runtime: z.literal('host'), wslDistro: z.null() }).strict(), - // Why: reset scope must identify one exact WSL distro; null means all slots only for selection. - z.object({ runtime: z.literal('wsl'), wslDistro: z.string().trim().min(1).max(255) }).strict() -]) - -const CodexSelectionTarget = z.discriminatedUnion('runtime', [ - z.object({ runtime: z.literal('host'), wslDistro: z.null() }).strict(), - z - .object({ - runtime: z.literal('wsl'), - // A null distro intentionally means all WSL selection slots. - wslDistro: z.string().trim().min(1).max(255).nullable() - }) - .strict() -]) - -const SelectAccountParams = z.object({ - accountId: z - .union([z.string().min(1, 'Missing accountId'), z.null()]) - .transform((v) => (v === null ? null : v)) -}) - -const SelectCodexAccountForTargetParams = SelectAccountParams.extend({ - target: CodexSelectionTarget -}) - -const RemoveAccountParams = z.object({ - accountId: z.string().min(1, 'Missing accountId') -}) - -const CodexResetExpectedScope = z - .object({ - target: CodexResetTarget, - accountId: z.string().min(1, 'Missing accountId').max(512), - accountRevision: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), - offerRevision: z.string().startsWith('v1:', 'Invalid offerRevision').max(4_096) - }) - .strict() - -const ConsumeCodexResetCreditParams = z - .object({ - // Why: the phone owns the logical attempt key so a lost response can be - // retried without spending a finite earned credit twice. - idempotencyKey: z.uuid('Invalid idempotencyKey'), - expectedScope: CodexResetExpectedScope - }) - .strict() - -const AddClaudeFromConfigDirParams = z.object({ - configDir: z.string().min(1, 'Missing configDir'), - runtime: z.enum(['host', 'wsl']).optional(), - wslDistro: z.string().nullish(), - previousLegacyCredentialsSha256: z - .string() - .regex(/^[a-f0-9]{64}$/, 'Invalid legacy credential digest') - .nullable() - .optional() -}) - -const AddCodexFromHomeParams = z.object({ - sourceHome: z.string().min(1, 'Missing sourceHome'), - runtime: z.enum(['host', 'wsl']).optional(), - wslDistro: z.string().nullish() -}) - -// Why: `orca account list` prints only emails and the active ids, so it opts out -// of the forced all-provider usage refresh below — that lane bypasses the poll -// throttle and Retry-After gate and costs one serial round-trip per account. -const ListAccountsParams = z.object({ - refreshUsage: z.boolean().default(true) -}) - -const AccountsUnsubscribeParams = z.object({ - subscriptionId: z - .unknown() - .transform((value) => (typeof value === 'string' && value.length > 0 ? value : '')) - .pipe(z.string().min(1, 'Missing subscriptionId')) -}) - // Why: bridges the desktop ClaudeAccountService / CodexAccountService / // RateLimitService into the WebSocket / local-socket RPC. Read + switch + // remove for all clients; interactive add/re-auth flows spawn `claude login` diff --git a/src/main/runtime/rpc/methods/agent-hooks.ts b/src/main/runtime/rpc/methods/agent-hooks.ts index b26b117bd32..e68c7df05df 100644 --- a/src/main/runtime/rpc/methods/agent-hooks.ts +++ b/src/main/runtime/rpc/methods/agent-hooks.ts @@ -1,19 +1,6 @@ -import { z } from 'zod' import { prepareManagedWslCodexHomeBeforeShellLaunch } from '../../../codex/managed-wsl-home-shell-preflight' import { defineMethod, type RpcMethod } from '../core' - -const PrepareCodexForWslPaneParams = z - .object({ - codexHome: z.string().max(4_096), - orcaCodexHome: z.string().max(4_096), - wslDistro: z - .string() - .trim() - .min(1) - .max(255) - .regex(/^[^\\/\r\n]+$/) - }) - .strict() +import { PrepareCodexForWslPaneParams } from '../../../../shared/rpc-contract/agent-hooks-params' export const AGENT_HOOK_METHODS: readonly RpcMethod[] = [ defineMethod({ diff --git a/src/main/runtime/rpc/methods/agent-session.ts b/src/main/runtime/rpc/methods/agent-session.ts index 08aaa91038e..79da783a939 100644 --- a/src/main/runtime/rpc/methods/agent-session.ts +++ b/src/main/runtime/rpc/methods/agent-session.ts @@ -1,9 +1,3 @@ -import { z } from 'zod' -import { - getAgentResumeArgv, - hasUnsafeProviderSessionIdChars, - RESUMABLE_TUI_AGENTS -} from '../../../../shared/agent-session-resume' import type { RuntimeAgentSessionRpcCaller, RuntimeCreateAgentSessionRequest, @@ -15,182 +9,13 @@ import { AGENT_SESSION_OPERATION_FUTURE_SKEW_MS, parseAgentSessionOperationTimestamp } from '../../../../shared/agent-session-host-authority' -import { isTuiAgent } from '../../../../shared/tui-agent-config' -import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' import type { OrcaRuntimeService } from '../../orca-runtime' import { defineMethod, type RpcAnyMethod } from '../core' - -const MAX_WORKTREE_SELECTOR_LENGTH = 32_768 -const MAX_TRANSCRIPT_PATH_BYTES = 16 * 1024 -const MAX_PROMPT_BYTES = 256 * 1024 -const MAX_AGENT_ARGS_BYTES = 16 * 1024 -const MAX_LAUNCH_PREFERENCE_LENGTH = 512 - -const StrictNonEmptyString = (max: number, message: string) => - z - .string() - .min(1, message) - .max(max, message) - .refine((value) => value === value.trim(), `${message}; surrounding whitespace is invalid`) - -const WorktreeSelector = StrictNonEmptyString( - MAX_WORKTREE_SELECTOR_LENGTH, - 'Invalid worktree selector' -) - -const Presentation = z.enum(['background', 'focused']) - -const Placement = z - .object({ - tabId: z - .string() - .min(1) - .max(512) - .refine(isValidTerminalTabId, 'Invalid terminal tab ID') - .optional(), - leafId: z.string().min(1).max(128).optional() - }) - .strict() - .refine((value) => value.tabId !== undefined || value.leafId !== undefined, { - message: 'Placement must include a tab or leaf ID' - }) - -const LaunchPreferences = z - .object({ - model: StrictNonEmptyString( - MAX_LAUNCH_PREFERENCE_LENGTH, - 'Invalid model preference' - ).optional(), - effort: StrictNonEmptyString( - MAX_LAUNCH_PREFERENCE_LENGTH, - 'Invalid effort preference' - ).optional(), - mode: StrictNonEmptyString(MAX_LAUNCH_PREFERENCE_LENGTH, 'Invalid mode preference').optional() - }) - .strict() - -const PromptDelivery = z.enum(['auto-submit', 'draft']) - -const AgentArgs = z - .string() - .refine( - (value) => Buffer.byteLength(value, 'utf8') <= MAX_AGENT_ARGS_BYTES, - 'Agent arguments are too large' - ) - .nullable() - -const OmpResumeFilePath = z - .string() - .min(1) - .refine((value) => value === value.trim(), 'Invalid OMP resume path') - .refine( - (value) => - !hasUnsafeProviderSessionIdChars(value) && - Buffer.byteLength(value, 'utf8') <= MAX_TRANSCRIPT_PATH_BYTES, - 'Invalid OMP resume path' - ) - -const ProviderSession = z - .object({ - key: z.enum(['session_id', 'conversation_id']), - id: StrictNonEmptyString(512, 'Invalid provider session ID').refine( - (value) => !value.startsWith('-') && !hasUnsafeProviderSessionIdChars(value), - 'Invalid provider session ID' - ), - transcriptPath: z - .string() - .min(1) - .refine((value) => value === value.trim(), 'Invalid transcript path') - .refine( - (value) => - !hasUnsafeProviderSessionIdChars(value) && - Buffer.byteLength(value, 'utf8') <= MAX_TRANSCRIPT_PATH_BYTES, - 'Invalid transcript path' - ) - .optional() - }) - .strict() - -const AutomaticEnsure = z - .object({ - kind: z.literal('automatic'), - sleepingCheckpointId: z - .string() - .min(32) - .max(128) - .regex(/^[A-Za-z0-9_-]+$/), - presentation: Presentation.optional() - }) - .strict() - -const ExplicitEnsure = z - .object({ - kind: z.literal('explicit'), - worktree: WorktreeSelector, - agent: z.enum(RESUMABLE_TUI_AGENTS), - providerSession: ProviderSession, - ompResumeFilePath: OmpResumeFilePath.optional(), - agentArgs: AgentArgs.optional(), - launchPreferences: LaunchPreferences.optional(), - presentation: Presentation.optional(), - placement: Placement.optional() - }) - .strict() - .superRefine((value, context) => { - if (value.ompResumeFilePath !== undefined && value.agent !== 'omp') { - context.addIssue({ - code: z.ZodIssueCode.custom, - path: ['ompResumeFilePath'], - message: 'OMP resume path requires the OMP agent' - }) - } - if (getAgentResumeArgv(value.agent, value.providerSession, value.ompResumeFilePath) === null) { - context.addIssue({ - code: z.ZodIssueCode.custom, - path: ['providerSession'], - message: 'Provider session is not resumable for this agent' - }) - } - }) - -export const EnsureAgentSessionParams: z.ZodType = - z.discriminatedUnion('kind', [AutomaticEnsure, ExplicitEnsure]) - -export const CreateAgentSessionParams: z.ZodType = z - .object({ - clientOperationId: z - .string() - .refine( - (value) => parseAgentSessionOperationTimestamp(value) !== null, - 'Invalid agent operation ID' - ), - worktree: WorktreeSelector, - agent: z.string().refine(isTuiAgent, 'Unknown agent preset'), - prompt: z - .string() - .refine( - (value) => Buffer.byteLength(value, 'utf8') <= MAX_PROMPT_BYTES, - 'Prompt is too large' - ) - .optional(), - promptDelivery: PromptDelivery.optional(), - agentArgs: AgentArgs.optional(), - launchPreferences: LaunchPreferences.optional(), - startupCwd: z.string().min(1).max(MAX_WORKTREE_SELECTOR_LENGTH).optional(), - presentation: Presentation.optional(), - placement: Placement.optional(), - viewMode: z.enum(['terminal', 'chat']).optional() - }) - .strict() - .superRefine((value, context) => { - if (value.promptDelivery === 'draft' && !value.prompt?.trim()) { - context.addIssue({ - code: z.ZodIssueCode.custom, - path: ['prompt'], - message: 'Draft delivery requires a non-empty prompt' - }) - } - }) +import { + CreateAgentSessionParams, + EnsureAgentSessionParams +} from '../../../../shared/rpc-contract/agent-session-params' +export { CreateAgentSessionParams, EnsureAgentSessionParams } type AgentSessionRuntime = OrcaRuntimeService & { ensureAgentSession( diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index c689165924d..d5f52bafded 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -1,84 +1,19 @@ -import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' -import { OptionalBoolean } from '../schemas' import { restampAiVaultListResult } from '../../../ai-vault/session-list-results' -import { AI_VAULT_AGENTS, AI_VAULT_SCOPE_PATHS_MAX_COUNT } from '../../../../shared/ai-vault-types' -import { AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT } from '../../../../shared/ai-vault-session-title' import type { AiVaultPrepareSessionResumeArgs } from '../../../../shared/ai-vault-resume-preparation' -import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../../shared/execution-host' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { describeAiVaultScanError } from '../../../../shared/ai-vault-scan-error-message' import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { assertLegacyAiVaultResumeAllowed, projectStructuredAiVaultSessions } from '../../../ai-vault/structured-session-ownership' - -// Why: bound limit + scopePaths so a client cannot force an unbounded scan. -// Each scopePath is a host-local match prefix (validated/capped, never used for -// traversal); the count/length caps mirror the worktree-schemas bounding style. -const AI_VAULT_SCOPE_PATH_MAX_LENGTH = 4096 -const AI_VAULT_LIMIT_MAX = 2000 - -const executionHostIdSchema = z.string().transform((value, ctx): `runtime:${string}` => { - const parsed = parseExecutionHostId(value) - if (parsed?.kind === 'runtime') { - return parsed.id - } - ctx.addIssue({ - code: 'custom', - message: 'Invalid runtime execution host id' - }) - return z.NEVER -}) - -export const AiVaultListSessionsParams = z - .object({ - limit: z - .unknown() - .transform((value) => - typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined - ) - .pipe(z.union([z.number().int(), z.undefined()])) - .optional(), - unlimited: OptionalBoolean, - force: OptionalBoolean, - scopePaths: z - .array(z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH)) - // Why: clamp instead of reject — scope paths only ever widen discovery, and - // rejecting would hard-break older/uncapped producers (web client, pre-cap - // desktop parents) that send more than the bound. - .transform((paths) => paths.slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT)) - .optional(), - // Why: desktop/web callers name the runtime host they are addressing; mobile - // omits it. The scan itself is host-local either way, so the id must never - // change what is scanned — it only restamps the shared cached result. - executionHostId: executionHostIdSchema.optional() - }) - .superRefine((params, ctx) => { - if (params.unlimited !== true && params.limit && params.limit > AI_VAULT_LIMIT_MAX) { - ctx.addIssue({ code: 'custom', path: ['limit'], message: 'Limit exceeds maximum' }) - } - }) - -export const AiVaultPrepareSessionResumeParams = z.object({ - agent: z.enum(AI_VAULT_AGENTS), - sessionId: z.string().min(1).max(512).optional(), - filePath: z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH), - codexHome: z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH).nullable(), - executionHostId: z.string().optional() -}) - -export const AiVaultSessionTitlesParams = z.object({ - requests: z - .array( - z.object({ - agent: z.enum(['claude', 'codex']), - sessionId: z.string().min(1).max(512), - transcriptPath: z.string().min(1).max(32_768).optional() - }) - ) - .max(AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT) -}) +import { + AiVaultListSessionsParams, + AiVaultPrepareSessionResumeParams, + AiVaultSessionTitlesParams +} from '../../../../shared/rpc-contract/ai-vault-params' +export { AiVaultListSessionsParams, AiVaultPrepareSessionResumeParams, AiVaultSessionTitlesParams } export const AI_VAULT_METHODS: RpcMethod[] = [ defineMethod({ diff --git a/src/main/runtime/rpc/methods/artifacts.ts b/src/main/runtime/rpc/methods/artifacts.ts index 4b5d7b5ab3a..c627380d612 100644 --- a/src/main/runtime/rpc/methods/artifacts.ts +++ b/src/main/runtime/rpc/methods/artifacts.ts @@ -1,45 +1,10 @@ -import { z } from 'zod' -import { - ARTIFACT_MAX_CONTENT_BYTES, - ARTIFACT_MAX_REQUEST_BYTES, - artifactContentByteLength, - artifactWriteRequestByteLength -} from '../../../../shared/artifacts' import { defineMethod, type RpcAnyMethod } from '../core' - -const CloudOptions = { - apiUrl: z.string().max(2_048).optional(), - authToken: z.string().max(16_384).optional() -} - -const ListOptions = z.object({ - ...CloudOptions, - cursor: z.string().min(1).max(2_048).optional() -}) - -const SourceRequest = z.object({ - sourceKey: z.string().min(1).max(32_768), - ...CloudOptions -}) - -const WriteRequest = z - .object({ - sourceKey: z.string().min(1).max(32_768), - content: z - .string() - .min(1) - .max(ARTIFACT_MAX_CONTENT_BYTES) - .refine((content) => artifactContentByteLength(content) <= ARTIFACT_MAX_CONTENT_BYTES, { - message: 'Artifact content exceeds the 10 MiB limit.' - }), - contentType: z.enum(['text/html', 'text/markdown']), - fileName: z.string().min(1).max(512), - title: z.string().max(512).optional(), - ...CloudOptions - }) - .refine((request) => artifactWriteRequestByteLength(request) <= ARTIFACT_MAX_REQUEST_BYTES, { - message: 'Artifact request exceeds the supported size.' - }) +import { + ArtifactsDeleteParams, + ListOptions, + SourceRequest, + WriteRequest +} from '../../../../shared/rpc-contract/artifacts-params' export const ARTIFACT_METHODS: readonly RpcAnyMethod[] = [ defineMethod({ @@ -74,7 +39,7 @@ export const ARTIFACT_METHODS: readonly RpcAnyMethod[] = [ }), defineMethod({ name: 'artifacts.delete', - params: z.object({ id: z.string().min(1), ...CloudOptions }), + params: ArtifactsDeleteParams, handler: (params, { runtime }) => runtime.deleteArtifact(params.id, params) }) ] diff --git a/src/main/runtime/rpc/methods/automation-schemas.ts b/src/main/runtime/rpc/methods/automation-schemas.ts index f2c829c1a9d..23962b84ccc 100644 --- a/src/main/runtime/rpc/methods/automation-schemas.ts +++ b/src/main/runtime/rpc/methods/automation-schemas.ts @@ -1,193 +1,10 @@ // Why: the automation method table stays readable only if its field-level validation lives beside it rather than inside it. -import { z } from 'zod' -import { isValidAutomationSchedule } from '../../../../shared/automation-schedule-parsing' -import { - MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, - normalizeAutomationPrecheckTimeoutSeconds -} from '../../../../shared/automation-precheck' -import { normalizeExecutionHostId } from '../../../../shared/execution-host' -import type { TaskProviderIdentity as SharedTaskProviderIdentity } from '../../../../shared/task-source-context' -import { isTuiAgent } from '../../../../shared/tui-agent-config' -import { - OptionalBoolean, - OptionalPlainString, - OptionalPositiveInt, - OptionalString, - requiredNumber, - requiredString -} from '../schemas' - -const TuiAgent = requiredString('Missing provider').refine(isTuiAgent, { - message: 'Unknown provider' -}) - -const AutomationWorkspaceMode = z.enum(['existing', 'new_per_run']).optional() -const SetupDecision = z.enum(['inherit', 'run', 'skip']).optional() -const ExecutionHostId = requiredString('Missing host id').transform((value, ctx) => { - const hostId = normalizeExecutionHostId(value) - if (!hostId) { - ctx.addIssue({ code: 'custom', message: 'Invalid host id' }) - return z.NEVER - } - return hostId -}) - -const AutomationSchedule = requiredString('Missing trigger').refine(isValidAutomationSchedule, { - message: 'Invalid automation trigger' -}) - -const AutomationPrecheck = z - .object({ - command: requiredString('Missing precheck command'), - timeoutSeconds: OptionalPositiveInt.transform((value) => - normalizeAutomationPrecheckTimeoutSeconds(value) - ).refine((value) => value <= MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, { - message: 'Precheck timeout is too large' - }) - }) - .nullable() - .optional() - -const OptionalNullablePlainString = z - .unknown() - .transform((value) => (value === null || typeof value === 'string' ? value : undefined)) - .pipe(z.union([z.string(), z.null(), z.undefined()])) - .optional() - -const TaskProviderIdentity = z - .custom( - (value) => - value !== null && - typeof value === 'object' && - 'provider' in value && - ['github', 'gitlab', 'linear', 'jira'].includes(String(value.provider)) - ) - .optional() - .nullable() - -const TaskSourceContext = z - .object({ - kind: z.literal('task-source'), - provider: z.enum(['github', 'gitlab', 'linear', 'jira']), - projectId: requiredString('Missing source project id'), - hostId: ExecutionHostId, - projectHostSetupId: OptionalNullablePlainString, - repoId: OptionalNullablePlainString, - providerIdentity: TaskProviderIdentity, - accountLabel: OptionalNullablePlainString - }) - .optional() - .nullable() - -const WorkspaceRunContext = z - .object({ - kind: z.literal('workspace-run'), - projectId: requiredString('Missing run project id'), - hostId: ExecutionHostId, - projectHostSetupId: requiredString('Missing project host setup id'), - repoId: requiredString('Missing repo id'), - path: requiredString('Missing run path') - }) - .optional() - .nullable() - -const SshTargetGeneration = requiredNumber('Missing SSH target generation').refine( - (value) => Number.isSafeInteger(value) && value >= 1, - { message: 'Invalid SSH target generation' } -) - -const OwnedSshSelector = z.object({ - kind: z.literal('ssh'), - targetId: requiredString('Missing SSH target id'), - targetGeneration: SshTargetGeneration -}) - -/** Orphan is accepted here, unlike a destination: a record with no executable host is still deletable. */ -const OwnerPreconditionSelector = z.discriminatedUnion('kind', [ - z.object({ kind: z.literal('self') }), - OwnedSshSelector, - z.object({ kind: z.literal('orphan') }) -]) - -const DestinationSelector = z.discriminatedUnion('kind', [ - z.object({ kind: z.literal('self') }), - OwnedSshSelector -]) - -export const ExpectedOwner = z.object({ selector: OwnerPreconditionSelector }).optional() -export const Destination = z.object({ selector: DestinationSelector }).optional() - -const ListScopeSelector = z.discriminatedUnion('kind', [ - z.object({ kind: z.literal('self') }), - z.object({ - kind: z.literal('ssh'), - targetId: requiredString('Missing SSH target id'), - expectedTargetGeneration: SshTargetGeneration - }), - z.object({ kind: z.literal('orphan') }) -]) - -/** An omitted selector is the legacy request; old clients keep the authority's complete list. */ -export const AutomationList = z.object({ selector: ListScopeSelector.optional() }) - -export const AutomationId = z.object({ - id: requiredString('Missing automation id'), - expectedOwner: ExpectedOwner -}) - -export const AutomationRuns = z.object({ - automationId: OptionalString, - expectedOwner: ExpectedOwner, - limit: OptionalPositiveInt, - cursor: OptionalString -}) - -export const AutomationCreate = z.object({ - creationKey: OptionalString, - name: requiredString('Missing automation name'), - prompt: requiredString('Missing automation prompt'), - precheck: AutomationPrecheck, - agentId: TuiAgent, - runContext: WorkspaceRunContext, - sourceContext: TaskSourceContext, - repo: OptionalString, - workspace: OptionalString, - workspaceMode: AutomationWorkspaceMode, - baseBranch: OptionalPlainString, - setupDecision: SetupDecision, - reuseSession: OptionalBoolean, - timezone: OptionalString, - rrule: AutomationSchedule, - dtstart: requiredNumber('Missing trigger start time'), - enabled: OptionalBoolean, - missedRunGraceMinutes: OptionalPositiveInt, - destination: Destination -}) - -const AutomationUpdateFields = z.object({ - name: OptionalString, - prompt: OptionalString, - precheck: AutomationPrecheck, - agentId: TuiAgent.optional(), - runContext: WorkspaceRunContext, - sourceContext: TaskSourceContext, - repo: OptionalString, - workspace: OptionalString, - workspaceMode: AutomationWorkspaceMode, - // Why: update patches distinguish omitted from null so callers can clear a saved base branch. - baseBranch: OptionalNullablePlainString, - setupDecision: SetupDecision, - reuseSession: OptionalBoolean, - timezone: OptionalString, - rrule: AutomationSchedule.optional(), - dtstart: requiredNumber('Missing trigger start time').optional(), - enabled: OptionalBoolean, - missedRunGraceMinutes: OptionalPositiveInt -}) - -export const AutomationUpdate = z.object({ - id: requiredString('Missing automation id'), - updates: AutomationUpdateFields, - expectedOwner: ExpectedOwner, - destination: Destination -}) +export { + AutomationCreate, + AutomationId, + AutomationList, + AutomationRuns, + AutomationUpdate, + Destination, + ExpectedOwner +} from '../../../../shared/rpc-contract/automation-params' diff --git a/src/main/runtime/rpc/methods/browser-core.ts b/src/main/runtime/rpc/methods/browser-core.ts index 5e5fba227b6..596c30a31c8 100644 --- a/src/main/runtime/rpc/methods/browser-core.ts +++ b/src/main/runtime/rpc/methods/browser-core.ts @@ -1,5 +1,5 @@ import { defineMethod, type RpcMethod } from '../core' -import { BrowserTarget, requiredString } from '../schemas' +import { BrowserTarget } from '../schemas' import { Check, Drag, @@ -33,10 +33,7 @@ import { } from './browser-schemas' import { BrowserOpenUrlParams, BrowserTabCreateParams } from './browser-tab-create-schema' import { BROWSER_TEXT_METHODS } from './browser-text-rpc-methods' - -const CertificateProceed = BrowserTarget.extend({ - challengeId: requiredString('Missing required challengeId') -}) +import { CertificateProceed } from '../../../../shared/rpc-contract/browser-core-params' export const BROWSER_CORE_METHODS: RpcMethod[] = [ defineMethod({ diff --git a/src/main/runtime/rpc/methods/browser-extras.ts b/src/main/runtime/rpc/methods/browser-extras.ts index 692c5e19b7f..fe87bde950f 100644 --- a/src/main/runtime/rpc/methods/browser-extras.ts +++ b/src/main/runtime/rpc/methods/browser-extras.ts @@ -1,7 +1,6 @@ -import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { assertRpcClipboardTextWriteWithinLimit } from '../rpc-clipboard-text-validation' -import { BrowserTarget, OptionalFiniteNumber } from '../schemas' +import { BrowserTarget } from '../schemas' import { ClipboardWrite, CookieDelete, @@ -22,17 +21,7 @@ import { StorageKeyValue, Viewport } from './browser-schemas' - -const MouseModifiers = z - .unknown() - .transform((v) => (Array.isArray(v) ? v : undefined)) - .pipe(z.union([z.array(z.enum(['cmd', 'ctrl', 'alt', 'shift'])), z.undefined()])) - .optional() - -const MouseClick = MouseXY.merge(MouseButton).extend({ - radius: OptionalFiniteNumber, - modifiers: MouseModifiers -}) +import { MouseClick } from '../../../../shared/rpc-contract/browser-extras-params' export const BROWSER_EXTRA_METHODS: RpcMethod[] = [ defineMethod({ diff --git a/src/main/runtime/rpc/methods/browser-schemas.ts b/src/main/runtime/rpc/methods/browser-schemas.ts index 03872b61cc0..034fee898ad 100644 --- a/src/main/runtime/rpc/methods/browser-schemas.ts +++ b/src/main/runtime/rpc/methods/browser-schemas.ts @@ -1,356 +1,55 @@ // Why: browser schemas stay separate from handler registration so both sides // remain under the line cap and dispatch wiring stays scannable. -import { z } from 'zod' -import { - BrowserTarget, - OptionalBoolean, - OptionalFiniteNumber, - OptionalPlainString, - OptionalString, - requiredStringAllowingEmpty, - requiredString -} from '../schemas' - -export const Element = BrowserTarget.extend({ - element: requiredString('Missing required --element') -}) - -export const Goto = BrowserTarget.extend({ - url: requiredString('Missing required --url') -}) - -export const Fill = BrowserTarget.extend({ - element: requiredString('Missing required --element'), - value: requiredStringAllowingEmpty('Missing required --value') -}) - -export const Type = BrowserTarget.extend({ - input: requiredString('Missing required --input') -}) - -export const Select = BrowserTarget.extend({ - element: requiredString('Missing required --element'), - value: z.custom((v) => typeof v === 'string', { - message: 'Missing required --value' - }) -}) - -export const Scroll = BrowserTarget.extend({ - direction: z.custom<'up' | 'down'>((v) => v === 'up' || v === 'down', { - message: 'Missing required --direction (up or down)' - }), - amount: z - .unknown() - .transform((v) => (typeof v === 'number' && v > 0 ? v : undefined)) - .pipe(z.union([z.number(), z.undefined()])) - .optional() -}) - -export const Screenshot = BrowserTarget.extend({ - format: z - .unknown() - .transform((v) => (v === 'png' || v === 'jpeg' ? v : undefined)) - .pipe(z.union([z.enum(['png', 'jpeg']), z.undefined()])) - .optional() -}) - -export const Screencast = BrowserTarget.extend({ - format: z - .unknown() - .optional() - .transform((v) => (v === 'png' ? 'png' : 'jpeg')) - .pipe(z.enum(['png', 'jpeg'])), - quality: OptionalFiniteNumber, - maxWidth: OptionalFiniteNumber, - maxHeight: OptionalFiniteNumber, - viewportWidth: OptionalFiniteNumber, - viewportHeight: OptionalFiniteNumber, - deviceScaleFactor: OptionalFiniteNumber, - mobile: OptionalBoolean, - everyNthFrame: OptionalFiniteNumber, - minFrameIntervalMs: OptionalFiniteNumber -}) - -export const FullScreenshot = BrowserTarget.extend({ - format: z - .unknown() - .optional() - .transform((v) => (v === 'jpeg' ? 'jpeg' : 'png')) - .pipe(z.enum(['png', 'jpeg'])) -}) - -export const Eval = BrowserTarget.extend({ - expression: requiredString('Missing required --expression') -}) - -export const TabList = z.object({ worktree: OptionalString }) -// Why: --index xor --page must be present. The refine guards that invariant -// so the dispatcher surfaces a single legible error instead of either shape -// leaking into the runtime. -// -// `focus` is opt-in: when true, the runtime sends `browser:pane-focus` to -// the renderer after the switch lands. The renderer surfaces the browser -// pane only if the user is already on the targeted worktree; otherwise it -// pre-stages per-worktree state silently. This avoids cross-worktree screen -// theft when multiple agents drive browsers in parallel worktrees. -export const TabSwitch = BrowserTarget.extend({ - index: z - .unknown() - .transform((v) => (typeof v === 'number' ? v : undefined)) - .pipe(z.union([z.number(), z.undefined()])) - .optional(), - focus: z.boolean().optional() -}).refine( - (val) => { - if (val.page !== undefined) { - return true - } - return val.index !== undefined && Number.isInteger(val.index) && val.index >= 0 - }, - { message: 'Missing required --index (non-negative integer) or --page' } -) - -export const TabShow = z.object({ - page: requiredString('Missing required --page'), - worktree: OptionalString -}) - -export const TabCurrent = z.object({ worktree: OptionalString }) - -export const TabClose = z.object({ - index: z - .unknown() - .transform((v) => (typeof v === 'number' ? v : undefined)) - .pipe(z.union([z.number(), z.undefined()])) - .optional(), - page: OptionalString, - worktree: OptionalString -}) - -export const TabSetProfile = BrowserTarget.extend({ - profileId: requiredString('Missing required --profile') -}) - -export const TabProfileClone = BrowserTarget.extend({ - profileId: requiredString('Missing required --profile') -}) - -export const ProfileCreate = z.object({ - label: requiredString('Missing required --label'), - // Strict enum so unknown scope values surface validation errors instead of being - // silently coerced to 'isolated' (pr-bug-scan finding from #1397). - scope: z.enum(['isolated', 'imported']), - userAgentMode: z.enum(['clean', 'native']).optional() -}) - -export const ProfileDelete = z.object({ profileId: requiredString('Missing required --profile') }) - -export const ProfileImportFromBrowser = z.object({ - profileId: requiredString('Missing required --profile'), - browserFamily: requiredString('Missing required --browser-family'), - browserProfile: OptionalString, - supportsPartitionSkippedCookies: z.literal(true).optional() -}) - -export const Drag = BrowserTarget.extend({ - from: requiredString('Missing required --from and --to element refs'), - to: requiredString('Missing required --from and --to element refs') -}) - -export const Upload = BrowserTarget.extend({ - element: requiredString('Missing required --element and --files'), - files: z.custom( - (v) => Array.isArray(v) && v.length > 0 && v.every((f) => typeof f === 'string'), - { message: 'Missing required --element and --files' } - ) -}) - -export const Wait = BrowserTarget.extend({ - selector: OptionalPlainString, - timeout: z - .unknown() - .transform((v) => (typeof v === 'number' && v > 0 ? v : undefined)) - .pipe(z.union([z.number(), z.undefined()])) - .optional(), - text: OptionalPlainString, - url: OptionalPlainString, - load: OptionalPlainString, - fn: OptionalPlainString, - state: OptionalPlainString -}) - -export const Check = BrowserTarget.extend({ - element: requiredString('Missing required --element'), - checked: z - .unknown() - .optional() - .transform((v) => (v === undefined ? true : v)) - .pipe(z.boolean()) -}) - -export const Keypress = BrowserTarget.extend({ - key: requiredString('Missing required --key') -}) - -export const SelectorPath = BrowserTarget.extend({ - selector: requiredString('Missing required --selector and --path'), - path: requiredString('Missing required --selector and --path') -}) - -export const Highlight = BrowserTarget.extend({ - selector: requiredString('Missing required --selector') -}) - -export const Exec = BrowserTarget.extend({ - command: requiredString('Missing required --command') -}) - -export const Get = BrowserTarget.extend({ - what: requiredString('Missing required --what'), - selector: OptionalString -}) - -export const Is = BrowserTarget.extend({ - what: z.custom((v) => typeof v === 'string' && v.length > 0, { - message: 'Missing required --what and --element' - }), - selector: z.custom((v) => typeof v === 'string' && v.length > 0, { - message: 'Missing required --what and --element' - }) -}) - -export const KeyboardInsert = BrowserTarget.extend({ - text: requiredString('Missing required --text') -}) - -export const LimitParam = BrowserTarget.extend({ - limit: OptionalFiniteNumber -}) - -export const Find = BrowserTarget.extend({ - locator: requiredString('Missing required --locator, --value, and --action'), - value: requiredString('Missing required --locator, --value, and --action'), - action: requiredString('Missing required --locator, --value, and --action'), - text: OptionalString -}) - -export const CookieGet = BrowserTarget.extend({ - url: OptionalPlainString -}) - -export const CookieSet = BrowserTarget.extend({ - name: z.custom((v) => typeof v === 'string' && v.length > 0, { - message: 'Missing name or value' - }), - value: z.custom((v) => typeof v === 'string', { - message: 'Missing name or value' - }), - domain: OptionalPlainString, - path: OptionalPlainString, - secure: OptionalBoolean, - httpOnly: OptionalBoolean, - sameSite: OptionalPlainString, - expires: OptionalFiniteNumber -}) - -export const CookieDelete = BrowserTarget.extend({ - name: requiredString('Missing cookie name'), - domain: OptionalPlainString, - url: OptionalPlainString -}) - -export const Viewport = BrowserTarget.extend({ - width: z.custom((v) => typeof v === 'number' && v > 0, { - message: 'Width and height must be positive numbers' - }), - height: z.custom((v) => typeof v === 'number' && v > 0, { - message: 'Width and height must be positive numbers' - }), - deviceScaleFactor: OptionalFiniteNumber, - mobile: OptionalBoolean -}) - -export const Geolocation = BrowserTarget.extend({ - latitude: z.custom((v) => typeof v === 'number', { - message: 'Missing latitude or longitude' - }), - longitude: z.custom((v) => typeof v === 'number', { - message: 'Missing latitude or longitude' - }), - accuracy: OptionalFiniteNumber -}) - -export const InterceptEnable = BrowserTarget.extend({ - patterns: z - .unknown() - .transform((v) => (Array.isArray(v) ? (v as string[]) : undefined)) - .pipe(z.union([z.array(z.string()), z.undefined()])) - .optional() -}) - -export const MouseXY = BrowserTarget.extend({ - x: z.custom((v) => typeof v === 'number', { - message: 'Missing required x and y coordinates' - }), - y: z.custom((v) => typeof v === 'number', { - message: 'Missing required x and y coordinates' - }) -}) - -export const MouseButton = BrowserTarget.extend({ - button: OptionalPlainString -}) - -export const MouseWheel = BrowserTarget.extend({ - dy: z.custom((v) => typeof v === 'number', { - message: 'Missing required --dy' - }), - dx: OptionalFiniteNumber -}) - -export const SetDevice = BrowserTarget.extend({ - name: requiredString('Missing required --name') -}) - -export const SetOffline = BrowserTarget.extend({ - state: OptionalPlainString -}) - -export const SetHeaders = BrowserTarget.extend({ - headers: requiredString('Missing required --headers (JSON string)') -}) - -export const SetCredentials = BrowserTarget.extend({ - user: z.custom((v) => typeof v === 'string' && v.length > 0, { - message: 'Missing required --user and --pass' - }), - pass: z.custom((v) => typeof v === 'string', { - message: 'Missing required --user and --pass' - }) -}) - -export const SetMedia = BrowserTarget.extend({ - colorScheme: OptionalPlainString, - reducedMotion: OptionalPlainString -}) - -export const ClipboardWrite = BrowserTarget.extend({ - text: requiredString('Missing required --text') -}) - -export const DialogAccept = BrowserTarget.extend({ - text: OptionalPlainString -}) - -export const StorageKey = BrowserTarget.extend({ - key: requiredString('Missing required --key') -}) - -export const StorageKeyValue = BrowserTarget.extend({ - key: z.custom((v) => typeof v === 'string' && v.length > 0, { - message: 'Missing required --key and --value' - }), - value: z.custom((v) => typeof v === 'string', { - message: 'Missing required --key and --value' - }) -}) +export { + Check, + ClipboardWrite, + CookieDelete, + CookieGet, + CookieSet, + DialogAccept, + Drag, + Element, + Eval, + Exec, + Fill, + Find, + FullScreenshot, + Geolocation, + Get, + Goto, + Highlight, + InterceptEnable, + Is, + KeyboardInsert, + Keypress, + LimitParam, + MouseButton, + MouseWheel, + MouseXY, + ProfileCreate, + ProfileDelete, + ProfileImportFromBrowser, + Screencast, + Screenshot, + Scroll, + Select, + SelectorPath, + SetCredentials, + SetDevice, + SetHeaders, + SetMedia, + SetOffline, + StorageKey, + StorageKeyValue, + TabClose, + TabCurrent, + TabList, + TabProfileClone, + TabSetProfile, + TabShow, + TabSwitch, + Type, + Upload, + Viewport, + Wait +} from '../../../../shared/rpc-contract/browser-params' diff --git a/src/main/runtime/rpc/methods/browser-screencast.ts b/src/main/runtime/rpc/methods/browser-screencast.ts index ea965a2b44a..ed16e9d0edf 100644 --- a/src/main/runtime/rpc/methods/browser-screencast.ts +++ b/src/main/runtime/rpc/methods/browser-screencast.ts @@ -1,13 +1,9 @@ -import { z } from 'zod' import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' import { Screencast } from './browser-schemas' import { BrowserError } from '../../../browser/browser-error' import { BROWSER_UNAVAILABLE_ERROR_CODE } from '../../../../shared/runtime-types' import { runtimeBrowserCommandsFactoryIsAvailable } from '../../runtime-browser-commands-factory' - -const ScreencastUnsubscribe = z.object({ - subscriptionId: z.string().min(1, 'Missing required --subscription-id') -}) +import { ScreencastUnsubscribe } from '../../../../shared/rpc-contract/browser-screencast-params' export const BROWSER_SCREENCAST_METHODS: RpcAnyMethod[] = [ defineStreamingMethod({ diff --git a/src/main/runtime/rpc/methods/browser-tab-create-schema.ts b/src/main/runtime/rpc/methods/browser-tab-create-schema.ts index 799111b2a6b..ad7c0ee5b75 100644 --- a/src/main/runtime/rpc/methods/browser-tab-create-schema.ts +++ b/src/main/runtime/rpc/methods/browser-tab-create-schema.ts @@ -1,23 +1,4 @@ -import { z } from 'zod' -import { OptionalString } from '../schemas' -import { BrowserPageCreationPlacement } from '../../../../shared/browser-client-host-placement' -import { RUNTIME_NAVIGATION_TARGETS } from '../../../../shared/runtime-navigation' - -export const BrowserTabCreateParams = z.object({ - url: OptionalString, - worktree: OptionalString, - page: OptionalString, - profileId: OptionalString, - waitForRegistration: z.boolean().optional(), - activate: z.boolean().optional(), - // Why: `activate` says the caller wants the new tab selected; `navigation` says on whose screens. - // Absent, a paired caller means 'caller' — one device's create must not steer every other UI. - navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(), - targetGroupId: OptionalString, - placement: BrowserPageCreationPlacement.optional() -}) - -export const BrowserOpenUrlParams = z.object({ - url: z.url(), - worktree: z.string().min(1) -}) +export { + BrowserOpenUrlParams, + BrowserTabCreateParams +} from '../../../../shared/rpc-contract/browser-tab-create-params' diff --git a/src/main/runtime/rpc/methods/client-events.ts b/src/main/runtime/rpc/methods/client-events.ts index 0c3a079262f..6c23ed9f15d 100644 --- a/src/main/runtime/rpc/methods/client-events.ts +++ b/src/main/runtime/rpc/methods/client-events.ts @@ -1,17 +1,10 @@ -import { z } from 'zod' import { getRegisteredSshState, listRegisteredSshTargets } from '../../../ssh/ssh-target-registry' import { getPublicSshState } from '../../public-ssh-state' import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' +import { ClientEventsUnsubscribeParams } from '../../../../shared/rpc-contract/client-events-params' let clientEventSubscriptionSeq = 0 -const ClientEventsUnsubscribeParams = z.object({ - subscriptionId: z - .unknown() - .transform((value) => (typeof value === 'string' && value.length > 0 ? value : '')) - .pipe(z.string().min(1, 'Missing subscriptionId')) -}) - export const CLIENT_EVENT_METHODS: readonly RpcAnyMethod[] = [ defineStreamingMethod({ name: 'runtime.clientEvents.subscribe', diff --git a/src/main/runtime/rpc/methods/client-settings-schemas.ts b/src/main/runtime/rpc/methods/client-settings-schemas.ts index e389ed9d12b..c8467636d9a 100644 --- a/src/main/runtime/rpc/methods/client-settings-schemas.ts +++ b/src/main/runtime/rpc/methods/client-settings-schemas.ts @@ -1,122 +1,5 @@ -import { z } from 'zod' -import { normalizePRBotAuthorOverrides } from '../../../../shared/pr-bot-author-overrides' -import { isTaskProvider } from '../../../../shared/task-providers' -import { isTuiAgent } from '../../../../shared/tui-agent-config' -import { - normalizeTuiAgentArgsRecord, - normalizeTuiAgentEnvRecord -} from '../../../../shared/tui-agent-launch-defaults' -import { normalizeDisabledTuiAgents } from '../../../../shared/tui-agent-selection' -import { WorktreeVisibilityDefaultsUpdate } from './worktree-visibility-defaults-schema' -import type { TaskProvider } from '../../../../shared/task-providers' - -const TaskProviderParam = z.custom(isTaskProvider, { - message: 'Unknown task provider' -}) - -export const PRBotAuthorOverrideUpdate = z - .object({ author: z.string(), isBot: z.boolean() }) - .strict() - -const NativeChatSessionOptionPickBase = { - modelId: z.string().trim().min(1).max(512), - adoptModelAsLaunchDefault: z.boolean().optional() -} - -const NativeChatSessionOptionPick = z.union([ - z - .object({ - ...NativeChatSessionOptionPickBase, - optionId: z.enum(['model', 'effort']), - value: z.string().trim().min(1).max(512) - }) - .strict(), - z - .object({ - ...NativeChatSessionOptionPickBase, - optionId: z.enum(['fastMode', 'thinking']), - value: z.boolean() - }) - .strict() -]) - -export const NativeChatSessionOptionsMutation = z.discriminatedUnion('type', [ - z - .object({ - type: z.literal('apply-picks'), - agent: z.enum(['claude', 'codex', 'gemini', 'cursor', 'grok']), - picks: z.array(NativeChatSessionOptionPick).min(1).max(8) - }) - .strict(), - z - .object({ - type: z.literal('clear-model-if-missing'), - agent: z.enum(['claude', 'codex', 'gemini', 'cursor', 'grok']), - availableModelIds: z.array(z.string().trim().min(1).max(512)).min(1).max(256) - }) - .strict() -]) - -const GitHubProjectRef = z - .object({ - owner: z.string(), - ownerType: z.enum(['organization', 'user']), - number: z.number().int(), - host: z.string().optional() - }) - .strict() -const GitHubProjectSettings = z - .object({ - pinned: z.array(GitHubProjectRef), - recent: z.array( - GitHubProjectRef.extend({ - lastOpenedAt: z.string() - }).strict() - ), - lastViewByProject: z.record(z.string(), z.object({ viewId: z.string() }).strict()), - activeProject: GitHubProjectRef.nullable() - }) - .strict() - -export const SettingsUpdate = z - .object({ - worktreeVisibilityDefaults: WorktreeVisibilityDefaultsUpdate.optional(), - defaultTuiAgent: z - .unknown() - .transform((value) => - value === null || value === 'blank' || isTuiAgent(value) ? value : undefined - ) - .optional(), - disabledTuiAgents: z - .unknown() - .transform((value) => normalizeDisabledTuiAgents(value)) - .optional(), - agentDefaultArgs: z - .unknown() - .transform((value) => normalizeTuiAgentArgsRecord(value)) - .optional(), - agentDefaultEnv: z - .unknown() - .transform((value) => normalizeTuiAgentEnvRecord(value)) - .optional(), - defaultTaskSource: TaskProviderParam.optional(), - visibleTaskProviders: z.array(TaskProviderParam).optional(), - defaultTaskViewPreset: z - .enum(['issues', 'my-issues', 'prs', 'my-prs', 'review', 'all']) - .optional(), - experimentalNewWorktreeCardStyle: z.boolean().optional(), - agentStatusHooksEnabled: z.boolean().optional(), - defaultRepoSelection: z.array(z.string()).nullable().optional(), - defaultLinearTeamSelection: z.array(z.string()).nullable().optional(), - compactWorktreeCards: z.boolean().optional(), - minimaxGroupId: z.string().optional(), - minimaxUsageModels: z.string().optional(), - minimaxEndpoint: z.enum(['overseas', 'cn']).optional(), - githubProjects: GitHubProjectSettings.optional(), - prBotAuthorOverrides: z - .unknown() - .transform((value) => normalizePRBotAuthorOverrides(value)) - .optional() - }) - .strict() - .default({}) +export { + NativeChatSessionOptionsMutation, + PRBotAuthorOverrideUpdate, + SettingsUpdate +} from '../../../../shared/rpc-contract/client-settings-params' diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index 32e62e105d7..f11bd7ca49c 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -1,245 +1,8 @@ -import { z } from 'zod' -import { - isFeatureInteractionId, - type FeatureInteractionId -} from '../../../../shared/feature-interactions' -import { - ACTIVITY_GROUP_BY_VALUES, - THREAD_READ_FILTER_VALUES -} from '../../../../shared/agents-view-thread-filters' -import { isFeatureTipId } from '../../../../shared/feature-tips' -import { isReleaseChannel, type ReleaseChannel } from '../../../../shared/release-channel' -import { - normalizeWorktreeCardProperties, - WORKTREE_CARD_PROPERTIES -} from '../../../../shared/worktree/card-properties' -import { isPluginPanelTabKey } from '../../../../shared/plugins/plugin-manifest' -import { ClientUiWorkspaceFilterFields } from './client-ui-workspace-filter-fields' -import { TaskResumeState } from './task-resume-state-schema' -import { WorkspaceCleanup } from './workspace-cleanup-ui-schema' -import { omitUndefinedValues, tolerateUnknownValues } from './ui-update-value-tolerance' - -const NullableString = z.string().nullable() -const StringArray = z.array(z.string()) -const FeatureTipIds = z.array(z.custom(isFeatureTipId, { message: 'Unknown feature tip id' })) -const UnknownRecord = z.record(z.string(), z.unknown()) -const UnknownRecordArray = z.array(UnknownRecord) -type StaticRightSidebarTab = (typeof STATIC_RIGHT_SIDEBAR_TABS)[number] -// Derived from the shared union so a new card property cannot drift out of the -// client schema — it previously omitted 'cli' and rejected the whole payload. -const WorktreeCardPropertyParam = z.enum(WORKTREE_CARD_PROPERTIES) -const WorktreeCardProperties = z - .array(WorktreeCardPropertyParam) - .transform((value) => normalizeWorktreeCardProperties(value)) -const STATIC_RIGHT_SIDEBAR_TABS = [ - 'explorer', - 'search', - 'vault', - 'workspaces', - 'pr-checks', - 'source-control', - 'checks', - 'ports' -] as const -// Plugin panels are open-ended `plugin:./` keys, so the -// schema validates their shape rather than enumerating them. -const RightSidebarTabParam = z.custom( - (value) => - typeof value === 'string' && - (STATIC_RIGHT_SIDEBAR_TABS.includes(value as StaticRightSidebarTab) || - isPluginPanelTabKey(value)), - { message: 'Unknown right sidebar tab' } -) -const AgentActivityDisplayMode = z.enum(['compact', 'full']) -const StatusBarItem = z.enum([ - 'claude', - 'codex', - 'gemini', - 'antigravity', - 'opencode-go', - 'kimi', - 'minimax', - 'grok', - 'ssh', - 'resource-usage', - 'ports' -]) -const WorkspaceStatusDefinition = z.object({ - id: z.string(), - label: z.string(), - color: z.string().optional(), - icon: z.string().optional() -}) -const FeatureInteractionRecord = z - .object({ - firstInteractedAt: z.number().finite().nonnegative(), - interactionCount: z.number().int().positive().optional() - }) - .strict() -const FeatureInteractions = z - .record(z.string(), FeatureInteractionRecord) - .superRefine((value, ctx) => { - for (const id of Object.keys(value)) { - if (!isFeatureInteractionId(id)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Unknown feature interaction id: ${id}`, - path: [id] - }) - } - } - }) -export const FeatureInteractionIdParam = z.custom(isFeatureInteractionId, { - message: 'Unknown feature interaction id' -}) -const TopLevelViewSchema = z.enum([ - 'terminal', - 'settings', - 'tasks', - 'activity', - 'automations', - 'space', - 'skills', - 'artifacts', - 'mobile' -]) -const UiUpdateFields = z - .object({ - lastActiveRepoId: NullableString.optional(), - lastActiveWorktreeId: NullableString.optional(), - // Why: sync hydration ignores this persisted startup view, so paired windows stay put. - activeView: TopLevelViewSchema.optional(), - sidebarWidth: z.number().finite().optional(), - rightSidebarOpen: z.boolean().optional(), - rightSidebarTab: RightSidebarTabParam.optional(), - rightSidebarExplorerView: z.enum(['files', 'search']).optional(), - rightSidebarWidth: z.number().finite().optional(), - markdownTocPanelWidth: z.number().finite().optional(), - combinedDiffFileTreeWidth: z.number().finite().optional(), - groupBy: z.enum(['none', 'workspace-status', 'repo', 'pr-status']).optional(), - showWorkspaceLineage: z.boolean().optional(), - sortBy: z.enum(['name', 'smart', 'recent', 'repo', 'manual']).optional(), - projectOrderBy: z.enum(['manual', 'recent']).optional(), - showActiveOnly: z.boolean().optional(), - hideSleepingWorkspaces: z.boolean().optional(), - showSleepingWorkspaces: z.boolean().optional(), - showInactiveWorkspaces: z.boolean().optional(), - workspaceHostScope: z.string().optional(), - visibleWorkspaceHostIds: z.array(z.string()).nullable().optional(), - agentsVisibleHostIds: z.array(z.string()).nullable().optional(), - agentsFilterRepoIds: StringArray.optional(), - agentsShowChildAgents: z.boolean().optional(), - agentsCompactMode: z.boolean().optional(), - agentsShowSearch: z.boolean().optional(), - agentsReadFilter: z.enum(THREAD_READ_FILTER_VALUES).optional(), - agentsGroupBy: z.enum(ACTIVITY_GROUP_BY_VALUES).optional(), - workspaceHostOrder: z.array(z.string()).optional(), - automationHostFilter: z - .union([ - z.object({ kind: z.literal('all') }).strict(), - z.object({ kind: z.literal('host'), hostKey: z.string().min(1) }).strict() - ]) - .optional(), - manualRepoOrder: z - .array(z.object({ hostId: z.string(), repoId: z.string() }).strict()) - .optional(), - ...ClientUiWorkspaceFilterFields, - // Why: rides App.tsx's debounced writer, so omitting it rejected that entire - // payload (sidebar widths, filters, agent acks) for every paired client. - showDotfilesByWorktree: z.record(z.string(), z.boolean()).optional(), - collapsedGroups: StringArray.optional(), - uiZoomLevel: z.number().finite().optional(), - editorFontZoomLevel: z.number().finite().optional(), - worktreeCardProperties: WorktreeCardProperties.optional(), - _worktreeCardModeDefaulted: z.boolean().optional(), - agentActivityDisplayMode: AgentActivityDisplayMode.optional(), - workspaceStatuses: z.array(WorkspaceStatusDefinition).optional(), - workspaceBoardOpacity: z.number().finite().optional(), - workspaceBoardColumnWidth: z.number().finite().optional(), - syncTaskStatusFromWorkspaceBoard: z.boolean().optional(), - _workspaceStatusesDefaultOrderMigrated: z.boolean().optional(), - _workspaceStatusesReorderedDefaultRepaired: z.boolean().optional(), - _workspaceStatusesDefaultWorkflowMigrated: z.boolean().optional(), - _workspaceStatusesDefaultVisualsMigrated: z.boolean().optional(), - statusBarItems: z.array(StatusBarItem).optional(), - _portsStatusBarDefaultAdded: z.boolean().optional(), - _kimiStatusBarDefaultAdded: z.boolean().optional(), - _minimaxStatusBarDefaultAdded: z.boolean().optional(), - _antigravityStatusBarDefaultAdded: z.boolean().optional(), - _grokStatusBarDefaultAdded: z.boolean().optional(), - statusBarVisible: z.boolean().optional(), - usagePercentageDisplay: z.enum(['used', 'remaining']).optional(), - statusBarUsageMode: z.enum(['verbose', 'compact']).optional(), - dismissedUpdateVersion: NullableString.optional(), - dismissedUnexpectedSignoutVersion: NullableString.optional(), - lastUpdateCheckAt: z.number().finite().nullable().optional(), - pendingUpdateNudgeId: NullableString.optional(), - dismissedUpdateNudgeId: NullableString.optional(), - // Why the predicate rather than an inline z.enum: an enum here is a copy of - // RELEASE_CHANNELS, and a copy that drifts silently rejects the new - // channel's override on its way here — the picker moves, nothing installs. - releaseChannelOverride: z.custom(isReleaseChannel).nullable().optional(), - notificationPermissionRequested: z.boolean().optional(), - updateReassuranceSeen: z.boolean().optional(), - osc52ClipboardDefaultOnNoticePending: z.boolean().optional(), - acknowledgedAgentsByPaneKey: z.record(z.string(), z.number().finite()).optional(), - activityClearedAtByPaneKey: z.record(z.string(), z.number().finite()).optional(), - manuallyUnreadTurnsByPaneKey: z.record(z.string(), z.number().finite()).optional(), - browserDefaultUrl: NullableString.optional(), - browserDefaultSearchEngine: z - .enum(['google', 'duckduckgo', 'bing', 'kagi']) - .nullable() - .optional(), - browserDefaultZoomLevel: z.number().finite().optional(), - browserKagiSessionLink: NullableString.optional(), - windowBounds: z - .object({ - x: z.number().finite(), - y: z.number().finite(), - width: z.number().finite(), - height: z.number().finite() - }) - .nullable() - .optional(), - windowMaximized: z.boolean().optional(), - _sortBySmartMigrated: z.boolean().optional(), - _inlineAgentsDefaultedForExperiment: z.boolean().optional(), - _inlineAgentsDefaultedForAllUsers: z.boolean().optional(), - trustedOrcaHooks: z.record(z.string(), z.unknown()).optional(), - setupScriptPromptDismissedRepoIds: StringArray.optional(), - // Why: one-shot dismissals the renderer writes through ui.set; each was a - // whole-payload rejection for paired clients while unlisted. - setupGuideSidebarDismissed: z.boolean().optional(), - setupGuideBrowserMilestoneMigrated: z.boolean().optional(), - setupGuideBrowserMilestoneLegacyComplete: z.boolean().optional(), - browserImportHintHidden: z.boolean().optional(), - mobileEmulatorTabIntroDismissed: z.boolean().optional(), - mobileEmulatorAgentSetupDismissed: z.boolean().optional(), - projectOrderManualDefaultNoticeDismissed: z.boolean().optional(), - usagePercentageDisplayChangeNoticeDismissed: z.boolean().optional(), - usageEmptyStateDismissed: z.boolean().optional(), - petVisible: z.boolean().optional(), - petId: z.string().optional(), - customPets: UnknownRecordArray.optional(), - petSize: z.number().finite().optional(), - sidekickVisible: z.boolean().optional(), - sidekickId: z.string().optional(), - customSidekicks: UnknownRecordArray.optional(), - sidekickSize: z.number().finite().optional(), - taskResumeState: TaskResumeState.optional(), - workspaceCleanup: WorkspaceCleanup.optional(), - featureTipsSeenIds: FeatureTipIds.optional(), - featureInteractions: FeatureInteractions.optional(), - contextualToursSeenIds: StringArray.optional(), - contextualToursAutoEligible: z.boolean().optional() - }) - .strict() - -export const UiUpdate = z - .object(tolerateUnknownValues(UiUpdateFields.shape)) - .strict() - .default({}) - .transform(omitUndefinedValues) +import type { UiUpdateFields } from '../../../../shared/rpc-contract/client-ui-params' +export { + FeatureInteractionIdParam, + UiUpdate +} from '../../../../shared/rpc-contract/client-ui-params' // The key/value parity assertions over this live in ui-state-schema-parity-checks.ts. export type UiUpdateFieldsSchema = typeof UiUpdateFields diff --git a/src/main/runtime/rpc/methods/client-ui-workspace-filter-fields.ts b/src/main/runtime/rpc/methods/client-ui-workspace-filter-fields.ts index d0239b03ec3..be6bc445f6e 100644 --- a/src/main/runtime/rpc/methods/client-ui-workspace-filter-fields.ts +++ b/src/main/runtime/rpc/methods/client-ui-workspace-filter-fields.ts @@ -1,11 +1 @@ -import { z } from 'zod' - -export const ClientUiWorkspaceFilterFields = { - hideDefaultBranchWorkspace: z.boolean().optional(), - hideAutomationGeneratedWorkspaces: z.boolean().optional(), - hideCliCreatedWorkspaces: z.boolean().optional(), - hideDetachedHeadWorkspaces: z.boolean().optional(), - hideWorkspacesFromOtherDevices: z.boolean().optional(), - alwaysShowDefaultBranchWorkspace: z.boolean().optional(), - filterRepoIds: z.array(z.string()).optional() -} +export { ClientUiWorkspaceFilterFields } from '../../../../shared/rpc-contract/client-ui-workspace-filter-fields-params' diff --git a/src/main/runtime/rpc/methods/clipboard.ts b/src/main/runtime/rpc/methods/clipboard.ts index e6b487d7761..e27b1cf1607 100644 --- a/src/main/runtime/rpc/methods/clipboard.ts +++ b/src/main/runtime/rpc/methods/clipboard.ts @@ -1,18 +1,18 @@ -import { z } from 'zod' import { defineMethod, type RpcContext, type RpcMethod } from '../core' import { saveClipboardImageBufferAsTempFile } from '../../../window/clipboard-image-temp-file' import { randomUUID } from 'node:crypto' -import { - CLIPBOARD_IMAGE_MAX_BASE64_CHARS, - CLIPBOARD_IMAGE_TOO_LARGE_ERROR -} from '../../../../shared/clipboard-image' import { recordMobileClipboardImagePath } from '../mobile-clipboard-image-provenance' - -const MAX_CLIPBOARD_IMAGE_BASE64_CHARS = CLIPBOARD_IMAGE_MAX_BASE64_CHARS -export const CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS = 512 * 1024 +import { + AbortImageUpload, + AppendImageUploadChunk, + CommitImageUpload, + SaveImageAsTempFile, + StartImageUpload, + isValidBase64 +} from '../../../../shared/rpc-contract/clipboard-params' +export { CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS } from '../../../../shared/rpc-contract/clipboard-params' export const CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT = 8 const CLIPBOARD_IMAGE_UPLOAD_TTL_MS = 5 * 60 * 1000 -const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/ type ClipboardImageUpload = { expectedBase64Length: number @@ -26,10 +26,6 @@ type ClipboardImageUpload = { const clipboardImageUploads = new Map() -function isValidBase64(value: string): boolean { - return value.length % 4 !== 1 && BASE64_PATTERN.test(value) -} - function pruneExpiredUploads(now = Date.now()): void { for (const [uploadId, upload] of clipboardImageUploads) { if (upload.expiresAt <= now) { @@ -99,58 +95,6 @@ function assertValidBase64Content(value: string): void { } } -function clipboardImageBase64Payload(maxChars: number, tooLargeMessage: string) { - return z.unknown().transform((value, ctx): string => { - if (typeof value !== 'string') { - ctx.addIssue({ code: 'custom', message: 'Missing image content' }) - return z.NEVER - } - if (value.length > maxChars) { - ctx.addIssue({ code: 'custom', message: tooLargeMessage }) - return z.NEVER - } - if (!isValidBase64(value)) { - ctx.addIssue({ code: 'custom', message: 'Clipboard image content must be base64' }) - return z.NEVER - } - return value - }) -} - -const SaveImageAsTempFile = z.object({ - contentBase64: clipboardImageBase64Payload( - MAX_CLIPBOARD_IMAGE_BASE64_CHARS, - CLIPBOARD_IMAGE_TOO_LARGE_ERROR - ), - connectionId: z.string().min(1).nullable().optional() -}) - -const StartImageUpload = z.object({ - expectedBase64Length: z - .number() - .int() - .nonnegative() - .max(MAX_CLIPBOARD_IMAGE_BASE64_CHARS, CLIPBOARD_IMAGE_TOO_LARGE_ERROR), - connectionId: z.string().min(1).nullable().optional() -}) - -const AppendImageUploadChunk = z.object({ - uploadId: z.string().min(1), - offset: z.number().int().nonnegative(), - contentBase64: clipboardImageBase64Payload( - CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS, - 'Clipboard image chunk is too large' - ) -}) - -const CommitImageUpload = z.object({ - uploadId: z.string().min(1) -}) - -const AbortImageUpload = z.object({ - uploadId: z.string().min(1) -}) - export const CLIPBOARD_METHODS: RpcMethod[] = [ defineMethod({ name: 'clipboard.saveImageAsTempFile', diff --git a/src/main/runtime/rpc/methods/computer-schemas.ts b/src/main/runtime/rpc/methods/computer-schemas.ts index e3ef7ca88f3..f949fa0459c 100644 --- a/src/main/runtime/rpc/methods/computer-schemas.ts +++ b/src/main/runtime/rpc/methods/computer-schemas.ts @@ -1,226 +1,15 @@ -import { z } from 'zod' -import { - computerUseClickModifiersValidationMessage, - computerUseHotkeyValidationMessage, - computerUsePressKeyValidationMessage -} from '../../../../shared/computer-use-key-spec' -import { - OptionalBoolean, - OptionalFiniteNumber, - OptionalString, - requiredString, - requiredStringAllowingEmpty -} from '../schemas' - -const OptionalNonNegativeInt = z.number().int().nonnegative().optional() -const OptionalPositiveInt = z.number().int().positive().optional() - -const ComputerTarget = z.object({ - app: requiredString('Missing app'), - session: OptionalString, - worktree: OptionalString -}) - -const ComputerObserveTargetBase = ComputerTarget.extend({ - noScreenshot: OptionalBoolean, - restoreWindow: OptionalBoolean, - windowId: OptionalNonNegativeInt, - windowIndex: OptionalNonNegativeInt -}) - -function validateWindowTarget( - value: { windowId?: number; windowIndex?: number }, - ctx: z.RefinementCtx -): void { - if (value.windowId !== undefined && value.windowIndex !== undefined) { - ctx.addIssue({ - code: 'custom', - message: 'Window targeting accepts either --window-id or --window-index, not both' - }) - } -} - -function validateComputerTarget( - value: { session?: string; worktree?: string; windowId?: number; windowIndex?: number }, - ctx: z.RefinementCtx -): void { - if (value.session !== undefined && value.worktree !== undefined) { - ctx.addIssue({ - code: 'custom', - message: 'Computer-use targeting accepts either session or worktree, not both' - }) - } - validateWindowTarget(value, ctx) -} - -export const ComputerObserveTarget = ComputerObserveTargetBase.superRefine(validateComputerTarget) - -export const ListApps = z.object({}).strict() - -export const ListWindows = z - .object({ - app: requiredString('Missing app') - }) - .strict() - -export const Click = ComputerObserveTargetBase.extend({ - elementIndex: OptionalNonNegativeInt, - x: OptionalFiniteNumber, - y: OptionalFiniteNumber, - clickCount: OptionalPositiveInt, - mouseButton: z.enum(['left', 'right', 'middle']).optional(), - modifiers: z.string().optional() -}).superRefine((value, ctx) => { - validateComputerTarget(value, ctx) - const hasElement = value.elementIndex !== undefined - const hasX = value.x !== undefined - const hasY = value.y !== undefined - if (!hasElement && !(hasX && hasY)) { - ctx.addIssue({ - code: 'custom', - message: 'Click requires --element-index or both --x and --y' - }) - } - if (hasX !== hasY) { - ctx.addIssue({ - code: 'custom', - message: 'Click coordinates require both --x and --y' - }) - } - if (hasElement && (hasX || hasY)) { - ctx.addIssue({ - code: 'custom', - message: 'Click accepts either --element-index or coordinate flags, not both' - }) - } - if (value.modifiers !== undefined) { - const message = computerUseClickModifiersValidationMessage(value.modifiers) - if (message) { - ctx.addIssue({ code: 'custom', message }) - } - } -}) - -export const PerformSecondaryAction = ComputerObserveTargetBase.extend({ - elementIndex: OptionalNonNegativeInt, - action: requiredString('Missing action') -}).superRefine((value, ctx) => { - validateComputerTarget(value, ctx) - if (value.elementIndex === undefined) { - ctx.addIssue({ code: 'custom', message: 'Missing element index' }) - } -}) - -export const Scroll = ComputerObserveTargetBase.extend({ - elementIndex: OptionalNonNegativeInt, - x: OptionalFiniteNumber, - y: OptionalFiniteNumber, - direction: z.enum(['up', 'down', 'left', 'right']), - pages: z.number().positive().optional() -}).superRefine((value, ctx) => { - validateComputerTarget(value, ctx) - const hasElement = value.elementIndex !== undefined - const hasX = value.x !== undefined - const hasY = value.y !== undefined - if (!hasElement && !(hasX && hasY)) { - ctx.addIssue({ - code: 'custom', - message: 'Scroll requires --element-index or both --x and --y' - }) - } - if (hasX !== hasY) { - ctx.addIssue({ - code: 'custom', - message: 'Scroll coordinates require both --x and --y' - }) - } - if (hasElement && (hasX || hasY)) { - ctx.addIssue({ - code: 'custom', - message: 'Scroll accepts either --element-index or coordinate flags, not both' - }) - } -}) - -export const Drag = ComputerObserveTargetBase.extend({ - fromElementIndex: OptionalNonNegativeInt, - toElementIndex: OptionalNonNegativeInt, - fromX: OptionalFiniteNumber, - fromY: OptionalFiniteNumber, - toX: OptionalFiniteNumber, - toY: OptionalFiniteNumber -}).superRefine((value, ctx) => { - validateComputerTarget(value, ctx) - const hasElementPair = value.fromElementIndex !== undefined && value.toElementIndex !== undefined - const hasPartialElementPair = - value.fromElementIndex !== undefined || value.toElementIndex !== undefined - const coordinateKeys = [value.fromX, value.fromY, value.toX, value.toY] - const hasCoordinatePair = coordinateKeys.every((coordinate) => coordinate !== undefined) - const hasPartialCoordinatePair = coordinateKeys.some((coordinate) => coordinate !== undefined) - if (hasElementPair && hasCoordinatePair) { - ctx.addIssue({ - code: 'custom', - message: 'Drag accepts either element indexes or coordinate flags, not both' - }) - } - if (!hasElementPair && !hasCoordinatePair) { - ctx.addIssue({ - code: 'custom', - message: 'Drag requires --from-element-index and --to-element-index, or all coordinate flags' - }) - } - if (hasPartialElementPair && !hasElementPair) { - ctx.addIssue({ - code: 'custom', - message: 'Drag element targeting requires both --from-element-index and --to-element-index' - }) - } - if (hasPartialCoordinatePair && !hasCoordinatePair) { - ctx.addIssue({ - code: 'custom', - message: 'Drag coordinates require --from-x, --from-y, --to-x, and --to-y' - }) - } -}) - -export const TypeText = ComputerObserveTargetBase.extend({ - text: requiredString('Missing text') -}).superRefine(validateComputerTarget) - -export const PressKey = ComputerObserveTargetBase.extend({ - key: requiredString('Missing key') -}).superRefine((value, ctx) => { - validateComputerTarget(value, ctx) - const message = computerUsePressKeyValidationMessage(value.key) - if (message) { - ctx.addIssue({ code: 'custom', message }) - } -}) - -export const Hotkey = ComputerObserveTargetBase.extend({ - key: requiredString('Missing key') -}).superRefine((value, ctx) => { - validateComputerTarget(value, ctx) - const message = computerUseHotkeyValidationMessage(value.key) - if (message) { - ctx.addIssue({ code: 'custom', message }) - } -}) - -export const ComputerPermissions = z.object({ - id: z.enum(['accessibility', 'screenshots']).optional() -}) - -export const PasteText = ComputerObserveTargetBase.extend({ - text: requiredString('Missing text') -}).superRefine(validateComputerTarget) - -export const SetValue = ComputerObserveTargetBase.extend({ - elementIndex: OptionalNonNegativeInt, - value: requiredStringAllowingEmpty('Missing value') -}).superRefine((value, ctx) => { - validateComputerTarget(value, ctx) - if (value.elementIndex === undefined) { - ctx.addIssue({ code: 'custom', message: 'Missing element index' }) - } -}) +export { + Click, + ComputerObserveTarget, + ComputerPermissions, + Drag, + Hotkey, + ListApps, + ListWindows, + PasteText, + PerformSecondaryAction, + PressKey, + Scroll, + SetValue, + TypeText +} from '../../../../shared/rpc-contract/computer-schemas-params' diff --git a/src/main/runtime/rpc/methods/computer.ts b/src/main/runtime/rpc/methods/computer.ts index 1f928b97afe..07459427bd4 100644 --- a/src/main/runtime/rpc/methods/computer.ts +++ b/src/main/runtime/rpc/methods/computer.ts @@ -1,4 +1,3 @@ -import { z } from 'zod' import { callComputerSidecarAction, callComputerSidecarCapabilities, @@ -23,6 +22,10 @@ import { SetValue, TypeText } from './computer-schemas' +import { + ComputerCapabilitiesParams, + ComputerPermissionsStatusParams +} from '../../../../shared/rpc-contract/computer-params' export function resetComputerSessionsForTest(): void { resetComputerSidecarForTest() @@ -31,7 +34,7 @@ export function resetComputerSessionsForTest(): void { export const COMPUTER_METHODS: RpcMethod[] = [ defineMethod({ name: 'computer.capabilities', - params: z.object({}), + params: ComputerCapabilitiesParams, handler: async () => { return await callComputerSidecarCapabilities() } @@ -54,7 +57,7 @@ export const COMPUTER_METHODS: RpcMethod[] = [ }), defineMethod({ name: 'computer.permissionsStatus', - params: z.object({}), + params: ComputerPermissionsStatusParams, handler: async () => { const { getComputerUsePermissionStatus } = await import('../../../computer/macos-computer-use-permissions') diff --git a/src/main/runtime/rpc/methods/emulator.ts b/src/main/runtime/rpc/methods/emulator.ts index 50354f790e4..00c239658d9 100644 --- a/src/main/runtime/rpc/methods/emulator.ts +++ b/src/main/runtime/rpc/methods/emulator.ts @@ -1,66 +1,26 @@ import { defineMethod, type RpcMethod } from '../core' import path from 'node:path' import { z } from 'zod' - -// Minimal schemas for emulator commands (loose for initial testing; can be tightened like browser-schemas). -const WorktreeParam = z.object({ worktree: z.string().optional() }).partial() - -const TapParams = z.object({ - x: z.number().min(0).max(1), - y: z.number().min(0).max(1), - device: z.string().optional(), - emulator: z.string().optional(), - worktree: z.string().optional() -}) - -const GesturePoint = z.object({ - edge: z.number().int().min(0).max(4).optional(), - type: z.enum(['begin', 'move', 'end']), - x: z.number().min(0).max(1), - y: z.number().min(0).max(1) -}) - -const GestureParams = z.object({ - points: z.array(GesturePoint).min(2).max(64), - device: z.string().optional(), - emulator: z.string().optional(), - worktree: z.string().optional() -}) - -const TypeParams = z.object({ - text: z.string(), - device: z.string().optional(), - emulator: z.string().optional(), - worktree: z.string().optional() -}) - -const ButtonParams = z.object({ - name: z.string(), - device: z.string().optional(), - emulator: z.string().optional(), - worktree: z.string().optional() -}) - -const RotateOrientation = z.enum([ - 'portrait', - 'portrait_upside_down', - 'landscape_left', - 'landscape_right' -]) - -const RotateParams = z.object({ - orientation: RotateOrientation, - device: z.string().optional(), - emulator: z.string().optional(), - worktree: z.string().optional() -}) - -const ExecParams = z.object({ - command: z.string(), - device: z.string().optional(), - emulator: z.string().optional(), - worktree: z.string().optional() -}) +import { + AttachParams, + AxParams, + ButtonParams, + EmulatorAvailabilityParams, + EmulatorListDevicesParams, + EmulatorListSimulatorsParams, + EmulatorUnregisterActiveParams, + ExecParams, + GestureParams, + KillParams, + LaunchParams, + ListParams, + LogcatParams, + PermissionsParams, + RotateParams, + ShutdownParams, + TapParams, + TypeParams +} from '../../../../shared/rpc-contract/emulator-params' const InstallParams = z.object({ path: z.string().refine((value) => path.isAbsolute(value), { @@ -72,89 +32,6 @@ const InstallParams = z.object({ worktree: z.string().optional() }) -const LaunchParams = z.object({ - package: z.string(), - activity: z.string().optional(), - device: z.string().optional(), - emulator: z.string().optional(), - worktree: z.string().optional() -}) - -const PermissionsParams = z - .object({ - op: z.enum(['grant', 'revoke', 'reset']), - package: z.string().optional(), - permission: z.string().optional(), - device: z.string().optional(), - emulator: z.string().optional(), - worktree: z.string().optional() - }) - .superRefine((value, ctx) => { - if (value.op === 'reset') { - if (value.package) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['package'], - message: 'package is not allowed for reset' - }) - } - if (value.permission) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['permission'], - message: 'permission is not allowed for reset' - }) - } - return - } - if (!value.package) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['package'], - message: 'package is required for grant/revoke' - }) - } - if (!value.permission) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['permission'], - message: 'permission is required for grant/revoke' - }) - } - }) - -const AxParams = z.object({ - device: z.string().optional(), - emulator: z.string().optional(), - worktree: z.string().optional() -}) - -const LogcatParams = z.object({ - lines: z.number().int().positive().optional(), - filters: z.array(z.string()).optional(), - device: z.string().optional(), - emulator: z.string().optional(), - worktree: z.string().optional() -}) - -const AttachParams = z.object({ - device: z.string().optional(), - worktree: z.string().optional(), - focus: z.boolean().optional() -}) - -const KillParams = z.object({ - device: z.string().optional(), - emulator: z.string().optional(), - worktree: z.string().optional() -}) - -const ShutdownParams = KillParams.extend({ - managedOnly: z.boolean().optional() -}) - -const ListParams = WorktreeParam - export const EMULATOR_METHODS: RpcMethod[] = [ defineMethod({ name: 'emulator.list', @@ -208,17 +85,17 @@ export const EMULATOR_METHODS: RpcMethod[] = [ }), defineMethod({ name: 'emulator.listSimulators', - params: z.object({ worktree: z.string().optional() }).partial(), + params: EmulatorListSimulatorsParams, handler: async (params, { runtime }) => runtime.emulatorListSimulators(params) }), defineMethod({ name: 'emulator.availability', - params: z.object({ worktree: z.string().optional() }).partial(), + params: EmulatorAvailabilityParams, handler: async (params, { runtime }) => runtime.emulatorAvailability(params) }), defineMethod({ name: 'emulator.listDevices', - params: z.object({ worktree: z.string().optional() }).partial(), + params: EmulatorListDevicesParams, handler: async (params, { runtime }) => runtime.emulatorListDevices(params) }), defineMethod({ @@ -248,7 +125,7 @@ export const EMULATOR_METHODS: RpcMethod[] = [ }), defineMethod({ name: 'emulator.unregisterActive', - params: z.object({ worktree: z.string().optional() }).partial(), + params: EmulatorUnregisterActiveParams, handler: async (params, { runtime }) => runtime.emulatorUnregisterActive(params) }) ] diff --git a/src/main/runtime/rpc/methods/files-mutation-methods.ts b/src/main/runtime/rpc/methods/files-mutation-methods.ts index 1add0232055..3c7e4231b96 100644 --- a/src/main/runtime/rpc/methods/files-mutation-methods.ts +++ b/src/main/runtime/rpc/methods/files-mutation-methods.ts @@ -1,14 +1,14 @@ -import { z } from 'zod' import { defineMethod, type RpcAnyMethod } from '../core' -import { FileOpen, WorktreeSelector } from './files-target-schemas' - -const RUNTIME_FILE_BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/ - -function isValidRuntimeFileBase64(value: unknown): value is string { - return ( - typeof value === 'string' && value.length % 4 !== 1 && RUNTIME_FILE_BASE64_PATTERN.test(value) - ) -} +import { + FileCommitUpload, + FileCopy, + FileDelete, + FileMutationOpen, + FileRename, + FileWrite, + FileWriteBase64, + FileWriteBase64Chunk +} from '../../../../shared/rpc-contract/files-mutation-params' type SshMutationParams = { expectedExecutionHostId?: string @@ -33,80 +33,6 @@ function sshMutationArguments( ] } -const FileMutationOpen = FileOpen.extend({ - expectedExecutionHostId: z.string().min(1).optional(), - expectedSshTargetId: z.string().min(1).optional(), - expectedSshConnectionGeneration: z.number().int().nonnegative().optional() -}) - -// Why: write content must be a real string. Coercing a missing/non-string value -// to '' silently truncated the target file to empty instead of erroring. An -// explicit '' is still accepted (writing an empty file is legitimate). -const FileWrite = FileMutationOpen.extend({ - content: z - .unknown() - .refine((v): v is string => typeof v === 'string', { message: 'Missing file content' }) -}) - -const FileWriteBase64 = FileMutationOpen.extend({ - contentBase64: z - .unknown() - .refine((v): v is string => typeof v === 'string', { message: 'Missing file content' }) - // Why: Buffer.from(..., 'base64') accepts malformed input by dropping - // invalid bytes, which can silently create empty or corrupt uploaded files. - .refine(isValidRuntimeFileBase64, 'File content must be base64') -}) - -const FileWriteBase64Chunk = FileWriteBase64.extend({ - append: z.boolean().optional() -}) - -const FileRename = WorktreeSelector.extend({ - expectedExecutionHostId: z.string().min(1).optional(), - expectedSshTargetId: z.string().min(1).optional(), - expectedSshConnectionGeneration: z.number().int().nonnegative().optional(), - oldRelativePath: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing source path')), - newRelativePath: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing destination path')) -}) - -const FileCopy = WorktreeSelector.extend({ - expectedExecutionHostId: z.string().min(1).optional(), - expectedSshTargetId: z.string().min(1).optional(), - expectedSshConnectionGeneration: z.number().int().nonnegative().optional(), - sourceRelativePath: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing source path')), - destinationRelativePath: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing destination path')) -}) - -const FileCommitUpload = WorktreeSelector.extend({ - expectedExecutionHostId: z.string().min(1).optional(), - expectedSshTargetId: z.string().min(1).optional(), - expectedSshConnectionGeneration: z.number().int().nonnegative().optional(), - tempRelativePath: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing temporary path')), - finalRelativePath: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing final path')) -}) - -const FileDelete = FileMutationOpen.extend({ - recursive: z.boolean().optional() -}) - export const FILE_MUTATION_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'files.write', diff --git a/src/main/runtime/rpc/methods/files-target-schemas.ts b/src/main/runtime/rpc/methods/files-target-schemas.ts index 6c546b3605e..945d3c6aa85 100644 --- a/src/main/runtime/rpc/methods/files-target-schemas.ts +++ b/src/main/runtime/rpc/methods/files-target-schemas.ts @@ -1,15 +1 @@ -import { z } from 'zod' - -export const WorktreeSelector = z.object({ - worktree: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing worktree selector')) -}) - -export const FileOpen = WorktreeSelector.extend({ - relativePath: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing relative path')) -}) +export { FileOpen, WorktreeSelector } from '../../../../shared/rpc-contract/files-target-params' diff --git a/src/main/runtime/rpc/methods/files-terminal-artifact-methods.ts b/src/main/runtime/rpc/methods/files-terminal-artifact-methods.ts index 08925e08689..cc343dd3ca1 100644 --- a/src/main/runtime/rpc/methods/files-terminal-artifact-methods.ts +++ b/src/main/runtime/rpc/methods/files-terminal-artifact-methods.ts @@ -1,24 +1,9 @@ -import { z } from 'zod' import { defineMethod, type RpcAnyMethod } from '../core' import { remoteFileContentBudget } from './files-remote-content-budget' -import { WorktreeSelector } from './files-target-schemas' - -const TerminalArtifactFile = WorktreeSelector.extend({ - grantId: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing terminal artifact grant')), - absolutePath: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing terminal artifact path')) -}) - -const TerminalArtifactFileWrite = TerminalArtifactFile.extend({ - content: z - .unknown() - .refine((v): v is string => typeof v === 'string', { message: 'Missing file content' }) -}) +import { + TerminalArtifactFile, + TerminalArtifactFileWrite +} from '../../../../shared/rpc-contract/files-terminal-artifact-params' export const FILE_TERMINAL_ARTIFACT_METHODS: RpcAnyMethod[] = [ defineMethod({ diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts index ef349a22f84..6f5cdbe4b34 100644 --- a/src/main/runtime/rpc/methods/files.ts +++ b/src/main/runtime/rpc/methods/files.ts @@ -1,114 +1,26 @@ -import { z } from 'zod' import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' import { runFileWatchStream } from './file-watch-stream-lifecycle' import { FILE_MUTATION_METHODS } from './files-mutation-methods' import { remoteFileContentBudget } from './files-remote-content-budget' -import { - QUICK_OPEN_REMOTE_QUERY_MAX_CODE_UNITS, - QUICK_OPEN_SEARCH_VERSION -} from '../../../../shared/quick-open-path-search' +import { QUICK_OPEN_SEARCH_VERSION } from '../../../../shared/quick-open-path-search' import { limitQuickOpenSearchReplyBySerializedBytes } from '../../../../shared/quick-open-transport-budget' import { FileOpen, WorktreeSelector } from './files-target-schemas' import { FILE_TERMINAL_ARTIFACT_METHODS } from './files-terminal-artifact-methods' +import { + DocPreviewFileRead, + FileListAll, + FileOpenDiff, + FilePathSearch, + FileReadChunk, + FileSearch, + FileTreePath, + FileUnwatch, + ResolveTerminalPath, + ServerDirectoryBrowse +} from '../../../../shared/rpc-contract/files-params' let filesWatchSubscriptionSeq = 0 -const FilePathSearch = WorktreeSelector.extend({ - query: z.string().max(QUICK_OPEN_REMOTE_QUERY_MAX_CODE_UNITS).default(''), - limit: z.number().int().positive().max(32).default(16), - excludePaths: z.array(z.string()).optional(), - mode: z.literal('quick-open').optional() -}) - -const ResolveTerminalPath = WorktreeSelector.extend({ - pathText: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing path text')), - terminal: z - .unknown() - .transform((v) => (typeof v === 'string' && v.length > 0 ? v : null)) - .nullable() - .optional(), - cwd: z - .unknown() - .transform((v) => (typeof v === 'string' && v.length > 0 ? v : null)) - .nullable() - .optional(), - crossWorkspace: z - .unknown() - .transform((v) => v === true) - .optional(), - nativeChatContext: z - .object({ - tabId: z.string().min(1), - sessionId: z.string().min(1) - }) - .optional() -}) - -const FileOpenDiff = FileOpen.extend({ - staged: z.boolean().optional() -}) - -const DocPreviewFileRead = FileOpen.extend({ - entryRelativePath: z.string().min(1), - implicitRootRelativePath: z.string().nullable(), - authorizedRootRelativePaths: z.array(z.string()) -}) - -const FileTreePath = WorktreeSelector.extend({ - relativePath: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string()) -}) - -const ServerDirectoryBrowse = z.object({ - path: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string()) -}) - -const FileReadChunk = FileOpen.extend({ - offset: z.number().int().nonnegative(), - length: z - .number() - .int() - .positive() - .max(512 * 1024) -}) - -const FileSearch = WorktreeSelector.extend({ - query: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing search query')), - caseSensitive: z.boolean().optional(), - wholeWord: z.boolean().optional(), - useRegex: z.boolean().optional(), - includePattern: z.string().optional(), - excludePattern: z.string().optional(), - maxResults: z.number().int().positive().optional() -}) - -// Why: `maxResults` is a new optional field (wire rule 1) — an older host strips it and keeps its -// own default. It existed only on the Electron IPC hop, so "the client names its cap and a full page -// means there is more" was true for desktop and merely incidental for web and mobile, which were -// saved by `remoteFileContentBudget` defaulting the cap inside `listRuntimeFiles`. -const FileListAll = WorktreeSelector.extend({ - excludePaths: z.array(z.string()).optional(), - maxResults: z.number().int().positive().optional() -}) - -const FileUnwatch = z.object({ - subscriptionId: z - .unknown() - .transform((value) => (typeof value === 'string' && value.length > 0 ? value : '')) - .pipe(z.string().min(1, 'Missing subscriptionId')) -}) - export const FILE_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'files.list', diff --git a/src/main/runtime/rpc/methods/folder-workspace.ts b/src/main/runtime/rpc/methods/folder-workspace.ts index aa39654d9f8..e0f780e710a 100644 --- a/src/main/runtime/rpc/methods/folder-workspace.ts +++ b/src/main/runtime/rpc/methods/folder-workspace.ts @@ -1,90 +1,11 @@ -import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' -import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' -import { isTuiAgent } from '../../../../shared/tui-agent-config' -import { TaskSourceContextSchema } from '../../../../shared/task-source-context-schema' -import { WorkspaceLinkedItemSchema } from '../../../../shared/workspace-linked-item-schema' -import { isWorkspaceLinkedItemSourceContextMatch } from '../../../../shared/workspace-linked-item-source-context' import { resolveRpcWorkspaceCreatorProvenance } from '../workspace-creator-context' -import { DiffCommentSchema } from '../../../../shared/diff-comment-schema' - -const FolderWorkspaceLinkedTask = WorkspaceLinkedItemSchema.nullable() - -function assertLinkedTaskSourceContextMatch( - value: { - linkedTask?: z.infer - linkedTaskSourceContext?: z.infer | null - }, - ctx: z.RefinementCtx -): void { - if ( - value.linkedTask && - value.linkedTaskSourceContext && - !isWorkspaceLinkedItemSourceContextMatch(value.linkedTask, value.linkedTaskSourceContext) - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Linked task and source context identities must match' - }) - } -} - -const FolderWorkspaceCreate = z - .object({ - projectGroupId: requiredString('Missing project group id'), - name: OptionalString, - folderPath: OptionalString.nullable().optional(), - connectionId: OptionalString.nullable().optional(), - linkedTask: FolderWorkspaceLinkedTask.optional(), - linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(), - createdWithAgent: z.string().refine(isTuiAgent).optional(), - pendingFirstAgentMessageRename: z.boolean().optional() - }) - .superRefine(assertLinkedTaskSourceContextMatch) - -const FolderWorkspaceUpdate = z.object({ - folderWorkspaceId: requiredString('Missing folder workspace id'), - updates: z - .object({ - name: OptionalString, - folderPath: OptionalString, - linkedTask: FolderWorkspaceLinkedTask.optional(), - linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(), - comment: z.string().optional(), - isArchived: z.boolean().optional(), - isUnread: z.boolean().optional(), - isPinned: z.boolean().optional(), - sortOrder: OptionalFiniteNumber, - manualOrder: OptionalFiniteNumber, - workspaceStatus: OptionalString, - createdWithAgent: z.string().refine(isTuiAgent).optional(), - pendingFirstAgentMessageRename: z.boolean().optional(), - firstAgentMessageRenameError: z.string().nullable().optional(), - lastActivityAt: OptionalFiniteNumber, - diffComments: z.array(DiffCommentSchema).optional() - }) - .superRefine(assertLinkedTaskSourceContextMatch) -}) - -const FolderWorkspaceSelector = z.object({ - folderWorkspaceId: requiredString('Missing folder workspace id') -}) - -const FolderWorkspacePathStatus = z.discriminatedUnion('scope', [ - z.object({ - scope: z.literal('folder-workspace'), - folderWorkspaceId: requiredString('Missing folder workspace id') - }), - z.object({ - scope: z.literal('project-group'), - projectGroupId: requiredString('Missing project group id') - }), - z.object({ - scope: z.literal('path'), - path: requiredString('Missing folder path'), - connectionId: OptionalString.nullable().optional() - }) -]) +import { + FolderWorkspaceCreate, + FolderWorkspacePathStatus, + FolderWorkspaceSelector, + FolderWorkspaceUpdate +} from '../../../../shared/rpc-contract/folder-workspace-params' export const FOLDER_WORKSPACE_METHODS: RpcMethod[] = [ defineMethod({ diff --git a/src/main/runtime/rpc/methods/git-admission-tier-schema.ts b/src/main/runtime/rpc/methods/git-admission-tier-schema.ts index 926aba5b6f9..c5366441b4b 100644 --- a/src/main/runtime/rpc/methods/git-admission-tier-schema.ts +++ b/src/main/runtime/rpc/methods/git-admission-tier-schema.ts @@ -1,11 +1 @@ -import { z } from 'zod' -import type { GitAdmissionTier } from '../../../git/command-runner/git-exec-options' - -export const OptionalGitAdmissionTier = z - .unknown() - .optional() - .transform((value): GitAdmissionTier | undefined => { - return value === 'interactive' || value === 'status' || value === 'background' - ? value - : undefined - }) +export { OptionalGitAdmissionTier } from '../../../../shared/rpc-contract/git-admission-tier-params' diff --git a/src/main/runtime/rpc/methods/git-params.ts b/src/main/runtime/rpc/methods/git-params.ts index f69b01cd053..ad80955ea76 100644 --- a/src/main/runtime/rpc/methods/git-params.ts +++ b/src/main/runtime/rpc/methods/git-params.ts @@ -1,271 +1,25 @@ -import { z } from 'zod' -import { OptionalGitAdmissionTier } from './git-admission-tier-schema' - -export const WorktreeSelector = z.object({ - worktree: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing worktree selector')) -}) - -export const GitStatusParams = WorktreeSelector.extend({ - admissionTier: OptionalGitAdmissionTier, - includeIgnored: z.boolean().optional(), - includeLineStats: z.boolean().optional(), - bypassEffectiveUpstreamNegativeCache: z.boolean().optional(), - reuseLineStats: z.boolean().optional(), - // Shape is re-validated host-side before it reaches a git argv. - branchLineTotalMergeBase: z.string().optional() -}) - -export const GitCheckIgnored = WorktreeSelector.extend({ - paths: z.array(z.string().min(1, 'Missing path')).max(2000) -}) - -export const GitSubmoduleStatus = WorktreeSelector.extend({ - submodulePath: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe( - z - .string() - .min(1, 'Missing submodule path') - // Why: never let a submodule path be parsed as a git flag (arg injection). - .refine((value) => !value.startsWith('-'), 'Submodule path must not start with -') - ), - // Why: submodule expansion is requested from a Source Control row; the row - // area determines whether the gitlink range is HEAD->index or index->worktree. - area: z.enum(['staged', 'unstaged', 'untracked']).optional() -}) - -export const GitFilePath = WorktreeSelector.extend({ - filePath: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing file path')) -}) - -export const GitDiff = GitFilePath.extend({ - staged: z.boolean(), - compareAgainstHead: z.boolean().optional() -}) - -export const GitBranchCompare = WorktreeSelector.extend({ - admissionTier: OptionalGitAdmissionTier, - baseRef: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe( - z - .string() - .min(1, 'Missing base ref') - .refine((value) => !value.startsWith('-'), 'Base ref must not start with -') - ) -}) - -const FullGitObjectId = z - .string() - .regex(/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/, 'Expected a full git object id') - -export const GitCommitCompare = WorktreeSelector.extend({ - commitId: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(FullGitObjectId) -}) - -export const GitHistory = WorktreeSelector.extend({ - limit: z.number().int().min(1).max(200).optional(), - baseRef: z.string().nullable().optional() -}) - -export const GitBranchDiff = GitFilePath.extend({ - compare: z.object({ - baseRef: z.string().optional(), - baseOid: FullGitObjectId.optional(), - headOid: FullGitObjectId, - mergeBase: FullGitObjectId - }), - oldPath: z.string().optional() -}) - -export const GitCommitDiff = GitFilePath.extend({ - commitOid: FullGitObjectId, - parentOid: FullGitObjectId.nullable().optional(), - oldPath: z.string().optional() -}) - -export const GitCommit = WorktreeSelector.extend({ - message: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing commit message')) -}) - -const CommitMessageModelCapability = z.object({ - id: z.string(), - label: z.string(), - thinkingLevels: z.array(z.object({ id: z.string(), label: z.string() })).optional(), - defaultThinkingLevel: z.string().optional() -}) - -const CommitMessageAiSettings = z.object({ - enabled: z.boolean(), - agentId: z.string().nullable(), - selectedModelByAgent: z.record(z.string(), z.string()), - selectedModelByAgentByHost: z.record(z.string(), z.record(z.string(), z.string())).optional(), - discoveredModelsByAgent: z.record(z.string(), z.array(CommitMessageModelCapability)).optional(), - discoveredModelsByAgentByHost: z - .record(z.string(), z.record(z.string(), z.array(CommitMessageModelCapability))) - .optional(), - selectedThinkingByModel: z.record(z.string(), z.string()), - customPrompt: z.string(), - customAgentCommand: z.string() -}) - -const SourceControlAiSettings = CommitMessageAiSettings.omit({ customPrompt: true }).extend({ - actions: z - .record( - z.string(), - z.object({ - agentId: z.string().nullable().optional(), - commandInputTemplate: z.string().optional(), - agentArgs: z.string().optional() - }) - ) - .optional(), - instructionsByOperation: z.record(z.string(), z.string()).optional(), - modelOverridesByOperation: z - .record( - z.string(), - z.object({ - selectedModelByAgent: z.record(z.string(), z.string()).optional(), - selectedModelByAgentByHost: z - .record(z.string(), z.record(z.string(), z.string())) - .optional(), - selectedThinkingByModel: z.record(z.string(), z.string()).optional() - }) - ) - .optional(), - prCreationDefaults: z - .object({ - draft: z.boolean().optional(), - useTemplate: z.boolean().optional(), - generateDetailsOnOpen: z.boolean().optional(), - openAfterCreate: z.boolean().optional() - }) - .optional(), - launchActionDefaults: z - .record( - z.string(), - z.object({ - agentId: z.string().nullable().optional(), - commandInputTemplate: z.string().optional(), - agentArgs: z.string().optional() - }) - ) - .optional() -}) - -const ResolvedSourceControlAiGenerationParams = z.object({ - agentId: z.string(), - model: z.string(), - thinkingLevel: z.string().optional(), - customPrompt: z.string().optional(), - commandInputTemplate: z.string().optional(), - agentArgs: z.string().optional(), - customAgentCommand: z.string().optional(), - agentCommandOverride: z.string().optional() -}) - -export const GitGenerateCommitMessage = WorktreeSelector.extend({ - commitMessageAi: CommitMessageAiSettings.optional(), - sourceControlAi: SourceControlAiSettings.optional(), - sourceControlAiResolvedParams: ResolvedSourceControlAiGenerationParams.optional(), - agentCmdOverrides: z.record(z.string(), z.string()).optional(), - commitMessageDiscoveryHostKey: z.string().optional() -}) - -export const GitDiscoverCommitMessageModels = WorktreeSelector.extend({ - agentId: z.string().min(1, 'Missing agent id'), - agentCmdOverrides: z.record(z.string(), z.string()).optional() -}) - -export const GitGeneratePullRequestFields = GitGenerateCommitMessage.extend({ - base: z.string().min(1, 'Missing base branch'), - title: z.string(), - body: z.string(), - draft: z.boolean(), - provider: z - .enum(['github', 'gitlab', 'bitbucket', 'azure-devops', 'gitea', 'unsupported']) - .optional(), - useTemplate: z.boolean().optional() -}) - -export const GitBulkPaths = WorktreeSelector.extend({ - filePaths: z.array(z.string().min(1, 'Missing file path')) -}) - -const GitPushTargetParam = z.object({ - remoteName: z.string(), - branchName: z.string(), - remoteUrl: z.string().optional(), - remoteCreated: z.boolean().optional() -}) - -export const GitPush = WorktreeSelector.extend({ - publish: z.boolean().optional(), - forceWithLease: z.boolean().optional(), - pushTarget: GitPushTargetParam.optional() -}) - -export const GitTargetedRemote = WorktreeSelector.extend({ - pushTarget: GitPushTargetParam.optional() -}) - -export const GitForkSync = WorktreeSelector.extend({ - expectedUpstream: z.object({ - owner: z.string().trim().min(1), - repo: z.string().trim().min(1) - }) -}) - -export const GitRebaseFromBase = WorktreeSelector.extend({ - baseRef: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe( - z - .string() - .min(1, 'Missing base ref') - .refine((value) => !value.startsWith('-'), 'Base ref must not start with -') - ) -}) - -export const GitCheckout = WorktreeSelector.extend({ - branch: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe( - z - .string() - .min(1, 'Missing branch') - // Why: never let a branch arg be parsed as a git flag (arg injection). - .refine((value) => !value.startsWith('-'), 'Branch must not start with -') - ) -}) - -export const GitRemoteFileUrl = WorktreeSelector.extend({ - relativePath: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing relative path')), - line: z.number().int().min(1) -}) - -export const GitRemoteCommitUrl = WorktreeSelector.extend({ - sha: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(FullGitObjectId) -}) +export { + GitBranchCompare, + GitBranchDiff, + GitBulkPaths, + GitCheckIgnored, + GitCheckout, + GitCommit, + GitCommitCompare, + GitCommitDiff, + GitDiff, + GitDiscoverCommitMessageModels, + GitFilePath, + GitForkSync, + GitGenerateCommitMessage, + GitGeneratePullRequestFields, + GitHistory, + GitPush, + GitRebaseFromBase, + GitRemoteCommitUrl, + GitRemoteFileUrl, + GitStatusParams, + GitSubmoduleStatus, + GitTargetedRemote, + WorktreeSelector +} from '../../../../shared/rpc-contract/git-params' diff --git a/src/main/runtime/rpc/methods/github-issue-methods.ts b/src/main/runtime/rpc/methods/github-issue-methods.ts index 75eb75c81b6..40ed162f28b 100644 --- a/src/main/runtime/rpc/methods/github-issue-methods.ts +++ b/src/main/runtime/rpc/methods/github-issue-methods.ts @@ -1,31 +1,10 @@ -import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' -import { requiredString } from '../schemas' -import { IssueUpdate } from './github-issue-update-schema' -import { RepoSelector, SlugRepo } from './github-repo-target-schemas' - -const Issue = RepoSelector.extend({ - number: z.number().int().positive() -}) - -const CreateIssue = RepoSelector.extend({ - title: requiredString('Missing title'), - body: z.string(), - labels: z.array(z.string()).optional(), - assignees: z.array(z.string()).optional() -}) - -const UpdateIssue = RepoSelector.extend({ - number: z.number().int().positive(), - updates: IssueUpdate -}) - -const IssueComment = RepoSelector.extend({ - number: z.number().int().positive(), - body: requiredString('Comment body required'), - type: z.enum(['issue', 'pr']).optional(), - prRepo: SlugRepo.nullable().optional() -}) +import { + CreateIssue, + Issue, + IssueComment, + UpdateIssue +} from '../../../../shared/rpc-contract/github-issue-params' export const GITHUB_ISSUE_METHODS: RpcMethod[] = [ defineMethod({ diff --git a/src/main/runtime/rpc/methods/github-issue-update-schema.ts b/src/main/runtime/rpc/methods/github-issue-update-schema.ts index 7b3020e91b5..6a9f860113b 100644 --- a/src/main/runtime/rpc/methods/github-issue-update-schema.ts +++ b/src/main/runtime/rpc/methods/github-issue-update-schema.ts @@ -1,13 +1 @@ -import { z } from 'zod' -import { OptionalString } from '../schemas' - -// Why: repo-selector and slug-addressed issue updates must accept the identical field set. -export const IssueUpdate = z.object({ - state: z.enum(['open', 'closed']).optional(), - title: OptionalString, - body: OptionalString, - addLabels: z.array(z.string()).optional(), - removeLabels: z.array(z.string()).optional(), - addAssignees: z.array(z.string()).optional(), - removeAssignees: z.array(z.string()).optional() -}) +export { IssueUpdate } from '../../../../shared/rpc-contract/github-issue-update-params' diff --git a/src/main/runtime/rpc/methods/github-project-methods.ts b/src/main/runtime/rpc/methods/github-project-methods.ts index ce70c200218..4cd2641b11a 100644 --- a/src/main/runtime/rpc/methods/github-project-methods.ts +++ b/src/main/runtime/rpc/methods/github-project-methods.ts @@ -1,135 +1,26 @@ -import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' -import { OptionalString, requiredString } from '../schemas' -import { IssueUpdate } from './github-issue-update-schema' import { SlugRepo } from './github-repo-target-schemas' - -const SlugAssignableUsers = SlugRepo.extend({ - seedLogins: z.array(z.string()).optional() -}) - -const ProjectOwnerType = z.enum(['organization', 'user']) - -const ProjectViewTable = z.object({ - owner: requiredString('Missing owner'), - // Why: Enterprise host identity must survive RPC parsing; Zod strips - // undeclared fields before the runtime can host-qualify gh requests. - host: OptionalString, - ownerType: ProjectOwnerType, - projectNumber: z.number().int().positive(), - viewId: OptionalString, - viewNumber: z.number().int().positive().optional(), - viewName: OptionalString, - queryOverride: OptionalString -}) - -const ProjectWorkItemDetailsBySlug = SlugRepo.extend({ - number: z.number().int().positive(), - type: z.enum(['issue', 'pr']) -}) - -const ProjectRef = z.object({ - input: requiredString('Missing project reference'), - // Why: Enterprise host identity must survive RPC parsing; Zod strips - // undeclared fields before the runtime can host-qualify gh requests. - host: OptionalString -}) - -const ProjectViews = z.object({ - owner: requiredString('Missing owner'), - // Why: Enterprise host identity must survive RPC parsing; Zod strips - // undeclared fields before the runtime can host-qualify gh requests. - host: OptionalString, - ownerType: ProjectOwnerType, - projectNumber: z.number().int().positive() -}) - -const ProjectItemField = z.object({ - projectId: requiredString('Missing project ID'), - // Why: Enterprise host identity must survive RPC parsing; Zod strips - // undeclared fields before the runtime can host-qualify gh requests. - host: OptionalString, - itemId: requiredString('Missing item ID'), - fieldId: requiredString('Missing field ID'), - value: z.any() -}) - -const ClearProjectItemField = z.object({ - projectId: requiredString('Missing project ID'), - // Why: Enterprise host identity must survive RPC parsing; Zod strips - // undeclared fields before the runtime can host-qualify gh requests. - host: OptionalString, - itemId: requiredString('Missing item ID'), - fieldId: requiredString('Missing field ID') -}) - -const SlugIssueUpdate = z.object({ - owner: requiredString('Missing owner'), - repo: requiredString('Missing repo'), - // Why: Enterprise host identity must survive RPC parsing; Zod strips - // undeclared fields before the runtime can host-qualify gh requests. - host: OptionalString, - number: z.number().int().positive(), - updates: IssueUpdate -}) - -const SlugPullRequestUpdate = z.object({ - owner: requiredString('Missing owner'), - repo: requiredString('Missing repo'), - // Why: Enterprise host identity must survive RPC parsing; Zod strips - // undeclared fields before the runtime can host-qualify gh requests. - host: OptionalString, - number: z.number().int().positive(), - updates: z.object({ - state: z.enum(['open', 'closed']).optional(), - title: OptionalString, - body: OptionalString - }) -}) - -const SlugIssueTypeUpdate = z.object({ - owner: requiredString('Missing owner'), - repo: requiredString('Missing repo'), - // Why: Enterprise host identity must survive RPC parsing; Zod strips - // undeclared fields before the runtime can host-qualify gh requests. - host: OptionalString, - number: z.number().int().positive(), - issueTypeId: z.string().nullable() -}) - -const SlugIssueComment = z.object({ - owner: requiredString('Missing owner'), - repo: requiredString('Missing repo'), - // Why: Enterprise host identity must survive RPC parsing; Zod strips - // undeclared fields before the runtime can host-qualify gh requests. - host: OptionalString, - number: z.number().int().positive(), - body: requiredString('Comment body required') -}) - -const SlugIssueCommentEdit = z.object({ - owner: requiredString('Missing owner'), - repo: requiredString('Missing repo'), - // Why: Enterprise host identity must survive RPC parsing; Zod strips - // undeclared fields before the runtime can host-qualify gh requests. - host: OptionalString, - commentId: z.number().int().positive(), - body: requiredString('Comment body required') -}) - -const SlugIssueCommentDelete = z.object({ - owner: requiredString('Missing owner'), - repo: requiredString('Missing repo'), - // Why: Enterprise host identity must survive RPC parsing; Zod strips - // undeclared fields before the runtime can host-qualify gh requests. - host: OptionalString, - commentId: z.number().int().positive() -}) +import { + ClearProjectItemField, + GithubProjectListAccessibleParams, + ProjectItemField, + ProjectRef, + ProjectViewTable, + ProjectViews, + ProjectWorkItemDetailsBySlug, + SlugAssignableUsers, + SlugIssueComment, + SlugIssueCommentDelete, + SlugIssueCommentEdit, + SlugIssueTypeUpdate, + SlugIssueUpdate, + SlugPullRequestUpdate +} from '../../../../shared/rpc-contract/github-project-params' export const GITHUB_PROJECT_METHODS: RpcMethod[] = [ defineMethod({ name: 'github.project.listAccessible', - params: z.object({ host: OptionalString }), + params: GithubProjectListAccessibleParams, handler: async (params, { runtime }) => runtime.listGitHubProjects(params) }), defineMethod({ diff --git a/src/main/runtime/rpc/methods/github-pull-request-methods.ts b/src/main/runtime/rpc/methods/github-pull-request-methods.ts index 958e08c439a..f7e66d41008 100644 --- a/src/main/runtime/rpc/methods/github-pull-request-methods.ts +++ b/src/main/runtime/rpc/methods/github-pull-request-methods.ts @@ -1,83 +1,15 @@ -import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' -import { OptionalString, requiredString } from '../schemas' -import { RepoSelector, SlugRepo } from './github-repo-target-schemas' -import type { GitHubPRRefreshReason } from '../../../../shared/github/pull-request-refresh-types' - -const OptionalPRRefreshReason = z - .unknown() - .optional() - .transform((value): GitHubPRRefreshReason | undefined => { - return value === 'visible' || - value === 'active' || - value === 'post-push' || - value === 'manual' || - value === 'swr' - ? value - : undefined - }) - -const PrForBranch = RepoSelector.extend({ - branch: requiredString('Missing branch'), - reason: OptionalPRRefreshReason, - linkedPRNumber: z.number().int().positive().nullable().optional(), - fallbackPRNumber: z.number().int().positive().nullable().optional(), - acceptMergedFallbackPR: z.boolean().optional(), - currentHeadOid: z.string().nullable().optional() -}) - -const PullRequest = RepoSelector.extend({ - prNumber: z.number().int().positive(), - noCache: z.boolean().optional(), - prRepo: SlugRepo.nullable().optional() -}) - -const PRCommentReaction = RepoSelector.extend({ - reactionSubjectId: requiredString('Missing reaction subject ID'), - content: z.enum(['+1', '-1', 'laugh', 'confused', 'heart', 'hooray', 'rocket', 'eyes']), - reacted: z.boolean(), - prRepo: SlugRepo.nullable().optional() -}) - -const PullRequestChecks = PullRequest.extend({ - headSha: OptionalString -}) - -const PullRequestCheckDetails = RepoSelector.extend({ - checkRunId: z.number().int().positive().optional(), - workflowRunId: z.number().int().positive().optional(), - checkName: OptionalString, - url: OptionalString.nullable().optional(), - prRepo: SlugRepo.nullable().optional() -}) - -const RerunPullRequestChecks = PullRequest.extend({ - headSha: OptionalString, - failedOnly: z.boolean().optional() -}) - -const PullRequestFileContents = RepoSelector.extend({ - prNumber: z.number().int().positive(), - prRepo: SlugRepo.nullable().optional(), - path: requiredString('Missing file path'), - oldPath: OptionalString, - status: z.enum(['added', 'removed', 'modified', 'renamed', 'copied', 'changed', 'unchanged']), - headSha: requiredString('Missing head SHA'), - baseSha: requiredString('Missing base SHA') -}) - -const PullRequestFileViewed = RepoSelector.extend({ - prRepo: SlugRepo.nullable().optional(), - pullRequestId: requiredString('Missing pull request ID'), - path: requiredString('Missing file path'), - viewed: z.boolean() -}) - -const ReviewThread = RepoSelector.extend({ - prRepo: SlugRepo.nullable().optional(), - threadId: requiredString('Missing thread ID'), - resolve: z.boolean() -}) +import { + PRCommentReaction, + PrForBranch, + PullRequest, + PullRequestCheckDetails, + PullRequestChecks, + PullRequestFileContents, + PullRequestFileViewed, + RerunPullRequestChecks, + ReviewThread +} from '../../../../shared/rpc-contract/github-pull-request-params' export const GITHUB_PULL_REQUEST_METHODS: RpcMethod[] = [ defineMethod({ diff --git a/src/main/runtime/rpc/methods/github-pull-request-update-methods.ts b/src/main/runtime/rpc/methods/github-pull-request-update-methods.ts index 4d34b89420e..ca5bdcabbab 100644 --- a/src/main/runtime/rpc/methods/github-pull-request-update-methods.ts +++ b/src/main/runtime/rpc/methods/github-pull-request-update-methods.ts @@ -1,80 +1,16 @@ -import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' -import { OptionalString, requiredString } from '../schemas' -import { RepoSelector, SlugRepo } from './github-repo-target-schemas' - -const UpdatePrTitle = RepoSelector.extend({ - prNumber: z.number().int().positive(), - title: requiredString('Missing title'), - prRepo: SlugRepo.nullable().optional() -}) - -const UpdatePr = RepoSelector.extend({ - prNumber: z.number().int().positive(), - updates: z.object({ - title: OptionalString, - body: z.string().optional() - }), - prRepo: SlugRepo.nullable().optional() -}) - -const MergePr = RepoSelector.extend({ - prNumber: z.number().int().positive(), - method: z.enum(['merge', 'squash', 'rebase']).optional(), - prRepo: SlugRepo.nullable().optional() -}) - -const SetPrAutoMerge = RepoSelector.extend({ - prNumber: z.number().int().positive(), - enabled: z.boolean(), - method: z.enum(['merge', 'squash', 'rebase']).optional(), - prRepo: SlugRepo.nullable().optional() -}) - -const UpdatePrState = RepoSelector.extend({ - prNumber: z.number().int().positive(), - prRepo: SlugRepo.nullable().optional(), - updates: z.object({ - state: z.enum(['open', 'closed']) - }) -}) - -const MarkPrReadyForReview = RepoSelector.extend({ - prNumber: z.number().int().positive(), - prRepo: SlugRepo.nullable().optional() -}) - -const RequestPrReviewers = RepoSelector.extend({ - prNumber: z.number().int().positive(), - prRepo: SlugRepo.nullable().optional(), - reviewers: z.array(z.string()).min(1) -}) - -const RemovePrReviewers = RepoSelector.extend({ - prNumber: z.number().int().positive(), - prRepo: SlugRepo.nullable().optional(), - reviewers: z.array(z.string()).min(1) -}) - -const PRReviewComment = RepoSelector.extend({ - prNumber: z.number().int().positive(), - prRepo: SlugRepo.nullable().optional(), - commitId: requiredString('Missing PR head SHA'), - path: requiredString('File path required'), - line: z.number().int().positive(), - startLine: z.number().int().positive().optional(), - body: requiredString('Comment body required') -}) - -const PRReviewCommentReply = RepoSelector.extend({ - prNumber: z.number().int().positive(), - commentId: z.number().int().positive(), - body: requiredString('Comment body required'), - threadId: OptionalString, - path: OptionalString, - line: z.number().int().positive().optional(), - prRepo: SlugRepo.nullable().optional() -}) +import { + MarkPrReadyForReview, + MergePr, + PRReviewComment, + PRReviewCommentReply, + RemovePrReviewers, + RequestPrReviewers, + SetPrAutoMerge, + UpdatePr, + UpdatePrState, + UpdatePrTitle +} from '../../../../shared/rpc-contract/github-pull-request-update-params' export const GITHUB_PULL_REQUEST_UPDATE_METHODS: RpcMethod[] = [ defineMethod({ diff --git a/src/main/runtime/rpc/methods/github-repo-target-schemas.ts b/src/main/runtime/rpc/methods/github-repo-target-schemas.ts index 3ea8b688910..06d47d47d9b 100644 --- a/src/main/runtime/rpc/methods/github-repo-target-schemas.ts +++ b/src/main/runtime/rpc/methods/github-repo-target-schemas.ts @@ -1,14 +1 @@ -import { z } from 'zod' -import { OptionalString, requiredString } from '../schemas' - -export const RepoSelector = z.object({ - repo: requiredString('Missing repo selector') -}) - -export const SlugRepo = z.object({ - owner: requiredString('Missing owner'), - repo: requiredString('Missing repo'), - // Why: Enterprise host identity must survive RPC parsing; Zod strips - // undeclared fields before the runtime can host-qualify gh requests. - host: OptionalString -}) +export { RepoSelector, SlugRepo } from '../../../../shared/rpc-contract/github-repo-target-params' diff --git a/src/main/runtime/rpc/methods/github-repo-work-item-methods.ts b/src/main/runtime/rpc/methods/github-repo-work-item-methods.ts index 9602ba6cb70..d4722ec1466 100644 --- a/src/main/runtime/rpc/methods/github-repo-work-item-methods.ts +++ b/src/main/runtime/rpc/methods/github-repo-work-item-methods.ts @@ -1,43 +1,14 @@ -import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' -import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' import { RepoSelector } from './github-repo-target-schemas' - -const WorkItemsList = RepoSelector.extend({ - limit: OptionalFiniteNumber, - query: OptionalString, - page: z.number().int().positive().optional(), - noCache: z.boolean().optional() -}) - -const IssuesList = RepoSelector.extend({ - limit: OptionalFiniteNumber -}) - -const WorkItem = RepoSelector.extend({ - number: z.number().int().positive(), - type: z.enum(['issue', 'pr']).optional() -}) - -const WorkItemByOwnerRepo = RepoSelector.extend({ - owner: requiredString('Missing owner'), - ownerRepo: requiredString('Missing repo'), - // Why: Enterprise host identity must survive RPC parsing; Zod strips - // undeclared fields before the runtime can host-qualify gh requests. - host: OptionalString, - number: z.number().int().positive(), - type: z.enum(['issue', 'pr']) -}) - -const WorkItemDetails = WorkItem - -const WorkItemsCount = RepoSelector.extend({ - query: OptionalString -}) - -const RateLimit = z.object({ - force: z.boolean().optional() -}) +import { + IssuesList, + RateLimit, + WorkItem, + WorkItemByOwnerRepo, + WorkItemDetails, + WorkItemsCount, + WorkItemsList +} from '../../../../shared/rpc-contract/github-repo-work-item-params' export const GITHUB_REPO_WORK_ITEM_METHODS: RpcMethod[] = [ defineMethod({ diff --git a/src/main/runtime/rpc/methods/gitlab.ts b/src/main/runtime/rpc/methods/gitlab.ts index ac8fcad6991..ba840447cfd 100644 --- a/src/main/runtime/rpc/methods/gitlab.ts +++ b/src/main/runtime/rpc/methods/gitlab.ts @@ -1,154 +1,27 @@ -import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' -import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' import { normalizeGitLabIssueListArgs } from '../../../gitlab/gitlab-preload-args' import { toGitLabJobLogExcerptResult } from '../../../../shared/gitlab-job-log-excerpt' - -const RepoSelector = z.object({ - repo: requiredString('Missing repo selector') -}) - -const EmptyParams = z.object({}).optional().default({}) -const GitLabRateLimit = z - .object({ - force: z.boolean().optional(), - host: OptionalString - }) - .optional() - .default({}) - -// nullish, not optional: renderer callers normalise a missing ref to `null` -// (`item.projectRef ?? null`), which a bare `.optional()` would reject outright. -const GitLabProjectRef = z - .object({ - host: requiredString('Missing GitLab host'), - path: requiredString('Missing GitLab project path') - }) - .nullish() - -const WorkItemsList = RepoSelector.extend({ - state: z.enum(['opened', 'merged', 'closed', 'all']).optional(), - page: OptionalFiniteNumber, - perPage: OptionalFiniteNumber, - query: OptionalString -}) - -const IssuesList = RepoSelector.extend({ - state: z.unknown().optional(), - assignee: OptionalString, - limit: OptionalFiniteNumber, - page: OptionalFiniteNumber -}) - -const CreateIssue = RepoSelector.extend({ - title: requiredString('Missing title'), - body: z.string() -}) - -const IssueUpdate = z.object({ - state: z.enum(['opened', 'closed']).optional(), - title: z.string().optional(), - body: z.string().optional(), - addLabels: z.array(z.string()).optional(), - removeLabels: z.array(z.string()).optional(), - addAssignees: z.array(z.string()).optional(), - removeAssignees: z.array(z.string()).optional() -}) - -const UpdateIssue = RepoSelector.extend({ - number: z.number().int().positive(), - updates: IssueUpdate, - projectRef: GitLabProjectRef -}) - -const UpdateMrState = RepoSelector.extend({ - iid: z.number().int().positive(), - state: z.enum(['opened', 'closed']), - projectRef: GitLabProjectRef -}) - -const UpdateMr = RepoSelector.extend({ - iid: z.number().int().positive(), - updates: z.object({ - title: z.string().optional(), - body: z.string().optional(), - addLabels: z.array(z.string()).optional(), - removeLabels: z.array(z.string()).optional(), - readyForReview: z.literal(true).optional() - }), - projectRef: GitLabProjectRef -}) - -const UpdateMrReviewers = RepoSelector.extend({ - iid: z.number().int().positive(), - reviewerIds: z.array(z.number().int().nonnegative()), - projectRef: GitLabProjectRef -}) - -const MergeMr = RepoSelector.extend({ - iid: z.number().int().positive(), - method: z.enum(['merge', 'squash', 'rebase']).optional(), - projectRef: GitLabProjectRef -}) - -const AddIssueComment = RepoSelector.extend({ - number: z.number().int().positive(), - body: requiredString('Comment body is required'), - projectRef: GitLabProjectRef -}) - -const AddMRComment = RepoSelector.extend({ - iid: z.number().int().positive(), - body: requiredString('Comment body is required'), - projectRef: GitLabProjectRef -}) - -const AddMRInlineComment = RepoSelector.extend({ - iid: z.number().int().positive(), - input: z.object({ - body: requiredString('Comment body is required'), - path: requiredString('File path is required'), - oldPath: z.string().optional(), - line: z.number().int().positive(), - baseSha: requiredString('Base SHA is required'), - startSha: requiredString('Start SHA is required'), - headSha: requiredString('Head SHA is required') - }), - projectRef: GitLabProjectRef -}) - -const ResolveMRDiscussion = RepoSelector.extend({ - iid: z.number().int().positive(), - discussionId: requiredString('Discussion id is required'), - resolved: z.boolean(), - projectRef: GitLabProjectRef -}) - -const JobTrace = RepoSelector.extend({ - jobId: z.number().int().positive(), - projectRef: GitLabProjectRef, - // Why: raw CI traces routinely exceed the 1 MB transport frame cap, so callers - // that only render an excerpt ask main to bound it before it crosses the wire. - logExcerpt: z.boolean().optional() -}) - -const RetryJob = RepoSelector.extend({ - jobId: z.number().int().positive(), - projectRef: GitLabProjectRef -}) - -const WorkItemDetails = RepoSelector.extend({ - iid: z.number().int().positive(), - type: z.enum(['issue', 'mr']), - projectRef: GitLabProjectRef -}) - -const WorkItemByPath = RepoSelector.extend({ - host: requiredString('Missing GitLab host'), - path: requiredString('Missing GitLab project path'), - iid: z.number().int().positive(), - type: z.enum(['issue', 'mr']) -}) +import { + AddIssueComment, + AddMRComment, + AddMRInlineComment, + CreateIssue, + EmptyParams, + GitLabRateLimit, + IssuesList, + JobTrace, + MergeMr, + RepoSelector, + ResolveMRDiscussion, + RetryJob, + UpdateIssue, + UpdateMr, + UpdateMrReviewers, + UpdateMrState, + WorkItemByPath, + WorkItemDetails, + WorkItemsList +} from '../../../../shared/rpc-contract/gitlab-params' export const GITLAB_METHODS: RpcMethod[] = [ defineMethod({ diff --git a/src/main/runtime/rpc/methods/hosted-review.ts b/src/main/runtime/rpc/methods/hosted-review.ts index fae2e8eb162..84b297ffa4e 100644 --- a/src/main/runtime/rpc/methods/hosted-review.ts +++ b/src/main/runtime/rpc/methods/hosted-review.ts @@ -1,51 +1,9 @@ -import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' -import { requiredString } from '../schemas' -import { OptionalGitAdmissionTier } from './git-admission-tier-schema' - -const HostedReviewForBranch = z.object({ - repo: requiredString('Missing repo selector'), - branch: requiredString('Missing branch'), - admissionTier: OptionalGitAdmissionTier, - currentHeadOid: z.string().nullable().optional(), - // Only the caller's selected worktree; the host caps how many earn the fast tier. - active: z.boolean().optional(), - linkedGitHubPR: z.number().int().positive().nullable().optional(), - fallbackGitHubPR: z.number().int().positive().nullable().optional(), - linkedGitLabMR: z.number().int().positive().nullable().optional(), - linkedBitbucketPR: z.number().int().positive().nullable().optional(), - linkedAzureDevOpsPR: z.number().int().positive().nullable().optional(), - linkedGiteaPR: z.number().int().positive().nullable().optional() -}) - -const HostedReviewCreationEligibility = z.object({ - repo: requiredString('Missing repo selector'), - worktree: z.string().min(1, 'Missing worktree selector').optional(), - branch: requiredString('Missing branch'), - base: z.string().nullable().optional(), - hasUncommittedChanges: z.boolean().optional(), - hasUpstream: z.boolean().optional(), - ahead: z.number().int().nonnegative().optional(), - behind: z.number().int().nonnegative().optional(), - linkedGitHubPR: z.number().int().positive().nullable().optional(), - fallbackGitHubPR: z.number().int().positive().nullable().optional(), - linkedGitLabMR: z.number().int().positive().nullable().optional(), - linkedBitbucketPR: z.number().int().positive().nullable().optional(), - linkedAzureDevOpsPR: z.number().int().positive().nullable().optional(), - linkedGiteaPR: z.number().int().positive().nullable().optional() -}) - -const HostedReviewCreate = z.object({ - repo: requiredString('Missing repo selector'), - worktree: z.string().min(1, 'Missing worktree selector').optional(), - provider: z.enum(['github', 'gitlab', 'bitbucket', 'azure-devops', 'gitea', 'unsupported']), - base: requiredString('Missing base branch'), - head: z.string().optional(), - title: requiredString('Missing title'), - body: z.string().optional(), - draft: z.boolean().optional(), - useTemplate: z.boolean().optional() -}) +import { + HostedReviewCreate, + HostedReviewCreationEligibility, + HostedReviewForBranch +} from '../../../../shared/rpc-contract/hosted-review-params' export const HOSTED_REVIEW_METHODS: RpcMethod[] = [ defineMethod({ diff --git a/src/main/runtime/rpc/methods/jira.ts b/src/main/runtime/rpc/methods/jira.ts index 087aa25f36e..4463c969919 100644 --- a/src/main/runtime/rpc/methods/jira.ts +++ b/src/main/runtime/rpc/methods/jira.ts @@ -1,109 +1,24 @@ -import { z } from 'zod' import { JIRA_PAYLOAD_CHUNK_CHARS, JIRA_PAYLOAD_MAX_CHARS } from '../../../../shared/jira-payload-stream' import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' import { - OptionalFiniteNumber, - OptionalPlainString, - OptionalString, - requiredString -} from '../schemas' - -const VALID_FILTERS = ['assigned', 'reported', 'all', 'done'] as const - -const SiteSelection = z - .object({ - siteId: OptionalString - }) - .optional() - -const Connect = z.object({ - siteUrl: requiredString('Site URL is required'), - // Self-hosted PAT auth needs no email; connect() enforces it for Cloud. - email: OptionalPlainString, - apiToken: requiredString('API token is required'), - authType: z.enum(['cloud', 'server']).optional() -}) - -const SelectSite = z.object({ - siteId: requiredString('Site ID is required') -}) - -const SearchIssues = z.object({ - jql: requiredString('Missing JQL'), - limit: OptionalFiniteNumber, - siteId: OptionalString -}) - -const ListIssues = z - .object({ - filter: z.enum(VALID_FILTERS).optional(), - limit: OptionalFiniteNumber, - siteId: OptionalString - }) - .optional() - -const IssueKey = z.object({ - key: requiredString('Issue key is required'), - siteId: OptionalString -}) - -const CreateIssue = z.object({ - siteId: OptionalString, - projectId: requiredString('Project is required'), - issueTypeId: requiredString('Issue type is required'), - title: requiredString('Title is required'), - description: OptionalPlainString, - customFields: z.record(z.string(), z.unknown()).optional(), - userFieldKeys: z.array(z.string()).optional() -}) - -const IssueUpdate = z.object({ - key: requiredString('Issue key is required'), - siteId: OptionalString, - updates: z.object({ - title: OptionalString, - labels: z.array(z.string()).optional(), - assigneeAccountId: z.union([z.string(), z.null()]).optional(), - priorityId: z.union([z.string(), z.null()]).optional(), - transitionId: OptionalString - }) -}) - -const IssueComment = z.object({ - key: requiredString('Issue key is required'), - body: requiredString('Comment body is required'), - siteId: OptionalString -}) - -const ProjectIssueTypes = z.object({ - projectIdOrKey: requiredString('Project is required'), - siteId: OptionalString -}) - -const ProjectIssueTypeFields = z.object({ - projectIdOrKey: requiredString('Project is required'), - issueTypeId: requiredString('Issue type is required'), - siteId: OptionalString -}) - -const AssignableUsers = z.object({ - key: requiredString('Issue key is required'), - query: OptionalPlainString, - siteId: OptionalString -}) - -const UserSearch = z.object({ - query: OptionalPlainString, - siteId: OptionalString -}) - -const ProjectStatusOrder = z.object({ - projectKey: requiredString('Project key is required'), - siteId: OptionalString -}) + AssignableUsers, + Connect, + CreateIssue, + IssueComment, + IssueKey, + IssueUpdate, + ListIssues, + ProjectIssueTypeFields, + ProjectIssueTypes, + ProjectStatusOrder, + SearchIssues, + SelectSite, + SiteSelection, + UserSearch +} from '../../../../shared/rpc-contract/jira-params' /** Emits a Jira result over RPC, normalizing it to the shape clients decode. */ function emitJiraPayload(value: unknown, emit: (result: unknown) => void): void { diff --git a/src/main/runtime/rpc/methods/linear-agent-access.ts b/src/main/runtime/rpc/methods/linear-agent-access.ts index 50e7bfef3a2..e76c843dede 100644 --- a/src/main/runtime/rpc/methods/linear-agent-access.ts +++ b/src/main/runtime/rpc/methods/linear-agent-access.ts @@ -1,149 +1,22 @@ -import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' -import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' import { linearError } from '../../../linear/issue-context-errors' import { isLinearUuid } from '../../../../shared/linear/uuid' - -const LINEAR_DUE_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/ -const LinearDueDate = z.string().refine((value) => LINEAR_DUE_DATE_PATTERN.test(value), { - message: 'Linear due dates must use YYYY-MM-DD' -}) -const OptionalLinearDueDate = LinearDueDate.optional() -const OptionalLinearDueDateOrClear = z.union([LinearDueDate, z.null()]).optional() - -const AgentSearchIssues = z.object({ - query: requiredString('Missing query'), - limit: OptionalFiniteNumber, - workspaceId: z.union([z.string(), z.literal('all')]).optional() -}) - -const LinearWorkspaceRead = z.object({ - workspaceId: z.union([z.string(), z.literal('all')]).optional() -}) - -const LinearTeamLookup = z.object({ - teamInput: requiredString('Missing team'), - workspaceId: OptionalString.refine((value) => value !== 'all', { - message: '--workspace all is only valid for team list' - }) -}) - -const LinearIssueList = z.object({ - filter: z.enum(['assigned', 'created', 'all', 'completed', 'open']).optional(), - teamInput: OptionalString, - limit: OptionalFiniteNumber, - workspaceId: z.union([z.string(), z.literal('all')]).optional() -}) - -const LinearProjectList = z.object({ - query: OptionalString, - limit: OptionalFiniteNumber, - workspaceId: z.union([z.string(), z.literal('all')]).optional() -}) - -const LinearIncludeFlags = z.object({ - comments: z.boolean(), - children: z.boolean(), - attachments: z.boolean(), - relations: z.boolean(), - activity: z.boolean().default(false) -}) - -const LinearCurrentContext = z - .object({ - worktreeId: OptionalString, - terminalHandle: OptionalString, - cwd: OptionalString, - remote: z.boolean().optional() - }) - .optional() - -const LinearWriteTarget = z.object({ - input: OptionalString, - current: z.boolean().optional(), - workspaceId: OptionalString.refine((value) => value !== 'all', { - message: '--workspace all is not valid for Linear writes' - }), - context: LinearCurrentContext -}) - -const AgentIssueContext = z.object({ - input: OptionalString, - current: z.boolean().optional(), - workspaceId: OptionalString, - include: LinearIncludeFlags, - depth: z.number().int().min(0).max(5), - context: LinearCurrentContext -}) - -const LinearIssueSetState = LinearWriteTarget.extend({ - to: requiredString('Missing target state') -}) - -const LinearIssueUpdateTask = LinearWriteTarget.extend({ - operation: z.enum(['assignee', 'priority', 'estimate', 'dueDate', 'labels']), - assigneeId: z.string().nullable().optional(), - assigneeMe: z.boolean().optional(), - priority: z.number().int().min(0).max(4).optional(), - estimate: z.number().int().min(0).nullable().optional(), - dueDate: OptionalLinearDueDateOrClear, - labelMode: z.enum(['add', 'remove', 'set']).optional(), - labels: z.array(z.string()).optional() -}) - -const LinearIssueAddComment = LinearWriteTarget.extend({ - body: requiredString('Missing comment body'), - replyTo: OptionalString, - writeId: OptionalString -}) - -const LinearIssueRelationWrite = LinearWriteTarget.extend({ - relatedInput: requiredString('Missing related issue'), - relationship: z.enum(['blocks', 'blockedBy', 'relatedTo', 'duplicateOf']), - operation: z.enum(['add', 'remove']) -}) - -const LinearIssueAttachLink = LinearWriteTarget.extend({ - url: requiredString('Missing attachment URL'), - title: OptionalString, - writeId: OptionalString -}) - -const LinearIssueCreate = z.object({ - title: requiredString('Missing issue title'), - body: OptionalString, - teamInput: OptionalString, - teamKey: OptionalString, - state: OptionalString, - assignee: OptionalString, - priority: z.number().int().min(0).max(4).optional(), - estimate: z.number().int().min(0).optional(), - dueDate: OptionalLinearDueDate, - labels: z.array(z.string()).optional(), - projectInput: OptionalString, - parentInput: OptionalString, - parentCurrent: z.boolean().optional(), - workspaceId: OptionalString.refine((value) => value !== 'all', { - message: '--workspace all is not valid for Linear writes' - }), - writeId: OptionalString, - context: LinearCurrentContext -}) - -const LinearSaveIssue = LinearWriteTarget.extend({ - team: OptionalString, - title: OptionalString, - description: z.string().optional(), - state: OptionalString, - assignee: z.string().nullable().optional(), - priority: z.number().int().min(0).max(4).optional(), - estimate: z.number().min(0).nullable().optional(), - dueDate: OptionalLinearDueDateOrClear, - labels: z.array(z.string()).optional(), - project: z.string().nullable().optional(), - parentId: z.string().nullable().optional(), - writeId: OptionalString -}) +import { + AgentIssueContext, + AgentSearchIssues, + LinearCurrentContext, + LinearIssueAddComment, + LinearIssueAttachLink, + LinearIssueCreate, + LinearIssueList, + LinearIssueRelationWrite, + LinearIssueSetState, + LinearIssueUpdateTask, + LinearProjectList, + LinearSaveIssue, + LinearTeamLookup, + LinearWorkspaceRead +} from '../../../../shared/rpc-contract/linear-agent-access-params' function parseLinearWriteId(writeId: string | undefined): string | undefined { if (writeId === undefined) { diff --git a/src/main/runtime/rpc/methods/linear-issue-attribute-filter-schema.ts b/src/main/runtime/rpc/methods/linear-issue-attribute-filter-schema.ts index 7b7176f48b8..b369a01a15c 100644 --- a/src/main/runtime/rpc/methods/linear-issue-attribute-filter-schema.ts +++ b/src/main/runtime/rpc/methods/linear-issue-attribute-filter-schema.ts @@ -1,35 +1 @@ -import { z } from 'zod' -import { - LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH, - LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS, - LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_PRIORITIES, - LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS -} from '../../../../shared/linear/issue-attribute-filter' - -// Why: keep ListIssues param validation co-located with shared limits without -// pushing linear.ts past the max-lines ratchet. -const LinearAttributeFilterId = z - .string() - .trim() - .min(1) - .max(LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH) - -export const LinearIssueAttributeFilterSchema = z - .object({ - stateIds: z.array(LinearAttributeFilterId).max(LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS), - priorities: z - .array(z.number().int().min(0).max(4)) - .max(LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_PRIORITIES), - assignee: z.union([ - z.object({ kind: z.literal('unassigned') }).strict(), - z - .object({ - kind: z.literal('user'), - id: LinearAttributeFilterId - }) - .strict(), - z.null() - ]), - labelIds: z.array(LinearAttributeFilterId).max(LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS) - }) - .strict() +export { LinearIssueAttributeFilterSchema } from '../../../../shared/rpc-contract/linear-issue-attribute-filter-params' diff --git a/src/main/runtime/rpc/methods/linear-issue-list-method.ts b/src/main/runtime/rpc/methods/linear-issue-list-method.ts index fa69b745377..9dd838c837a 100644 --- a/src/main/runtime/rpc/methods/linear-issue-list-method.ts +++ b/src/main/runtime/rpc/methods/linear-issue-list-method.ts @@ -1,42 +1,6 @@ -import { z } from 'zod' +import type { z } from 'zod' import { defineMethod } from '../core' -import { OptionalFiniteNumber, OptionalString } from '../schemas' -import { LinearIssueAttributeFilterSchema } from './linear-issue-attribute-filter-schema' - -const LegacyListIssues = z - .object({ - filter: z.enum(['assigned', 'created', 'all', 'completed']).optional(), - limit: OptionalFiniteNumber, - workspaceId: OptionalString, - attributeFilter: LinearIssueAttributeFilterSchema.optional() - }) - .strict() - .optional() - -const McpListIssues = z - .object({ - team: OptionalString, - cycle: OptionalString, - label: OptionalString, - limit: z.number().int().min(1).max(250).optional(), - query: OptionalString, - state: OptionalString, - cursor: OptionalString, - orderBy: z.enum(['createdAt', 'updatedAt']).optional(), - project: OptionalString, - release: OptionalString, - assignee: OptionalString, - delegate: OptionalString, - parentId: OptionalString, - priority: z.number().int().min(0).max(4).optional(), - createdAt: OptionalString, - updatedAt: OptionalString, - includeArchived: z.boolean().optional(), - workspaceId: OptionalString - }) - .strict() - -const ListIssues = z.union([McpListIssues, LegacyListIssues]) +import { ListIssues, McpListIssues } from '../../../../shared/rpc-contract/linear-issue-list-params' export const LINEAR_ISSUE_LIST_METHOD = defineMethod({ name: 'linear.listIssues', diff --git a/src/main/runtime/rpc/methods/linear-project-create.ts b/src/main/runtime/rpc/methods/linear-project-create.ts index 026c585aba2..f2c020f6656 100644 --- a/src/main/runtime/rpc/methods/linear-project-create.ts +++ b/src/main/runtime/rpc/methods/linear-project-create.ts @@ -1,23 +1,5 @@ -import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' -import { OptionalString, requiredString } from '../schemas' - -const LinearPriority = z.number().int().min(0).max(4).optional() -const LinearLabelIds = z.array(requiredString('Invalid label ID')).optional() - -const CreateProject = z.object({ - name: requiredString('Project name is required'), - description: OptionalString, - content: OptionalString, - workspaceId: OptionalString, - teamIds: z.array(requiredString('Invalid team ID')).min(1, 'At least one team is required'), - leadId: z.union([z.string(), z.null()]).optional(), - memberIds: z.array(requiredString('Invalid member ID')).optional(), - labelIds: LinearLabelIds, - priority: LinearPriority, - startDate: OptionalString, - targetDate: OptionalString -}) +import { CreateProject } from '../../../../shared/rpc-contract/linear-project-create-params' export const LINEAR_PROJECT_CREATE_METHOD: RpcMethod = defineMethod({ name: 'linear.createProject', diff --git a/src/main/runtime/rpc/methods/linear.ts b/src/main/runtime/rpc/methods/linear.ts index 1f3e474e78d..f0ec2106830 100644 --- a/src/main/runtime/rpc/methods/linear.ts +++ b/src/main/runtime/rpc/methods/linear.ts @@ -1,124 +1,24 @@ -import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' -import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' import { LINEAR_PROJECT_CREATE_METHOD } from './linear-project-create' import { LINEAR_ISSUE_LIST_METHOD, LINEAR_MCP_ISSUE_LIST_METHOD } from './linear-issue-list-method' - -const VALID_CUSTOM_VIEW_MODELS = ['issue', 'project'] as const -const LinearPriority = z.number().int().min(0).max(4).optional() -const LinearLabelIds = z.array(requiredString('Invalid label ID')).optional() - -const Connect = z.object({ - apiKey: requiredString('Invalid API key') -}) - -const WorkspaceSelection = z - .object({ - workspaceId: OptionalString - }) - .optional() - -const ConcreteWorkspaceId = requiredString('Concrete Linear workspace ID is required').refine( - (value) => value !== 'all', - 'Concrete Linear workspace ID is required' -) - -const SelectWorkspace = z.object({ - workspaceId: requiredString('Workspace ID is required') -}) - -const SearchIssues = z.object({ - query: requiredString('Missing query'), - limit: OptionalFiniteNumber, - workspaceId: OptionalString -}) - -const CreateIssue = z.object({ - teamId: requiredString('Team ID is required'), - title: requiredString('Title is required'), - description: OptionalString, - workspaceId: OptionalString, - parentIssueId: OptionalString, - projectId: z.union([z.string(), z.null()]).optional(), - stateId: OptionalString, - priority: LinearPriority, - assigneeId: z.union([z.string(), z.null()]).optional(), - labelIds: LinearLabelIds -}) - -const IssueId = z.object({ - id: requiredString('Issue ID is required'), - workspaceId: OptionalString -}) - -const IssueComment = z.object({ - issueId: requiredString('Issue ID is required'), - body: requiredString('Comment body is required'), - workspaceId: OptionalString -}) - -const ListProjects = z - .object({ - query: OptionalString, - limit: OptionalFiniteNumber, - workspaceId: OptionalString, - force: z.boolean().optional() - }) - .optional() - -const ProjectId = z.object({ - id: requiredString('Project ID is required'), - workspaceId: ConcreteWorkspaceId, - force: z.boolean().optional() -}) - -const ProjectIssues = z.object({ - projectId: requiredString('Project ID is required'), - limit: OptionalFiniteNumber, - workspaceId: ConcreteWorkspaceId, - force: z.boolean().optional() -}) - -const ListCustomViews = z.object({ - model: z.enum(VALID_CUSTOM_VIEW_MODELS), - limit: OptionalFiniteNumber, - workspaceId: OptionalString, - force: z.boolean().optional() -}) - -const CustomViewId = z.object({ - viewId: requiredString('Custom view ID is required'), - model: z.enum(VALID_CUSTOM_VIEW_MODELS), - workspaceId: ConcreteWorkspaceId, - force: z.boolean().optional() -}) - -const CustomViewContents = z.object({ - viewId: requiredString('Custom view ID is required'), - limit: OptionalFiniteNumber, - workspaceId: ConcreteWorkspaceId, - force: z.boolean().optional() -}) - -const TeamId = z.object({ - teamId: requiredString('Team ID is required'), - workspaceId: OptionalString -}) - -const IssueUpdate = z.object({ - id: requiredString('Issue ID is required'), - workspaceId: OptionalString, - updates: z.object({ - stateId: OptionalString, - title: OptionalString, - description: z.string().optional(), - assigneeId: z.union([z.string(), z.null()]).optional(), - estimate: z.union([z.number().int().min(0), z.null()]).optional(), - priority: z.number().int().min(0).max(4).optional(), - labelIds: z.array(z.string()).optional(), - projectId: z.union([z.string(), z.null()]).optional() - }) -}) +import { + Connect, + CreateIssue, + CustomViewContents, + CustomViewId, + IssueComment, + IssueId, + IssueUpdate, + LinearIssueCommentsParams, + ListCustomViews, + ListProjects, + ProjectId, + ProjectIssues, + SearchIssues, + SelectWorkspace, + TeamId, + WorkspaceSelection +} from '../../../../shared/rpc-contract/linear-params' export const LINEAR_METHODS: RpcMethod[] = [ defineMethod({ @@ -193,10 +93,7 @@ export const LINEAR_METHODS: RpcMethod[] = [ }), defineMethod({ name: 'linear.issueComments', - params: z.object({ - issueId: requiredString('Issue ID is required'), - workspaceId: OptionalString - }), + params: LinearIssueCommentsParams, handler: async (params, { runtime }) => runtime.linearIssueComments(params.issueId.trim(), params.workspaceId) }), diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts index e1a92dd52db..305e8adb771 100644 --- a/src/main/runtime/rpc/methods/native-chat.ts +++ b/src/main/runtime/rpc/methods/native-chat.ts @@ -1,5 +1,4 @@ -import { z } from 'zod' -import type { NativeChatMessage, AgentType } from '../../../../shared/native-chat-types' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { readNativeChatTranscriptTail, subscribeNativeChatTranscript, @@ -8,52 +7,11 @@ import { } from '../../../native-chat/transcript-watch' import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core' import { sanitizeNativeChatRpcBlock } from './native-chat-rpc-block-sanitize' - -// Why: native chat renders an agent's own transcript (Claude/Codex JSONL). The -// desktop reaches the readers via Electron IPC; mobile/web clients reach the -// same pure readers through these runtime RPC methods so the native chat view -// works over the paired connection, not just in the desktop renderer. - -const NativeChatSession = z.object({ - agent: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing agent')) - .transform((v) => v as AgentType), - sessionId: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing session id')), - // How many of the most-recent messages to return. Clients start small for a - // fast first paint and raise it to page older history in as the user scrolls. - // Clamp (don't reject) a limit past the max window so a client paging beyond it - // gets the capped tail and pagination stops cleanly — a hard `.max` rejection - // would fail the read and stall "load earlier" at the boundary. - limit: z - .number() - .int() - .positive() - .transform((value) => Math.min(value, MOBILE_NATIVE_CHAT_MAX_WINDOW)) - .optional(), - // Optional client-supplied cleanup token. When present, the subscribe handler - // keys the fs-watcher cleanup under it so registration and unsubscribe derive - // from the SAME token (back-compat: falls back to `agent:sessionId` when absent, - // which is exactly what existing mobile clients rely on). - subscriptionId: z.string().min(1).optional(), - // Authoritative transcript path from the agent hook (providerSession), used to - // locate the file directly when the session id no longer names it (recent - // Claude Code). Optional for back-compat with older clients. - transcriptPath: z.string().min(1).optional(), - // A pending snapshot is not authoritative transcript history. Only clients - // that advertise this semantic may receive one; legacy clients treat it as a - // settled empty read and can overwrite retention / unblock launch drafts. - capabilities: z.object({ transcriptPending: z.literal(1).optional() }).optional(), - beforeOffset: z.number().int().nonnegative().optional() -}) - -const NativeChatUnsubscribe = z.object({ - subscriptionId: z.string().min(1).optional() -}) +import { + MOBILE_NATIVE_CHAT_MAX_WINDOW, + NativeChatSession, + NativeChatUnsubscribe +} from '../../../../shared/rpc-contract/native-chat-params' // Why: a long agent session can hold thousands of turns (with full tool I/O). // Shipping all of them over the paired connection and rendering them at once @@ -63,7 +21,6 @@ const NativeChatUnsubscribe = z.object({ // Small first page for a fast initial paint; the client raises `limit` to load // older history as the user scrolls back. const MOBILE_NATIVE_CHAT_DEFAULT_WINDOW = 40 -const MOBILE_NATIVE_CHAT_MAX_WINDOW = 2000 function sanitizeMessage( message: NativeChatMessage, diff --git a/src/main/runtime/rpc/methods/notifications.ts b/src/main/runtime/rpc/methods/notifications.ts index a1adb84805a..2b9e05fb6f5 100644 --- a/src/main/runtime/rpc/methods/notifications.ts +++ b/src/main/runtime/rpc/methods/notifications.ts @@ -1,76 +1,22 @@ -import { z } from 'zod' import { createNotificationStreamFilter } from './notification-stream-policy' -import { - MOBILE_PUSH_APNS_ENVIRONMENTS, - MOBILE_PUSH_PLATFORMS -} from '../../../../shared/mobile-push-contract' import { defineStreamingMethod, defineMethod, type RpcAnyMethod } from '../core' +import { + NotificationGetMissedSinceParams, + NotificationRegisterPushParams, + NotificationUnsubscribeParams, + NotificationsSubscribeParams +} from '../../../../shared/rpc-contract/notifications-params' // Why: monotonically increasing per-process counter eliminates the // Date.now() collision that could fire when two near-simultaneous // notifications.subscribe calls landed on the same millisecond. let notificationsSubscriptionSeq = 0 -const NotificationUnsubscribeParams = z.object({ - subscriptionId: z - .unknown() - .transform((value) => (typeof value === 'string' && value.length > 0 ? value : '')) - .pipe(z.string().min(1, 'Missing subscriptionId')) -}) - -// Why: notifications.getMissedSince is the catch-up RPC for mobile reconnect -// (#8129). The client passes the highest seq it has already delivered; the -// runtime returns only notifications dispatched after that seq. Because the -// desktop assigns a monotonic seq to every dispatched notification, the cut is -// exact and idempotent — re-requesting with the same watermark can never -// return an already-delivered event, so reconnects never duplicate local -// pushes (the adversarial-review gate for #8129). -// `epoch` names the counter lifetime lastSeenSeq came from (#8591). The desktop's -// seq restarts at 0 on every launch while the client's watermark is persisted, so -// without it a post-restart watermark silently cuts away everything. Optional: a -// client that predates the field keeps the seq-only cut. -const NotificationGetMissedSinceParams = z.object({ - lastSeenSeq: z.number().int().min(0, 'lastSeenSeq must be a non-negative integer'), - epoch: z.string().optional(), - includeDesktopSuppressed: z.boolean().optional(), - deliveredPushes: z - .array( - z.object({ - notificationId: z.string().min(1).max(2048), - notificationEpoch: z.string().min(1).max(128), - notificationSeq: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER) - }) - ) - .max(256) - .optional() -}) - -const NotificationPushFilterParams = z.object({ - onlyWhenDesktopAway: z.boolean().optional(), - sound: z.boolean().optional() -}) - -const NotificationRegisterPushParams = z - .object({ - platform: z.enum(MOBILE_PUSH_PLATFORMS), - token: z.string().min(1).max(4096), - apnsEnvironment: z.enum(MOBILE_PUSH_APNS_ENVIRONMENTS).optional(), - filter: NotificationPushFilterParams - }) - // Why strict: the device identity is added by the handler, so a caller-supplied - // `deviceId` must be an error, not a key silently dropped. - .strict() - // Why: an APNs token is only routable against the environment it was minted in, - // so a missing environment must fail loudly rather than default to production. - .refine((params) => params.platform !== 'ios' || params.apnsEnvironment !== undefined, { - message: 'apnsEnvironment is required for ios' - }) - // Legacy callers retain filtered socket alerts; push clients opt into the full event stream. export const NOTIFICATION_METHODS: readonly RpcAnyMethod[] = [ defineStreamingMethod({ name: 'notifications.subscribe', - params: z.object({ includeDesktopSuppressed: z.boolean().optional() }).optional(), + params: NotificationsSubscribeParams, handler: async (params, { runtime, connectionId }, emit) => { const shouldEmit = createNotificationStreamFilter(params?.includeDesktopSuppressed) await new Promise((resolve) => { diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-control.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-control.ts index 6091c1080fa..0ab3cce34aa 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation-control.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-control.ts @@ -1,9 +1,6 @@ -import { z } from 'zod' -import { ORCHESTRATION_WORKER_READ_SOURCES } from '../../../../../../shared/orchestration-worker-output' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import type { RemoteDispatchAttachmentRow } from '../../../../orchestration/types' import { defineMethod, type RpcMethod } from '../../../core' -import { OptionalFiniteNumber, requiredString } from '../../../schemas' import { mapWithConcurrency } from '../../../../../../shared/map-with-concurrency' import { readExactWorkerOutput } from '../worker/worker-output' import { describeUnconfirmedAgentStop } from '../../../../../../shared/pty-liveness-verdict' @@ -12,22 +9,12 @@ import { readRemoteAttachmentArchive, releaseRemoteAttachment } from './federated-worker-release-host' - -const FederationDispatchParams = z.object({ - dispatchId: requiredString('Missing Dispatch ID') -}) -const FederationReadParams = FederationDispatchParams.extend({ - cursor: OptionalFiniteNumber, - limit: OptionalFiniteNumber -}) -const FederationOutputReadParams = FederationDispatchParams.extend({ - cursor: z.union([z.number().int().nonnegative(), z.string().min(1).max(2_048)]).optional(), - limit: OptionalFiniteNumber, - source: z.enum(ORCHESTRATION_WORKER_READ_SOURCES).optional() -}) -const FederationFleetSnapshotParams = z.object({ - dispatchIds: z.array(requiredString('Missing Dispatch ID')).min(1).max(100) -}) +import { + FederationDispatchParams, + FederationFleetSnapshotParams, + FederationOutputReadParams, + FederationReadParams +} from '../../../../../../shared/rpc-contract/orchestration-federation-control-params' export const ORCHESTRATION_FEDERATION_CONTROL_METHODS: RpcMethod[] = [ defineMethod({ diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-relay.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-relay.ts index 8cb4e08f2f3..58561e5e044 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation-relay.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-relay.ts @@ -1,4 +1,3 @@ -import { z } from 'zod' import { ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION } from '../../../../../../shared/protocol-version' import { importFederatedControlMessage } from '../../../../orchestration/federation-control-message' import { OrchestrationError } from '../../../../orchestration/orchestration-error' @@ -8,52 +7,11 @@ import { type FederatedLifecycleSettlement } from '../../../../orchestration/federation-lifecycle-settlement' import { defineMethod, type RpcMethod } from '../../../core' -import { OptionalFiniteNumber, requiredString } from '../../../schemas' - -const FederationPullParams = z.object({ - dispatchId: requiredString('Missing Dispatch ID'), - afterSequence: OptionalFiniteNumber, - replayUnacknowledged: z.boolean().optional(), - limit: OptionalFiniteNumber -}) - -const FederationAckParams = z.object({ - dispatchId: requiredString('Missing Dispatch ID'), - throughSequence: z.number().int().nonnegative(), - settlements: z - .array( - z.object({ - sequence: z.number().int().positive(), - lifecycle: z.discriminatedUnion('action', [ - z.object({ - action: z.enum(['completed', 'failed']), - authority: z.literal('run_home') - }), - z.object({ - action: z.literal('rejected'), - code: z.string(), - reason: z.string(), - authority: z.literal('run_home') - }) - ]) - }) - ) - .optional() -}) - -const FederationImportParams = z.object({ - dispatchId: requiredString('Missing Dispatch ID'), - items: z.array( - z.object({ - dispatch_id: requiredString('Missing item Dispatch ID'), - direction: z.literal('to_worker'), - sequence: z.number().int().positive(), - message_id: requiredString('Missing relay message ID'), - kind: requiredString('Missing relay kind'), - payload: requiredString('Missing relay payload') - }) - ) -}) +import { + FederationAckParams, + FederationImportParams, + FederationPullParams +} from '../../../../../../shared/rpc-contract/orchestration-federation-relay-params' export const ORCHESTRATION_FEDERATION_RELAY_METHODS: RpcMethod[] = [ defineMethod({ diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.ts index 84ed57d58cc..89b55c86a4c 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.ts @@ -1,31 +1,5 @@ -import { z } from 'zod' -import { OptionalFiniteNumber, OptionalString, requiredString } from '../../../schemas' -import { OptionalWorkerLaunchPreference } from '../worker/worker-start-schema' - -export const FederationAttachStartParams = z.object({ - /** Omitted by v1.4.198 coordinators; the worker host then mints a stub home Run. */ - runId: OptionalString, - dispatchId: requiredString('Missing Dispatch ID'), - taskId: requiredString('Missing Task ID'), - taskSpec: requiredString('Missing Task spec'), - /** Depth stamped by the Run home; omitted by older clients and defaults to 1. */ - depth: z.number().int().min(1).optional(), - protocolVersion: z.union([z.literal(1), z.literal(2), z.literal(3)]), - worktree: requiredString('Missing remote worktree selector'), - name: OptionalString, - repo: OptionalString, - baseBranch: OptionalString, - displayName: OptionalString, - displayNameKind: z.enum(['generated', 'user']).optional(), - comment: OptionalString, - setup: z.enum(['run', 'skip', 'inherit']).optional(), - setupSource: z.enum(['explicit_request', 'orchestration_default']).optional(), - terminal: OptionalString, - agent: OptionalString, - model: OptionalWorkerLaunchPreference, - effort: OptionalWorkerLaunchPreference, - timeoutMs: OptionalFiniteNumber, - devMode: z.boolean().optional() -}) +import type { z } from 'zod' +import { FederationAttachStartParams } from '../../../../../../shared/rpc-contract/orchestration-federation-start-params' +export { FederationAttachStartParams } export type FederationAttachStartInput = z.infer diff --git a/src/main/runtime/rpc/methods/orchestration/gates/gates.ts b/src/main/runtime/rpc/methods/orchestration/gates/gates.ts index 76bfd23b76e..6bb8750628e 100644 --- a/src/main/runtime/rpc/methods/orchestration/gates/gates.ts +++ b/src/main/runtime/rpc/methods/orchestration/gates/gates.ts @@ -1,48 +1,21 @@ -import { z } from 'zod' import { defineMethod, type RpcMethod } from '../../../core' -import { OptionalFiniteNumber, OptionalString, requiredString } from '../../../schemas' import type { GateStatus } from '../../../../orchestration/db' import { Coordinator } from '../../../../orchestration/coordinator' import { resolveRunScope } from '../runs/run-scope' import { taskNotFoundError } from '../../../../orchestration/task-dispatch-refusal' +import { + GateCreateParams, + GateListParams, + GateResolveParams, + RunParams, + RunStopParams +} from '../../../../../../shared/rpc-contract/orchestration-gates-params' // Why: the coordinator instance is stored at module scope so orchestration.runStop // can signal it to halt. Only one coordinator can run at a time (enforced by // the DB's active-run check), so a single reference suffices. let activeCoordinator: Coordinator | null = null -const RunParams = z.object({ - spec: requiredString('Missing --spec'), - from: OptionalString, - pollIntervalMs: OptionalFiniteNumber, - maxConcurrent: OptionalFiniteNumber, - worktree: OptionalString -}) - -const RunStopParams = z.object({}) - -const GateCreateParams = z.object({ - task: requiredString('Missing --task'), - question: requiredString('Missing --question'), - options: OptionalString, - from: OptionalString, - run: OptionalString -}) - -const GateResolveParams = z.object({ - id: requiredString('Missing --id'), - resolution: requiredString('Missing --resolution'), - from: OptionalString, - run: OptionalString -}) - -const GateListParams = z.object({ - task: OptionalString, - status: z.enum(['pending', 'resolved', 'timeout']).optional(), - from: OptionalString, - run: OptionalString -}) - export const ORCHESTRATION_GATE_METHODS: RpcMethod[] = [ // Why: Section 4.12 — orchestration.run returns immediately with a run ID. // The coordinator loop runs in the background; progress is queried via diff --git a/src/main/runtime/rpc/methods/orchestration/runs/mutation-request-show.ts b/src/main/runtime/rpc/methods/orchestration/runs/mutation-request-show.ts index 5dd72b61c6e..8ca5333f307 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/mutation-request-show.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/mutation-request-show.ts @@ -3,10 +3,7 @@ import { type OrchestrationMutationRequestShowResult } from '../../../../../../shared/orchestration-mutation-request' import { defineMethod, type RpcMethod } from '../../../core' -import { requiredString } from '../../../schemas' -import { z } from 'zod' - -const RequestShowParams = z.object({ request: requiredString('Missing --request') }) +import { RequestShowParams } from '../../../../../../shared/rpc-contract/orchestration-runs-mutation-request-show-params' export const ORCHESTRATION_MUTATION_REQUEST_METHODS: RpcMethod[] = [ defineMethod({ diff --git a/src/main/runtime/rpc/methods/orchestration/runs/runs.ts b/src/main/runtime/rpc/methods/orchestration/runs/runs.ts index 77bcea4924c..dc0b112a168 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/runs.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/runs.ts @@ -1,28 +1,14 @@ -import { z } from 'zod' import { defineMethod, type RpcMethod } from '../../../core' -import { OptionalBoolean, OptionalString, requiredString } from '../../../schemas' -import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../../../../../shared/orchestration-run-pagination' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import { assertCallerHandleMatchesEvidence, resolveOrchestrationCaller } from './run-scope' import { exposeRun } from './run-receipt' - -const RunCreateParams = z.object({ - objective: requiredString('Missing --objective'), - from: requiredString('Missing coordinator terminal') -}) - -const RunUseParams = z.object({ - id: requiredString('Missing --id'), - from: requiredString('Missing coordinator terminal'), - takeoverLegacy: OptionalBoolean -}) - -const RunCurrentParams = z.object({ from: requiredString('Missing coordinator terminal') }) -const RunListParams = z.object({ - limit: z.number().int().min(1).max(ORCHESTRATION_RUN_PAGE_LIMIT).optional(), - cursor: z.string().min(1).optional() -}) -const RunShowParams = z.object({ id: requiredString('Missing --id'), from: OptionalString }) +import { + RunCreateParams, + RunCurrentParams, + RunListParams, + RunShowParams, + RunUseParams +} from '../../../../../../shared/rpc-contract/orchestration-runs-params' export const ORCHESTRATION_RUN_METHODS: RpcMethod[] = [ defineMethod({ diff --git a/src/main/runtime/rpc/methods/orchestration/schemas.ts b/src/main/runtime/rpc/methods/orchestration/schemas.ts index 51b51137475..eae80849ffc 100644 --- a/src/main/runtime/rpc/methods/orchestration/schemas.ts +++ b/src/main/runtime/rpc/methods/orchestration/schemas.ts @@ -1,15 +1,26 @@ import { z } from 'zod' import { setImmediate as yieldToEventLoop } from 'node:timers/promises' -import { - OptionalFiniteNumber, - OptionalString, - OptionalBoolean, - requiredString -} from '../../schemas' +import { OptionalString, OptionalBoolean, requiredString } from '../../schemas' import type { TaskStatus } from '../../../orchestration/db' import { isGroupAddress } from '../../../orchestration/groups' import { MESSAGE_TYPES } from '../../../orchestration/types' import { OrchestrationError } from '../../../orchestration/orchestration-error' +import { + getLifecycleGroupRecipientError, + isDispatchMutationMessageType +} from '../../../../../shared/rpc-contract/orchestration-params' +export { + AskParams, + CheckParams, + DispatchParams, + DispatchShowParams, + InboxParams, + ReplyParams, + ResetParams, + TaskCreateParams, + TaskListParams +} from '../../../../../shared/rpc-contract/orchestration-params' +export { getLifecycleGroupRecipientError, isDispatchMutationMessageType } export const TASK_STATUSES: TaskStatus[] = [ 'pending', @@ -44,27 +55,6 @@ const SEND_MESSAGE_TYPE_ERROR = [ 'To answer a worker question, use the same Orca CLI executable with orchestration reply --id --body .' ].join(' ') -export type DispatchMutationMessageType = - | 'worker_done' - | 'heartbeat' - | 'escalation' - | 'decision_gate' - -export function isDispatchMutationMessageType( - type: string | undefined -): type is DispatchMutationMessageType { - return ( - type === 'worker_done' || - type === 'heartbeat' || - type === 'escalation' || - type === 'decision_gate' - ) -} - -export function getLifecycleGroupRecipientError(type: DispatchMutationMessageType): string { - return `${type} messages belong to one exact Dispatch and cannot target a group address.` -} - export function parseRemoteWorkerPayload(payload: string | undefined): Record { if (!payload) { return {} @@ -131,73 +121,6 @@ export const SendParams = z }) }) -export const CheckParams = z - .object({ - terminal: OptionalString, - terminalPaneKey: OptionalString, - unread: OptionalBoolean, - peek: OptionalBoolean, - // Why: `all` surfaces every message and skips mark-read; legacy encoding was the `{unread: false}` trick (design doc §3.2/§3.3). - all: OptionalBoolean, - types: OptionalString, - format: OptionalBoolean, - // Why: one-release RPC compatibility only; the public CLI uses --format because no terminal input is injected. - inject: OptionalBoolean, - ack: OptionalString, - compatibilityAck: OptionalString, - compatibilityQuestionAck: OptionalString, - compatibilityCliCommand: z.enum(['orca', 'orca-ide', 'orca-dev']).optional(), - run: OptionalString, - wait: OptionalBoolean, - timeoutMs: OptionalFiniteNumber - }) - .superRefine((params, ctx) => { - // Why: CLI encodes --peek as {peek:true, unread:false} for pre-peek runtimes, so that pair is one mode, not a conflict. - const modes = [ - params.unread === true, - params.peek === true, - params.all === true || (params.unread === false && params.peek !== true) - ].filter(Boolean) - if (modes.length > 1) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Choose at most one message read mode: --unread, --peek, or --all.' - }) - } - }) - -export const ReplyParams = z.object({ - id: requiredString('Missing --id'), - body: requiredString('Missing --body'), - from: OptionalString, - run: OptionalString -}) - -export const InboxParams = z.object({ - limit: OptionalFiniteNumber, - // Why: filters the inbox to a handle so inbox and check --all give agreeing results (design doc §3.3). - terminal: OptionalString -}) - -export const TaskCreateParams = z.object({ - spec: requiredString('Missing --spec'), - taskTitle: OptionalString, - displayName: OptionalString, - deps: OptionalString, - parent: OptionalString, - callerTerminalHandle: OptionalString, - run: OptionalString -}) - -export const TaskListParams = z.object({ - status: z.enum(['pending', 'ready', 'dispatched', 'completed', 'failed', 'blocked']).optional(), - ready: OptionalBoolean, - // Why: server-side truncation keeps --brief cheap over SSH/relay instead of shipping full specs the CLI throws away. - brief: OptionalBoolean, - run: OptionalString, - callerTerminalHandle: OptionalString -}) - export const TaskUpdateParams = z.object({ id: requiredString('Missing --id'), status: z @@ -217,61 +140,4 @@ export const TaskUpdateParams = z.object({ run: OptionalString, callerTerminalHandle: OptionalString }) - -export const DispatchParams = z.object({ - task: requiredString('Missing --task'), - // Why: --to is optional so --dry-run can preview without a target; the handler enforces presence before any side-effecting work. - to: OptionalString, - from: OptionalString, - inject: OptionalBoolean, - dryRun: OptionalBoolean, - returnPreamble: OptionalBoolean, - devMode: OptionalBoolean, - run: OptionalString -}) - -export const DispatchShowParams = z.object({ - task: OptionalString, - preamble: OptionalBoolean, - from: OptionalString, - devMode: OptionalBoolean -}) - -export const AskParams = z - .object({ - to: OptionalString, - question: OptionalString, - resume: OptionalString, - options: OptionalString, - timeoutMs: OptionalFiniteNumber, - from: OptionalString, - run: OptionalString, - compatibilityCliCommand: z.enum(['orca', 'orca-ide', 'orca-dev']).optional(), - compatibilityWindowsCommand: z.enum(['orca', 'orca-ide']).optional() - }) - .superRefine((params, ctx) => { - if ((params.question ? 1 : 0) + (params.resume ? 1 : 0) !== 1) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Choose exactly one of --question or --resume.' - }) - } - }) - -export const ResetParams = z - .object({ - all: OptionalBoolean, - tasks: OptionalBoolean, - messages: OptionalBoolean - }) - .superRefine((params, ctx) => { - const selectedScopeCount = [params.all, params.tasks, params.messages].filter( - (scope) => scope === true - ).length - if (selectedScopeCount !== 1) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Choose exactly one reset scope: --all, --tasks, or --messages.' - }) - } - }) +export type { DispatchMutationMessageType } from '../../../../../shared/rpc-contract/orchestration-params' diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts index cf08ce31f69..3e27e12eba0 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts @@ -1,9 +1,6 @@ -import { z } from 'zod' -import { ORCHESTRATION_WORKER_READ_SOURCES } from '../../../../../../shared/orchestration-worker-output' import { contextOnlyAbandonWarning } from '../../../../orchestration/context-only-dispatch-release' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import { defineMethod, type RpcMethod } from '../../../core' -import { OptionalFiniteNumber, requiredString } from '../../../schemas' import { exposeDispatchContext, exposeObservation, @@ -20,12 +17,10 @@ import { readExactWorkerOutput } from './worker-output' import { exposeWorkerTerminalResource } from './worker-release-completion' import { readFederatedWorkerOutput } from '../federation/federated-worker-read' import { showFederatedWorker } from '../federation/federated-worker-show' -const WorkerDispatchParams = z.object({ dispatch: requiredString('Missing --dispatch') }) -const WorkerReadParams = WorkerDispatchParams.extend({ - cursor: z.union([z.number().int().nonnegative(), z.string().min(1).max(2_048)]).optional(), - limit: OptionalFiniteNumber, - source: z.enum(ORCHESTRATION_WORKER_READ_SOURCES).optional() -}) +import { + WorkerDispatchParams, + WorkerReadParams +} from '../../../../../../shared/rpc-contract/orchestration-worker-control-params' export const ORCHESTRATION_WORKER_CONTROL_METHODS: RpcMethod[] = [ defineMethod({ diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-schemas.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-schemas.ts index 52310a2fd9b..66eaf2263f9 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-schemas.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-schemas.ts @@ -1,24 +1,6 @@ -import { z } from 'zod' -import { ORCHESTRATION_FLEET_PAGE_MAX } from '../../../../../../shared/orchestration-fleet-projection' -import { requiredString } from '../../../schemas' - -export const WorkerDispatchParams = z.object({ dispatch: requiredString('Missing --dispatch') }) -export const WorkerRetainParams = WorkerDispatchParams.strict() - -export const WORKER_TERMINAL_LIST_STATES = [ - 'active', - 'reclaimable', - 'retained', - 'release_pending', - 'release_unknown', - 'released' -] as const - -export const WorkerListParams = z.object({ - run: z.string().min(1).optional(), - terminalState: z.enum(WORKER_TERMINAL_LIST_STATES).optional(), - cursor: z.string().min(1).max(2_048).optional(), - limit: z.number().int().min(1).max(ORCHESTRATION_FLEET_PAGE_MAX).optional(), - includeRemote: z.boolean().optional(), - paginate: z.boolean().optional() -}) +export { + WORKER_TERMINAL_LIST_STATES, + WorkerDispatchParams, + WorkerListParams, + WorkerRetainParams +} from '../../../../../../shared/rpc-contract/orchestration-worker-release-schemas-params' diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release.ts index e121f1b3f25..236dc7cf76f 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release.ts @@ -1,4 +1,3 @@ -import { z } from 'zod' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import { defineMethod, type RpcMethod } from '../../../core' import { releaseFederatedWorker } from '../federation/federated-worker-release' @@ -10,6 +9,7 @@ import { type WorkerReleaseReceipt } from './worker-release-completion' import { WorkerDispatchParams, WorkerRetainParams } from './worker-release-schemas' +import { OrchestrationWorkerTerminalUserInputParams } from '../../../../../../shared/rpc-contract/orchestration-worker-release-params' export const ORCHESTRATION_WORKER_RELEASE_METHODS: RpcMethod[] = [ defineMethod({ @@ -135,16 +135,7 @@ export const ORCHESTRATION_WORKER_RELEASE_METHODS: RpcMethod[] = [ // `sessionId` addresses a worker that IS a structured agent session. Its pane key is a random // identity credential that never leaves main, so the caller names the session and the owning // runtime resolves it — a renderer echoing the pane key back would make it learnable. - params: z - .object({ - paneKey: z.string().min(1).optional(), - sessionId: z.string().min(1).optional(), - terminal: z.string().min(1).optional() - }) - .refine( - (value) => Boolean(value.paneKey ?? value.sessionId ?? value.terminal), - 'Missing paneKey, sessionId or terminal' - ), + params: OrchestrationWorkerTerminalUserInputParams, // Real user keystrokes durably relinquish orchestration ownership on the owning runtime, so // restarts, SSH drops, remote viewing, and renderer remounts cannot erase the takeover. handler: (params, { runtime }) => { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-schema.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-schema.ts index 2f9d9456609..b0f5328801d 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-schema.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-schema.ts @@ -1,63 +1,6 @@ -import { z } from 'zod' -import { OptionalFiniteNumber, OptionalString, requiredString } from '../../../schemas' - -export const OptionalWorkerLaunchPreference = z - .string() - .min(1) - .max(512) - .refine((value) => value === value.trim(), 'Surrounding whitespace is invalid') - .optional() - -export const WorkerStartParams = z - .object({ - task: OptionalString, - spec: OptionalString, - taskTitle: OptionalString, - deps: OptionalString, - parent: OptionalString, - on: OptionalString, - run: OptionalString, - from: requiredString('Missing --from'), - worktree: OptionalString, - name: OptionalString, - repo: OptionalString, - baseBranch: OptionalString, - displayName: OptionalString, - comment: OptionalString, - setup: z.enum(['run', 'skip', 'inherit']).optional(), - terminal: OptionalString, - agent: OptionalString, - model: OptionalWorkerLaunchPreference, - effort: OptionalWorkerLaunchPreference, - retryOf: OptionalString, - timeoutMs: OptionalFiniteNumber, - devMode: z.boolean().optional() - }) - .superRefine((params, ctx) => { - if (!params.task && !params.spec) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['task'], - message: 'Missing --task or --spec' - }) - } - if (params.task && params.spec) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['spec'], - message: '--task and --spec are mutually exclusive' - }) - } - // Why: --spec creates a new Task, so a retry link to a prior Dispatch could never resolve and - // the refusal named a Task id the caller never supplied. - if (params.retryOf && params.spec) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['retryOf'], - message: - '--retry-of needs --task naming the failed Task; --spec creates a new one' - }) - } - }) +import type { z } from 'zod' +import { WorkerStartParams } from '../../../../../../shared/rpc-contract/orchestration-worker-start-params' +export { OptionalWorkerLaunchPreference } from '../../../../../../shared/rpc-contract/orchestration-worker-start-params' +export { WorkerStartParams } export type WorkerStartInput = z.infer diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-stop.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-stop.ts index 643323cf62e..79a51ca506b 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-stop.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-stop.ts @@ -1,7 +1,5 @@ -import { z } from 'zod' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import { defineMethod, type RpcMethod } from '../../../core' -import { requiredString } from '../../../schemas' import { describeUnconfirmedAgentStop } from '../../../../../../shared/pty-liveness-verdict' import { ORCHESTRATION_WORKER_STOP_VERDICT_RUNTIME_CAPABILITY } from '../../../../../../shared/protocol-version' import type { RuntimeStatus } from '../../../../../../shared/runtime-types' @@ -12,8 +10,7 @@ import { stopStructuredWorker } from '../../orchestration-structured-worker-lifecycle' import { isStructuredWorkerHandle } from '../../../../structured-worker-identity' - -const WorkerDispatchParams = z.object({ dispatch: requiredString('Missing --dispatch') }) +import { WorkerDispatchParams } from '../../../../../../shared/rpc-contract/orchestration-worker-stop-params' export const ORCHESTRATION_WORKER_STOP_METHODS: RpcMethod[] = [ defineMethod({ diff --git a/src/main/runtime/rpc/methods/plugins.ts b/src/main/runtime/rpc/methods/plugins.ts index bb035b984eb..4d1e8d597ca 100644 --- a/src/main/runtime/rpc/methods/plugins.ts +++ b/src/main/runtime/rpc/methods/plugins.ts @@ -1,4 +1,3 @@ -import { z } from 'zod' import { defineMethod, type RpcContext, type RpcMethod } from '../core' import type { PluginPanelEntry } from '../../../../shared/plugins/plugin-panel-bridge' import { listPluginsForClients } from '../../../plugins/plugin-client-list' @@ -8,7 +7,12 @@ import { pluginConsentRequestSchema, type PluginConsentRequest } from '../../../../shared/plugins/plugin-consent-request' -import { isQualifiedPluginKey } from '../../../../shared/plugins/plugin-manifest' +import { + PluginInvokeCommandParams, + PluginReadPanelEntryParams, + PluginSetEnabledParams, + PluginsPanelActionParams +} from '../../../../shared/rpc-contract/plugins-params' /** * Serve/headless parity surface: the same consent, enablement, panel-action, @@ -45,22 +49,6 @@ function requirePluginService(): PluginService { return pluginServiceForRpc } -const PluginSetEnabledParams = z.object({ - pluginKey: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key'), - enabled: z.boolean() -}) - -const PluginReadPanelEntryParams = z.object({ - pluginKey: z.string().min(1), - panelId: z.string().min(1) -}) - -const PluginInvokeCommandParams = z.object({ - pluginKey: z.string().min(1), - commandId: z.string().min(1), - args: z.unknown().optional() -}) - async function listForRpc(): Promise { return listPluginsForClients(requirePluginService()) } @@ -118,7 +106,7 @@ export const PLUGIN_METHODS: readonly RpcMethod[] = [ name: 'plugins.panelAction', // Why: raw admission must run before strict schema parsing so malformed // and oversized traffic cannot bypass the panel budget. - params: z.unknown(), + params: PluginsPanelActionParams, handler: async (params, context) => { const service = requirePluginService() await service.whenReady() diff --git a/src/main/runtime/rpc/methods/preflight.ts b/src/main/runtime/rpc/methods/preflight.ts index f863a1941d1..9a5af8776f1 100644 --- a/src/main/runtime/rpc/methods/preflight.ts +++ b/src/main/runtime/rpc/methods/preflight.ts @@ -1,4 +1,3 @@ -import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { detectRemoteAgents, @@ -7,16 +6,11 @@ import { refreshShellPathAndDetectAgents, runPreflightCheck } from '../../../preflight/agent-detection' - -const PreflightCheck = z.object({ - force: z.boolean().optional() -}) -const PreflightDetectRemoteAgents = z.object({ - connectionId: z.string().min(1) -}) -const PreflightDetectRemoteWindowsTerminalCapabilities = z.object({ - connectionId: z.string().min(1) -}) +import { + PreflightCheck, + PreflightDetectRemoteAgents, + PreflightDetectRemoteWindowsTerminalCapabilities +} from '../../../../shared/rpc-contract/preflight-params' export const PREFLIGHT_METHODS: RpcMethod[] = [ defineMethod({ diff --git a/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts b/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts index c25671d5bed..2ef04798813 100644 --- a/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts +++ b/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts @@ -1,100 +1,13 @@ -import { z } from 'zod' -import { - LOCAL_EXECUTION_HOST_ID, - normalizeExecutionHostId, - parseExecutionHostId -} from '../../../../shared/execution-host' import { defineMethod, type RpcMethod } from '../core' -import { OptionalString, requiredString } from '../schemas' import { projectRepoResultVisibilityForClient } from '../repo-visibility-projection' - -const ProjectProviderIdentity = z.object({ - provider: z.literal('github'), - owner: requiredString('Missing project owner'), - repo: requiredString('Missing project repository'), - host: OptionalString -}) - -// Why: `runtime:` ids are minted by the calling client's own pairing store -// (addEnvironmentFromPairingCode -> randomUUID), so they name a machine only relative to that -// client. A client sending one to this runtime is addressing *us*, and runtimes do not proxy -// these calls onward, so the host it names is this machine. Persisting the caller's id verbatim -// makes one machine look like a different host to every other client, hides its rows from them, -// and defeats the (projectId, hostId) duplicate check. Store our own spelling instead: `local`. -// Rows written before this normalization keep their client-minted stamp; readers still project -// `local` back to `runtime:`, so the client-visible model is unchanged. -const RequestedHostId = requiredString('Missing host ID').transform((value, ctx) => { - const hostId = normalizeExecutionHostId(value) - if (!hostId) { - ctx.addIssue({ code: 'custom', message: 'Invalid host ID' }) - return z.NEVER - } - return parseExecutionHostId(hostId)?.kind === 'runtime' ? LOCAL_EXECUTION_HOST_ID : hostId -}) - -const ProjectHostSetupExistingFolder = z.object({ - projectId: requiredString('Missing project ID'), - projectProviderIdentity: ProjectProviderIdentity.optional(), - hostId: RequestedHostId, - path: requiredString('Missing project path'), - kind: z.enum(['git', 'folder']).optional(), - displayName: OptionalString, - setupMethod: z.enum(['imported-existing-folder', 'cloned']).optional() -}) - -const ProjectHostSetupClone = z.object({ - projectId: requiredString('Missing project ID'), - projectProviderIdentity: ProjectProviderIdentity.optional(), - hostId: RequestedHostId, - url: requiredString('Missing clone URL'), - destination: requiredString('Missing clone destination'), - displayName: OptionalString -}) - -const LocalWindowsRuntimePreference = z.discriminatedUnion('kind', [ - z.object({ kind: z.literal('inherit-global') }), - z.object({ kind: z.literal('windows-host') }), - z.object({ kind: z.literal('wsl'), distro: requiredString('Missing WSL distro') }) -]) - -const ProjectUpdate = z.object({ - projectId: requiredString('Missing project ID'), - updates: z.object({ - localWindowsRuntimePreference: LocalWindowsRuntimePreference.optional() - }) -}) - -const ProjectHostSetupCreate = z.object({ - projectId: requiredString('Missing project ID'), - hostId: RequestedHostId, - setupId: OptionalString, - path: OptionalString, - kind: z.enum(['git', 'folder']).optional(), - displayName: OptionalString, - worktreeBasePath: OptionalString, - gitUsername: OptionalString, - setupState: z.enum(['ready', 'not-set-up', 'setting-up', 'error', 'unsupported']).optional(), - setupMethod: z.enum(['imported-existing-folder', 'cloned', 'provisioned']).optional() -}) - -const ProjectHostSetupUpdate = z.object({ - setupId: requiredString('Missing setup ID'), - updates: z.object({ - displayName: OptionalString, - path: OptionalString, - worktreeBasePath: OptionalString, - setupState: z.enum(['ready', 'not-set-up', 'setting-up', 'error', 'unsupported']).optional(), - setupMethod: z - .enum(['legacy-repo', 'imported-existing-folder', 'cloned', 'provisioned']) - .optional(), - gitUsername: OptionalString, - kind: z.enum(['git', 'folder']).optional() - }) -}) - -const ProjectHostSetupDelete = z.object({ - setupId: requiredString('Missing setup ID') -}) +import { + ProjectHostSetupClone, + ProjectHostSetupCreate, + ProjectHostSetupDelete, + ProjectHostSetupExistingFolder, + ProjectHostSetupUpdate, + ProjectUpdate +} from '../../../../shared/rpc-contract/project-runtime-params' export const PROJECT_RUNTIME_METHODS: RpcMethod[] = [ defineMethod({ diff --git a/src/main/runtime/rpc/methods/repo-update-schema.ts b/src/main/runtime/rpc/methods/repo-update-schema.ts index b613b35d8ba..f4e189f84a7 100644 --- a/src/main/runtime/rpc/methods/repo-update-schema.ts +++ b/src/main/runtime/rpc/methods/repo-update-schema.ts @@ -1,76 +1,4 @@ -import { z } from 'zod' -import { OptionalFiniteNumber, OptionalString } from '../schemas' -import { sanitizeRepoIcon } from '../../../../shared/repo-icon' -import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color' -import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai' -import { - normalizeCustomWorktreeVisibilitySources, - normalizeWorktreeVisibilitySourcePreferences -} from '../../../../shared/worktree/visibility-sources' - -export const RepoSourceControlAiOverrides = z - .unknown() - .optional() - .transform((value) => - value === undefined - ? undefined - : value === null - ? null - : normalizeRepoSourceControlAiOverrides(value) - ) - -const RepoBadgeColor = z - .unknown() - .optional() - .transform((value) => - value === undefined ? undefined : (normalizeRepoBadgeColor(value) ?? undefined) - ) - -const RepoUpstream = z - .object({ - owner: z.string().min(1), - repo: z.string().min(1) - }) - .nullable() - .optional() - -export function createRepoUpdateSchema( - selectorShape: T -): z.ZodObject }> { - return z.object({ - ...selectorShape, - updates: z.object({ - displayName: OptionalString, - badgeColor: RepoBadgeColor, - repoIcon: z - .unknown() - .transform((value) => sanitizeRepoIcon(value)) - .optional(), - upstream: RepoUpstream, - hookSettings: z.unknown().optional(), - worktreeBaseRef: OptionalString, - worktreeBasePath: OptionalString, - kind: z.enum(['git', 'folder']).optional(), - symlinkPaths: z.array(z.string()).optional(), - issueSourcePreference: z.enum(['auto', 'upstream', 'origin']).optional(), - forkSyncMode: z.enum(['ask', 'safe-auto', 'off']).optional(), - externalWorktreeVisibility: z.enum(['hide', 'show']).nullable().optional(), - externalWorktreeVisibilityPromptDismissedAt: z.number().finite().optional(), - externalWorktreeInboxBaselinePaths: z.array(z.string()).optional(), - importedExternalWorktreePaths: z.array(z.string()).optional(), - agentWorktreeVisibility: z.enum(['hide', 'show']).nullable().optional(), - customWorktreeVisibilitySources: z - .unknown() - .transform((value) => normalizeCustomWorktreeVisibilitySources(value)) - .optional(), - worktreeVisibilitySourcePreferences: z - .unknown() - .transform((value) => normalizeWorktreeVisibilitySourcePreferences(value)) - .optional(), - externalWorktreeDiscoverySuppressedAt: z.number().finite().nullable().optional(), - projectGroupId: OptionalString.nullable().optional(), - projectGroupOrder: OptionalFiniteNumber, - sourceControlAi: RepoSourceControlAiOverrides - }) - }) as z.ZodObject }> -} +export { + RepoSourceControlAiOverrides, + createRepoUpdateSchema +} from '../../../../shared/rpc-contract/repo-update-params' diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index 7bf42922db8..31fc871e897 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -1,113 +1,28 @@ -import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' -import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' import { PROJECT_RUNTIME_METHODS } from './project-runtime-rpc-methods' import { FOLDER_WORKSPACE_METHODS } from './folder-workspace' -import { createRepoUpdateSchema } from './repo-update-schema' +import { RepoSelector } from './github-repo-target-schemas' import { projectRepoResultVisibilityForClient, projectRepoVisibilityForClient } from '../repo-visibility-projection' - -const RepoSelector = z.object({ - repo: requiredString('Missing repo selector') -}) - -const RepoPath = z.object({ - path: requiredString('Missing repo path'), - kind: z.enum(['git', 'folder']).optional(), - displayName: OptionalString -}) - -const RepoCreate = z.object({ - parentPath: requiredString('Missing parent path'), - name: requiredString('Missing repo name'), - kind: z.enum(['git', 'folder']).optional() -}) - -const RepoClone = z.object({ - url: requiredString('Missing clone URL'), - destination: requiredString('Missing clone destination') -}) - -const RepoSetBaseRef = z.object({ - repo: requiredString('Missing repo selector'), - ref: requiredString('Missing base ref') -}) - -const RepoUpdate = createRepoUpdateSchema(RepoSelector.shape) - -const RepoSearchRefs = z.object({ - repo: requiredString('Missing repo selector'), - query: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : undefined)) - .pipe(z.string({ message: 'Missing query' })), - limit: OptionalFiniteNumber -}) - -const RepoReorder = z.object({ - orderedIds: z.array(z.string()) -}) - -const ProjectGroupCreate = z.object({ - name: requiredString('Missing group name'), - parentPath: OptionalString, - connectionId: OptionalString.nullable().optional(), - parentGroupId: OptionalString.nullable().optional(), - createdFrom: z.enum(['manual', 'folder-scan', 'migration']).optional() -}) - -const ProjectGroupUpdate = z.object({ - groupId: requiredString('Missing group id'), - updates: z.object({ - name: OptionalString, - isCollapsed: z.boolean().optional(), - tabOrder: OptionalFiniteNumber, - color: OptionalString.nullable().optional() - }) -}) - -const ProjectGroupSelector = z.object({ - groupId: requiredString('Missing group id') -}) - -const ProjectGroupMoveProject = z.object({ - repo: requiredString('Missing repo selector'), - groupId: OptionalString.nullable(), - order: OptionalFiniteNumber -}) - -const ProjectGroupScanNested = z.object({ - path: requiredString('Missing folder path') -}) - -const ProjectGroupImportNested = z.discriminatedUnion('mode', [ - z.object({ - parentPath: requiredString('Missing parent path'), - groupName: z.string().optional().default(''), - projectPaths: z.array(z.string()), - mode: z.literal('group') - }), - z.object({ - parentPath: requiredString('Missing parent path'), - // Why: blank group names fall back to the scanned folder basename; separate - // imports do not create a group but share the same renderer payload shape. - groupName: z.string().optional().default(''), - projectPaths: z.array(z.string()), - mode: z.literal('separate') - }) -]) - -const RepoIssueCommandWrite = RepoSelector.extend({ - content: z.string() -}) - -const RepoSparsePresetSave = RepoSelector.extend({ - id: OptionalString, - name: requiredString('Missing preset name'), - directories: z.array(z.string()) -}) +import { + ProjectGroupCreate, + ProjectGroupImportNested, + ProjectGroupMoveProject, + ProjectGroupScanNested, + ProjectGroupSelector, + ProjectGroupUpdate, + RepoClone, + RepoCreate, + RepoIssueCommandWrite, + RepoPath, + RepoReorder, + RepoSearchRefs, + RepoSetBaseRef, + RepoSparsePresetSave, + RepoUpdate +} from '../../../../shared/rpc-contract/repo-params' export const REPO_METHODS: RpcMethod[] = [ defineMethod({ diff --git a/src/main/runtime/rpc/methods/runtime-client-capabilities.ts b/src/main/runtime/rpc/methods/runtime-client-capabilities.ts index a1ab53267b3..fe152fa6f27 100644 --- a/src/main/runtime/rpc/methods/runtime-client-capabilities.ts +++ b/src/main/runtime/rpc/methods/runtime-client-capabilities.ts @@ -1,12 +1,6 @@ -import { z } from 'zod' import type { RuntimeCapability } from '../../../../shared/protocol-version' import { defineMethod, type RpcAnyMethod } from '../core' - -const ClientCapabilitiesUpdate = z - .object({ - clientCapabilities: z.array(z.string().min(1).max(128)).max(64) - }) - .strict() +import { ClientCapabilitiesUpdate } from '../../../../shared/rpc-contract/runtime-client-capabilities-params' export const RUNTIME_CLIENT_CAPABILITY_METHODS: RpcAnyMethod[] = [ defineMethod({ diff --git a/src/main/runtime/rpc/methods/session-tabs-schemas.ts b/src/main/runtime/rpc/methods/session-tabs-schemas.ts index 1f44e17ea0b..ecd434f1e0a 100644 --- a/src/main/runtime/rpc/methods/session-tabs-schemas.ts +++ b/src/main/runtime/rpc/methods/session-tabs-schemas.ts @@ -1,229 +1,14 @@ -import { z } from 'zod' -import { MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH } from '../../../../shared/terminal-quick-commands' -import { isTuiAgent } from '../../../../shared/tui-agent-config' -import type { TuiAgent } from '../../../../shared/tui-agent' -import { sleepingAgentLaunchConfigSchema } from '../../../../shared/workspace-session-sleeping-agents' -import { RUNTIME_NAVIGATION_TARGETS } from '../../../../shared/runtime-navigation' -import { TAB_ACTIVATION_INTENTS } from '../../../../shared/tab-activation-intent' -import { OptionalBoolean } from '../schemas' - -export const WorktreeTabSelector = z.object({ - worktree: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing worktree selector')) -}) - -export const SessionTabsUnsubscribe = WorktreeTabSelector.extend({ - subscriptionId: z.string().min(1).optional() -}) - -export const ActivateTab = WorktreeTabSelector.extend({ - tabId: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing tab id')), - leafId: z.string().max(128).optional(), - notifyClients: OptionalBoolean, - navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(), - // Why: absent means user intent, so clients that predate this field keep the - // tab-open wake gesture. Only 'automatic' may be refused for a slept pane. - intent: z.enum(TAB_ACTIVATION_INTENTS).optional() -}) - -export const CloseTab = ActivateTab.extend({ - // Why: optional preserves authenticated legacy user closes; lifecycle intent - // uses the additive evidence-bearing method instead. - reason: z.literal('user').optional() -}) - -export const CloseLifecycleTab = ActivateTab.extend({ - reason: z.enum(['pty-exit', 'cleanup']), - publicationEpoch: z.string().min(1).max(128), - terminal: z.string().min(1).max(256) -}) - -export type TerminalPaneLayoutNodeInput = - | { type: 'leaf'; leafId: string } - | { - type: 'split' - direction: 'horizontal' | 'vertical' - first: TerminalPaneLayoutNodeInput - second: TerminalPaneLayoutNodeInput - ratio?: number - } - -// Why: this schema parses UNTRUSTED remote-client input. A recursive zod parse -// of a deeply-nested tree would overflow the main-process stack, so validate -// iteratively with hard depth + node-count caps before building the typed value. -const MAX_PANE_LAYOUT_DEPTH = 64 -const MAX_PANE_LAYOUT_NODES = 1024 - -function parseTerminalPaneLayoutNode(value: unknown): TerminalPaneLayoutNodeInput | null { - // Iterative validate-then-build: first walk the raw tree with an explicit - // stack (no recursion) enforcing caps, then build bottom-up. - let nodeCount = 0 - const stack: { raw: unknown; depth: number }[] = [{ raw: value, depth: 0 }] - while (stack.length > 0) { - const { raw, depth } = stack.pop()! - if (depth > MAX_PANE_LAYOUT_DEPTH || ++nodeCount > MAX_PANE_LAYOUT_NODES) { - return null - } - if (typeof raw !== 'object' || raw === null) { - return null - } - const node = raw as Record - if (node.type === 'leaf') { - if (typeof node.leafId !== 'string' || node.leafId.length < 1 || node.leafId.length > 128) { - return null - } - continue - } - if (node.type === 'split') { - if (node.direction !== 'horizontal' && node.direction !== 'vertical') { - return null - } - if ( - node.ratio !== undefined && - (typeof node.ratio !== 'number' || - !Number.isFinite(node.ratio) || - node.ratio < 0 || - node.ratio > 1) - ) { - return null - } - stack.push({ raw: node.first, depth: depth + 1 }, { raw: node.second, depth: depth + 1 }) - continue - } - return null - } - return value as TerminalPaneLayoutNodeInput -} - -export const TerminalPaneLayoutNodeSchema = z - .unknown() - .transform((value) => parseTerminalPaneLayoutNode(value)) - .pipe( - z.custom((value) => value !== null, { - message: 'Invalid or too-deep pane layout tree' - }) - ) - -export const UpdatePaneLayout = WorktreeTabSelector.extend({ - tabId: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing tab id')), - root: z.union([z.null(), TerminalPaneLayoutNodeSchema]), - expandedLeafId: z.string().max(128).nullable().optional(), - titlesByLeafId: z.record(z.string(), z.string()).optional() -}) - -export const SetTabProps = WorktreeTabSelector.extend({ - tabId: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing tab id')), - // undefined = leave unchanged; null = clear color / unset. - color: z.string().max(64).nullable().optional(), - isPinned: z.boolean().optional(), - // undefined = leave unchanged; no "clear" semantic (absence means default 'terminal'). - viewMode: z.enum(['terminal', 'chat']).optional() -}) - -export const CreateTerminalTab = WorktreeTabSelector.extend({ - afterTabId: z.string().optional(), - targetGroupId: z.string().optional(), - command: z.string().optional(), - cwd: z.string().min(1).optional(), - env: z.record(z.string(), z.string()).optional(), - envToDelete: z.array(z.string().min(1).max(256)).max(32).optional(), - startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(), - launchConfig: sleepingAgentLaunchConfigSchema, - launchToken: z.string().min(1).max(128).optional(), - agent: z - .custom(isTuiAgent, { - message: 'Unknown agent preset' - }) - .optional(), - // Why: agent prompts must be quoted and injected for the host shell (native, - // WSL, or SSH) instead of pasted from the mobile client before the TUI is ready. - agentPrompt: z - .string() - .max(MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH) - .refine((value) => value.trim().length > 0, { message: 'Agent prompt cannot be empty' }) - .optional(), - // Why: `agent` is the legacy preset field; `launchAgent` is the launch-plan - // identity used when preserving resume config across runtime boundaries. - launchAgent: z - .custom(isTuiAgent, { - message: 'Unknown launch agent' - }) - .optional(), - viewMode: z.enum(['terminal', 'chat']).optional(), - activate: z.boolean().optional(), - select: z.boolean().optional(), - navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(), - // Why: idempotency key so a retried create (double-tap, reconnect replay) - // returns the in-flight operation instead of spawning a duplicate terminal. - clientMutationId: z.string().min(1).max(128).optional() -}).superRefine((value, context) => { - if (value.agentPrompt !== undefined && value.agent === undefined) { - context.addIssue({ - code: 'custom', - path: ['agentPrompt'], - message: 'Agent prompt requires an agent preset' - }) - } - if (value.agentPrompt !== undefined && value.command !== undefined) { - context.addIssue({ - code: 'custom', - path: ['agentPrompt'], - message: 'Agent prompt cannot be combined with a startup command' - }) - } -}) - -const MoveTabBase = { - worktree: WorktreeTabSelector.shape.worktree, - tabId: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing tab id')), - targetGroupId: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing target group id')) -} as const - -export const MoveTab = z.discriminatedUnion('kind', [ - z - .object({ - ...MoveTabBase, - kind: z.literal('reorder'), - tabOrder: z.array(z.string().min(1)).min(1, 'Missing tab order') - }) - .strict(), - z - .object({ - ...MoveTabBase, - kind: z.literal('move-to-group'), - index: z.number().int().nonnegative().optional() - }) - .strict(), - z - .object({ - ...MoveTabBase, - kind: z.literal('split'), - splitDirection: z.enum(['left', 'right', 'up', 'down']) - }) - .strict() -]) - -export const SaveMarkdownTab = ActivateTab.extend({ - baseVersion: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing base version')), - content: z.string() -}) +export { + ActivateTab, + CloseLifecycleTab, + CloseTab, + CreateTerminalTab, + MoveTab, + SaveMarkdownTab, + SessionTabsUnsubscribe, + SetTabProps, + TerminalPaneLayoutNodeSchema, + UpdatePaneLayout, + WorktreeTabSelector +} from '../../../../shared/rpc-contract/session-tabs-schemas-params' +export type { TerminalPaneLayoutNodeInput } from '../../../../shared/rpc-contract/session-tabs-schemas-params' diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index aa0e0b24939..1f4019bad7c 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -1,4 +1,3 @@ -import { z } from 'zod' import { resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation' import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' import { @@ -18,6 +17,7 @@ import { createSessionTabsRetirementProofDelta } from './session-tabs-retirement import { restoreStructuredTabsIfSupported } from './structured-session-tab-restore' import { isStructuredNativeChatEnabled } from './structured-agent-session-policy' import { assertLegacyAiVaultResumeCommandAllowed } from '../../../ai-vault/structured-session-ownership' +import { SessionTabsUnsubscribeAllParams } from '../../../../shared/rpc-contract/session-tabs-params' export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineMethod({ @@ -181,11 +181,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ }), defineMethod({ name: 'session.tabs.unsubscribeAll', - params: z - .object({ - subscriptionId: z.string().min(1).optional() - }) - .nullish(), + params: SessionTabsUnsubscribeAllParams, handler: async (params, { runtime, connectionId }) => { const cleanupPrefix = `session.tabs:${connectionId ?? 'local'}:*` if (params?.subscriptionId) { diff --git a/src/main/runtime/rpc/methods/skills.ts b/src/main/runtime/rpc/methods/skills.ts index 13ecbefc3c7..01caa64baa6 100644 --- a/src/main/runtime/rpc/methods/skills.ts +++ b/src/main/runtime/rpc/methods/skills.ts @@ -1,5 +1,5 @@ import { defineMethod, type RpcMethod } from '../core' -import { z } from 'zod' +import type { z } from 'zod' import { getAppEnvironment } from '../../../../shared/app-environment' import { SkillDeleteRequestSchema } from '../../../../shared/skill-delete-contract' import { @@ -7,7 +7,7 @@ import { runSkillDeleteRequest, type SkillDeleteRequestDependencies } from '../../../skills/skill-delete/request-service' -import { SkillDiscoveryTargetSchema } from '../../../../shared/skills' +import type { SkillDiscoveryTargetSchema } from '../../../../shared/skills' import { SkillInstallPreviewRequestSchema, SkillInstallRequestSchema, @@ -33,6 +33,11 @@ import { AgentSkillShareRequestSchema, AgentSkillSharingError } from '../../../../shared/agent-skill-sharing-contract' +import { + SkillsCancelInstallParams, + SkillsDiscoverParams, + SkillsGetInstallProgressParams +} from '../../../../shared/rpc-contract/skills-params' /** Exported so the delete plan's root rebuild resolves its target exactly the * way `skills.discover` resolved the scan's — including WSL. */ @@ -62,7 +67,7 @@ function skillDeleteDependencies( export const SKILL_METHODS: RpcMethod[] = [ defineMethod({ name: 'skills.discover', - params: SkillDiscoveryTargetSchema.default({}), + params: SkillsDiscoverParams, handler: async (params, { runtime }) => { // Why: the executing runtime owns WSL project preferences. Remote callers // send worktree identity only; trusting their projectRuntime absence @@ -146,14 +151,14 @@ export const SKILL_METHODS: RpcMethod[] = [ }), defineMethod({ name: 'skills.cancelInstall', - params: z.object({ operationId: z.string().min(1).max(128) }).strict(), + params: SkillsCancelInstallParams, handler: (params, { runtime }) => ({ cancelled: runtime.cancelSharedSkillInstall(params.operationId) }) }), defineMethod({ name: 'skills.getInstallProgress', - params: z.object({ operationId: z.string().min(1).max(128) }).strict(), + params: SkillsGetInstallProgressParams, handler: (params, { runtime }) => { const progress = runtime.getSharedSkillInstallProgress(params.operationId) return progress ? SkillBundleInstallProgressSchema.parse(progress) : null diff --git a/src/main/runtime/rpc/methods/speech.ts b/src/main/runtime/rpc/methods/speech.ts index d686475ab3f..8e5e8bdbd4d 100644 --- a/src/main/runtime/rpc/methods/speech.ts +++ b/src/main/runtime/rpc/methods/speech.ts @@ -1,52 +1,11 @@ -import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' -import { OptionalString, requiredString } from '../schemas' - -const AUDIO_BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/ -const DICTATION_SAMPLE_RATE = 16_000 -const PCM_BYTES_PER_SAMPLE = 2 -const MAX_DICTATION_AUDIO_SECONDS = 5 -const MAX_DICTATION_AUDIO_CHUNK_BYTES = - DICTATION_SAMPLE_RATE * PCM_BYTES_PER_SAMPLE * MAX_DICTATION_AUDIO_SECONDS -const MAX_DICTATION_AUDIO_CHUNK_BASE64_LENGTH = Math.ceil(MAX_DICTATION_AUDIO_CHUNK_BYTES / 3) * 4 - -function isValidAudioBase64(value: string): boolean { - return value.length % 4 !== 1 && AUDIO_BASE64_PATTERN.test(value) -} - -const DictationStart = z.object({ - dictationId: requiredString('Missing dictation ID'), - modelId: OptionalString -}) - -const DictationChunk = z.object({ - dictationId: requiredString('Missing dictation ID'), - audioBase64: requiredString('Missing audio chunk') - // Why: feedMobileDictation decodes into Buffer + Float32Array; reject - // oversized chunks before allocation. This mirrors the mobile pending-audio budget. - .refine( - (value) => value.length <= MAX_DICTATION_AUDIO_CHUNK_BASE64_LENGTH, - 'Audio chunk is too large' - ) - // Why: Buffer.from(..., 'base64') silently drops malformed bytes; reject - // bad mobile audio chunks instead of feeding empty/corrupt PCM. - .refine(isValidAudioBase64, 'Audio chunk must be base64'), - sampleRate: z.number().finite().positive() -}) - -const DictationHandle = z.object({ - dictationId: requiredString('Missing dictation ID') -}) - -const SpeechModelAction = z.object({ - modelId: requiredString('Missing model ID') -}) - -const DictationSetup = z.object({ - enabled: z.boolean().optional(), - modelId: OptionalString, - dictationMode: z.enum(['toggle', 'hold']).optional() -}) +import { + DictationChunk, + DictationHandle, + DictationSetup, + DictationStart, + SpeechModelAction +} from '../../../../shared/rpc-contract/speech-params' export const SPEECH_METHODS: RpcMethod[] = [ defineMethod({ diff --git a/src/main/runtime/rpc/methods/ssh.ts b/src/main/runtime/rpc/methods/ssh.ts index e6cb6b47b50..8988ab3a569 100644 --- a/src/main/runtime/rpc/methods/ssh.ts +++ b/src/main/runtime/rpc/methods/ssh.ts @@ -1,4 +1,3 @@ -import { z } from 'zod' import { connectRegisteredSshTarget, getRegisteredSshState, @@ -8,10 +7,7 @@ import { import { defineMethod, type RpcMethod } from '../core' import { getPublicSshError, getPublicSshState } from '../../public-ssh-state' import type { SshTargetSummary } from '../../../../shared/ssh-types' - -const SshTarget = z.object({ - targetId: z.string().min(1) -}) +import { SshTarget } from '../../../../shared/rpc-contract/ssh-params' // Why: `generation` stays optional on the wire — an old server simply omits it and its rows key on target id alone. function listRegisteredSshTargetSummaries(): SshTargetSummary[] { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index 5c7f40d7f35..7725e70bded 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -2,246 +2,25 @@ // // Strict objects throughout: zod drops unknown keys, and a silently dropped key // is how a newer client's field becomes a different effect on an older host. - -import { z } from 'zod' -import { isAgentSessionId } from '../../../../shared/agent-session-record' -import { - AGENT_SESSION_HISTORY_DIRECTIONS, - AGENT_SESSION_HISTORY_MAX_LIMIT -} from '../../../../shared/agent-session-wire' -import { normalizeExecutionHostId } from '../../../../shared/execution-host' - -const MAX_ID_LENGTH = 512 -// Four Claude questions with all four generated choices occupy 610 chars when fully percent-encoded. -const MAX_RESPONSE_OPTION_ID_LENGTH = 1024 -const MAX_PROMPT_BYTES = 256 * 1024 -const MAX_BLOCKS = 64 -const MAX_OPTION_LABEL = 512 - -export const SessionId = z - .string() - .max(MAX_ID_LENGTH) - .refine(isAgentSessionId, 'Invalid agent session id') - -const Identifier = (message: string, maxLength = MAX_ID_LENGTH) => - z - .string() - .min(1, message) - .max(maxLength, message) - .refine((value) => value === value.trim(), message) - -export const JournalCursor = z - .object({ - epoch: Identifier('Invalid journal epoch'), - sequence: z.number().int().nonnegative() - }) - .strict() - -export const MutationEnvelope = z - .object({ - sessionId: SessionId, - clientOperationId: Identifier('Invalid client operation id'), - /** Null is the "must not exist yet" case; every other call fences. */ - expectedRuntimeFence: z.number().int().positive().nullable(), - payloadFingerprint: z - .string() - .regex(/^[0-9a-f]{64}$/, 'Payload fingerprint must be a sha256 hex digest') - }) - .strict() - -const ProviderHandle = z.discriminatedUnion('kind', [ - z.object({ kind: z.literal('codex'), threadId: Identifier('Invalid thread id') }).strict(), - z - .object({ - kind: z.literal('claude'), - sessionId: Identifier('Invalid provider session id'), - leafUuid: Identifier('Invalid leaf uuid').nullable() - }) - .strict() -]) - -const ExecutionHostId = z - .string() - .max(MAX_ID_LENGTH) - .transform((value) => normalizeExecutionHostId(value)) - .refine((value): value is NonNullable => value !== null, { - message: 'Invalid execution host id' - }) - -const ExecutionLocation = z - .object({ - executionHostId: ExecutionHostId, - wslDistro: Identifier('Invalid WSL distro').nullable(), - workspaceId: Identifier('Invalid workspace id'), - workspaceKind: z.enum(['git-worktree', 'folder']) - }) - .strict() - -const AccountHome = z - .object({ - variable: z.enum(['CLAUDE_CONFIG_DIR', 'CODEX_HOME']), - path: z.string().min(1).max(4096) - }) - .strict() - -export const AttachParams = z - .object({ - envelope: MutationEnvelope, - location: ExecutionLocation, - provider: z.enum(['codex', 'claude']), - agent: Identifier('Invalid agent'), - accountHome: AccountHome, - runtimeKind: z.enum(['native', 'tui']), - providerHandle: ProviderHandle - }) - .strict() - -/** An identity, and nothing the host would otherwise read off disk. A transcript path or account - * home here would let a client choose which file this host imports and which credential directory - * the provider child launches against; both are derived host-side from this id instead. */ -const ResumeSource = z - .object({ - providerSessionId: Identifier('Invalid provider session id') - }) - .strict() - -export const CreateIntentParams = z - .object({ - envelope: MutationEnvelope, - worktree: Identifier('Invalid worktree selector'), - agent: z.enum(['claude', 'codex']), - resumeFrom: ResumeSource.optional() - }) - .strict() - -export const CreateParams = z.union([AttachParams, CreateIntentParams]) - -export const CreateSupportParams = z - .object({ - worktree: Identifier('Invalid worktree selector'), - agent: z.enum(['claude', 'codex']) - }) - .strict() - -/** Clients may only author user turns. Accepting an assistant or tool role here - * would let one client write words into the agent's mouth in another's - * timeline, and the provider — not the client — owns those. */ -const SendBlock = z.discriminatedUnion('type', [ - z.object({ type: z.literal('text'), text: z.string() }).strict(), - z - .object({ - type: z.literal('image-ref'), - path: z.string().min(1).max(4096).optional(), - url: z.string().min(1).max(4096).optional(), - alt: z.string().max(MAX_OPTION_LABEL).optional() - }) - .strict() - .refine( - (value) => Boolean(value.path) !== Boolean(value.url), - 'Provide exactly one of path/url' - ) -]) - -export const SendParams = z - .object({ - envelope: MutationEnvelope, - retryUnknown: z.literal(true).optional(), - body: z - .object({ - kind: z.literal('message'), - role: z.literal('user'), - blocks: z.array(SendBlock).min(1).max(MAX_BLOCKS) - }) - .strict() - .refine( - (value) => Buffer.byteLength(JSON.stringify(value.blocks), 'utf8') <= MAX_PROMPT_BYTES, - 'Message is too large' - ) - }) - .strict() - -export const CancelParams = z - .object({ - envelope: MutationEnvelope, - turnId: Identifier('Invalid turn id'), - scope: z.literal('background-tasks').optional(), - taskId: Identifier('Invalid task id').optional() - }) - .strict() - .refine((value) => value.taskId === undefined || value.scope === 'background-tasks', { - message: 'A task id requires background-task scope' - }) - -export const RespondParams = z - .object({ - envelope: MutationEnvelope, - itemId: Identifier('Invalid item id'), - /** Compare-and-set: the revision the client had on screen. */ - expectedRevision: z.number().int().positive(), - optionId: Identifier('Invalid option id', MAX_RESPONSE_OPTION_ID_LENGTH) - }) - .strict() - -export const SetOptionParams = z - .object({ - envelope: MutationEnvelope, - key: Identifier('Invalid option key'), - value: z.string().max(MAX_OPTION_LABEL) - }) - .strict() - -export const HandoffParams = z - .object({ - envelope: MutationEnvelope, - direction: z.enum(['to-tui', 'to-native']), - mode: z.enum(['now', 'after-turn', 'stop-turn']), - action: z.enum(['start', 'cancel-queued', 'retry', 'recover']).optional() - }) - .strict() - -export const OptionsParams = z.object({ sessionId: SessionId }).strict() - -export const ConversationCommandParams = z - .object({ - envelope: MutationEnvelope, - command: z.enum(['clear', 'compact']) - }) - .strict() - -/** One surface's claim on one session. The id names the surface, not the client: two chat views - * looking at the same session are two holders, and either leaving must not release - * the other's. */ -export const HoldParams = z - .object({ sessionId: SessionId, holderId: Identifier('Invalid holder id') }) - .strict() - -export const HistoryParams = z - .object({ - sessionId: SessionId, - direction: z.enum(AGENT_SESSION_HISTORY_DIRECTIONS), - cursor: JournalCursor.optional(), - limit: z.number().int().positive().max(AGENT_SESSION_HISTORY_MAX_LIMIT).optional() - }) - .strict() - -export const SubscribeParams = z - .object({ sessionId: SessionId, cursor: JournalCursor.optional() }) - .strict() - -export const UnsubscribeParams = z - .object({ - sessionId: SessionId, - subscriptionId: Identifier('Invalid subscription id').optional() - }) - .strict() - -/** Read-only owner classification retained for restart safety; mutation handoff is separate. */ -export const HandoffStatusParams = z.object({ sessionId: SessionId }).strict() - -export const RewindParams = z - .object({ - envelope: MutationEnvelope, - itemId: Identifier('Invalid item id', 4096), - expectedEpoch: Identifier('Invalid journal epoch') - }) - .strict() +export { + AttachParams, + CancelParams, + ConversationCommandParams, + CreateIntentParams, + CreateParams, + CreateSupportParams, + HandoffParams, + HandoffStatusParams, + HistoryParams, + HoldParams, + JournalCursor, + MutationEnvelope, + OptionsParams, + RespondParams, + RewindParams, + SendParams, + SessionId, + SetOptionParams, + SubscribeParams, + UnsubscribeParams +} from '../../../../shared/rpc-contract/structured-agent-session-params' diff --git a/src/main/runtime/rpc/methods/task-resume-state-schema.ts b/src/main/runtime/rpc/methods/task-resume-state-schema.ts index fc85cad6270..f923d3993b3 100644 --- a/src/main/runtime/rpc/methods/task-resume-state-schema.ts +++ b/src/main/runtime/rpc/methods/task-resume-state-schema.ts @@ -1,37 +1,8 @@ -import { z } from 'zod' +import type { z } from 'zod' import type { TaskResumeState as TaskResumeStateType } from '../../../../shared/ui-chrome-types' import type { AssertNoMissingKeys } from './ui-state-schema-parity' - -/** - * Tasks page-position state persisted through `ui.set`; mirrors `TaskResumeState`. - * - * This object is `.strict()` and sits behind `ui.set`'s field-level `.catch`, so a key - * a host predates makes that host drop the ENTIRE resume state — github and jira with - * it — and report success. Only add a field here when clients must agree on it across - * versions; per-device view preferences belong in client-local storage instead. - */ -export const TaskResumeState = z - .object({ - githubMode: z.enum(['items', 'project']).optional(), - githubItemsPreset: z.string().nullable().optional(), - githubItemsQuery: z.string().optional(), - githubProjectHiddenFieldIdsByView: z.record(z.string(), z.array(z.string())).optional(), - linearMode: z.enum(['issues', 'projects', 'views', 'in-orca']).optional(), - linearPreset: z.enum(['assigned', 'created', 'all', 'completed']).optional(), - linearQuery: z.string().optional(), - linearContext: z - .object({ - kind: z.enum(['project', 'view']), - id: z.string(), - workspaceId: z.string(), - model: z.enum(['issue', 'project']).optional() - }) - .strict() - .optional(), - jiraPreset: z.enum(['assigned', 'reported', 'all', 'done']).optional(), - jiraQuery: z.string().optional() - }) - .strict() +import { TaskResumeState } from '../../../../shared/rpc-contract/task-resume-state-params' +export { TaskResumeState } const _taskResumeStateParity: AssertNoMissingKeys< TaskResumeStateType, diff --git a/src/main/runtime/rpc/methods/terminal-orphan.ts b/src/main/runtime/rpc/methods/terminal-orphan.ts index 97296d0fac5..7ca7cd771b1 100644 --- a/src/main/runtime/rpc/methods/terminal-orphan.ts +++ b/src/main/runtime/rpc/methods/terminal-orphan.ts @@ -1,108 +1,5 @@ -import { z } from 'zod' -import type { TabGroupLayoutNode } from '../../../../shared/tab-types' -import { isPtyIncarnationId, type PtyIncarnationId } from '../../../../shared/pty-incarnation' import { defineMethod, type RpcAnyMethod } from '../core' -import { OptionalString, requiredString } from '../schemas' -import { TerminalPaneLayoutNodeSchema } from './session-tabs-schemas' - -function parseOrphanGroupLayout(value: unknown): TabGroupLayoutNode | null { - const stack: { value: unknown; depth: number }[] = [{ value, depth: 0 }] - let count = 0 - while (stack.length > 0) { - const current = stack.pop()! - if ( - current.depth > 64 || - ++count > 1_024 || - !current.value || - typeof current.value !== 'object' - ) { - return null - } - const node = current.value as Record - if (node.type === 'leaf') { - if ( - typeof node.groupId !== 'string' || - node.groupId.length < 1 || - node.groupId.length > 256 - ) { - return null - } - continue - } - if ( - node.type !== 'split' || - (node.direction !== 'horizontal' && node.direction !== 'vertical') || - (node.ratio !== undefined && - (typeof node.ratio !== 'number' || - !Number.isFinite(node.ratio) || - node.ratio < 0 || - node.ratio > 1)) - ) { - return null - } - stack.push( - { value: node.first, depth: current.depth + 1 }, - { value: node.second, depth: current.depth + 1 } - ) - } - return value as TabGroupLayoutNode -} - -const TerminalOrphanGroupLayout = z - .unknown() - .transform(parseOrphanGroupLayout) - .pipe(z.custom((value) => value !== null, 'Invalid orphan group layout')) - -const TerminalOrphanTopology = z.object({ - tabs: z - .array( - z.object({ - tabId: requiredString('Missing topology tab id').pipe(z.string().max(256)), - root: TerminalPaneLayoutNodeSchema, - activeLeafId: requiredString('Missing active leaf id').pipe(z.string().max(128)), - expandedLeafId: z.string().max(128).nullable() - }) - ) - .min(1) - .max(64), - groups: z - .array( - z.object({ - id: z.string().min(1).max(256), - activeTabId: z.string().min(1).max(256), - tabOrder: z.array(z.string().min(1).max(256)).min(1).max(64), - recentTabIds: z.array(z.string().min(1).max(256)).max(64).optional() - }) - ) - .min(1) - .max(64), - groupLayout: TerminalOrphanGroupLayout.optional() -}) - -const TerminalOrphanIncarnationId = z.custom( - isPtyIncarnationId, - 'Invalid PTY incarnation' -) - -const TerminalAdoptOrphans = z.object({ - worktree: requiredString('Missing worktree selector').pipe(z.string().max(32_768)), - expectedTopologyRevision: z.number().int().nonnegative(), - claims: z - .array( - z.object({ - terminal: requiredString('Missing terminal handle').pipe(z.string().max(256)), - ptyId: requiredString('Missing PTY id').pipe(z.string().max(8_192)), - incarnationId: TerminalOrphanIncarnationId, - tabId: requiredString('Missing tab id').pipe(z.string().max(256)), - leafId: requiredString('Missing leaf id').pipe(z.string().max(128)) - }) - ) - .min(1) - .max(64), - activeTabId: OptionalString.pipe(z.string().max(256).optional()), - activeGroupId: OptionalString.pipe(z.string().max(256).optional()), - topology: TerminalOrphanTopology.optional() -}) +import { TerminalAdoptOrphans } from '../../../../shared/rpc-contract/terminal-orphan-params' export const TERMINAL_ORPHAN_METHODS: RpcAnyMethod[] = [ defineMethod({ diff --git a/src/main/runtime/rpc/methods/terminal-quick-command-rpc-schema.ts b/src/main/runtime/rpc/methods/terminal-quick-command-rpc-schema.ts index 18661f10b57..9d4511bbd4d 100644 --- a/src/main/runtime/rpc/methods/terminal-quick-command-rpc-schema.ts +++ b/src/main/runtime/rpc/methods/terminal-quick-command-rpc-schema.ts @@ -1,73 +1 @@ -import { z } from 'zod' -import type { TerminalQuickCommand } from '../../../../shared/terminal-quick-command-types' -import { - MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH, - MAX_QUICK_COMMAND_ID_LENGTH, - MAX_QUICK_COMMAND_LABEL_LENGTH, - MAX_QUICK_COMMAND_REPO_ID_LENGTH, - MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH, - normalizeTerminalQuickCommands, - supportsTerminalAgentQuickCommand -} from '../../../../shared/terminal-quick-commands' - -const TerminalQuickCommandScopeUpdate = z.discriminatedUnion('type', [ - z.object({ type: z.literal('global') }).strict(), - z - .object({ - type: z.literal('repo'), - repoId: z.string().max(MAX_QUICK_COMMAND_REPO_ID_LENGTH) - }) - .strict() -]) - -const TerminalQuickCommandUpdateItem = z.union([ - z - .object({ - id: z.string().max(MAX_QUICK_COMMAND_ID_LENGTH), - label: z.string().max(MAX_QUICK_COMMAND_LABEL_LENGTH), - action: z.literal('terminal-command').optional(), - command: z.string().max(MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH), - appendEnter: z.boolean(), - scope: TerminalQuickCommandScopeUpdate.optional() - }) - .strict(), - z - .object({ - id: z.string().max(MAX_QUICK_COMMAND_ID_LENGTH), - label: z.string().max(MAX_QUICK_COMMAND_LABEL_LENGTH), - action: z.literal('agent-prompt'), - agent: z.custom(supportsTerminalAgentQuickCommand, { - message: 'Agent does not support prompt commands' - }), - prompt: z.string().max(MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH), - scope: TerminalQuickCommandScopeUpdate.optional() - }) - .strict() -]) - -export const TerminalQuickCommandsUpdate = z - .object({ - // Why: a single host-side mutation preserves unrelated desktop/mobile edits - // and avoids retransmitting the full ~240 KB list for every small change. - mutation: z.union([ - z - .object({ - type: z.literal('upsert'), - command: TerminalQuickCommandUpdateItem.transform( - (value) => normalizeTerminalQuickCommands([value])[0] - ).pipe( - z.custom((value) => value !== undefined, { - message: 'Quick command cannot be normalized' - }) - ) - }) - .strict(), - z - .object({ - type: z.literal('delete'), - id: z.string().min(1).max(MAX_QUICK_COMMAND_ID_LENGTH) - }) - .strict() - ]) - }) - .strict() +export { TerminalQuickCommandsUpdate } from '../../../../shared/rpc-contract/terminal-quick-command-params' diff --git a/src/main/runtime/rpc/methods/terminal/stream-schemas.ts b/src/main/runtime/rpc/methods/terminal/stream-schemas.ts index 04a6990fe51..f3e6e3a7870 100644 --- a/src/main/runtime/rpc/methods/terminal/stream-schemas.ts +++ b/src/main/runtime/rpc/methods/terminal/stream-schemas.ts @@ -1,43 +1,12 @@ import { z } from 'zod' import { requiredString } from '../../schemas' import { TerminalViewport } from './unary-schemas' - -const TerminalHandle = z.object({ terminal: requiredString('Missing terminal handle') }) - -export const TerminalResizeForClient = z.discriminatedUnion('mode', [ - z.object({ - terminal: requiredString('Missing terminal handle'), - mode: z.literal('mobile-fit'), - cols: z.number().finite().positive(), - rows: z.number().finite().positive(), - clientId: requiredString('Missing client ID') - }), - z.object({ - terminal: requiredString('Missing terminal handle'), - mode: z.literal('restore'), - clientId: requiredString('Missing client ID') - }) -]) - -export const TerminalSubscribe = TerminalHandle.extend({ - client: z - .object({ - id: requiredString('Missing client ID'), - type: z.enum(['mobile', 'desktop']).default('desktop') - }) - .optional(), - viewport: TerminalViewport.optional(), - capabilities: z - .object({ - terminalBinaryStream: z.literal(1).optional(), - desktopViewportClaims: z.literal(1).optional(), - mobileInputLeaseOnly: z.literal(1).optional(), - writeUnavailable: z.literal(1).optional() - }) - .optional() -}) - -export const TerminalMultiplex = z.object({}) +import { TerminalHandle } from '../../../../../shared/rpc-contract/terminal-stream-params' +export { + TerminalMultiplex, + TerminalResizeForClient, + TerminalSubscribe +} from '../../../../../shared/rpc-contract/terminal-stream-params' export const TerminalMultiplexSubscribeFrame = TerminalHandle.extend({ streamId: z.number().int().min(1), diff --git a/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts index 71feee4f24d..d71ac53e249 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts @@ -1,4 +1,3 @@ -import { z } from 'zod' import { defineMethod, type RpcAnyMethod } from '../../core' import { TerminalHandle } from './unary-schemas' import { @@ -8,6 +7,7 @@ import { TerminalUpdateViewport } from './viewport-schemas' import { updateViewportForClient } from './terminal-viewport-update' +import { TerminalGetAutoRestoreFitParams } from '../../../../../shared/rpc-contract/terminal-viewport-methods-params' export const TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS: RpcAnyMethod[] = [ defineMethod({ @@ -105,7 +105,7 @@ export const TERMINAL_VIEWPORT_METHODS_AFTER_STREAMS: RpcAnyMethod[] = [ }), defineMethod({ name: 'terminal.getAutoRestoreFit', - params: z.object({}), + params: TerminalGetAutoRestoreFitParams, handler: async (_params, { runtime }) => ({ ms: runtime.getMobileAutoRestoreFitMs() }) diff --git a/src/main/runtime/rpc/methods/terminal/unary-schemas.ts b/src/main/runtime/rpc/methods/terminal/unary-schemas.ts index 89928128e69..0b7f9d90408 100644 --- a/src/main/runtime/rpc/methods/terminal/unary-schemas.ts +++ b/src/main/runtime/rpc/methods/terminal/unary-schemas.ts @@ -1,222 +1,22 @@ -import { z } from 'zod' -import { OptionalFiniteNumber, OptionalString, requiredString } from '../../schemas' -import { TERMINAL_PANE_SPLIT_SOURCES } from '../../../../../shared/feature-education-telemetry' -import { isTuiAgent } from '../../../../../shared/tui-agent-config' - -export const TerminalHandle = z.object({ - terminal: requiredString('Missing terminal handle'), - // Additive fence understood by newer hosts; legacy hosts safely ignore it. - expectedIncarnationId: requiredString('Missing PTY incarnation').optional() -}) - -export const TerminalFocus = TerminalHandle.extend({ - navigation: z.enum(['caller', 'host']).optional() -}) - -/** - * `terminal.inspectProcess` carries one member the sibling handle methods must not: whether the - * caller's answer decides something once, which is what licenses the host to pay for a process-table - * read. Extended rather than added to `TerminalHandle` so `clearBuffer`/`agentStatus`/`isRunningAgent` - * keep refusing an option they have no use for. - */ -export const TerminalInspectProcess = TerminalHandle.extend({ - // Additive request member understood by newer hosts; legacy hosts safely ignore it. - scanChildProcesses: z.boolean().optional() -}) - -export const TerminalListParams = z.object({ - worktree: OptionalString, - limit: OptionalFiniteNumber, - handles: z - .array(requiredString('Missing terminal handle').pipe(z.string().max(256))) - .max(64) - .optional(), - requireFreshPtyLiveness: z.boolean().optional(), - // Why: layouts are ~31% of a large listing and only the human CLI formatter - // reads them. Absent means "include" so pre-flag clients keep rendering them. - includeVisualLayouts: z.boolean().optional() -}) - -export const TerminalResolveActive = z.object({ - worktree: OptionalString, - /** Refuse instead of guessing when several leaves could be the caller's own terminal. */ - requireUnambiguous: z.boolean().optional() -}) - -export const TerminalResolvePane = z.object({ - paneKey: requiredString('Missing pane key'), - worktreeId: OptionalString -}) - -export const TerminalRecoverPane = z.object({ - paneKey: requiredString('Missing pane key'), - worktreeId: requiredString('Missing worktree ID'), - expectedTerminal: requiredString('Missing expected terminal handle').optional() -}) - -export const TerminalRead = TerminalHandle.extend({ - cursor: z - .unknown() - .transform((value) => { - if (value === undefined) { - return undefined - } - if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { - return Number.NaN - } - return value - }) - .pipe( - z - .number() - .optional() - .refine((v) => v === undefined || Number.isFinite(v), { - message: 'Cursor must be a non-negative integer' - }) - ) - .optional(), - limit: OptionalFiniteNumber, - // Why: optional so an older host that does not understand it simply drops the key and answers - // with its usual stream read; the response's `source` is what tells the caller which it got. - screen: z.literal(true).optional() -}).refine((params) => !(params.screen === true && params.cursor !== undefined), { - // Why: a cursor pages through accumulated output; a screen is the current frame with nothing - // behind it. Honoring both would answer with rendered lines carrying the stream's pagination - // metadata — two frames of reference in one payload, which is the confusion `source` exists to - // remove. The CLI already refuses the pair, but the RPC is reachable without it. - message: 'Cursor cannot be combined with a screen read' -}) - -// Why: preserve the legacy contract — `title: string | null` only, `undefined` rejected, so the CLI's "reset" signal stays distinct. -export const TerminalRename = TerminalHandle.extend({ - title: z.custom((value) => value === null || typeof value === 'string', { - message: 'Missing --title (pass empty string or null to reset)' - }) -}) - -export const TerminalSend = TerminalHandle.extend({ - text: OptionalString, - enter: z.unknown().optional(), - interrupt: z.unknown().optional(), - // Why: older hosts strip this optional intent and retain their direct-send behavior. - agentPrompt: z.literal(true).optional(), - // Why: waiting observes the same prompt receipt; it never authorizes a second write. - waitSubmitMs: z.number().int().min(0).max(3_600_000).optional(), - resolvedLaunchDraft: z - .object({ - text: z.string(), - createdAt: z.number().finite() - }) - .optional(), - requireAgentStatus: z.enum(['sendable']).optional(), - // Why: terminal-generated replies are valid input but must not transfer the shared terminal floor. - inputKind: z.enum(['query-reply']).optional(), - // Why: identifies the caller for the driver state machine; when absent (older clients) the server falls back to the most recent mobile actor (docs/mobile-presence-lock.md). - client: z - .object({ - id: requiredString('Missing client ID'), - type: z.enum(['mobile', 'desktop']).default('desktop').optional() - }) - .optional(), - viewport: z - .object({ - cols: z.number().int().min(1).max(1000), - rows: z.number().int().min(1).max(500) - }) - .optional(), - claimViewport: z.literal(true).optional() -}) - -export const TerminalViewport = z.object({ - cols: z.number().int().min(1).max(1000), - rows: z.number().int().min(1).max(500) -}) - -export const TerminalWait = TerminalHandle.extend({ - for: z.custom<'exit' | 'tui-idle'>((value) => value === 'exit' || value === 'tui-idle', { - message: 'Invalid --for value. Supported: exit, tui-idle' - }), - timeoutMs: OptionalFiniteNumber -}) - -export const TerminalCreateParams = z.object({ - worktree: OptionalString, - clientMutationId: z.string().min(1).max(128).optional(), - reconcileExisting: z.boolean().optional(), - command: OptionalString, - startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(), - env: z.record(z.string(), z.string()).optional(), - envToDelete: z.array(z.string().min(1).max(256)).max(32).optional(), - launchConfig: z - .object({ - agentCommand: z.string().optional(), - agentArgs: z.string(), - agentEnv: z.record(z.string(), z.string()), - ompResumeFilePath: z - .string() - .min(1) - .max(32 * 1024) - .optional() - }) - .optional(), - resumeProviderSession: z - .object({ - key: z.enum(['session_id', 'conversation_id']), - id: z.string().min(1).max(512), - transcriptPath: z.string().min(1).max(32_768).optional() - }) - .optional(), - launchToken: OptionalString, - launchAgent: z.string().refine(isTuiAgent).optional(), - terminalColorQueryReplies: z - .object({ - foreground: z.string().max(128).optional(), - background: z.string().max(128).optional() - }) - .optional(), - title: OptionalString, - focus: z.unknown().optional(), - rendererBacked: z.unknown().optional(), - activate: z.unknown().optional(), - presentation: z.enum(['background', 'focused']).optional(), - tabId: OptionalString, - leafId: OptionalString -}) - -export const TerminalSplit = TerminalHandle.extend({ - direction: z - .unknown() - .transform((v) => (v === 'vertical' || v === 'horizontal' ? v : undefined)) - .pipe(z.union([z.enum(['vertical', 'horizontal']), z.undefined()])) - .optional(), - command: OptionalString, - env: z.record(z.string(), z.string()).optional(), - telemetrySource: z.enum(TERMINAL_PANE_SPLIT_SOURCES).optional() -}) - -export const TerminalStop = z.object({ - worktree: requiredString('Missing worktree selector') -}) - -export const TerminalCloseAll = TerminalStop - -export const TerminalSleep = TerminalStop - -export const TerminalStopExact = TerminalStop.extend({ - expectedPtyIds: z.array(requiredString('Missing PTY ID')).min(1), - keepHistory: z.boolean().optional(), - targetOnly: z.boolean().optional() -}) - -export const AgentTeamsTmuxCompat = z.object({ - teamId: requiredString('Missing agent team ID'), - token: requiredString('Missing agent team token'), - envPane: requiredString('Missing tmux pane identity'), - cwd: OptionalString, - argv: z.array(z.string()) -}) - -export const AgentTeamsPrepareLaunch = z.object({ - paneKey: requiredString('Missing pane key'), - env: z.record(z.string(), z.string()).optional() -}) +export { + AgentTeamsPrepareLaunch, + AgentTeamsTmuxCompat, + TerminalCloseAll, + TerminalCreateParams, + TerminalFocus, + TerminalHandle, + TerminalInspectProcess, + TerminalListParams, + TerminalRead, + TerminalRecoverPane, + TerminalRename, + TerminalResolveActive, + TerminalResolvePane, + TerminalSend, + TerminalSleep, + TerminalSplit, + TerminalStop, + TerminalStopExact, + TerminalViewport, + TerminalWait +} from '../../../../../shared/rpc-contract/terminal-unary-params' diff --git a/src/main/runtime/rpc/methods/terminal/viewport-schemas.ts b/src/main/runtime/rpc/methods/terminal/viewport-schemas.ts index d9b76d66ea1..70ecd9037d1 100644 --- a/src/main/runtime/rpc/methods/terminal/viewport-schemas.ts +++ b/src/main/runtime/rpc/methods/terminal/viewport-schemas.ts @@ -1,51 +1,6 @@ -import { z } from 'zod' -import { requiredString } from '../../schemas' - -const TerminalHandle = z.object({ terminal: requiredString('Missing terminal handle') }) - -export const TerminalSetDisplayMode = TerminalHandle.extend({ - // Why: 'auto' = mobile drives dims while subscribed (desktop restores on last-leave); 'desktop' = no resize, mobile scales to fit. - mode: z.enum(['auto', 'desktop']), - // Why: identifies the caller for the driver state machine; optional for older mobile clients. - client: z - .object({ - id: requiredString('Missing client ID'), - type: z.enum(['mobile', 'desktop']).default('desktop').optional() - }) - .optional(), - // Why: carries the measured viewport so an 'auto' toggle on a viewport-less record can phone-fit instead of no-op'ing. - viewport: z - .object({ - cols: z.number().int().positive(), - rows: z.number().int().positive() - }) - .optional() -}) - -export const TerminalUnsubscribe = z.object({ - subscriptionId: requiredString('Missing subscription ID'), - // Why: lets the server rebuild the composite `${terminal}:${clientId}` cleanup key when older clients pass a bare subscriptionId (docs/mobile-presence-lock.md). - client: z - .object({ - id: requiredString('Missing client ID') - }) - .optional() -}) - -// Why: in-place update avoids an unsubscribe→resubscribe that flashed the lock banner and stranded the PTY at phone dims (docs/mobile-presence-lock.md). -export const TerminalUpdateViewport = TerminalHandle.extend({ - client: z.object({ - id: requiredString('Missing client ID'), - type: z.enum(['mobile', 'desktop']).default('mobile').optional() - }), - viewport: z.object({ - cols: z.number().int().min(20).max(240), - rows: z.number().int().min(8).max(120) - }), - claim: z.boolean().optional() -}) - -// Why: phone-fit auto-restore preference (docs/mobile-fit-hold.md); `null` = Indefinite, finite ms clamped to [5_000, 60min] server-side. -export const TerminalSetAutoRestoreFit = z.object({ - ms: z.number().nullable() -}) +export { + TerminalSetAutoRestoreFit, + TerminalSetDisplayMode, + TerminalUnsubscribe, + TerminalUpdateViewport +} from '../../../../../shared/rpc-contract/terminal-viewport-schemas-params' diff --git a/src/main/runtime/rpc/methods/ui-update-value-tolerance.ts b/src/main/runtime/rpc/methods/ui-update-value-tolerance.ts index 1b8ca0c2cd4..e03a9bfad06 100644 --- a/src/main/runtime/rpc/methods/ui-update-value-tolerance.ts +++ b/src/main/runtime/rpc/methods/ui-update-value-tolerance.ts @@ -1,25 +1,4 @@ -import type { z } from 'zod' - -/** - * `UiUpdate` rides App.tsx's debounced writer, so one drifted enum member used - * to fail the WHOLE batch and silently drop sidebar widths, filters and agent - * acks alongside it. Degrade instead: a value the schema cannot express is - * dropped from the payload and the rest of the batch still lands. Unknown KEYS - * stay a hard rejection — the parity assertions exist to catch those. - */ -export function tolerateUnknownValues(shape: TShape): TShape { - return Object.fromEntries( - Object.entries(shape).map(([key, schema]) => [ - key, - (schema as z.ZodType).catch(() => undefined) - ]) - ) as unknown as TShape -} - -/** Drops the `undefined` entries `tolerateUnknownValues` leaves behind, so a - * rejected value reads as absent rather than as an explicit clear. */ -export function omitUndefinedValues>(value: TValue): TValue { - return Object.fromEntries( - Object.entries(value).filter(([, entry]) => entry !== undefined) - ) as TValue -} +export { + omitUndefinedValues, + tolerateUnknownValues +} from '../../../../shared/rpc-contract/ui-update-value-tolerance-params' diff --git a/src/main/runtime/rpc/methods/updater.ts b/src/main/runtime/rpc/methods/updater.ts index 1baa2aff53b..357f07a8c09 100644 --- a/src/main/runtime/rpc/methods/updater.ts +++ b/src/main/runtime/rpc/methods/updater.ts @@ -1,11 +1,11 @@ import { defineMethod, type RpcMethod } from '../core' -import { z } from 'zod' import { checkRemoteServerUpdater, downloadRemoteServerUpdater, getRemoteServerUpdaterSnapshot, installRemoteServerUpdater } from '../../remote-server-updater' +import { UpdaterCheckParams } from '../../../../shared/rpc-contract/updater-params' export const UPDATER_METHODS: RpcMethod[] = [ defineMethod({ @@ -15,10 +15,7 @@ export const UPDATER_METHODS: RpcMethod[] = [ }), defineMethod({ name: 'updater.check', - params: z.object({ - includePrerelease: z.boolean().optional(), - includePerfPrerelease: z.boolean().optional() - }), + params: UpdaterCheckParams, handler: (params, { runtime }) => checkRemoteServerUpdater(runtime.getRuntimeId(), params) }), defineMethod({ diff --git a/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.ts b/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.ts index 624a63ff44e..11e6cab4ee3 100644 --- a/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.ts +++ b/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.ts @@ -1,30 +1 @@ -import { z } from 'zod' -import { - normalizeWorkspaceCleanupBrowseState, - type WorkspaceCleanupBrowseState -} from '../../../../shared/workspace-cleanup-browse-state' - -const WorkspaceCleanupDismissal = z.object({ - worktreeId: z.string(), - dismissedAt: z.number().finite(), - fingerprint: z.string(), - classifierVersion: z.number().finite(), - executionHostId: z.string().min(1).optional() -}) - -/** - * Deliberately unvalidated shape, then normalized: the filter groups must NOT be - * strict or enumerated here. A newer client sends filters this build has never - * heard of, and a per-field zod shape would reject the whole `ui.set` payload - * instead of persisting the parts the host does understand. The shared - * normalizer never throws and degrades field by field, so an older host narrows - * the state rather than refusing it. - */ -const WorkspaceCleanupBrowse = z - .custom() - .transform((value) => normalizeWorkspaceCleanupBrowseState(value)) - -export const WorkspaceCleanup = z.object({ - dismissals: z.record(z.string(), WorkspaceCleanupDismissal), - browse: WorkspaceCleanupBrowse.optional() -}) +export { WorkspaceCleanup } from '../../../../shared/rpc-contract/workspace-cleanup-ui-params' diff --git a/src/main/runtime/rpc/methods/workspace-ports.ts b/src/main/runtime/rpc/methods/workspace-ports.ts index 9a6ff82764b..5778f25732f 100644 --- a/src/main/runtime/rpc/methods/workspace-ports.ts +++ b/src/main/runtime/rpc/methods/workspace-ports.ts @@ -1,16 +1,8 @@ -import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' -import { OptionalString, requiredNumber } from '../schemas' - -const WorkspacePortScanParams = z.object({ - repoId: OptionalString -}) - -const WorkspacePortKillParams = z.object({ - repoId: OptionalString, - pid: requiredNumber('Missing process id'), - port: requiredNumber('Missing port') -}) +import { + WorkspacePortKillParams, + WorkspacePortScanParams +} from '../../../../shared/rpc-contract/workspace-ports-params' export const WORKSPACE_PORT_METHODS: RpcMethod[] = [ defineMethod({ diff --git a/src/main/runtime/rpc/methods/worktree-create-schemas.ts b/src/main/runtime/rpc/methods/worktree-create-schemas.ts index 61f6eb65e35..659f0c87fcf 100644 --- a/src/main/runtime/rpc/methods/worktree-create-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-create-schemas.ts @@ -1,154 +1,4 @@ -import { z } from 'zod' -import { isTuiAgent } from '../../../../shared/tui-agent-config' -import { workspaceSourceSchema } from '../../../../shared/telemetry-events' -import { sleepingAgentLaunchConfigSchema } from '../../../../shared/workspace-session-sleeping-agents' -import { RUNTIME_NAVIGATION_TARGETS } from '../../../../shared/runtime-navigation' -import { TaskSourceContextSchema } from '../../../../shared/task-source-context-schema' -import { WorkspaceLinkedItemSchema } from '../../../../shared/workspace-linked-item-schema' -import { - OptionalBoolean, - OptionalFiniteNumber, - OptionalString, - TriStateLinkedIssue -} from '../schemas' -import { - assertLinkedWorkItemSourceContextMatch, - AutomationWorkspaceProvenanceRequest, - CliWorkspaceProvenanceRequest, - OptionalTuiAgent -} from './worktree-schemas' - -export const WorktreeCreate = z - .object({ - repo: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing repo selector')), - name: OptionalString, - /** Set by clients that fell back to a generated creature name. Absent means user-typed, so the - * host neither skips a retired candidate nor retires the name it lands on. */ - nameWasGenerated: z.boolean().optional(), - baseBranch: OptionalString, - compareBaseRef: OptionalString, - branchNameOverride: OptionalString, - linkedIssue: TriStateLinkedIssue, - linkedPR: TriStateLinkedIssue, - linkedLinearIssue: z.string().optional(), - linkedLinearIssueWorkspaceId: z.union([z.string(), z.null()]).optional(), - linkedLinearIssueOrganizationUrlKey: z.union([z.string(), z.null()]).optional(), - linkedGitLabMR: TriStateLinkedIssue, - linkedGitLabIssue: TriStateLinkedIssue, - linkedBitbucketPR: TriStateLinkedIssue, - linkedAzureDevOpsPR: TriStateLinkedIssue, - linkedGiteaPR: TriStateLinkedIssue, - linkedWorkItem: WorkspaceLinkedItemSchema.nullable().optional(), - linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(), - comment: OptionalString, - displayName: OptionalString, - displayNameKind: z.enum(['generated', 'user']).optional(), - telemetrySource: z - .unknown() - .transform((value) => { - const parsed = workspaceSourceSchema.safeParse(value) - return parsed.success ? parsed.data : undefined - }) - .optional(), - workspaceStatus: OptionalString, - manualOrder: OptionalFiniteNumber, - sparseCheckout: z - .object({ - directories: z.array(z.string()), - presetId: OptionalString - }) - .optional(), - pushTarget: z - .object({ - remoteName: z.string(), - branchName: z.string(), - remoteUrl: OptionalString - }) - .optional(), - runHooks: OptionalBoolean, - activate: OptionalBoolean, - // Why: activation on create is view intent, so it is addressed like worktree.activate. - // Contract: a paired desktop/web caller resolves to 'caller' and therefore receives NO - // activateWorktree event — it must reveal from this call's result, which carries setup, - // startup and defaultTabs. Pass an explicit target to opt into an all-surface reveal. - navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(), - parentWorkspace: OptionalString, - // Why: an app-selected parent is a manual action, not the CLI's `--parent-workspace` flag. - // Absent keeps the CLI provenance older clients rely on. - parentWorkspaceOrigin: z.literal('manual').optional(), - envParentWorkspace: OptionalString, - parentWorktree: OptionalString, - cwdParentWorktree: OptionalString, - noParent: OptionalBoolean, - callerTerminalHandle: OptionalString, - orchestrationContext: z - .object({ - parentWorktreeId: OptionalString, - orchestrationRunId: OptionalString, - taskId: OptionalString, - coordinatorHandle: OptionalString - }) - .optional(), - setupDecision: z - .unknown() - .transform((v) => - typeof v === 'string' && (v === 'run' || v === 'skip' || v === 'inherit') ? v : undefined - ) - .pipe(z.union([z.enum(['run', 'skip', 'inherit']), z.undefined()])) - .optional(), - // Why: some clients (e.g. desktop) pass a pre-built launch command so the - // first terminal pane launches the selected agent instead of an idle shell. - // Clients that can't quote for the host shell send `startupAgent` instead. - startupCommand: OptionalString, - startupEnv: z.record(z.string(), z.string()).optional(), - startupLaunchConfig: sleepingAgentLaunchConfigSchema, - startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(), - // Why: CLI clients should not hardcode agent launch quoting because SSH - // workspaces execute in a different shell than the client process. - startupAgent: OptionalTuiAgent, - startupPrompt: OptionalString, - // Why: task-driven mobile creates need desktop parity: the host chooses - // the same default/detected agent and drafts the linked issue/PR URL into it. - startupDraft: OptionalString, - createdWithAgent: z - .unknown() - .transform((value) => (isTuiAgent(value) ? value : undefined)) - .optional(), - // Why: mobile retries a create interrupted by a connection migration with the - // same key so the host dedupes instead of spawning a duplicate worktree. - clientMutationId: z.string().min(1).max(128).optional(), - automationProvenanceRequest: AutomationWorkspaceProvenanceRequest.optional(), - cliProvenanceRequest: CliWorkspaceProvenanceRequest.optional() - }) - .superRefine((params, ctx) => { - assertLinkedWorkItemSourceContextMatch(params, ctx) - if ((params.parentWorkspace || params.parentWorktree) && params.noParent === true) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Choose either one parent selector or --no-parent.' - }) - } - if (params.parentWorkspace && params.parentWorktree) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Choose either one parent selector or --no-parent.' - }) - } - if (params.startupPrompt !== undefined && params.startupAgent === undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'startupPrompt requires startupAgent' - }) - } - }) - -export const WorktreePrefetchCreateBase = z.object({ - repo: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing repo selector')), - baseBranch: OptionalString -}) +export { + WorktreeCreate, + WorktreePrefetchCreateBase +} from '../../../../shared/rpc-contract/worktree-create-params' diff --git a/src/main/runtime/rpc/methods/worktree-schemas.ts b/src/main/runtime/rpc/methods/worktree-schemas.ts index b7c3b9493a9..41d2c38fec7 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.ts @@ -1,215 +1,18 @@ -import { z } from 'zod' -import { isTuiAgent } from '../../../../shared/tui-agent-config' -import type { TuiAgent } from '../../../../shared/tui-agent' -import { RUNTIME_NAVIGATION_TARGETS } from '../../../../shared/runtime-navigation' -import { - OptionalBoolean, - OptionalFiniteNumber, - OptionalPlainString, - OptionalString, - TriStateLinkedIssue -} from '../schemas' -import { TaskSourceContextSchema } from '../../../../shared/task-source-context-schema' -import { WorkspaceLinkedItemSchema } from '../../../../shared/workspace-linked-item-schema' -import { isWorkspaceLinkedItemSourceContextMatch } from '../../../../shared/workspace-linked-item-source-context' -import { normalizeExecutionHostId } from '../../../../shared/execution-host' - -const OptionalExecutionHostId = z - .string() - .transform((value, ctx) => { - const hostId = normalizeExecutionHostId(value) - if (!hostId) { - ctx.addIssue({ code: 'custom', message: 'Invalid host id' }) - return z.NEVER - } - return hostId - }) - .optional() - -export const OptionalTuiAgent = z - .unknown() - .superRefine((value, ctx) => { - if (value !== undefined && !isTuiAgent(value)) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Unknown TUI agent' }) - } - }) - .transform((value): TuiAgent | undefined => (isTuiAgent(value) ? value : undefined)) - .optional() - -export const AutomationWorkspaceProvenanceRequest = z.object({ - automationId: z.string(), - automationRunId: z.string(), - dispatchToken: z.string(), - createRequestId: z.string() -}) - -// Why no dispatch token (unlike automation provenance): this is a descriptive -// origin marker for sidebar filtering, not an authority grant. The host stamps -// createdAt itself so a client clock can't skew sort order. -export const CliWorkspaceProvenanceRequest = z.object({ - callerTerminalHandle: OptionalString -}) - -export const WorktreeListParams = z.object({ - repo: OptionalString, - limit: OptionalFiniteNumber -}) - -export const WorktreeDetectedListParams = z.object({ - repo: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing repo selector')) -}) - -export const WorktreeTeardownMissingTerminalsParams = WorktreeDetectedListParams.extend({ - worktreeIds: z.array(z.string().min(1)).max(10_000), - connectionId: z.string().nullable().optional() -}) - -export const WorktreePsParams = z.object({ - limit: OptionalFiniteNumber, - afterSnapshotId: z.string().min(1).max(128).nullable().optional(), - supportsWorktreeVisibilitySourceDefaults: z.literal(true).optional() -}) - -export const WorktreeSortOrder = z.object({ - orderedIds: z.array(z.string()) -}) - -export const WorktreeSelector = z.object({ - worktree: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing worktree selector')) -}) - -export const WorktreeActivate = WorktreeSelector.extend({ - notifyClients: OptionalBoolean, - navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional() -}) - -/** Shared by WorktreeCreate and WorktreeSet so the two error messages cannot drift. */ -export function assertLinkedWorkItemSourceContextMatch( - params: { - linkedWorkItem?: z.infer | null - linkedTaskSourceContext?: z.infer | null - }, - ctx: z.RefinementCtx -): void { - if ( - params.linkedWorkItem && - params.linkedTaskSourceContext && - !isWorkspaceLinkedItemSourceContextMatch(params.linkedWorkItem, params.linkedTaskSourceContext) - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Linked work item and source context identities must match' - }) - } -} - -export const WorktreeSet = WorktreeSelector.extend({ - // Why: '' is the blanking contract — "fall back to the branch/folder name". - // OptionalString coerced it to undefined, so on remote/SSH hosts clearing the - // name was dropped here and the old name came back on the next refresh. - displayName: OptionalPlainString, - // Why: empty comments are meaningful metadata updates, so use the plain - // string parser instead of OptionalString's empty-as-undefined behavior. - comment: OptionalPlainString, - linkedIssue: TriStateLinkedIssue, - linkedPR: TriStateLinkedIssue, - suppressedGitHubPR: z.number().int().positive().nullable().optional(), - linkedLinearIssue: z.union([z.string(), z.null()]).optional(), - linkedLinearIssueWorkspaceId: z.union([z.string(), z.null()]).optional(), - linkedLinearIssueOrganizationUrlKey: z.union([z.string(), z.null()]).optional(), - linkedGitLabMR: TriStateLinkedIssue, - linkedGitLabIssue: TriStateLinkedIssue, - linkedBitbucketPR: TriStateLinkedIssue, - linkedAzureDevOpsPR: TriStateLinkedIssue, - linkedGiteaPR: TriStateLinkedIssue, - linkedWorkItem: WorkspaceLinkedItemSchema.nullable().optional(), - linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(), - isArchived: OptionalBoolean, - isUnread: OptionalBoolean, - isPinned: OptionalBoolean, - sortOrder: OptionalFiniteNumber, - manualOrder: OptionalFiniteNumber, - lastActivityAt: OptionalFiniteNumber, - createdAt: OptionalFiniteNumber, - sparseDirectories: z.array(z.string()).optional(), - sparseBaseRef: OptionalString, - sparsePresetId: OptionalString, - baseRef: OptionalString, - workspaceStatus: OptionalString, - pushTarget: z - .object({ - remoteName: z.string(), - branchName: z.string(), - remoteUrl: OptionalString - }) - .nullable() - .optional(), - diffComments: z.array(z.unknown()).optional(), - mobileDiffReview: z.unknown().optional(), - parentWorktree: OptionalString, - noParent: OptionalBoolean -}).superRefine((params, ctx) => { - assertLinkedWorkItemSourceContextMatch(params, ctx) - if (params.parentWorktree && params.noParent === true) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Choose either --parent-worktree or --no-parent, not both.' - }) - } -}) - -export const WorktreeRemove = WorktreeSelector.extend({ - hostId: OptionalExecutionHostId, - force: OptionalBoolean, - // Why (#11960): the CLI's --force is an unambiguous force affordance, but the - // desktop sets `force` for an ordinary confirmed delete too, so the PTY-stop - // waiver travels on its own field. - allowUnverifiedPtyStop: OptionalBoolean, - runHooks: OptionalBoolean -}) - -export const WorktreeForceDeleteBranch = WorktreeSelector.extend({ - hostId: OptionalExecutionHostId, - branchName: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing branch name')), - expectedHead: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing expected branch head')) -}) - -export const WorktreeResolvePrBase = z.object({ - repo: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing repo selector')), - prNumber: z - .unknown() - .transform((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)) - .pipe(z.number().int().positive('Missing PR number')), - headRefName: OptionalString, - baseRefName: OptionalString, - isCrossRepository: OptionalBoolean -}) - -export const WorktreeResolveMrBase = z.object({ - repo: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing repo selector')), - mrIid: z - .unknown() - .transform((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)) - .pipe(z.number().int().positive('Missing MR number')), - sourceBranch: OptionalString, - targetBranch: OptionalString, - isCrossRepository: OptionalBoolean -}) +export { + AutomationWorkspaceProvenanceRequest, + CliWorkspaceProvenanceRequest, + OptionalTuiAgent, + WorktreeActivate, + WorktreeDetectedListParams, + WorktreeForceDeleteBranch, + WorktreeListParams, + WorktreePsParams, + WorktreeRemove, + WorktreeResolveMrBase, + WorktreeResolvePrBase, + WorktreeSelector, + WorktreeSet, + WorktreeSortOrder, + WorktreeTeardownMissingTerminalsParams, + assertLinkedWorkItemSourceContextMatch +} from '../../../../shared/rpc-contract/worktree-params' diff --git a/src/main/runtime/rpc/methods/worktree-visibility-defaults-schema.ts b/src/main/runtime/rpc/methods/worktree-visibility-defaults-schema.ts index 8bb7e1d081e..19a6dba90be 100644 --- a/src/main/runtime/rpc/methods/worktree-visibility-defaults-schema.ts +++ b/src/main/runtime/rpc/methods/worktree-visibility-defaults-schema.ts @@ -1,19 +1 @@ -import { z } from 'zod' -import { - normalizeCustomWorktreeVisibilitySources, - normalizeWorktreeVisibilitySourcePreferences -} from '../../../../shared/worktree/visibility-sources' - -export const WorktreeVisibilityDefaultsUpdate = z - .object({ - external: z.enum(['hide', 'show']).optional(), - customSources: z - .unknown() - .transform((value) => normalizeCustomWorktreeVisibilitySources(value)) - .optional(), - sourcePreferences: z - .unknown() - .transform((value) => normalizeWorktreeVisibilitySourcePreferences(value)) - .optional() - }) - .strict() +export { WorktreeVisibilityDefaultsUpdate } from '../../../../shared/rpc-contract/worktree-visibility-defaults-params' diff --git a/src/main/runtime/rpc/schemas.ts b/src/main/runtime/rpc/schemas.ts index fb7b09d8ceb..2c469341be5 100644 --- a/src/main/runtime/rpc/schemas.ts +++ b/src/main/runtime/rpc/schemas.ts @@ -3,87 +3,15 @@ // recur across domains (optional worktree selector, bounded limit, browser // target envelope, etc.). Methods compose these to declare their real // contract without repeating the same `typeof` gymnastics 90 times. -import { z } from 'zod' - -// Why: the original handlers treated non-numeric/NaN limit values as "no -// limit" rather than as errors. Preserve that forgiving behavior so CLI -// callers passing stringified numbers or Infinity still reach the runtime. -// The outer optional() is required for omitted keys in Zod v4; an optional -// schema hidden behind pipe() still makes z.object require the property. -export const OptionalFiniteNumber = z - .unknown() - .transform((value) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined)) - .pipe(z.union([z.number(), z.undefined()])) - .optional() - -export const OptionalPositiveInt = z - .unknown() - .transform((value) => - typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined - ) - .pipe(z.union([z.number(), z.undefined()])) - .optional() - -export const OptionalString = z - .unknown() - .transform((value) => (typeof value === 'string' && value.length > 0 ? value : undefined)) - .pipe(z.union([z.string(), z.undefined()])) - .optional() - -export const OptionalPlainString = z - .unknown() - .transform((value) => (typeof value === 'string' ? value : undefined)) - .pipe(z.union([z.string(), z.undefined()])) - .optional() - -export const OptionalBoolean = z - .unknown() - .transform((value) => (typeof value === 'boolean' ? value : undefined)) - .pipe(z.union([z.boolean(), z.undefined()])) - .optional() - -// Why: runtime handlers accept `linkedIssue: number | null | undefined` with -// distinct meanings — undefined means "no update", null means "clear", number -// means "set". The ambient JSON decode produces all three shapes as-is. -export const TriStateLinkedIssue = z - .unknown() - .transform((value) => { - if (value === null) { - return null - } - if (typeof value === 'number' && Number.isFinite(value)) { - return value - } - return undefined - }) - .pipe(z.union([z.number(), z.null(), z.undefined()])) - .optional() - -// Why: the legacy extractBrowserTarget treated worktree as a plain-string -// passthrough (empty string preserved) but `page` as non-empty-string. The -// browser bridge uses worktree-as-empty-string to mean "any worktree", so -// keep that asymmetry intact to avoid widening scope unexpectedly. -export const BrowserTarget = z.object({ - worktree: OptionalPlainString, - page: OptionalString -}) - -export function requiredString(message: string) { - return z - .unknown() - .transform((value) => (typeof value === 'string' ? value : '')) - .pipe(z.string().min(1, message)) -} - -export function requiredStringAllowingEmpty(message: string) { - return z.unknown().refine((value): value is string => typeof value === 'string', { message }) -} - -export function requiredNumber(message: string) { - return z - .unknown() - .transform((value) => - typeof value === 'number' && Number.isFinite(value) ? value : Number.NaN - ) - .pipe(z.number().refine((v) => Number.isFinite(v), { message })) -} +export { + BrowserTarget, + OptionalBoolean, + OptionalFiniteNumber, + OptionalPlainString, + OptionalPositiveInt, + OptionalString, + TriStateLinkedIssue, + requiredNumber, + requiredString, + requiredStringAllowingEmpty +} from '../../../shared/rpc-contract/rpc-param-primitives' diff --git a/src/shared/rpc-contract/accounts-params.ts b/src/shared/rpc-contract/accounts-params.ts new file mode 100644 index 00000000000..0a9559e9549 --- /dev/null +++ b/src/shared/rpc-contract/accounts-params.ts @@ -0,0 +1,81 @@ +import { z } from 'zod' + +export const CodexResetTarget = z.discriminatedUnion('runtime', [ + z.object({ runtime: z.literal('host'), wslDistro: z.null() }).strict(), + // Why: reset scope must identify one exact WSL distro; null means all slots only for selection. + z.object({ runtime: z.literal('wsl'), wslDistro: z.string().trim().min(1).max(255) }).strict() +]) + +export const CodexSelectionTarget = z.discriminatedUnion('runtime', [ + z.object({ runtime: z.literal('host'), wslDistro: z.null() }).strict(), + z + .object({ + runtime: z.literal('wsl'), + // A null distro intentionally means all WSL selection slots. + wslDistro: z.string().trim().min(1).max(255).nullable() + }) + .strict() +]) + +export const SelectAccountParams = z.object({ + accountId: z + .union([z.string().min(1, 'Missing accountId'), z.null()]) + .transform((v) => (v === null ? null : v)) +}) + +export const SelectCodexAccountForTargetParams = SelectAccountParams.extend({ + target: CodexSelectionTarget +}) + +export const RemoveAccountParams = z.object({ + accountId: z.string().min(1, 'Missing accountId') +}) + +export const CodexResetExpectedScope = z + .object({ + target: CodexResetTarget, + accountId: z.string().min(1, 'Missing accountId').max(512), + accountRevision: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + offerRevision: z.string().startsWith('v1:', 'Invalid offerRevision').max(4_096) + }) + .strict() + +export const ConsumeCodexResetCreditParams = z + .object({ + // Why: the phone owns the logical attempt key so a lost response can be + // retried without spending a finite earned credit twice. + idempotencyKey: z.uuid('Invalid idempotencyKey'), + expectedScope: CodexResetExpectedScope + }) + .strict() + +export const AddClaudeFromConfigDirParams = z.object({ + configDir: z.string().min(1, 'Missing configDir'), + runtime: z.enum(['host', 'wsl']).optional(), + wslDistro: z.string().nullish(), + previousLegacyCredentialsSha256: z + .string() + .regex(/^[a-f0-9]{64}$/, 'Invalid legacy credential digest') + .nullable() + .optional() +}) + +export const AddCodexFromHomeParams = z.object({ + sourceHome: z.string().min(1, 'Missing sourceHome'), + runtime: z.enum(['host', 'wsl']).optional(), + wslDistro: z.string().nullish() +}) + +// Why: `orca account list` prints only emails and the active ids, so it opts out +// of the forced all-provider usage refresh below — that lane bypasses the poll +// throttle and Retry-After gate and costs one serial round-trip per account. +export const ListAccountsParams = z.object({ + refreshUsage: z.boolean().default(true) +}) + +export const AccountsUnsubscribeParams = z.object({ + subscriptionId: z + .unknown() + .transform((value) => (typeof value === 'string' && value.length > 0 ? value : '')) + .pipe(z.string().min(1, 'Missing subscriptionId')) +}) diff --git a/src/shared/rpc-contract/agent-hooks-params.ts b/src/shared/rpc-contract/agent-hooks-params.ts new file mode 100644 index 00000000000..bed616bc0a7 --- /dev/null +++ b/src/shared/rpc-contract/agent-hooks-params.ts @@ -0,0 +1,14 @@ +import { z } from 'zod' + +export const PrepareCodexForWslPaneParams = z + .object({ + codexHome: z.string().max(4_096), + orcaCodexHome: z.string().max(4_096), + wslDistro: z + .string() + .trim() + .min(1) + .max(255) + .regex(/^[^\\/\r\n]+$/) + }) + .strict() diff --git a/src/shared/rpc-contract/agent-session-params.ts b/src/shared/rpc-contract/agent-session-params.ts new file mode 100644 index 00000000000..6a2aa23635d --- /dev/null +++ b/src/shared/rpc-contract/agent-session-params.ts @@ -0,0 +1,189 @@ +import { z } from 'zod' +import { isValidTerminalTabId } from '../terminal-tab-id' +import { + RESUMABLE_TUI_AGENTS, + getAgentResumeArgv, + hasUnsafeProviderSessionIdChars +} from '../agent-session-resume' +import { parseAgentSessionOperationTimestamp } from '../agent-session-host-authority' +import type { + RuntimeCreateAgentSessionRequest, + RuntimeEnsureAgentSessionRequest +} from '../agent-session-host-authority' +import { isTuiAgent } from '../tui-agent-config' + +export const MAX_WORKTREE_SELECTOR_LENGTH = 32_768 + +export const MAX_TRANSCRIPT_PATH_BYTES = 16 * 1024 + +export const MAX_PROMPT_BYTES = 256 * 1024 + +export const MAX_AGENT_ARGS_BYTES = 16 * 1024 + +export const MAX_LAUNCH_PREFERENCE_LENGTH = 512 + +export const StrictNonEmptyString = (max: number, message: string) => + z + .string() + .min(1, message) + .max(max, message) + .refine((value) => value === value.trim(), `${message}; surrounding whitespace is invalid`) + +export const WorktreeSelector = StrictNonEmptyString( + MAX_WORKTREE_SELECTOR_LENGTH, + 'Invalid worktree selector' +) + +export const Presentation = z.enum(['background', 'focused']) + +export const Placement = z + .object({ + tabId: z + .string() + .min(1) + .max(512) + .refine(isValidTerminalTabId, 'Invalid terminal tab ID') + .optional(), + leafId: z.string().min(1).max(128).optional() + }) + .strict() + .refine((value) => value.tabId !== undefined || value.leafId !== undefined, { + message: 'Placement must include a tab or leaf ID' + }) + +export const LaunchPreferences = z + .object({ + model: StrictNonEmptyString( + MAX_LAUNCH_PREFERENCE_LENGTH, + 'Invalid model preference' + ).optional(), + effort: StrictNonEmptyString( + MAX_LAUNCH_PREFERENCE_LENGTH, + 'Invalid effort preference' + ).optional(), + mode: StrictNonEmptyString(MAX_LAUNCH_PREFERENCE_LENGTH, 'Invalid mode preference').optional() + }) + .strict() + +export const PromptDelivery = z.enum(['auto-submit', 'draft']) + +export const AgentArgs = z + .string() + .refine( + (value) => Buffer.byteLength(value, 'utf8') <= MAX_AGENT_ARGS_BYTES, + 'Agent arguments are too large' + ) + .nullable() + +export const OmpResumeFilePath = z + .string() + .min(1) + .refine((value) => value === value.trim(), 'Invalid OMP resume path') + .refine( + (value) => + !hasUnsafeProviderSessionIdChars(value) && + Buffer.byteLength(value, 'utf8') <= MAX_TRANSCRIPT_PATH_BYTES, + 'Invalid OMP resume path' + ) + +export const ProviderSession = z + .object({ + key: z.enum(['session_id', 'conversation_id']), + id: StrictNonEmptyString(512, 'Invalid provider session ID').refine( + (value) => !value.startsWith('-') && !hasUnsafeProviderSessionIdChars(value), + 'Invalid provider session ID' + ), + transcriptPath: z + .string() + .min(1) + .refine((value) => value === value.trim(), 'Invalid transcript path') + .refine( + (value) => + !hasUnsafeProviderSessionIdChars(value) && + Buffer.byteLength(value, 'utf8') <= MAX_TRANSCRIPT_PATH_BYTES, + 'Invalid transcript path' + ) + .optional() + }) + .strict() + +export const AutomaticEnsure = z + .object({ + kind: z.literal('automatic'), + sleepingCheckpointId: z + .string() + .min(32) + .max(128) + .regex(/^[A-Za-z0-9_-]+$/), + presentation: Presentation.optional() + }) + .strict() + +export const ExplicitEnsure = z + .object({ + kind: z.literal('explicit'), + worktree: WorktreeSelector, + agent: z.enum(RESUMABLE_TUI_AGENTS), + providerSession: ProviderSession, + ompResumeFilePath: OmpResumeFilePath.optional(), + agentArgs: AgentArgs.optional(), + launchPreferences: LaunchPreferences.optional(), + presentation: Presentation.optional(), + placement: Placement.optional() + }) + .strict() + .superRefine((value, context) => { + if (value.ompResumeFilePath !== undefined && value.agent !== 'omp') { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['ompResumeFilePath'], + message: 'OMP resume path requires the OMP agent' + }) + } + if (getAgentResumeArgv(value.agent, value.providerSession, value.ompResumeFilePath) === null) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['providerSession'], + message: 'Provider session is not resumable for this agent' + }) + } + }) + +export const EnsureAgentSessionParams: z.ZodType = + z.discriminatedUnion('kind', [AutomaticEnsure, ExplicitEnsure]) + +export const CreateAgentSessionParams: z.ZodType = z + .object({ + clientOperationId: z + .string() + .refine( + (value) => parseAgentSessionOperationTimestamp(value) !== null, + 'Invalid agent operation ID' + ), + worktree: WorktreeSelector, + agent: z.string().refine(isTuiAgent, 'Unknown agent preset'), + prompt: z + .string() + .refine( + (value) => Buffer.byteLength(value, 'utf8') <= MAX_PROMPT_BYTES, + 'Prompt is too large' + ) + .optional(), + promptDelivery: PromptDelivery.optional(), + agentArgs: AgentArgs.optional(), + launchPreferences: LaunchPreferences.optional(), + startupCwd: z.string().min(1).max(MAX_WORKTREE_SELECTOR_LENGTH).optional(), + presentation: Presentation.optional(), + placement: Placement.optional(), + viewMode: z.enum(['terminal', 'chat']).optional() + }) + .strict() + .superRefine((value, context) => { + if (value.promptDelivery === 'draft' && !value.prompt?.trim()) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['prompt'], + message: 'Draft delivery requires a non-empty prompt' + }) + } + }) diff --git a/src/shared/rpc-contract/ai-vault-params.ts b/src/shared/rpc-contract/ai-vault-params.ts new file mode 100644 index 00000000000..d036af9177f --- /dev/null +++ b/src/shared/rpc-contract/ai-vault-params.ts @@ -0,0 +1,73 @@ +import { z } from 'zod' +import { parseExecutionHostId } from '../execution-host' +import { AI_VAULT_AGENTS, AI_VAULT_SCOPE_PATHS_MAX_COUNT } from '../ai-vault-types' +import { OptionalBoolean } from './rpc-param-primitives' +import { AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT } from '../ai-vault-session-title' + +// Why: bound limit + scopePaths so a client cannot force an unbounded scan. +// Each scopePath is a host-local match prefix (validated/capped, never used for +// traversal); the count/length caps mirror the worktree-schemas bounding style. +export const AI_VAULT_SCOPE_PATH_MAX_LENGTH = 4096 + +export const AI_VAULT_LIMIT_MAX = 2000 + +export const executionHostIdSchema = z.string().transform((value, ctx): `runtime:${string}` => { + const parsed = parseExecutionHostId(value) + if (parsed?.kind === 'runtime') { + return parsed.id + } + ctx.addIssue({ + code: 'custom', + message: 'Invalid runtime execution host id' + }) + return z.NEVER +}) + +export const AiVaultListSessionsParams = z + .object({ + limit: z + .unknown() + .transform((value) => + typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined + ) + .pipe(z.union([z.number().int(), z.undefined()])) + .optional(), + unlimited: OptionalBoolean, + force: OptionalBoolean, + scopePaths: z + .array(z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH)) + // Why: clamp instead of reject — scope paths only ever widen discovery, and + // rejecting would hard-break older/uncapped producers (web client, pre-cap + // desktop parents) that send more than the bound. + .transform((paths) => paths.slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT)) + .optional(), + // Why: desktop/web callers name the runtime host they are addressing; mobile + // omits it. The scan itself is host-local either way, so the id must never + // change what is scanned — it only restamps the shared cached result. + executionHostId: executionHostIdSchema.optional() + }) + .superRefine((params, ctx) => { + if (params.unlimited !== true && params.limit && params.limit > AI_VAULT_LIMIT_MAX) { + ctx.addIssue({ code: 'custom', path: ['limit'], message: 'Limit exceeds maximum' }) + } + }) + +export const AiVaultPrepareSessionResumeParams = z.object({ + agent: z.enum(AI_VAULT_AGENTS), + sessionId: z.string().min(1).max(512).optional(), + filePath: z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH), + codexHome: z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH).nullable(), + executionHostId: z.string().optional() +}) + +export const AiVaultSessionTitlesParams = z.object({ + requests: z + .array( + z.object({ + agent: z.enum(['claude', 'codex']), + sessionId: z.string().min(1).max(512), + transcriptPath: z.string().min(1).max(32_768).optional() + }) + ) + .max(AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT) +}) diff --git a/src/shared/rpc-contract/artifacts-params.ts b/src/shared/rpc-contract/artifacts-params.ts new file mode 100644 index 00000000000..e5743485892 --- /dev/null +++ b/src/shared/rpc-contract/artifacts-params.ts @@ -0,0 +1,43 @@ +import { z } from 'zod' +import { + ARTIFACT_MAX_CONTENT_BYTES, + ARTIFACT_MAX_REQUEST_BYTES, + artifactContentByteLength, + artifactWriteRequestByteLength +} from '../artifacts' + +export const CloudOptions = { + apiUrl: z.string().max(2_048).optional(), + authToken: z.string().max(16_384).optional() +} + +export const ListOptions = z.object({ + ...CloudOptions, + cursor: z.string().min(1).max(2_048).optional() +}) + +export const SourceRequest = z.object({ + sourceKey: z.string().min(1).max(32_768), + ...CloudOptions +}) + +export const WriteRequest = z + .object({ + sourceKey: z.string().min(1).max(32_768), + content: z + .string() + .min(1) + .max(ARTIFACT_MAX_CONTENT_BYTES) + .refine((content) => artifactContentByteLength(content) <= ARTIFACT_MAX_CONTENT_BYTES, { + message: 'Artifact content exceeds the 10 MiB limit.' + }), + contentType: z.enum(['text/html', 'text/markdown']), + fileName: z.string().min(1).max(512), + title: z.string().max(512).optional(), + ...CloudOptions + }) + .refine((request) => artifactWriteRequestByteLength(request) <= ARTIFACT_MAX_REQUEST_BYTES, { + message: 'Artifact request exceeds the supported size.' + }) + +export const ArtifactsDeleteParams = z.object({ id: z.string().min(1), ...CloudOptions }) diff --git a/src/shared/rpc-contract/automation-params.ts b/src/shared/rpc-contract/automation-params.ts new file mode 100644 index 00000000000..fff5d5db615 --- /dev/null +++ b/src/shared/rpc-contract/automation-params.ts @@ -0,0 +1,198 @@ +import { z } from 'zod' +import { isTuiAgent } from '../tui-agent-config' +import { + OptionalBoolean, + OptionalPlainString, + OptionalPositiveInt, + OptionalString, + requiredNumber, + requiredString +} from './rpc-param-primitives' +import { normalizeExecutionHostId } from '../execution-host' +import { isValidAutomationSchedule } from '../automation-schedule-parsing' +import { + MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, + normalizeAutomationPrecheckTimeoutSeconds +} from '../automation-precheck' +import type { TaskProviderIdentity as SharedTaskProviderIdentity } from '../task-source-context' + +export const TuiAgent = requiredString('Missing provider').refine(isTuiAgent, { + message: 'Unknown provider' +}) + +export const AutomationWorkspaceMode = z.enum(['existing', 'new_per_run']).optional() + +export const SetupDecision = z.enum(['inherit', 'run', 'skip']).optional() + +export const ExecutionHostId = requiredString('Missing host id').transform((value, ctx) => { + const hostId = normalizeExecutionHostId(value) + if (!hostId) { + ctx.addIssue({ code: 'custom', message: 'Invalid host id' }) + return z.NEVER + } + return hostId +}) + +export const AutomationSchedule = requiredString('Missing trigger').refine( + isValidAutomationSchedule, + { + message: 'Invalid automation trigger' + } +) + +export const AutomationPrecheck = z + .object({ + command: requiredString('Missing precheck command'), + timeoutSeconds: OptionalPositiveInt.transform((value) => + normalizeAutomationPrecheckTimeoutSeconds(value) + ).refine((value) => value <= MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, { + message: 'Precheck timeout is too large' + }) + }) + .nullable() + .optional() + +export const OptionalNullablePlainString = z + .unknown() + .transform((value) => (value === null || typeof value === 'string' ? value : undefined)) + .pipe(z.union([z.string(), z.null(), z.undefined()])) + .optional() + +export const TaskProviderIdentity = z + .custom( + (value) => + value !== null && + typeof value === 'object' && + 'provider' in value && + ['github', 'gitlab', 'linear', 'jira'].includes(String(value.provider)) + ) + .optional() + .nullable() + +export const TaskSourceContext = z + .object({ + kind: z.literal('task-source'), + provider: z.enum(['github', 'gitlab', 'linear', 'jira']), + projectId: requiredString('Missing source project id'), + hostId: ExecutionHostId, + projectHostSetupId: OptionalNullablePlainString, + repoId: OptionalNullablePlainString, + providerIdentity: TaskProviderIdentity, + accountLabel: OptionalNullablePlainString + }) + .optional() + .nullable() + +export const WorkspaceRunContext = z + .object({ + kind: z.literal('workspace-run'), + projectId: requiredString('Missing run project id'), + hostId: ExecutionHostId, + projectHostSetupId: requiredString('Missing project host setup id'), + repoId: requiredString('Missing repo id'), + path: requiredString('Missing run path') + }) + .optional() + .nullable() + +export const SshTargetGeneration = requiredNumber('Missing SSH target generation').refine( + (value) => Number.isSafeInteger(value) && value >= 1, + { message: 'Invalid SSH target generation' } +) + +export const OwnedSshSelector = z.object({ + kind: z.literal('ssh'), + targetId: requiredString('Missing SSH target id'), + targetGeneration: SshTargetGeneration +}) + +/** Orphan is accepted here, unlike a destination: a record with no executable host is still deletable. */ +export const OwnerPreconditionSelector = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('self') }), + OwnedSshSelector, + z.object({ kind: z.literal('orphan') }) +]) + +export const DestinationSelector = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('self') }), + OwnedSshSelector +]) + +export const ExpectedOwner = z.object({ selector: OwnerPreconditionSelector }).optional() + +export const Destination = z.object({ selector: DestinationSelector }).optional() + +export const ListScopeSelector = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('self') }), + z.object({ + kind: z.literal('ssh'), + targetId: requiredString('Missing SSH target id'), + expectedTargetGeneration: SshTargetGeneration + }), + z.object({ kind: z.literal('orphan') }) +]) + +/** An omitted selector is the legacy request; old clients keep the authority's complete list. */ +export const AutomationList = z.object({ selector: ListScopeSelector.optional() }) + +export const AutomationId = z.object({ + id: requiredString('Missing automation id'), + expectedOwner: ExpectedOwner +}) + +export const AutomationRuns = z.object({ + automationId: OptionalString, + expectedOwner: ExpectedOwner, + limit: OptionalPositiveInt, + cursor: OptionalString +}) + +export const AutomationCreate = z.object({ + creationKey: OptionalString, + name: requiredString('Missing automation name'), + prompt: requiredString('Missing automation prompt'), + precheck: AutomationPrecheck, + agentId: TuiAgent, + runContext: WorkspaceRunContext, + sourceContext: TaskSourceContext, + repo: OptionalString, + workspace: OptionalString, + workspaceMode: AutomationWorkspaceMode, + baseBranch: OptionalPlainString, + setupDecision: SetupDecision, + reuseSession: OptionalBoolean, + timezone: OptionalString, + rrule: AutomationSchedule, + dtstart: requiredNumber('Missing trigger start time'), + enabled: OptionalBoolean, + missedRunGraceMinutes: OptionalPositiveInt, + destination: Destination +}) + +export const AutomationUpdateFields = z.object({ + name: OptionalString, + prompt: OptionalString, + precheck: AutomationPrecheck, + agentId: TuiAgent.optional(), + runContext: WorkspaceRunContext, + sourceContext: TaskSourceContext, + repo: OptionalString, + workspace: OptionalString, + workspaceMode: AutomationWorkspaceMode, + // Why: update patches distinguish omitted from null so callers can clear a saved base branch. + baseBranch: OptionalNullablePlainString, + setupDecision: SetupDecision, + reuseSession: OptionalBoolean, + timezone: OptionalString, + rrule: AutomationSchedule.optional(), + dtstart: requiredNumber('Missing trigger start time').optional(), + enabled: OptionalBoolean, + missedRunGraceMinutes: OptionalPositiveInt +}) + +export const AutomationUpdate = z.object({ + id: requiredString('Missing automation id'), + updates: AutomationUpdateFields, + expectedOwner: ExpectedOwner, + destination: Destination +}) diff --git a/src/shared/rpc-contract/browser-core-params.ts b/src/shared/rpc-contract/browser-core-params.ts new file mode 100644 index 00000000000..6cd0dccfbd3 --- /dev/null +++ b/src/shared/rpc-contract/browser-core-params.ts @@ -0,0 +1,5 @@ +import { BrowserTarget, requiredString } from './rpc-param-primitives' + +export const CertificateProceed = BrowserTarget.extend({ + challengeId: requiredString('Missing required challengeId') +}) diff --git a/src/shared/rpc-contract/browser-extras-params.ts b/src/shared/rpc-contract/browser-extras-params.ts new file mode 100644 index 00000000000..421c8976608 --- /dev/null +++ b/src/shared/rpc-contract/browser-extras-params.ts @@ -0,0 +1,14 @@ +import { z } from 'zod' +import { MouseButton, MouseXY } from './browser-params' +import { OptionalFiniteNumber } from './rpc-param-primitives' + +export const MouseModifiers = z + .unknown() + .transform((v) => (Array.isArray(v) ? v : undefined)) + .pipe(z.union([z.array(z.enum(['cmd', 'ctrl', 'alt', 'shift'])), z.undefined()])) + .optional() + +export const MouseClick = MouseXY.merge(MouseButton).extend({ + radius: OptionalFiniteNumber, + modifiers: MouseModifiers +}) diff --git a/src/shared/rpc-contract/browser-params.ts b/src/shared/rpc-contract/browser-params.ts new file mode 100644 index 00000000000..141e9ffe8fd --- /dev/null +++ b/src/shared/rpc-contract/browser-params.ts @@ -0,0 +1,355 @@ +import { z } from 'zod' +import { + BrowserTarget, + OptionalBoolean, + OptionalFiniteNumber, + OptionalPlainString, + OptionalString, + requiredString, + requiredStringAllowingEmpty +} from './rpc-param-primitives' + +export const Element = BrowserTarget.extend({ + element: requiredString('Missing required --element') +}) + +export const Goto = BrowserTarget.extend({ + url: requiredString('Missing required --url') +}) + +export const Fill = BrowserTarget.extend({ + element: requiredString('Missing required --element'), + value: requiredStringAllowingEmpty('Missing required --value') +}) + +export const Type = BrowserTarget.extend({ + input: requiredString('Missing required --input') +}) + +export const Select = BrowserTarget.extend({ + element: requiredString('Missing required --element'), + value: z.custom((v) => typeof v === 'string', { + message: 'Missing required --value' + }) +}) + +export const Scroll = BrowserTarget.extend({ + direction: z.custom<'up' | 'down'>((v) => v === 'up' || v === 'down', { + message: 'Missing required --direction (up or down)' + }), + amount: z + .unknown() + .transform((v) => (typeof v === 'number' && v > 0 ? v : undefined)) + .pipe(z.union([z.number(), z.undefined()])) + .optional() +}) + +export const Screenshot = BrowserTarget.extend({ + format: z + .unknown() + .transform((v) => (v === 'png' || v === 'jpeg' ? v : undefined)) + .pipe(z.union([z.enum(['png', 'jpeg']), z.undefined()])) + .optional() +}) + +export const Screencast = BrowserTarget.extend({ + format: z + .unknown() + .optional() + .transform((v) => (v === 'png' ? 'png' : 'jpeg')) + .pipe(z.enum(['png', 'jpeg'])), + quality: OptionalFiniteNumber, + maxWidth: OptionalFiniteNumber, + maxHeight: OptionalFiniteNumber, + viewportWidth: OptionalFiniteNumber, + viewportHeight: OptionalFiniteNumber, + deviceScaleFactor: OptionalFiniteNumber, + mobile: OptionalBoolean, + everyNthFrame: OptionalFiniteNumber, + minFrameIntervalMs: OptionalFiniteNumber +}) + +export const FullScreenshot = BrowserTarget.extend({ + format: z + .unknown() + .optional() + .transform((v) => (v === 'jpeg' ? 'jpeg' : 'png')) + .pipe(z.enum(['png', 'jpeg'])) +}) + +export const Eval = BrowserTarget.extend({ + expression: requiredString('Missing required --expression') +}) + +export const TabList = z.object({ worktree: OptionalString }) + +// Why: --index xor --page must be present. The refine guards that invariant +// so the dispatcher surfaces a single legible error instead of either shape +// leaking into the runtime. +// +// `focus` is opt-in: when true, the runtime sends `browser:pane-focus` to +// the renderer after the switch lands. The renderer surfaces the browser +// pane only if the user is already on the targeted worktree; otherwise it +// pre-stages per-worktree state silently. This avoids cross-worktree screen +// theft when multiple agents drive browsers in parallel worktrees. +export const TabSwitch = BrowserTarget.extend({ + index: z + .unknown() + .transform((v) => (typeof v === 'number' ? v : undefined)) + .pipe(z.union([z.number(), z.undefined()])) + .optional(), + focus: z.boolean().optional() +}).refine( + (val) => { + if (val.page !== undefined) { + return true + } + return val.index !== undefined && Number.isInteger(val.index) && val.index >= 0 + }, + { message: 'Missing required --index (non-negative integer) or --page' } +) + +export const TabShow = z.object({ + page: requiredString('Missing required --page'), + worktree: OptionalString +}) + +export const TabCurrent = z.object({ worktree: OptionalString }) + +export const TabClose = z.object({ + index: z + .unknown() + .transform((v) => (typeof v === 'number' ? v : undefined)) + .pipe(z.union([z.number(), z.undefined()])) + .optional(), + page: OptionalString, + worktree: OptionalString +}) + +export const TabSetProfile = BrowserTarget.extend({ + profileId: requiredString('Missing required --profile') +}) + +export const TabProfileClone = BrowserTarget.extend({ + profileId: requiredString('Missing required --profile') +}) + +export const ProfileCreate = z.object({ + label: requiredString('Missing required --label'), + // Strict enum so unknown scope values surface validation errors instead of being + // silently coerced to 'isolated' (pr-bug-scan finding from #1397). + scope: z.enum(['isolated', 'imported']), + userAgentMode: z.enum(['clean', 'native']).optional() +}) + +export const ProfileDelete = z.object({ profileId: requiredString('Missing required --profile') }) + +export const ProfileImportFromBrowser = z.object({ + profileId: requiredString('Missing required --profile'), + browserFamily: requiredString('Missing required --browser-family'), + browserProfile: OptionalString, + supportsPartitionSkippedCookies: z.literal(true).optional() +}) + +export const Drag = BrowserTarget.extend({ + from: requiredString('Missing required --from and --to element refs'), + to: requiredString('Missing required --from and --to element refs') +}) + +export const Upload = BrowserTarget.extend({ + element: requiredString('Missing required --element and --files'), + files: z.custom( + (v) => Array.isArray(v) && v.length > 0 && v.every((f) => typeof f === 'string'), + { message: 'Missing required --element and --files' } + ) +}) + +export const Wait = BrowserTarget.extend({ + selector: OptionalPlainString, + timeout: z + .unknown() + .transform((v) => (typeof v === 'number' && v > 0 ? v : undefined)) + .pipe(z.union([z.number(), z.undefined()])) + .optional(), + text: OptionalPlainString, + url: OptionalPlainString, + load: OptionalPlainString, + fn: OptionalPlainString, + state: OptionalPlainString +}) + +export const Check = BrowserTarget.extend({ + element: requiredString('Missing required --element'), + checked: z + .unknown() + .optional() + .transform((v) => (v === undefined ? true : v)) + .pipe(z.boolean()) +}) + +export const Keypress = BrowserTarget.extend({ + key: requiredString('Missing required --key') +}) + +export const SelectorPath = BrowserTarget.extend({ + selector: requiredString('Missing required --selector and --path'), + path: requiredString('Missing required --selector and --path') +}) + +export const Highlight = BrowserTarget.extend({ + selector: requiredString('Missing required --selector') +}) + +export const Exec = BrowserTarget.extend({ + command: requiredString('Missing required --command') +}) + +export const Get = BrowserTarget.extend({ + what: requiredString('Missing required --what'), + selector: OptionalString +}) + +export const Is = BrowserTarget.extend({ + what: z.custom((v) => typeof v === 'string' && v.length > 0, { + message: 'Missing required --what and --element' + }), + selector: z.custom((v) => typeof v === 'string' && v.length > 0, { + message: 'Missing required --what and --element' + }) +}) + +export const KeyboardInsert = BrowserTarget.extend({ + text: requiredString('Missing required --text') +}) + +export const LimitParam = BrowserTarget.extend({ + limit: OptionalFiniteNumber +}) + +export const Find = BrowserTarget.extend({ + locator: requiredString('Missing required --locator, --value, and --action'), + value: requiredString('Missing required --locator, --value, and --action'), + action: requiredString('Missing required --locator, --value, and --action'), + text: OptionalString +}) + +export const CookieGet = BrowserTarget.extend({ + url: OptionalPlainString +}) + +export const CookieSet = BrowserTarget.extend({ + name: z.custom((v) => typeof v === 'string' && v.length > 0, { + message: 'Missing name or value' + }), + value: z.custom((v) => typeof v === 'string', { + message: 'Missing name or value' + }), + domain: OptionalPlainString, + path: OptionalPlainString, + secure: OptionalBoolean, + httpOnly: OptionalBoolean, + sameSite: OptionalPlainString, + expires: OptionalFiniteNumber +}) + +export const CookieDelete = BrowserTarget.extend({ + name: requiredString('Missing cookie name'), + domain: OptionalPlainString, + url: OptionalPlainString +}) + +export const Viewport = BrowserTarget.extend({ + width: z.custom((v) => typeof v === 'number' && v > 0, { + message: 'Width and height must be positive numbers' + }), + height: z.custom((v) => typeof v === 'number' && v > 0, { + message: 'Width and height must be positive numbers' + }), + deviceScaleFactor: OptionalFiniteNumber, + mobile: OptionalBoolean +}) + +export const Geolocation = BrowserTarget.extend({ + latitude: z.custom((v) => typeof v === 'number', { + message: 'Missing latitude or longitude' + }), + longitude: z.custom((v) => typeof v === 'number', { + message: 'Missing latitude or longitude' + }), + accuracy: OptionalFiniteNumber +}) + +export const InterceptEnable = BrowserTarget.extend({ + patterns: z + .unknown() + .transform((v) => (Array.isArray(v) ? (v as string[]) : undefined)) + .pipe(z.union([z.array(z.string()), z.undefined()])) + .optional() +}) + +export const MouseXY = BrowserTarget.extend({ + x: z.custom((v) => typeof v === 'number', { + message: 'Missing required x and y coordinates' + }), + y: z.custom((v) => typeof v === 'number', { + message: 'Missing required x and y coordinates' + }) +}) + +export const MouseButton = BrowserTarget.extend({ + button: OptionalPlainString +}) + +export const MouseWheel = BrowserTarget.extend({ + dy: z.custom((v) => typeof v === 'number', { + message: 'Missing required --dy' + }), + dx: OptionalFiniteNumber +}) + +export const SetDevice = BrowserTarget.extend({ + name: requiredString('Missing required --name') +}) + +export const SetOffline = BrowserTarget.extend({ + state: OptionalPlainString +}) + +export const SetHeaders = BrowserTarget.extend({ + headers: requiredString('Missing required --headers (JSON string)') +}) + +export const SetCredentials = BrowserTarget.extend({ + user: z.custom((v) => typeof v === 'string' && v.length > 0, { + message: 'Missing required --user and --pass' + }), + pass: z.custom((v) => typeof v === 'string', { + message: 'Missing required --user and --pass' + }) +}) + +export const SetMedia = BrowserTarget.extend({ + colorScheme: OptionalPlainString, + reducedMotion: OptionalPlainString +}) + +export const ClipboardWrite = BrowserTarget.extend({ + text: requiredString('Missing required --text') +}) + +export const DialogAccept = BrowserTarget.extend({ + text: OptionalPlainString +}) + +export const StorageKey = BrowserTarget.extend({ + key: requiredString('Missing required --key') +}) + +export const StorageKeyValue = BrowserTarget.extend({ + key: z.custom((v) => typeof v === 'string' && v.length > 0, { + message: 'Missing required --key and --value' + }), + value: z.custom((v) => typeof v === 'string', { + message: 'Missing required --key and --value' + }) +}) diff --git a/src/shared/rpc-contract/browser-screencast-params.ts b/src/shared/rpc-contract/browser-screencast-params.ts new file mode 100644 index 00000000000..016b7e0f83b --- /dev/null +++ b/src/shared/rpc-contract/browser-screencast-params.ts @@ -0,0 +1,5 @@ +import { z } from 'zod' + +export const ScreencastUnsubscribe = z.object({ + subscriptionId: z.string().min(1, 'Missing required --subscription-id') +}) diff --git a/src/shared/rpc-contract/browser-tab-create-params.ts b/src/shared/rpc-contract/browser-tab-create-params.ts new file mode 100644 index 00000000000..1250b6ff2a3 --- /dev/null +++ b/src/shared/rpc-contract/browser-tab-create-params.ts @@ -0,0 +1,23 @@ +import { z } from 'zod' +import { RUNTIME_NAVIGATION_TARGETS } from '../runtime-navigation' +import { BrowserPageCreationPlacement } from '../browser-client-host-placement' +import { OptionalString } from './rpc-param-primitives' + +export const BrowserTabCreateParams = z.object({ + url: OptionalString, + worktree: OptionalString, + page: OptionalString, + profileId: OptionalString, + waitForRegistration: z.boolean().optional(), + activate: z.boolean().optional(), + // Why: `activate` says the caller wants the new tab selected; `navigation` says on whose screens. + // Absent, a paired caller means 'caller' — one device's create must not steer every other UI. + navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(), + targetGroupId: OptionalString, + placement: BrowserPageCreationPlacement.optional() +}) + +export const BrowserOpenUrlParams = z.object({ + url: z.url(), + worktree: z.string().min(1) +}) diff --git a/src/shared/rpc-contract/client-events-params.ts b/src/shared/rpc-contract/client-events-params.ts new file mode 100644 index 00000000000..134c976e7d1 --- /dev/null +++ b/src/shared/rpc-contract/client-events-params.ts @@ -0,0 +1,8 @@ +import { z } from 'zod' + +export const ClientEventsUnsubscribeParams = z.object({ + subscriptionId: z + .unknown() + .transform((value) => (typeof value === 'string' && value.length > 0 ? value : '')) + .pipe(z.string().min(1, 'Missing subscriptionId')) +}) diff --git a/src/shared/rpc-contract/client-settings-params.ts b/src/shared/rpc-contract/client-settings-params.ts new file mode 100644 index 00000000000..def24ddc703 --- /dev/null +++ b/src/shared/rpc-contract/client-settings-params.ts @@ -0,0 +1,123 @@ +import { z } from 'zod' +import { isTaskProvider } from '../task-providers' +import type { TaskProvider } from '../task-providers' +import { isTuiAgent } from '../tui-agent-config' +import { normalizeDisabledTuiAgents } from '../tui-agent-selection' +import { + normalizeTuiAgentArgsRecord, + normalizeTuiAgentEnvRecord +} from '../tui-agent-launch-defaults' +import { normalizePRBotAuthorOverrides } from '../pr-bot-author-overrides' +import { WorktreeVisibilityDefaultsUpdate } from './worktree-visibility-defaults-params' + +export const TaskProviderParam = z.custom(isTaskProvider, { + message: 'Unknown task provider' +}) + +export const PRBotAuthorOverrideUpdate = z + .object({ author: z.string(), isBot: z.boolean() }) + .strict() + +export const NativeChatSessionOptionPickBase = { + modelId: z.string().trim().min(1).max(512), + adoptModelAsLaunchDefault: z.boolean().optional() +} + +export const NativeChatSessionOptionPick = z.union([ + z + .object({ + ...NativeChatSessionOptionPickBase, + optionId: z.enum(['model', 'effort']), + value: z.string().trim().min(1).max(512) + }) + .strict(), + z + .object({ + ...NativeChatSessionOptionPickBase, + optionId: z.enum(['fastMode', 'thinking']), + value: z.boolean() + }) + .strict() +]) + +export const NativeChatSessionOptionsMutation = z.discriminatedUnion('type', [ + z + .object({ + type: z.literal('apply-picks'), + agent: z.enum(['claude', 'codex', 'gemini', 'cursor', 'grok']), + picks: z.array(NativeChatSessionOptionPick).min(1).max(8) + }) + .strict(), + z + .object({ + type: z.literal('clear-model-if-missing'), + agent: z.enum(['claude', 'codex', 'gemini', 'cursor', 'grok']), + availableModelIds: z.array(z.string().trim().min(1).max(512)).min(1).max(256) + }) + .strict() +]) + +export const GitHubProjectRef = z + .object({ + owner: z.string(), + ownerType: z.enum(['organization', 'user']), + number: z.number().int(), + host: z.string().optional() + }) + .strict() + +export const GitHubProjectSettings = z + .object({ + pinned: z.array(GitHubProjectRef), + recent: z.array( + GitHubProjectRef.extend({ + lastOpenedAt: z.string() + }).strict() + ), + lastViewByProject: z.record(z.string(), z.object({ viewId: z.string() }).strict()), + activeProject: GitHubProjectRef.nullable() + }) + .strict() + +export const SettingsUpdate = z + .object({ + worktreeVisibilityDefaults: WorktreeVisibilityDefaultsUpdate.optional(), + defaultTuiAgent: z + .unknown() + .transform((value) => + value === null || value === 'blank' || isTuiAgent(value) ? value : undefined + ) + .optional(), + disabledTuiAgents: z + .unknown() + .transform((value) => normalizeDisabledTuiAgents(value)) + .optional(), + agentDefaultArgs: z + .unknown() + .transform((value) => normalizeTuiAgentArgsRecord(value)) + .optional(), + agentDefaultEnv: z + .unknown() + .transform((value) => normalizeTuiAgentEnvRecord(value)) + .optional(), + defaultTaskSource: TaskProviderParam.optional(), + visibleTaskProviders: z.array(TaskProviderParam).optional(), + defaultTaskViewPreset: z + .enum(['issues', 'my-issues', 'prs', 'my-prs', 'review', 'all']) + .optional(), + experimentalNewWorktreeCardStyle: z.boolean().optional(), + agentStatusHooksEnabled: z.boolean().optional(), + defaultRepoSelection: z.array(z.string()).nullable().optional(), + defaultLinearTeamSelection: z.array(z.string()).nullable().optional(), + compactWorktreeCards: z.boolean().optional(), + minimaxGroupId: z.string().optional(), + minimaxUsageModels: z.string().optional(), + minimaxEndpoint: z.enum(['overseas', 'cn']).optional(), + githubProjects: GitHubProjectSettings.optional(), + prBotAuthorOverrides: z + .unknown() + .transform((value) => normalizePRBotAuthorOverrides(value)) + .optional() + }) + .strict() + .default({}) diff --git a/src/shared/rpc-contract/client-ui-params.ts b/src/shared/rpc-contract/client-ui-params.ts new file mode 100644 index 00000000000..df84a06a256 --- /dev/null +++ b/src/shared/rpc-contract/client-ui-params.ts @@ -0,0 +1,257 @@ +import { z } from 'zod' +import { isFeatureTipId } from '../feature-tips' +import { + WORKTREE_CARD_PROPERTIES, + normalizeWorktreeCardProperties +} from '../worktree/card-properties' +import { isPluginPanelTabKey } from '../plugins/plugin-manifest' +import { isFeatureInteractionId } from '../feature-interactions' +import type { FeatureInteractionId } from '../feature-interactions' +import { ACTIVITY_GROUP_BY_VALUES, THREAD_READ_FILTER_VALUES } from '../agents-view-thread-filters' +import { isReleaseChannel } from '../release-channel' +import type { ReleaseChannel } from '../release-channel' +import { ClientUiWorkspaceFilterFields } from './client-ui-workspace-filter-fields-params' +import { TaskResumeState } from './task-resume-state-params' +import { WorkspaceCleanup } from './workspace-cleanup-ui-params' +import { omitUndefinedValues, tolerateUnknownValues } from './ui-update-value-tolerance-params' + +export const NullableString = z.string().nullable() + +export const StringArray = z.array(z.string()) + +export const FeatureTipIds = z.array( + z.custom(isFeatureTipId, { message: 'Unknown feature tip id' }) +) + +export const UnknownRecord = z.record(z.string(), z.unknown()) + +export const UnknownRecordArray = z.array(UnknownRecord) + +export type StaticRightSidebarTab = (typeof STATIC_RIGHT_SIDEBAR_TABS)[number] + +// Derived from the shared union so a new card property cannot drift out of the +// client schema — it previously omitted 'cli' and rejected the whole payload. +export const WorktreeCardPropertyParam = z.enum(WORKTREE_CARD_PROPERTIES) + +export const WorktreeCardProperties = z + .array(WorktreeCardPropertyParam) + .transform((value) => normalizeWorktreeCardProperties(value)) + +export const STATIC_RIGHT_SIDEBAR_TABS = [ + 'explorer', + 'search', + 'vault', + 'workspaces', + 'pr-checks', + 'source-control', + 'checks', + 'ports' +] as const + +// Plugin panels are open-ended `plugin:./` keys, so the +// schema validates their shape rather than enumerating them. +export const RightSidebarTabParam = z.custom( + (value) => + typeof value === 'string' && + (STATIC_RIGHT_SIDEBAR_TABS.includes(value as StaticRightSidebarTab) || + isPluginPanelTabKey(value)), + { message: 'Unknown right sidebar tab' } +) + +export const AgentActivityDisplayMode = z.enum(['compact', 'full']) + +export const StatusBarItem = z.enum([ + 'claude', + 'codex', + 'gemini', + 'antigravity', + 'opencode-go', + 'kimi', + 'minimax', + 'grok', + 'ssh', + 'resource-usage', + 'ports' +]) + +export const WorkspaceStatusDefinition = z.object({ + id: z.string(), + label: z.string(), + color: z.string().optional(), + icon: z.string().optional() +}) + +export const FeatureInteractionRecord = z + .object({ + firstInteractedAt: z.number().finite().nonnegative(), + interactionCount: z.number().int().positive().optional() + }) + .strict() + +export const FeatureInteractions = z + .record(z.string(), FeatureInteractionRecord) + .superRefine((value, ctx) => { + for (const id of Object.keys(value)) { + if (!isFeatureInteractionId(id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Unknown feature interaction id: ${id}`, + path: [id] + }) + } + } + }) + +export const FeatureInteractionIdParam = z.custom(isFeatureInteractionId, { + message: 'Unknown feature interaction id' +}) + +export const TopLevelViewSchema = z.enum([ + 'terminal', + 'settings', + 'tasks', + 'activity', + 'automations', + 'space', + 'skills', + 'artifacts', + 'mobile' +]) + +export const UiUpdateFields = z + .object({ + lastActiveRepoId: NullableString.optional(), + lastActiveWorktreeId: NullableString.optional(), + // Why: sync hydration ignores this persisted startup view, so paired windows stay put. + activeView: TopLevelViewSchema.optional(), + sidebarWidth: z.number().finite().optional(), + rightSidebarOpen: z.boolean().optional(), + rightSidebarTab: RightSidebarTabParam.optional(), + rightSidebarExplorerView: z.enum(['files', 'search']).optional(), + rightSidebarWidth: z.number().finite().optional(), + markdownTocPanelWidth: z.number().finite().optional(), + combinedDiffFileTreeWidth: z.number().finite().optional(), + groupBy: z.enum(['none', 'workspace-status', 'repo', 'pr-status']).optional(), + showWorkspaceLineage: z.boolean().optional(), + sortBy: z.enum(['name', 'smart', 'recent', 'repo', 'manual']).optional(), + projectOrderBy: z.enum(['manual', 'recent']).optional(), + showActiveOnly: z.boolean().optional(), + hideSleepingWorkspaces: z.boolean().optional(), + showSleepingWorkspaces: z.boolean().optional(), + showInactiveWorkspaces: z.boolean().optional(), + workspaceHostScope: z.string().optional(), + visibleWorkspaceHostIds: z.array(z.string()).nullable().optional(), + agentsVisibleHostIds: z.array(z.string()).nullable().optional(), + agentsFilterRepoIds: StringArray.optional(), + agentsShowChildAgents: z.boolean().optional(), + agentsCompactMode: z.boolean().optional(), + agentsShowSearch: z.boolean().optional(), + agentsReadFilter: z.enum(THREAD_READ_FILTER_VALUES).optional(), + agentsGroupBy: z.enum(ACTIVITY_GROUP_BY_VALUES).optional(), + workspaceHostOrder: z.array(z.string()).optional(), + automationHostFilter: z + .union([ + z.object({ kind: z.literal('all') }).strict(), + z.object({ kind: z.literal('host'), hostKey: z.string().min(1) }).strict() + ]) + .optional(), + manualRepoOrder: z + .array(z.object({ hostId: z.string(), repoId: z.string() }).strict()) + .optional(), + ...ClientUiWorkspaceFilterFields, + // Why: rides App.tsx's debounced writer, so omitting it rejected that entire + // payload (sidebar widths, filters, agent acks) for every paired client. + showDotfilesByWorktree: z.record(z.string(), z.boolean()).optional(), + collapsedGroups: StringArray.optional(), + uiZoomLevel: z.number().finite().optional(), + editorFontZoomLevel: z.number().finite().optional(), + worktreeCardProperties: WorktreeCardProperties.optional(), + _worktreeCardModeDefaulted: z.boolean().optional(), + agentActivityDisplayMode: AgentActivityDisplayMode.optional(), + workspaceStatuses: z.array(WorkspaceStatusDefinition).optional(), + workspaceBoardOpacity: z.number().finite().optional(), + workspaceBoardColumnWidth: z.number().finite().optional(), + syncTaskStatusFromWorkspaceBoard: z.boolean().optional(), + _workspaceStatusesDefaultOrderMigrated: z.boolean().optional(), + _workspaceStatusesReorderedDefaultRepaired: z.boolean().optional(), + _workspaceStatusesDefaultWorkflowMigrated: z.boolean().optional(), + _workspaceStatusesDefaultVisualsMigrated: z.boolean().optional(), + statusBarItems: z.array(StatusBarItem).optional(), + _portsStatusBarDefaultAdded: z.boolean().optional(), + _kimiStatusBarDefaultAdded: z.boolean().optional(), + _minimaxStatusBarDefaultAdded: z.boolean().optional(), + _antigravityStatusBarDefaultAdded: z.boolean().optional(), + _grokStatusBarDefaultAdded: z.boolean().optional(), + statusBarVisible: z.boolean().optional(), + usagePercentageDisplay: z.enum(['used', 'remaining']).optional(), + statusBarUsageMode: z.enum(['verbose', 'compact']).optional(), + dismissedUpdateVersion: NullableString.optional(), + dismissedUnexpectedSignoutVersion: NullableString.optional(), + lastUpdateCheckAt: z.number().finite().nullable().optional(), + pendingUpdateNudgeId: NullableString.optional(), + dismissedUpdateNudgeId: NullableString.optional(), + // Why the predicate rather than an inline z.enum: an enum here is a copy of + // RELEASE_CHANNELS, and a copy that drifts silently rejects the new + // channel's override on its way here — the picker moves, nothing installs. + releaseChannelOverride: z.custom(isReleaseChannel).nullable().optional(), + notificationPermissionRequested: z.boolean().optional(), + updateReassuranceSeen: z.boolean().optional(), + osc52ClipboardDefaultOnNoticePending: z.boolean().optional(), + acknowledgedAgentsByPaneKey: z.record(z.string(), z.number().finite()).optional(), + activityClearedAtByPaneKey: z.record(z.string(), z.number().finite()).optional(), + manuallyUnreadTurnsByPaneKey: z.record(z.string(), z.number().finite()).optional(), + browserDefaultUrl: NullableString.optional(), + browserDefaultSearchEngine: z + .enum(['google', 'duckduckgo', 'bing', 'kagi']) + .nullable() + .optional(), + browserDefaultZoomLevel: z.number().finite().optional(), + browserKagiSessionLink: NullableString.optional(), + windowBounds: z + .object({ + x: z.number().finite(), + y: z.number().finite(), + width: z.number().finite(), + height: z.number().finite() + }) + .nullable() + .optional(), + windowMaximized: z.boolean().optional(), + _sortBySmartMigrated: z.boolean().optional(), + _inlineAgentsDefaultedForExperiment: z.boolean().optional(), + _inlineAgentsDefaultedForAllUsers: z.boolean().optional(), + trustedOrcaHooks: z.record(z.string(), z.unknown()).optional(), + setupScriptPromptDismissedRepoIds: StringArray.optional(), + // Why: one-shot dismissals the renderer writes through ui.set; each was a + // whole-payload rejection for paired clients while unlisted. + setupGuideSidebarDismissed: z.boolean().optional(), + setupGuideBrowserMilestoneMigrated: z.boolean().optional(), + setupGuideBrowserMilestoneLegacyComplete: z.boolean().optional(), + browserImportHintHidden: z.boolean().optional(), + mobileEmulatorTabIntroDismissed: z.boolean().optional(), + mobileEmulatorAgentSetupDismissed: z.boolean().optional(), + projectOrderManualDefaultNoticeDismissed: z.boolean().optional(), + usagePercentageDisplayChangeNoticeDismissed: z.boolean().optional(), + usageEmptyStateDismissed: z.boolean().optional(), + petVisible: z.boolean().optional(), + petId: z.string().optional(), + customPets: UnknownRecordArray.optional(), + petSize: z.number().finite().optional(), + sidekickVisible: z.boolean().optional(), + sidekickId: z.string().optional(), + customSidekicks: UnknownRecordArray.optional(), + sidekickSize: z.number().finite().optional(), + taskResumeState: TaskResumeState.optional(), + workspaceCleanup: WorkspaceCleanup.optional(), + featureTipsSeenIds: FeatureTipIds.optional(), + featureInteractions: FeatureInteractions.optional(), + contextualToursSeenIds: StringArray.optional(), + contextualToursAutoEligible: z.boolean().optional() + }) + .strict() + +export const UiUpdate = z + .object(tolerateUnknownValues(UiUpdateFields.shape)) + .strict() + .default({}) + .transform(omitUndefinedValues) diff --git a/src/shared/rpc-contract/client-ui-workspace-filter-fields-params.ts b/src/shared/rpc-contract/client-ui-workspace-filter-fields-params.ts new file mode 100644 index 00000000000..d0239b03ec3 --- /dev/null +++ b/src/shared/rpc-contract/client-ui-workspace-filter-fields-params.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' + +export const ClientUiWorkspaceFilterFields = { + hideDefaultBranchWorkspace: z.boolean().optional(), + hideAutomationGeneratedWorkspaces: z.boolean().optional(), + hideCliCreatedWorkspaces: z.boolean().optional(), + hideDetachedHeadWorkspaces: z.boolean().optional(), + hideWorkspacesFromOtherDevices: z.boolean().optional(), + alwaysShowDefaultBranchWorkspace: z.boolean().optional(), + filterRepoIds: z.array(z.string()).optional() +} diff --git a/src/shared/rpc-contract/clipboard-params.ts b/src/shared/rpc-contract/clipboard-params.ts new file mode 100644 index 00000000000..3f2b1948775 --- /dev/null +++ b/src/shared/rpc-contract/clipboard-params.ts @@ -0,0 +1,67 @@ +import { z } from 'zod' +import { + CLIPBOARD_IMAGE_MAX_BASE64_CHARS, + CLIPBOARD_IMAGE_TOO_LARGE_ERROR +} from '../clipboard-image' + +export const MAX_CLIPBOARD_IMAGE_BASE64_CHARS = CLIPBOARD_IMAGE_MAX_BASE64_CHARS + +export const CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS = 512 * 1024 + +export const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/ + +export function isValidBase64(value: string): boolean { + return value.length % 4 !== 1 && BASE64_PATTERN.test(value) +} + +export function clipboardImageBase64Payload(maxChars: number, tooLargeMessage: string) { + return z.unknown().transform((value, ctx): string => { + if (typeof value !== 'string') { + ctx.addIssue({ code: 'custom', message: 'Missing image content' }) + return z.NEVER + } + if (value.length > maxChars) { + ctx.addIssue({ code: 'custom', message: tooLargeMessage }) + return z.NEVER + } + if (!isValidBase64(value)) { + ctx.addIssue({ code: 'custom', message: 'Clipboard image content must be base64' }) + return z.NEVER + } + return value + }) +} + +export const SaveImageAsTempFile = z.object({ + contentBase64: clipboardImageBase64Payload( + MAX_CLIPBOARD_IMAGE_BASE64_CHARS, + CLIPBOARD_IMAGE_TOO_LARGE_ERROR + ), + connectionId: z.string().min(1).nullable().optional() +}) + +export const StartImageUpload = z.object({ + expectedBase64Length: z + .number() + .int() + .nonnegative() + .max(MAX_CLIPBOARD_IMAGE_BASE64_CHARS, CLIPBOARD_IMAGE_TOO_LARGE_ERROR), + connectionId: z.string().min(1).nullable().optional() +}) + +export const AppendImageUploadChunk = z.object({ + uploadId: z.string().min(1), + offset: z.number().int().nonnegative(), + contentBase64: clipboardImageBase64Payload( + CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS, + 'Clipboard image chunk is too large' + ) +}) + +export const CommitImageUpload = z.object({ + uploadId: z.string().min(1) +}) + +export const AbortImageUpload = z.object({ + uploadId: z.string().min(1) +}) diff --git a/src/shared/rpc-contract/computer-params.ts b/src/shared/rpc-contract/computer-params.ts new file mode 100644 index 00000000000..08ffd1a6cfa --- /dev/null +++ b/src/shared/rpc-contract/computer-params.ts @@ -0,0 +1,5 @@ +import { z } from 'zod' + +export const ComputerPermissionsStatusParams = z.object({}) + +export const ComputerCapabilitiesParams = z.object({}) diff --git a/src/shared/rpc-contract/computer-schemas-params.ts b/src/shared/rpc-contract/computer-schemas-params.ts new file mode 100644 index 00000000000..d1802de3eff --- /dev/null +++ b/src/shared/rpc-contract/computer-schemas-params.ts @@ -0,0 +1,227 @@ +import { z } from 'zod' +import { + OptionalBoolean, + OptionalFiniteNumber, + OptionalString, + requiredString, + requiredStringAllowingEmpty +} from './rpc-param-primitives' +import { + computerUseClickModifiersValidationMessage, + computerUseHotkeyValidationMessage, + computerUsePressKeyValidationMessage +} from '../computer-use-key-spec' + +export const OptionalNonNegativeInt = z.number().int().nonnegative().optional() + +export const OptionalPositiveInt = z.number().int().positive().optional() + +export const ComputerTarget = z.object({ + app: requiredString('Missing app'), + session: OptionalString, + worktree: OptionalString +}) + +export const ComputerObserveTargetBase = ComputerTarget.extend({ + noScreenshot: OptionalBoolean, + restoreWindow: OptionalBoolean, + windowId: OptionalNonNegativeInt, + windowIndex: OptionalNonNegativeInt +}) + +export function validateWindowTarget( + value: { windowId?: number; windowIndex?: number }, + ctx: z.RefinementCtx +): void { + if (value.windowId !== undefined && value.windowIndex !== undefined) { + ctx.addIssue({ + code: 'custom', + message: 'Window targeting accepts either --window-id or --window-index, not both' + }) + } +} + +export function validateComputerTarget( + value: { session?: string; worktree?: string; windowId?: number; windowIndex?: number }, + ctx: z.RefinementCtx +): void { + if (value.session !== undefined && value.worktree !== undefined) { + ctx.addIssue({ + code: 'custom', + message: 'Computer-use targeting accepts either session or worktree, not both' + }) + } + validateWindowTarget(value, ctx) +} + +export const ComputerObserveTarget = ComputerObserveTargetBase.superRefine(validateComputerTarget) + +export const ListApps = z.object({}).strict() + +export const ListWindows = z + .object({ + app: requiredString('Missing app') + }) + .strict() + +export const Click = ComputerObserveTargetBase.extend({ + elementIndex: OptionalNonNegativeInt, + x: OptionalFiniteNumber, + y: OptionalFiniteNumber, + clickCount: OptionalPositiveInt, + mouseButton: z.enum(['left', 'right', 'middle']).optional(), + modifiers: z.string().optional() +}).superRefine((value, ctx) => { + validateComputerTarget(value, ctx) + const hasElement = value.elementIndex !== undefined + const hasX = value.x !== undefined + const hasY = value.y !== undefined + if (!hasElement && !(hasX && hasY)) { + ctx.addIssue({ + code: 'custom', + message: 'Click requires --element-index or both --x and --y' + }) + } + if (hasX !== hasY) { + ctx.addIssue({ + code: 'custom', + message: 'Click coordinates require both --x and --y' + }) + } + if (hasElement && (hasX || hasY)) { + ctx.addIssue({ + code: 'custom', + message: 'Click accepts either --element-index or coordinate flags, not both' + }) + } + if (value.modifiers !== undefined) { + const message = computerUseClickModifiersValidationMessage(value.modifiers) + if (message) { + ctx.addIssue({ code: 'custom', message }) + } + } +}) + +export const PerformSecondaryAction = ComputerObserveTargetBase.extend({ + elementIndex: OptionalNonNegativeInt, + action: requiredString('Missing action') +}).superRefine((value, ctx) => { + validateComputerTarget(value, ctx) + if (value.elementIndex === undefined) { + ctx.addIssue({ code: 'custom', message: 'Missing element index' }) + } +}) + +export const Scroll = ComputerObserveTargetBase.extend({ + elementIndex: OptionalNonNegativeInt, + x: OptionalFiniteNumber, + y: OptionalFiniteNumber, + direction: z.enum(['up', 'down', 'left', 'right']), + pages: z.number().positive().optional() +}).superRefine((value, ctx) => { + validateComputerTarget(value, ctx) + const hasElement = value.elementIndex !== undefined + const hasX = value.x !== undefined + const hasY = value.y !== undefined + if (!hasElement && !(hasX && hasY)) { + ctx.addIssue({ + code: 'custom', + message: 'Scroll requires --element-index or both --x and --y' + }) + } + if (hasX !== hasY) { + ctx.addIssue({ + code: 'custom', + message: 'Scroll coordinates require both --x and --y' + }) + } + if (hasElement && (hasX || hasY)) { + ctx.addIssue({ + code: 'custom', + message: 'Scroll accepts either --element-index or coordinate flags, not both' + }) + } +}) + +export const Drag = ComputerObserveTargetBase.extend({ + fromElementIndex: OptionalNonNegativeInt, + toElementIndex: OptionalNonNegativeInt, + fromX: OptionalFiniteNumber, + fromY: OptionalFiniteNumber, + toX: OptionalFiniteNumber, + toY: OptionalFiniteNumber +}).superRefine((value, ctx) => { + validateComputerTarget(value, ctx) + const hasElementPair = value.fromElementIndex !== undefined && value.toElementIndex !== undefined + const hasPartialElementPair = + value.fromElementIndex !== undefined || value.toElementIndex !== undefined + const coordinateKeys = [value.fromX, value.fromY, value.toX, value.toY] + const hasCoordinatePair = coordinateKeys.every((coordinate) => coordinate !== undefined) + const hasPartialCoordinatePair = coordinateKeys.some((coordinate) => coordinate !== undefined) + if (hasElementPair && hasCoordinatePair) { + ctx.addIssue({ + code: 'custom', + message: 'Drag accepts either element indexes or coordinate flags, not both' + }) + } + if (!hasElementPair && !hasCoordinatePair) { + ctx.addIssue({ + code: 'custom', + message: 'Drag requires --from-element-index and --to-element-index, or all coordinate flags' + }) + } + if (hasPartialElementPair && !hasElementPair) { + ctx.addIssue({ + code: 'custom', + message: 'Drag element targeting requires both --from-element-index and --to-element-index' + }) + } + if (hasPartialCoordinatePair && !hasCoordinatePair) { + ctx.addIssue({ + code: 'custom', + message: 'Drag coordinates require --from-x, --from-y, --to-x, and --to-y' + }) + } +}) + +export const TypeText = ComputerObserveTargetBase.extend({ + text: requiredString('Missing text') +}).superRefine(validateComputerTarget) + +export const PressKey = ComputerObserveTargetBase.extend({ + key: requiredString('Missing key') +}).superRefine((value, ctx) => { + validateComputerTarget(value, ctx) + const message = computerUsePressKeyValidationMessage(value.key) + if (message) { + ctx.addIssue({ code: 'custom', message }) + } +}) + +export const Hotkey = ComputerObserveTargetBase.extend({ + key: requiredString('Missing key') +}).superRefine((value, ctx) => { + validateComputerTarget(value, ctx) + const message = computerUseHotkeyValidationMessage(value.key) + if (message) { + ctx.addIssue({ code: 'custom', message }) + } +}) + +export const ComputerPermissions = z.object({ + id: z.enum(['accessibility', 'screenshots']).optional() +}) + +export const PasteText = ComputerObserveTargetBase.extend({ + text: requiredString('Missing text') +}).superRefine(validateComputerTarget) + +export const SetValue = ComputerObserveTargetBase.extend({ + elementIndex: OptionalNonNegativeInt, + value: requiredStringAllowingEmpty('Missing value') +}).superRefine((value, ctx) => { + validateComputerTarget(value, ctx) + if (value.elementIndex === undefined) { + ctx.addIssue({ code: 'custom', message: 'Missing element index' }) + } +}) diff --git a/src/shared/rpc-contract/emulator-params.ts b/src/shared/rpc-contract/emulator-params.ts new file mode 100644 index 00000000000..401e1ee05aa --- /dev/null +++ b/src/shared/rpc-contract/emulator-params.ts @@ -0,0 +1,154 @@ +import { z } from 'zod' + +// Minimal schemas for emulator commands (loose for initial testing; can be tightened like browser-schemas). +export const WorktreeParam = z.object({ worktree: z.string().optional() }).partial() + +export const TapParams = z.object({ + x: z.number().min(0).max(1), + y: z.number().min(0).max(1), + device: z.string().optional(), + emulator: z.string().optional(), + worktree: z.string().optional() +}) + +export const GesturePoint = z.object({ + edge: z.number().int().min(0).max(4).optional(), + type: z.enum(['begin', 'move', 'end']), + x: z.number().min(0).max(1), + y: z.number().min(0).max(1) +}) + +export const GestureParams = z.object({ + points: z.array(GesturePoint).min(2).max(64), + device: z.string().optional(), + emulator: z.string().optional(), + worktree: z.string().optional() +}) + +export const TypeParams = z.object({ + text: z.string(), + device: z.string().optional(), + emulator: z.string().optional(), + worktree: z.string().optional() +}) + +export const ButtonParams = z.object({ + name: z.string(), + device: z.string().optional(), + emulator: z.string().optional(), + worktree: z.string().optional() +}) + +export const RotateOrientation = z.enum([ + 'portrait', + 'portrait_upside_down', + 'landscape_left', + 'landscape_right' +]) + +export const RotateParams = z.object({ + orientation: RotateOrientation, + device: z.string().optional(), + emulator: z.string().optional(), + worktree: z.string().optional() +}) + +export const ExecParams = z.object({ + command: z.string(), + device: z.string().optional(), + emulator: z.string().optional(), + worktree: z.string().optional() +}) + +export const LaunchParams = z.object({ + package: z.string(), + activity: z.string().optional(), + device: z.string().optional(), + emulator: z.string().optional(), + worktree: z.string().optional() +}) + +export const PermissionsParams = z + .object({ + op: z.enum(['grant', 'revoke', 'reset']), + package: z.string().optional(), + permission: z.string().optional(), + device: z.string().optional(), + emulator: z.string().optional(), + worktree: z.string().optional() + }) + .superRefine((value, ctx) => { + if (value.op === 'reset') { + if (value.package) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['package'], + message: 'package is not allowed for reset' + }) + } + if (value.permission) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['permission'], + message: 'permission is not allowed for reset' + }) + } + return + } + if (!value.package) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['package'], + message: 'package is required for grant/revoke' + }) + } + if (!value.permission) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['permission'], + message: 'permission is required for grant/revoke' + }) + } + }) + +export const AxParams = z.object({ + device: z.string().optional(), + emulator: z.string().optional(), + worktree: z.string().optional() +}) + +export const LogcatParams = z.object({ + lines: z.number().int().positive().optional(), + filters: z.array(z.string()).optional(), + device: z.string().optional(), + emulator: z.string().optional(), + worktree: z.string().optional() +}) + +export const AttachParams = z.object({ + device: z.string().optional(), + worktree: z.string().optional(), + focus: z.boolean().optional() +}) + +export const KillParams = z.object({ + device: z.string().optional(), + emulator: z.string().optional(), + worktree: z.string().optional() +}) + +export const ShutdownParams = KillParams.extend({ + managedOnly: z.boolean().optional() +}) + +export const ListParams = WorktreeParam + +export const EmulatorUnregisterActiveParams = z + .object({ worktree: z.string().optional() }) + .partial() + +export const EmulatorListDevicesParams = z.object({ worktree: z.string().optional() }).partial() + +export const EmulatorAvailabilityParams = z.object({ worktree: z.string().optional() }).partial() + +export const EmulatorListSimulatorsParams = z.object({ worktree: z.string().optional() }).partial() diff --git a/src/shared/rpc-contract/files-mutation-params.ts b/src/shared/rpc-contract/files-mutation-params.ts new file mode 100644 index 00000000000..554d1f387e0 --- /dev/null +++ b/src/shared/rpc-contract/files-mutation-params.ts @@ -0,0 +1,84 @@ +import { z } from 'zod' +import { FileOpen, WorktreeSelector } from './files-target-params' + +export const RUNTIME_FILE_BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/ + +export function isValidRuntimeFileBase64(value: unknown): value is string { + return ( + typeof value === 'string' && value.length % 4 !== 1 && RUNTIME_FILE_BASE64_PATTERN.test(value) + ) +} + +export const FileMutationOpen = FileOpen.extend({ + expectedExecutionHostId: z.string().min(1).optional(), + expectedSshTargetId: z.string().min(1).optional(), + expectedSshConnectionGeneration: z.number().int().nonnegative().optional() +}) + +// Why: write content must be a real string. Coercing a missing/non-string value +// to '' silently truncated the target file to empty instead of erroring. An +// explicit '' is still accepted (writing an empty file is legitimate). +export const FileWrite = FileMutationOpen.extend({ + content: z + .unknown() + .refine((v): v is string => typeof v === 'string', { message: 'Missing file content' }) +}) + +export const FileWriteBase64 = FileMutationOpen.extend({ + contentBase64: z + .unknown() + .refine((v): v is string => typeof v === 'string', { message: 'Missing file content' }) + // Why: Buffer.from(..., 'base64') accepts malformed input by dropping + // invalid bytes, which can silently create empty or corrupt uploaded files. + .refine(isValidRuntimeFileBase64, 'File content must be base64') +}) + +export const FileWriteBase64Chunk = FileWriteBase64.extend({ + append: z.boolean().optional() +}) + +export const FileRename = WorktreeSelector.extend({ + expectedExecutionHostId: z.string().min(1).optional(), + expectedSshTargetId: z.string().min(1).optional(), + expectedSshConnectionGeneration: z.number().int().nonnegative().optional(), + oldRelativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing source path')), + newRelativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing destination path')) +}) + +export const FileCopy = WorktreeSelector.extend({ + expectedExecutionHostId: z.string().min(1).optional(), + expectedSshTargetId: z.string().min(1).optional(), + expectedSshConnectionGeneration: z.number().int().nonnegative().optional(), + sourceRelativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing source path')), + destinationRelativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing destination path')) +}) + +export const FileCommitUpload = WorktreeSelector.extend({ + expectedExecutionHostId: z.string().min(1).optional(), + expectedSshTargetId: z.string().min(1).optional(), + expectedSshConnectionGeneration: z.number().int().nonnegative().optional(), + tempRelativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing temporary path')), + finalRelativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing final path')) +}) + +export const FileDelete = FileMutationOpen.extend({ + recursive: z.boolean().optional() +}) diff --git a/src/shared/rpc-contract/files-params.ts b/src/shared/rpc-contract/files-params.ts new file mode 100644 index 00000000000..6ae6267caa0 --- /dev/null +++ b/src/shared/rpc-contract/files-params.ts @@ -0,0 +1,99 @@ +import { z } from 'zod' +import { QUICK_OPEN_REMOTE_QUERY_MAX_CODE_UNITS } from '../quick-open-path-search' +import { FileOpen, WorktreeSelector } from './files-target-params' + +export const FilePathSearch = WorktreeSelector.extend({ + query: z.string().max(QUICK_OPEN_REMOTE_QUERY_MAX_CODE_UNITS).default(''), + limit: z.number().int().positive().max(32).default(16), + excludePaths: z.array(z.string()).optional(), + mode: z.literal('quick-open').optional() +}) + +export const ResolveTerminalPath = WorktreeSelector.extend({ + pathText: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing path text')), + terminal: z + .unknown() + .transform((v) => (typeof v === 'string' && v.length > 0 ? v : null)) + .nullable() + .optional(), + cwd: z + .unknown() + .transform((v) => (typeof v === 'string' && v.length > 0 ? v : null)) + .nullable() + .optional(), + crossWorkspace: z + .unknown() + .transform((v) => v === true) + .optional(), + nativeChatContext: z + .object({ + tabId: z.string().min(1), + sessionId: z.string().min(1) + }) + .optional() +}) + +export const FileOpenDiff = FileOpen.extend({ + staged: z.boolean().optional() +}) + +export const DocPreviewFileRead = FileOpen.extend({ + entryRelativePath: z.string().min(1), + implicitRootRelativePath: z.string().nullable(), + authorizedRootRelativePaths: z.array(z.string()) +}) + +export const FileTreePath = WorktreeSelector.extend({ + relativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string()) +}) + +export const ServerDirectoryBrowse = z.object({ + path: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string()) +}) + +export const FileReadChunk = FileOpen.extend({ + offset: z.number().int().nonnegative(), + length: z + .number() + .int() + .positive() + .max(512 * 1024) +}) + +export const FileSearch = WorktreeSelector.extend({ + query: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing search query')), + caseSensitive: z.boolean().optional(), + wholeWord: z.boolean().optional(), + useRegex: z.boolean().optional(), + includePattern: z.string().optional(), + excludePattern: z.string().optional(), + maxResults: z.number().int().positive().optional() +}) + +// Why: `maxResults` is a new optional field (wire rule 1) — an older host strips it and keeps its +// own default. It existed only on the Electron IPC hop, so "the client names its cap and a full page +// means there is more" was true for desktop and merely incidental for web and mobile, which were +// saved by `remoteFileContentBudget` defaulting the cap inside `listRuntimeFiles`. +export const FileListAll = WorktreeSelector.extend({ + excludePaths: z.array(z.string()).optional(), + maxResults: z.number().int().positive().optional() +}) + +export const FileUnwatch = z.object({ + subscriptionId: z + .unknown() + .transform((value) => (typeof value === 'string' && value.length > 0 ? value : '')) + .pipe(z.string().min(1, 'Missing subscriptionId')) +}) diff --git a/src/shared/rpc-contract/files-target-params.ts b/src/shared/rpc-contract/files-target-params.ts new file mode 100644 index 00000000000..6c546b3605e --- /dev/null +++ b/src/shared/rpc-contract/files-target-params.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' + +export const WorktreeSelector = z.object({ + worktree: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing worktree selector')) +}) + +export const FileOpen = WorktreeSelector.extend({ + relativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing relative path')) +}) diff --git a/src/shared/rpc-contract/files-terminal-artifact-params.ts b/src/shared/rpc-contract/files-terminal-artifact-params.ts new file mode 100644 index 00000000000..2e7e4f8015b --- /dev/null +++ b/src/shared/rpc-contract/files-terminal-artifact-params.ts @@ -0,0 +1,19 @@ +import { z } from 'zod' +import { WorktreeSelector } from './files-target-params' + +export const TerminalArtifactFile = WorktreeSelector.extend({ + grantId: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing terminal artifact grant')), + absolutePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing terminal artifact path')) +}) + +export const TerminalArtifactFileWrite = TerminalArtifactFile.extend({ + content: z + .unknown() + .refine((v): v is string => typeof v === 'string', { message: 'Missing file content' }) +}) diff --git a/src/shared/rpc-contract/folder-workspace-params.ts b/src/shared/rpc-contract/folder-workspace-params.ts new file mode 100644 index 00000000000..7b416b0083c --- /dev/null +++ b/src/shared/rpc-contract/folder-workspace-params.ts @@ -0,0 +1,85 @@ +import { z } from 'zod' +import { WorkspaceLinkedItemSchema } from '../workspace-linked-item-schema' +import { TaskSourceContextSchema } from '../task-source-context-schema' +import { isWorkspaceLinkedItemSourceContextMatch } from '../workspace-linked-item-source-context' +import { isTuiAgent } from '../tui-agent-config' +import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives' +import { DiffCommentSchema } from '../diff-comment-schema' + +export const FolderWorkspaceLinkedTask = WorkspaceLinkedItemSchema.nullable() + +export function assertLinkedTaskSourceContextMatch( + value: { + linkedTask?: z.infer + linkedTaskSourceContext?: z.infer | null + }, + ctx: z.RefinementCtx +): void { + if ( + value.linkedTask && + value.linkedTaskSourceContext && + !isWorkspaceLinkedItemSourceContextMatch(value.linkedTask, value.linkedTaskSourceContext) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Linked task and source context identities must match' + }) + } +} + +export const FolderWorkspaceCreate = z + .object({ + projectGroupId: requiredString('Missing project group id'), + name: OptionalString, + folderPath: OptionalString.nullable().optional(), + connectionId: OptionalString.nullable().optional(), + linkedTask: FolderWorkspaceLinkedTask.optional(), + linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(), + createdWithAgent: z.string().refine(isTuiAgent).optional(), + pendingFirstAgentMessageRename: z.boolean().optional() + }) + .superRefine(assertLinkedTaskSourceContextMatch) + +export const FolderWorkspaceUpdate = z.object({ + folderWorkspaceId: requiredString('Missing folder workspace id'), + updates: z + .object({ + name: OptionalString, + folderPath: OptionalString, + linkedTask: FolderWorkspaceLinkedTask.optional(), + linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(), + comment: z.string().optional(), + isArchived: z.boolean().optional(), + isUnread: z.boolean().optional(), + isPinned: z.boolean().optional(), + sortOrder: OptionalFiniteNumber, + manualOrder: OptionalFiniteNumber, + workspaceStatus: OptionalString, + createdWithAgent: z.string().refine(isTuiAgent).optional(), + pendingFirstAgentMessageRename: z.boolean().optional(), + firstAgentMessageRenameError: z.string().nullable().optional(), + lastActivityAt: OptionalFiniteNumber, + diffComments: z.array(DiffCommentSchema).optional() + }) + .superRefine(assertLinkedTaskSourceContextMatch) +}) + +export const FolderWorkspaceSelector = z.object({ + folderWorkspaceId: requiredString('Missing folder workspace id') +}) + +export const FolderWorkspacePathStatus = z.discriminatedUnion('scope', [ + z.object({ + scope: z.literal('folder-workspace'), + folderWorkspaceId: requiredString('Missing folder workspace id') + }), + z.object({ + scope: z.literal('project-group'), + projectGroupId: requiredString('Missing project group id') + }), + z.object({ + scope: z.literal('path'), + path: requiredString('Missing folder path'), + connectionId: OptionalString.nullable().optional() + }) +]) diff --git a/src/shared/rpc-contract/git-admission-tier-params.ts b/src/shared/rpc-contract/git-admission-tier-params.ts new file mode 100644 index 00000000000..e45173782f8 --- /dev/null +++ b/src/shared/rpc-contract/git-admission-tier-params.ts @@ -0,0 +1,14 @@ +import { z } from 'zod' + +// Why: the admission tier is part of the wire contract, so the literal union +// lives with the schema; src/main re-exports it instead of redeclaring it. +export type GitAdmissionTier = 'interactive' | 'status' | 'background' + +export const OptionalGitAdmissionTier = z + .unknown() + .optional() + .transform((value): GitAdmissionTier | undefined => { + return value === 'interactive' || value === 'status' || value === 'background' + ? value + : undefined + }) diff --git a/src/shared/rpc-contract/git-params.ts b/src/shared/rpc-contract/git-params.ts new file mode 100644 index 00000000000..6e4cb35b8ef --- /dev/null +++ b/src/shared/rpc-contract/git-params.ts @@ -0,0 +1,271 @@ +import { z } from 'zod' +import { OptionalGitAdmissionTier } from './git-admission-tier-params' + +export const WorktreeSelector = z.object({ + worktree: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing worktree selector')) +}) + +export const GitStatusParams = WorktreeSelector.extend({ + admissionTier: OptionalGitAdmissionTier, + includeIgnored: z.boolean().optional(), + includeLineStats: z.boolean().optional(), + bypassEffectiveUpstreamNegativeCache: z.boolean().optional(), + reuseLineStats: z.boolean().optional(), + // Shape is re-validated host-side before it reaches a git argv. + branchLineTotalMergeBase: z.string().optional() +}) + +export const GitCheckIgnored = WorktreeSelector.extend({ + paths: z.array(z.string().min(1, 'Missing path')).max(2000) +}) + +export const GitSubmoduleStatus = WorktreeSelector.extend({ + submodulePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe( + z + .string() + .min(1, 'Missing submodule path') + // Why: never let a submodule path be parsed as a git flag (arg injection). + .refine((value) => !value.startsWith('-'), 'Submodule path must not start with -') + ), + // Why: submodule expansion is requested from a Source Control row; the row + // area determines whether the gitlink range is HEAD->index or index->worktree. + area: z.enum(['staged', 'unstaged', 'untracked']).optional() +}) + +export const GitFilePath = WorktreeSelector.extend({ + filePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing file path')) +}) + +export const GitDiff = GitFilePath.extend({ + staged: z.boolean(), + compareAgainstHead: z.boolean().optional() +}) + +export const GitBranchCompare = WorktreeSelector.extend({ + admissionTier: OptionalGitAdmissionTier, + baseRef: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe( + z + .string() + .min(1, 'Missing base ref') + .refine((value) => !value.startsWith('-'), 'Base ref must not start with -') + ) +}) + +export const FullGitObjectId = z + .string() + .regex(/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/, 'Expected a full git object id') + +export const GitCommitCompare = WorktreeSelector.extend({ + commitId: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(FullGitObjectId) +}) + +export const GitHistory = WorktreeSelector.extend({ + limit: z.number().int().min(1).max(200).optional(), + baseRef: z.string().nullable().optional() +}) + +export const GitBranchDiff = GitFilePath.extend({ + compare: z.object({ + baseRef: z.string().optional(), + baseOid: FullGitObjectId.optional(), + headOid: FullGitObjectId, + mergeBase: FullGitObjectId + }), + oldPath: z.string().optional() +}) + +export const GitCommitDiff = GitFilePath.extend({ + commitOid: FullGitObjectId, + parentOid: FullGitObjectId.nullable().optional(), + oldPath: z.string().optional() +}) + +export const GitCommit = WorktreeSelector.extend({ + message: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing commit message')) +}) + +export const CommitMessageModelCapability = z.object({ + id: z.string(), + label: z.string(), + thinkingLevels: z.array(z.object({ id: z.string(), label: z.string() })).optional(), + defaultThinkingLevel: z.string().optional() +}) + +export const CommitMessageAiSettings = z.object({ + enabled: z.boolean(), + agentId: z.string().nullable(), + selectedModelByAgent: z.record(z.string(), z.string()), + selectedModelByAgentByHost: z.record(z.string(), z.record(z.string(), z.string())).optional(), + discoveredModelsByAgent: z.record(z.string(), z.array(CommitMessageModelCapability)).optional(), + discoveredModelsByAgentByHost: z + .record(z.string(), z.record(z.string(), z.array(CommitMessageModelCapability))) + .optional(), + selectedThinkingByModel: z.record(z.string(), z.string()), + customPrompt: z.string(), + customAgentCommand: z.string() +}) + +export const SourceControlAiSettings = CommitMessageAiSettings.omit({ customPrompt: true }).extend({ + actions: z + .record( + z.string(), + z.object({ + agentId: z.string().nullable().optional(), + commandInputTemplate: z.string().optional(), + agentArgs: z.string().optional() + }) + ) + .optional(), + instructionsByOperation: z.record(z.string(), z.string()).optional(), + modelOverridesByOperation: z + .record( + z.string(), + z.object({ + selectedModelByAgent: z.record(z.string(), z.string()).optional(), + selectedModelByAgentByHost: z + .record(z.string(), z.record(z.string(), z.string())) + .optional(), + selectedThinkingByModel: z.record(z.string(), z.string()).optional() + }) + ) + .optional(), + prCreationDefaults: z + .object({ + draft: z.boolean().optional(), + useTemplate: z.boolean().optional(), + generateDetailsOnOpen: z.boolean().optional(), + openAfterCreate: z.boolean().optional() + }) + .optional(), + launchActionDefaults: z + .record( + z.string(), + z.object({ + agentId: z.string().nullable().optional(), + commandInputTemplate: z.string().optional(), + agentArgs: z.string().optional() + }) + ) + .optional() +}) + +export const ResolvedSourceControlAiGenerationParams = z.object({ + agentId: z.string(), + model: z.string(), + thinkingLevel: z.string().optional(), + customPrompt: z.string().optional(), + commandInputTemplate: z.string().optional(), + agentArgs: z.string().optional(), + customAgentCommand: z.string().optional(), + agentCommandOverride: z.string().optional() +}) + +export const GitGenerateCommitMessage = WorktreeSelector.extend({ + commitMessageAi: CommitMessageAiSettings.optional(), + sourceControlAi: SourceControlAiSettings.optional(), + sourceControlAiResolvedParams: ResolvedSourceControlAiGenerationParams.optional(), + agentCmdOverrides: z.record(z.string(), z.string()).optional(), + commitMessageDiscoveryHostKey: z.string().optional() +}) + +export const GitDiscoverCommitMessageModels = WorktreeSelector.extend({ + agentId: z.string().min(1, 'Missing agent id'), + agentCmdOverrides: z.record(z.string(), z.string()).optional() +}) + +export const GitGeneratePullRequestFields = GitGenerateCommitMessage.extend({ + base: z.string().min(1, 'Missing base branch'), + title: z.string(), + body: z.string(), + draft: z.boolean(), + provider: z + .enum(['github', 'gitlab', 'bitbucket', 'azure-devops', 'gitea', 'unsupported']) + .optional(), + useTemplate: z.boolean().optional() +}) + +export const GitBulkPaths = WorktreeSelector.extend({ + filePaths: z.array(z.string().min(1, 'Missing file path')) +}) + +export const GitPushTargetParam = z.object({ + remoteName: z.string(), + branchName: z.string(), + remoteUrl: z.string().optional(), + remoteCreated: z.boolean().optional() +}) + +export const GitPush = WorktreeSelector.extend({ + publish: z.boolean().optional(), + forceWithLease: z.boolean().optional(), + pushTarget: GitPushTargetParam.optional() +}) + +export const GitTargetedRemote = WorktreeSelector.extend({ + pushTarget: GitPushTargetParam.optional() +}) + +export const GitForkSync = WorktreeSelector.extend({ + expectedUpstream: z.object({ + owner: z.string().trim().min(1), + repo: z.string().trim().min(1) + }) +}) + +export const GitRebaseFromBase = WorktreeSelector.extend({ + baseRef: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe( + z + .string() + .min(1, 'Missing base ref') + .refine((value) => !value.startsWith('-'), 'Base ref must not start with -') + ) +}) + +export const GitCheckout = WorktreeSelector.extend({ + branch: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe( + z + .string() + .min(1, 'Missing branch') + // Why: never let a branch arg be parsed as a git flag (arg injection). + .refine((value) => !value.startsWith('-'), 'Branch must not start with -') + ) +}) + +export const GitRemoteFileUrl = WorktreeSelector.extend({ + relativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing relative path')), + line: z.number().int().min(1) +}) + +export const GitRemoteCommitUrl = WorktreeSelector.extend({ + sha: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(FullGitObjectId) +}) diff --git a/src/shared/rpc-contract/github-issue-params.ts b/src/shared/rpc-contract/github-issue-params.ts new file mode 100644 index 00000000000..47effe410ff --- /dev/null +++ b/src/shared/rpc-contract/github-issue-params.ts @@ -0,0 +1,27 @@ +import { z } from 'zod' +import { RepoSelector, SlugRepo } from './github-repo-target-params' +import { requiredString } from './rpc-param-primitives' +import { IssueUpdate } from './github-issue-update-params' + +export const Issue = RepoSelector.extend({ + number: z.number().int().positive() +}) + +export const CreateIssue = RepoSelector.extend({ + title: requiredString('Missing title'), + body: z.string(), + labels: z.array(z.string()).optional(), + assignees: z.array(z.string()).optional() +}) + +export const UpdateIssue = RepoSelector.extend({ + number: z.number().int().positive(), + updates: IssueUpdate +}) + +export const IssueComment = RepoSelector.extend({ + number: z.number().int().positive(), + body: requiredString('Comment body required'), + type: z.enum(['issue', 'pr']).optional(), + prRepo: SlugRepo.nullable().optional() +}) diff --git a/src/shared/rpc-contract/github-issue-update-params.ts b/src/shared/rpc-contract/github-issue-update-params.ts new file mode 100644 index 00000000000..40eb7ab7d51 --- /dev/null +++ b/src/shared/rpc-contract/github-issue-update-params.ts @@ -0,0 +1,13 @@ +import { z } from 'zod' +import { OptionalString } from './rpc-param-primitives' + +// Why: repo-selector and slug-addressed issue updates must accept the identical field set. +export const IssueUpdate = z.object({ + state: z.enum(['open', 'closed']).optional(), + title: OptionalString, + body: OptionalString, + addLabels: z.array(z.string()).optional(), + removeLabels: z.array(z.string()).optional(), + addAssignees: z.array(z.string()).optional(), + removeAssignees: z.array(z.string()).optional() +}) diff --git a/src/shared/rpc-contract/github-project-params.ts b/src/shared/rpc-contract/github-project-params.ts new file mode 100644 index 00000000000..1a7a0d76f0c --- /dev/null +++ b/src/shared/rpc-contract/github-project-params.ts @@ -0,0 +1,128 @@ +import { z } from 'zod' +import { SlugRepo } from './github-repo-target-params' +import { OptionalString, requiredString } from './rpc-param-primitives' +import { IssueUpdate } from './github-issue-update-params' + +export const SlugAssignableUsers = SlugRepo.extend({ + seedLogins: z.array(z.string()).optional() +}) + +export const ProjectOwnerType = z.enum(['organization', 'user']) + +export const ProjectViewTable = z.object({ + owner: requiredString('Missing owner'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, + ownerType: ProjectOwnerType, + projectNumber: z.number().int().positive(), + viewId: OptionalString, + viewNumber: z.number().int().positive().optional(), + viewName: OptionalString, + queryOverride: OptionalString +}) + +export const ProjectWorkItemDetailsBySlug = SlugRepo.extend({ + number: z.number().int().positive(), + type: z.enum(['issue', 'pr']) +}) + +export const ProjectRef = z.object({ + input: requiredString('Missing project reference'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString +}) + +export const ProjectViews = z.object({ + owner: requiredString('Missing owner'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, + ownerType: ProjectOwnerType, + projectNumber: z.number().int().positive() +}) + +export const ProjectItemField = z.object({ + projectId: requiredString('Missing project ID'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, + itemId: requiredString('Missing item ID'), + fieldId: requiredString('Missing field ID'), + value: z.any() +}) + +export const ClearProjectItemField = z.object({ + projectId: requiredString('Missing project ID'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, + itemId: requiredString('Missing item ID'), + fieldId: requiredString('Missing field ID') +}) + +export const SlugIssueUpdate = z.object({ + owner: requiredString('Missing owner'), + repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, + number: z.number().int().positive(), + updates: IssueUpdate +}) + +export const SlugPullRequestUpdate = z.object({ + owner: requiredString('Missing owner'), + repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, + number: z.number().int().positive(), + updates: z.object({ + state: z.enum(['open', 'closed']).optional(), + title: OptionalString, + body: OptionalString + }) +}) + +export const SlugIssueTypeUpdate = z.object({ + owner: requiredString('Missing owner'), + repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, + number: z.number().int().positive(), + issueTypeId: z.string().nullable() +}) + +export const SlugIssueComment = z.object({ + owner: requiredString('Missing owner'), + repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, + number: z.number().int().positive(), + body: requiredString('Comment body required') +}) + +export const SlugIssueCommentEdit = z.object({ + owner: requiredString('Missing owner'), + repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, + commentId: z.number().int().positive(), + body: requiredString('Comment body required') +}) + +export const SlugIssueCommentDelete = z.object({ + owner: requiredString('Missing owner'), + repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, + commentId: z.number().int().positive() +}) + +export const GithubProjectListAccessibleParams = z.object({ host: OptionalString }) diff --git a/src/shared/rpc-contract/github-pull-request-params.ts b/src/shared/rpc-contract/github-pull-request-params.ts new file mode 100644 index 00000000000..6762e8ea130 --- /dev/null +++ b/src/shared/rpc-contract/github-pull-request-params.ts @@ -0,0 +1,79 @@ +import { z } from 'zod' +import type { GitHubPRRefreshReason } from '../github/pull-request-refresh-types' +import { RepoSelector, SlugRepo } from './github-repo-target-params' +import { OptionalString, requiredString } from './rpc-param-primitives' + +export const OptionalPRRefreshReason = z + .unknown() + .optional() + .transform((value): GitHubPRRefreshReason | undefined => { + return value === 'visible' || + value === 'active' || + value === 'post-push' || + value === 'manual' || + value === 'swr' + ? value + : undefined + }) + +export const PrForBranch = RepoSelector.extend({ + branch: requiredString('Missing branch'), + reason: OptionalPRRefreshReason, + linkedPRNumber: z.number().int().positive().nullable().optional(), + fallbackPRNumber: z.number().int().positive().nullable().optional(), + acceptMergedFallbackPR: z.boolean().optional(), + currentHeadOid: z.string().nullable().optional() +}) + +export const PullRequest = RepoSelector.extend({ + prNumber: z.number().int().positive(), + noCache: z.boolean().optional(), + prRepo: SlugRepo.nullable().optional() +}) + +export const PRCommentReaction = RepoSelector.extend({ + reactionSubjectId: requiredString('Missing reaction subject ID'), + content: z.enum(['+1', '-1', 'laugh', 'confused', 'heart', 'hooray', 'rocket', 'eyes']), + reacted: z.boolean(), + prRepo: SlugRepo.nullable().optional() +}) + +export const PullRequestChecks = PullRequest.extend({ + headSha: OptionalString +}) + +export const PullRequestCheckDetails = RepoSelector.extend({ + checkRunId: z.number().int().positive().optional(), + workflowRunId: z.number().int().positive().optional(), + checkName: OptionalString, + url: OptionalString.nullable().optional(), + prRepo: SlugRepo.nullable().optional() +}) + +export const RerunPullRequestChecks = PullRequest.extend({ + headSha: OptionalString, + failedOnly: z.boolean().optional() +}) + +export const PullRequestFileContents = RepoSelector.extend({ + prNumber: z.number().int().positive(), + prRepo: SlugRepo.nullable().optional(), + path: requiredString('Missing file path'), + oldPath: OptionalString, + status: z.enum(['added', 'removed', 'modified', 'renamed', 'copied', 'changed', 'unchanged']), + headSha: requiredString('Missing head SHA'), + baseSha: requiredString('Missing base SHA') +}) + +export const PullRequestFileViewed = RepoSelector.extend({ + prRepo: SlugRepo.nullable().optional(), + pullRequestId: requiredString('Missing pull request ID'), + path: requiredString('Missing file path'), + viewed: z.boolean() +}) + +export const ReviewThread = RepoSelector.extend({ + prRepo: SlugRepo.nullable().optional(), + threadId: requiredString('Missing thread ID'), + resolve: z.boolean() +}) diff --git a/src/shared/rpc-contract/github-pull-request-update-params.ts b/src/shared/rpc-contract/github-pull-request-update-params.ts new file mode 100644 index 00000000000..f5fcc38ef73 --- /dev/null +++ b/src/shared/rpc-contract/github-pull-request-update-params.ts @@ -0,0 +1,76 @@ +import { z } from 'zod' +import { RepoSelector, SlugRepo } from './github-repo-target-params' +import { OptionalString, requiredString } from './rpc-param-primitives' + +export const UpdatePrTitle = RepoSelector.extend({ + prNumber: z.number().int().positive(), + title: requiredString('Missing title'), + prRepo: SlugRepo.nullable().optional() +}) + +export const UpdatePr = RepoSelector.extend({ + prNumber: z.number().int().positive(), + updates: z.object({ + title: OptionalString, + body: z.string().optional() + }), + prRepo: SlugRepo.nullable().optional() +}) + +export const MergePr = RepoSelector.extend({ + prNumber: z.number().int().positive(), + method: z.enum(['merge', 'squash', 'rebase']).optional(), + prRepo: SlugRepo.nullable().optional() +}) + +export const SetPrAutoMerge = RepoSelector.extend({ + prNumber: z.number().int().positive(), + enabled: z.boolean(), + method: z.enum(['merge', 'squash', 'rebase']).optional(), + prRepo: SlugRepo.nullable().optional() +}) + +export const UpdatePrState = RepoSelector.extend({ + prNumber: z.number().int().positive(), + prRepo: SlugRepo.nullable().optional(), + updates: z.object({ + state: z.enum(['open', 'closed']) + }) +}) + +export const MarkPrReadyForReview = RepoSelector.extend({ + prNumber: z.number().int().positive(), + prRepo: SlugRepo.nullable().optional() +}) + +export const RequestPrReviewers = RepoSelector.extend({ + prNumber: z.number().int().positive(), + prRepo: SlugRepo.nullable().optional(), + reviewers: z.array(z.string()).min(1) +}) + +export const RemovePrReviewers = RepoSelector.extend({ + prNumber: z.number().int().positive(), + prRepo: SlugRepo.nullable().optional(), + reviewers: z.array(z.string()).min(1) +}) + +export const PRReviewComment = RepoSelector.extend({ + prNumber: z.number().int().positive(), + prRepo: SlugRepo.nullable().optional(), + commitId: requiredString('Missing PR head SHA'), + path: requiredString('File path required'), + line: z.number().int().positive(), + startLine: z.number().int().positive().optional(), + body: requiredString('Comment body required') +}) + +export const PRReviewCommentReply = RepoSelector.extend({ + prNumber: z.number().int().positive(), + commentId: z.number().int().positive(), + body: requiredString('Comment body required'), + threadId: OptionalString, + path: OptionalString, + line: z.number().int().positive().optional(), + prRepo: SlugRepo.nullable().optional() +}) diff --git a/src/shared/rpc-contract/github-repo-target-params.ts b/src/shared/rpc-contract/github-repo-target-params.ts new file mode 100644 index 00000000000..199e90ad388 --- /dev/null +++ b/src/shared/rpc-contract/github-repo-target-params.ts @@ -0,0 +1,14 @@ +import { z } from 'zod' +import { OptionalString, requiredString } from './rpc-param-primitives' + +export const RepoSelector = z.object({ + repo: requiredString('Missing repo selector') +}) + +export const SlugRepo = z.object({ + owner: requiredString('Missing owner'), + repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString +}) diff --git a/src/shared/rpc-contract/github-repo-work-item-params.ts b/src/shared/rpc-contract/github-repo-work-item-params.ts new file mode 100644 index 00000000000..ecee149054f --- /dev/null +++ b/src/shared/rpc-contract/github-repo-work-item-params.ts @@ -0,0 +1,39 @@ +import { z } from 'zod' +import { RepoSelector } from './github-repo-target-params' +import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives' + +export const WorkItemsList = RepoSelector.extend({ + limit: OptionalFiniteNumber, + query: OptionalString, + page: z.number().int().positive().optional(), + noCache: z.boolean().optional() +}) + +export const IssuesList = RepoSelector.extend({ + limit: OptionalFiniteNumber +}) + +export const WorkItem = RepoSelector.extend({ + number: z.number().int().positive(), + type: z.enum(['issue', 'pr']).optional() +}) + +export const WorkItemByOwnerRepo = RepoSelector.extend({ + owner: requiredString('Missing owner'), + ownerRepo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, + number: z.number().int().positive(), + type: z.enum(['issue', 'pr']) +}) + +export const WorkItemDetails = WorkItem + +export const WorkItemsCount = RepoSelector.extend({ + query: OptionalString +}) + +export const RateLimit = z.object({ + force: z.boolean().optional() +}) diff --git a/src/shared/rpc-contract/gitlab-params.ts b/src/shared/rpc-contract/gitlab-params.ts new file mode 100644 index 00000000000..4165f190968 --- /dev/null +++ b/src/shared/rpc-contract/gitlab-params.ts @@ -0,0 +1,149 @@ +import { z } from 'zod' +import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives' + +export const RepoSelector = z.object({ + repo: requiredString('Missing repo selector') +}) + +export const EmptyParams = z.object({}).optional().default({}) + +export const GitLabRateLimit = z + .object({ + force: z.boolean().optional(), + host: OptionalString + }) + .optional() + .default({}) + +// nullish, not optional: renderer callers normalise a missing ref to `null` +// (`item.projectRef ?? null`), which a bare `.optional()` would reject outright. +export const GitLabProjectRef = z + .object({ + host: requiredString('Missing GitLab host'), + path: requiredString('Missing GitLab project path') + }) + .nullish() + +export const WorkItemsList = RepoSelector.extend({ + state: z.enum(['opened', 'merged', 'closed', 'all']).optional(), + page: OptionalFiniteNumber, + perPage: OptionalFiniteNumber, + query: OptionalString +}) + +export const IssuesList = RepoSelector.extend({ + state: z.unknown().optional(), + assignee: OptionalString, + limit: OptionalFiniteNumber, + page: OptionalFiniteNumber +}) + +export const CreateIssue = RepoSelector.extend({ + title: requiredString('Missing title'), + body: z.string() +}) + +export const IssueUpdate = z.object({ + state: z.enum(['opened', 'closed']).optional(), + title: z.string().optional(), + body: z.string().optional(), + addLabels: z.array(z.string()).optional(), + removeLabels: z.array(z.string()).optional(), + addAssignees: z.array(z.string()).optional(), + removeAssignees: z.array(z.string()).optional() +}) + +export const UpdateIssue = RepoSelector.extend({ + number: z.number().int().positive(), + updates: IssueUpdate, + projectRef: GitLabProjectRef +}) + +export const UpdateMrState = RepoSelector.extend({ + iid: z.number().int().positive(), + state: z.enum(['opened', 'closed']), + projectRef: GitLabProjectRef +}) + +export const UpdateMr = RepoSelector.extend({ + iid: z.number().int().positive(), + updates: z.object({ + title: z.string().optional(), + body: z.string().optional(), + addLabels: z.array(z.string()).optional(), + removeLabels: z.array(z.string()).optional(), + readyForReview: z.literal(true).optional() + }), + projectRef: GitLabProjectRef +}) + +export const UpdateMrReviewers = RepoSelector.extend({ + iid: z.number().int().positive(), + reviewerIds: z.array(z.number().int().nonnegative()), + projectRef: GitLabProjectRef +}) + +export const MergeMr = RepoSelector.extend({ + iid: z.number().int().positive(), + method: z.enum(['merge', 'squash', 'rebase']).optional(), + projectRef: GitLabProjectRef +}) + +export const AddIssueComment = RepoSelector.extend({ + number: z.number().int().positive(), + body: requiredString('Comment body is required'), + projectRef: GitLabProjectRef +}) + +export const AddMRComment = RepoSelector.extend({ + iid: z.number().int().positive(), + body: requiredString('Comment body is required'), + projectRef: GitLabProjectRef +}) + +export const AddMRInlineComment = RepoSelector.extend({ + iid: z.number().int().positive(), + input: z.object({ + body: requiredString('Comment body is required'), + path: requiredString('File path is required'), + oldPath: z.string().optional(), + line: z.number().int().positive(), + baseSha: requiredString('Base SHA is required'), + startSha: requiredString('Start SHA is required'), + headSha: requiredString('Head SHA is required') + }), + projectRef: GitLabProjectRef +}) + +export const ResolveMRDiscussion = RepoSelector.extend({ + iid: z.number().int().positive(), + discussionId: requiredString('Discussion id is required'), + resolved: z.boolean(), + projectRef: GitLabProjectRef +}) + +export const JobTrace = RepoSelector.extend({ + jobId: z.number().int().positive(), + projectRef: GitLabProjectRef, + // Why: raw CI traces routinely exceed the 1 MB transport frame cap, so callers + // that only render an excerpt ask main to bound it before it crosses the wire. + logExcerpt: z.boolean().optional() +}) + +export const RetryJob = RepoSelector.extend({ + jobId: z.number().int().positive(), + projectRef: GitLabProjectRef +}) + +export const WorkItemDetails = RepoSelector.extend({ + iid: z.number().int().positive(), + type: z.enum(['issue', 'mr']), + projectRef: GitLabProjectRef +}) + +export const WorkItemByPath = RepoSelector.extend({ + host: requiredString('Missing GitLab host'), + path: requiredString('Missing GitLab project path'), + iid: z.number().int().positive(), + type: z.enum(['issue', 'mr']) +}) diff --git a/src/shared/rpc-contract/hosted-review-params.ts b/src/shared/rpc-contract/hosted-review-params.ts new file mode 100644 index 00000000000..cb9ec7683cc --- /dev/null +++ b/src/shared/rpc-contract/hosted-review-params.ts @@ -0,0 +1,47 @@ +import { z } from 'zod' +import { requiredString } from './rpc-param-primitives' +import { OptionalGitAdmissionTier } from './git-admission-tier-params' + +export const HostedReviewForBranch = z.object({ + repo: requiredString('Missing repo selector'), + branch: requiredString('Missing branch'), + admissionTier: OptionalGitAdmissionTier, + currentHeadOid: z.string().nullable().optional(), + // Only the caller's selected worktree; the host caps how many earn the fast tier. + active: z.boolean().optional(), + linkedGitHubPR: z.number().int().positive().nullable().optional(), + fallbackGitHubPR: z.number().int().positive().nullable().optional(), + linkedGitLabMR: z.number().int().positive().nullable().optional(), + linkedBitbucketPR: z.number().int().positive().nullable().optional(), + linkedAzureDevOpsPR: z.number().int().positive().nullable().optional(), + linkedGiteaPR: z.number().int().positive().nullable().optional() +}) + +export const HostedReviewCreationEligibility = z.object({ + repo: requiredString('Missing repo selector'), + worktree: z.string().min(1, 'Missing worktree selector').optional(), + branch: requiredString('Missing branch'), + base: z.string().nullable().optional(), + hasUncommittedChanges: z.boolean().optional(), + hasUpstream: z.boolean().optional(), + ahead: z.number().int().nonnegative().optional(), + behind: z.number().int().nonnegative().optional(), + linkedGitHubPR: z.number().int().positive().nullable().optional(), + fallbackGitHubPR: z.number().int().positive().nullable().optional(), + linkedGitLabMR: z.number().int().positive().nullable().optional(), + linkedBitbucketPR: z.number().int().positive().nullable().optional(), + linkedAzureDevOpsPR: z.number().int().positive().nullable().optional(), + linkedGiteaPR: z.number().int().positive().nullable().optional() +}) + +export const HostedReviewCreate = z.object({ + repo: requiredString('Missing repo selector'), + worktree: z.string().min(1, 'Missing worktree selector').optional(), + provider: z.enum(['github', 'gitlab', 'bitbucket', 'azure-devops', 'gitea', 'unsupported']), + base: requiredString('Missing base branch'), + head: z.string().optional(), + title: requiredString('Missing title'), + body: z.string().optional(), + draft: z.boolean().optional(), + useTemplate: z.boolean().optional() +}) diff --git a/src/shared/rpc-contract/jira-params.ts b/src/shared/rpc-contract/jira-params.ts new file mode 100644 index 00000000000..b550124fd7f --- /dev/null +++ b/src/shared/rpc-contract/jira-params.ts @@ -0,0 +1,101 @@ +import { z } from 'zod' +import { + OptionalFiniteNumber, + OptionalPlainString, + OptionalString, + requiredString +} from './rpc-param-primitives' + +export const VALID_FILTERS = ['assigned', 'reported', 'all', 'done'] as const + +export const SiteSelection = z + .object({ + siteId: OptionalString + }) + .optional() + +export const Connect = z.object({ + siteUrl: requiredString('Site URL is required'), + // Self-hosted PAT auth needs no email; connect() enforces it for Cloud. + email: OptionalPlainString, + apiToken: requiredString('API token is required'), + authType: z.enum(['cloud', 'server']).optional() +}) + +export const SelectSite = z.object({ + siteId: requiredString('Site ID is required') +}) + +export const SearchIssues = z.object({ + jql: requiredString('Missing JQL'), + limit: OptionalFiniteNumber, + siteId: OptionalString +}) + +export const ListIssues = z + .object({ + filter: z.enum(VALID_FILTERS).optional(), + limit: OptionalFiniteNumber, + siteId: OptionalString + }) + .optional() + +export const IssueKey = z.object({ + key: requiredString('Issue key is required'), + siteId: OptionalString +}) + +export const CreateIssue = z.object({ + siteId: OptionalString, + projectId: requiredString('Project is required'), + issueTypeId: requiredString('Issue type is required'), + title: requiredString('Title is required'), + description: OptionalPlainString, + customFields: z.record(z.string(), z.unknown()).optional(), + userFieldKeys: z.array(z.string()).optional() +}) + +export const IssueUpdate = z.object({ + key: requiredString('Issue key is required'), + siteId: OptionalString, + updates: z.object({ + title: OptionalString, + labels: z.array(z.string()).optional(), + assigneeAccountId: z.union([z.string(), z.null()]).optional(), + priorityId: z.union([z.string(), z.null()]).optional(), + transitionId: OptionalString + }) +}) + +export const IssueComment = z.object({ + key: requiredString('Issue key is required'), + body: requiredString('Comment body is required'), + siteId: OptionalString +}) + +export const ProjectIssueTypes = z.object({ + projectIdOrKey: requiredString('Project is required'), + siteId: OptionalString +}) + +export const ProjectIssueTypeFields = z.object({ + projectIdOrKey: requiredString('Project is required'), + issueTypeId: requiredString('Issue type is required'), + siteId: OptionalString +}) + +export const AssignableUsers = z.object({ + key: requiredString('Issue key is required'), + query: OptionalPlainString, + siteId: OptionalString +}) + +export const UserSearch = z.object({ + query: OptionalPlainString, + siteId: OptionalString +}) + +export const ProjectStatusOrder = z.object({ + projectKey: requiredString('Project key is required'), + siteId: OptionalString +}) diff --git a/src/shared/rpc-contract/linear-agent-access-params.ts b/src/shared/rpc-contract/linear-agent-access-params.ts new file mode 100644 index 00000000000..ac1c627c6cd --- /dev/null +++ b/src/shared/rpc-contract/linear-agent-access-params.ts @@ -0,0 +1,146 @@ +import { z } from 'zod' +import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives' + +export const LINEAR_DUE_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/ + +export const LinearDueDate = z.string().refine((value) => LINEAR_DUE_DATE_PATTERN.test(value), { + message: 'Linear due dates must use YYYY-MM-DD' +}) + +export const OptionalLinearDueDate = LinearDueDate.optional() + +export const OptionalLinearDueDateOrClear = z.union([LinearDueDate, z.null()]).optional() + +export const AgentSearchIssues = z.object({ + query: requiredString('Missing query'), + limit: OptionalFiniteNumber, + workspaceId: z.union([z.string(), z.literal('all')]).optional() +}) + +export const LinearWorkspaceRead = z.object({ + workspaceId: z.union([z.string(), z.literal('all')]).optional() +}) + +export const LinearTeamLookup = z.object({ + teamInput: requiredString('Missing team'), + workspaceId: OptionalString.refine((value) => value !== 'all', { + message: '--workspace all is only valid for team list' + }) +}) + +export const LinearIssueList = z.object({ + filter: z.enum(['assigned', 'created', 'all', 'completed', 'open']).optional(), + teamInput: OptionalString, + limit: OptionalFiniteNumber, + workspaceId: z.union([z.string(), z.literal('all')]).optional() +}) + +export const LinearProjectList = z.object({ + query: OptionalString, + limit: OptionalFiniteNumber, + workspaceId: z.union([z.string(), z.literal('all')]).optional() +}) + +export const LinearIncludeFlags = z.object({ + comments: z.boolean(), + children: z.boolean(), + attachments: z.boolean(), + relations: z.boolean(), + activity: z.boolean().default(false) +}) + +export const LinearCurrentContext = z + .object({ + worktreeId: OptionalString, + terminalHandle: OptionalString, + cwd: OptionalString, + remote: z.boolean().optional() + }) + .optional() + +export const LinearWriteTarget = z.object({ + input: OptionalString, + current: z.boolean().optional(), + workspaceId: OptionalString.refine((value) => value !== 'all', { + message: '--workspace all is not valid for Linear writes' + }), + context: LinearCurrentContext +}) + +export const AgentIssueContext = z.object({ + input: OptionalString, + current: z.boolean().optional(), + workspaceId: OptionalString, + include: LinearIncludeFlags, + depth: z.number().int().min(0).max(5), + context: LinearCurrentContext +}) + +export const LinearIssueSetState = LinearWriteTarget.extend({ + to: requiredString('Missing target state') +}) + +export const LinearIssueUpdateTask = LinearWriteTarget.extend({ + operation: z.enum(['assignee', 'priority', 'estimate', 'dueDate', 'labels']), + assigneeId: z.string().nullable().optional(), + assigneeMe: z.boolean().optional(), + priority: z.number().int().min(0).max(4).optional(), + estimate: z.number().int().min(0).nullable().optional(), + dueDate: OptionalLinearDueDateOrClear, + labelMode: z.enum(['add', 'remove', 'set']).optional(), + labels: z.array(z.string()).optional() +}) + +export const LinearIssueAddComment = LinearWriteTarget.extend({ + body: requiredString('Missing comment body'), + replyTo: OptionalString, + writeId: OptionalString +}) + +export const LinearIssueRelationWrite = LinearWriteTarget.extend({ + relatedInput: requiredString('Missing related issue'), + relationship: z.enum(['blocks', 'blockedBy', 'relatedTo', 'duplicateOf']), + operation: z.enum(['add', 'remove']) +}) + +export const LinearIssueAttachLink = LinearWriteTarget.extend({ + url: requiredString('Missing attachment URL'), + title: OptionalString, + writeId: OptionalString +}) + +export const LinearIssueCreate = z.object({ + title: requiredString('Missing issue title'), + body: OptionalString, + teamInput: OptionalString, + teamKey: OptionalString, + state: OptionalString, + assignee: OptionalString, + priority: z.number().int().min(0).max(4).optional(), + estimate: z.number().int().min(0).optional(), + dueDate: OptionalLinearDueDate, + labels: z.array(z.string()).optional(), + projectInput: OptionalString, + parentInput: OptionalString, + parentCurrent: z.boolean().optional(), + workspaceId: OptionalString.refine((value) => value !== 'all', { + message: '--workspace all is not valid for Linear writes' + }), + writeId: OptionalString, + context: LinearCurrentContext +}) + +export const LinearSaveIssue = LinearWriteTarget.extend({ + team: OptionalString, + title: OptionalString, + description: z.string().optional(), + state: OptionalString, + assignee: z.string().nullable().optional(), + priority: z.number().int().min(0).max(4).optional(), + estimate: z.number().min(0).nullable().optional(), + dueDate: OptionalLinearDueDateOrClear, + labels: z.array(z.string()).optional(), + project: z.string().nullable().optional(), + parentId: z.string().nullable().optional(), + writeId: OptionalString +}) diff --git a/src/shared/rpc-contract/linear-issue-attribute-filter-params.ts b/src/shared/rpc-contract/linear-issue-attribute-filter-params.ts new file mode 100644 index 00000000000..8d793f42ba1 --- /dev/null +++ b/src/shared/rpc-contract/linear-issue-attribute-filter-params.ts @@ -0,0 +1,35 @@ +import { z } from 'zod' +import { + LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH, + LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS, + LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_PRIORITIES, + LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS +} from '../linear/issue-attribute-filter' + +// Why: keep ListIssues param validation co-located with shared limits without +// pushing linear.ts past the max-lines ratchet. +export const LinearAttributeFilterId = z + .string() + .trim() + .min(1) + .max(LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH) + +export const LinearIssueAttributeFilterSchema = z + .object({ + stateIds: z.array(LinearAttributeFilterId).max(LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS), + priorities: z + .array(z.number().int().min(0).max(4)) + .max(LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_PRIORITIES), + assignee: z.union([ + z.object({ kind: z.literal('unassigned') }).strict(), + z + .object({ + kind: z.literal('user'), + id: LinearAttributeFilterId + }) + .strict(), + z.null() + ]), + labelIds: z.array(LinearAttributeFilterId).max(LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS) + }) + .strict() diff --git a/src/shared/rpc-contract/linear-issue-list-params.ts b/src/shared/rpc-contract/linear-issue-list-params.ts new file mode 100644 index 00000000000..ec4813f4186 --- /dev/null +++ b/src/shared/rpc-contract/linear-issue-list-params.ts @@ -0,0 +1,38 @@ +import { z } from 'zod' +import { OptionalFiniteNumber, OptionalString } from './rpc-param-primitives' +import { LinearIssueAttributeFilterSchema } from './linear-issue-attribute-filter-params' + +export const LegacyListIssues = z + .object({ + filter: z.enum(['assigned', 'created', 'all', 'completed']).optional(), + limit: OptionalFiniteNumber, + workspaceId: OptionalString, + attributeFilter: LinearIssueAttributeFilterSchema.optional() + }) + .strict() + .optional() + +export const McpListIssues = z + .object({ + team: OptionalString, + cycle: OptionalString, + label: OptionalString, + limit: z.number().int().min(1).max(250).optional(), + query: OptionalString, + state: OptionalString, + cursor: OptionalString, + orderBy: z.enum(['createdAt', 'updatedAt']).optional(), + project: OptionalString, + release: OptionalString, + assignee: OptionalString, + delegate: OptionalString, + parentId: OptionalString, + priority: z.number().int().min(0).max(4).optional(), + createdAt: OptionalString, + updatedAt: OptionalString, + includeArchived: z.boolean().optional(), + workspaceId: OptionalString + }) + .strict() + +export const ListIssues = z.union([McpListIssues, LegacyListIssues]) diff --git a/src/shared/rpc-contract/linear-params.ts b/src/shared/rpc-contract/linear-params.ts new file mode 100644 index 00000000000..313227c6029 --- /dev/null +++ b/src/shared/rpc-contract/linear-params.ts @@ -0,0 +1,124 @@ +import { z } from 'zod' +import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives' + +export const VALID_CUSTOM_VIEW_MODELS = ['issue', 'project'] as const + +export const LinearPriority = z.number().int().min(0).max(4).optional() + +export const LinearLabelIds = z.array(requiredString('Invalid label ID')).optional() + +export const Connect = z.object({ + apiKey: requiredString('Invalid API key') +}) + +export const WorkspaceSelection = z + .object({ + workspaceId: OptionalString + }) + .optional() + +export const ConcreteWorkspaceId = requiredString( + 'Concrete Linear workspace ID is required' +).refine((value) => value !== 'all', 'Concrete Linear workspace ID is required') + +export const SelectWorkspace = z.object({ + workspaceId: requiredString('Workspace ID is required') +}) + +export const SearchIssues = z.object({ + query: requiredString('Missing query'), + limit: OptionalFiniteNumber, + workspaceId: OptionalString +}) + +export const CreateIssue = z.object({ + teamId: requiredString('Team ID is required'), + title: requiredString('Title is required'), + description: OptionalString, + workspaceId: OptionalString, + parentIssueId: OptionalString, + projectId: z.union([z.string(), z.null()]).optional(), + stateId: OptionalString, + priority: LinearPriority, + assigneeId: z.union([z.string(), z.null()]).optional(), + labelIds: LinearLabelIds +}) + +export const IssueId = z.object({ + id: requiredString('Issue ID is required'), + workspaceId: OptionalString +}) + +export const IssueComment = z.object({ + issueId: requiredString('Issue ID is required'), + body: requiredString('Comment body is required'), + workspaceId: OptionalString +}) + +export const ListProjects = z + .object({ + query: OptionalString, + limit: OptionalFiniteNumber, + workspaceId: OptionalString, + force: z.boolean().optional() + }) + .optional() + +export const ProjectId = z.object({ + id: requiredString('Project ID is required'), + workspaceId: ConcreteWorkspaceId, + force: z.boolean().optional() +}) + +export const ProjectIssues = z.object({ + projectId: requiredString('Project ID is required'), + limit: OptionalFiniteNumber, + workspaceId: ConcreteWorkspaceId, + force: z.boolean().optional() +}) + +export const ListCustomViews = z.object({ + model: z.enum(VALID_CUSTOM_VIEW_MODELS), + limit: OptionalFiniteNumber, + workspaceId: OptionalString, + force: z.boolean().optional() +}) + +export const CustomViewId = z.object({ + viewId: requiredString('Custom view ID is required'), + model: z.enum(VALID_CUSTOM_VIEW_MODELS), + workspaceId: ConcreteWorkspaceId, + force: z.boolean().optional() +}) + +export const CustomViewContents = z.object({ + viewId: requiredString('Custom view ID is required'), + limit: OptionalFiniteNumber, + workspaceId: ConcreteWorkspaceId, + force: z.boolean().optional() +}) + +export const TeamId = z.object({ + teamId: requiredString('Team ID is required'), + workspaceId: OptionalString +}) + +export const IssueUpdate = z.object({ + id: requiredString('Issue ID is required'), + workspaceId: OptionalString, + updates: z.object({ + stateId: OptionalString, + title: OptionalString, + description: z.string().optional(), + assigneeId: z.union([z.string(), z.null()]).optional(), + estimate: z.union([z.number().int().min(0), z.null()]).optional(), + priority: z.number().int().min(0).max(4).optional(), + labelIds: z.array(z.string()).optional(), + projectId: z.union([z.string(), z.null()]).optional() + }) +}) + +export const LinearIssueCommentsParams = z.object({ + issueId: requiredString('Issue ID is required'), + workspaceId: OptionalString +}) diff --git a/src/shared/rpc-contract/linear-project-create-params.ts b/src/shared/rpc-contract/linear-project-create-params.ts new file mode 100644 index 00000000000..2eec8beb1fa --- /dev/null +++ b/src/shared/rpc-contract/linear-project-create-params.ts @@ -0,0 +1,20 @@ +import { z } from 'zod' +import { OptionalString, requiredString } from './rpc-param-primitives' + +export const LinearPriority = z.number().int().min(0).max(4).optional() + +export const LinearLabelIds = z.array(requiredString('Invalid label ID')).optional() + +export const CreateProject = z.object({ + name: requiredString('Project name is required'), + description: OptionalString, + content: OptionalString, + workspaceId: OptionalString, + teamIds: z.array(requiredString('Invalid team ID')).min(1, 'At least one team is required'), + leadId: z.union([z.string(), z.null()]).optional(), + memberIds: z.array(requiredString('Invalid member ID')).optional(), + labelIds: LinearLabelIds, + priority: LinearPriority, + startDate: OptionalString, + targetDate: OptionalString +}) diff --git a/src/shared/rpc-contract/native-chat-params.ts b/src/shared/rpc-contract/native-chat-params.ts new file mode 100644 index 00000000000..5a88049e10f --- /dev/null +++ b/src/shared/rpc-contract/native-chat-params.ts @@ -0,0 +1,50 @@ +import { z } from 'zod' +import type { AgentType } from '../native-chat-types' + +// Why: native chat renders an agent's own transcript (Claude/Codex JSONL). The +// desktop reaches the readers via Electron IPC; mobile/web clients reach the +// same pure readers through these runtime RPC methods so the native chat view +// works over the paired connection, not just in the desktop renderer. + +export const NativeChatSession = z.object({ + agent: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing agent')) + .transform((v) => v as AgentType), + sessionId: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing session id')), + // How many of the most-recent messages to return. Clients start small for a + // fast first paint and raise it to page older history in as the user scrolls. + // Clamp (don't reject) a limit past the max window so a client paging beyond it + // gets the capped tail and pagination stops cleanly — a hard `.max` rejection + // would fail the read and stall "load earlier" at the boundary. + limit: z + .number() + .int() + .positive() + .transform((value) => Math.min(value, MOBILE_NATIVE_CHAT_MAX_WINDOW)) + .optional(), + // Optional client-supplied cleanup token. When present, the subscribe handler + // keys the fs-watcher cleanup under it so registration and unsubscribe derive + // from the SAME token (back-compat: falls back to `agent:sessionId` when absent, + // which is exactly what existing mobile clients rely on). + subscriptionId: z.string().min(1).optional(), + // Authoritative transcript path from the agent hook (providerSession), used to + // locate the file directly when the session id no longer names it (recent + // Claude Code). Optional for back-compat with older clients. + transcriptPath: z.string().min(1).optional(), + // A pending snapshot is not authoritative transcript history. Only clients + // that advertise this semantic may receive one; legacy clients treat it as a + // settled empty read and can overwrite retention / unblock launch drafts. + capabilities: z.object({ transcriptPending: z.literal(1).optional() }).optional(), + beforeOffset: z.number().int().nonnegative().optional() +}) + +export const NativeChatUnsubscribe = z.object({ + subscriptionId: z.string().min(1).optional() +}) + +export const MOBILE_NATIVE_CHAT_MAX_WINDOW = 2000 diff --git a/src/shared/rpc-contract/notifications-params.ts b/src/shared/rpc-contract/notifications-params.ts new file mode 100644 index 00000000000..6ae2cd45f5e --- /dev/null +++ b/src/shared/rpc-contract/notifications-params.ts @@ -0,0 +1,61 @@ +import { z } from 'zod' +import { MOBILE_PUSH_APNS_ENVIRONMENTS, MOBILE_PUSH_PLATFORMS } from '../mobile-push-contract' + +export const NotificationUnsubscribeParams = z.object({ + subscriptionId: z + .unknown() + .transform((value) => (typeof value === 'string' && value.length > 0 ? value : '')) + .pipe(z.string().min(1, 'Missing subscriptionId')) +}) + +// Why: notifications.getMissedSince is the catch-up RPC for mobile reconnect +// (#8129). The client passes the highest seq it has already delivered; the +// runtime returns only notifications dispatched after that seq. Because the +// desktop assigns a monotonic seq to every dispatched notification, the cut is +// exact and idempotent — re-requesting with the same watermark can never +// return an already-delivered event, so reconnects never duplicate local +// pushes (the adversarial-review gate for #8129). +// `epoch` names the counter lifetime lastSeenSeq came from (#8591). The desktop's +// seq restarts at 0 on every launch while the client's watermark is persisted, so +// without it a post-restart watermark silently cuts away everything. Optional: a +// client that predates the field keeps the seq-only cut. +export const NotificationGetMissedSinceParams = z.object({ + lastSeenSeq: z.number().int().min(0, 'lastSeenSeq must be a non-negative integer'), + epoch: z.string().optional(), + includeDesktopSuppressed: z.boolean().optional(), + deliveredPushes: z + .array( + z.object({ + notificationId: z.string().min(1).max(2048), + notificationEpoch: z.string().min(1).max(128), + notificationSeq: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER) + }) + ) + .max(256) + .optional() +}) + +export const NotificationPushFilterParams = z.object({ + onlyWhenDesktopAway: z.boolean().optional(), + sound: z.boolean().optional() +}) + +export const NotificationRegisterPushParams = z + .object({ + platform: z.enum(MOBILE_PUSH_PLATFORMS), + token: z.string().min(1).max(4096), + apnsEnvironment: z.enum(MOBILE_PUSH_APNS_ENVIRONMENTS).optional(), + filter: NotificationPushFilterParams + }) + // Why strict: the device identity is added by the handler, so a caller-supplied + // `deviceId` must be an error, not a key silently dropped. + .strict() + // Why: an APNs token is only routable against the environment it was minted in, + // so a missing environment must fail loudly rather than default to production. + .refine((params) => params.platform !== 'ios' || params.apnsEnvironment !== undefined, { + message: 'apnsEnvironment is required for ios' + }) + +export const NotificationsSubscribeParams = z + .object({ includeDesktopSuppressed: z.boolean().optional() }) + .optional() diff --git a/src/shared/rpc-contract/orchestration-federation-control-params.ts b/src/shared/rpc-contract/orchestration-federation-control-params.ts new file mode 100644 index 00000000000..9bb5ada502f --- /dev/null +++ b/src/shared/rpc-contract/orchestration-federation-control-params.ts @@ -0,0 +1,22 @@ +import { z } from 'zod' +import { OptionalFiniteNumber, requiredString } from './rpc-param-primitives' +import { ORCHESTRATION_WORKER_READ_SOURCES } from '../orchestration-worker-output' + +export const FederationDispatchParams = z.object({ + dispatchId: requiredString('Missing Dispatch ID') +}) + +export const FederationReadParams = FederationDispatchParams.extend({ + cursor: OptionalFiniteNumber, + limit: OptionalFiniteNumber +}) + +export const FederationOutputReadParams = FederationDispatchParams.extend({ + cursor: z.union([z.number().int().nonnegative(), z.string().min(1).max(2_048)]).optional(), + limit: OptionalFiniteNumber, + source: z.enum(ORCHESTRATION_WORKER_READ_SOURCES).optional() +}) + +export const FederationFleetSnapshotParams = z.object({ + dispatchIds: z.array(requiredString('Missing Dispatch ID')).min(1).max(100) +}) diff --git a/src/shared/rpc-contract/orchestration-federation-relay-params.ts b/src/shared/rpc-contract/orchestration-federation-relay-params.ts new file mode 100644 index 00000000000..ef1608e8611 --- /dev/null +++ b/src/shared/rpc-contract/orchestration-federation-relay-params.ts @@ -0,0 +1,47 @@ +import { z } from 'zod' +import { OptionalFiniteNumber, requiredString } from './rpc-param-primitives' + +export const FederationPullParams = z.object({ + dispatchId: requiredString('Missing Dispatch ID'), + afterSequence: OptionalFiniteNumber, + replayUnacknowledged: z.boolean().optional(), + limit: OptionalFiniteNumber +}) + +export const FederationAckParams = z.object({ + dispatchId: requiredString('Missing Dispatch ID'), + throughSequence: z.number().int().nonnegative(), + settlements: z + .array( + z.object({ + sequence: z.number().int().positive(), + lifecycle: z.discriminatedUnion('action', [ + z.object({ + action: z.enum(['completed', 'failed']), + authority: z.literal('run_home') + }), + z.object({ + action: z.literal('rejected'), + code: z.string(), + reason: z.string(), + authority: z.literal('run_home') + }) + ]) + }) + ) + .optional() +}) + +export const FederationImportParams = z.object({ + dispatchId: requiredString('Missing Dispatch ID'), + items: z.array( + z.object({ + dispatch_id: requiredString('Missing item Dispatch ID'), + direction: z.literal('to_worker'), + sequence: z.number().int().positive(), + message_id: requiredString('Missing relay message ID'), + kind: requiredString('Missing relay kind'), + payload: requiredString('Missing relay payload') + }) + ) +}) diff --git a/src/shared/rpc-contract/orchestration-federation-start-params.ts b/src/shared/rpc-contract/orchestration-federation-start-params.ts new file mode 100644 index 00000000000..24cf82223f6 --- /dev/null +++ b/src/shared/rpc-contract/orchestration-federation-start-params.ts @@ -0,0 +1,29 @@ +import { z } from 'zod' +import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives' +import { OptionalWorkerLaunchPreference } from './orchestration-worker-start-params' + +export const FederationAttachStartParams = z.object({ + /** Omitted by v1.4.198 coordinators; the worker host then mints a stub home Run. */ + runId: OptionalString, + dispatchId: requiredString('Missing Dispatch ID'), + taskId: requiredString('Missing Task ID'), + taskSpec: requiredString('Missing Task spec'), + /** Depth stamped by the Run home; omitted by older clients and defaults to 1. */ + depth: z.number().int().min(1).optional(), + protocolVersion: z.union([z.literal(1), z.literal(2), z.literal(3)]), + worktree: requiredString('Missing remote worktree selector'), + name: OptionalString, + repo: OptionalString, + baseBranch: OptionalString, + displayName: OptionalString, + displayNameKind: z.enum(['generated', 'user']).optional(), + comment: OptionalString, + setup: z.enum(['run', 'skip', 'inherit']).optional(), + setupSource: z.enum(['explicit_request', 'orchestration_default']).optional(), + terminal: OptionalString, + agent: OptionalString, + model: OptionalWorkerLaunchPreference, + effort: OptionalWorkerLaunchPreference, + timeoutMs: OptionalFiniteNumber, + devMode: z.boolean().optional() +}) diff --git a/src/shared/rpc-contract/orchestration-gates-params.ts b/src/shared/rpc-contract/orchestration-gates-params.ts new file mode 100644 index 00000000000..c7ea607b345 --- /dev/null +++ b/src/shared/rpc-contract/orchestration-gates-params.ts @@ -0,0 +1,34 @@ +import { z } from 'zod' +import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives' + +export const RunParams = z.object({ + spec: requiredString('Missing --spec'), + from: OptionalString, + pollIntervalMs: OptionalFiniteNumber, + maxConcurrent: OptionalFiniteNumber, + worktree: OptionalString +}) + +export const RunStopParams = z.object({}) + +export const GateCreateParams = z.object({ + task: requiredString('Missing --task'), + question: requiredString('Missing --question'), + options: OptionalString, + from: OptionalString, + run: OptionalString +}) + +export const GateResolveParams = z.object({ + id: requiredString('Missing --id'), + resolution: requiredString('Missing --resolution'), + from: OptionalString, + run: OptionalString +}) + +export const GateListParams = z.object({ + task: OptionalString, + status: z.enum(['pending', 'resolved', 'timeout']).optional(), + from: OptionalString, + run: OptionalString +}) diff --git a/src/shared/rpc-contract/orchestration-params.ts b/src/shared/rpc-contract/orchestration-params.ts new file mode 100644 index 00000000000..e58dca2143c --- /dev/null +++ b/src/shared/rpc-contract/orchestration-params.ts @@ -0,0 +1,153 @@ +import { z } from 'zod' +import { + OptionalBoolean, + OptionalFiniteNumber, + OptionalString, + requiredString +} from './rpc-param-primitives' + +export type DispatchMutationMessageType = + | 'worker_done' + | 'heartbeat' + | 'escalation' + | 'decision_gate' + +export function isDispatchMutationMessageType( + type: string | undefined +): type is DispatchMutationMessageType { + return ( + type === 'worker_done' || + type === 'heartbeat' || + type === 'escalation' || + type === 'decision_gate' + ) +} + +export function getLifecycleGroupRecipientError(type: DispatchMutationMessageType): string { + return `${type} messages belong to one exact Dispatch and cannot target a group address.` +} + +export const CheckParams = z + .object({ + terminal: OptionalString, + terminalPaneKey: OptionalString, + unread: OptionalBoolean, + peek: OptionalBoolean, + // Why: `all` surfaces every message and skips mark-read; legacy encoding was the `{unread: false}` trick (design doc §3.2/§3.3). + all: OptionalBoolean, + types: OptionalString, + format: OptionalBoolean, + // Why: one-release RPC compatibility only; the public CLI uses --format because no terminal input is injected. + inject: OptionalBoolean, + ack: OptionalString, + compatibilityAck: OptionalString, + compatibilityQuestionAck: OptionalString, + compatibilityCliCommand: z.enum(['orca', 'orca-ide', 'orca-dev']).optional(), + run: OptionalString, + wait: OptionalBoolean, + timeoutMs: OptionalFiniteNumber + }) + .superRefine((params, ctx) => { + // Why: CLI encodes --peek as {peek:true, unread:false} for pre-peek runtimes, so that pair is one mode, not a conflict. + const modes = [ + params.unread === true, + params.peek === true, + params.all === true || (params.unread === false && params.peek !== true) + ].filter(Boolean) + if (modes.length > 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose at most one message read mode: --unread, --peek, or --all.' + }) + } + }) + +export const ReplyParams = z.object({ + id: requiredString('Missing --id'), + body: requiredString('Missing --body'), + from: OptionalString, + run: OptionalString +}) + +export const InboxParams = z.object({ + limit: OptionalFiniteNumber, + // Why: filters the inbox to a handle so inbox and check --all give agreeing results (design doc §3.3). + terminal: OptionalString +}) + +export const TaskCreateParams = z.object({ + spec: requiredString('Missing --spec'), + taskTitle: OptionalString, + displayName: OptionalString, + deps: OptionalString, + parent: OptionalString, + callerTerminalHandle: OptionalString, + run: OptionalString +}) + +export const TaskListParams = z.object({ + status: z.enum(['pending', 'ready', 'dispatched', 'completed', 'failed', 'blocked']).optional(), + ready: OptionalBoolean, + // Why: server-side truncation keeps --brief cheap over SSH/relay instead of shipping full specs the CLI throws away. + brief: OptionalBoolean, + run: OptionalString, + callerTerminalHandle: OptionalString +}) + +export const DispatchParams = z.object({ + task: requiredString('Missing --task'), + // Why: --to is optional so --dry-run can preview without a target; the handler enforces presence before any side-effecting work. + to: OptionalString, + from: OptionalString, + inject: OptionalBoolean, + dryRun: OptionalBoolean, + returnPreamble: OptionalBoolean, + devMode: OptionalBoolean, + run: OptionalString +}) + +export const DispatchShowParams = z.object({ + task: OptionalString, + preamble: OptionalBoolean, + from: OptionalString, + devMode: OptionalBoolean +}) + +export const AskParams = z + .object({ + to: OptionalString, + question: OptionalString, + resume: OptionalString, + options: OptionalString, + timeoutMs: OptionalFiniteNumber, + from: OptionalString, + run: OptionalString, + compatibilityCliCommand: z.enum(['orca', 'orca-ide', 'orca-dev']).optional(), + compatibilityWindowsCommand: z.enum(['orca', 'orca-ide']).optional() + }) + .superRefine((params, ctx) => { + if ((params.question ? 1 : 0) + (params.resume ? 1 : 0) !== 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose exactly one of --question or --resume.' + }) + } + }) + +export const ResetParams = z + .object({ + all: OptionalBoolean, + tasks: OptionalBoolean, + messages: OptionalBoolean + }) + .superRefine((params, ctx) => { + const selectedScopeCount = [params.all, params.tasks, params.messages].filter( + (scope) => scope === true + ).length + if (selectedScopeCount !== 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose exactly one reset scope: --all, --tasks, or --messages.' + }) + } + }) diff --git a/src/shared/rpc-contract/orchestration-runs-mutation-request-show-params.ts b/src/shared/rpc-contract/orchestration-runs-mutation-request-show-params.ts new file mode 100644 index 00000000000..496c1ea3827 --- /dev/null +++ b/src/shared/rpc-contract/orchestration-runs-mutation-request-show-params.ts @@ -0,0 +1,4 @@ +import { z } from 'zod' +import { requiredString } from './rpc-param-primitives' + +export const RequestShowParams = z.object({ request: requiredString('Missing --request') }) diff --git a/src/shared/rpc-contract/orchestration-runs-params.ts b/src/shared/rpc-contract/orchestration-runs-params.ts new file mode 100644 index 00000000000..30aad1e9eae --- /dev/null +++ b/src/shared/rpc-contract/orchestration-runs-params.ts @@ -0,0 +1,23 @@ +import { z } from 'zod' +import { OptionalBoolean, OptionalString, requiredString } from './rpc-param-primitives' +import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../orchestration-run-pagination' + +export const RunCreateParams = z.object({ + objective: requiredString('Missing --objective'), + from: requiredString('Missing coordinator terminal') +}) + +export const RunUseParams = z.object({ + id: requiredString('Missing --id'), + from: requiredString('Missing coordinator terminal'), + takeoverLegacy: OptionalBoolean +}) + +export const RunCurrentParams = z.object({ from: requiredString('Missing coordinator terminal') }) + +export const RunListParams = z.object({ + limit: z.number().int().min(1).max(ORCHESTRATION_RUN_PAGE_LIMIT).optional(), + cursor: z.string().min(1).optional() +}) + +export const RunShowParams = z.object({ id: requiredString('Missing --id'), from: OptionalString }) diff --git a/src/shared/rpc-contract/orchestration-worker-control-params.ts b/src/shared/rpc-contract/orchestration-worker-control-params.ts new file mode 100644 index 00000000000..9aa097522f1 --- /dev/null +++ b/src/shared/rpc-contract/orchestration-worker-control-params.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' +import { OptionalFiniteNumber, requiredString } from './rpc-param-primitives' +import { ORCHESTRATION_WORKER_READ_SOURCES } from '../orchestration-worker-output' + +export const WorkerDispatchParams = z.object({ dispatch: requiredString('Missing --dispatch') }) + +export const WorkerReadParams = WorkerDispatchParams.extend({ + cursor: z.union([z.number().int().nonnegative(), z.string().min(1).max(2_048)]).optional(), + limit: OptionalFiniteNumber, + source: z.enum(ORCHESTRATION_WORKER_READ_SOURCES).optional() +}) diff --git a/src/shared/rpc-contract/orchestration-worker-release-params.ts b/src/shared/rpc-contract/orchestration-worker-release-params.ts new file mode 100644 index 00000000000..345a99e87b1 --- /dev/null +++ b/src/shared/rpc-contract/orchestration-worker-release-params.ts @@ -0,0 +1,12 @@ +import { z } from 'zod' + +export const OrchestrationWorkerTerminalUserInputParams = z + .object({ + paneKey: z.string().min(1).optional(), + sessionId: z.string().min(1).optional(), + terminal: z.string().min(1).optional() + }) + .refine( + (value) => Boolean(value.paneKey ?? value.sessionId ?? value.terminal), + 'Missing paneKey, sessionId or terminal' + ) diff --git a/src/shared/rpc-contract/orchestration-worker-release-schemas-params.ts b/src/shared/rpc-contract/orchestration-worker-release-schemas-params.ts new file mode 100644 index 00000000000..b2d75c46adc --- /dev/null +++ b/src/shared/rpc-contract/orchestration-worker-release-schemas-params.ts @@ -0,0 +1,25 @@ +import { z } from 'zod' +import { requiredString } from './rpc-param-primitives' +import { ORCHESTRATION_FLEET_PAGE_MAX } from '../orchestration-fleet-projection' + +export const WorkerDispatchParams = z.object({ dispatch: requiredString('Missing --dispatch') }) + +export const WorkerRetainParams = WorkerDispatchParams.strict() + +export const WORKER_TERMINAL_LIST_STATES = [ + 'active', + 'reclaimable', + 'retained', + 'release_pending', + 'release_unknown', + 'released' +] as const + +export const WorkerListParams = z.object({ + run: z.string().min(1).optional(), + terminalState: z.enum(WORKER_TERMINAL_LIST_STATES).optional(), + cursor: z.string().min(1).max(2_048).optional(), + limit: z.number().int().min(1).max(ORCHESTRATION_FLEET_PAGE_MAX).optional(), + includeRemote: z.boolean().optional(), + paginate: z.boolean().optional() +}) diff --git a/src/shared/rpc-contract/orchestration-worker-start-params.ts b/src/shared/rpc-contract/orchestration-worker-start-params.ts new file mode 100644 index 00000000000..dc6432aa47a --- /dev/null +++ b/src/shared/rpc-contract/orchestration-worker-start-params.ts @@ -0,0 +1,61 @@ +import { z } from 'zod' +import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives' + +export const OptionalWorkerLaunchPreference = z + .string() + .min(1) + .max(512) + .refine((value) => value === value.trim(), 'Surrounding whitespace is invalid') + .optional() + +export const WorkerStartParams = z + .object({ + task: OptionalString, + spec: OptionalString, + taskTitle: OptionalString, + deps: OptionalString, + parent: OptionalString, + on: OptionalString, + run: OptionalString, + from: requiredString('Missing --from'), + worktree: OptionalString, + name: OptionalString, + repo: OptionalString, + baseBranch: OptionalString, + displayName: OptionalString, + comment: OptionalString, + setup: z.enum(['run', 'skip', 'inherit']).optional(), + terminal: OptionalString, + agent: OptionalString, + model: OptionalWorkerLaunchPreference, + effort: OptionalWorkerLaunchPreference, + retryOf: OptionalString, + timeoutMs: OptionalFiniteNumber, + devMode: z.boolean().optional() + }) + .superRefine((params, ctx) => { + if (!params.task && !params.spec) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['task'], + message: 'Missing --task or --spec' + }) + } + if (params.task && params.spec) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['spec'], + message: '--task and --spec are mutually exclusive' + }) + } + // Why: --spec creates a new Task, so a retry link to a prior Dispatch could never resolve and + // the refusal named a Task id the caller never supplied. + if (params.retryOf && params.spec) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['retryOf'], + message: + '--retry-of needs --task naming the failed Task; --spec creates a new one' + }) + } + }) diff --git a/src/shared/rpc-contract/orchestration-worker-stop-params.ts b/src/shared/rpc-contract/orchestration-worker-stop-params.ts new file mode 100644 index 00000000000..cb465c8808b --- /dev/null +++ b/src/shared/rpc-contract/orchestration-worker-stop-params.ts @@ -0,0 +1,4 @@ +import { z } from 'zod' +import { requiredString } from './rpc-param-primitives' + +export const WorkerDispatchParams = z.object({ dispatch: requiredString('Missing --dispatch') }) diff --git a/src/shared/rpc-contract/plugins-params.ts b/src/shared/rpc-contract/plugins-params.ts new file mode 100644 index 00000000000..8819f328d86 --- /dev/null +++ b/src/shared/rpc-contract/plugins-params.ts @@ -0,0 +1,20 @@ +import { z } from 'zod' +import { isQualifiedPluginKey } from '../plugins/plugin-manifest' + +export const PluginSetEnabledParams = z.object({ + pluginKey: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key'), + enabled: z.boolean() +}) + +export const PluginReadPanelEntryParams = z.object({ + pluginKey: z.string().min(1), + panelId: z.string().min(1) +}) + +export const PluginInvokeCommandParams = z.object({ + pluginKey: z.string().min(1), + commandId: z.string().min(1), + args: z.unknown().optional() +}) + +export const PluginsPanelActionParams = z.unknown() diff --git a/src/shared/rpc-contract/preflight-params.ts b/src/shared/rpc-contract/preflight-params.ts new file mode 100644 index 00000000000..3dc6a8d1ef2 --- /dev/null +++ b/src/shared/rpc-contract/preflight-params.ts @@ -0,0 +1,13 @@ +import { z } from 'zod' + +export const PreflightCheck = z.object({ + force: z.boolean().optional() +}) + +export const PreflightDetectRemoteAgents = z.object({ + connectionId: z.string().min(1) +}) + +export const PreflightDetectRemoteWindowsTerminalCapabilities = z.object({ + connectionId: z.string().min(1) +}) diff --git a/src/shared/rpc-contract/project-runtime-params.ts b/src/shared/rpc-contract/project-runtime-params.ts new file mode 100644 index 00000000000..38d9ebff0ba --- /dev/null +++ b/src/shared/rpc-contract/project-runtime-params.ts @@ -0,0 +1,95 @@ +import { z } from 'zod' +import { OptionalString, requiredString } from './rpc-param-primitives' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + parseExecutionHostId +} from '../execution-host' + +export const ProjectProviderIdentity = z.object({ + provider: z.literal('github'), + owner: requiredString('Missing project owner'), + repo: requiredString('Missing project repository'), + host: OptionalString +}) + +// Why: `runtime:` ids are minted by the calling client's own pairing store +// (addEnvironmentFromPairingCode -> randomUUID), so they name a machine only relative to that +// client. A client sending one to this runtime is addressing *us*, and runtimes do not proxy +// these calls onward, so the host it names is this machine. Persisting the caller's id verbatim +// makes one machine look like a different host to every other client, hides its rows from them, +// and defeats the (projectId, hostId) duplicate check. Store our own spelling instead: `local`. +// Rows written before this normalization keep their client-minted stamp; readers still project +// `local` back to `runtime:`, so the client-visible model is unchanged. +export const RequestedHostId = requiredString('Missing host ID').transform((value, ctx) => { + const hostId = normalizeExecutionHostId(value) + if (!hostId) { + ctx.addIssue({ code: 'custom', message: 'Invalid host ID' }) + return z.NEVER + } + return parseExecutionHostId(hostId)?.kind === 'runtime' ? LOCAL_EXECUTION_HOST_ID : hostId +}) + +export const ProjectHostSetupExistingFolder = z.object({ + projectId: requiredString('Missing project ID'), + projectProviderIdentity: ProjectProviderIdentity.optional(), + hostId: RequestedHostId, + path: requiredString('Missing project path'), + kind: z.enum(['git', 'folder']).optional(), + displayName: OptionalString, + setupMethod: z.enum(['imported-existing-folder', 'cloned']).optional() +}) + +export const ProjectHostSetupClone = z.object({ + projectId: requiredString('Missing project ID'), + projectProviderIdentity: ProjectProviderIdentity.optional(), + hostId: RequestedHostId, + url: requiredString('Missing clone URL'), + destination: requiredString('Missing clone destination'), + displayName: OptionalString +}) + +export const LocalWindowsRuntimePreference = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('inherit-global') }), + z.object({ kind: z.literal('windows-host') }), + z.object({ kind: z.literal('wsl'), distro: requiredString('Missing WSL distro') }) +]) + +export const ProjectUpdate = z.object({ + projectId: requiredString('Missing project ID'), + updates: z.object({ + localWindowsRuntimePreference: LocalWindowsRuntimePreference.optional() + }) +}) + +export const ProjectHostSetupCreate = z.object({ + projectId: requiredString('Missing project ID'), + hostId: RequestedHostId, + setupId: OptionalString, + path: OptionalString, + kind: z.enum(['git', 'folder']).optional(), + displayName: OptionalString, + worktreeBasePath: OptionalString, + gitUsername: OptionalString, + setupState: z.enum(['ready', 'not-set-up', 'setting-up', 'error', 'unsupported']).optional(), + setupMethod: z.enum(['imported-existing-folder', 'cloned', 'provisioned']).optional() +}) + +export const ProjectHostSetupUpdate = z.object({ + setupId: requiredString('Missing setup ID'), + updates: z.object({ + displayName: OptionalString, + path: OptionalString, + worktreeBasePath: OptionalString, + setupState: z.enum(['ready', 'not-set-up', 'setting-up', 'error', 'unsupported']).optional(), + setupMethod: z + .enum(['legacy-repo', 'imported-existing-folder', 'cloned', 'provisioned']) + .optional(), + gitUsername: OptionalString, + kind: z.enum(['git', 'folder']).optional() + }) +}) + +export const ProjectHostSetupDelete = z.object({ + setupId: requiredString('Missing setup ID') +}) diff --git a/src/shared/rpc-contract/repo-params.ts b/src/shared/rpc-contract/repo-params.ts new file mode 100644 index 00000000000..e5d29174a03 --- /dev/null +++ b/src/shared/rpc-contract/repo-params.ts @@ -0,0 +1,100 @@ +import { z } from 'zod' +import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives' +import { createRepoUpdateSchema } from './repo-update-params' +import { RepoSelector } from './github-repo-target-params' + +export const RepoPath = z.object({ + path: requiredString('Missing repo path'), + kind: z.enum(['git', 'folder']).optional(), + displayName: OptionalString +}) + +export const RepoCreate = z.object({ + parentPath: requiredString('Missing parent path'), + name: requiredString('Missing repo name'), + kind: z.enum(['git', 'folder']).optional() +}) + +export const RepoClone = z.object({ + url: requiredString('Missing clone URL'), + destination: requiredString('Missing clone destination') +}) + +export const RepoSetBaseRef = z.object({ + repo: requiredString('Missing repo selector'), + ref: requiredString('Missing base ref') +}) + +export const RepoUpdate = createRepoUpdateSchema(RepoSelector.shape) + +export const RepoSearchRefs = z.object({ + repo: requiredString('Missing repo selector'), + query: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : undefined)) + .pipe(z.string({ message: 'Missing query' })), + limit: OptionalFiniteNumber +}) + +export const RepoReorder = z.object({ + orderedIds: z.array(z.string()) +}) + +export const ProjectGroupCreate = z.object({ + name: requiredString('Missing group name'), + parentPath: OptionalString, + connectionId: OptionalString.nullable().optional(), + parentGroupId: OptionalString.nullable().optional(), + createdFrom: z.enum(['manual', 'folder-scan', 'migration']).optional() +}) + +export const ProjectGroupUpdate = z.object({ + groupId: requiredString('Missing group id'), + updates: z.object({ + name: OptionalString, + isCollapsed: z.boolean().optional(), + tabOrder: OptionalFiniteNumber, + color: OptionalString.nullable().optional() + }) +}) + +export const ProjectGroupSelector = z.object({ + groupId: requiredString('Missing group id') +}) + +export const ProjectGroupMoveProject = z.object({ + repo: requiredString('Missing repo selector'), + groupId: OptionalString.nullable(), + order: OptionalFiniteNumber +}) + +export const ProjectGroupScanNested = z.object({ + path: requiredString('Missing folder path') +}) + +export const ProjectGroupImportNested = z.discriminatedUnion('mode', [ + z.object({ + parentPath: requiredString('Missing parent path'), + groupName: z.string().optional().default(''), + projectPaths: z.array(z.string()), + mode: z.literal('group') + }), + z.object({ + parentPath: requiredString('Missing parent path'), + // Why: blank group names fall back to the scanned folder basename; separate + // imports do not create a group but share the same renderer payload shape. + groupName: z.string().optional().default(''), + projectPaths: z.array(z.string()), + mode: z.literal('separate') + }) +]) + +export const RepoIssueCommandWrite = RepoSelector.extend({ + content: z.string() +}) + +export const RepoSparsePresetSave = RepoSelector.extend({ + id: OptionalString, + name: requiredString('Missing preset name'), + directories: z.array(z.string()) +}) diff --git a/src/shared/rpc-contract/repo-update-params.ts b/src/shared/rpc-contract/repo-update-params.ts new file mode 100644 index 00000000000..179bb1994dc --- /dev/null +++ b/src/shared/rpc-contract/repo-update-params.ts @@ -0,0 +1,77 @@ +import { z } from 'zod' +import { normalizeRepoSourceControlAiOverrides } from '../source-control-ai' +import { normalizeRepoBadgeColor } from '../repo-badge-color' +import { sanitizeRepoIcon } from '../repo-icon' +import { + normalizeCustomWorktreeVisibilitySources, + normalizeWorktreeVisibilitySourcePreferences +} from '../worktree/visibility-sources' +import { OptionalFiniteNumber, OptionalString } from './rpc-param-primitives' + +export const RepoSourceControlAiOverrides = z + .unknown() + .optional() + .transform((value) => + value === undefined + ? undefined + : value === null + ? null + : normalizeRepoSourceControlAiOverrides(value) + ) + +export const RepoBadgeColor = z + .unknown() + .optional() + .transform((value) => + value === undefined ? undefined : (normalizeRepoBadgeColor(value) ?? undefined) + ) + +export const RepoUpstream = z + .object({ + owner: z.string().min(1), + repo: z.string().min(1) + }) + .nullable() + .optional() + +// The return type is inferred on purpose: an explicit z.ZodObject<...z.ZodRawShape> +// annotation widened `updates` to an open record, which erased all 24 named fields +// from RpcParams<'repo.update'> for every typed caller. +export function createRepoUpdateSchema(selectorShape: T) { + return z.object({ + ...selectorShape, + updates: z.object({ + displayName: OptionalString, + badgeColor: RepoBadgeColor, + repoIcon: z + .unknown() + .transform((value) => sanitizeRepoIcon(value)) + .optional(), + upstream: RepoUpstream, + hookSettings: z.unknown().optional(), + worktreeBaseRef: OptionalString, + worktreeBasePath: OptionalString, + kind: z.enum(['git', 'folder']).optional(), + symlinkPaths: z.array(z.string()).optional(), + issueSourcePreference: z.enum(['auto', 'upstream', 'origin']).optional(), + forkSyncMode: z.enum(['ask', 'safe-auto', 'off']).optional(), + externalWorktreeVisibility: z.enum(['hide', 'show']).nullable().optional(), + externalWorktreeVisibilityPromptDismissedAt: z.number().finite().optional(), + externalWorktreeInboxBaselinePaths: z.array(z.string()).optional(), + importedExternalWorktreePaths: z.array(z.string()).optional(), + agentWorktreeVisibility: z.enum(['hide', 'show']).nullable().optional(), + customWorktreeVisibilitySources: z + .unknown() + .transform((value) => normalizeCustomWorktreeVisibilitySources(value)) + .optional(), + worktreeVisibilitySourcePreferences: z + .unknown() + .transform((value) => normalizeWorktreeVisibilitySourcePreferences(value)) + .optional(), + externalWorktreeDiscoverySuppressedAt: z.number().finite().nullable().optional(), + projectGroupId: OptionalString.nullable().optional(), + projectGroupOrder: OptionalFiniteNumber, + sourceControlAi: RepoSourceControlAiOverrides + }) + }) +} diff --git a/src/shared/rpc-contract/rpc-param-primitives.ts b/src/shared/rpc-contract/rpc-param-primitives.ts new file mode 100644 index 00000000000..a22efbd2765 --- /dev/null +++ b/src/shared/rpc-contract/rpc-param-primitives.ts @@ -0,0 +1,84 @@ +import { z } from 'zod' + +// Why: the original handlers treated non-numeric/NaN limit values as "no +// limit" rather than as errors. Preserve that forgiving behavior so CLI +// callers passing stringified numbers or Infinity still reach the runtime. +// The outer optional() is required for omitted keys in Zod v4; an optional +// schema hidden behind pipe() still makes z.object require the property. +export const OptionalFiniteNumber = z + .unknown() + .transform((value) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined)) + .pipe(z.union([z.number(), z.undefined()])) + .optional() + +export const OptionalPositiveInt = z + .unknown() + .transform((value) => + typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined + ) + .pipe(z.union([z.number(), z.undefined()])) + .optional() + +export const OptionalString = z + .unknown() + .transform((value) => (typeof value === 'string' && value.length > 0 ? value : undefined)) + .pipe(z.union([z.string(), z.undefined()])) + .optional() + +export const OptionalPlainString = z + .unknown() + .transform((value) => (typeof value === 'string' ? value : undefined)) + .pipe(z.union([z.string(), z.undefined()])) + .optional() + +export const OptionalBoolean = z + .unknown() + .transform((value) => (typeof value === 'boolean' ? value : undefined)) + .pipe(z.union([z.boolean(), z.undefined()])) + .optional() + +// Why: runtime handlers accept `linkedIssue: number | null | undefined` with +// distinct meanings — undefined means "no update", null means "clear", number +// means "set". The ambient JSON decode produces all three shapes as-is. +export const TriStateLinkedIssue = z + .unknown() + .transform((value) => { + if (value === null) { + return null + } + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + return undefined + }) + .pipe(z.union([z.number(), z.null(), z.undefined()])) + .optional() + +// Why: the legacy extractBrowserTarget treated worktree as a plain-string +// passthrough (empty string preserved) but `page` as non-empty-string. The +// browser bridge uses worktree-as-empty-string to mean "any worktree", so +// keep that asymmetry intact to avoid widening scope unexpectedly. +export const BrowserTarget = z.object({ + worktree: OptionalPlainString, + page: OptionalString +}) + +export function requiredString(message: string) { + return z + .unknown() + .transform((value) => (typeof value === 'string' ? value : '')) + .pipe(z.string().min(1, message)) +} + +export function requiredStringAllowingEmpty(message: string) { + return z.unknown().refine((value): value is string => typeof value === 'string', { message }) +} + +export function requiredNumber(message: string) { + return z + .unknown() + .transform((value) => + typeof value === 'number' && Number.isFinite(value) ? value : Number.NaN + ) + .pipe(z.number().refine((v) => Number.isFinite(v), { message })) +} diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts new file mode 100644 index 00000000000..15d04a5b655 --- /dev/null +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -0,0 +1,1167 @@ +// GENERATED by config/scripts/generate-rpc-params-catalog.mjs. Do not edit; +// run `pnpm run generate:rpc-params-catalog`. +import type { z } from 'zod' +import { AgentSkillShareRequestSchema } from '../agent-skill-sharing-contract' +import { + BrowserClientFileChannelAbortParams, + BrowserClientFileChannelReadParams, + BrowserClientFileChannelWriteParams +} from '../browser-client-file-channel-protocol' +import { + BrowserClientHostAttachParams, + BrowserClientHostCommandResultParams, + BrowserNetworkTunnelAttachParams +} from '../browser-client-host-protocol' +import { BrowserClientPageMetadataParams } from '../browser-client-page-metadata-protocol' +import { + PairingGetEndpointsParamsSchema, + PairingProvisionRelayParamsSchema +} from '../mobile-relay-credential-contract' +import { pluginConsentRequestSchema } from '../plugins/plugin-consent-request' +import { + AccountsUnsubscribeParams, + AddClaudeFromConfigDirParams, + AddCodexFromHomeParams, + ConsumeCodexResetCreditParams, + ListAccountsParams, + RemoveAccountParams, + SelectAccountParams, + SelectCodexAccountForTargetParams +} from './accounts-params' +import { PrepareCodexForWslPaneParams } from './agent-hooks-params' +import { CreateAgentSessionParams, EnsureAgentSessionParams } from './agent-session-params' +import { + AiVaultListSessionsParams, + AiVaultPrepareSessionResumeParams, + AiVaultSessionTitlesParams +} from './ai-vault-params' +import { ArtifactsDeleteParams, ListOptions, SourceRequest, WriteRequest } from './artifacts-params' +import { + AutomationCreate, + AutomationId, + AutomationList, + AutomationRuns, + AutomationUpdate +} from './automation-params' +import { CertificateProceed } from './browser-core-params' +import { MouseClick } from './browser-extras-params' +import { + Check, + ClipboardWrite, + CookieDelete, + CookieGet, + CookieSet, + DialogAccept, + Drag, + Element, + Eval, + Exec, + Fill, + Find, + FullScreenshot, + Geolocation, + Get, + Goto, + Highlight, + InterceptEnable, + Is, + KeyboardInsert, + Keypress, + LimitParam, + MouseButton, + MouseWheel, + MouseXY, + ProfileCreate, + ProfileDelete, + ProfileImportFromBrowser, + Screencast, + Screenshot, + Scroll, + Select, + SelectorPath, + SetCredentials, + SetDevice, + SetHeaders, + SetMedia, + SetOffline, + StorageKey, + StorageKeyValue, + TabClose, + TabCurrent, + TabList, + TabProfileClone, + TabSetProfile, + TabShow, + TabSwitch, + Type, + Upload, + Viewport, + Wait +} from './browser-params' +import { ScreencastUnsubscribe } from './browser-screencast-params' +import { BrowserOpenUrlParams, BrowserTabCreateParams } from './browser-tab-create-params' +import { ClientEventsUnsubscribeParams } from './client-events-params' +import { + NativeChatSessionOptionsMutation, + PRBotAuthorOverrideUpdate, + SettingsUpdate +} from './client-settings-params' +import { FeatureInteractionIdParam, UiUpdate } from './client-ui-params' +import { + AbortImageUpload, + AppendImageUploadChunk, + CommitImageUpload, + SaveImageAsTempFile, + StartImageUpload +} from './clipboard-params' +import { ComputerCapabilitiesParams, ComputerPermissionsStatusParams } from './computer-params' +import { + Click, + ComputerObserveTarget, + ComputerPermissions, + Drag as DragOfComputerSchemasParams, + Hotkey, + ListApps, + ListWindows, + PasteText, + PerformSecondaryAction, + PressKey, + Scroll as ScrollOfComputerSchemasParams, + SetValue, + TypeText +} from './computer-schemas-params' +import { + AttachParams as AttachParamsOfEmulatorParams, + AxParams, + ButtonParams, + EmulatorAvailabilityParams, + EmulatorListDevicesParams, + EmulatorListSimulatorsParams, + EmulatorUnregisterActiveParams, + ExecParams, + GestureParams, + KillParams, + LaunchParams, + ListParams, + LogcatParams, + PermissionsParams, + RotateParams, + ShutdownParams, + TapParams, + TypeParams +} from './emulator-params' +import { + FileCommitUpload, + FileCopy, + FileDelete, + FileMutationOpen, + FileRename, + FileWrite, + FileWriteBase64, + FileWriteBase64Chunk +} from './files-mutation-params' +import { + DocPreviewFileRead, + FileListAll, + FileOpenDiff, + FilePathSearch, + FileReadChunk, + FileSearch, + FileTreePath, + FileUnwatch, + ResolveTerminalPath, + ServerDirectoryBrowse +} from './files-params' +import { FileOpen, WorktreeSelector } from './files-target-params' +import { TerminalArtifactFile, TerminalArtifactFileWrite } from './files-terminal-artifact-params' +import { + FolderWorkspaceCreate, + FolderWorkspacePathStatus, + FolderWorkspaceSelector, + FolderWorkspaceUpdate +} from './folder-workspace-params' +import { + GitBranchCompare, + GitBranchDiff, + GitBulkPaths, + GitCheckIgnored, + GitCheckout, + GitCommit, + GitCommitCompare, + GitCommitDiff, + GitDiff, + GitDiscoverCommitMessageModels, + GitFilePath, + GitForkSync, + GitGenerateCommitMessage, + GitGeneratePullRequestFields, + GitHistory, + GitPush, + GitRebaseFromBase, + GitRemoteCommitUrl, + GitRemoteFileUrl, + GitStatusParams, + GitSubmoduleStatus, + GitTargetedRemote, + WorktreeSelector as WorktreeSelectorOfGitParams +} from './git-params' +import { CreateIssue, Issue, IssueComment, UpdateIssue } from './github-issue-params' +import { + ClearProjectItemField, + GithubProjectListAccessibleParams, + ProjectItemField, + ProjectRef, + ProjectViewTable, + ProjectViews, + ProjectWorkItemDetailsBySlug, + SlugAssignableUsers, + SlugIssueComment, + SlugIssueCommentDelete, + SlugIssueCommentEdit, + SlugIssueTypeUpdate, + SlugIssueUpdate, + SlugPullRequestUpdate +} from './github-project-params' +import { + PRCommentReaction, + PrForBranch, + PullRequest, + PullRequestCheckDetails, + PullRequestChecks, + PullRequestFileContents, + PullRequestFileViewed, + RerunPullRequestChecks, + ReviewThread +} from './github-pull-request-params' +import { + MarkPrReadyForReview, + MergePr, + PRReviewComment, + PRReviewCommentReply, + RemovePrReviewers, + RequestPrReviewers, + SetPrAutoMerge, + UpdatePr, + UpdatePrState, + UpdatePrTitle +} from './github-pull-request-update-params' +import { RepoSelector, SlugRepo } from './github-repo-target-params' +import { + IssuesList, + RateLimit, + WorkItem, + WorkItemByOwnerRepo, + WorkItemsCount, + WorkItemsList +} from './github-repo-work-item-params' +import { + AddIssueComment, + AddMRComment, + AddMRInlineComment, + CreateIssue as CreateIssueOfGitlabParams, + EmptyParams, + GitLabRateLimit, + IssuesList as IssuesListOfGitlabParams, + JobTrace, + MergeMr, + RepoSelector as RepoSelectorOfGitlabParams, + ResolveMRDiscussion, + RetryJob, + UpdateIssue as UpdateIssueOfGitlabParams, + UpdateMr, + UpdateMrReviewers, + UpdateMrState, + WorkItemByPath, + WorkItemDetails, + WorkItemsList as WorkItemsListOfGitlabParams +} from './gitlab-params' +import { + HostedReviewCreate, + HostedReviewCreationEligibility, + HostedReviewForBranch +} from './hosted-review-params' +import { + AssignableUsers, + Connect, + CreateIssue as CreateIssueOfJiraParams, + IssueComment as IssueCommentOfJiraParams, + IssueKey, + IssueUpdate, + ListIssues, + ProjectIssueTypeFields, + ProjectIssueTypes, + ProjectStatusOrder, + SearchIssues, + SelectSite, + SiteSelection, + UserSearch +} from './jira-params' +import { + AgentIssueContext, + AgentSearchIssues, + LinearCurrentContext, + LinearIssueAddComment, + LinearIssueAttachLink, + LinearIssueCreate, + LinearIssueList, + LinearIssueRelationWrite, + LinearIssueSetState, + LinearIssueUpdateTask, + LinearProjectList, + LinearSaveIssue, + LinearTeamLookup, + LinearWorkspaceRead +} from './linear-agent-access-params' +import { + ListIssues as ListIssuesOfLinearIssueListParams, + McpListIssues +} from './linear-issue-list-params' +import { + Connect as ConnectOfLinearParams, + CreateIssue as CreateIssueOfLinearParams, + CustomViewContents, + CustomViewId, + IssueComment as IssueCommentOfLinearParams, + IssueId, + IssueUpdate as IssueUpdateOfLinearParams, + LinearIssueCommentsParams, + ListCustomViews, + ListProjects, + ProjectId, + ProjectIssues, + SearchIssues as SearchIssuesOfLinearParams, + SelectWorkspace, + TeamId, + WorkspaceSelection +} from './linear-params' +import { CreateProject } from './linear-project-create-params' +import { NativeChatSession, NativeChatUnsubscribe } from './native-chat-params' +import { + NotificationGetMissedSinceParams, + NotificationRegisterPushParams, + NotificationUnsubscribeParams, + NotificationsSubscribeParams +} from './notifications-params' +import { + FederationDispatchParams, + FederationFleetSnapshotParams, + FederationOutputReadParams, + FederationReadParams +} from './orchestration-federation-control-params' +import { + FederationAckParams, + FederationImportParams, + FederationPullParams +} from './orchestration-federation-relay-params' +import { FederationAttachStartParams } from './orchestration-federation-start-params' +import { + GateCreateParams, + GateListParams, + GateResolveParams, + RunParams, + RunStopParams +} from './orchestration-gates-params' +import { + AskParams, + CheckParams, + DispatchParams, + DispatchShowParams, + InboxParams, + ReplyParams, + ResetParams, + TaskCreateParams, + TaskListParams +} from './orchestration-params' +import { RequestShowParams } from './orchestration-runs-mutation-request-show-params' +import { + RunCreateParams, + RunCurrentParams, + RunListParams, + RunShowParams, + RunUseParams +} from './orchestration-runs-params' +import { WorkerDispatchParams, WorkerReadParams } from './orchestration-worker-control-params' +import { OrchestrationWorkerTerminalUserInputParams } from './orchestration-worker-release-params' +import { + WorkerDispatchParams as WorkerDispatchParamsOfOrchestrationWorkerReleaseSchemasParams, + WorkerListParams, + WorkerRetainParams +} from './orchestration-worker-release-schemas-params' +import { WorkerStartParams } from './orchestration-worker-start-params' +import { WorkerDispatchParams as WorkerDispatchParamsOfOrchestrationWorkerStopParams } from './orchestration-worker-stop-params' +import { + PluginInvokeCommandParams, + PluginReadPanelEntryParams, + PluginSetEnabledParams, + PluginsPanelActionParams +} from './plugins-params' +import { + PreflightCheck, + PreflightDetectRemoteAgents, + PreflightDetectRemoteWindowsTerminalCapabilities +} from './preflight-params' +import { + ProjectHostSetupClone, + ProjectHostSetupCreate, + ProjectHostSetupDelete, + ProjectHostSetupExistingFolder, + ProjectHostSetupUpdate, + ProjectUpdate +} from './project-runtime-params' +import { + ProjectGroupCreate, + ProjectGroupImportNested, + ProjectGroupMoveProject, + ProjectGroupScanNested, + ProjectGroupSelector, + ProjectGroupUpdate, + RepoClone, + RepoCreate, + RepoIssueCommandWrite, + RepoPath, + RepoReorder, + RepoSearchRefs, + RepoSetBaseRef, + RepoSparsePresetSave, + RepoUpdate +} from './repo-params' +import { BrowserTarget } from './rpc-param-primitives' +import { ClientCapabilitiesUpdate } from './runtime-client-capabilities-params' +import { SessionTabsUnsubscribeAllParams } from './session-tabs-params' +import { + ActivateTab, + CloseLifecycleTab, + CloseTab, + CreateTerminalTab, + MoveTab, + SaveMarkdownTab, + SessionTabsUnsubscribe, + SetTabProps, + UpdatePaneLayout, + WorktreeTabSelector +} from './session-tabs-schemas-params' +import { + SkillsCancelInstallParams, + SkillsDiscoverParams, + SkillsGetInstallProgressParams +} from './skills-params' +import { + DictationChunk, + DictationHandle, + DictationSetup, + DictationStart, + SpeechModelAction +} from './speech-params' +import { SshTarget } from './ssh-params' +import { + AttachParams, + CancelParams, + ConversationCommandParams, + CreateParams, + CreateSupportParams, + HandoffParams, + HandoffStatusParams, + HistoryParams, + HoldParams, + OptionsParams, + RespondParams, + RewindParams, + SendParams, + SetOptionParams, + SubscribeParams, + UnsubscribeParams +} from './structured-agent-session-params' +import { TerminalAdoptOrphans } from './terminal-orphan-params' +import { TerminalQuickCommandsUpdate } from './terminal-quick-command-params' +import { + TerminalMultiplex, + TerminalResizeForClient, + TerminalSubscribe +} from './terminal-stream-params' +import { + AgentTeamsPrepareLaunch, + AgentTeamsTmuxCompat, + TerminalCloseAll, + TerminalCreateParams, + TerminalFocus, + TerminalHandle, + TerminalInspectProcess, + TerminalListParams, + TerminalRead, + TerminalRecoverPane, + TerminalRename, + TerminalResolveActive, + TerminalResolvePane, + TerminalSend, + TerminalSplit, + TerminalStopExact, + TerminalWait +} from './terminal-unary-params' +import { TerminalGetAutoRestoreFitParams } from './terminal-viewport-methods-params' +import { + TerminalSetAutoRestoreFit, + TerminalSetDisplayMode, + TerminalUnsubscribe, + TerminalUpdateViewport +} from './terminal-viewport-schemas-params' +import { UpdaterCheckParams } from './updater-params' +import { WorkspacePortKillParams, WorkspacePortScanParams } from './workspace-ports-params' +import { WorktreeCreate, WorktreePrefetchCreateBase } from './worktree-create-params' +import { + WorktreeActivate, + WorktreeDetectedListParams, + WorktreeForceDeleteBranch, + WorktreeListParams, + WorktreePsParams, + WorktreeRemove, + WorktreeResolveMrBase, + WorktreeResolvePrBase, + WorktreeSelector as WorktreeSelectorOfWorktreeParams, + WorktreeSet, + WorktreeSortOrder, + WorktreeTeardownMissingTerminalsParams +} from './worktree-params' +import { SkillBundleInstallRequestSchema } from '../skill-bundle-install-contract' +import { SkillDeleteRequestSchema } from '../skill-delete-contract' +import { + SkillInstallPreviewRequestSchema, + SkillInstallRequestSchema, + SkillRemoveRequestSchema +} from '../skill-install-contract' +import { + SkillUploadBeginRequestSchema, + SkillUploadChunkRequestSchema, + SkillUploadCommitRequestSchema +} from '../skill-upload-session-contract' + +// Why: the host parses params with these schemas, so a client that matches this map +// matches the dispatcher. Clients must import it for types only — parsing a params +// schema client-side runs the coercing transforms and rewrites the wire bytes. +export const RPC_PARAMS_BY_METHOD = { + 'accounts.addClaudeFromConfigDir': AddClaudeFromConfigDirParams, + 'accounts.addCodexFromHome': AddCodexFromHomeParams, + 'accounts.consumeCodexResetCredit': ConsumeCodexResetCreditParams, + 'accounts.list': ListAccountsParams, + 'accounts.removeClaude': RemoveAccountParams, + 'accounts.removeCodex': RemoveAccountParams, + 'accounts.selectClaude': SelectAccountParams, + 'accounts.selectCodex': SelectAccountParams, + 'accounts.selectCodexForTarget': SelectCodexAccountForTargetParams, + 'accounts.subscribe': null, + 'accounts.unsubscribe': AccountsUnsubscribeParams, + 'agentHooks.prepareCodexForWslPane': PrepareCodexForWslPaneParams, + 'agentSession.cancel': CancelParams, + 'agentSession.close': OptionsParams, + 'agentSession.commands': OptionsParams, + 'agentSession.conversationCommand': ConversationCommandParams, + 'agentSession.create': CreateParams, + 'agentSession.createSupport': CreateSupportParams, + 'agentSession.ensure': AttachParams, + 'agentSession.handoffStatus': HandoffStatusParams, + 'agentSession.history': HistoryParams, + 'agentSession.hold': HoldParams, + 'agentSession.options': OptionsParams, + 'agentSession.release': HoldParams, + 'agentSession.requestHandoff': HandoffParams, + 'agentSession.respondToApproval': RespondParams, + 'agentSession.respondToQuestion': RespondParams, + 'agentSession.reveal': OptionsParams, + 'agentSession.rewind': RewindParams, + 'agentSession.send': SendParams, + 'agentSession.setOption': SetOptionParams, + 'agentSession.subscribe': SubscribeParams, + 'agentSession.subscribeStatus': null, + 'agentSession.unsubscribe': UnsubscribeParams, + 'agentTeams.prepareLaunch': AgentTeamsPrepareLaunch, + 'agentTeams.tmuxCompat': AgentTeamsTmuxCompat, + 'aiVault.listSessions': AiVaultListSessionsParams, + 'aiVault.prepareSessionResume': AiVaultPrepareSessionResumeParams, + 'aiVault.resolveSessionTitles': AiVaultSessionTitlesParams, + 'artifacts.delete': ArtifactsDeleteParams, + 'artifacts.getPublishedLink': SourceRequest, + 'artifacts.list': ListOptions, + 'artifacts.publish': WriteRequest, + 'artifacts.share': WriteRequest, + 'artifacts.unshare': SourceRequest, + 'artifacts.update': WriteRequest, + 'automation.create': AutomationCreate, + 'automation.delete': AutomationId, + 'automation.list': AutomationList, + 'automation.runNow': AutomationId, + 'automation.runs': AutomationRuns, + 'automation.show': AutomationId, + 'automation.update': AutomationUpdate, + 'browser.back': BrowserTarget, + 'browser.capture.start': BrowserTarget, + 'browser.capture.stop': BrowserTarget, + 'browser.certificate.proceed': CertificateProceed, + 'browser.check': Check, + 'browser.clear': Element, + 'browser.click': Element, + 'browser.clientHost.attach': BrowserClientHostAttachParams, + 'browser.clientHost.commandResult': BrowserClientHostCommandResultParams, + 'browser.clientHost.fileChannel.abort': BrowserClientFileChannelAbortParams, + 'browser.clientHost.fileChannel.read': BrowserClientFileChannelReadParams, + 'browser.clientHost.fileChannel.write': BrowserClientFileChannelWriteParams, + 'browser.clientHost.pageMetadata': BrowserClientPageMetadataParams, + 'browser.clipboardRead': BrowserTarget, + 'browser.clipboardWrite': ClipboardWrite, + 'browser.console': LimitParam, + 'browser.cookie.delete': CookieDelete, + 'browser.cookie.get': CookieGet, + 'browser.cookie.set': CookieSet, + 'browser.dblclick': Element, + 'browser.dialogAccept': DialogAccept, + 'browser.dialogDismiss': BrowserTarget, + 'browser.download': SelectorPath, + 'browser.drag': Drag, + 'browser.eval': Eval, + 'browser.exec': Exec, + 'browser.fill': Fill, + 'browser.find': Find, + 'browser.focus': Element, + 'browser.forward': BrowserTarget, + 'browser.fullScreenshot': FullScreenshot, + 'browser.geolocation': Geolocation, + 'browser.get': Get, + 'browser.goto': Goto, + 'browser.highlight': Highlight, + 'browser.hover': Element, + 'browser.intercept.disable': BrowserTarget, + 'browser.intercept.enable': InterceptEnable, + 'browser.intercept.list': BrowserTarget, + 'browser.is': Is, + 'browser.keyboardInsertText': KeyboardInsert, + 'browser.keypress': Keypress, + 'browser.mouseClick': MouseClick, + 'browser.mouseDown': MouseButton, + 'browser.mouseMove': MouseXY, + 'browser.mouseUp': MouseButton, + 'browser.mouseWheel': MouseWheel, + 'browser.network': LimitParam, + 'browser.openUrl': BrowserOpenUrlParams, + 'browser.pdf': BrowserTarget, + 'browser.profileClearDefaultCookies': null, + 'browser.profileCreate': ProfileCreate, + 'browser.profileDelete': ProfileDelete, + 'browser.profileDetectBrowsers': null, + 'browser.profileImportFromBrowser': ProfileImportFromBrowser, + 'browser.profileList': null, + 'browser.reload': BrowserTarget, + 'browser.screencast': Screencast, + 'browser.screencast.unsubscribe': ScreencastUnsubscribe, + 'browser.screenshot': Screenshot, + 'browser.scroll': Scroll, + 'browser.scrollIntoView': Element, + 'browser.select': Select, + 'browser.selectAll': Element, + 'browser.setCredentials': SetCredentials, + 'browser.setDevice': SetDevice, + 'browser.setHeaders': SetHeaders, + 'browser.setMedia': SetMedia, + 'browser.setOffline': SetOffline, + 'browser.snapshot': BrowserTarget, + 'browser.storage.local.clear': BrowserTarget, + 'browser.storage.local.get': StorageKey, + 'browser.storage.local.set': StorageKeyValue, + 'browser.storage.session.clear': BrowserTarget, + 'browser.storage.session.get': StorageKey, + 'browser.storage.session.set': StorageKeyValue, + 'browser.tabClose': TabClose, + 'browser.tabCreate': BrowserTabCreateParams, + 'browser.tabCurrent': TabCurrent, + 'browser.tabList': TabList, + 'browser.tabProfileClone': TabProfileClone, + 'browser.tabProfileShow': TabShow, + 'browser.tabSetProfile': TabSetProfile, + 'browser.tabShow': TabShow, + 'browser.tabSwitch': TabSwitch, + 'browser.type': Type, + 'browser.upload': Upload, + 'browser.viewport': Viewport, + 'browser.wait': Wait, + 'clipboard.abortImageUpload': AbortImageUpload, + 'clipboard.appendImageUploadChunk': AppendImageUploadChunk, + 'clipboard.commitImageUpload': CommitImageUpload, + 'clipboard.saveImageAsTempFile': SaveImageAsTempFile, + 'clipboard.startImageUpload': StartImageUpload, + 'computer.capabilities': ComputerCapabilitiesParams, + 'computer.click': Click, + 'computer.drag': DragOfComputerSchemasParams, + 'computer.getAppState': ComputerObserveTarget, + 'computer.hotkey': Hotkey, + 'computer.listApps': ListApps, + 'computer.listWindows': ListWindows, + 'computer.pasteText': PasteText, + 'computer.performSecondaryAction': PerformSecondaryAction, + 'computer.permissions': ComputerPermissions, + 'computer.permissionsStatus': ComputerPermissionsStatusParams, + 'computer.pressKey': PressKey, + 'computer.scroll': ScrollOfComputerSchemasParams, + 'computer.setValue': SetValue, + 'computer.typeText': TypeText, + 'diagnostics.memory': null, + 'emulator.attach': AttachParamsOfEmulatorParams, + 'emulator.availability': EmulatorAvailabilityParams, + 'emulator.ax': AxParams, + 'emulator.button': ButtonParams, + 'emulator.exec': ExecParams, + 'emulator.gesture': GestureParams, + 'emulator.kill': KillParams, + 'emulator.launch': LaunchParams, + 'emulator.list': ListParams, + 'emulator.listDevices': EmulatorListDevicesParams, + 'emulator.listSimulators': EmulatorListSimulatorsParams, + 'emulator.logcat': LogcatParams, + 'emulator.permissions': PermissionsParams, + 'emulator.rotate': RotateParams, + 'emulator.shutdown': ShutdownParams, + 'emulator.tap': TapParams, + 'emulator.type': TypeParams, + 'emulator.unregisterActive': EmulatorUnregisterActiveParams, + 'files.browseServerDir': ServerDirectoryBrowse, + 'files.commitUpload': FileCommitUpload, + 'files.copy': FileCopy, + 'files.createDir': FileMutationOpen, + 'files.createDirNoClobber': FileMutationOpen, + 'files.createFile': FileMutationOpen, + 'files.delete': FileDelete, + 'files.list': WorktreeSelector, + 'files.listAll': FileListAll, + 'files.listMarkdownDocuments': WorktreeSelector, + 'files.open': FileOpen, + 'files.openDiff': FileOpenDiff, + 'files.read': FileOpen, + 'files.readChunk': FileReadChunk, + 'files.readDir': FileTreePath, + 'files.readDocPreview': DocPreviewFileRead, + 'files.readPreview': FileOpen, + 'files.readTerminalArtifact': TerminalArtifactFile, + 'files.readTerminalArtifactPreview': TerminalArtifactFile, + 'files.rename': FileRename, + 'files.resolveTerminalPath': ResolveTerminalPath, + 'files.search': FileSearch, + 'files.searchPaths': FilePathSearch, + 'files.stat': FileTreePath, + 'files.unwatch': FileUnwatch, + 'files.watch': WorktreeSelector, + 'files.write': FileWrite, + 'files.writeBase64': FileWriteBase64, + 'files.writeBase64Chunk': FileWriteBase64Chunk, + 'files.writeTerminalArtifact': TerminalArtifactFileWrite, + 'folderWorkspace.create': FolderWorkspaceCreate, + 'folderWorkspace.delete': FolderWorkspaceSelector, + 'folderWorkspace.getPathStatus': FolderWorkspacePathStatus, + 'folderWorkspace.list': null, + 'folderWorkspace.update': FolderWorkspaceUpdate, + 'git.abortMerge': WorktreeSelectorOfGitParams, + 'git.abortRebase': WorktreeSelectorOfGitParams, + 'git.branchCompare': GitBranchCompare, + 'git.branchDiff': GitBranchDiff, + 'git.bulkDiscard': GitBulkPaths, + 'git.bulkStage': GitBulkPaths, + 'git.bulkUnstage': GitBulkPaths, + 'git.cancelGenerateCommitMessage': WorktreeSelectorOfGitParams, + 'git.cancelGeneratePullRequestFields': WorktreeSelectorOfGitParams, + 'git.checkIgnored': GitCheckIgnored, + 'git.checkout': GitCheckout, + 'git.commit': GitCommit, + 'git.commitCompare': GitCommitCompare, + 'git.commitDiff': GitCommitDiff, + 'git.conflictOperation': WorktreeSelectorOfGitParams, + 'git.diff': GitDiff, + 'git.discard': GitFilePath, + 'git.discoverCommitMessageModels': GitDiscoverCommitMessageModels, + 'git.fastForward': GitTargetedRemote, + 'git.fetch': GitTargetedRemote, + 'git.forkSync': GitForkSync, + 'git.generateCommitMessage': GitGenerateCommitMessage, + 'git.generatePullRequestFields': GitGeneratePullRequestFields, + 'git.history': GitHistory, + 'git.localBranches': WorktreeSelectorOfGitParams, + 'git.pull': GitTargetedRemote, + 'git.push': GitPush, + 'git.rebaseFromBase': GitRebaseFromBase, + 'git.remoteCommitUrl': GitRemoteCommitUrl, + 'git.remoteFileUrl': GitRemoteFileUrl, + 'git.stage': GitFilePath, + 'git.status': GitStatusParams, + 'git.submoduleStatus': GitSubmoduleStatus, + 'git.unstage': GitFilePath, + 'git.upstreamStatus': GitTargetedRemote, + 'github.addIssueComment': IssueComment, + 'github.addPRReviewComment': PRReviewComment, + 'github.addPRReviewCommentReply': PRReviewCommentReply, + 'github.countWorkItems': WorkItemsCount, + 'github.createIssue': CreateIssue, + 'github.issue': Issue, + 'github.listAssignableUsers': RepoSelector, + 'github.listIssues': IssuesList, + 'github.listLabels': RepoSelector, + 'github.listWorkItems': WorkItemsList, + 'github.markPRReadyForReview': MarkPrReadyForReview, + 'github.mergePR': MergePr, + 'github.prCheckDetails': PullRequestCheckDetails, + 'github.prChecks': PullRequestChecks, + 'github.prComments': PullRequest, + 'github.prFileContents': PullRequestFileContents, + 'github.prForBranch': PrForBranch, + 'github.project.addIssueCommentBySlug': SlugIssueComment, + 'github.project.clearItemField': ClearProjectItemField, + 'github.project.deleteIssueCommentBySlug': SlugIssueCommentDelete, + 'github.project.listAccessible': GithubProjectListAccessibleParams, + 'github.project.listAssignableUsersBySlug': SlugAssignableUsers, + 'github.project.listIssueTypesBySlug': SlugRepo, + 'github.project.listLabelsBySlug': SlugRepo, + 'github.project.listViews': ProjectViews, + 'github.project.resolveRef': ProjectRef, + 'github.project.updateIssueBySlug': SlugIssueUpdate, + 'github.project.updateIssueCommentBySlug': SlugIssueCommentEdit, + 'github.project.updateIssueTypeBySlug': SlugIssueTypeUpdate, + 'github.project.updateItemField': ProjectItemField, + 'github.project.updatePullRequestBySlug': SlugPullRequestUpdate, + 'github.project.viewTable': ProjectViewTable, + 'github.project.workItemDetailsBySlug': ProjectWorkItemDetailsBySlug, + 'github.rateLimit': RateLimit, + 'github.removePRReviewers': RemovePrReviewers, + 'github.repoSlug': RepoSelector, + 'github.repoUpstream': RepoSelector, + 'github.requestPRReviewers': RequestPrReviewers, + 'github.rerunPRChecks': RerunPullRequestChecks, + 'github.resolveReviewThread': ReviewThread, + 'github.setPRAutoMerge': SetPrAutoMerge, + 'github.setPRCommentReaction': PRCommentReaction, + 'github.setPRFileViewed': PullRequestFileViewed, + 'github.updateIssue': UpdateIssue, + 'github.updatePR': UpdatePr, + 'github.updatePRState': UpdatePrState, + 'github.updatePRTitle': UpdatePrTitle, + 'github.workItem': WorkItem, + 'github.workItemByOwnerRepo': WorkItemByOwnerRepo, + 'github.workItemDetails': WorkItem, + 'gitlab.addIssueComment': AddIssueComment, + 'gitlab.addMRComment': AddMRComment, + 'gitlab.addMRInlineComment': AddMRInlineComment, + 'gitlab.createIssue': CreateIssueOfGitlabParams, + 'gitlab.diagnoseAuth': EmptyParams, + 'gitlab.jobTrace': JobTrace, + 'gitlab.listIssues': IssuesListOfGitlabParams, + 'gitlab.listLabels': RepoSelectorOfGitlabParams, + 'gitlab.listMRs': WorkItemsListOfGitlabParams, + 'gitlab.listWorkItems': WorkItemsListOfGitlabParams, + 'gitlab.mergeMR': MergeMr, + 'gitlab.rateLimit': GitLabRateLimit, + 'gitlab.resolveMRDiscussion': ResolveMRDiscussion, + 'gitlab.retryJob': RetryJob, + 'gitlab.todos': RepoSelectorOfGitlabParams, + 'gitlab.updateIssue': UpdateIssueOfGitlabParams, + 'gitlab.updateMR': UpdateMr, + 'gitlab.updateMRReviewers': UpdateMrReviewers, + 'gitlab.updateMRState': UpdateMrState, + 'gitlab.workItemByPath': WorkItemByPath, + 'gitlab.workItemDetails': WorkItemDetails, + 'host.gitBash.isAvailable': null, + 'host.platform': null, + 'host.pwsh.isAvailable': null, + 'host.wsl.isAvailable': null, + 'host.wsl.listDistros': null, + 'hostedReview.create': HostedReviewCreate, + 'hostedReview.createStacked': HostedReviewCreate, + 'hostedReview.forBranch': HostedReviewForBranch, + 'hostedReview.getCreationEligibility': HostedReviewCreationEligibility, + 'jira.addIssueComment': IssueCommentOfJiraParams, + 'jira.connect': Connect, + 'jira.createIssue': CreateIssueOfJiraParams, + 'jira.disconnect': SiteSelection, + 'jira.getIssue': IssueKey, + 'jira.getIssueStream': IssueKey, + 'jira.getProjectStatusOrder': ProjectStatusOrder, + 'jira.issueComments': IssueKey, + 'jira.issueCommentsStream': IssueKey, + 'jira.listAssignableUsers': AssignableUsers, + 'jira.listCreateFields': ProjectIssueTypeFields, + 'jira.listIssueTypes': ProjectIssueTypes, + 'jira.listIssues': ListIssues, + 'jira.listPriorities': SiteSelection, + 'jira.listProjects': SiteSelection, + 'jira.listTransitions': IssueKey, + 'jira.lookupIssueSummary': IssueKey, + 'jira.readStatus': null, + 'jira.searchIssues': SearchIssues, + 'jira.searchUsers': UserSearch, + 'jira.selectSite': SelectSite, + 'jira.status': null, + 'jira.testConnection': SiteSelection, + 'jira.updateIssue': IssueUpdate, + 'linear.addIssueComment': IssueCommentOfLinearParams, + 'linear.agentIssueList': LinearIssueList, + 'linear.agentProjectList': LinearProjectList, + 'linear.agentSearchIssues': AgentSearchIssues, + 'linear.agentTeamLabels': LinearTeamLookup, + 'linear.agentTeamList': LinearWorkspaceRead, + 'linear.agentTeamMembers': LinearTeamLookup, + 'linear.agentTeamStates': LinearTeamLookup, + 'linear.connect': ConnectOfLinearParams, + 'linear.createIssue': CreateIssueOfLinearParams, + 'linear.createProject': CreateProject, + 'linear.disconnect': WorkspaceSelection, + 'linear.getCustomView': CustomViewId, + 'linear.getIssue': IssueId, + 'linear.getProject': ProjectId, + 'linear.issueAddComment': LinearIssueAddComment, + 'linear.issueAttachLink': LinearIssueAttachLink, + 'linear.issueComments': LinearIssueCommentsParams, + 'linear.issueContext': AgentIssueContext, + 'linear.issueCreate': LinearIssueCreate, + 'linear.issueRelationWrite': LinearIssueRelationWrite, + 'linear.issueSetState': LinearIssueSetState, + 'linear.issueUpdateTask': LinearIssueUpdateTask, + 'linear.listCustomViewIssues': CustomViewContents, + 'linear.listCustomViewProjects': CustomViewContents, + 'linear.listCustomViews': ListCustomViews, + 'linear.listIssues': ListIssuesOfLinearIssueListParams, + 'linear.listProjectIssues': ProjectIssues, + 'linear.listProjects': ListProjects, + 'linear.listTeams': WorkspaceSelection, + 'linear.mcpListIssues': McpListIssues, + 'linear.resolveCurrentIssue': LinearCurrentContext, + 'linear.saveIssue': LinearSaveIssue, + 'linear.searchIssues': SearchIssuesOfLinearParams, + 'linear.selectWorkspace': SelectWorkspace, + 'linear.status': null, + 'linear.teamLabels': TeamId, + 'linear.teamMembers': TeamId, + 'linear.teamStates': TeamId, + 'linear.testConnection': WorkspaceSelection, + 'linear.updateIssue': IssueUpdateOfLinearParams, + 'markdown.readTab': ActivateTab, + 'markdown.saveTab': SaveMarkdownTab, + 'nativeChat.readSession': NativeChatSession, + 'nativeChat.subscribe': NativeChatSession, + 'nativeChat.unsubscribe': NativeChatUnsubscribe, + 'network.browserTunnel': BrowserNetworkTunnelAttachParams, + 'notifications.getMissedSince': NotificationGetMissedSinceParams, + 'notifications.registerPush': NotificationRegisterPushParams, + 'notifications.subscribe': NotificationsSubscribeParams, + 'notifications.unregisterPush': null, + 'notifications.unsubscribe': NotificationUnsubscribeParams, + 'orchestration.ask': AskParams, + 'orchestration.check': CheckParams, + 'orchestration.dispatch': DispatchParams, + 'orchestration.dispatchShow': DispatchShowParams, + 'orchestration.federationAck': FederationAckParams, + 'orchestration.federationAttachStart': FederationAttachStartParams, + 'orchestration.federationFleetSnapshot': FederationFleetSnapshotParams, + 'orchestration.federationImport': FederationImportParams, + 'orchestration.federationPull': FederationPullParams, + 'orchestration.federationRead': FederationReadParams, + 'orchestration.federationReadOutput': FederationOutputReadParams, + 'orchestration.federationRelease': FederationDispatchParams, + 'orchestration.federationShow': FederationDispatchParams, + 'orchestration.federationStop': FederationDispatchParams, + 'orchestration.gateCreate': GateCreateParams, + 'orchestration.gateList': GateListParams, + 'orchestration.gateResolve': GateResolveParams, + 'orchestration.inbox': InboxParams, + 'orchestration.reply': ReplyParams, + 'orchestration.requestShow': RequestShowParams, + 'orchestration.reset': ResetParams, + 'orchestration.run': RunParams, + 'orchestration.runCreate': RunCreateParams, + 'orchestration.runCurrent': RunCurrentParams, + 'orchestration.runList': RunListParams, + 'orchestration.runShow': RunShowParams, + 'orchestration.runStop': RunStopParams, + 'orchestration.runUse': RunUseParams, + 'orchestration.taskCreate': TaskCreateParams, + 'orchestration.taskList': TaskListParams, + 'orchestration.workerAbandon': WorkerDispatchParams, + 'orchestration.workerList': WorkerListParams, + 'orchestration.workerRead': WorkerReadParams, + 'orchestration.workerRelease': WorkerDispatchParamsOfOrchestrationWorkerReleaseSchemasParams, + 'orchestration.workerRetain': WorkerRetainParams, + 'orchestration.workerShow': WorkerDispatchParams, + 'orchestration.workerStart': WorkerStartParams, + 'orchestration.workerStop': WorkerDispatchParamsOfOrchestrationWorkerStopParams, + 'orchestration.workerTerminalUserInput': OrchestrationWorkerTerminalUserInputParams, + 'pairing.getEndpoints': PairingGetEndpointsParamsSchema, + 'pairing.provisionRelay': PairingProvisionRelayParamsSchema, + 'plugins.consent': pluginConsentRequestSchema, + 'plugins.invokeCommand': PluginInvokeCommandParams, + 'plugins.list': null, + 'plugins.panelAction': PluginsPanelActionParams, + 'plugins.readPanelEntry': PluginReadPanelEntryParams, + 'plugins.setEnabled': PluginSetEnabledParams, + 'preflight.check': PreflightCheck, + 'preflight.detectAgents': null, + 'preflight.detectRemoteAgents': PreflightDetectRemoteAgents, + 'preflight.detectRemoteWindowsTerminalCapabilities': + PreflightDetectRemoteWindowsTerminalCapabilities, + 'preflight.refreshAgents': null, + 'project.list': null, + 'project.update': ProjectUpdate, + 'projectGroup.create': ProjectGroupCreate, + 'projectGroup.delete': ProjectGroupSelector, + 'projectGroup.importNested': ProjectGroupImportNested, + 'projectGroup.list': null, + 'projectGroup.moveProject': ProjectGroupMoveProject, + 'projectGroup.scanNested': ProjectGroupScanNested, + 'projectGroup.update': ProjectGroupUpdate, + 'projectHostSetup.clone': ProjectHostSetupClone, + 'projectHostSetup.create': ProjectHostSetupCreate, + 'projectHostSetup.delete': ProjectHostSetupDelete, + 'projectHostSetup.list': null, + 'projectHostSetup.setupExistingFolder': ProjectHostSetupExistingFolder, + 'projectHostSetup.update': ProjectHostSetupUpdate, + 'repo.add': RepoPath, + 'repo.baseRefDefault': RepoSelector, + 'repo.clone': RepoClone, + 'repo.create': RepoCreate, + 'repo.gitAvailable': null, + 'repo.hooks': RepoSelector, + 'repo.hooksCheck': RepoSelector, + 'repo.issueCommandRead': RepoSelector, + 'repo.issueCommandWrite': RepoIssueCommandWrite, + 'repo.list': null, + 'repo.reorder': RepoReorder, + 'repo.rm': RepoSelector, + 'repo.saveSparsePreset': RepoSparsePresetSave, + 'repo.searchRefs': RepoSearchRefs, + 'repo.setBaseRef': RepoSetBaseRef, + 'repo.setupScriptImports': RepoSelector, + 'repo.show': RepoSelector, + 'repo.sparsePresets': RepoSelector, + 'repo.update': RepoUpdate, + 'runtime.clientCapabilities.update': ClientCapabilitiesUpdate, + 'runtime.clientEvents.subscribe': null, + 'runtime.clientEvents.unsubscribe': ClientEventsUnsubscribeParams, + 'session.tabs.activate': ActivateTab, + 'session.tabs.close': CloseTab, + 'session.tabs.closeLifecycle': CloseLifecycleTab, + 'session.tabs.createTerminal': CreateTerminalTab, + 'session.tabs.list': WorktreeTabSelector, + 'session.tabs.listAll': null, + 'session.tabs.move': MoveTab, + 'session.tabs.setTabProps': SetTabProps, + 'session.tabs.subscribe': WorktreeTabSelector, + 'session.tabs.subscribeAll': null, + 'session.tabs.unsubscribe': SessionTabsUnsubscribe, + 'session.tabs.unsubscribeAll': SessionTabsUnsubscribeAllParams, + 'session.tabs.updatePaneLayout': UpdatePaneLayout, + 'settings.get': null, + 'settings.getTerminalQuickCommands': null, + 'settings.mutateNativeChatSessionOptions': NativeChatSessionOptionsMutation, + 'settings.update': SettingsUpdate, + 'settings.updatePRBotAuthorOverride': PRBotAuthorOverrideUpdate, + 'settings.updateTerminalQuickCommands': TerminalQuickCommandsUpdate, + 'skills.beginUpload': SkillUploadBeginRequestSchema, + 'skills.cancelInstall': SkillsCancelInstallParams, + 'skills.cancelUpload': SkillUploadCommitRequestSchema, + 'skills.commitUpload': SkillUploadCommitRequestSchema, + 'skills.delete': SkillDeleteRequestSchema, + 'skills.discover': SkillsDiscoverParams, + 'skills.getInstallProgress': SkillsGetInstallProgressParams, + 'skills.install': SkillInstallRequestSchema, + 'skills.installBundle': SkillBundleInstallRequestSchema, + 'skills.listManagedInstalls': null, + 'skills.previewDelete': SkillDeleteRequestSchema, + 'skills.previewInstall': SkillInstallPreviewRequestSchema, + 'skills.removeInstall': SkillRemoveRequestSchema, + 'skills.share': AgentSkillShareRequestSchema, + 'skills.uploadChunk': SkillUploadChunkRequestSchema, + 'speech.dictation.cancel': DictationHandle, + 'speech.dictation.chunk': DictationChunk, + 'speech.dictation.finish': DictationHandle, + 'speech.dictation.setup': DictationSetup, + 'speech.dictation.start': DictationStart, + 'speech.models.delete': SpeechModelAction, + 'speech.models.download': SpeechModelAction, + 'speech.models.list': null, + 'ssh.connect': SshTarget, + 'ssh.getState': SshTarget, + 'ssh.listRemovedTargetLabels': null, + 'ssh.listTargetSummaries': null, + 'ssh.listTargets': null, + 'stats.summary': null, + 'status.get': null, + 'terminal.adoptOrphans': TerminalAdoptOrphans, + 'terminal.agentStatus': TerminalHandle, + 'terminal.clearBuffer': TerminalHandle, + 'terminal.close': TerminalHandle, + 'terminal.closeAll': TerminalCloseAll, + 'terminal.closeTab': TerminalHandle, + 'terminal.create': TerminalCreateParams, + 'terminal.createAgentSession': CreateAgentSessionParams, + 'terminal.ensureAgentSession': EnsureAgentSessionParams, + 'terminal.focus': TerminalFocus, + 'terminal.getAutoRestoreFit': TerminalGetAutoRestoreFitParams, + 'terminal.getDisplayMode': TerminalHandle, + 'terminal.inspectProcess': TerminalInspectProcess, + 'terminal.isRunningAgent': TerminalHandle, + 'terminal.list': TerminalListParams, + 'terminal.multiplex': TerminalMultiplex, + 'terminal.read': TerminalRead, + 'terminal.recoverPane': TerminalRecoverPane, + 'terminal.rename': TerminalRename, + 'terminal.resizeForClient': TerminalResizeForClient, + 'terminal.resolveActive': TerminalResolveActive, + 'terminal.resolveIdentity': TerminalHandle, + 'terminal.resolvePane': TerminalResolvePane, + 'terminal.restoreFit': TerminalHandle, + 'terminal.send': TerminalSend, + 'terminal.setAutoRestoreFit': TerminalSetAutoRestoreFit, + 'terminal.setDisplayMode': TerminalSetDisplayMode, + 'terminal.show': TerminalHandle, + 'terminal.sleep': TerminalCloseAll, + 'terminal.split': TerminalSplit, + 'terminal.stop': TerminalCloseAll, + 'terminal.stopExact': TerminalStopExact, + 'terminal.subscribe': TerminalSubscribe, + 'terminal.unsubscribe': TerminalUnsubscribe, + 'terminal.updateViewport': TerminalUpdateViewport, + 'terminal.wait': TerminalWait, + 'ui.get': null, + 'ui.recordFeatureInteraction': FeatureInteractionIdParam, + 'ui.set': UiUpdate, + 'updater.check': UpdaterCheckParams, + 'updater.download': null, + 'updater.getStatus': null, + 'updater.install': null, + 'workspacePorts.kill': WorkspacePortKillParams, + 'workspacePorts.scan': WorkspacePortScanParams, + 'worktree.activate': WorktreeActivate, + 'worktree.create': WorktreeCreate, + 'worktree.detectedList': WorktreeDetectedListParams, + 'worktree.forceDeleteBranch': WorktreeForceDeleteBranch, + 'worktree.lineageList': null, + 'worktree.list': WorktreeListParams, + 'worktree.listRetiredNames': WorktreeDetectedListParams, + 'worktree.persistSortOrder': WorktreeSortOrder, + 'worktree.prefetchCreateBase': WorktreePrefetchCreateBase, + 'worktree.ps': WorktreePsParams, + 'worktree.resolveMrBase': WorktreeResolveMrBase, + 'worktree.resolvePrBase': WorktreeResolvePrBase, + 'worktree.rm': WorktreeRemove, + 'worktree.set': WorktreeSet, + 'worktree.show': WorktreeSelectorOfWorktreeParams, + 'worktree.sleep': WorktreeSelectorOfWorktreeParams, + 'worktree.teardownMissingTerminals': WorktreeTeardownMissingTerminalsParams +} as const + +// Why: these methods bind a schema the shared contract cannot hold because its value +// graph reaches into src/main. Listing them keeps the gap visible instead of absent. +export const RPC_METHODS_WITHOUT_SHARED_PARAMS: readonly string[] = [ + 'emulator.install', + 'orchestration.send', + 'orchestration.taskUpdate' +] + +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. +export type RpcParams = + (typeof RPC_PARAMS_BY_METHOD)[Method] extends z.ZodType + ? z.output<(typeof RPC_PARAMS_BY_METHOD)[Method]> + : void diff --git a/src/shared/rpc-contract/runtime-client-capabilities-params.ts b/src/shared/rpc-contract/runtime-client-capabilities-params.ts new file mode 100644 index 00000000000..77f7914585b --- /dev/null +++ b/src/shared/rpc-contract/runtime-client-capabilities-params.ts @@ -0,0 +1,7 @@ +import { z } from 'zod' + +export const ClientCapabilitiesUpdate = z + .object({ + clientCapabilities: z.array(z.string().min(1).max(128)).max(64) + }) + .strict() diff --git a/src/shared/rpc-contract/session-tabs-params.ts b/src/shared/rpc-contract/session-tabs-params.ts new file mode 100644 index 00000000000..dfcdf60cca2 --- /dev/null +++ b/src/shared/rpc-contract/session-tabs-params.ts @@ -0,0 +1,7 @@ +import { z } from 'zod' + +export const SessionTabsUnsubscribeAllParams = z + .object({ + subscriptionId: z.string().min(1).optional() + }) + .nullish() diff --git a/src/shared/rpc-contract/session-tabs-schemas-params.ts b/src/shared/rpc-contract/session-tabs-schemas-params.ts new file mode 100644 index 00000000000..d04f5443477 --- /dev/null +++ b/src/shared/rpc-contract/session-tabs-schemas-params.ts @@ -0,0 +1,230 @@ +import { z } from 'zod' +import { RUNTIME_NAVIGATION_TARGETS } from '../runtime-navigation' +import { TAB_ACTIVATION_INTENTS } from '../tab-activation-intent' +import { OptionalBoolean } from './rpc-param-primitives' +import { sleepingAgentLaunchConfigSchema } from '../workspace-session-sleeping-agents' +import type { TuiAgent } from '../tui-agent' +import { isTuiAgent } from '../tui-agent-config' +import { MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH } from '../terminal-quick-commands' + +export const WorktreeTabSelector = z.object({ + worktree: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing worktree selector')) +}) + +export const SessionTabsUnsubscribe = WorktreeTabSelector.extend({ + subscriptionId: z.string().min(1).optional() +}) + +export const ActivateTab = WorktreeTabSelector.extend({ + tabId: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing tab id')), + leafId: z.string().max(128).optional(), + notifyClients: OptionalBoolean, + navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(), + // Why: absent means user intent, so clients that predate this field keep the + // tab-open wake gesture. Only 'automatic' may be refused for a slept pane. + intent: z.enum(TAB_ACTIVATION_INTENTS).optional() +}) + +export const CloseTab = ActivateTab.extend({ + // Why: optional preserves authenticated legacy user closes; lifecycle intent + // uses the additive evidence-bearing method instead. + reason: z.literal('user').optional() +}) + +export const CloseLifecycleTab = ActivateTab.extend({ + reason: z.enum(['pty-exit', 'cleanup']), + publicationEpoch: z.string().min(1).max(128), + terminal: z.string().min(1).max(256) +}) + +export type TerminalPaneLayoutNodeInput = + | { type: 'leaf'; leafId: string } + | { + type: 'split' + direction: 'horizontal' | 'vertical' + first: TerminalPaneLayoutNodeInput + second: TerminalPaneLayoutNodeInput + ratio?: number + } + +// Why: this schema parses UNTRUSTED remote-client input. A recursive zod parse +// of a deeply-nested tree would overflow the main-process stack, so validate +// iteratively with hard depth + node-count caps before building the typed value. +export const MAX_PANE_LAYOUT_DEPTH = 64 + +export const MAX_PANE_LAYOUT_NODES = 1024 + +export function parseTerminalPaneLayoutNode(value: unknown): TerminalPaneLayoutNodeInput | null { + // Iterative validate-then-build: first walk the raw tree with an explicit + // stack (no recursion) enforcing caps, then build bottom-up. + let nodeCount = 0 + const stack: { raw: unknown; depth: number }[] = [{ raw: value, depth: 0 }] + while (stack.length > 0) { + const { raw, depth } = stack.pop()! + if (depth > MAX_PANE_LAYOUT_DEPTH || ++nodeCount > MAX_PANE_LAYOUT_NODES) { + return null + } + if (typeof raw !== 'object' || raw === null) { + return null + } + const node = raw as Record + if (node.type === 'leaf') { + if (typeof node.leafId !== 'string' || node.leafId.length < 1 || node.leafId.length > 128) { + return null + } + continue + } + if (node.type === 'split') { + if (node.direction !== 'horizontal' && node.direction !== 'vertical') { + return null + } + if ( + node.ratio !== undefined && + (typeof node.ratio !== 'number' || + !Number.isFinite(node.ratio) || + node.ratio < 0 || + node.ratio > 1) + ) { + return null + } + stack.push({ raw: node.first, depth: depth + 1 }, { raw: node.second, depth: depth + 1 }) + continue + } + return null + } + return value as TerminalPaneLayoutNodeInput +} + +export const TerminalPaneLayoutNodeSchema = z + .unknown() + .transform((value) => parseTerminalPaneLayoutNode(value)) + .pipe( + z.custom((value) => value !== null, { + message: 'Invalid or too-deep pane layout tree' + }) + ) + +export const UpdatePaneLayout = WorktreeTabSelector.extend({ + tabId: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing tab id')), + root: z.union([z.null(), TerminalPaneLayoutNodeSchema]), + expandedLeafId: z.string().max(128).nullable().optional(), + titlesByLeafId: z.record(z.string(), z.string()).optional() +}) + +export const SetTabProps = WorktreeTabSelector.extend({ + tabId: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing tab id')), + // undefined = leave unchanged; null = clear color / unset. + color: z.string().max(64).nullable().optional(), + isPinned: z.boolean().optional(), + // undefined = leave unchanged; no "clear" semantic (absence means default 'terminal'). + viewMode: z.enum(['terminal', 'chat']).optional() +}) + +export const CreateTerminalTab = WorktreeTabSelector.extend({ + afterTabId: z.string().optional(), + targetGroupId: z.string().optional(), + command: z.string().optional(), + cwd: z.string().min(1).optional(), + env: z.record(z.string(), z.string()).optional(), + envToDelete: z.array(z.string().min(1).max(256)).max(32).optional(), + startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(), + launchConfig: sleepingAgentLaunchConfigSchema, + launchToken: z.string().min(1).max(128).optional(), + agent: z + .custom(isTuiAgent, { + message: 'Unknown agent preset' + }) + .optional(), + // Why: agent prompts must be quoted and injected for the host shell (native, + // WSL, or SSH) instead of pasted from the mobile client before the TUI is ready. + agentPrompt: z + .string() + .max(MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH) + .refine((value) => value.trim().length > 0, { message: 'Agent prompt cannot be empty' }) + .optional(), + // Why: `agent` is the legacy preset field; `launchAgent` is the launch-plan + // identity used when preserving resume config across runtime boundaries. + launchAgent: z + .custom(isTuiAgent, { + message: 'Unknown launch agent' + }) + .optional(), + viewMode: z.enum(['terminal', 'chat']).optional(), + activate: z.boolean().optional(), + select: z.boolean().optional(), + navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(), + // Why: idempotency key so a retried create (double-tap, reconnect replay) + // returns the in-flight operation instead of spawning a duplicate terminal. + clientMutationId: z.string().min(1).max(128).optional() +}).superRefine((value, context) => { + if (value.agentPrompt !== undefined && value.agent === undefined) { + context.addIssue({ + code: 'custom', + path: ['agentPrompt'], + message: 'Agent prompt requires an agent preset' + }) + } + if (value.agentPrompt !== undefined && value.command !== undefined) { + context.addIssue({ + code: 'custom', + path: ['agentPrompt'], + message: 'Agent prompt cannot be combined with a startup command' + }) + } +}) + +export const MoveTabBase = { + worktree: WorktreeTabSelector.shape.worktree, + tabId: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing tab id')), + targetGroupId: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing target group id')) +} as const + +export const MoveTab = z.discriminatedUnion('kind', [ + z + .object({ + ...MoveTabBase, + kind: z.literal('reorder'), + tabOrder: z.array(z.string().min(1)).min(1, 'Missing tab order') + }) + .strict(), + z + .object({ + ...MoveTabBase, + kind: z.literal('move-to-group'), + index: z.number().int().nonnegative().optional() + }) + .strict(), + z + .object({ + ...MoveTabBase, + kind: z.literal('split'), + splitDirection: z.enum(['left', 'right', 'up', 'down']) + }) + .strict() +]) + +export const SaveMarkdownTab = ActivateTab.extend({ + baseVersion: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing base version')), + content: z.string() +}) diff --git a/src/shared/rpc-contract/skills-params.ts b/src/shared/rpc-contract/skills-params.ts new file mode 100644 index 00000000000..53d7b769523 --- /dev/null +++ b/src/shared/rpc-contract/skills-params.ts @@ -0,0 +1,12 @@ +import { z } from 'zod' +import { SkillDiscoveryTargetSchema } from '../skills' + +export const SkillsGetInstallProgressParams = z + .object({ operationId: z.string().min(1).max(128) }) + .strict() + +export const SkillsCancelInstallParams = z + .object({ operationId: z.string().min(1).max(128) }) + .strict() + +export const SkillsDiscoverParams = SkillDiscoveryTargetSchema.default({}) diff --git a/src/shared/rpc-contract/speech-params.ts b/src/shared/rpc-contract/speech-params.ts new file mode 100644 index 00000000000..8cd0ea00a49 --- /dev/null +++ b/src/shared/rpc-contract/speech-params.ts @@ -0,0 +1,54 @@ +import { z } from 'zod' +import { OptionalString, requiredString } from './rpc-param-primitives' + +export const AUDIO_BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/ + +export const DICTATION_SAMPLE_RATE = 16_000 + +export const PCM_BYTES_PER_SAMPLE = 2 + +export const MAX_DICTATION_AUDIO_SECONDS = 5 + +export const MAX_DICTATION_AUDIO_CHUNK_BYTES = + DICTATION_SAMPLE_RATE * PCM_BYTES_PER_SAMPLE * MAX_DICTATION_AUDIO_SECONDS + +export const MAX_DICTATION_AUDIO_CHUNK_BASE64_LENGTH = + Math.ceil(MAX_DICTATION_AUDIO_CHUNK_BYTES / 3) * 4 + +export function isValidAudioBase64(value: string): boolean { + return value.length % 4 !== 1 && AUDIO_BASE64_PATTERN.test(value) +} + +export const DictationStart = z.object({ + dictationId: requiredString('Missing dictation ID'), + modelId: OptionalString +}) + +export const DictationChunk = z.object({ + dictationId: requiredString('Missing dictation ID'), + audioBase64: requiredString('Missing audio chunk') + // Why: feedMobileDictation decodes into Buffer + Float32Array; reject + // oversized chunks before allocation. This mirrors the mobile pending-audio budget. + .refine( + (value) => value.length <= MAX_DICTATION_AUDIO_CHUNK_BASE64_LENGTH, + 'Audio chunk is too large' + ) + // Why: Buffer.from(..., 'base64') silently drops malformed bytes; reject + // bad mobile audio chunks instead of feeding empty/corrupt PCM. + .refine(isValidAudioBase64, 'Audio chunk must be base64'), + sampleRate: z.number().finite().positive() +}) + +export const DictationHandle = z.object({ + dictationId: requiredString('Missing dictation ID') +}) + +export const SpeechModelAction = z.object({ + modelId: requiredString('Missing model ID') +}) + +export const DictationSetup = z.object({ + enabled: z.boolean().optional(), + modelId: OptionalString, + dictationMode: z.enum(['toggle', 'hold']).optional() +}) diff --git a/src/shared/rpc-contract/ssh-params.ts b/src/shared/rpc-contract/ssh-params.ts new file mode 100644 index 00000000000..77f336c991b --- /dev/null +++ b/src/shared/rpc-contract/ssh-params.ts @@ -0,0 +1,5 @@ +import { z } from 'zod' + +export const SshTarget = z.object({ + targetId: z.string().min(1) +}) diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts new file mode 100644 index 00000000000..7d2c73afb66 --- /dev/null +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -0,0 +1,246 @@ +import { z } from 'zod' +import { isAgentSessionId } from '../agent-session-record' +import { normalizeExecutionHostId } from '../execution-host' +import { + AGENT_SESSION_HISTORY_DIRECTIONS, + AGENT_SESSION_HISTORY_MAX_LIMIT +} from '../agent-session-wire' + +export const MAX_ID_LENGTH = 512 + +// Four Claude questions with all four generated choices occupy 610 chars when fully percent-encoded. +export const MAX_RESPONSE_OPTION_ID_LENGTH = 1024 + +export const MAX_PROMPT_BYTES = 256 * 1024 + +export const MAX_BLOCKS = 64 + +export const MAX_OPTION_LABEL = 512 + +export const SessionId = z + .string() + .max(MAX_ID_LENGTH) + .refine(isAgentSessionId, 'Invalid agent session id') + +export const Identifier = (message: string, maxLength = MAX_ID_LENGTH) => + z + .string() + .min(1, message) + .max(maxLength, message) + .refine((value) => value === value.trim(), message) + +export const JournalCursor = z + .object({ + epoch: Identifier('Invalid journal epoch'), + sequence: z.number().int().nonnegative() + }) + .strict() + +export const MutationEnvelope = z + .object({ + sessionId: SessionId, + clientOperationId: Identifier('Invalid client operation id'), + /** Null is the "must not exist yet" case; every other call fences. */ + expectedRuntimeFence: z.number().int().positive().nullable(), + payloadFingerprint: z + .string() + .regex(/^[0-9a-f]{64}$/, 'Payload fingerprint must be a sha256 hex digest') + }) + .strict() + +export const ProviderHandle = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('codex'), threadId: Identifier('Invalid thread id') }).strict(), + z + .object({ + kind: z.literal('claude'), + sessionId: Identifier('Invalid provider session id'), + leafUuid: Identifier('Invalid leaf uuid').nullable() + }) + .strict() +]) + +export const ExecutionHostId = z + .string() + .max(MAX_ID_LENGTH) + .transform((value) => normalizeExecutionHostId(value)) + .refine((value): value is NonNullable => value !== null, { + message: 'Invalid execution host id' + }) + +export const ExecutionLocation = z + .object({ + executionHostId: ExecutionHostId, + wslDistro: Identifier('Invalid WSL distro').nullable(), + workspaceId: Identifier('Invalid workspace id'), + workspaceKind: z.enum(['git-worktree', 'folder']) + }) + .strict() + +export const AccountHome = z + .object({ + variable: z.enum(['CLAUDE_CONFIG_DIR', 'CODEX_HOME']), + path: z.string().min(1).max(4096) + }) + .strict() + +export const AttachParams = z + .object({ + envelope: MutationEnvelope, + location: ExecutionLocation, + provider: z.enum(['codex', 'claude']), + agent: Identifier('Invalid agent'), + accountHome: AccountHome, + runtimeKind: z.enum(['native', 'tui']), + providerHandle: ProviderHandle + }) + .strict() + +/** An identity, and nothing the host would otherwise read off disk. A transcript path or account + * home here would let a client choose which file this host imports and which credential directory + * the provider child launches against; both are derived host-side from this id instead. */ +export const ResumeSource = z + .object({ + providerSessionId: Identifier('Invalid provider session id') + }) + .strict() + +export const CreateIntentParams = z + .object({ + envelope: MutationEnvelope, + worktree: Identifier('Invalid worktree selector'), + agent: z.enum(['claude', 'codex']), + resumeFrom: ResumeSource.optional() + }) + .strict() + +export const CreateParams = z.union([AttachParams, CreateIntentParams]) + +export const CreateSupportParams = z + .object({ + worktree: Identifier('Invalid worktree selector'), + agent: z.enum(['claude', 'codex']) + }) + .strict() + +/** Clients may only author user turns. Accepting an assistant or tool role here + * would let one client write words into the agent's mouth in another's + * timeline, and the provider — not the client — owns those. */ +export const SendBlock = z.discriminatedUnion('type', [ + z.object({ type: z.literal('text'), text: z.string() }).strict(), + z + .object({ + type: z.literal('image-ref'), + path: z.string().min(1).max(4096).optional(), + url: z.string().min(1).max(4096).optional(), + alt: z.string().max(MAX_OPTION_LABEL).optional() + }) + .strict() + .refine( + (value) => Boolean(value.path) !== Boolean(value.url), + 'Provide exactly one of path/url' + ) +]) + +export const SendParams = z + .object({ + envelope: MutationEnvelope, + retryUnknown: z.literal(true).optional(), + body: z + .object({ + kind: z.literal('message'), + role: z.literal('user'), + blocks: z.array(SendBlock).min(1).max(MAX_BLOCKS) + }) + .strict() + .refine( + (value) => Buffer.byteLength(JSON.stringify(value.blocks), 'utf8') <= MAX_PROMPT_BYTES, + 'Message is too large' + ) + }) + .strict() + +export const CancelParams = z + .object({ + envelope: MutationEnvelope, + turnId: Identifier('Invalid turn id'), + scope: z.literal('background-tasks').optional(), + taskId: Identifier('Invalid task id').optional() + }) + .strict() + .refine((value) => value.taskId === undefined || value.scope === 'background-tasks', { + message: 'A task id requires background-task scope' + }) + +export const RespondParams = z + .object({ + envelope: MutationEnvelope, + itemId: Identifier('Invalid item id'), + /** Compare-and-set: the revision the client had on screen. */ + expectedRevision: z.number().int().positive(), + optionId: Identifier('Invalid option id', MAX_RESPONSE_OPTION_ID_LENGTH) + }) + .strict() + +export const SetOptionParams = z + .object({ + envelope: MutationEnvelope, + key: Identifier('Invalid option key'), + value: z.string().max(MAX_OPTION_LABEL) + }) + .strict() + +export const HandoffParams = z + .object({ + envelope: MutationEnvelope, + direction: z.enum(['to-tui', 'to-native']), + mode: z.enum(['now', 'after-turn', 'stop-turn']), + action: z.enum(['start', 'cancel-queued', 'retry', 'recover']).optional() + }) + .strict() + +export const OptionsParams = z.object({ sessionId: SessionId }).strict() + +export const ConversationCommandParams = z + .object({ + envelope: MutationEnvelope, + command: z.enum(['clear', 'compact']) + }) + .strict() + +/** One surface's claim on one session. The id names the surface, not the client: two chat views + * looking at the same session are two holders, and either leaving must not release + * the other's. */ +export const HoldParams = z + .object({ sessionId: SessionId, holderId: Identifier('Invalid holder id') }) + .strict() + +export const HistoryParams = z + .object({ + sessionId: SessionId, + direction: z.enum(AGENT_SESSION_HISTORY_DIRECTIONS), + cursor: JournalCursor.optional(), + limit: z.number().int().positive().max(AGENT_SESSION_HISTORY_MAX_LIMIT).optional() + }) + .strict() + +export const SubscribeParams = z + .object({ sessionId: SessionId, cursor: JournalCursor.optional() }) + .strict() + +export const UnsubscribeParams = z + .object({ + sessionId: SessionId, + subscriptionId: Identifier('Invalid subscription id').optional() + }) + .strict() + +/** Read-only owner classification retained for restart safety; mutation handoff is separate. */ +export const HandoffStatusParams = z.object({ sessionId: SessionId }).strict() + +export const RewindParams = z + .object({ + envelope: MutationEnvelope, + itemId: Identifier('Invalid item id', 4096), + expectedEpoch: Identifier('Invalid journal epoch') + }) + .strict() diff --git a/src/shared/rpc-contract/task-resume-state-params.ts b/src/shared/rpc-contract/task-resume-state-params.ts new file mode 100644 index 00000000000..56ab022eb92 --- /dev/null +++ b/src/shared/rpc-contract/task-resume-state-params.ts @@ -0,0 +1,32 @@ +import { z } from 'zod' + +/** + * Tasks page-position state persisted through `ui.set`; mirrors `TaskResumeState`. + * + * This object is `.strict()` and sits behind `ui.set`'s field-level `.catch`, so a key + * a host predates makes that host drop the ENTIRE resume state — github and jira with + * it — and report success. Only add a field here when clients must agree on it across + * versions; per-device view preferences belong in client-local storage instead. + */ +export const TaskResumeState = z + .object({ + githubMode: z.enum(['items', 'project']).optional(), + githubItemsPreset: z.string().nullable().optional(), + githubItemsQuery: z.string().optional(), + githubProjectHiddenFieldIdsByView: z.record(z.string(), z.array(z.string())).optional(), + linearMode: z.enum(['issues', 'projects', 'views', 'in-orca']).optional(), + linearPreset: z.enum(['assigned', 'created', 'all', 'completed']).optional(), + linearQuery: z.string().optional(), + linearContext: z + .object({ + kind: z.enum(['project', 'view']), + id: z.string(), + workspaceId: z.string(), + model: z.enum(['issue', 'project']).optional() + }) + .strict() + .optional(), + jiraPreset: z.enum(['assigned', 'reported', 'all', 'done']).optional(), + jiraQuery: z.string().optional() + }) + .strict() diff --git a/src/shared/rpc-contract/terminal-orphan-params.ts b/src/shared/rpc-contract/terminal-orphan-params.ts new file mode 100644 index 00000000000..2a14c2b72c3 --- /dev/null +++ b/src/shared/rpc-contract/terminal-orphan-params.ts @@ -0,0 +1,105 @@ +import { z } from 'zod' +import type { TabGroupLayoutNode } from '../tab-types' +import { OptionalString, requiredString } from './rpc-param-primitives' +import { TerminalPaneLayoutNodeSchema } from './session-tabs-schemas-params' +import { isPtyIncarnationId } from '../pty-incarnation' +import type { PtyIncarnationId } from '../pty-incarnation' + +export function parseOrphanGroupLayout(value: unknown): TabGroupLayoutNode | null { + const stack: { value: unknown; depth: number }[] = [{ value, depth: 0 }] + let count = 0 + while (stack.length > 0) { + const current = stack.pop()! + if ( + current.depth > 64 || + ++count > 1_024 || + !current.value || + typeof current.value !== 'object' + ) { + return null + } + const node = current.value as Record + if (node.type === 'leaf') { + if ( + typeof node.groupId !== 'string' || + node.groupId.length < 1 || + node.groupId.length > 256 + ) { + return null + } + continue + } + if ( + node.type !== 'split' || + (node.direction !== 'horizontal' && node.direction !== 'vertical') || + (node.ratio !== undefined && + (typeof node.ratio !== 'number' || + !Number.isFinite(node.ratio) || + node.ratio < 0 || + node.ratio > 1)) + ) { + return null + } + stack.push( + { value: node.first, depth: current.depth + 1 }, + { value: node.second, depth: current.depth + 1 } + ) + } + return value as TabGroupLayoutNode +} + +export const TerminalOrphanGroupLayout = z + .unknown() + .transform(parseOrphanGroupLayout) + .pipe(z.custom((value) => value !== null, 'Invalid orphan group layout')) + +export const TerminalOrphanTopology = z.object({ + tabs: z + .array( + z.object({ + tabId: requiredString('Missing topology tab id').pipe(z.string().max(256)), + root: TerminalPaneLayoutNodeSchema, + activeLeafId: requiredString('Missing active leaf id').pipe(z.string().max(128)), + expandedLeafId: z.string().max(128).nullable() + }) + ) + .min(1) + .max(64), + groups: z + .array( + z.object({ + id: z.string().min(1).max(256), + activeTabId: z.string().min(1).max(256), + tabOrder: z.array(z.string().min(1).max(256)).min(1).max(64), + recentTabIds: z.array(z.string().min(1).max(256)).max(64).optional() + }) + ) + .min(1) + .max(64), + groupLayout: TerminalOrphanGroupLayout.optional() +}) + +export const TerminalOrphanIncarnationId = z.custom( + isPtyIncarnationId, + 'Invalid PTY incarnation' +) + +export const TerminalAdoptOrphans = z.object({ + worktree: requiredString('Missing worktree selector').pipe(z.string().max(32_768)), + expectedTopologyRevision: z.number().int().nonnegative(), + claims: z + .array( + z.object({ + terminal: requiredString('Missing terminal handle').pipe(z.string().max(256)), + ptyId: requiredString('Missing PTY id').pipe(z.string().max(8_192)), + incarnationId: TerminalOrphanIncarnationId, + tabId: requiredString('Missing tab id').pipe(z.string().max(256)), + leafId: requiredString('Missing leaf id').pipe(z.string().max(128)) + }) + ) + .min(1) + .max(64), + activeTabId: OptionalString.pipe(z.string().max(256).optional()), + activeGroupId: OptionalString.pipe(z.string().max(256).optional()), + topology: TerminalOrphanTopology.optional() +}) diff --git a/src/shared/rpc-contract/terminal-quick-command-params.ts b/src/shared/rpc-contract/terminal-quick-command-params.ts new file mode 100644 index 00000000000..2a38bf28d8f --- /dev/null +++ b/src/shared/rpc-contract/terminal-quick-command-params.ts @@ -0,0 +1,73 @@ +import { z } from 'zod' +import { + MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH, + MAX_QUICK_COMMAND_ID_LENGTH, + MAX_QUICK_COMMAND_LABEL_LENGTH, + MAX_QUICK_COMMAND_REPO_ID_LENGTH, + MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH, + normalizeTerminalQuickCommands, + supportsTerminalAgentQuickCommand +} from '../terminal-quick-commands' +import type { TerminalQuickCommand } from '../terminal-quick-command-types' + +export const TerminalQuickCommandScopeUpdate = z.discriminatedUnion('type', [ + z.object({ type: z.literal('global') }).strict(), + z + .object({ + type: z.literal('repo'), + repoId: z.string().max(MAX_QUICK_COMMAND_REPO_ID_LENGTH) + }) + .strict() +]) + +export const TerminalQuickCommandUpdateItem = z.union([ + z + .object({ + id: z.string().max(MAX_QUICK_COMMAND_ID_LENGTH), + label: z.string().max(MAX_QUICK_COMMAND_LABEL_LENGTH), + action: z.literal('terminal-command').optional(), + command: z.string().max(MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH), + appendEnter: z.boolean(), + scope: TerminalQuickCommandScopeUpdate.optional() + }) + .strict(), + z + .object({ + id: z.string().max(MAX_QUICK_COMMAND_ID_LENGTH), + label: z.string().max(MAX_QUICK_COMMAND_LABEL_LENGTH), + action: z.literal('agent-prompt'), + agent: z.custom(supportsTerminalAgentQuickCommand, { + message: 'Agent does not support prompt commands' + }), + prompt: z.string().max(MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH), + scope: TerminalQuickCommandScopeUpdate.optional() + }) + .strict() +]) + +export const TerminalQuickCommandsUpdate = z + .object({ + // Why: a single host-side mutation preserves unrelated desktop/mobile edits + // and avoids retransmitting the full ~240 KB list for every small change. + mutation: z.union([ + z + .object({ + type: z.literal('upsert'), + command: TerminalQuickCommandUpdateItem.transform( + (value) => normalizeTerminalQuickCommands([value])[0] + ).pipe( + z.custom((value) => value !== undefined, { + message: 'Quick command cannot be normalized' + }) + ) + }) + .strict(), + z + .object({ + type: z.literal('delete'), + id: z.string().min(1).max(MAX_QUICK_COMMAND_ID_LENGTH) + }) + .strict() + ]) + }) + .strict() diff --git a/src/shared/rpc-contract/terminal-stream-params.ts b/src/shared/rpc-contract/terminal-stream-params.ts new file mode 100644 index 00000000000..88506f63b47 --- /dev/null +++ b/src/shared/rpc-contract/terminal-stream-params.ts @@ -0,0 +1,40 @@ +import { z } from 'zod' +import { requiredString } from './rpc-param-primitives' +import { TerminalViewport } from './terminal-unary-params' + +export const TerminalHandle = z.object({ terminal: requiredString('Missing terminal handle') }) + +export const TerminalResizeForClient = z.discriminatedUnion('mode', [ + z.object({ + terminal: requiredString('Missing terminal handle'), + mode: z.literal('mobile-fit'), + cols: z.number().finite().positive(), + rows: z.number().finite().positive(), + clientId: requiredString('Missing client ID') + }), + z.object({ + terminal: requiredString('Missing terminal handle'), + mode: z.literal('restore'), + clientId: requiredString('Missing client ID') + }) +]) + +export const TerminalSubscribe = TerminalHandle.extend({ + client: z + .object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('desktop') + }) + .optional(), + viewport: TerminalViewport.optional(), + capabilities: z + .object({ + terminalBinaryStream: z.literal(1).optional(), + desktopViewportClaims: z.literal(1).optional(), + mobileInputLeaseOnly: z.literal(1).optional(), + writeUnavailable: z.literal(1).optional() + }) + .optional() +}) + +export const TerminalMultiplex = z.object({}) diff --git a/src/shared/rpc-contract/terminal-unary-params.ts b/src/shared/rpc-contract/terminal-unary-params.ts new file mode 100644 index 00000000000..e86bdf1fdc1 --- /dev/null +++ b/src/shared/rpc-contract/terminal-unary-params.ts @@ -0,0 +1,222 @@ +import { z } from 'zod' +import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives' +import { isTuiAgent } from '../tui-agent-config' +import { TERMINAL_PANE_SPLIT_SOURCES } from '../feature-education-telemetry' + +export const TerminalHandle = z.object({ + terminal: requiredString('Missing terminal handle'), + // Additive fence understood by newer hosts; legacy hosts safely ignore it. + expectedIncarnationId: requiredString('Missing PTY incarnation').optional() +}) + +export const TerminalFocus = TerminalHandle.extend({ + navigation: z.enum(['caller', 'host']).optional() +}) + +/** + * `terminal.inspectProcess` carries one member the sibling handle methods must not: whether the + * caller's answer decides something once, which is what licenses the host to pay for a process-table + * read. Extended rather than added to `TerminalHandle` so `clearBuffer`/`agentStatus`/`isRunningAgent` + * keep refusing an option they have no use for. + */ +export const TerminalInspectProcess = TerminalHandle.extend({ + // Additive request member understood by newer hosts; legacy hosts safely ignore it. + scanChildProcesses: z.boolean().optional() +}) + +export const TerminalListParams = z.object({ + worktree: OptionalString, + limit: OptionalFiniteNumber, + handles: z + .array(requiredString('Missing terminal handle').pipe(z.string().max(256))) + .max(64) + .optional(), + requireFreshPtyLiveness: z.boolean().optional(), + // Why: layouts are ~31% of a large listing and only the human CLI formatter + // reads them. Absent means "include" so pre-flag clients keep rendering them. + includeVisualLayouts: z.boolean().optional() +}) + +export const TerminalResolveActive = z.object({ + worktree: OptionalString, + /** Refuse instead of guessing when several leaves could be the caller's own terminal. */ + requireUnambiguous: z.boolean().optional() +}) + +export const TerminalResolvePane = z.object({ + paneKey: requiredString('Missing pane key'), + worktreeId: OptionalString +}) + +export const TerminalRecoverPane = z.object({ + paneKey: requiredString('Missing pane key'), + worktreeId: requiredString('Missing worktree ID'), + expectedTerminal: requiredString('Missing expected terminal handle').optional() +}) + +export const TerminalRead = TerminalHandle.extend({ + cursor: z + .unknown() + .transform((value) => { + if (value === undefined) { + return undefined + } + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { + return Number.NaN + } + return value + }) + .pipe( + z + .number() + .optional() + .refine((v) => v === undefined || Number.isFinite(v), { + message: 'Cursor must be a non-negative integer' + }) + ) + .optional(), + limit: OptionalFiniteNumber, + // Why: optional so an older host that does not understand it simply drops the key and answers + // with its usual stream read; the response's `source` is what tells the caller which it got. + screen: z.literal(true).optional() +}).refine((params) => !(params.screen === true && params.cursor !== undefined), { + // Why: a cursor pages through accumulated output; a screen is the current frame with nothing + // behind it. Honoring both would answer with rendered lines carrying the stream's pagination + // metadata — two frames of reference in one payload, which is the confusion `source` exists to + // remove. The CLI already refuses the pair, but the RPC is reachable without it. + message: 'Cursor cannot be combined with a screen read' +}) + +// Why: preserve the legacy contract — `title: string | null` only, `undefined` rejected, so the CLI's "reset" signal stays distinct. +export const TerminalRename = TerminalHandle.extend({ + title: z.custom((value) => value === null || typeof value === 'string', { + message: 'Missing --title (pass empty string or null to reset)' + }) +}) + +export const TerminalSend = TerminalHandle.extend({ + text: OptionalString, + enter: z.unknown().optional(), + interrupt: z.unknown().optional(), + // Why: older hosts strip this optional intent and retain their direct-send behavior. + agentPrompt: z.literal(true).optional(), + // Why: waiting observes the same prompt receipt; it never authorizes a second write. + waitSubmitMs: z.number().int().min(0).max(3_600_000).optional(), + resolvedLaunchDraft: z + .object({ + text: z.string(), + createdAt: z.number().finite() + }) + .optional(), + requireAgentStatus: z.enum(['sendable']).optional(), + // Why: terminal-generated replies are valid input but must not transfer the shared terminal floor. + inputKind: z.enum(['query-reply']).optional(), + // Why: identifies the caller for the driver state machine; when absent (older clients) the server falls back to the most recent mobile actor (docs/mobile-presence-lock.md). + client: z + .object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('desktop').optional() + }) + .optional(), + viewport: z + .object({ + cols: z.number().int().min(1).max(1000), + rows: z.number().int().min(1).max(500) + }) + .optional(), + claimViewport: z.literal(true).optional() +}) + +export const TerminalViewport = z.object({ + cols: z.number().int().min(1).max(1000), + rows: z.number().int().min(1).max(500) +}) + +export const TerminalWait = TerminalHandle.extend({ + for: z.custom<'exit' | 'tui-idle'>((value) => value === 'exit' || value === 'tui-idle', { + message: 'Invalid --for value. Supported: exit, tui-idle' + }), + timeoutMs: OptionalFiniteNumber +}) + +export const TerminalCreateParams = z.object({ + worktree: OptionalString, + clientMutationId: z.string().min(1).max(128).optional(), + reconcileExisting: z.boolean().optional(), + command: OptionalString, + startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(), + env: z.record(z.string(), z.string()).optional(), + envToDelete: z.array(z.string().min(1).max(256)).max(32).optional(), + launchConfig: z + .object({ + agentCommand: z.string().optional(), + agentArgs: z.string(), + agentEnv: z.record(z.string(), z.string()), + ompResumeFilePath: z + .string() + .min(1) + .max(32 * 1024) + .optional() + }) + .optional(), + resumeProviderSession: z + .object({ + key: z.enum(['session_id', 'conversation_id']), + id: z.string().min(1).max(512), + transcriptPath: z.string().min(1).max(32_768).optional() + }) + .optional(), + launchToken: OptionalString, + launchAgent: z.string().refine(isTuiAgent).optional(), + terminalColorQueryReplies: z + .object({ + foreground: z.string().max(128).optional(), + background: z.string().max(128).optional() + }) + .optional(), + title: OptionalString, + focus: z.unknown().optional(), + rendererBacked: z.unknown().optional(), + activate: z.unknown().optional(), + presentation: z.enum(['background', 'focused']).optional(), + tabId: OptionalString, + leafId: OptionalString +}) + +export const TerminalSplit = TerminalHandle.extend({ + direction: z + .unknown() + .transform((v) => (v === 'vertical' || v === 'horizontal' ? v : undefined)) + .pipe(z.union([z.enum(['vertical', 'horizontal']), z.undefined()])) + .optional(), + command: OptionalString, + env: z.record(z.string(), z.string()).optional(), + telemetrySource: z.enum(TERMINAL_PANE_SPLIT_SOURCES).optional() +}) + +export const TerminalStop = z.object({ + worktree: requiredString('Missing worktree selector') +}) + +export const TerminalCloseAll = TerminalStop + +export const TerminalSleep = TerminalStop + +export const TerminalStopExact = TerminalStop.extend({ + expectedPtyIds: z.array(requiredString('Missing PTY ID')).min(1), + keepHistory: z.boolean().optional(), + targetOnly: z.boolean().optional() +}) + +export const AgentTeamsTmuxCompat = z.object({ + teamId: requiredString('Missing agent team ID'), + token: requiredString('Missing agent team token'), + envPane: requiredString('Missing tmux pane identity'), + cwd: OptionalString, + argv: z.array(z.string()) +}) + +export const AgentTeamsPrepareLaunch = z.object({ + paneKey: requiredString('Missing pane key'), + env: z.record(z.string(), z.string()).optional() +}) diff --git a/src/shared/rpc-contract/terminal-viewport-methods-params.ts b/src/shared/rpc-contract/terminal-viewport-methods-params.ts new file mode 100644 index 00000000000..f0aba484f63 --- /dev/null +++ b/src/shared/rpc-contract/terminal-viewport-methods-params.ts @@ -0,0 +1,3 @@ +import { z } from 'zod' + +export const TerminalGetAutoRestoreFitParams = z.object({}) diff --git a/src/shared/rpc-contract/terminal-viewport-schemas-params.ts b/src/shared/rpc-contract/terminal-viewport-schemas-params.ts new file mode 100644 index 00000000000..aac38a1858e --- /dev/null +++ b/src/shared/rpc-contract/terminal-viewport-schemas-params.ts @@ -0,0 +1,51 @@ +import { z } from 'zod' +import { requiredString } from './rpc-param-primitives' + +export const TerminalHandle = z.object({ terminal: requiredString('Missing terminal handle') }) + +export const TerminalSetDisplayMode = TerminalHandle.extend({ + // Why: 'auto' = mobile drives dims while subscribed (desktop restores on last-leave); 'desktop' = no resize, mobile scales to fit. + mode: z.enum(['auto', 'desktop']), + // Why: identifies the caller for the driver state machine; optional for older mobile clients. + client: z + .object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('desktop').optional() + }) + .optional(), + // Why: carries the measured viewport so an 'auto' toggle on a viewport-less record can phone-fit instead of no-op'ing. + viewport: z + .object({ + cols: z.number().int().positive(), + rows: z.number().int().positive() + }) + .optional() +}) + +export const TerminalUnsubscribe = z.object({ + subscriptionId: requiredString('Missing subscription ID'), + // Why: lets the server rebuild the composite `${terminal}:${clientId}` cleanup key when older clients pass a bare subscriptionId (docs/mobile-presence-lock.md). + client: z + .object({ + id: requiredString('Missing client ID') + }) + .optional() +}) + +// Why: in-place update avoids an unsubscribe→resubscribe that flashed the lock banner and stranded the PTY at phone dims (docs/mobile-presence-lock.md). +export const TerminalUpdateViewport = TerminalHandle.extend({ + client: z.object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('mobile').optional() + }), + viewport: z.object({ + cols: z.number().int().min(20).max(240), + rows: z.number().int().min(8).max(120) + }), + claim: z.boolean().optional() +}) + +// Why: phone-fit auto-restore preference (docs/mobile-fit-hold.md); `null` = Indefinite, finite ms clamped to [5_000, 60min] server-side. +export const TerminalSetAutoRestoreFit = z.object({ + ms: z.number().nullable() +}) diff --git a/src/shared/rpc-contract/ui-update-value-tolerance-params.ts b/src/shared/rpc-contract/ui-update-value-tolerance-params.ts new file mode 100644 index 00000000000..1b8ca0c2cd4 --- /dev/null +++ b/src/shared/rpc-contract/ui-update-value-tolerance-params.ts @@ -0,0 +1,25 @@ +import type { z } from 'zod' + +/** + * `UiUpdate` rides App.tsx's debounced writer, so one drifted enum member used + * to fail the WHOLE batch and silently drop sidebar widths, filters and agent + * acks alongside it. Degrade instead: a value the schema cannot express is + * dropped from the payload and the rest of the batch still lands. Unknown KEYS + * stay a hard rejection — the parity assertions exist to catch those. + */ +export function tolerateUnknownValues(shape: TShape): TShape { + return Object.fromEntries( + Object.entries(shape).map(([key, schema]) => [ + key, + (schema as z.ZodType).catch(() => undefined) + ]) + ) as unknown as TShape +} + +/** Drops the `undefined` entries `tolerateUnknownValues` leaves behind, so a + * rejected value reads as absent rather than as an explicit clear. */ +export function omitUndefinedValues>(value: TValue): TValue { + return Object.fromEntries( + Object.entries(value).filter(([, entry]) => entry !== undefined) + ) as TValue +} diff --git a/src/shared/rpc-contract/updater-params.ts b/src/shared/rpc-contract/updater-params.ts new file mode 100644 index 00000000000..8020f126712 --- /dev/null +++ b/src/shared/rpc-contract/updater-params.ts @@ -0,0 +1,6 @@ +import { z } from 'zod' + +export const UpdaterCheckParams = z.object({ + includePrerelease: z.boolean().optional(), + includePerfPrerelease: z.boolean().optional() +}) diff --git a/src/shared/rpc-contract/workspace-cleanup-ui-params.ts b/src/shared/rpc-contract/workspace-cleanup-ui-params.ts new file mode 100644 index 00000000000..ebe35ff4826 --- /dev/null +++ b/src/shared/rpc-contract/workspace-cleanup-ui-params.ts @@ -0,0 +1,28 @@ +import { z } from 'zod' +import { normalizeWorkspaceCleanupBrowseState } from '../workspace-cleanup-browse-state' +import type { WorkspaceCleanupBrowseState } from '../workspace-cleanup-browse-state' + +export const WorkspaceCleanupDismissal = z.object({ + worktreeId: z.string(), + dismissedAt: z.number().finite(), + fingerprint: z.string(), + classifierVersion: z.number().finite(), + executionHostId: z.string().min(1).optional() +}) + +/** + * Deliberately unvalidated shape, then normalized: the filter groups must NOT be + * strict or enumerated here. A newer client sends filters this build has never + * heard of, and a per-field zod shape would reject the whole `ui.set` payload + * instead of persisting the parts the host does understand. The shared + * normalizer never throws and degrades field by field, so an older host narrows + * the state rather than refusing it. + */ +export const WorkspaceCleanupBrowse = z + .custom() + .transform((value) => normalizeWorkspaceCleanupBrowseState(value)) + +export const WorkspaceCleanup = z.object({ + dismissals: z.record(z.string(), WorkspaceCleanupDismissal), + browse: WorkspaceCleanupBrowse.optional() +}) diff --git a/src/shared/rpc-contract/workspace-ports-params.ts b/src/shared/rpc-contract/workspace-ports-params.ts new file mode 100644 index 00000000000..300f0e84fb1 --- /dev/null +++ b/src/shared/rpc-contract/workspace-ports-params.ts @@ -0,0 +1,12 @@ +import { z } from 'zod' +import { OptionalString, requiredNumber } from './rpc-param-primitives' + +export const WorkspacePortScanParams = z.object({ + repoId: OptionalString +}) + +export const WorkspacePortKillParams = z.object({ + repoId: OptionalString, + pid: requiredNumber('Missing process id'), + port: requiredNumber('Missing port') +}) diff --git a/src/shared/rpc-contract/worktree-create-params.ts b/src/shared/rpc-contract/worktree-create-params.ts new file mode 100644 index 00000000000..7c0b1f55fad --- /dev/null +++ b/src/shared/rpc-contract/worktree-create-params.ts @@ -0,0 +1,154 @@ +import { z } from 'zod' +import { WorkspaceLinkedItemSchema } from '../workspace-linked-item-schema' +import { TaskSourceContextSchema } from '../task-source-context-schema' +import { workspaceSourceSchema } from '../telemetry-events' +import { RUNTIME_NAVIGATION_TARGETS } from '../runtime-navigation' +import { sleepingAgentLaunchConfigSchema } from '../workspace-session-sleeping-agents' +import { isTuiAgent } from '../tui-agent-config' +import { + OptionalBoolean, + OptionalFiniteNumber, + OptionalString, + TriStateLinkedIssue +} from './rpc-param-primitives' +import { + AutomationWorkspaceProvenanceRequest, + CliWorkspaceProvenanceRequest, + OptionalTuiAgent, + assertLinkedWorkItemSourceContextMatch +} from './worktree-params' + +export const WorktreeCreate = z + .object({ + repo: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing repo selector')), + name: OptionalString, + /** Set by clients that fell back to a generated creature name. Absent means user-typed, so the + * host neither skips a retired candidate nor retires the name it lands on. */ + nameWasGenerated: z.boolean().optional(), + baseBranch: OptionalString, + compareBaseRef: OptionalString, + branchNameOverride: OptionalString, + linkedIssue: TriStateLinkedIssue, + linkedPR: TriStateLinkedIssue, + linkedLinearIssue: z.string().optional(), + linkedLinearIssueWorkspaceId: z.union([z.string(), z.null()]).optional(), + linkedLinearIssueOrganizationUrlKey: z.union([z.string(), z.null()]).optional(), + linkedGitLabMR: TriStateLinkedIssue, + linkedGitLabIssue: TriStateLinkedIssue, + linkedBitbucketPR: TriStateLinkedIssue, + linkedAzureDevOpsPR: TriStateLinkedIssue, + linkedGiteaPR: TriStateLinkedIssue, + linkedWorkItem: WorkspaceLinkedItemSchema.nullable().optional(), + linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(), + comment: OptionalString, + displayName: OptionalString, + displayNameKind: z.enum(['generated', 'user']).optional(), + telemetrySource: z + .unknown() + .transform((value) => { + const parsed = workspaceSourceSchema.safeParse(value) + return parsed.success ? parsed.data : undefined + }) + .optional(), + workspaceStatus: OptionalString, + manualOrder: OptionalFiniteNumber, + sparseCheckout: z + .object({ + directories: z.array(z.string()), + presetId: OptionalString + }) + .optional(), + pushTarget: z + .object({ + remoteName: z.string(), + branchName: z.string(), + remoteUrl: OptionalString + }) + .optional(), + runHooks: OptionalBoolean, + activate: OptionalBoolean, + // Why: activation on create is view intent, so it is addressed like worktree.activate. + // Contract: a paired desktop/web caller resolves to 'caller' and therefore receives NO + // activateWorktree event — it must reveal from this call's result, which carries setup, + // startup and defaultTabs. Pass an explicit target to opt into an all-surface reveal. + navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(), + parentWorkspace: OptionalString, + // Why: an app-selected parent is a manual action, not the CLI's `--parent-workspace` flag. + // Absent keeps the CLI provenance older clients rely on. + parentWorkspaceOrigin: z.literal('manual').optional(), + envParentWorkspace: OptionalString, + parentWorktree: OptionalString, + cwdParentWorktree: OptionalString, + noParent: OptionalBoolean, + callerTerminalHandle: OptionalString, + orchestrationContext: z + .object({ + parentWorktreeId: OptionalString, + orchestrationRunId: OptionalString, + taskId: OptionalString, + coordinatorHandle: OptionalString + }) + .optional(), + setupDecision: z + .unknown() + .transform((v) => + typeof v === 'string' && (v === 'run' || v === 'skip' || v === 'inherit') ? v : undefined + ) + .pipe(z.union([z.enum(['run', 'skip', 'inherit']), z.undefined()])) + .optional(), + // Why: some clients (e.g. desktop) pass a pre-built launch command so the + // first terminal pane launches the selected agent instead of an idle shell. + // Clients that can't quote for the host shell send `startupAgent` instead. + startupCommand: OptionalString, + startupEnv: z.record(z.string(), z.string()).optional(), + startupLaunchConfig: sleepingAgentLaunchConfigSchema, + startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(), + // Why: CLI clients should not hardcode agent launch quoting because SSH + // workspaces execute in a different shell than the client process. + startupAgent: OptionalTuiAgent, + startupPrompt: OptionalString, + // Why: task-driven mobile creates need desktop parity: the host chooses + // the same default/detected agent and drafts the linked issue/PR URL into it. + startupDraft: OptionalString, + createdWithAgent: z + .unknown() + .transform((value) => (isTuiAgent(value) ? value : undefined)) + .optional(), + // Why: mobile retries a create interrupted by a connection migration with the + // same key so the host dedupes instead of spawning a duplicate worktree. + clientMutationId: z.string().min(1).max(128).optional(), + automationProvenanceRequest: AutomationWorkspaceProvenanceRequest.optional(), + cliProvenanceRequest: CliWorkspaceProvenanceRequest.optional() + }) + .superRefine((params, ctx) => { + assertLinkedWorkItemSourceContextMatch(params, ctx) + if ((params.parentWorkspace || params.parentWorktree) && params.noParent === true) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose either one parent selector or --no-parent.' + }) + } + if (params.parentWorkspace && params.parentWorktree) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose either one parent selector or --no-parent.' + }) + } + if (params.startupPrompt !== undefined && params.startupAgent === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'startupPrompt requires startupAgent' + }) + } + }) + +export const WorktreePrefetchCreateBase = z.object({ + repo: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing repo selector')), + baseBranch: OptionalString +}) diff --git a/src/shared/rpc-contract/worktree-params.ts b/src/shared/rpc-contract/worktree-params.ts new file mode 100644 index 00000000000..00ed7627f9d --- /dev/null +++ b/src/shared/rpc-contract/worktree-params.ts @@ -0,0 +1,215 @@ +import { z } from 'zod' +import { normalizeExecutionHostId } from '../execution-host' +import { isTuiAgent } from '../tui-agent-config' +import type { TuiAgent } from '../tui-agent' +import { + OptionalBoolean, + OptionalFiniteNumber, + OptionalPlainString, + OptionalString, + TriStateLinkedIssue +} from './rpc-param-primitives' +import { RUNTIME_NAVIGATION_TARGETS } from '../runtime-navigation' +import { WorkspaceLinkedItemSchema } from '../workspace-linked-item-schema' +import { TaskSourceContextSchema } from '../task-source-context-schema' +import { isWorkspaceLinkedItemSourceContextMatch } from '../workspace-linked-item-source-context' + +export const OptionalExecutionHostId = z + .string() + .transform((value, ctx) => { + const hostId = normalizeExecutionHostId(value) + if (!hostId) { + ctx.addIssue({ code: 'custom', message: 'Invalid host id' }) + return z.NEVER + } + return hostId + }) + .optional() + +export const OptionalTuiAgent = z + .unknown() + .superRefine((value, ctx) => { + if (value !== undefined && !isTuiAgent(value)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Unknown TUI agent' }) + } + }) + .transform((value): TuiAgent | undefined => (isTuiAgent(value) ? value : undefined)) + .optional() + +export const AutomationWorkspaceProvenanceRequest = z.object({ + automationId: z.string(), + automationRunId: z.string(), + dispatchToken: z.string(), + createRequestId: z.string() +}) + +// Why no dispatch token (unlike automation provenance): this is a descriptive +// origin marker for sidebar filtering, not an authority grant. The host stamps +// createdAt itself so a client clock can't skew sort order. +export const CliWorkspaceProvenanceRequest = z.object({ + callerTerminalHandle: OptionalString +}) + +export const WorktreeListParams = z.object({ + repo: OptionalString, + limit: OptionalFiniteNumber +}) + +export const WorktreeDetectedListParams = z.object({ + repo: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing repo selector')) +}) + +export const WorktreeTeardownMissingTerminalsParams = WorktreeDetectedListParams.extend({ + worktreeIds: z.array(z.string().min(1)).max(10_000), + connectionId: z.string().nullable().optional() +}) + +export const WorktreePsParams = z.object({ + limit: OptionalFiniteNumber, + afterSnapshotId: z.string().min(1).max(128).nullable().optional(), + supportsWorktreeVisibilitySourceDefaults: z.literal(true).optional() +}) + +export const WorktreeSortOrder = z.object({ + orderedIds: z.array(z.string()) +}) + +export const WorktreeSelector = z.object({ + worktree: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing worktree selector')) +}) + +export const WorktreeActivate = WorktreeSelector.extend({ + notifyClients: OptionalBoolean, + navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional() +}) + +/** Shared by WorktreeCreate and WorktreeSet so the two error messages cannot drift. */ +export function assertLinkedWorkItemSourceContextMatch( + params: { + linkedWorkItem?: z.infer | null + linkedTaskSourceContext?: z.infer | null + }, + ctx: z.RefinementCtx +): void { + if ( + params.linkedWorkItem && + params.linkedTaskSourceContext && + !isWorkspaceLinkedItemSourceContextMatch(params.linkedWorkItem, params.linkedTaskSourceContext) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Linked work item and source context identities must match' + }) + } +} + +export const WorktreeSet = WorktreeSelector.extend({ + // Why: '' is the blanking contract — "fall back to the branch/folder name". + // OptionalString coerced it to undefined, so on remote/SSH hosts clearing the + // name was dropped here and the old name came back on the next refresh. + displayName: OptionalPlainString, + // Why: empty comments are meaningful metadata updates, so use the plain + // string parser instead of OptionalString's empty-as-undefined behavior. + comment: OptionalPlainString, + linkedIssue: TriStateLinkedIssue, + linkedPR: TriStateLinkedIssue, + suppressedGitHubPR: z.number().int().positive().nullable().optional(), + linkedLinearIssue: z.union([z.string(), z.null()]).optional(), + linkedLinearIssueWorkspaceId: z.union([z.string(), z.null()]).optional(), + linkedLinearIssueOrganizationUrlKey: z.union([z.string(), z.null()]).optional(), + linkedGitLabMR: TriStateLinkedIssue, + linkedGitLabIssue: TriStateLinkedIssue, + linkedBitbucketPR: TriStateLinkedIssue, + linkedAzureDevOpsPR: TriStateLinkedIssue, + linkedGiteaPR: TriStateLinkedIssue, + linkedWorkItem: WorkspaceLinkedItemSchema.nullable().optional(), + linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(), + isArchived: OptionalBoolean, + isUnread: OptionalBoolean, + isPinned: OptionalBoolean, + sortOrder: OptionalFiniteNumber, + manualOrder: OptionalFiniteNumber, + lastActivityAt: OptionalFiniteNumber, + createdAt: OptionalFiniteNumber, + sparseDirectories: z.array(z.string()).optional(), + sparseBaseRef: OptionalString, + sparsePresetId: OptionalString, + baseRef: OptionalString, + workspaceStatus: OptionalString, + pushTarget: z + .object({ + remoteName: z.string(), + branchName: z.string(), + remoteUrl: OptionalString + }) + .nullable() + .optional(), + diffComments: z.array(z.unknown()).optional(), + mobileDiffReview: z.unknown().optional(), + parentWorktree: OptionalString, + noParent: OptionalBoolean +}).superRefine((params, ctx) => { + assertLinkedWorkItemSourceContextMatch(params, ctx) + if (params.parentWorktree && params.noParent === true) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose either --parent-worktree or --no-parent, not both.' + }) + } +}) + +export const WorktreeRemove = WorktreeSelector.extend({ + hostId: OptionalExecutionHostId, + force: OptionalBoolean, + // Why (#11960): the CLI's --force is an unambiguous force affordance, but the + // desktop sets `force` for an ordinary confirmed delete too, so the PTY-stop + // waiver travels on its own field. + allowUnverifiedPtyStop: OptionalBoolean, + runHooks: OptionalBoolean +}) + +export const WorktreeForceDeleteBranch = WorktreeSelector.extend({ + hostId: OptionalExecutionHostId, + branchName: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing branch name')), + expectedHead: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing expected branch head')) +}) + +export const WorktreeResolvePrBase = z.object({ + repo: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing repo selector')), + prNumber: z + .unknown() + .transform((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)) + .pipe(z.number().int().positive('Missing PR number')), + headRefName: OptionalString, + baseRefName: OptionalString, + isCrossRepository: OptionalBoolean +}) + +export const WorktreeResolveMrBase = z.object({ + repo: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing repo selector')), + mrIid: z + .unknown() + .transform((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)) + .pipe(z.number().int().positive('Missing MR number')), + sourceBranch: OptionalString, + targetBranch: OptionalString, + isCrossRepository: OptionalBoolean +}) diff --git a/src/shared/rpc-contract/worktree-visibility-defaults-params.ts b/src/shared/rpc-contract/worktree-visibility-defaults-params.ts new file mode 100644 index 00000000000..c03199d78db --- /dev/null +++ b/src/shared/rpc-contract/worktree-visibility-defaults-params.ts @@ -0,0 +1,19 @@ +import { z } from 'zod' +import { + normalizeCustomWorktreeVisibilitySources, + normalizeWorktreeVisibilitySourcePreferences +} from '../worktree/visibility-sources' + +export const WorktreeVisibilityDefaultsUpdate = z + .object({ + external: z.enum(['hide', 'show']).optional(), + customSources: z + .unknown() + .transform((value) => normalizeCustomWorktreeVisibilitySources(value)) + .optional(), + sourcePreferences: z + .unknown() + .transform((value) => normalizeWorktreeVisibilitySourcePreferences(value)) + .optional() + }) + .strict() From 9b83f976f95b00c907de61026a0544d14dcd4254 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:24:22 -0700 Subject: [PATCH 06/17] feat(native-chat): describe slash commands from the provider's own report (#19928) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(native-chat): describe slash commands from the provider's own report The Claude session reports a description and argument hint for every command it can run, but the catalog kept only the name, so the `/` picker described the handful of commands our curated map covers and left the rest — `/goal` included — with a blank row. Carry `description`/`argumentHint` through the catalog and the session wire (both optional, so mixed-version hosts are unaffected), and let a reported description win over the curated one, which stays as the fallback for the name-only report shape. The curated maps are untouched, so structured dispatch still claims exactly the commands it claimed before. * feat(native-chat): show the reported argument hint in the slash picker `argumentHint` was carried to the renderer but nothing read it. Show it beside the command token — `/goal ` over the description — so a row says how the command is invoked, not just what it does. It sits at the row's existing 11px muted tier, subordinate to the description, and truncates in a min-width-0 flex row; the picker also caps the hint at 80 characters, so a provider cannot swamp the row. * fix(native-chat): normalize slash command descriptors consistently --------- Co-authored-by: Merge Sim --- .../claude-slash-command-catalog.test.ts | 147 +++++++++++++++++- .../claude/claude-slash-command-catalog.ts | 111 ++++++++++--- ...claude-structured-session-commands.test.ts | 13 +- .../NativeChatAutocompleteMenus.test.tsx | 41 +++++ .../NativeChatAutocompleteMenus.tsx | 9 +- .../native-chat/native-chat-picker-items.ts | 5 + src/shared/agent-session-wire.ts | 4 + src/shared/native-chat-slash-commands.test.ts | 35 +++++ src/shared/native-chat-slash-commands.ts | 12 +- 9 files changed, 346 insertions(+), 31 deletions(-) diff --git a/src/main/claude/claude-slash-command-catalog.test.ts b/src/main/claude/claude-slash-command-catalog.test.ts index 20d79f9f53d..f4374e52e4f 100644 --- a/src/main/claude/claude-slash-command-catalog.test.ts +++ b/src/main/claude/claude-slash-command-catalog.test.ts @@ -86,8 +86,8 @@ it('accepts descriptor reloads, removing old skills while retaining terminal fil } expect(catalog.observe(reload)).toBe(true) expect(catalog.commands).toEqual([ - { name: 'clear', kind: 'command' }, - { name: 'new-skill', kind: 'skill' } + { name: 'clear', kind: 'command', description: 'Clear' }, + { name: 'new-skill', kind: 'skill', description: 'New' } ]) expect(catalog.observe(reload)).toBe(false) expect(catalog.observe({ ...reload, commands: [] })).toBe(true) @@ -130,3 +130,146 @@ it('publishes classification becoming authoritative even when the name and kind expect(catalog.observe(init({ slash_commands: ['clear'], skills: [] }))).toBe(true) expect(catalog.commands).toEqual([{ name: 'clear', kind: 'command' }]) }) + +it('keeps the description and argument hint a descriptor report authored', () => { + const catalog = new ClaudeSlashCommandCatalog(undefined, { + commands: [ + { name: 'goal', description: 'Set or view the goal', argumentHint: '' }, + { name: 'quiet', description: '', argumentHint: '' } + ] + }) + expect(catalog.commands).toEqual([ + { + name: 'goal', + kind: 'command', + kindUnspecified: true, + description: 'Set or view the goal', + argumentHint: '' + }, + { name: 'quiet', kind: 'command', kindUnspecified: true } + ]) +}) + +it('bounds the row text a provider can put in the picker', () => { + const catalog = new ClaudeSlashCommandCatalog(undefined, { + commands: [ + { name: 'long', description: 'x'.repeat(201), argumentHint: 'y'.repeat(101) }, + { name: 'wrong-type', description: 42, argumentHint: { text: 'no' } }, + { name: 'blank', description: ' ' }, + { name: 'long-whitespace', description: `Visible${' '.repeat(201)}` }, + { name: 'wrapped', description: 'first line\n second line' } + ] + }) + expect(catalog.commands).toEqual([ + { name: 'long', kind: 'command', kindUnspecified: true }, + { name: 'wrong-type', kind: 'command', kindUnspecified: true }, + { name: 'blank', kind: 'command', kindUnspecified: true }, + { name: 'long-whitespace', kind: 'command', kindUnspecified: true }, + { + name: 'wrapped', + kind: 'command', + kindUnspecified: true, + description: 'first line second line' + } + ]) +}) + +it('does not let malformed descriptor names consume the command detail budget', () => { + const catalog = new ClaudeSlashCommandCatalog(undefined, { + commands: [ + ...Array.from({ length: 512 }, (_, index) => ({ + name: `invalid name ${index}`, + description: 'Rejected with its name' + })), + { name: 'goal', description: 'Set or view the goal', argumentHint: '' } + ] + }) + expect(catalog.commands).toEqual([ + { + name: 'goal', + kind: 'command', + kindUnspecified: true, + description: 'Set or view the goal', + argumentHint: '' + } + ]) +}) + +it('combines non-empty fields from duplicate descriptors without discarding earlier text', () => { + const catalog = new ClaudeSlashCommandCatalog(undefined, { + commands: [ + { name: 'goal', description: 'Set or view the goal' }, + { name: 'goal', argumentHint: '' } + ] + }) + expect(catalog.commands).toEqual([ + { + name: 'goal', + kind: 'command', + kindUnspecified: true, + description: 'Set or view the goal', + argumentHint: '' + } + ]) +}) + +it('carries descriptor text across the name-only stream init that classifies it', () => { + const catalog = new ClaudeSlashCommandCatalog(undefined, { + commands: [ + { name: 'clear', description: 'Clear conversation' }, + { name: 'ref-oss', description: 'A skill' } + ] + }) + expect(catalog.observe(init({ slash_commands: ['clear', 'ref-oss'], skills: ['ref-oss'] }))).toBe( + true + ) + expect(catalog.commands).toEqual([ + { name: 'clear', kind: 'command', description: 'Clear conversation' }, + { name: 'ref-oss', kind: 'skill', description: 'A skill' } + ]) +}) + +it('reports a description-only change and lets a later report drop the text', () => { + const catalog = new ClaudeSlashCommandCatalog(init({ slash_commands: ['clear'], skills: [] })) + expect(catalog.commands).toEqual([{ name: 'clear', kind: 'command' }]) + const changed = { + type: 'system', + subtype: 'commands_changed', + commands: [{ name: 'clear', description: 'Clear conversation history' }] + } + expect(catalog.observe(changed)).toBe(true) + expect(catalog.commands).toEqual([ + { name: 'clear', kind: 'command', description: 'Clear conversation history' } + ]) + expect(catalog.observe(changed)).toBe(false) + expect(catalog.observe({ ...changed, commands: [{ name: 'clear' }] })).toBe(true) + expect(catalog.commands).toEqual([{ name: 'clear', kind: 'command' }]) +}) + +it('describes nothing when the session reported names only', () => { + expect(readClaudeSlashCommands(init())).toEqual([ + { name: 'clear', kind: 'command' }, + { name: 'ref-oss', kind: 'skill' }, + { name: 'opsx:apply', kind: 'command' } + ]) + expect(new ClaudeSlashCommandCatalog(init()).commands).toEqual([ + { name: 'clear', kind: 'command' }, + { name: 'ref-oss', kind: 'skill' }, + { name: 'opsx:apply', kind: 'command' } + ]) +}) + +it('still hides terminal-only names however well the provider describes them', () => { + const catalog = new ClaudeSlashCommandCatalog(init()) + expect( + catalog.observe({ + type: 'system', + subtype: 'commands_changed', + commands: [ + { name: 'doctor', description: 'Diagnose the CLI install' }, + { name: 'ref-oss', description: 'A skill' } + ] + }) + ).toBe(true) + expect(catalog.commands).toEqual([{ name: 'ref-oss', kind: 'skill', description: 'A skill' }]) +}) diff --git a/src/main/claude/claude-slash-command-catalog.ts b/src/main/claude/claude-slash-command-catalog.ts index b1f65d93d50..2a4d91260f0 100644 --- a/src/main/claude/claude-slash-command-catalog.ts +++ b/src/main/claude/claude-slash-command-catalog.ts @@ -3,6 +3,16 @@ import type { AgentSessionSlashCommand } from '../../shared/agent-session-wire' // Stream init carries name arrays; control initialization and reloads carry descriptors. const MAX_COMMANDS = 512 const MAX_NAME_LENGTH = 200 +const MAX_DESCRIPTION_LENGTH = 200 +const MAX_ARGUMENT_HINT_LENGTH = 100 + +/** The provider's own row text for one command, absent when it reported none. */ +type CommandDetail = Pick + +function commandName(value: unknown): string | undefined { + const name = typeof value === 'string' ? value.trim() : '' + return name.length > 0 && name.length <= MAX_NAME_LENGTH && !/\s/u.test(name) ? name : undefined +} function names(value: unknown): string[] { if (!Array.isArray(value)) { @@ -13,20 +23,61 @@ function names(value: unknown): string[] { if (seen.size >= MAX_COMMANDS) { break } - const name = typeof entry === 'string' ? entry.trim() : '' - if (name.length > 0 && name.length <= MAX_NAME_LENGTH && !/\s/u.test(name)) { + const name = commandName(entry) + if (name !== undefined) { seen.add(name) } } return [...seen] } -function descriptorNames(value: unknown): string[] { - return names( - Array.isArray(value) - ? value.map((entry) => (entry !== null && typeof entry === 'object' ? entry.name : undefined)) - : [] - ) +/** A single picker row's worth of provider text: unusable values are dropped, not truncated. */ +function rowText(value: unknown, maxLength: number): string | undefined { + if (typeof value !== 'string' || value.length > maxLength) { + return undefined + } + const collapsed = value.replace(/\s+/gu, ' ').trim() + return collapsed.length > 0 && collapsed.length <= maxLength ? collapsed : undefined +} + +function descriptorCatalog(value: unknown): { + names: string[] + details: Map +} { + const names: string[] = [] + const seen = new Set() + const details = new Map() + if (!Array.isArray(value)) { + return { names, details } + } + for (const entry of value) { + if (seen.size >= MAX_COMMANDS) { + break + } + if (entry === null || typeof entry !== 'object') { + continue + } + const name = commandName(entry.name) + if (name === undefined) { + continue + } + if (!seen.has(name)) { + seen.add(name) + names.push(name) + } + const previous = details.get(name) + const description = previous?.description ?? rowText(entry.description, MAX_DESCRIPTION_LENGTH) + const argumentHint = + previous?.argumentHint ?? rowText(entry.argumentHint, MAX_ARGUMENT_HINT_LENGTH) + if (description === undefined && argumentHint === undefined) { + continue + } + details.set(name, { + ...(description === undefined ? {} : { description }), + ...(argumentHint === undefined ? {} : { argumentHint }) + }) + } + return { names, details } } function carriesCommandCatalog(message: Record): boolean { @@ -56,6 +107,7 @@ export class ClaudeSlashCommandCatalog { private hasSkillClassification = false private hidden = new Set() private commandNames = new Set() + private details = new Map() constructor(initMessage?: Record, initialization?: unknown) { // SessionStart can prove acquisition before the first stream init exists. @@ -65,11 +117,15 @@ export class ClaudeSlashCommandCatalog { 'commands' in initialization && Array.isArray(initialization.commands) ) { - this.entries = descriptorNames(initialization.commands).map((name) => ({ - name, - kind: 'command', - kindUnspecified: true - })) + const catalog = descriptorCatalog(initialization.commands) + this.details = catalog.details + this.entries = this.describe( + catalog.names.map((name) => ({ + name, + kind: 'command', + kindUnspecified: true + })) + ) } if (initMessage) { this.observe(initMessage) @@ -80,13 +136,18 @@ export class ClaudeSlashCommandCatalog { return this.entries } + /** Provider row text, carried across the name-only frames that never restate it. */ + private describe(entries: AgentSessionSlashCommand[]): AgentSessionSlashCommand[] { + return entries.map((entry) => ({ ...entry, ...this.details.get(entry.name) })) + } + /** True when this frame replaced the catalog with a different one. */ observe(message: Record): boolean { let next: AgentSessionSlashCommand[] if (carriesCommandCatalog(message)) { this.hasSkillClassification = true this.hidden = new Set(names(message.terminal_slash_commands)) - next = readClaudeSlashCommands(message) + next = this.describe(readClaudeSlashCommands(message)) this.commandNames = new Set( next.filter((entry) => entry.kind === 'command').map((entry) => entry.name) ) @@ -95,13 +156,17 @@ export class ClaudeSlashCommandCatalog { message.subtype === 'commands_changed' && Array.isArray(message.commands) ) { - next = descriptorNames(message.commands) - .filter((name) => !this.hidden.has(name)) - .map((name) => - this.hasSkillClassification - ? { name, kind: this.commandNames.has(name) ? 'command' : 'skill' } - : { name, kind: 'command', kindUnspecified: true } - ) + const catalog = descriptorCatalog(message.commands) + this.details = catalog.details + next = this.describe( + catalog.names + .filter((name) => !this.hidden.has(name)) + .map((name) => + this.hasSkillClassification + ? { name, kind: this.commandNames.has(name) ? 'command' : 'skill' } + : { name, kind: 'command', kindUnspecified: true } + ) + ) } else { return false } @@ -112,7 +177,9 @@ export class ClaudeSlashCommandCatalog { (entry, index) => entry.name === this.entries?.[index]?.name && entry.kind === this.entries?.[index]?.kind && - entry.kindUnspecified === this.entries?.[index]?.kindUnspecified + entry.kindUnspecified === this.entries?.[index]?.kindUnspecified && + entry.description === this.entries?.[index]?.description && + entry.argumentHint === this.entries?.[index]?.argumentHint ) ) { return false diff --git a/src/main/claude/claude-structured-session-commands.test.ts b/src/main/claude/claude-structured-session-commands.test.ts index 1b2fb6bde31..29f2bcf4aa7 100644 --- a/src/main/claude/claude-structured-session-commands.test.ts +++ b/src/main/claude/claude-structured-session-commands.test.ts @@ -55,8 +55,14 @@ it.each([ const adapter = adapterFor(claude) try { await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + const described = commands[0]?.description expect(adapter.readCommands('session-1')).toEqual( - commands.map(({ name }) => ({ name, kind: 'command', kindUnspecified: true })) + commands.map(({ name, description }) => ({ + name, + kind: 'command', + kindUnspecified: true, + ...(description ? { description } : {}) + })) ) expect(claude.connections[0].sent).toEqual([]) expect(claude.connections[0].calls.map(({ subtype }) => subtype)).toEqual([ @@ -70,7 +76,10 @@ it.each([ slash_commands: ['project:check'], skills: ['project:check'] }) - expect(adapter.readCommands('session-1')).toEqual([{ name: 'project:check', kind: 'skill' }]) + // The stream init classifies the name; the control seed's text survives it. + expect(adapter.readCommands('session-1')).toEqual([ + { name: 'project:check', kind: 'skill', ...(described ? { description: described } : {}) } + ]) } finally { await adapter.closeSession('session-1') } diff --git a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx index 88506c51838..60c70baee66 100644 --- a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx @@ -3,6 +3,8 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { NativeChatPickerMenu } from './NativeChatAutocompleteMenus' +import { buildNativeChatPickerItems } from './native-chat-picker-items' +import { sessionSlashCommandSuggestions } from '../../../../shared/native-chat-slash-commands' import type { ComposerAutocomplete } from './native-chat-composer-state' function autocomplete( @@ -128,6 +130,45 @@ describe('NativeChatPickerMenu', () => { expect(screen.getAllByText('No matching commands')).toHaveLength(2) }) + it('shows the argument hint the provider reported beside the command token', () => { + render( + ' + }, + { name: 'clear', kind: 'command' }, + { name: 'wordy', kind: 'command', argumentHint: `<${'a'.repeat(200)}>` } + ]), + [], + '', + '/' + ) + })} + activeIndex={0} + listboxId="picker" + onChoose={vi.fn()} + onRetry={vi.fn()} + /> + ) + const goal = screen.getByRole('option', { name: /goal/i }) + expect(goal.textContent).toContain('') + expect(goal.textContent).toContain('Set a goal and keep working until it is met') + // A command the report left hintless renders its row unchanged. + expect(screen.getByRole('option', { name: /clear/i }).textContent).toBe( + '/clearClear conversation history' + ) + // A hint long enough to swamp the row is capped before it reaches the DOM. + expect(screen.getByRole('option', { name: /wordy/i }).textContent).toBe( + `/wordy<${'a'.repeat(79)}` + ) + }) + it('announces a successful empty skill result distinctly from loading', () => { render( ) : null} - {item.token} + + {item.token} + {item.kind === 'command' && item.argumentHint ? ( + + {item.argumentHint} + + ) : null} + {item.description ? ( {item.description} ) : null} diff --git a/src/renderer/src/components/native-chat/native-chat-picker-items.ts b/src/renderer/src/components/native-chat/native-chat-picker-items.ts index 1a21a2461c6..4786fcbecb2 100644 --- a/src/renderer/src/components/native-chat/native-chat-picker-items.ts +++ b/src/renderer/src/components/native-chat/native-chat-picker-items.ts @@ -21,6 +21,8 @@ export type NativeChatPickerItem = /** Exactly what a pick inserts — the form the agent invokes. */ token: string description?: string + /** How the provider says the command is invoked, e.g. ``. */ + argumentHint?: string skillCollision: boolean } | { @@ -80,6 +82,9 @@ export function buildNativeChatPickerItems( name: command.name, token: `/${command.name}`, description: command.description ? sanitizePickerText(command.description, 240) : undefined, + argumentHint: command.argumentHint + ? sanitizePickerText(command.argumentHint, 80) + : undefined, skillCollision: sharedSigil && skillNames.has(command.name) }, stableOrder: index diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 546f995456c..b4ef57a6b91 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -311,6 +311,10 @@ export type AgentSessionSlashCommand = { kind: 'command' | 'skill' /** Membership is authoritative, but this provider report did not classify the name. */ kindUnspecified?: true + /** Provider-authored row text; absent when the report carried names only. */ + description?: string + /** Provider-authored argument sketch, e.g. ``. */ + argumentHint?: string } /** The provider's own command surface, read per session. Additive read-only diff --git a/src/shared/native-chat-slash-commands.test.ts b/src/shared/native-chat-slash-commands.test.ts index 32d3d2d8876..33219d8f7db 100644 --- a/src/shared/native-chat-slash-commands.test.ts +++ b/src/shared/native-chat-slash-commands.test.ts @@ -89,4 +89,39 @@ describe('a session that reports its own command surface', () => { it('splits skills out for the picker to group on its own', () => { expect(sessionReportedSkillNames(reported)).toEqual(['ref-oss']) }) + + it('prefers the description the session reported over the curated one', () => { + expect( + sessionSlashCommandSuggestions('claude', [ + { name: 'clear', kind: 'command', description: 'Wipe the transcript' }, + { name: 'goal', kind: 'command', description: 'Set or view the goal' }, + { name: 'compact', kind: 'command' } + ]) + ).toEqual([ + { name: 'clear', description: 'Wipe the transcript' }, + { name: 'goal', description: 'Set or view the goal' }, + { name: 'compact', description: 'Summarize and compact the conversation' } + ]) + }) + + it('keeps a reported description and argument hint the curated catalog never claims', () => { + expect( + sessionSlashCommandSuggestions('codex', [ + { + name: 'opsx:apply', + kind: 'command', + description: 'Apply the plan', + argumentHint: '', + kindUnspecified: true + } + ]) + ).toEqual([ + { + name: 'opsx:apply', + description: 'Apply the plan', + argumentHint: '', + kindUnspecified: true + } + ]) + }) }) diff --git a/src/shared/native-chat-slash-commands.ts b/src/shared/native-chat-slash-commands.ts index 9337e9c78f7..6360ca05f83 100644 --- a/src/shared/native-chat-slash-commands.ts +++ b/src/shared/native-chat-slash-commands.ts @@ -12,6 +12,8 @@ export type SlashCommandSuggestion = { name: string /** Optional one-line description for the suggestion row. */ description?: string + /** Provider-authored argument sketch, e.g. ``. */ + argumentHint?: string kindUnspecified?: true } @@ -93,9 +95,10 @@ export function getAgentSlashCommands(agent: AgentType): readonly SlashCommandSu } /** The command rows for a session that reports its own `/` surface. The report - * is the authority on WHICH commands exist; the curated catalog above is kept - * only as the description source for the names both know about. Skills are - * excluded — they render in the picker's own skills group. */ + * is the authority on WHICH commands exist and, when it carries one, on how a + * command is described; the curated catalog above only covers the names whose + * report is text-free. Skills are excluded — they render in the picker's own + * skills group. */ export function sessionSlashCommandSuggestions( agent: AgentType, reported: readonly AgentSessionSlashCommand[] @@ -106,10 +109,11 @@ export function sessionSlashCommandSuggestions( return reported .filter((entry) => entry.kind === 'command') .map((entry) => { - const description = described.get(entry.name) + const description = entry.description ?? described.get(entry.name) return { name: entry.name, ...(description ? { description } : {}), + ...(entry.argumentHint ? { argumentHint: entry.argumentHint } : {}), ...(entry.kindUnspecified ? { kindUnspecified: true as const } : {}) } }) From 3b99a59ea7606589e3af44ade61c07e151d83051 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:30:12 -0700 Subject: [PATCH 07/17] perf(terminal): stop spending reveal-restore frames on panes that replay nothing (#19972) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hidden-output restore queue drains one entry per 16 ms frame. An entry whose pane has since gone hidden, been disposed, or had its restore superseded hits a guard and returns without replaying anything — but it still consumed the frame, pushing the next on-screen pane back 16 ms per dead entry. The scheduled callback now reports whether it started a replay, and the drain walks past entries that report false within the same tick. Order is unchanged (strict FIFO) and the one-real-replay-per-frame pacing is unchanged; only the no-op entries stop costing a frame. --- .../hidden-output-restore-scheduler.test.ts | 83 +++++++++++++++++-- .../hidden-output-restore-scheduler.ts | 24 ++++-- .../hidden-output-restore-request.ts | 8 +- 3 files changed, 99 insertions(+), 16 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.test.ts b/src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.test.ts index 354fff67009..f2cbc042a20 100644 --- a/src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.test.ts +++ b/src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Mock } from 'vitest' import { cancelScheduledHiddenOutputRestore, @@ -6,6 +7,11 @@ import { scheduleHiddenOutputRestore } from './hidden-output-restore-scheduler' +/** A pane that actually replays scrollback when its turn comes. */ +const replaying = (): Mock<() => boolean> => vi.fn(() => true) +/** A pane whose guards decline — hidden, disposed, or superseded restore. */ +const declining = (): Mock<() => boolean> => vi.fn(() => false) + describe('hidden output restore scheduler', () => { beforeEach(() => { vi.useFakeTimers() @@ -19,7 +25,7 @@ describe('hidden output restore scheduler', () => { it('runs active restores immediately', () => { const target = {} - const requestRestore = vi.fn() + const requestRestore = replaying() scheduleHiddenOutputRestore(target, requestRestore, 'active') @@ -27,8 +33,8 @@ describe('hidden output restore scheduler', () => { }) it('spreads inactive restores across timer ticks', () => { - const firstRestore = vi.fn() - const secondRestore = vi.fn() + const firstRestore = replaying() + const secondRestore = replaying() scheduleHiddenOutputRestore({}, firstRestore, 'inactive') scheduleHiddenOutputRestore({}, secondRestore, 'inactive') @@ -46,8 +52,8 @@ describe('hidden output restore scheduler', () => { it('cancels pending inactive restore when a target is promoted', () => { const target = {} - const inactiveRestore = vi.fn() - const activeRestore = vi.fn() + const inactiveRestore = replaying() + const activeRestore = replaying() scheduleHiddenOutputRestore(target, inactiveRestore, 'inactive') scheduleHiddenOutputRestore(target, activeRestore, 'active') @@ -59,7 +65,7 @@ describe('hidden output restore scheduler', () => { it('can cancel pending inactive restores', () => { const target = {} - const requestRestore = vi.fn() + const requestRestore = replaying() scheduleHiddenOutputRestore(target, requestRestore, 'inactive') cancelScheduledHiddenOutputRestore(target) @@ -67,4 +73,69 @@ describe('hidden output restore scheduler', () => { expect(requestRestore).not.toHaveBeenCalled() }) + + it('does not charge a frame to panes that replay nothing', () => { + const hiddenPanes = [declining(), declining(), declining()] + const visibleRestore = replaying() + + for (const hiddenPane of hiddenPanes) { + scheduleHiddenOutputRestore({}, hiddenPane, 'inactive') + } + scheduleHiddenOutputRestore({}, visibleRestore, 'inactive') + + vi.advanceTimersByTime(16) + + for (const hiddenPane of hiddenPanes) { + expect(hiddenPane).toHaveBeenCalledTimes(1) + } + expect(visibleRestore).toHaveBeenCalledTimes(1) + }) + + it('keeps one replay per frame once a queued pane replays', () => { + const firstRestore = replaying() + const declined = declining() + const secondRestore = replaying() + + scheduleHiddenOutputRestore({}, firstRestore, 'inactive') + scheduleHiddenOutputRestore({}, declined, 'inactive') + scheduleHiddenOutputRestore({}, secondRestore, 'inactive') + + vi.advanceTimersByTime(16) + expect(firstRestore).toHaveBeenCalledTimes(1) + expect(declined).not.toHaveBeenCalled() + expect(secondRestore).not.toHaveBeenCalled() + + vi.advanceTimersByTime(16) + expect(declined).toHaveBeenCalledTimes(1) + expect(secondRestore).toHaveBeenCalledTimes(1) + }) + + it('drops declining panes instead of retrying them', () => { + const declined = declining() + + scheduleHiddenOutputRestore({}, declined, 'inactive') + vi.advanceTimersByTime(16) + vi.advanceTimersByTime(160) + + expect(declined).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + }) + + it('does not re-enter a pane queued by a restore that ran in the same drain', () => { + const requeued = declining() + const target = {} + const reschedulingRestore = vi.fn(() => { + scheduleHiddenOutputRestore(target, requeued, 'inactive') + return false + }) + + scheduleHiddenOutputRestore({}, reschedulingRestore, 'inactive') + vi.advanceTimersByTime(16) + + expect(reschedulingRestore).toHaveBeenCalledTimes(1) + expect(requeued).not.toHaveBeenCalled() + + vi.advanceTimersByTime(16) + expect(requeued).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.ts b/src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.ts index 909dfe29d59..df22453e59d 100644 --- a/src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.ts +++ b/src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.ts @@ -1,6 +1,7 @@ type HiddenOutputRestorePriority = 'active' | 'inactive' -type HiddenOutputRestoreRequest = () => void +/** Returns whether the pane actually started a replay; a guard-only return is free. */ +type HiddenOutputRestoreRequest = () => boolean type HiddenOutputRestoreEntry = { requestRestore: HiddenOutputRestoreRequest @@ -30,13 +31,22 @@ function scheduleInactiveRestoreDrain(): void { function drainInactiveRestoreQueue(): void { inactiveRestoreTimer = null - const next = inactiveRestoreQueue.entries().next() - if (next.done) { - return + // Why the loop: an entry whose pane went hidden, was disposed, or had its restore + // superseded replays nothing, so charging it a whole frame only delays the next + // on-screen pane. Still at most one real replay per frame; the skips are guard reads. + let remaining = inactiveRestoreQueue.size + while (remaining > 0) { + remaining -= 1 + const next = inactiveRestoreQueue.entries().next() + if (next.done) { + break + } + const [target, entry] = next.value + inactiveRestoreQueue.delete(target) + if (entry.requestRestore()) { + break + } } - const [target, entry] = next.value - inactiveRestoreQueue.delete(target) - entry.requestRestore() scheduleInactiveRestoreDrain() } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-request.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-request.ts index f245c9c16f2..08946421c5d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-request.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-request.ts @@ -68,7 +68,7 @@ export function bindHiddenOutputRestoreRequest(session: ConnectPanePtySession): // Why: resume can reveal many split panes at once; spread inactive replays across frames so xterm scrollback replay doesn't block return. scheduleHiddenOutputRestore( session.pane.terminal, - () => { + (): boolean => { session.hiddenOutputRestoreScheduled = false if ( session.disposed || @@ -80,9 +80,11 @@ export function bindHiddenOutputRestoreRequest(session: ConnectPanePtySession): session.hiddenOutputRestorePendingChunks.length === 0) || !shouldWritePtyOutputForeground(session.deps.isVisibleRef.current) ) { - return + // Why report false: nothing replayed here, so the scheduler can spend + // this frame on the next queued pane instead of on a hidden/stale one. + return false } - session.requestHiddenOutputRestoreIfNeeded({ bypassScheduler: true }) + return session.requestHiddenOutputRestoreIfNeeded({ bypassScheduler: true }) === true }, priority ) From 6c1580aebad6c0bf12637b5c8d45eb5d12fe7e16 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 10 Sep 2026 21:42:05 -0700 Subject: [PATCH 08/17] i18n: Make file reveal labels translatable (#20010) Convert hardcoded "Reveal in Finder", "Reveal in File Explorer", and "Open Containing Folder" labels to use i18n.translate() in three menu components. Add corresponding English locale entries so these platform-specific labels are now part of the translation system instead of untranslated strings. Co-authored-by: m4air --- .../editor/EditorPanelHeaderPath.tsx | 20 +++++++++++----- .../file-explorer-row-context-menu.tsx | 20 +++++++++++----- .../tab-bar/EditorFileTabContextMenu.tsx | 23 ++++++++++++++----- src/renderer/src/i18n/locales/en.json | 15 +++++++++--- 4 files changed, 57 insertions(+), 21 deletions(-) diff --git a/src/renderer/src/components/editor/EditorPanelHeaderPath.tsx b/src/renderer/src/components/editor/EditorPanelHeaderPath.tsx index f1d0ae5e7b6..eaa5db71209 100644 --- a/src/renderer/src/components/editor/EditorPanelHeaderPath.tsx +++ b/src/renderer/src/components/editor/EditorPanelHeaderPath.tsx @@ -20,11 +20,19 @@ const isMac = navigator.userAgent.includes('Mac') const isLinux = navigator.userAgent.includes('Linux') /** Platform-appropriate label: macOS -> Finder, Windows -> File Explorer, Linux -> Files */ -const revealLabel = isMac - ? 'Reveal in Finder' - : isLinux - ? 'Open Containing Folder' - : 'Reveal in File Explorer' +function getRevealLabel(): string { + return isMac + ? translate('auto.components.editor.EditorPanelHeader.revealInFinder', 'Reveal in Finder') + : isLinux + ? translate( + 'auto.components.editor.EditorPanelHeader.openContainingFolder', + 'Open Containing Folder' + ) + : translate( + 'auto.components.editor.EditorPanelHeader.revealInFileExplorer', + 'Reveal in File Explorer' + ) +} type EditorPanelHeaderPathProps = { activeFile: OpenFile @@ -196,7 +204,7 @@ export function EditorPanelHeaderPath({ {!isVirtualEditorTab && ( - {revealLabel} + {getRevealLabel()} )} diff --git a/src/renderer/src/components/right-sidebar/file-explorer-row-context-menu.tsx b/src/renderer/src/components/right-sidebar/file-explorer-row-context-menu.tsx index d669b53bf79..a9be313a436 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-row-context-menu.tsx +++ b/src/renderer/src/components/right-sidebar/file-explorer-row-context-menu.tsx @@ -43,11 +43,19 @@ const isMac = navigator.userAgent.includes('Mac') const isLinux = navigator.userAgent.includes('Linux') /** Platform-appropriate label: macOS → Finder, Windows → File Explorer, Linux → Files */ -const revealLabel = isMac - ? 'Reveal in Finder' - : isLinux - ? 'Open Containing Folder' - : 'Reveal in File Explorer' +function getRevealLabel(): string { + return isMac + ? translate('auto.components.right.sidebar.FileExplorerRow.revealInFinder', 'Reveal in Finder') + : isLinux + ? translate( + 'auto.components.right.sidebar.FileExplorerRow.openContainingFolder', + 'Open Containing Folder' + ) + : translate( + 'auto.components.right.sidebar.FileExplorerRow.revealInFileExplorer', + 'Reveal in File Explorer' + ) +} function stopRightButtonMenuSelection(event: React.PointerEvent): void { if (event.button !== 2) { @@ -290,7 +298,7 @@ export function FileExplorerRowContextMenu({ }} > - {revealLabel} + {getRevealLabel()} onStartRename(node)}> diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx index 1813265573f..a6df437feac 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx @@ -32,11 +32,22 @@ const isMac = navigator.userAgent.includes('Mac') const isLinux = navigator.userAgent.includes('Linux') /** Platform-appropriate label: macOS → Finder, Windows → File Explorer, Linux → Files */ -const revealLabel = isMac - ? 'Reveal in Finder' - : isLinux - ? 'Open Containing Folder' - : 'Reveal in File Explorer' +function getRevealLabel(): string { + return isMac + ? translate( + 'auto.components.tab.bar.EditorFileTabContextMenu.revealInFinder', + 'Reveal in Finder' + ) + : isLinux + ? translate( + 'auto.components.tab.bar.EditorFileTabContextMenu.openContainingFolder', + 'Open Containing Folder' + ) + : translate( + 'auto.components.tab.bar.EditorFileTabContextMenu.revealInFileExplorer', + 'Reveal in File Explorer' + ) +} type EditorFileTabContextMenuProps = { open: boolean @@ -251,7 +262,7 @@ export function EditorFileTabContextMenu({ }} > - {revealLabel} + {getRevealLabel()} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 607359d1b71..fb1f37eab9b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3368,7 +3368,10 @@ "1d04b1630b": "Split Down", "6b3efb106e": "Split Up", "fdd29eb669": "Pin Tab", - "8e9d603a09": "Unpin Tab" + "8e9d603a09": "Unpin Tab", + "revealInFinder": "Reveal in Finder", + "openContainingFolder": "Open Containing Folder", + "revealInFileExplorer": "Reveal in File Explorer" }, "QuickLaunchButton": { "348a04c1ad": "Agent settings…", @@ -11976,7 +11979,10 @@ "3161c4e425": "folder", "e887fa4b2e": "Open in Terminal", "1d8e182c32": "View File", - "clipboardStagingUnavailable": "Could not copy the file because Orca's temporary storage is unavailable" + "clipboardStagingUnavailable": "Could not copy the file because Orca's temporary storage is unavailable", + "revealInFinder": "Reveal in Finder", + "openContainingFolder": "Open Containing Folder", + "revealInFileExplorer": "Reveal in File Explorer" }, "FileExplorerToolbar": { "d238264654": "Show Git Ignored Files", @@ -14676,7 +14682,10 @@ "f0fd4174b5": "Open file tab to use rich markdown editing", "a10d9b8337": "Open file", "2076ecfc9c": "Previous change", - "631dab0df3": "Next change" + "631dab0df3": "Next change", + "revealInFinder": "Reveal in Finder", + "openContainingFolder": "Open Containing Folder", + "revealInFileExplorer": "Reveal in File Explorer" }, "EditorPanelMarkdownActionsMenu": { "3e0ce48c24": "Export as PDF", From 1798786d4e846e23582918aae645963a8ffed792 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:52:21 -0700 Subject: [PATCH 09/17] perf(native-chat): mount only the transcript rows near the viewport (#19869) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(native-chat): share one row-content derivation between row and list Windowing needs the list and the row to agree on which messages draw nothing: a row the list counts but the row declines to render would reserve estimated height for an empty slot. Extracts the block derivation out of NativeChatMessageRow into a module cached on the block array, so a streaming turn pays for it once per revision rather than once per consumer. * refactor(native-chat): keep an opened tool run open past its row's lifetime A tool run, tool line or diff card the reader opened is state they created, but it lives in the component's own `useState`. That is fine while every row is mounted forever. It stops being fine the moment rows can be unmounted: the run silently re-collapses behind the reader's back. Rows now read their disclosure from a transcript-level map when one is provided and fall back to their own state when they are rendered standalone. The controls that re-sync a run — the toolbar's expand-all, a turn's disclosure, a diff reveal — are folded into the key the choice is remembered under, so a control flip reads as "nothing recorded yet" and the new default stands without a mid-render write to a map an ancestor owns. `ToolLine` moves to its own file; the run was over the line cap with it. * perf(native-chat): mount only the transcript rows near the viewport A settled transcript mounts every row it has ever loaded, so the cost of opening a conversation grows with its length even though only a screenful is legible. Rows near the viewport are now the only ones in the document; the rest are reserved as estimated height and measured when they arrive. Four things had to change for that to be safe: - `zoom` moves from the transcript column onto the scroll container. Item measurements are in the zoomed content's pixels while `scrollTop` is not, so with the two split across the boundary the window's arithmetic was off by exactly the font scale — correct at the top of a transcript and blank deep inside it. The column's padding moves to a new inner element to keep the layout it had. This does mean the scrollbar itself zooms with the text. - The three siblings that made up a row — the message, the turn status, the turn's diff rollup — move into one wrapper that carries the spacing they used to take from the column. The spacing between rows is the window's `gap`, never the height estimate, which would otherwise be counted twice. - Messages that draw nothing no longer take a slot. Counted but undrawn, each one would reserve estimated height for a row that never appears. - Paging in older history is driven by scroll events alone. Every row that resolves its real height moves the content and re-fires the size observers, so the old "am I near the top?" test would have asked for another page once per measurement. It now also requires the view to have moved upwards and requires new items since the last request. Anchoring is the virtualizer's: `anchorTo: 'end'` re-resolves the row at the current offset across a count change, which replaces the hand-rolled prepend anchor, and `followOnAppend` keeps a reader at the bottom pinned there. The document-level bottom pin stays, because the typing indicator, the activity line and the column's end padding all live past the last row. Revealing a diff from a turn rollup can target a row that isn't mounted, so that row is pinned into the window and the card still reports its own position — a turn that touched four files lands on the one that was asked for. * fix(native-chat): let a pinned row reach the mounted window Two faults the windowing tests turned up, plus the handles they needed. The virtualizer memoizes its mounted index list on the range extractor's identity. Holding that identity stable — which is right for the measurement memo, and was the reason it was written that way — meant a row pinned after the fact was never picked up: revealing a diff in a row the window had left behind pointed at a row that stayed unmounted. The extractor now changes identity with the pinned set, which is not a dependency of the measurement memo, so nothing expensive is rebuilt. The offset a row sits at is read off the `offsetParent` chain, with a rect-based fallback for the case where there is none. Using that fallback for the window's own scroll margin was wrong in kind: with no layout to measure, it returns the scroll position itself, so the margin tracked the offset and the window sat at the top of the transcript wherever the reader scrolled. The margin now takes the offset chain or nothing; the fallback stays where it belongs, on the reveal. The scroll root and the window's spacer are named, so measurement can find the scroll root without depending on which utility class makes it scroll, and so a test can tell a window from a whole transcript. * test(native-chat): cover the windowed transcript, and prove the window engaged The integration harness stubs `offsetHeight` — on the scroll root and on every row — because that is what the virtualizer measures with, and a DOM without layout answers zero to all of it. Rows report the height their own estimate predicted, which keeps the reserved totals exact no matter which rows have been mounted long enough to be measured. Every case reads the window through one helper that refuses to pass when there is no window. Without that, raising the usability gate would send all of them down the whole-transcript path, where "fewer rows mounted than messages" is false but every other assertion still holds — and they would go on reporting green while covering nothing. Reserved height is asserted as an exact total rather than "greater than zero", which a degenerate empty window also satisfies, and the mounted range is asserted to bracket the offset rather than merely to be smaller than the transcript. Covered: the window mounts a subset and moves with the reader; the newest row and a reveal's target stay mounted from outside it; an opened tool run is still open when its row comes back; a message that draws nothing takes no slot; and the scroll root with no usable height still renders every row as a direct child of the transcript column. What the environment cannot show is stated where it matters rather than faked: its ResizeObserver never fires and a scroll assignment emits no event, so measurement settling, the bottom pin under a streaming turn, prepend anchoring and smooth scrolling are covered as pure decisions — height estimation, the pinned set, range extraction, and whether a position should page in older history — and left to a real renderer as behaviour. * docs(native-chat): say that one offset path does read rects * test(native-chat): pin the window against a row that grows in place Whole-message appends were covered; a row being replaced by a taller version of itself — what a streaming reply is — was not. The existing windowing harness gains two things it needs to see that: a scroll root with a real document (a height, a viewport, and a scrollTop that clamps), and a resize observer that delivers when a target's height actually changed, since happy-dom's never fires and nothing re-measures without it. Frame by frame, while one row grows from 24px to 6358px: the view stays 0px from the bottom, the row stays mounted, and the reserved total tracks the measurement rather than the estimate. A reader who scrolls up mid growth keeps the exact offset they chose for the rest of it. * test(native-chat): guard history prepend anchoring * test(native-chat): strengthen prepend anchor contract * fix(native-chat): preserve provider tool call identity * fix(native-chat): harden transcript windowing lifecycle * test(native-chat): install virtualizer viewport for turn timing * fix(native-chat): reject blank tool call identities --------- Co-authored-by: Merge Sim --- .../claude-structured-item-translation.ts | 1 + ...ude-structured-journal-translation.test.ts | 3 + .../codex-structured-item-translation.test.ts | 13 + .../codex-structured-item-translation.ts | 4 + .../transcript-line-decoders-codex.ts | 5 +- ...anscript-reader-codex-history-mode.test.ts | 2 +- .../native-chat/transcript-reader.test.ts | 5 +- .../native-chat/transcript-record-blocks.ts | 3 +- .../native-chat/NativeChatDiffCard.tsx | 19 +- ...hatMessageList.stream-render.perf.test.tsx | 8 +- ...eChatMessageList.task-list-frames.test.tsx | 8 +- .../NativeChatMessageList.test.tsx | 8 +- ...eChatMessageList.tool-stream-cost.test.tsx | 8 +- .../native-chat/NativeChatMessageList.tsx | 383 +++++------ ...ativeChatMessageList.turn-history.test.tsx | 8 +- ...NativeChatMessageList.turn-timing.test.tsx | 11 +- .../NativeChatMessageList.windowing.test.tsx | 640 ++++++++++++++++++ .../native-chat/NativeChatMessageRow.tsx | 48 +- .../native-chat/NativeChatToolLine.tsx | 139 ++++ .../NativeChatToolRun.identity.test.tsx | 109 ++- .../native-chat/NativeChatToolRun.tsx | 193 ++---- .../native-chat/NativeChatTranscriptItems.tsx | 50 ++ .../native-chat/NativeChatTranscriptRow.tsx | 86 +++ .../native-chat-autoscroll.test.ts | 45 ++ .../native-chat/native-chat-autoscroll.ts | 35 + .../native-chat-disclosure-store.ts | 71 ++ .../native-chat-message-list-test-viewport.ts | 26 + .../native-chat-pinned-rows.test.ts | 46 ++ .../native-chat/native-chat-pinned-rows.ts | 57 ++ .../native-chat/native-chat-row-content.ts | 61 ++ .../native-chat-row-height-estimate.test.ts | 147 ++++ .../native-chat-row-height-estimate.ts | 126 ++++ .../native-chat-transcript-slots.test.ts | 111 +++ .../native-chat-transcript-slots.ts | 119 ++++ .../use-native-chat-transcript-scroll.ts | 145 ++++ ...ve-chat-transcript-window.options.test.tsx | 121 ++++ .../use-native-chat-transcript-window.ts | 230 +++++++ .../agent-session-journal-schemas.test.ts | 23 +- src/shared/agent-session-journal-schemas.ts | 7 + src/shared/agent-session-journal-types.ts | 2 + src/shared/native-chat-types.ts | 2 + ...tructured-agent-session-projection.test.ts | 1 + .../structured-agent-session-projection.ts | 1 + ...native-chat-history-prepend-anchor.spec.ts | 204 ++++++ 44 files changed, 2912 insertions(+), 422 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatToolLine.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatTranscriptItems.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-disclosure-store.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-message-list-test-viewport.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-pinned-rows.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-pinned-rows.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-row-content.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-row-height-estimate.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-row-height-estimate.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-transcript-slots.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts create mode 100644 tests/e2e/native-chat-history-prepend-anchor.spec.ts diff --git a/src/main/claude/claude-structured-item-translation.ts b/src/main/claude/claude-structured-item-translation.ts index 1c1673b59cd..0fdb231f6ef 100644 --- a/src/main/claude/claude-structured-item-translation.ts +++ b/src/main/claude/claude-structured-item-translation.ts @@ -179,6 +179,7 @@ export function claudeToolBody(input: { kind: 'tool-call', name: input.tool.name, input: input.tool.input, + callId: input.tool.id, state: input.result ? (input.result.failed ? 'failed' : 'completed') : 'running', ...(input.result ? { output: boundInlineText(input.result.output, DEFAULT_JOURNAL_PAYLOAD_LIMITS).bounded } diff --git a/src/main/claude/claude-structured-journal-translation.test.ts b/src/main/claude/claude-structured-journal-translation.test.ts index 050fccc42b6..44d7e3a1251 100644 --- a/src/main/claude/claude-structured-journal-translation.test.ts +++ b/src/main/claude/claude-structured-journal-translation.test.ts @@ -528,6 +528,7 @@ describe('Claude structured journal translation', () => { expect(keyed.get('orca:claude-tool%3Aclaude-session%3Atool-1')).toMatchObject({ kind: 'tool-call', name: 'Bash', + callId: 'tool-1', state: 'completed', output: { head: 'a.ts\nb.ts', truncated: false } }) @@ -546,6 +547,7 @@ describe('Claude structured journal translation', () => { expect(state.items.at(-1)?.body).toMatchObject({ kind: 'tool-call', name: 'tool', + callId: 'tool-1', input: null, output: { head: 'done again' } }) @@ -606,6 +608,7 @@ describe('Claude structured journal translation', () => { ]) expect(state.items[0]?.body).toMatchObject({ kind: 'tool-call', + callId: 'tool-1', state: 'completed', output: { head: 'done' } }) diff --git a/src/main/codex/codex-structured-item-translation.test.ts b/src/main/codex/codex-structured-item-translation.test.ts index 201df58bc90..3f8ec524002 100644 --- a/src/main/codex/codex-structured-item-translation.test.ts +++ b/src/main/codex/codex-structured-item-translation.test.ts @@ -201,6 +201,7 @@ describe('codex item bodies', () => { expect(codexItemBody(LIVE_TURN[2] as CodexThreadItem)).toEqual({ kind: 'tool-call', name: 'shell', + callId: 'item-2', input: { command: 'ls', cwd: '/tmp' }, exitCode: 0, state: 'completed', @@ -229,6 +230,7 @@ describe('codex item bodies', () => { expect(body).toEqual({ kind: 'tool-call', name: 'read', + callId: 'item-read', // `name` is the target's basename, which `path` already carries and no // label ever reads, so it stays out of the bounded journal payload. input: { command: "sed -n '1,200p' notes.txt", cwd: '/repo', path: '/repo/notes.txt' }, @@ -257,6 +259,7 @@ describe('codex item bodies', () => { ).toEqual({ kind: 'tool-call', name: 'search', + callId: 'item-search', input: { command: 'rg -n --no-heading beta .', cwd: '/repo', query: 'beta', directory: '.' }, state: 'running' }) @@ -276,6 +279,7 @@ describe('codex item bodies', () => { ).toEqual({ kind: 'tool-call', name: 'search', + callId: 'item-search-bare', input: { command: 'rg beta', cwd: '/repo' }, exitCode: 0, state: 'completed' @@ -296,6 +300,7 @@ describe('codex item bodies', () => { expect(body).toEqual({ kind: 'tool-call', name: 'list', + callId: 'item-list', input: { command: 'ls', cwd: '/repo' }, exitCode: 0, state: 'completed' @@ -326,6 +331,7 @@ describe('codex item bodies', () => { ).toEqual({ kind: 'tool-call', name: 'shell', + callId: 'item-mixed', input: { command: 'cat a.txt && ls src', cwd: '/repo' }, exitCode: 0, state: 'completed' @@ -349,6 +355,7 @@ describe('codex item bodies', () => { ).toEqual({ kind: 'tool-call', name: 'read', + callId: 'item-two-reads', input: { command: 'cat a.ts && cat b.ts', cwd: '/repo' }, exitCode: 0, state: 'completed' @@ -424,6 +431,7 @@ describe('codex item bodies', () => { ).toEqual({ kind: 'tool-call', name: 'read', + callId: 'item-read-null', input: { command: 'cat', cwd: '/repo' }, exitCode: 0, state: 'completed' @@ -451,6 +459,7 @@ describe('codex item bodies', () => { const shellRow = { kind: 'tool-call', name: 'shell', + callId: 'item-fallback', input: { command: 'ls', cwd: '/tmp' }, exitCode: 0, state: 'completed' @@ -624,6 +633,7 @@ describe('codex item bodies', () => { // Server-qualified, and the arguments stay top level so the row label can // read `query`/`command`/`file_path` out of them. name: 'weather/get_forecast', + callId: 'mcp-1', mcpIdentity: { server: 'weather', tool: 'get_forecast' }, input: { city: 'Oslo' }, state: 'completed', @@ -672,6 +682,7 @@ describe('codex item bodies', () => { expect(codexItemBody({ type: 'mcpToolCall', id: 'm', tool: 't', arguments: {} })).toEqual({ kind: 'tool-call', name: 't', + callId: 'm', input: null, state: 'running' }) @@ -719,6 +730,7 @@ describe('codex item bodies', () => { expect(codexItemBody({ type: 'webSearch', id: 'w', query: '', action: null })).toEqual({ kind: 'tool-call', name: 'web_search', + callId: 'w', input: null, state: 'running' }) @@ -733,6 +745,7 @@ describe('codex item bodies', () => { ).toEqual({ kind: 'tool-call', name: 'web_search', + callId: 'w', input: { query: 'orca release notes', description: 'search', diff --git a/src/main/codex/codex-structured-item-translation.ts b/src/main/codex/codex-structured-item-translation.ts index 576f3fb19ec..52d7d5ae47f 100644 --- a/src/main/codex/codex-structured-item-translation.ts +++ b/src/main/codex/codex-structured-item-translation.ts @@ -93,6 +93,7 @@ function commandItem(item: CodexThreadItem): CodexJournalItem { body: { kind: 'tool-call', name: parsed?.name ?? 'shell', + callId: item.id, // Raw command and cwd stay so the expanded view still shows what ran. input: boundToolInput( { command: item.command ?? null, cwd: item.cwd ?? null, ...parsed?.fields }, @@ -120,6 +121,7 @@ function fileChangeItem(item: CodexThreadItem): CodexJournalItem { body: { kind: 'tool-call', name: 'apply_patch', + callId: item.id, input: boundToolInput({ changes: item.changes ?? null }, DEFAULT_JOURNAL_PAYLOAD_LIMITS), state: commandState(item) }, @@ -171,6 +173,7 @@ function mcpToolCallItem(item: CodexThreadItem): CodexJournalItem { body: { kind: 'tool-call', name: mcpToolCallName(item), + callId: item.id, ...(server && tool ? { mcpIdentity: { server, tool } } : {}), input: boundToolInput(mcpToolArguments(item.arguments), DEFAULT_JOURNAL_PAYLOAD_LIMITS), state: failure === null ? commandState(item) : 'failed', @@ -213,6 +216,7 @@ function webSearchItem(item: CodexThreadItem): CodexJournalItem { body: { kind: 'tool-call', name: 'web_search', + callId: item.id, ...(results.length > 0 ? { webSearchResults: results } : {}), input: boundToolInput(webSearchInput(item), DEFAULT_JOURNAL_PAYLOAD_LIMITS), state: item.action === null || item.action === undefined ? 'running' : 'completed', diff --git a/src/main/native-chat/transcript-line-decoders-codex.ts b/src/main/native-chat/transcript-line-decoders-codex.ts index 229ace2a461..ddc748dde03 100644 --- a/src/main/native-chat/transcript-line-decoders-codex.ts +++ b/src/main/native-chat/transcript-line-decoders-codex.ts @@ -92,10 +92,13 @@ function codexResponseItem( payload.type === 'custom_tool_call' ) { const name = extractString(payload.name) ?? 'tool' + const callId = extractString(payload.call_id) return { id, role: 'assistant', - blocks: [{ type: 'tool-call', name, input: codexCallInput(payload) }], + blocks: [ + { type: 'tool-call', name, input: codexCallInput(payload), ...(callId ? { callId } : {}) } + ], timestamp, source: 'transcript' } diff --git a/src/main/native-chat/transcript-reader-codex-history-mode.test.ts b/src/main/native-chat/transcript-reader-codex-history-mode.test.ts index 5d18bec8c85..9a9b2b76094 100644 --- a/src/main/native-chat/transcript-reader-codex-history-mode.test.ts +++ b/src/main/native-chat/transcript-reader-codex-history-mode.test.ts @@ -240,7 +240,7 @@ describe('Codex transcript history modes', () => { expect(call).toMatchObject({ id: 'call-1', role: 'assistant', - blocks: [{ type: 'tool-call', name: 'exec', input: 'pwd' }] + blocks: [{ type: 'tool-call', name: 'exec', input: 'pwd', callId: 'durable-call-1' }] }) expect(output).toMatchObject({ id: 'fallback-output', diff --git a/src/main/native-chat/transcript-reader.test.ts b/src/main/native-chat/transcript-reader.test.ts index ff48548804d..eb333cd8fda 100644 --- a/src/main/native-chat/transcript-reader.test.ts +++ b/src/main/native-chat/transcript-reader.test.ts @@ -67,7 +67,7 @@ describe('readNativeChatTranscript (claude)', () => { timestamp: '2026-06-01T10:05:00.000Z', message: { role: 'assistant', - content: [{ type: 'tool_use', name: 'Bash', input: { command: 'ls' } }] + content: [{ type: 'tool_use', id: 'tool-call-1', name: 'Bash', input: { command: 'ls' } }] } }) records.push({ @@ -98,7 +98,8 @@ describe('readNativeChatTranscript (claude)', () => { expect(toolCall?.blocks[0]).toEqual({ type: 'tool-call', name: 'Bash', - input: { command: 'ls' } + input: { command: 'ls' }, + callId: 'tool-call-1' }) const toolResult = result.messages.at(-1) diff --git a/src/main/native-chat/transcript-record-blocks.ts b/src/main/native-chat/transcript-record-blocks.ts index 6355df277e5..b82ff9672ca 100644 --- a/src/main/native-chat/transcript-record-blocks.ts +++ b/src/main/native-chat/transcript-record-blocks.ts @@ -81,7 +81,8 @@ function claudeContentBlock(record: Record): NativeChatBlock | } case 'tool_use': { const name = extractString(record.name) ?? 'tool' - return { type: 'tool-call', name, input: record.input } + const callId = extractString(record.id) + return { type: 'tool-call', name, input: record.input, ...(callId ? { callId } : {}) } } case 'tool_result': return toolResultBlock(record) diff --git a/src/renderer/src/components/native-chat/NativeChatDiffCard.tsx b/src/renderer/src/components/native-chat/NativeChatDiffCard.tsx index a69ae1252f1..91d113f15e0 100644 --- a/src/renderer/src/components/native-chat/NativeChatDiffCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatDiffCard.tsx @@ -1,4 +1,5 @@ -import { useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useLayoutEffect, useMemo, useRef } from 'react' +import { useNativeChatDisclosure } from './native-chat-disclosure-store' import { ChevronRight, FilePlus2, FileMinus2, FilePen } from 'lucide-react' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' @@ -113,21 +114,29 @@ export function NativeChatDiffCard({ file, revealSignal, onReveal, - initiallyExpanded = false + initiallyExpanded = false, + disclosureKey }: { file: NativeChatEditFile revealSignal?: number onReveal?: (element: HTMLElement) => void initiallyExpanded?: boolean + /** Identity this card's open state is remembered under while it is unmounted. */ + disclosureKey?: string }): React.JSX.Element { - const [expanded, setExpanded] = useState(initiallyExpanded) + const { open: expanded, setOpen: setExpanded } = useNativeChatDisclosure( + disclosureKey, + initiallyExpanded + ) const cardRef = useRef(null) useLayoutEffect(() => { if (revealSignal && cardRef.current) { setExpanded(true) + // Reported from the card, not the row: a turn that touched four files must + // land on the one that was asked for, and only the card knows where it is. onReveal?.(cardRef.current) } - }, [revealSignal, onReveal]) + }, [revealSignal, onReveal, setExpanded]) // Joining every row to seed the copy button is the card's most expensive // work, and a collapsed card renders none of those rows. const copyText = useMemo(() => patchText(file.lines), [file.lines]) @@ -141,7 +150,7 @@ export function NativeChatDiffCard({
+
+
+ {hasMore ? ( +
+ +
+ ) : null} + + {showTurnStatus && + latestUserIndex === -1 && + turnStatuses.active && + showTypingIndicator ? ( + + ) : null} + {showTurnStatus && isWorking ? ( + + ) : null} + {!showTurnStatus && showTypingIndicator ? : null}
- ) : null} - {messages.map((message, index) => { - const turnKey = turnKeys[index] - const isCurrentTurn = currentTurnKey - ? turnKey === currentTurnKey - : turnKey === undefined - const status = - index === latestUserIndex - ? turnStatuses.active - : message.role === 'user' && turnKey - ? turnStatuses.completedByTurn[turnKey] - : undefined - const receipt = receipts.get(message.id) - const turnDiff = - turnKey && turnKeys[index + 1] !== turnKey ? turnDiffs.get(turnKey) : undefined - return ( - - {receipt ? ( - - ) : ( - - )} - {showTurnStatus && - status && - (index !== latestUserIndex || showTypingIndicator || !isWorking) ? ( - toggleExpandedTurn(turnKey) - : undefined - } - /> - ) : null} - {turnDiff ? ( - - ) : null} - - ) - })} - {showTurnStatus && - latestUserIndex === -1 && - turnStatuses.active && - showTypingIndicator ? ( - - ) : null} - {showTurnStatus && isWorking ? ( - - ) : null} - {!showTurnStatus && showTypingIndicator ? : null} +
+ {showJump ? ( + + ) : null}
- {showJump ? ( - + {taskListState.list && taskListState.list.tasks.length > 0 ? ( +
+
+ +
+
) : null}
- {taskListState.list && taskListState.list.tasks.length > 0 ? ( -
-
- -
-
- ) : null} -
+ ) } diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-history.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-history.test.tsx index 628d78b4c58..6447dfcbec0 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-history.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-history.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import '@testing-library/jest-dom/vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody, AgentJournalRenderItem @@ -9,8 +9,14 @@ import type { import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection' import { NativeChatMessageList } from './NativeChatMessageList' import type { NativeChatLiveSession } from './use-native-chat-live-session' +import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' const scrollTo = vi.fn() +let restoreViewport = (): void => {} +beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() +}) +afterAll(() => restoreViewport()) afterEach(() => { cleanup() vi.restoreAllMocks() diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-timing.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-timing.test.tsx index 022750d83cc..2eacc13ed07 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-timing.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-timing.test.tsx @@ -3,12 +3,21 @@ import '@testing-library/jest-dom/vitest' import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import type { NativeChatLiveSession } from './use-native-chat-live-session' import { NativeChatMessageList } from './NativeChatMessageList' +import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' afterEach(cleanup) +let restoreViewport = (): void => {} + +beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() +}) + +afterAll(() => restoreViewport()) + const session: NativeChatLiveSession = { messages: [ { diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx new file mode 100644 index 00000000000..c4de79ceeed --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx @@ -0,0 +1,640 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalRenderItem +} from '../../../../shared/agent-session-journal-types' +import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import type { NativeChatLiveSession } from './use-native-chat-live-session' +import { NativeChatMessageList } from './NativeChatMessageList' +import { NATIVE_CHAT_BOTTOM_THRESHOLD_PX } from './native-chat-autoscroll' +import { + estimateNativeChatRowHeight, + NATIVE_CHAT_ROW_GAP_PX, + nativeChatRowContentMetrics +} from './native-chat-row-height-estimate' + +afterEach(cleanup) + +const VIEWPORT_PX = 600 +const TRANSCRIPT_LENGTH = 200 + +/** Everything the document holds below the last row: the transcript column's + * trailing chrome and the scroll root's bottom padding. Non-zero on purpose — + * the document's bottom sits past the window's last row, which is exactly where + * a pin computed from the virtualizer's totals and one computed from the + * document disagree. */ +const BELOW_TRANSCRIPT_PX = 24 + +/** Heights the stubbed layout reports per row index, when a case wants a row to + * measure as something other than its estimate. Empty means "every row at its + * estimate", which is what every non-growth case wants. */ +let measuredRowHeights: readonly number[] = [] + +function marker(index: number): NativeChatMessage { + return { + id: `message-${index}`, + role: 'assistant', + blocks: [{ type: 'text', text: `marker-${index}` }], + timestamp: index + 1, + source: 'transcript' + } +} + +const ROW_PX = estimateNativeChatRowHeight(nativeChatRowContentMetrics(marker(0)), { + hasReceipt: false, + hasStatus: false, + hasTurnDiff: false +}) +const ROW_PITCH_PX = ROW_PX + NATIVE_CHAT_ROW_GAP_PX + +/** Replace a layout property on every element, and hand back the undo. */ +function overrideLayoutProperty(name: string, descriptor: PropertyDescriptor): () => void { + const original = Object.getOwnPropertyDescriptor(HTMLElement.prototype, name) + Object.defineProperty(HTMLElement.prototype, name, { configurable: true, ...descriptor }) + return () => { + if (original) { + Object.defineProperty(HTMLElement.prototype, name, original) + } else { + Reflect.deleteProperty(HTMLElement.prototype, name) + } + } +} + +/** The spacer's reserved height, which is the transcript's whole rendered height: + * windowed rows are absolutely positioned inside it, so a row growing in place + * reaches the document only through the height the window reserves for it. */ +function reservedTranscriptHeight(root: ParentNode): number { + const spacer = root.querySelector('[data-native-chat-window]') + return spacer ? Number.parseFloat(spacer.style.height) || 0 : 0 +} + +// The virtualizer measures with `offsetHeight` — not `clientHeight`, not a +// bounding rect — so that is the one thing a DOM without layout has to answer +// for windowing to engage at all. Rows report the height their own estimate +// predicted, which keeps the totals exact and independent of which rows happen +// to have been mounted long enough to be measured; `measuredRowHeights` is how a +// case says a row measures as something else. +// +// `scrollGeometry` additionally gives the scroll root a document to scroll: a +// height, a viewport, and a `scrollTop` that clamps the way a real one does. +// Off by default, because a transcript with a real document opens pinned to its +// bottom and the cases above are about where the window sits, not where it lands. +function stubLayout({ + scrollGeometry = false, + viewportHeight = () => VIEWPORT_PX +}: { + scrollGeometry?: boolean + viewportHeight?: () => number +} = {}): () => void { + const scrollTops = new WeakMap() + const restores = [ + overrideLayoutProperty('offsetHeight', { + get(this: HTMLElement): number { + if (this.hasAttribute('data-native-chat-scroll')) { + return viewportHeight() + } + if (this.hasAttribute('data-native-chat-window')) { + return reservedTranscriptHeight(this.parentElement ?? this) + } + const index = this.dataset.index + if (index !== undefined) { + return measuredRowHeights[Number(index)] ?? ROW_PX + } + // The transcript column: as tall as the window it wraps, plus what sits + // under it. This is the element the list observes for streamed growth. + return this.classList.contains('max-w-4xl') + ? reservedTranscriptHeight(this) + BELOW_TRANSCRIPT_PX + : 0 + } + }) + ] + if (scrollGeometry) { + restores.push( + overrideLayoutProperty('clientHeight', { + get(this: HTMLElement): number { + return this.hasAttribute('data-native-chat-scroll') ? viewportHeight() : 0 + } + }), + overrideLayoutProperty('scrollHeight', { + get(this: HTMLElement): number { + return this.hasAttribute('data-native-chat-scroll') + ? reservedTranscriptHeight(this) + BELOW_TRANSCRIPT_PX + : 0 + } + }), + overrideLayoutProperty('scrollTop', { + get(this: HTMLElement): number { + return scrollTops.get(this) ?? 0 + }, + set(this: HTMLElement, value: number): void { + // A browser clamps; without this `scrollTop = scrollHeight` would park + // the view past the end and every distance-from-bottom would read 0. + const max = Math.max(0, this.scrollHeight - this.clientHeight) + scrollTops.set(this, Math.min(Math.max(0, value), max)) + } + }) + ) + } + return () => { + for (const restore of restores.toReversed()) { + restore() + } + } +} + +type FakeResizeObservation = { + callback: ResizeObserverCallback + /** Target -> height last delivered. -1 means "never", so the first flush + * delivers, the way a real observer's initial callback does. */ + observed: Map +} + +const resizeObservations = new Set() + +/** happy-dom's ResizeObserver never fires, so nothing that re-measures ever runs. + * This one records what production observes and delivers only when a target's + * height actually changed — the browser's own rule — and only when a test says + * a frame was painted. Entries carry no `borderBoxSize`, so the virtualizer + * falls back to `offsetHeight`, which is the path being modelled. */ +function stubResizeObserver(): () => void { + const original = window.ResizeObserver + class TestResizeObserver { + private readonly observation: FakeResizeObservation + constructor(callback: ResizeObserverCallback) { + this.observation = { callback, observed: new Map() } + resizeObservations.add(this.observation) + } + observe(target: Element): void { + this.observation.observed.set(target, -1) + } + unobserve(target: Element): void { + this.observation.observed.delete(target) + } + disconnect(): void { + this.observation.observed.clear() + resizeObservations.delete(this.observation) + } + } + window.ResizeObserver = TestResizeObserver as unknown as typeof ResizeObserver + return () => { + resizeObservations.clear() + window.ResizeObserver = original + } +} + +/** Deliver one round of resize callbacks; true when anything was delivered. */ +function deliverResizes(): boolean { + let delivered = false + // A copy: a callback may disconnect its own observer mid-delivery. + for (const observation of Array.from(resizeObservations)) { + const entries: ResizeObserverEntry[] = [] + for (const [target, lastHeight] of observation.observed) { + const height = (target as HTMLElement).offsetHeight + if (height !== lastHeight) { + observation.observed.set(target, height) + entries.push({ target } as unknown as ResizeObserverEntry) + } + } + if (entries.length > 0) { + delivered = true + observation.callback(entries, undefined as unknown as ResizeObserver) + } + } + return delivered +} + +function session(messages: NativeChatMessage[]): NativeChatLiveSession { + return { + messages, + status: 'ready', + sessionId: 'session-1', + agent: 'codex', + hasMore: false, + loadingEarlier: false, + loadEarlier: vi.fn(), + readPhase: 'ready' + } +} + +function list(messages: NativeChatMessage[]): React.JSX.Element { + return ( + + ) +} + +/** Reads the window, and refuses to pass if there is no window to read. + * + * Without this a change to the usability gate would quietly send every case + * below down the whole-transcript path, where "fewer rows than messages" is + * false but every other assertion still holds. */ +function windowState(container: HTMLElement): { totalSize: number; indexes: number[] } { + const spacer = container.querySelector('[data-native-chat-window]') + if (!spacer) { + throw new Error('transcript is not windowed: no spacer, every row is mounted') + } + const totalSize = Number.parseFloat(spacer.style.height) + if (!(totalSize > 0)) { + throw new Error(`transcript reserved no height (${spacer.style.height})`) + } + return { + totalSize, + indexes: Array.from(container.querySelectorAll('[data-index]')) + .map((row) => Number(row.dataset.index)) + .sort((left, right) => left - right) + } +} + +/** happy-dom fires no scroll event for an assignment to `scrollTop`. */ +function scrollTranscript(container: HTMLElement, top: number): void { + const scroller = container.querySelector('[data-native-chat-scroll]') + if (!scroller) { + throw new Error('no transcript scroll root') + } + scroller.scrollTop = top + fireEvent.scroll(scroller) +} + +describe('windowed transcript', () => { + let restoreLayout = (): void => {} + beforeEach(() => { + restoreLayout = stubLayout() + }) + afterEach(() => { + restoreLayout() + }) + + const transcript = Array.from({ length: TRANSCRIPT_LENGTH }, (_, index) => marker(index)) + + it('mounts a window over the transcript rather than all of it', () => { + const { container } = render(list(transcript)) + const { indexes } = windowState(container) + + expect(indexes.length).toBeGreaterThan(0) + expect(indexes.length).toBeLessThan(TRANSCRIPT_LENGTH / 4) + expect(indexes).toContain(0) + expect(screen.getByText('marker-0')).toBeInTheDocument() + expect(screen.queryByText(`marker-${TRANSCRIPT_LENGTH - 2}`)).toBeNull() + }) + + // One gap per pair of rows, and none after the last one. The other half of + // this — that a row's own reservation does not include the gap as well — is + // pinned on the estimate itself, where it can be seen without layout. + it('reserves each row once and one gap between each pair', () => { + const { container } = render(list(transcript)) + + expect(windowState(container).totalSize).toBe( + TRANSCRIPT_LENGTH * ROW_PX + (TRANSCRIPT_LENGTH - 1) * NATIVE_CHAT_ROW_GAP_PX + ) + }) + + it('moves the mounted rows to bracket the offset the reader scrolled to', () => { + const { container } = render(list(transcript)) + const offset = 5000 + scrollTranscript(container, offset) + const { indexes } = windowState(container) + const focused = Math.floor(offset / ROW_PITCH_PX) + + expect(indexes).toContain(focused) + expect(indexes[0]).toBeLessThanOrEqual(focused) + expect(indexes.at(-1)).toBeGreaterThanOrEqual(focused) + expect(indexes).not.toContain(0) + expect(indexes.length).toBeLessThan(TRANSCRIPT_LENGTH / 4) + }) + + // The live row announces a running tool through `aria-live`, which says nothing + // from a row that is not in the document. + it('keeps the newest row mounted after the reader scrolls away from it', () => { + const { container } = render(list(transcript)) + scrollTranscript(container, 5000) + + expect(windowState(container).indexes).toContain(TRANSCRIPT_LENGTH - 1) + }) + + it('gives no slot to a message that draws nothing', () => { + const withBlanks = Array.from({ length: TRANSCRIPT_LENGTH }, (_, index) => + index % 4 === 0 + ? { ...marker(index), blocks: [{ type: 'text' as const, text: '' }] } + : marker(index) + ) + const drawn = TRANSCRIPT_LENGTH - TRANSCRIPT_LENGTH / 4 + const { container } = render(list(withBlanks)) + const { totalSize, indexes } = windowState(container) + + expect(totalSize).toBe(drawn * ROW_PX + (drawn - 1) * NATIVE_CHAT_ROW_GAP_PX) + expect(indexes.at(-1)).toBeLessThanOrEqual(drawn - 1) + }) + + it('still has the tool run open when the row carrying it comes back', () => { + const withTool = [...transcript] + withTool[1] = { + ...marker(1), + blocks: [ + { type: 'text', text: 'marker-1' }, + { type: 'tool-call', name: 'shell', input: { command: 'ls' }, state: 'completed' } + ] + } + const { container } = render(list(withTool)) + + const header = screen.getByRole('button', { name: /1×/ }) + expect(header).toHaveAttribute('aria-expanded', 'false') + fireEvent.click(header) + expect(screen.getByRole('button', { name: /1×/ })).toHaveAttribute('aria-expanded', 'true') + + scrollTranscript(container, 5000) + expect(windowState(container).indexes).not.toContain(1) + expect(screen.queryByRole('button', { name: /1×/ })).toBeNull() + + scrollTranscript(container, 0) + expect(screen.getByRole('button', { name: /1×/ })).toHaveAttribute('aria-expanded', 'true') + }) +}) + +// The reveal chain runs message -> tool run -> diff card and lands on a card in +// a DIFFERENT, earlier message than the rollup that was clicked. Under windowing +// that message may not be mounted to be pointed at, so the reveal names it by id +// and the row is pinned into the window until the card can answer for itself. +describe('revealing a diff from a turn rollup', () => { + let restoreLayout = (): void => {} + beforeEach(() => { + restoreLayout = stubLayout() + }) + afterEach(() => { + restoreLayout() + vi.restoreAllMocks() + }) + + function journalItem(itemId: string, body: AgentJournalItemBody, sequence: number) { + return { itemId, body, sequence, observedAt: sequence * 1000, revision: 1 } + } + + const patch = '@@ -1 +1 @@\n-before\n+after' + const items: AgentJournalRenderItem[] = [ + journalItem( + 'user', + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Edit it' }] }, + 1 + ), + journalItem( + 'diff', + { + kind: 'diff', + path: 'src/a.ts', + patch: { head: patch, truncated: false, digest: 'fixture', byteLength: patch.length } + }, + 2 + ), + ...Array.from({ length: TRANSCRIPT_LENGTH }, (_, index) => + journalItem( + `tail-${index}`, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `marker-${index}` }] }, + index + 3 + ) + ) + ] + + it('mounts the row a reveal names even when the window has left it behind', () => { + const scrollTo = vi.fn() + vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo) + const { container } = render( + + ) + // The rollup rides the turn's last row, which is pinned; the diff it points + // at is near the top and long gone from the window. + scrollTranscript(container, 4000) + expect(screen.queryByText('Edited file')).toBeNull() + const mountedBefore = windowState(container).indexes.length + + fireEvent.click(screen.getByRole('button', { name: /1 changed file/ })) + scrollTo.mockClear() + fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ })) + + expect(screen.getByText('Edited file')).toBeInTheDocument() + expect(screen.getByText('after')).toBeInTheDocument() + expect(scrollTo).toHaveBeenCalled() + // Pinned, not paged to: the window is still a window. + expect(windowState(container).indexes.length).toBeLessThanOrEqual(mountedBefore + 2) + }) +}) + +describe('transcript with a hidden scroll root', () => { + const transcript = Array.from({ length: 40 }, (_, index) => marker(index)) + + it('keeps the transcript bounded and rehydrates when the viewport becomes measurable', () => { + let viewportHeight = 0 + const restoreLayout = stubLayout({ viewportHeight: () => viewportHeight }) + const restoreResizeObserver = stubResizeObserver() + try { + const { container } = render(list(transcript)) + + expect(container.querySelector('[data-native-chat-window]')).toBeInTheDocument() + expect(container.querySelectorAll('[data-index]')).toHaveLength(0) + expect(screen.queryByText(/^marker-/)).toBeNull() + const column = container.querySelector('.max-w-4xl') + expect(column?.children).toHaveLength(1) + + viewportHeight = VIEWPORT_PX + act(() => { + deliverResizes() + }) + const { indexes } = windowState(container) + expect(indexes.length).toBeGreaterThan(0) + expect(indexes.length).toBeLessThan(transcript.length) + } finally { + restoreResizeObserver() + restoreLayout() + } + }) +}) + +// A row that grows in place: the same message id, more content, a taller measured +// box — what a streaming reply looks like to the window. Whole-message appends +// arrive at their final height and are a different case; this is the one where +// the row the reader is looking at keeps changing size underneath them. +// +// Two mechanisms are supposed to hold the pin, and both are exercised here: the +// list's own resize observer on the transcript column (which re-runs +// `scrollToBottom` against the document) and the virtualizer's end anchor (which +// compensates `scrollTop` by the growth when the view was already at the end). +describe('a row growing in place while the view is pinned to the bottom', () => { + const TAIL_INDEX = TRANSCRIPT_LENGTH - 1 + const GROWTH_STEPS = 24 + const LINES_PER_STEP = 12 + /** One wrapped prose line. Content and measured height grow from this one + * number, so a step that adds lines is a step that adds pixels. */ + const STREAM_LINE_PX = 22 + /** Every row but the growing one measures at its estimate, so the reserved + * total is arithmetic rather than a snapshot. */ + const BASE_TOTAL_PX = + (TRANSCRIPT_LENGTH - 1) * ROW_PX + (TRANSCRIPT_LENGTH - 1) * NATIVE_CHAT_ROW_GAP_PX + + /** Fixed so a re-render never restamps the turn and moves the status row. */ + const TURN_STARTED_AT = Date.now() + + const transcript = Array.from({ length: TRANSCRIPT_LENGTH }, (_, index) => marker(index)) + + function tailHeightAt(step: number): number { + return Math.max(ROW_PX, (1 + step * LINES_PER_STEP) * STREAM_LINE_PX) + } + + function transcriptAt(step: number): NativeChatMessage[] { + const lines = Array.from( + { length: step * LINES_PER_STEP }, + (_, index) => `streamed line ${index}` + ) + const next = [...transcript] + next[TAIL_INDEX] = { + ...marker(TAIL_INDEX), + blocks: [{ type: 'text', text: [`marker-${TAIL_INDEX}`, ...lines].join('\n') }] + } + return next + } + + function streamingList(step: number): React.JSX.Element { + return ( + + ) + } + + function scrollRoot(container: HTMLElement): HTMLElement { + const scroller = container.querySelector('[data-native-chat-scroll]') + if (!scroller) { + throw new Error('no transcript scroll root') + } + return scroller + } + + /** One painted frame, repeated to a fixed point: deliver the resize callbacks + * the growth caused, then fire the scroll event a browser fires for any + * `scrollTop` the code wrote itself. Refusing to settle is a failure in its + * own right — that is the view oscillating. */ + function paint(container: HTMLElement): void { + const scroller = scrollRoot(container) + let lastScrollTop = scroller.scrollTop + for (let pass = 0; pass < 12; pass += 1) { + let changed = false + act(() => { + changed = deliverResizes() + }) + if (scroller.scrollTop !== lastScrollTop) { + lastScrollTop = scroller.scrollTop + fireEvent.scroll(scroller) + changed = true + } + if (!changed) { + return + } + } + throw new Error('the transcript never settled: resize and scroll kept moving it') + } + + function distanceFromBottom(container: HTMLElement): number { + const scroller = scrollRoot(container) + return scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop + } + + function setMeasuredTail(step: number): void { + const heights = Array.from({ length: TRANSCRIPT_LENGTH }, () => ROW_PX) + heights[TAIL_INDEX] = tailHeightAt(step) + measuredRowHeights = heights + } + + let restoreLayout = (): void => {} + let restoreResizeObserver = (): void => {} + beforeEach(() => { + restoreLayout = stubLayout({ scrollGeometry: true }) + restoreResizeObserver = stubResizeObserver() + setMeasuredTail(0) + }) + afterEach(() => { + restoreResizeObserver() + restoreLayout() + measuredRowHeights = [] + }) + + it('holds the pin, the mount and the reserved total at every frame of the growth', () => { + setMeasuredTail(0) + const { container, rerender } = render(streamingList(0)) + paint(container) + + expect(distanceFromBottom(container)).toBeLessThanOrEqual(NATIVE_CHAT_BOTTOM_THRESHOLD_PX) + expect(windowState(container).totalSize).toBe(BASE_TOTAL_PX + tailHeightAt(0)) + + const frames: { step: number; tail: number; total: number; distance: number }[] = [] + for (let step = 1; step <= GROWTH_STEPS; step += 1) { + setMeasuredTail(step) + rerender(streamingList(step)) + paint(container) + + const { totalSize, indexes } = windowState(container) + const distance = distanceFromBottom(container) + frames.push({ step, tail: tailHeightAt(step), total: totalSize, distance }) + + // Pinned: the reader is still looking at the bottom of the row. + expect(distance).toBeLessThanOrEqual(NATIVE_CHAT_BOTTOM_THRESHOLD_PX) + // Mounted: never swapped for reserved space while it is the live row. + expect(indexes).toContain(TAIL_INDEX) + expect(screen.getByText(/streamed line 0/)).toBeInTheDocument() + // Tracking: the reservation follows the measurement, not the estimate. + expect(totalSize).toBe(BASE_TOTAL_PX + tailHeightAt(step)) + // Still a window, not the whole transcript remounted by the growth. + expect(indexes.length).toBeLessThan(TRANSCRIPT_LENGTH / 4) + } + + expect(frames).toHaveLength(GROWTH_STEPS) + expect(frames.at(-1)?.tail).toBeGreaterThan(VIEWPORT_PX * 10) + expect(Math.max(...frames.map((frame) => frame.distance))).toBeLessThanOrEqual( + NATIVE_CHAT_BOTTOM_THRESHOLD_PX + ) + }) + + it('leaves a reader who scrolled up where they were, however far the row grows', () => { + setMeasuredTail(4) + const { container, rerender } = render(streamingList(4)) + paint(container) + + const readingAt = 2000 + scrollTranscript(container, readingAt) + paint(container) + expect(distanceFromBottom(container)).toBeGreaterThan(NATIVE_CHAT_BOTTOM_THRESHOLD_PX) + expect(screen.getByRole('button', { name: /jump to latest/i })).toBeInTheDocument() + + for (let step = 5; step <= GROWTH_STEPS; step += 1) { + setMeasuredTail(step) + rerender(streamingList(step)) + paint(container) + + const { totalSize, indexes } = windowState(container) + // Not yanked: the offset the reader chose is the offset they still have. + expect(scrollRoot(container).scrollTop).toBe(readingAt) + // The row is off screen but still measured, which is what keeps the + // reserved total — and so the scrollbar — honest while it grows. + expect(indexes).toContain(TAIL_INDEX) + expect(totalSize).toBe(BASE_TOTAL_PX + tailHeightAt(step)) + } + + expect(screen.getByRole('button', { name: /jump to latest/i })).toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index 773e6f2c2ff..d73772dfaf9 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -1,23 +1,17 @@ -import { memo, useCallback, useMemo, useRef } from 'react' +import { memo, useCallback, useRef } from 'react' import CommentMarkdown, { type CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' -import { - isSubagentGroupFallbackText, - subagentGroupBlocks -} from '../../../../shared/native-chat-subagent-summary' -import { - isSubagentGroupBlock, - type NativeChatMessage, - type NativeChatToolCallBlock +import type { + NativeChatMessage, + NativeChatToolCallBlock } from '../../../../shared/native-chat-types' -import { splitNativeChatBlocks } from './native-chat-tool-fold' +import { deriveNativeChatRowContent } from './native-chat-row-content' import { NativeChatToolRun } from './NativeChatToolRun' import { NativeChatNoticeRow } from './NativeChatNoticeRow' import { NativeChatMessageTimestamp } from './NativeChatMessageTimestamp' -import { nativeChatProseToMarkdown } from './native-chat-prose' import { NativeChatAgentControls, NativeChatImageAttachments, @@ -62,32 +56,11 @@ export const MessageRow = memo(function MessageRow({ runtimeContext?: RuntimeFileOperationArgs | null }): React.JSX.Element | null { const rowRef = useRef(null) - // One pass per block set: a streaming turn re-renders this row on every frame, and these - // derivations used to re-run each time even though `message.blocks` had not changed. - const { hasImages, markdown, prose, subagentGroups, tools } = useMemo(() => { - const split = splitNativeChatBlocks(message.blocks) - const groups = subagentGroupBlocks(split.prose) - // A spawn-group row carries a plain-text twin so a client without the block - // type still reads the roster. This one draws the block, so the twin is - // dropped rather than printed beside it — only the twin, never the prose - // beside it: the block is provider-agnostic, so a lane that folds a roster - // into a message with real text must not lose that text here. - const prose = - groups.length === 0 - ? split.prose - : split.prose.filter( - (block) => - !isSubagentGroupBlock(block) && - !(block.type === 'text' && isSubagentGroupFallbackText(block.text)) - ) - return { - tools: split.tools, - prose, - subagentGroups: groups, - markdown: nativeChatProseToMarkdown(prose), - hasImages: prose.some((block) => block.type === 'image-ref') - } - }, [message.blocks]) + // One pass per block set, shared with the list that decides whether this row + // occupies a slot — so "draws nothing" means the same thing to both. + const { hasImages, markdown, prose, subagentGroups, tools } = deriveNativeChatRowContent( + message.blocks + ) const isUser = message.role === 'user' const isReasoning = message.role === 'reasoning' const isSystem = message.role === 'system' @@ -220,6 +193,7 @@ export const MessageRow = memo(function MessageRow({ expandOverride={activityExpandOverride} activeTurnIsWorking={activeTurnIsWorking} structuredActivityUi={structuredActivityUi} + disclosureId={message.id} /> ) : null} {showControls ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatToolLine.tsx b/src/renderer/src/components/native-chat/NativeChatToolLine.tsx new file mode 100644 index 00000000000..883244d8f91 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatToolLine.tsx @@ -0,0 +1,139 @@ +import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' +import { ChevronRight } from 'lucide-react' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { + isToolCallBlock, + isToolResultBlock, + type NativeChatBlock +} from '../../../../shared/native-chat-types' +import { + NativeChatCommandMetadata, + NativeChatSearchResults, + NativeChatToolName +} from './NativeChatToolAnnotations' +import { NativeChatToolIcon } from './NativeChatToolIcon' +import { NativeChatDiffView } from './NativeChatDiffView' +import { diffFromText, diffFromToolCall, type DiffLine } from './native-chat-diff' +import { useNativeChatDisclosure } from './native-chat-disclosure-store' +import { createToolInputDisplay, truncateToolDetail } from './native-chat-tool-summary' + +/** A single inline tool line — `▸ ToolName preview` — that expands in place to + * show the call's diff/input or the result's body. Tool calls read as flat + * lines in the conversation rather than boxed blocks (mobile parity). Lines only + * mount while the parent run is open and are individually collapsible. */ +export function NativeChatToolLine({ + block, + initiallyExpanded = true, + disclosureKey, + onLinkClick +}: { + block: NativeChatBlock + initiallyExpanded?: boolean + /** Identity this line's open state is remembered under while it is unmounted. */ + disclosureKey?: string + onLinkClick?: CommentMarkdownLinkClickHandler +}): React.JSX.Element | null { + const { open: expanded, setOpen: setExpanded } = useNativeChatDisclosure( + disclosureKey, + initiallyExpanded + ) + + let name: string + let preview: string + let diff: DiffLine[] | null = null + let body: { output: string; isError?: boolean } | null = null + let detail: string | null = null + let inputHasDetail = false + const isCall = isToolCallBlock(block) + + if (isCall) { + name = block.name + const inputDisplay = createToolInputDisplay(block.input) + preview = inputDisplay.label + inputHasDetail = inputDisplay.hasDetail + diff = expanded ? diffFromToolCall(block.name, block.input) : null + detail = expanded && !diff ? inputDisplay.formatDetail() : null + } else if (isToolResultBlock(block)) { + name = translate('components.native-chat.tool.result', 'Result') + preview = block.output.split('\n')[0]?.slice(0, 80) ?? '' + diff = expanded ? diffFromText(block.output) : null + body = { output: block.output, isError: block.isError } + } else { + return null + } + + const hasResults = isCall && (block.webSearchResults?.length ?? 0) > 0 + const hasDetail = diff !== null || body !== null || inputHasDetail || hasResults + + return ( +
+ + {hasDetail && expanded ? ( +
+ {isCall && hasResults ? ( + + ) : null} + {diff ? : null} + {!diff && body ? ( +
+              {truncateToolDetail(body.output)}
+            
+ ) : null} + {!diff && !body && detail ? ( +
+              {detail}
+            
+ ) : null} +
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.identity.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.identity.test.tsx index 2b83f86d14a..86d962ae81d 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.identity.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.identity.test.tsx @@ -3,10 +3,24 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { NativeChatToolRun } from './NativeChatToolRun' import type { NativeChatToolCallBlock } from '../../../../shared/native-chat-types' +import { + NativeChatDisclosureContext, + useNativeChatDisclosures +} from './native-chat-disclosure-store' vi.mock('./NativeChatDiffCard', () => ({ NativeChatDiffCard: () => null })) vi.mock('./NativeChatDiffView', () => ({ NativeChatDiffView: () => null })) -afterEach(cleanup) + +const disclosureWrite = vi.fn() +const capturedDisclosures = { + read: (_key: string) => undefined, + write: (key: string, open: boolean) => disclosureWrite(key, open) +} + +afterEach(() => { + cleanup() + disclosureWrite.mockReset() +}) const shell: NativeChatToolCallBlock = { type: 'tool-call', @@ -17,7 +31,100 @@ const shell: NativeChatToolCallBlock = { durationMs: 400 } +function ToolRunDisclosureHarness({ expandOverride }: { expandOverride: boolean }) { + const disclosures = useNativeChatDisclosures() + return ( + + + + ) +} + describe('inline tool annotations', () => { + it('restores a per-run deviation when its turn returns to the same disclosure state', () => { + const { rerender } = render() + const run = screen.getByRole('button', { expanded: true }) + + fireEvent.click(run) + expect(run.getAttribute('aria-expanded')).toBe('false') + + rerender() + expect(screen.queryByRole('button')).toBeNull() + + rerender() + expect(screen.getByRole('button', { name: /1×/ }).getAttribute('aria-expanded')).toBe('false') + }) + + it('resynchronizes a standalone run when the toolbar signal flips', () => { + const { rerender } = render( + + ) + expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false') + + rerender() + + expect(screen.getByRole('button', { name: /1×/ }).getAttribute('aria-expanded')).toBe('true') + }) + + it('uses provider call identities for byte-identical line disclosure keys', () => { + const blocks = [ + { ...shell, callId: 'call-a' }, + { ...shell, callId: 'call-b' } + ] + render( + + + + ) + + fireEvent.click(screen.getAllByRole('button')[2]!) + + expect(disclosureWrite).toHaveBeenCalledExactlyOnceWith('line:message-1:call:call-b', false) + }) + + it('keeps occurrence identity as the fallback for calls without provider IDs', () => { + render( + + + + ) + + fireEvent.click(screen.getAllByRole('button')[2]!) + + expect(disclosureWrite).toHaveBeenCalledExactlyOnceWith( + 'line:message-1:tool-call:shell:{"command":"missing-command"}:1', + false + ) + }) + + it('keeps occurrence identity for whitespace-only provider IDs', () => { + render( + + + + ) + + fireEvent.click(screen.getAllByRole('button')[2]!) + + expect(disclosureWrite).toHaveBeenCalledExactlyOnceWith( + 'line:message-1:tool-call:shell:{"command":"missing-command"}:1', + false + ) + }) + it('keeps command completion annotations on the collapsed tool line', () => { render( 0 - const hasDetail = diff !== null || body !== null || inputHasDetail || hasResults - - return ( -
- - {hasDetail && expanded ? ( -
- {isCall && hasResults ? ( - - ) : null} - {diff ? : null} - {!diff && body ? ( -
-              {truncateToolDetail(body.output)}
-            
- ) : null} - {!diff && !body && detail ? ( -
-              {detail}
-            
- ) : null} -
- ) : null} -
- ) -} - /** A run of a message's tool calls/results, collapsed to a one-line summary that - * expands to the individual inline tool lines. `expandSignal` lets the global - * toolbar toggle drive every run at once while still allowing per-run override. */ + * expands to the individual inline tool lines. */ export function NativeChatToolRun({ blocks, previousTodoWrite, @@ -170,6 +47,7 @@ export function NativeChatToolRun({ activeTurnIsWorking, expandOverride, structuredActivityUi = true, + disclosureId, onLinkClick }: { blocks: NativeChatBlock[] @@ -179,32 +57,28 @@ export function NativeChatToolRun({ onRevealDiff?: (element: HTMLElement) => void /** Spawn-group rosters that belong with this run's activity, one row each. */ subagentGroups?: NativeChatSubagentGroupBlock[] - /** Toolbar-driven desired open state. Each change re-syncs this run's state. */ + /** Legacy view-level default; production native-chat entry points pass false. */ expandSignal: boolean /** Per-turn disclosure state controlled by the completed turn status row. */ expandOverride?: boolean /** Structured lifecycle state, when available, keeps orphaned running calls from spinning. */ activeTurnIsWorking?: boolean structuredActivityUi?: boolean + /** Message this run belongs to. Windowing unmounts rows, so a run the reader + * opened has to be remembered somewhere that outlives the row. */ + disclosureId?: string onLinkClick?: CommentMarkdownLinkClickHandler }): React.JSX.Element | null { - const [open, setOpen] = useState(revealedDiff ? true : (expandOverride ?? expandSignal)) - const [controls, setControls] = useState({ expandOverride, expandSignal, revealedDiff }) - if ( - controls.expandOverride !== expandOverride || - controls.expandSignal !== expandSignal || - controls.revealedDiff !== revealedDiff - ) { - setControls({ expandOverride, expandSignal, revealedDiff }) - if (revealedDiff && controls.revealedDiff !== revealedDiff) { - setOpen(true) - } else if ( - controls.expandOverride !== expandOverride || - controls.expandSignal !== expandSignal - ) { - setOpen(expandOverride ?? expandSignal) - } - } + // A reader's deviation belongs to the controlling disclosure state, so returning + // to that state restores the same choice without writing to the store mid-render. + const runKey = + disclosureId === undefined + ? undefined + : `run:${disclosureId}:${expandOverride ?? '-'}:${expandSignal}:${revealedDiff?.requestId ?? '-'}` + const { open, setOpen } = useNativeChatDisclosure( + runKey, + revealedDiff ? true : (expandOverride ?? expandSignal) + ) // Childless groups are dropped so `subagentRows.length` stays an honest test of // "something will draw": the roster-only branch below returns a margin-bearing @@ -236,8 +110,7 @@ export function NativeChatToolRun({ : null const isSettled = latestActiveCall == null const hasRunningCall = blocks.some((block) => isToolCallBlock(block) && block.state === 'running') - // The turn caret opens the activity group, while each child tool remains - // collapsed. The global expand toolbar still opens child details together. + // The turn caret opens the activity group while each child tool stays collapsed. const expandToolLines = expandOverride === undefined ? open : false // Diffing every edit is the run's most expensive work, so a collapsed run — // which renders none of it — never pays for it. @@ -307,7 +180,7 @@ export function NativeChatToolRun({ {latestActiveCall ? (
@@ -440,12 +318,25 @@ export function NativeChatToolRun({ : `${block.type}` const occurrence = seen.get(signature) ?? 0 seen.set(signature, occurrence + 1) + const providerCallId = + block.type === 'tool-call' && + block.callId !== undefined && + block.callId.trim().length > 0 + ? block.callId + : undefined + const lineIdentity = + providerCallId !== undefined + ? `call:${providerCallId}` + : `${signature}:${occurrence}` return ( - ) }) diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptItems.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptItems.tsx new file mode 100644 index 00000000000..c8b51e0263e --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptItems.tsx @@ -0,0 +1,50 @@ +import { + NativeChatTranscriptRow, + type NativeChatTranscriptRowContext +} from './NativeChatTranscriptRow' +import type { NativeChatTranscriptSlot } from './native-chat-transcript-slots' +import type { NativeChatTranscriptWindow } from './use-native-chat-transcript-window' + +/** Windowed transcript rows, absolutely positioned inside a full-height spacer. */ +export function NativeChatTranscriptItems({ + slots, + context, + window +}: { + slots: readonly NativeChatTranscriptSlot[] + context: NativeChatTranscriptRowContext + window: NativeChatTranscriptWindow +}): React.JSX.Element { + return ( +
+ {window.virtualItems.map((item) => { + const slot = slots[item.index] + if (!slot) { + return null + } + return ( +
+ +
+ ) + })} +
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx new file mode 100644 index 00000000000..93c5676cddb --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx @@ -0,0 +1,86 @@ +import { memo } from 'react' +import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' +import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' +import { MessageRow } from './NativeChatMessageRow' +import { NativeChatResolutionReceipt } from './NativeChatResolutionReceipt' +import { NativeChatWorkingStatus } from './NativeChatWorkingStatus' +import { NativeChatTurnDiffRollup } from './NativeChatTurnDiffRollup' +import type { NativeChatTaskListPredecessors } from './native-chat-task-list-history' +import type { NativeChatTranscriptSlot } from './native-chat-transcript-slots' +import type { NativeChatDiffReveal, NativeChatDiffTarget } from './native-chat-turn-diffs' + +/** Everything a row needs that is the same for every row. Held as one memoized + * object so a row's props change only when that row's own slot does. */ +export type NativeChatTranscriptRowContext = { + expandSignal: boolean + showTurnStatus: boolean + revealedDiff: NativeChatDiffReveal | null + taskListPredecessors: ReadonlyMap + expandedTurnIds: ReadonlySet + failedDeliveryMessageIds?: ReadonlySet + allowFileUriLinks: boolean + runtimeContext?: RuntimeFileOperationArgs | null + onLinkClick?: CommentMarkdownLinkClickHandler + onToggleExpandedTurn: (turnKey: string) => void + onScrollMessageToTop: (element: HTMLElement) => void + onRevealDiff: (target: NativeChatDiffTarget) => void +} + +/** One transcript row: the message (or the receipt standing in for it), the turn + * status under it, and the turn's diff rollup. + * + * These three were siblings in the transcript column and took their spacing from + * it. Windowing needs one element per row to position and measure, so the + * wrapper carries that spacing itself — the gap BETWEEN rows is the window's. */ +export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({ + slot, + context +}: { + slot: NativeChatTranscriptSlot + context: NativeChatTranscriptRowContext +}): React.JSX.Element { + const { message, turnKey, status, receipt, turnDiff } = slot + const predecessors = context.taskListPredecessors.get(message.id) + const expanded = turnKey ? context.expandedTurnIds.has(turnKey) : undefined + return ( +
+ {receipt ? ( + + ) : ( + + )} + {status ? ( + context.onToggleExpandedTurn(turnKey) + : undefined + } + /> + ) : null} + {turnDiff ? ( + + ) : null} +
+ ) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-autoscroll.test.ts b/src/renderer/src/components/native-chat/native-chat-autoscroll.test.ts index 79d60b647f0..f7ae35bf3ea 100644 --- a/src/renderer/src/components/native-chat/native-chat-autoscroll.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-autoscroll.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest' import { distanceFromBottom, isNearBottom, + shouldLoadEarlier, shouldShowJumpToLatest, NATIVE_CHAT_BOTTOM_THRESHOLD_PX } from './native-chat-autoscroll' @@ -42,3 +43,47 @@ describe('shouldShowJumpToLatest', () => { expect(shouldShowJumpToLatest(false, noOverflow)).toBe(false) }) }) + +// Windowing turns measurement into a constant source of movement: every row that +// resolves its real height changes the content and re-fires the observers that +// ask this question. So "near the top" alone can no longer be the answer. +describe('shouldLoadEarlier', () => { + const nearTop = { scrollTop: 10, scrollHeight: 4000, clientHeight: 600 } + const base = { + geometry: nearTop, + previousScrollTop: 400, + hasMore: true, + loadingEarlier: false, + itemCount: 40, + requestedAtItemCount: null + } + + it('pages in older history when the reader scrolls up to the top', () => { + expect(shouldLoadEarlier(base)).toBe(true) + }) + + it('says nothing to page when there is no more history', () => { + expect(shouldLoadEarlier({ ...base, hasMore: false })).toBe(false) + }) + + it('waits for the page already in flight', () => { + expect(shouldLoadEarlier({ ...base, loadingEarlier: true })).toBe(false) + }) + + it('ignores a position that is not near the top', () => { + expect(shouldLoadEarlier({ ...base, geometry: { ...nearTop, scrollTop: 400 } })).toBe(false) + }) + + // The bottom pin and a settling measurement both move the view DOWN. Only a + // reader moving up is asking for older history. + it('ignores movement towards the bottom', () => { + expect(shouldLoadEarlier({ ...base, previousScrollTop: 0 })).toBe(false) + }) + + it('asks once per page, not once per measurement, while parked at the top', () => { + const parked = { ...base, previousScrollTop: 10, requestedAtItemCount: 40 } + expect(shouldLoadEarlier(parked)).toBe(false) + // New history arrived and the reader is still at the top: asking again is right. + expect(shouldLoadEarlier({ ...parked, itemCount: 60 })).toBe(true) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-autoscroll.ts b/src/renderer/src/components/native-chat/native-chat-autoscroll.ts index bf6b4f73ee1..6f6dc8b79d2 100644 --- a/src/renderer/src/components/native-chat/native-chat-autoscroll.ts +++ b/src/renderer/src/components/native-chat/native-chat-autoscroll.ts @@ -42,3 +42,38 @@ export function shouldShowJumpToLatest( } return distanceFromBottom(geometry) > threshold } + +/** Distance from the top within which the transcript pages in older history. */ +export const NATIVE_CHAT_LOAD_EARLIER_THRESHOLD_PX = 80 + +export type LoadEarlierIntent = { + geometry: ScrollGeometry + /** Offset at the previous scroll event; a prepend or a bottom pin moves down. */ + previousScrollTop: number + hasMore: boolean + loadingEarlier: boolean + itemCount: number + /** Item count when the last page was asked for, or null if none has been. */ + requestedAtItemCount: number | null +} + +/** Whether reaching this offset should page in older history. + * + * Windowing makes the naive "am I near the top?" test unsafe: every row that + * resolves its real height changes the content height and re-fires the + * observers that ask. So the answer also requires the view to have moved + * *upwards* — measurement settling and the bottom pin both move it down — and + * requires new items since the last request, which caps a stuck near-top view at + * one request per page rather than one per measurement. */ +export function shouldLoadEarlier(intent: LoadEarlierIntent): boolean { + if (!intent.hasMore || intent.loadingEarlier) { + return false + } + if (intent.geometry.scrollTop >= NATIVE_CHAT_LOAD_EARLIER_THRESHOLD_PX) { + return false + } + if (intent.geometry.scrollTop > intent.previousScrollTop) { + return false + } + return intent.requestedAtItemCount !== intent.itemCount +} diff --git a/src/renderer/src/components/native-chat/native-chat-disclosure-store.ts b/src/renderer/src/components/native-chat/native-chat-disclosure-store.ts new file mode 100644 index 00000000000..956ae3b0ca1 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-disclosure-store.ts @@ -0,0 +1,71 @@ +// Where a transcript row's open/closed disclosures live when the row itself may +// be unmounted. +// +// A tool run the reader opened is state they created. Held in the run's own +// `useState` it survives exactly as long as the row is mounted, which under +// windowing is "until you scroll past it" — the run silently re-collapses behind +// the reader's back. Rows read through this store when the transcript provides +// one, and fall back to their own state when they are rendered standalone. + +import { createContext, useCallback, useContext, useMemo, useState } from 'react' + +export type NativeChatDisclosureStore = { + read: (key: string) => boolean | undefined + write: (key: string, open: boolean) => void +} + +export const NativeChatDisclosureContext = createContext(null) + +/** Bounded like the transcript's other per-turn map: a session that ran for a day + * should not carry every disclosure it ever opened. */ +export const MAX_NATIVE_CHAT_DISCLOSURES = 512 + +export function useNativeChatDisclosures(): NativeChatDisclosureStore { + const [open, setOpen] = useState>(() => new Map()) + const write = useCallback((key: string, next: boolean) => { + setOpen((current) => { + if (current.get(key) === next) { + return current + } + const updated = new Map(current) + updated.set(key, next) + if (updated.size > MAX_NATIVE_CHAT_DISCLOSURES) { + const oldest = updated.keys().next().value + if (oldest !== undefined && oldest !== key) { + updated.delete(oldest) + } + } + return updated + }) + }, []) + return useMemo(() => ({ read: (key: string) => open.get(key), write }), [open, write]) +} + +export type NativeChatDisclosure = { + open: boolean + /** A reader's choice: remembered past this row's lifetime when keyed. */ + setOpen: (next: boolean) => void +} + +export function useNativeChatDisclosure( + key: string | undefined, + initialOpen: boolean +): NativeChatDisclosure { + const store = useContext(NativeChatDisclosureContext) + const [local, setLocal] = useState({ key, initialOpen, open: initialOpen }) + const write = store?.write + const isStored = key !== undefined && store !== null + const localOpen = + local.key === key && local.initialOpen === initialOpen ? local.open : initialOpen + const open = isStored ? (store.read(key) ?? localOpen) : localOpen + const setOpen = useCallback( + (next: boolean) => { + setLocal({ key, initialOpen, open: next }) + if (key !== undefined && write) { + write(key, next) + } + }, + [initialOpen, key, write] + ) + return { open, setOpen } +} diff --git a/src/renderer/src/components/native-chat/native-chat-message-list-test-viewport.ts b/src/renderer/src/components/native-chat/native-chat-message-list-test-viewport.ts new file mode 100644 index 00000000000..b997ccf83c3 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-message-list-test-viewport.ts @@ -0,0 +1,26 @@ +const TEST_VIEWPORT_HEIGHT_PX = 1_000_000 +const TEST_ROW_HEIGHT_PX = 48 + +/** Give non-windowing component tests a measurable viewport that contains every fixture row. */ +export function installNativeChatMessageListTestViewport(): () => void { + const original = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight') + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { + configurable: true, + get(this: HTMLElement): number { + if (this.hasAttribute('data-native-chat-scroll')) { + return TEST_VIEWPORT_HEIGHT_PX + } + if (this.dataset.index !== undefined) { + return TEST_ROW_HEIGHT_PX + } + return original?.get?.call(this) ?? 0 + } + }) + return () => { + if (original) { + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', original) + } else { + Reflect.deleteProperty(HTMLElement.prototype, 'offsetHeight') + } + } +} diff --git a/src/renderer/src/components/native-chat/native-chat-pinned-rows.test.ts b/src/renderer/src/components/native-chat/native-chat-pinned-rows.test.ts new file mode 100644 index 00000000000..114df41cf13 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-pinned-rows.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { nativeChatPinnedRowIndexes, nativeChatTranscriptRange } from './native-chat-pinned-rows' + +describe('pinned transcript rows', () => { + it('pins the live row at the end of the transcript', () => { + expect([...nativeChatPinnedRowIndexes({ count: 40 })]).toEqual([39]) + }) + + it('pins nothing when there is nothing to pin', () => { + expect(nativeChatPinnedRowIndexes({ count: 0 }).size).toBe(0) + }) + + it('pins a row a reveal is aimed at, wherever it sits', () => { + expect([...nativeChatPinnedRowIndexes({ count: 40, revealIndex: 3 })].sort((a, b) => a - b)) // + .toEqual([3, 39]) + }) + + it('ignores a reveal target that is no longer in the transcript', () => { + expect([...nativeChatPinnedRowIndexes({ count: 5, revealIndex: -1 })]).toEqual([4]) + expect([...nativeChatPinnedRowIndexes({ count: 5, revealIndex: 99 })]).toEqual([4]) + }) +}) + +describe('transcript window range', () => { + const range = { startIndex: 10, endIndex: 12, overscan: 2, count: 100 } + + it('widens the window by overscan on both sides', () => { + expect(nativeChatTranscriptRange(range, new Set())).toEqual([8, 9, 10, 11, 12, 13, 14]) + }) + + it('clamps to the transcript at both ends', () => { + expect( + nativeChatTranscriptRange({ startIndex: 0, endIndex: 1, overscan: 3, count: 3 }, new Set()) + ).toEqual([0, 1, 2]) + }) + + it('adds pinned rows outside the window, in transcript order and without repeats', () => { + expect(nativeChatTranscriptRange(range, new Set([99, 11, 0]))).toEqual([ + 0, 8, 9, 10, 11, 12, 13, 14, 99 + ]) + }) + + it('drops a pinned row that is out of bounds rather than mounting nothing at it', () => { + expect(nativeChatTranscriptRange(range, new Set([100, -1]))).toEqual([8, 9, 10, 11, 12, 13, 14]) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-pinned-rows.ts b/src/renderer/src/components/native-chat/native-chat-pinned-rows.ts new file mode 100644 index 00000000000..f675f84c4a8 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-pinned-rows.ts @@ -0,0 +1,57 @@ +// Rows the transcript keeps mounted no matter where the window sits, and the +// range extraction that folds them into the virtualizer's own range. + +/** The virtualizer's range, restated so this module needs nothing from the lib. */ +export type NativeChatWindowRange = { + startIndex: number + endIndex: number + overscan: number + count: number +} + +export type NativeChatPinnedRowsInput = { + count: number + /** Row holding a diff the transcript was asked to reveal; it has to be mounted + * for its card to expand and report where to scroll. */ + revealIndex?: number | null +} + +/** Indexes that stay mounted outside the window. + * + * The last row is pinned because it is the live one: a running tool announces + * itself through `aria-live`, which says nothing from a row that isn't in the + * document, and its measured height is what keeps the bottom pin honest while + * a turn streams. */ +export function nativeChatPinnedRowIndexes({ + count, + revealIndex +}: NativeChatPinnedRowsInput): ReadonlySet { + const pinned = new Set() + if (count <= 0) { + return pinned + } + pinned.add(count - 1) + if (revealIndex != null && revealIndex >= 0 && revealIndex < count) { + pinned.add(revealIndex) + } + return pinned +} + +/** The window's own range widened by overscan, plus the pinned rows. */ +export function nativeChatTranscriptRange( + range: NativeChatWindowRange, + pinned: ReadonlySet +): number[] { + const first = Math.max(range.startIndex - range.overscan, 0) + const last = Math.min(range.endIndex + range.overscan, range.count - 1) + const indexes = new Set() + for (let index = first; index <= last; index += 1) { + indexes.add(index) + } + for (const index of pinned) { + if (index >= 0 && index < range.count) { + indexes.add(index) + } + } + return Array.from(indexes).sort((left, right) => left - right) +} diff --git a/src/renderer/src/components/native-chat/native-chat-row-content.ts b/src/renderer/src/components/native-chat/native-chat-row-content.ts new file mode 100644 index 00000000000..e410a2b2099 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-row-content.ts @@ -0,0 +1,61 @@ +// One derivation of a message's renderable parts, shared by the row that draws it +// and the list that decides whether it occupies a transcript slot. Windowing makes +// that agreement load-bearing: a row the list counts but the row component declines +// to draw would reserve estimated height for nothing. +// +// Cached on the block array itself, so a streaming turn re-deriving on every frame +// pays once per revision rather than once per consumer. + +import { + isSubagentGroupFallbackText, + subagentGroupBlocks +} from '../../../../shared/native-chat-subagent-summary' +import { isSubagentGroupBlock, type NativeChatBlock } from '../../../../shared/native-chat-types' +import { splitNativeChatBlocks } from './native-chat-tool-fold' +import { nativeChatProseToMarkdown } from './native-chat-prose' + +/** Inferred from `derive` so the shape cannot drift from what it returns. */ +export type NativeChatRowContent = ReturnType + +const derivations = new WeakMap() + +function derive(blocks: readonly NativeChatBlock[]) { + const split = splitNativeChatBlocks(blocks) + const groups = subagentGroupBlocks(split.prose) + // A spawn-group row carries a plain-text twin so a client without the block type + // still reads the roster. This draws the block, so only the twin is dropped — + // never real text beside it, which a lane folding a roster into a message keeps. + const prose = + groups.length === 0 + ? split.prose + : split.prose.filter( + (block) => + !isSubagentGroupBlock(block) && + !(block.type === 'text' && isSubagentGroupFallbackText(block.text)) + ) + return { + prose, + tools: split.tools, + subagentGroups: groups, + markdown: nativeChatProseToMarkdown(prose), + hasImages: prose.some((block) => block.type === 'image-ref') + } +} + +export function deriveNativeChatRowContent( + blocks: readonly NativeChatBlock[] +): NativeChatRowContent { + const cached = derivations.get(blocks) + if (cached) { + return cached + } + const content = derive(blocks) + derivations.set(blocks, content) + return content +} + +/** Whether the row draws anything. An empty row takes no slot in the transcript. */ +export function nativeChatRowRendersContent(blocks: readonly NativeChatBlock[]): boolean { + const { markdown, hasImages, tools, subagentGroups } = deriveNativeChatRowContent(blocks) + return markdown.length > 0 || hasImages || tools.length > 0 || subagentGroups.length > 0 +} diff --git a/src/renderer/src/components/native-chat/native-chat-row-height-estimate.test.ts b/src/renderer/src/components/native-chat/native-chat-row-height-estimate.test.ts new file mode 100644 index 00000000000..0fc03ebc34b --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-row-height-estimate.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { + estimateNativeChatRowHeight, + estimateNativeChatTextLines, + NATIVE_CHAT_ROW_GAP_PX, + nativeChatRowContentMetrics +} from './native-chat-row-height-estimate' + +const NO_CHROME = { hasReceipt: false, hasStatus: false, hasTurnDiff: false } + +function message(text: string, role: NativeChatMessage['role'] = 'assistant'): NativeChatMessage { + return { + id: `m-${text.length}`, + role, + blocks: [{ type: 'text', text }], + timestamp: 1, + source: 'transcript' + } +} + +describe('transcript row height estimate', () => { + it('counts hard breaks and soft wraps as separate display lines', () => { + expect(estimateNativeChatTextLines('')).toBe(0) + expect(estimateNativeChatTextLines('one line')).toBe(1) + expect(estimateNativeChatTextLines('one\ntwo\nthree')).toBe(3) + // A single 300-character paragraph wraps rather than staying one line. + expect(estimateNativeChatTextLines('x'.repeat(300))).toBeGreaterThan(1) + // A blank line still occupies one. + expect(estimateNativeChatTextLines('a\n\nb')).toBe(3) + }) + + it('grows with the prose it is estimating', () => { + const short = estimateNativeChatRowHeight( + nativeChatRowContentMetrics(message('one line')), + NO_CHROME + ) + const long = estimateNativeChatRowHeight( + nativeChatRowContentMetrics(message(Array.from({ length: 40 }, () => 'line').join('\n'))), + NO_CHROME + ) + expect(long).toBeGreaterThan(short) + }) + + it('bounds the estimate at both ends', () => { + const empty = estimateNativeChatRowHeight(nativeChatRowContentMetrics(message('')), NO_CHROME) + const enormous = estimateNativeChatRowHeight( + nativeChatRowContentMetrics(message('line\n'.repeat(5000))), + NO_CHROME + ) + expect(empty).toBeGreaterThan(0) + expect(enormous).toBeLessThan(5000 * 22) + }) + + // The gap between rows belongs to the window, which puts one between every + // pair. A row that also charged for it would sit one gap lower than the row + // above it, and the drift compounds the whole way down the transcript. + // + // Stated without naming any constant: a prose row is its lines and nothing + // else, so its estimate has to be exactly its line count times what one more + // line costs. Any fixed amount riding along — a gap above all — breaks that. + it('charges a prose row for its lines and for nothing else', () => { + const lines = (count: number): number => + estimateNativeChatRowHeight( + nativeChatRowContentMetrics(message(Array.from({ length: count }, () => 'x').join('\n'))), + NO_CHROME + ) + const perLine = lines(10) - lines(9) + + expect(perLine).toBeGreaterThan(0) + expect(lines(10)).toBe(10 * perLine) + }) + + // The parts stacked INSIDE one row do pay for the gap between them, because + // that gap is inside the height the row will be measured at. + it('charges a row for the gap above a turn status it carries', () => { + const metrics = nativeChatRowContentMetrics(message('one line')) + const bare = estimateNativeChatRowHeight(metrics, NO_CHROME) + const withStatus = estimateNativeChatRowHeight(metrics, { ...NO_CHROME, hasStatus: true }) + + expect(withStatus - bare).toBeGreaterThan(NATIVE_CHAT_ROW_GAP_PX) + }) + + it('does not add a leading gap to status-only or diff-only rows', () => { + const empty = nativeChatRowContentMetrics(message('')) + const bare = estimateNativeChatRowHeight(empty, NO_CHROME) + const statusOnly = estimateNativeChatRowHeight(empty, { ...NO_CHROME, hasStatus: true }) + const diffOnly = estimateNativeChatRowHeight(empty, { ...NO_CHROME, hasTurnDiff: true }) + + expect(statusOnly).toBe(diffOnly) + expect(statusOnly - bare).toBeLessThan(NATIVE_CHAT_ROW_GAP_PX) + }) + + it('includes both rendered parts and their gap for a receipt carrying a diff', () => { + const empty = nativeChatRowContentMetrics(message('')) + const receipt = estimateNativeChatRowHeight(empty, { + hasReceipt: true, + hasStatus: false, + hasTurnDiff: false + }) + const diff = estimateNativeChatRowHeight(empty, { + hasReceipt: false, + hasStatus: false, + hasTurnDiff: true + }) + const together = estimateNativeChatRowHeight(empty, { + hasReceipt: true, + hasStatus: false, + hasTurnDiff: true + }) + + expect(together).toBe(receipt + NATIVE_CHAT_ROW_GAP_PX + diff) + }) + + it('reuses one derivation per message', () => { + const subject = message('cached') + expect(nativeChatRowContentMetrics(subject)).toBe(nativeChatRowContentMetrics(subject)) + }) + + it('keeps role-specific chrome when two messages share their blocks', () => { + const blocks: NativeChatMessage['blocks'] = [{ type: 'text', text: 'same content' }] + const withRole = (role: NativeChatMessage['role']): NativeChatMessage => ({ + ...message('same content', role), + blocks + }) + + expect(nativeChatRowContentMetrics(withRole('user')).role).toBe('user') + expect(nativeChatRowContentMetrics(withRole('assistant')).role).toBe('assistant') + }) + + it('reserves more for a row carrying a tool run than for its prose alone', () => { + const prose = nativeChatRowContentMetrics(message('ran something')) + const withTool = nativeChatRowContentMetrics({ + id: 'tool', + role: 'assistant', + blocks: [ + { type: 'text', text: 'ran something' }, + { type: 'tool-call', name: 'shell', input: { command: 'ls' }, state: 'completed' } + ], + timestamp: 1, + source: 'transcript' + }) + expect(estimateNativeChatRowHeight(withTool, NO_CHROME)).toBeGreaterThan( + estimateNativeChatRowHeight(prose, NO_CHROME) + ) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-row-height-estimate.ts b/src/renderer/src/components/native-chat/native-chat-row-height-estimate.ts new file mode 100644 index 00000000000..4d8d21edacc --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-row-height-estimate.ts @@ -0,0 +1,126 @@ +// First-paint height for a transcript row. Windowing has to place every row — +// including the thousand nobody has looked at — before any of them has been +// measured, so the estimate only has to be close enough that the scrollbar +// doesn't lurch once the real measurement lands. +// +// Deliberately arithmetic over already-derived content: `estimateSize` is called +// once per item every time a measurement resolves, so anything that walks blocks +// or joins strings here would turn one row's ResizeObserver callback into a +// whole-transcript scan. + +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { deriveNativeChatRowContent } from './native-chat-row-content' + +/** What a row contains, reduced to the few numbers that drive its height. */ +export type NativeChatRowContentMetrics = { + role: NativeChatMessage['role'] + /** Wrapped display lines of prose, not source lines. */ + textLines: number + imageCount: number + toolCount: number + subagentGroupCount: number +} + +/** Extras the message itself doesn't know about — they come from the turn. */ +export type NativeChatRowChromeMetrics = { + hasReceipt: boolean + hasStatus: boolean + hasTurnDiff: boolean +} + +const LINE_HEIGHT_PX = 22 +const CHARS_PER_LINE = 96 +const PROSE_MIN_LINES = 1 +const USER_BUBBLE_CHROME_PX = 32 +const IMAGE_STRIP_PX = 88 +const TOOL_RUN_PX = 40 +const SUBAGENT_ROW_PX = 32 +const STATUS_ROW_PX = 28 +const TURN_DIFF_PX = 28 +const RECEIPT_PX = 56 +const ROW_MIN_PX = 24 +/** `gap-5` between the parts stacked inside one row's wrapper. The identical gap + * BETWEEN rows is the virtualizer's `gap` option and must never be added here: + * counted in both places every row would sit 20px lower than the one above it. */ +export const NATIVE_CHAT_ROW_GAP_PX = 20 +// A single row can legitimately be enormous (a pasted file, an open diff). The +// cap only bounds the *estimate*: measurement replaces it as soon as the row +// mounts, and an estimate the size of ten viewports makes the scrollbar useless +// until then. +const ROW_MAX_PX = 1600 + +/** Wrapped line count for a markdown body, counting hard breaks and soft wraps. */ +export function estimateNativeChatTextLines(markdown: string): number { + if (markdown.length === 0) { + return 0 + } + let lines = 0 + let lineStart = 0 + for (let index = 0; index <= markdown.length; index += 1) { + if (index === markdown.length || markdown[index] === '\n') { + const length = index - lineStart + lines += Math.max(PROSE_MIN_LINES, Math.ceil(length / CHARS_PER_LINE)) + lineStart = index + 1 + } + } + return lines +} + +const metricsCache = new WeakMap() + +/** Cached on the message, so its role remains part of the identity and a streaming turn + * re-deriving on every frame pays for the changed row only. */ +export function nativeChatRowContentMetrics( + message: NativeChatMessage +): NativeChatRowContentMetrics { + const cached = metricsCache.get(message) + if (cached) { + return cached + } + const content = deriveNativeChatRowContent(message.blocks) + const metrics: NativeChatRowContentMetrics = { + role: message.role, + textLines: estimateNativeChatTextLines(content.markdown), + imageCount: content.prose.filter((block) => block.type === 'image-ref').length, + toolCount: content.tools.length, + subagentGroupCount: content.subagentGroups.length + } + metricsCache.set(message, metrics) + return metrics +} + +export function estimateNativeChatRowHeight( + content: NativeChatRowContentMetrics, + chrome: NativeChatRowChromeMetrics +): number { + let partCount = 0 + let height = 0 + if (chrome.hasReceipt) { + height = RECEIPT_PX + partCount = 1 + } else { + height = content.textLines * LINE_HEIGHT_PX + if (content.role === 'user' && content.textLines > 0) { + height += USER_BUBBLE_CHROME_PX + } + if (content.imageCount > 0) { + height += IMAGE_STRIP_PX + } + if (content.toolCount > 0) { + // A run is one collapsed header by default; its members only exist while open. + height += TOOL_RUN_PX + } + height += content.subagentGroupCount * SUBAGENT_ROW_PX + partCount = height > 0 ? 1 : 0 + } + if (chrome.hasStatus) { + height += STATUS_ROW_PX + partCount += 1 + } + if (chrome.hasTurnDiff) { + height += TURN_DIFF_PX + partCount += 1 + } + height += Math.max(0, partCount - 1) * NATIVE_CHAT_ROW_GAP_PX + return Math.min(ROW_MAX_PX, Math.max(ROW_MIN_PX, height)) +} diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts new file mode 100644 index 00000000000..e3ad2ff7e77 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import type { NativeChatTurnStatus } from '../../../../shared/native-chat-turn-status' +import type { NativeChatResolvedPrompt } from './native-chat-resolution-receipt' +import type { NativeChatTurnDiff } from './native-chat-turn-diffs' +import { + buildNativeChatTranscriptSlots, + nativeChatSlotIndexOf +} from './native-chat-transcript-slots' + +const NO_STATUSES = { active: null, completedByTurn: {} } + +function text(id: string, body: string, role: NativeChatMessage['role'] = 'assistant') { + return { + id, + role, + blocks: [{ type: 'text' as const, text: body }], + timestamp: 1, + source: 'transcript' as const + } +} + +function build( + messages: NativeChatMessage[], + overrides: Partial[0]> = {} +) { + let turn: string | undefined + const turnKeys = messages.map((message) => { + if (message.role === 'user') { + turn = message.id + } + return turn + }) + return buildNativeChatTranscriptSlots({ + messages, + turnKeys, + latestUserIndex: messages.findLastIndex((message) => message.role === 'user'), + currentTurnKey: undefined, + receipts: new Map(), + turnStatuses: NO_STATUSES, + turnDiffs: new Map(), + showTurnStatus: true, + showTypingIndicator: false, + isWorking: false, + lifecycleWorking: false, + ...overrides + }) +} + +describe('transcript slots', () => { + // A counted row that draws nothing is a gap in the transcript: it reserves + // estimated height for a bubble that never appears. + it('gives no slot to a message with nothing to draw', () => { + const slots = build([text('a', 'visible'), text('blank', ''), text('b', 'also visible')]) + expect(slots.map((slot) => slot.message.id)).toEqual(['a', 'b']) + }) + + it('keeps a message whose only content is a turn status under it', () => { + const status: NativeChatTurnStatus = { startedAt: 1, thinking: false, workedSeconds: 4 } + const slots = build([text('u', '', 'user')], { + latestUserIndex: 0, + turnStatuses: { active: status, completedByTurn: {} } + }) + expect(slots).toHaveLength(1) + expect(slots[0]?.status).toBe(status) + }) + + it('keeps a message whose only content is its turn diff rollup', () => { + const diff: NativeChatTurnDiff = { files: [], added: 1, removed: 0, truncated: false } + const slots = build([text('u', 'ask', 'user'), text('blank', '')], { + turnDiffs: new Map([['u', diff]]) + }) + expect(slots.map((slot) => slot.message.id)).toEqual(['u', 'blank']) + expect(slots[1]?.turnDiff).toBe(diff) + }) + + it('keeps a resolved prompt that stands in for a message drawing nothing', () => { + const receipt = { + kind: 'approval', + title: 'Run it?', + resolution: { state: 'resolved', selectedOptionId: 'yes' } + } as unknown as NativeChatResolvedPrompt + const slots = build([text('blank', '')], { receipts: new Map([['blank', receipt]]) }) + expect(slots).toHaveLength(1) + expect(slots[0]?.receipt).toBe(receipt) + }) + + it('hides the running turn status until the turn has something to say', () => { + const status: NativeChatTurnStatus = { startedAt: 1, thinking: false, workedSeconds: null } + const slots = build([text('u', 'ask', 'user')], { + latestUserIndex: 0, + turnStatuses: { active: status, completedByTurn: {} }, + isWorking: true, + showTypingIndicator: false + }) + expect(slots[0]?.status).toBeUndefined() + }) + + it('reserves a height for every slot it keeps', () => { + for (const slot of build([text('a', 'one'), text('b', 'two\nlines')])) { + expect(slot.estimatedHeight).toBeGreaterThan(0) + } + }) + + it('finds the slot a reveal names, and reports -1 for one that has no slot', () => { + const slots = build([text('a', 'visible'), text('blank', ''), text('b', 'also visible')]) + expect(nativeChatSlotIndexOf(slots, 'b')).toBe(1) + expect(nativeChatSlotIndexOf(slots, 'blank')).toBe(-1) + expect(nativeChatSlotIndexOf(slots, undefined)).toBe(-1) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts new file mode 100644 index 00000000000..dc21fb35164 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts @@ -0,0 +1,119 @@ +// One transcript slot per row the reader can actually see. +// +// Without windowing "a message that draws nothing" costs nothing: React renders +// null and the flex column lays out what's left. With windowing every entry is a +// counted index that reserves estimated height, so a message the list counts and +// the row declines to draw becomes a gap in the transcript. This module is the +// single place that answers "does this message take a slot?", and it answers it +// with the same derivation the row itself renders from. + +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import type { NativeChatTurnStatus } from '../../../../shared/native-chat-turn-status' +import { nativeChatRowRendersContent } from './native-chat-row-content' +import { + estimateNativeChatRowHeight, + nativeChatRowContentMetrics +} from './native-chat-row-height-estimate' +import type { NativeChatResolvedPrompt } from './native-chat-resolution-receipt' +import type { NativeChatTurnDiff } from './native-chat-turn-diffs' + +export type NativeChatTranscriptSlot = { + message: NativeChatMessage + turnKey: string | undefined + /** The row's own turn is the one still running, so its tools stay live. */ + activeTurnIsWorking: boolean + /** Resolved approval/question stands in for the message it answered. */ + receipt: NativeChatResolvedPrompt | undefined + /** Turn timing shown under this row, already filtered to "should render". */ + status: NativeChatTurnStatus | undefined + turnDiff: NativeChatTurnDiff | undefined + /** Height to reserve before the row has ever been measured. */ + estimatedHeight: number +} + +export type NativeChatTranscriptSlotsInput = { + messages: readonly NativeChatMessage[] + turnKeys: readonly (string | undefined)[] + latestUserIndex: number + currentTurnKey: string | undefined + receipts: ReadonlyMap + turnStatuses: { + active: NativeChatTurnStatus | null + completedByTurn: Readonly> + } + turnDiffs: ReadonlyMap + showTurnStatus: boolean + showTypingIndicator: boolean + isWorking: boolean + /** Session-level lifecycle, which outlives a transcript that never said "done". */ + lifecycleWorking: boolean +} + +export function buildNativeChatTranscriptSlots( + input: NativeChatTranscriptSlotsInput +): NativeChatTranscriptSlot[] { + const { + messages, + turnKeys, + latestUserIndex, + currentTurnKey, + receipts, + turnStatuses, + turnDiffs, + showTurnStatus, + showTypingIndicator, + isWorking, + lifecycleWorking + } = input + const slots: NativeChatTranscriptSlot[] = [] + for (const [index, message] of messages.entries()) { + const turnKey = turnKeys[index] + const receipt = receipts.get(message.id) + const candidateStatus = + index === latestUserIndex + ? turnStatuses.active + : message.role === 'user' && turnKey + ? turnStatuses.completedByTurn[turnKey] + : undefined + const status = + showTurnStatus && + candidateStatus && + (index !== latestUserIndex || showTypingIndicator || !isWorking) + ? candidateStatus + : undefined + const turnDiff = turnKey && turnKeys[index + 1] !== turnKey ? turnDiffs.get(turnKey) : undefined + const drawsRow = receipt !== undefined || nativeChatRowRendersContent(message.blocks) + if (!drawsRow && status === undefined && turnDiff === undefined) { + continue + } + slots.push({ + message, + turnKey, + activeTurnIsWorking: + showTurnStatus && + (currentTurnKey ? turnKey === currentTurnKey : turnKey === undefined) && + (isWorking || lifecycleWorking), + receipt, + status: status ?? undefined, + turnDiff, + estimatedHeight: estimateNativeChatRowHeight(nativeChatRowContentMetrics(message), { + hasReceipt: receipt !== undefined, + hasStatus: status !== undefined, + hasTurnDiff: turnDiff !== undefined + }) + }) + } + return slots +} + +/** Slot index of a message id, or -1. Reveal targets arrive as ids because the + * row that owns them may not be mounted to be pointed at. */ +export function nativeChatSlotIndexOf( + slots: readonly NativeChatTranscriptSlot[], + messageId: string | undefined +): number { + if (messageId === undefined) { + return -1 + } + return slots.findIndex((slot) => slot.message.id === messageId) +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.ts b/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.ts new file mode 100644 index 00000000000..7c9549a1f13 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.ts @@ -0,0 +1,145 @@ +// The transcript's scroll behaviour: staying pinned to the bottom while a turn +// streams, offering the way back when the reader has left, aligning a row or a +// card to the top, and paging in older history. +// +// Split from the list because windowing changed what these have to be careful +// about, not what they decide: rows resolving their measured height move the +// content constantly, so "the content changed" and "the reader scrolled" stopped +// being the same event and only the latter may ask for another page. + +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { + isNearBottom, + shouldLoadEarlier, + shouldShowJumpToLatest, + type ScrollGeometry +} from './native-chat-autoscroll' + +function geometryOf(element: HTMLElement): ScrollGeometry { + return { + scrollTop: element.scrollTop, + scrollHeight: element.scrollHeight, + clientHeight: element.clientHeight + } +} + +export type NativeChatTranscriptScroll = { + showJump: boolean + onScroll: () => void + scrollToBottom: () => void + /** Align an element inside the transcript with the top of the viewport. */ + scrollMessageToTop: (element: HTMLElement) => void +} + +export function useNativeChatTranscriptScroll({ + scrollRef, + contentRef, + itemCount, + isWorking, + showTypingIndicator, + hasMore, + loadingEarlier, + loadEarlier, + alignToViewportTop +}: { + scrollRef: React.RefObject + contentRef: React.RefObject + itemCount: number + isWorking: boolean + showTypingIndicator: boolean + hasMore: boolean + loadingEarlier: boolean + loadEarlier: () => void + alignToViewportTop: (element: HTMLElement) => void +}): NativeChatTranscriptScroll { + const [showJump, setShowJump] = useState(false) + const stuckToBottomRef = useRef(true) + const previousScrollTopRef = useRef(0) + const loadEarlierRequestedAtRef = useRef(null) + + const syncScrollState = useCallback((): ScrollGeometry | null => { + const element = scrollRef.current + if (!element) { + return null + } + const geometry = geometryOf(element) + const stick = isNearBottom(geometry) + stuckToBottomRef.current = stick + setShowJump(shouldShowJumpToLatest(stick, geometry)) + return geometry + }, [scrollRef]) + + // Only a real scroll event pages in older history. Every row that resolves its + // true height moves the content and re-fires the size observers; routing those + // through here too would ask for the next page once per measurement. + const onScroll = useCallback(() => { + const geometry = syncScrollState() + if (!geometry) { + return + } + const previousScrollTop = previousScrollTopRef.current + previousScrollTopRef.current = geometry.scrollTop + if ( + shouldLoadEarlier({ + geometry, + previousScrollTop, + hasMore, + loadingEarlier, + itemCount, + requestedAtItemCount: loadEarlierRequestedAtRef.current + }) + ) { + loadEarlierRequestedAtRef.current = itemCount + loadEarlier() + } + }, [hasMore, itemCount, loadEarlier, loadingEarlier, syncScrollState]) + + const scrollToBottom = useCallback(() => { + const element = scrollRef.current + if (!element) { + return + } + // The document's own bottom, not the window's last row: the typing indicator, + // the activity line and the column's end padding all live past it. + element.scrollTop = element.scrollHeight + stuckToBottomRef.current = true + setShowJump(false) + }, [scrollRef]) + + const scrollMessageToTop = useCallback( + (element: HTMLElement) => { + stuckToBottomRef.current = false + alignToViewportTop(element) + }, + [alignToViewportTop] + ) + + useLayoutEffect(() => { + if (stuckToBottomRef.current) { + scrollToBottom() + } + }, [itemCount, isWorking, showTypingIndicator, scrollToBottom]) + + useEffect(() => { + const element = scrollRef.current + if (!element || typeof ResizeObserver === 'undefined') { + return + } + const observer = new ResizeObserver(() => { + if (stuckToBottomRef.current) { + scrollToBottom() + } else { + syncScrollState() + } + }) + // Observe the growing content, not just the fixed-height viewport, so an + // in-place streaming growth is seen; also watch the viewport for reflows. + observer.observe(element) + if (contentRef.current) { + observer.observe(contentRef.current) + } + return () => observer.disconnect() + }, [contentRef, scrollRef, scrollToBottom, syncScrollState]) + + return { showJump, onScroll, scrollToBottom, scrollMessageToTop } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx new file mode 100644 index 00000000000..0a71ac8e905 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx @@ -0,0 +1,121 @@ +// @vitest-environment happy-dom + +import { cleanup, renderHook } from '@testing-library/react' +import type { VirtualItem } from '@tanstack/react-virtual' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatTranscriptSlot } from './native-chat-transcript-slots' + +type VirtualizerOptionsCapture = { + current: + | ({ count: number; getItemKey: (index: number) => VirtualItem['key'] } & Record< + string, + unknown + >) + | null +} + +const virtualizerMock = vi.hoisted(() => ({ + options: { current: null } as VirtualizerOptionsCapture, + getTotalSize: vi.fn(() => 0), + getVirtualItems: vi.fn(() => []), + measureElement: vi.fn(), + measure: vi.fn(), + resizeItem: vi.fn(), + scrollToOffset: vi.fn(), + takeSnapshot: vi.fn<() => VirtualItem[]>(() => []) +})) + +vi.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: (options: VirtualizerOptionsCapture['current']) => { + virtualizerMock.options.current = options + return { ...virtualizerMock, scrollElement: null } + } +})) + +const { MAX_RETIRED_NATIVE_CHAT_MEASUREMENTS, useNativeChatTranscriptWindow } = + await import('./use-native-chat-transcript-window') + +function slot(id: string): NativeChatTranscriptSlot { + return { + message: { + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: 1, + source: 'transcript' + }, + turnKey: undefined, + activeTurnIsWorking: false, + receipt: undefined, + status: undefined, + turnDiff: undefined, + estimatedHeight: 48 + } +} + +afterEach(() => { + cleanup() + virtualizerMock.options.current = null + vi.clearAllMocks() +}) + +describe('native chat transcript virtualizer contract', () => { + it('configures prepend anchoring and matching bottom-follow behavior', () => { + renderHook(() => + useNativeChatTranscriptWindow({ + scrollRef: { current: null }, + slots: [], + revealIndex: -1 + }) + ) + + expect(virtualizerMock.options.current).toMatchObject({ + anchorTo: 'end', + followOnAppend: true, + scrollEndThreshold: 48 + }) + }) + + it('periodically resets retired measurements while restoring live measured sizes', () => { + const scrollElement = document.createElement('div') + scrollElement.scrollTop = 320 + virtualizerMock.takeSnapshot.mockImplementation(() => { + const key = virtualizerMock.options.current?.getItemKey(0) ?? 'message-0' + return [{ index: 0, key, start: 0, size: 96, end: 96, lane: 0 }] + }) + const { rerender } = renderHook( + ({ id }) => + useNativeChatTranscriptWindow({ + scrollRef: { current: scrollElement }, + slots: [slot(id)], + revealIndex: -1 + }), + { initialProps: { id: 'message-0' } } + ) + + for (let index = 1; index <= MAX_RETIRED_NATIVE_CHAT_MEASUREMENTS; index += 1) { + rerender({ id: `message-${index}` }) + } + + expect(virtualizerMock.measure).toHaveBeenCalledOnce() + expect(virtualizerMock.resizeItem).toHaveBeenCalledExactlyOnceWith(0, 96) + expect(virtualizerMock.scrollToOffset).toHaveBeenCalledExactlyOnceWith(320) + }) + + it('keeps item-key lookup stable across content-only row revisions', () => { + const scrollRef = { current: null } + const { rerender } = renderHook( + ({ text }) => { + const current = slot('message-0') + current.message.blocks = [{ type: 'text', text }] + return useNativeChatTranscriptWindow({ scrollRef, slots: [current], revealIndex: -1 }) + }, + { initialProps: { text: 'first' } } + ) + const getItemKey = virtualizerMock.options.current?.getItemKey + + rerender({ text: 'streamed revision' }) + + expect(virtualizerMock.options.current?.getItemKey).toBe(getItemKey) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts b/src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts new file mode 100644 index 00000000000..0dacb9319b0 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts @@ -0,0 +1,230 @@ +// DOM windowing for the transcript: only the rows near the viewport are mounted, +// the rest are reserved as estimated height. +// +// Anchoring is the library's, not ours. `anchorTo: 'end'` captures the row at the +// current offset before a count change and re-resolves its position afterwards, +// which is what keeps a "load earlier" prepend from yanking the view; +// `followOnAppend` + `scrollEndThreshold` keep a reader who is already at the +// bottom pinned there as a turn streams. +// +// Every measurement here ends up in the scroll container's own coordinate space, +// which means `offsetTop` / `offsetHeight` rather than a bounding rect. The +// transcript is zoomable, and a rect is in viewport pixels while `scrollTop` is +// not: mixing the two puts the window out of place by exactly the zoom factor. +// One path does read rects, and it converts them back before using them. + +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useVirtualizer, type VirtualItem } from '@tanstack/react-virtual' +import { NATIVE_CHAT_BOTTOM_THRESHOLD_PX } from './native-chat-autoscroll' +import { NATIVE_CHAT_ROW_GAP_PX } from './native-chat-row-height-estimate' +import { nativeChatPinnedRowIndexes, nativeChatTranscriptRange } from './native-chat-pinned-rows' +import type { NativeChatTranscriptSlot } from './native-chat-transcript-slots' + +/** Rows kept mounted past each edge of the viewport. Chat rows are tall and + * arbitrarily expensive, so this buys smoothness by the row, not by the screen. */ +export const NATIVE_CHAT_WINDOW_OVERSCAN = 6 + +const FALLBACK_ROW_PX = 48 +/** Retired keys are harmless to layout but otherwise accumulate for the pane's + * lifetime as a capped transcript advances. Compact them well before the stale + * entries become material compared with the live window. */ +export const MAX_RETIRED_NATIVE_CHAT_MEASUREMENTS = 512 + +export type NativeChatTranscriptWindow = { + virtualItems: VirtualItem[] + totalSize: number + scrollMargin: number + sizerRef: (node: HTMLDivElement | null) => void + measureRow: (node: HTMLElement | null) => void + /** Scroll so this element's top meets the top of the viewport. */ + alignToViewportTop: (element: HTMLElement) => void +} + +/** Distance from a container's scroll origin down to a descendant, in the + * container's own scroll pixels, or null when there is no chain to walk. + * Absolutely positioned windowed rows are placed with `top`, never a transform, + * so `offsetTop` stays true through the window as well. */ +export function nativeChatScrollOffsetWithin( + element: HTMLElement, + container: HTMLElement +): number | null { + let top = 0 + let node: HTMLElement | null = element + while (node !== null && node !== container) { + top += node.offsetTop + // A DOM without layout has no `offsetParent` at all; that ends the chain + // rather than walking into nothing, and the caller reads the null as + // "cannot place this yet". + const parent = node.offsetParent as HTMLElement | null | undefined + node = parent && typeof parent.offsetTop === 'number' ? parent : null + } + return node === container ? top : null +} + +/** Same distance read off rects, for the case where there is no `offsetParent` + * chain to walk. Rects are viewport pixels, so the container's own measured + * zoom converts them back; a container with no layout reports no zoom and no + * distance, which leaves the offset where it already is. */ +function rectOffsetWithin(element: HTMLElement, container: HTMLElement): number { + const containerRect = container.getBoundingClientRect() + const zoom = + container.offsetHeight > 0 && containerRect.height > 0 + ? containerRect.height / container.offsetHeight + : 1 + return container.scrollTop + (element.getBoundingClientRect().top - containerRect.top) / zoom +} + +export function useNativeChatTranscriptWindow({ + scrollRef, + slots, + revealIndex +}: { + scrollRef: React.RefObject + slots: readonly NativeChatTranscriptSlot[] + /** Slot the transcript was asked to reveal, or -1. */ + revealIndex: number +}): NativeChatTranscriptWindow { + const sizerElementRef = useRef(null) + const [scrollMargin, setScrollMargin] = useState(0) + const previousMeasurementKeysRef = useRef | null>(null) + const retiredMeasurementCountRef = useRef(0) + const pinned = useMemo( + () => nativeChatPinnedRowIndexes({ count: slots.length, revealIndex }), + [slots.length, revealIndex] + ) + // A content-only tail revision must not rebuild measured offsets: doing so + // breaks the end anchor while the row grows. Structural changes replace it. + const encodedItemKeys = JSON.stringify(slots.map((slot) => slot.message.id)) + const itemKeys = useMemo(() => JSON.parse(encodedItemKeys) as string[], [encodedItemKeys]) + const estimateSize = useCallback( + (index: number) => slots[index]?.estimatedHeight ?? FALLBACK_ROW_PX, + [slots] + ) + const getItemKey = useCallback((index: number) => itemKeys[index] ?? index, [itemKeys]) + // Identity tracks the pinned set on purpose. The virtualizer memoizes the + // mounted indexes on this function, so a stable one would keep serving the + // range from before a row was pinned — and a reveal would point at a row that + // never mounted. It is not a dependency of the measurement memo, so nothing + // expensive is rebuilt by changing it. + const rangeExtractor = useCallback( + (range: { startIndex: number; endIndex: number; overscan: number; count: number }) => + nativeChatTranscriptRange(range, pinned), + [pinned] + ) + + const virtualizer = useVirtualizer({ + count: slots.length, + getScrollElement: () => scrollRef.current, + estimateSize, + getItemKey, + rangeExtractor, + overscan: NATIVE_CHAT_WINDOW_OVERSCAN, + gap: NATIVE_CHAT_ROW_GAP_PX, + scrollMargin, + anchorTo: 'end', + followOnAppend: true, + scrollEndThreshold: NATIVE_CHAT_BOTTOM_THRESHOLD_PX + }) + + // Read, never assumed: the "load earlier" button sits above the window and + // appears exactly when a prepend is about to land, which is the one moment a + // stale margin would place every row wrong. + const readScrollMargin = useCallback(() => { + const container = scrollRef.current + const sizer = sizerElementRef.current + if (!container || !sizer) { + return + } + // Only the offset chain, never the rect fallback: a container with no + // layout would report the scroll position itself as the margin, which would + // hold the window at the top of the transcript no matter where it scrolled. + const offset = nativeChatScrollOffsetWithin(sizer, container) + if (offset !== null) { + setScrollMargin((current) => (current === offset ? current : offset)) + } + }, [scrollRef]) + useLayoutEffect(readScrollMargin) + + useLayoutEffect(() => { + const container = scrollRef.current + if (!container) { + return + } + readScrollMargin() + if (typeof ResizeObserver === 'undefined') { + return + } + const observer = new ResizeObserver(readScrollMargin) + observer.observe(container) + return () => observer.disconnect() + }, [readScrollMargin, scrollRef]) + + useLayoutEffect(() => { + const currentKeys = new Set(itemKeys) + const previousKeys = previousMeasurementKeysRef.current + previousMeasurementKeysRef.current = currentKeys + if (previousKeys !== null) { + for (const key of previousKeys) { + if (!currentKeys.has(key)) { + retiredMeasurementCountRef.current += 1 + } + } + } + if (retiredMeasurementCountRef.current < MAX_RETIRED_NATIVE_CHAT_MEASUREMENTS) { + return + } + + const retainedMeasurements = virtualizer + .takeSnapshot() + .filter((item) => typeof item.key === 'string' && currentKeys.has(item.key)) + const scrollTop = scrollRef.current?.scrollTop + virtualizer.measure() + // Materialize the estimate-only layout before restoring retained sizes. + virtualizer.getTotalSize() + for (const item of retainedMeasurements) { + virtualizer.resizeItem(item.index, item.size) + } + if (scrollTop !== undefined) { + virtualizer.scrollToOffset(scrollTop) + } + retiredMeasurementCountRef.current = 0 + }, [itemKeys, scrollRef, virtualizer]) + + const sizerRef = useCallback( + (node: HTMLDivElement | null) => { + sizerElementRef.current = node + if (node) { + readScrollMargin() + } + }, + [readScrollMargin] + ) + + const alignToViewportTop = useCallback( + (element: HTMLElement) => { + const container = scrollRef.current + if (!container) { + return + } + const top = + nativeChatScrollOffsetWithin(element, container) ?? rectOffsetWithin(element, container) + // Through the virtualizer so a scroll it is still reconciling — the jump + // that mounted this row in the first place — is replaced rather than raced. + if (virtualizer.scrollElement) { + virtualizer.scrollToOffset(top, { align: 'start', behavior: 'smooth' }) + } else { + container.scrollTo({ top, behavior: 'smooth' }) + } + }, + [scrollRef, virtualizer] + ) + + return { + virtualItems: virtualizer.getVirtualItems(), + totalSize: virtualizer.getTotalSize(), + scrollMargin, + sizerRef, + measureRow: virtualizer.measureElement, + alignToViewportTop + } +} diff --git a/src/shared/agent-session-journal-schemas.test.ts b/src/shared/agent-session-journal-schemas.test.ts index c23f7915589..3a515952601 100644 --- a/src/shared/agent-session-journal-schemas.test.ts +++ b/src/shared/agent-session-journal-schemas.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + AgentJournalItemBodySchema, isAdmissibleAgentJournalItemBody, isAdmissibleAgentJournalMessageBody, isAdmissibleAgentJournalRenderItem, @@ -275,13 +276,20 @@ describe('optional tool annotations', () => { const body = { kind: 'tool-call', name: 'shell', input: null, state: 'completed' } it('admits old rows and rows with optional annotations without a new kind', () => { expect(isAdmissibleAgentJournalItemBody(body)).toBe(true) - expect(isAdmissibleAgentJournalItemBody({ ...body, exitCode: 0, durationMs: 0 })).toBe(true) + expect( + isAdmissibleAgentJournalItemBody({ ...body, callId: 'call-1', exitCode: 0, durationMs: 0 }) + ).toBe(true) expect( isAdmissibleAgentJournalItemBody({ ...body, webSearchResults: [{ title: 'Docs', url: 'https://example.com' }] }) ).toBe(true) + const padded = AgentJournalItemBodySchema.safeParse({ ...body, callId: ' call-1 ' }) + expect(padded.success).toBe(true) + if (padded.success && padded.data.kind === 'tool-call') { + expect(padded.data.callId).toBe(' call-1 ') + } }) it('admits explicit MCP identity without constraining the raw name', () => { expect( @@ -293,6 +301,9 @@ describe('optional tool annotations', () => { ).toBe(true) }) it.each([ + { callId: '' }, + { callId: ' \t' }, + { callId: 1 }, { exitCode: '127' }, { exitCode: 1.5 }, { durationMs: -1 }, @@ -300,4 +311,14 @@ describe('optional tool annotations', () => { ])('rejects malformed annotation %s', (metadata) => expect(isAdmissibleAgentJournalItemBody({ ...body, ...metadata })).toBe(false) ) + + it('rejects whitespace-only provider IDs in message blocks too', () => { + expect( + isAdmissibleAgentJournalItemBody({ + kind: 'message', + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'shell', input: null, callId: '\n\t' }] + }) + ).toBe(false) + }) }) diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 6acae63f202..eaee6bb9a63 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -49,6 +49,11 @@ const KNOWN_BLOCK_TYPES = new Set([ 'subagent-group' ]) +/** Provider IDs are opaque; reject all-whitespace values without rewriting valid IDs. */ +const ProviderCallId = z + .string() + .refine((value) => value.trim().length > 0, 'callId must contain a non-whitespace character') + /** Child-agent lifecycle stays an open string for the same reason tool states * do: a state a newer build writes must not turn the row malformed. */ const SubagentEntry = z.object({ @@ -78,6 +83,7 @@ const Block = z.union([ type: z.literal('tool-call'), name: z.string(), input: z.unknown().optional(), + callId: ProviderCallId.optional(), ...ToolMetadata }), z.object({ @@ -140,6 +146,7 @@ export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ name: z.string(), // See the tool-call block: the key itself is lost when `input` is undefined. input: z.unknown().optional(), + callId: ProviderCallId.optional(), state: z.string().min(1), output: BoundedPayload.optional() }), diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index f1669058d63..7ba5b277e6d 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -97,6 +97,8 @@ export type AgentJournalToolCallItem = NativeChatToolMetadata & { kind: 'tool-call' name: string input: unknown + /** Provider-supplied identity within this item stream; optional for mixed-version peers. */ + callId?: string state: AgentJournalToolCallState output?: AgentJournalBoundedPayload } diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index 3a018d75e6b..c6835301c88 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -55,6 +55,8 @@ export type NativeChatToolCallBlock = NativeChatToolMetadata & { type: 'tool-call' name: string input: unknown + /** Provider-supplied identity within this item stream; absent on legacy transcripts and peers. */ + callId?: string /** Provider lifecycle when the structured app-server path can supply it. */ state?: 'running' | 'completed' | 'failed' } diff --git a/src/shared/structured-agent-session-projection.test.ts b/src/shared/structured-agent-session-projection.test.ts index 645aca4d6fe..928180e7a6c 100644 --- a/src/shared/structured-agent-session-projection.test.ts +++ b/src/shared/structured-agent-session-projection.test.ts @@ -475,6 +475,7 @@ describe('notice projection for desktop and mobile consumers', () => { it('preserves optional tool annotations for desktop and mobile projection', () => { const metadata = { + callId: 'call-1', exitCode: 127, durationMs: 400, webSearchResults: [{ title: 'Docs', url: 'https://example.com' }] diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index 559be217ee9..bd13d600a34 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -54,6 +54,7 @@ function itemBlocks(item: AgentJournalRenderItem): { name: body.name, input: body.input, state: body.state, + ...(body.callId !== undefined ? { callId: body.callId } : {}), ...(body.mcpIdentity !== undefined ? { mcpIdentity: body.mcpIdentity } : {}), ...(body.exitCode !== undefined ? { exitCode: body.exitCode } : {}), ...(body.durationMs !== undefined ? { durationMs: body.durationMs } : {}), diff --git a/tests/e2e/native-chat-history-prepend-anchor.spec.ts b/tests/e2e/native-chat-history-prepend-anchor.spec.ts new file mode 100644 index 00000000000..aea95302363 --- /dev/null +++ b/tests/e2e/native-chat-history-prepend-anchor.spec.ts @@ -0,0 +1,204 @@ +import { randomUUID } from 'node:crypto' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import type { GlobalSettings } from '../../src/shared/global-settings-types' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { waitForActivePaneHookDescriptor, waitForActiveTerminalManager } from './helpers/terminal' + +const TRANSCRIPT_ROWS = 650 + +async function enableNativeChatSetting(page: Page): Promise { + await page.evaluate(async () => { + const nextSettings = await window.api.settings.set({ experimentalNativeChat: true }) + window.__store?.setState({ settings: nextSettings as GlobalSettings }) + }) +} + +async function seedClaudeProviderSession( + page: Page, + args: { paneKey: string; worktreeId: string; sessionId: string; transcriptPath: string } +): Promise { + await page.evaluate(({ paneKey, worktreeId, sessionId, transcriptPath }) => { + window.__store + ?.getState() + .setAgentStatus( + paneKey, + { state: 'working', prompt: 'e2e history anchor probe', agentType: 'claude' }, + 'Claude', + undefined, + { worktreeId }, + { providerSession: { key: 'session_id', id: sessionId, transcriptPath } } + ) + }, args) +} + +async function toggleTerminalTabToChatView( + page: Page, + args: { tabId: string; worktreeId: string } +): Promise { + await page.evaluate(({ tabId, worktreeId }) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const state = store.getState() + const unifiedTab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find( + (tab) => tab.contentType === 'terminal' && tab.entityId === tabId + ) + if (!unifiedTab) { + throw new Error('Unified terminal tab not found for chat toggle') + } + state.toggleTabViewMode(unifiedTab.id) + }, args) +} + +function claudeTranscript(rowCount: number, sessionId: string): string { + const startedAt = Date.now() - rowCount * 1_000 + return `${Array.from({ length: rowCount }, (_, index) => { + const marker = `E2E transcript row ${String(index).padStart(4, '0')}` + const body = Array.from( + { length: 5 }, + (_unused, line) => `Measured paragraph ${line + 1} for row ${String(index).padStart(4, '0')}.` + ).join('\n\n') + return JSON.stringify({ + sessionId, + uuid: `${sessionId}-${index}`, + timestamp: new Date(startedAt + index * 1_000).toISOString(), + type: index % 2 === 0 ? 'user' : 'assistant', + message: { + role: index % 2 === 0 ? 'user' : 'assistant', + model: 'claude-opus-4', + content: [{ type: 'text', text: `${marker}\n\n${body}` }] + } + }) + }).join('\n')}\n` +} + +test.describe('Native chat history prepend anchoring', () => { + test('keeps the visible transcript row at the same viewport offset', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const descriptor = await waitForActivePaneHookDescriptor(orcaPage) + const [tabId] = descriptor.paneKey.split(':') + const sessionId = `e2e-prepend-anchor-${randomUUID()}` + const scratchDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-native-chat-anchor-')) + const transcriptPath = path.join(scratchDir, `${sessionId}.jsonl`) + writeFileSync(transcriptPath, claudeTranscript(TRANSCRIPT_ROWS, sessionId)) + + try { + await enableNativeChatSetting(orcaPage) + await seedClaudeProviderSession(orcaPage, { + paneKey: descriptor.paneKey, + worktreeId: descriptor.worktreeId, + sessionId, + transcriptPath + }) + await toggleTerminalTabToChatView(orcaPage, { + tabId, + worktreeId: descriptor.worktreeId + }) + + await expect(orcaPage.locator('[data-native-chat-root="true"]')).toBeVisible({ + timeout: 15_000 + }) + const scroll = orcaPage.locator('[data-native-chat-scroll]') + const transcriptWindow = orcaPage.locator('[data-native-chat-window]') + const loadEarlier = orcaPage.getByRole('button', { name: 'Load earlier messages' }) + await expect(transcriptWindow).toBeVisible({ timeout: 30_000 }) + await expect(loadEarlier).toBeAttached({ timeout: 30_000 }) + await expect + .poll(() => transcriptWindow.locator(':scope > [data-index]').count()) + .toBeGreaterThan(3) + + const initialTotalSize = await transcriptWindow.evaluate((element) => element.offsetHeight) + const anchor = await scroll.evaluate(async (element) => { + element.scrollTop = element.scrollHeight * 0.55 + element.dispatchEvent(new Event('scroll', { bubbles: true })) + let previousGeometry = '' + let stableFrames = 0 + for (let frame = 0; frame < 120 && stableFrames < 5; frame += 1) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + const geometry = `${element.scrollHeight}:${element.scrollTop}` + stableFrames = geometry === previousGeometry ? stableFrames + 1 : 0 + previousGeometry = geometry + } + const scrollRect = element.getBoundingClientRect() + const candidates = Array.from( + element.querySelectorAll('[data-native-chat-window] > [data-index]') + ).filter((row) => { + const rect = row.getBoundingClientRect() + return rect.top >= scrollRect.top + 40 && rect.bottom <= scrollRect.bottom - 40 + }) + const row = candidates[Math.floor(candidates.length / 2)] + const marker = row + ? Array.from(row.querySelectorAll('p')).find((paragraph) => + /^E2E transcript row \d{4}$/.test(paragraph.textContent?.trim() ?? '') + ) + : undefined + if (!row || !marker) { + return null + } + return { + index: Number(row.dataset.index), + marker: marker.textContent?.trim() ?? '', + scrollHeight: element.scrollHeight, + scrollTop: element.scrollTop, + viewportOffset: row.getBoundingClientRect().top - scrollRect.top + } + }) + expect(anchor, 'expected a fully visible measured row to anchor').not.toBeNull() + if (!anchor) { + throw new Error('Expected a fully visible measured row to anchor') + } + + // The button stays off-screen so invoking it here exercises the real pagination + // handler without Playwright first scrolling the reader to the transcript head. + await loadEarlier.evaluate((button: HTMLButtonElement) => button.click()) + await expect + .poll(() => transcriptWindow.evaluate((element) => element.offsetHeight)) + .toBeGreaterThan(initialTotalSize) + await expect(loadEarlier).toBeAttached({ timeout: 30_000 }) + + const anchoredMarker = orcaPage.getByText(anchor.marker, { exact: true }) + await expect(anchoredMarker).toBeAttached({ timeout: 15_000 }) + const after = await anchoredMarker.evaluate(async (marker) => { + const row = marker.closest('[data-index]') + const scrollRoot = marker.closest('[data-native-chat-scroll]') + if (!row || !scrollRoot) { + return null + } + let previousGeometry = '' + let stableFrames = 0 + for (let frame = 0; frame < 120 && stableFrames < 5; frame += 1) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + const geometry = `${scrollRoot.scrollHeight}:${scrollRoot.scrollTop}` + stableFrames = geometry === previousGeometry ? stableFrames + 1 : 0 + previousGeometry = geometry + } + return { + index: Number(row.dataset.index), + scrollHeight: scrollRoot.scrollHeight, + scrollTop: scrollRoot.scrollTop, + viewportOffset: row.getBoundingClientRect().top - scrollRoot.getBoundingClientRect().top + } + }) + expect(after, 'anchored row must remain mounted after history prepends').not.toBeNull() + expect(after?.index).toBe(anchor.index + 200) + const contentGrowth = (after?.scrollHeight ?? 0) - anchor.scrollHeight + const scrollAdjustment = (after?.scrollTop ?? 0) - anchor.scrollTop + expect( + Math.abs(contentGrowth - scrollAdjustment), + `content grew ${contentGrowth}px while scrollTop adjusted ${scrollAdjustment}px` + ).toBeLessThanOrEqual(2) + expect(Math.abs((after?.viewportOffset ?? 0) - anchor.viewportOffset)).toBeLessThanOrEqual(3) + } finally { + rmSync(scratchDir, { recursive: true, force: true }) + } + }) +}) From d33354cfd22bd3459c8dc75b399adf34cef0bea3 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:00:16 -0400 Subject: [PATCH 10/17] feat(mobile): receive native push notifications from paired desktops (#19951) * feat(mobile): deliver native push notifications from paired desktops * fix(mobile): retry push capability probes * fix(mobile): cancel retired push capability probes * fix(mobile): ignore stale push reconciliations * fix(mobile): type capability probe at its boundary * fix(notifications): route mobile push taps to the originating pane * Require explicit mobile push-service consent on upgrade --- .github/workflows/mobile-ios-release.yml | 7 + .github/workflows/mobile.yml | 14 + cloud/apps/push/src/push-delivery-message.ts | 2 + cloud/apps/push/src/push-pane-routing.test.ts | 14 + .../push-contract/src/send-messages.ts | 3 +- docs/site/content/docs/mobile.mdx | 2 +- docs/site/content/docs/notifications.mdx | 18 + mobile/app.config.js | 28 + mobile/app.json | 4 +- mobile/app/_layout.tsx | 60 +- mobile/app/mobile-onboarding.tsx | 4 +- mobile/app/notifications.tsx | 6 +- mobile/assets/notification-icon.png | Bin 0 -> 625 bytes mobile/google-services.json | 39 + .../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 | 1 + .../patches/expo-notifications@55.0.27.patch | 36 + mobile/pnpm-lock.yaml | 2448 ++++++++--------- mobile/pnpm-workspace.yaml | 1 + .../home/use-mobile-home-host-connections.ts | 8 + .../NotificationDeliverySection.test.tsx | 37 + .../NotificationDeliverySection.tsx | 72 + .../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 | 308 +++ .../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 | 40 + .../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 | 258 ++ mobile/src/notifications/push-receive.ts | 146 + .../push-registration-cancellation.test.ts | 263 ++ .../notifications/push-registration.test.ts | 423 +++ mobile/src/notifications/push-registration.ts | 328 +++ .../push-socket-dismissal.test.ts | 60 + .../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 | 84 + .../src/notifications/push-tray-dismissal.ts | 66 + .../use-remote-push-capable-hosts.test.tsx | 180 ++ .../use-remote-push-capable-hosts.ts | 105 + .../src/onboarding/MobileOnboardingPage.tsx | 14 +- .../mobile-onboarding-screen.test.ts | 33 +- .../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 | 100 + .../native-notification-delivery-settings.tsx | 78 + ...native-notification-settings-operations.ts | 5 +- .../settings/notification-settings-screen.tsx | 34 +- mobile/src/storage/preferences.test.ts | 29 +- mobile/src/storage/preferences.ts | 40 +- .../transport/host-removal-lifecycle.test.ts | 65 +- .../src/transport/host-removal-lifecycle.ts | 20 +- .../src/transport/runtime-capability-probe.ts | 2 +- .../ipc/notifications-mobile-fanout.test.ts | 2 + src/main/ipc/notifications.ts | 1 + src/main/runtime/push/push-dispatcher.test.ts | 7 +- src/main/runtime/push/push-dispatcher.ts | 1 + src/main/runtime/push/push-gateway-client.ts | 1 + .../runtime-mobile-notification-controller.ts | 1 + 104 files changed, 6023 insertions(+), 4283 deletions(-) create mode 100644 cloud/apps/push/src/push-pane-routing.test.ts 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/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/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 diff --git a/.github/workflows/mobile-ios-release.yml b/.github/workflows/mobile-ios-release.yml index 934b3f694a3..27372260c01 100644 --- a/.github/workflows/mobile-ios-release.yml +++ b/.github/workflows/mobile-ios-release.yml @@ -94,6 +94,13 @@ 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 + # Why the env var: app.config.js derives the expo-notifications plugin's + # `mode` from it, which is what writes `aps-environment: production` into the + # entitlements. push-token.ts reports a production APNs environment for every + # non-__DEV__ build, so a development entitlement here would leave TestFlight + # and App Store builds registered against a sandbox they never receive from. + env: + ORCA_IOS_APS_ENVIRONMENT: production run: npx expo prebuild --platform ios --no-install - name: Install CocoaPods diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index 6dbfc02aa3c..3c32dda64d4 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -83,3 +83,17 @@ jobs: - name: Check formatting run: pnpm format:check + + native-dismissal: + runs-on: macos-15 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Check native dismissal ledger + run: | + swiftc mobile/modules/orca-notification-dismissal/ios/PushDismissalLedger.swift \ + mobile/modules/orca-notification-dismissal/tests/PushDismissalLedgerChecks.swift \ + -o "$RUNNER_TEMP/push-dismissal-ledger-checks" + "$RUNNER_TEMP/push-dismissal-ledger-checks" diff --git a/cloud/apps/push/src/push-delivery-message.ts b/cloud/apps/push/src/push-delivery-message.ts index bc2c1a5d9b2..3bd2dae6e7c 100644 --- a/cloud/apps/push/src/push-delivery-message.ts +++ b/cloud/apps/push/src/push-delivery-message.ts @@ -5,6 +5,7 @@ export type PushOrcaData = { kind?: 'alert' | 'dismiss' hostFingerprint: string worktreeId?: string + paneKey?: string notificationId?: string notificationSeq: number notificationEpoch: string @@ -51,6 +52,7 @@ export function buildPushDelivery(input: { orca: { ...(notification.kind ? { kind: notification.kind } : {}), hostFingerprint, + ...(notification.paneKey === undefined ? {} : { paneKey: notification.paneKey }), ...(notification.worktreeId === undefined ? {} : { worktreeId: notification.worktreeId }), ...(notification.notificationId === undefined ? {} diff --git a/cloud/apps/push/src/push-pane-routing.test.ts b/cloud/apps/push/src/push-pane-routing.test.ts new file mode 100644 index 00000000000..1ed9575ffe7 --- /dev/null +++ b/cloud/apps/push/src/push-pane-routing.test.ts @@ -0,0 +1,14 @@ +import { expect, it } from 'vitest' +import { PushNotificationSchema } from '@orca-cloud/push-contract' +import { buildPushDelivery, orcaDataStrings } from './push-delivery-message.js' + +it('preserves pane identity for both APNs and FCM, and accepts older workspace-only messages', () => { + const base = { notificationSeq: 1, notificationEpoch: 'epoch', source: 'agent-task-complete', agentState: 'finished', title: 'Done', body: '', worktreeId: 'folder:/work' } + const paneKey = 'tab-b:11111111-1111-4111-8111-111111111111' + for (const extra of [{}, { paneKey }]) { + const notification = PushNotificationSchema.parse({ ...base, ...extra }) + const delivery = buildPushDelivery({ notification, hostFingerprint: 'host', registrationId: 'phone', expiresAt: Date.now() + 300000 }) + expect(delivery.orca.paneKey).toBe('paneKey' in extra ? paneKey : undefined) + expect(orcaDataStrings(delivery.orca).paneKey).toBe('paneKey' in extra ? paneKey : undefined) + } +}) diff --git a/cloud/packages/push-contract/src/send-messages.ts b/cloud/packages/push-contract/src/send-messages.ts index 57d1c2e2745..a8959c18b05 100644 --- a/cloud/packages/push-contract/src/send-messages.ts +++ b/cloud/packages/push-contract/src/send-messages.ts @@ -26,7 +26,8 @@ export const PushNotificationSchema = z agentState: PushAgentStateSchema.nullable(), title: z.string().min(1).max(PUSH_LIMITS.titleMaxChars), body: z.string().max(PUSH_LIMITS.bodyMaxChars), - worktreeId: z.string().min(1).max(2048).optional() + worktreeId: z.string().min(1).max(2048).optional(), + paneKey: z.string().min(1).max(2048).optional() }) .strict() .refine( diff --git a/docs/site/content/docs/mobile.mdx b/docs/site/content/docs/mobile.mdx index 5883cb81fcd..537f0eb4254 100644 --- a/docs/site/content/docs/mobile.mdx +++ b/docs/site/content/docs/mobile.mdx @@ -31,7 +31,7 @@ The Orca mobile companion is an iOS/Android app that pairs with your desktop Orc - Create a workspace from mobile with the same Smart source modes as desktop: Smart, GitHub, Linear, GitLab, Branch, and Name. With **multiple connected desktops**, **New Workspace** asks which host should create it first (one connected host skips the picker). - Open a host card's **⋯** menu for **Edit**, **Connect**, **Remove**, and related actions (long-press still works as a shortcut). - Edit a saved host's display name or connection address without re-pairing (for example when the desktop moves between home LAN and Tailscale). -- Get push notifications when an agent finishes, mirroring [desktop notifications](/docs/notifications). +- Get push notifications when an agent finishes or needs input, mirroring [desktop notifications](/docs/notifications). Turn on **Enable notifications** in the phone's Notifications settings to keep receiving them while Orca is closed; see [Notifications](/docs/notifications#notifications-on-your-phone) for what that sends and where. The mobile app is intentionally not a full editor — it's a remote control for the desktop you already have running. diff --git a/docs/site/content/docs/notifications.mdx b/docs/site/content/docs/notifications.mdx index 8d5e02866f7..37505c39c4f 100644 --- a/docs/site/content/docs/notifications.mdx +++ b/docs/site/content/docs/notifications.mdx @@ -29,3 +29,21 @@ Pick a custom desktop notification sound per category under [Settings → Notifi Supported formats: MP3, WAV, OGG, M4A, AAC, FLAC. One file applies to all delivered desktop notifications. When you use a custom sound, set its playback volume from the same settings pane. + +## Notifications on your phone + +Turn on **Enable notifications** in the mobile app to receive agent alerts, including while the app is closed. Notifications are delivered through Orca’s push service and Apple or Google. + +The service receives the notification title and text, a native push token, and opaque host and device IDs. Notification text is not end-to-end encrypted. Turning notifications off or unpairing queues removal of the push registration; an offline desktop completes that removal when contact returns. + +Alert types always follow each paired desktop’s notification settings. **Notification sound** and **Suppress while focused** apply only to the phone; the latter skips alerts for the workspace currently open there. + +**Only when away from desktop** is on by default. Phone alerts are suppressed while the desktop has had keyboard or mouse activity within the last three minutes. Locking the desktop allows alerts immediately. This includes activity in other desktop apps. Suppressed events do not arrive later as a backlog. When desktop presence cannot be determined, alerts are allowed. + +Notifications pause after seven days without a foreground mobile registration renewal. Open the mobile app and reconnect to resume. Using desktop alone does not renew this phone’s registration. The app renews while it is open and connected, including periodically during longer visits. + +Phone notifications require an updated paired desktop with push support. This mobile version does not create notifications from the live connection to older desktops; update the desktop to receive phone alerts. Headless hosts cannot determine desktop presence and do not infer it from SSH activity; agent completion detection still requires the desktop app. Android background delivery requires Google Play services. + +The push service temporarily stores pending notification content for delivery, for up to five minutes +plus cleanup time. Content is cleared after processing; delivery identifiers and outcomes are retained +for 24 hours for retry and duplicate protection. Alerts expire after five minutes. diff --git a/mobile/app.config.js b/mobile/app.config.js new file mode 100644 index 00000000000..f858ae6325e --- /dev/null +++ b/mobile/app.config.js @@ -0,0 +1,28 @@ +// 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, + 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..97ebd1bee71 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -1,6 +1,9 @@ +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 +13,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 +29,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 +61,10 @@ 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(), []) + // 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 +115,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 +154,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..108f33bf160 100644 --- a/mobile/app/notifications.tsx +++ b/mobile/app/notifications.tsx @@ -1,3 +1,4 @@ +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 +8,9 @@ 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) => } + ) } diff --git a/mobile/assets/notification-icon.png b/mobile/assets/notification-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..fffb571fe76f6dfc0ccb55a60e130dfb84234e42 GIT binary patch literal 625 zcmV-%0*?KOP)-DGQ@4EF>F=W?>^EB*_MaBt?=E#xxC@xh&puyXW3HckU_Yo;!Z^)cZX5Jmd<%2&6)QBc*?f>%oCfs;zGZ!xz)T#4!uTGv1<_EHJBrye)$#3tqE*IHUB zY<2Nmus4>g2zw(#MzB+8-tl^SF1+EpupBd^xy@LEUg6K7P;p&HG_No6N3bkQ*dqxy zt!B06J&Wai62*>Y3A-mH=w$W**Kt571Hvj7gc>gH%s;Cc^KiPxyb=74#-qZw*cjg6 zg>d?+)DEEs?~h$Or8S6ajfP08cHtPgq|qR0)hpEVf1`cs^hN#`QFhDXbocVT00000 LNkvXXu0mjfuaY6N 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/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..4971c4e1d66 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -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/home/use-mobile-home-host-connections.ts b/mobile/src/home/use-mobile-home-host-connections.ts index 989583f11ab..9cf094ee240 100644 --- a/mobile/src/home/use-mobile-home-host-connections.ts +++ b/mobile/src/home/use-mobile-home-host-connections.ts @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { decodeAccountsSnapshot } from '../components/AccountUsage' import { subscribeToDesktopNotifications } from '../notifications/mobile-notifications' +import { attachPushRegistration } from '../notifications/push-registration' import { usePrimeHosts } from '../transport/client-context' import { createHostConnectRefetchGate } from '../transport/host-connect-refetch-gate' import { selectHomeAutoConnectHostIds } from '../transport/home-host-auto-connect' @@ -37,11 +38,15 @@ function wireMobileHomeHostSubscriptions( ): () => void { let unsubscribeNotifications: (() => void) | null = null let unsubscribeAccounts: (() => void) | null = null + let detachPushRegistration: (() => void) | null = null const refetchGate = createHostConnectRefetchGate() const wireState = (state: ConnectionState): void => { const reconnected = refetchGate.observe(state) if (state === 'connected') { unsubscribeNotifications ??= subscribeToDesktopNotifications(entry.client, entry.hostId) + // Why here: this is the one place a host is known to be authenticated, which is + // what registerPush needs; it no-ops on hosts without the push capability. + detachPushRegistration ??= attachPushRegistration(entry.hostId, entry.client) unsubscribeAccounts ??= entry.client.subscribe('accounts.subscribe', null, (payload) => { if (!payload || typeof payload !== 'object') { return @@ -78,6 +83,8 @@ function wireMobileHomeHostSubscriptions( unsubscribeNotifications = null unsubscribeAccounts?.() unsubscribeAccounts = null + detachPushRegistration?.() + detachPushRegistration = null } wireState(entry.state) const unsubscribeState = entry.client.onStateChange(wireState) @@ -85,6 +92,7 @@ function wireMobileHomeHostSubscriptions( unsubscribeState() unsubscribeNotifications?.() unsubscribeAccounts?.() + detachPushRegistration?.() } } 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/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..b6f1f048bc1 --- /dev/null +++ b/mobile/src/notifications/notification-consent-ownership.test.ts @@ -0,0 +1,308 @@ +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('./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..0ba88a77212 --- /dev/null +++ b/mobile/src/notifications/push-background-dismissal.test.ts @@ -0,0 +1,40 @@ +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() })) +vi.mock('./push-tray-dismissal', () => ({ dismissPresentedPushNotification: vi.fn() })) +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() +}) 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..0ed6a4abd49 --- /dev/null +++ b/mobile/src/notifications/push-receive.test.ts @@ -0,0 +1,258 @@ +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, + 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 + }) + } +) diff --git a/mobile/src/notifications/push-receive.ts b/mobile/src/notifications/push-receive.ts new file mode 100644 index 00000000000..c09368d4e7b --- /dev/null +++ b/mobile/src/notifications/push-receive.ts @@ -0,0 +1,146 @@ +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 suppressed = await shouldSuppressForegroundPush( + payload, + preferences.suppressWhileViewing + ).catch(() => payload !== null) + return { + shouldShowBanner: !suppressed, + shouldShowList: !suppressed, + shouldPlaySound: !suppressed && preferences.sound, + shouldSetBadge: false + } +} + +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 (await wasPushDismissed(payload)) || !claimForegroundPush(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..4607bed61a4 --- /dev/null +++ b/mobile/src/notifications/push-socket-dismissal.test.ts @@ -0,0 +1,60 @@ +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: '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..752910df43f --- /dev/null +++ b/mobile/src/notifications/push-tray-dismissal.test.ts @@ -0,0 +1,84 @@ +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' + ]) +}) diff --git a/mobile/src/notifications/push-tray-dismissal.ts b/mobile/src/notifications/push-tray-dismissal.ts new file mode 100644 index 00000000000..43f4c5600d7 --- /dev/null +++ b/mobile/src/notifications/push-tray-dismissal.ts @@ -0,0 +1,66 @@ +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 || + Boolean( + fence.notificationEpoch && + fence.notificationSeq !== undefined && + payload.notificationEpoch === fence.notificationEpoch && + payload.notificationSeq !== undefined && + payload.notificationSeq <= fence.notificationSeq + )) + ) + }) +} 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..cecde90218a --- /dev/null +++ b/mobile/src/notifications/use-remote-push-capable-hosts.test.tsx @@ -0,0 +1,180 @@ +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('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 setClients([{ hostId: 'host-1', client: clientFor('host-1'), state: 'connected' }]) + + 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..8c6b35c96ee --- /dev/null +++ b/mobile/src/notifications/use-remote-push-capable-hosts.ts @@ -0,0 +1,105 @@ +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)) { + 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..9fc0dbbd7e1 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,30 @@ 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.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 3a6b04661de..6aeefe78bfa 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..49c5fdda41d --- /dev/null +++ b/mobile/src/settings/native-notification-delivery-settings.test.tsx @@ -0,0 +1,100 @@ +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 } +})) +vi.mock('react-native', () => ({ + Text: 'Text', + AppState: { addEventListener: () => ({ 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') +}) 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..cfbc71367d2 --- /dev/null +++ b/mobile/src/settings/native-notification-delivery-settings.tsx @@ -0,0 +1,78 @@ +import { useCallback, useEffect, 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 support = useRemotePushCapableHosts() + const refresh = useCallback(async () => { + try { + setDelivery(await loadNotificationDeliveryPreferences()) + setLoaded(true) + setError(null) + } catch { + 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) => { + setSaving(true) + setError(null) + try { + await setNotificationDeliveryPreferences(value) + setDelivery(value) + } catch { + setError('Could not save delivery settings. Try again.') + } finally { + 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-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/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/src/main/ipc/notifications-mobile-fanout.test.ts b/src/main/ipc/notifications-mobile-fanout.test.ts index ab797293042..20013f24b96 100644 --- a/src/main/ipc/notifications-mobile-fanout.test.ts +++ b/src/main/ipc/notifications-mobile-fanout.test.ts @@ -60,6 +60,7 @@ describe('registerNotificationHandlers', () => { { source: 'agent-task-complete', worktreeId: 'repo::wt1', + paneKey: 'tab-b:11111111-1111-4111-8111-111111111111', worktreeLabel: 'feat/notis', agentType: 'hermes', agentState: 'done', @@ -76,6 +77,7 @@ describe('registerNotificationHandlers', () => { title: 'feat/notis - Hermes finished', body: 'The diff updates notification formatting.', worktreeId: 'repo::wt1', + paneKey: 'tab-b:11111111-1111-4111-8111-111111111111', agentState: 'done' }) expect(notificationCtorMock).not.toHaveBeenCalled() diff --git a/src/main/ipc/notifications.ts b/src/main/ipc/notifications.ts index 274ab2719d8..0352473e938 100644 --- a/src/main/ipc/notifications.ts +++ b/src/main/ipc/notifications.ts @@ -147,6 +147,7 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime title: notificationOptions.title, body: notificationOptions.body, worktreeId: args.worktreeId, + ...(args.paneKey ? { paneKey: args.paneKey } : {}), ...(args.notificationId ? { notificationId: args.notificationId } : {}), // Why: background push needs the agent's real state to pick "needs input" // vs "finished" — and to stay silent while the agent is still working. diff --git a/src/main/runtime/push/push-dispatcher.test.ts b/src/main/runtime/push/push-dispatcher.test.ts index 47373045f28..71f20d6ebc3 100644 --- a/src/main/runtime/push/push-dispatcher.test.ts +++ b/src/main/runtime/push/push-dispatcher.test.ts @@ -19,7 +19,9 @@ describe('PushDispatcher', () => { ] }) - harness.dispatcher.enqueue(notification()) + harness.dispatcher.enqueue( + notification({ paneKey: 'tab-b:11111111-1111-4111-8111-111111111111' }) + ) await flush() expect(harness.sends).toHaveLength(1) @@ -29,7 +31,8 @@ describe('PushDispatcher', () => { agentState: 'finished', notificationSeq: 7, notificationEpoch: 'epoch-1', - worktreeId: 'repo::wt1' + worktreeId: 'repo::wt1', + paneKey: 'tab-b:11111111-1111-4111-8111-111111111111' }) }) diff --git a/src/main/runtime/push/push-dispatcher.ts b/src/main/runtime/push/push-dispatcher.ts index e028b80d858..c40216bae1c 100644 --- a/src/main/runtime/push/push-dispatcher.ts +++ b/src/main/runtime/push/push-dispatcher.ts @@ -187,6 +187,7 @@ export class PushDispatcher { agentState, title: clip(event.title, PUSH_TITLE_MAX_LENGTH), body: clip(event.body, PUSH_BODY_MAX_LENGTH), + ...(event.paneKey ? { paneKey: event.paneKey } : {}), ...(event.worktreeId ? { worktreeId: event.worktreeId } : {}) } } diff --git a/src/main/runtime/push/push-gateway-client.ts b/src/main/runtime/push/push-gateway-client.ts index 05fb9a1ffef..7ad899704b6 100644 --- a/src/main/runtime/push/push-gateway-client.ts +++ b/src/main/runtime/push/push-gateway-client.ts @@ -48,6 +48,7 @@ export type PushSendNotification = { title: string body: string worktreeId?: string + paneKey?: string } type PushGatewayClientOptions = { diff --git a/src/main/runtime/runtime-mobile-notification-controller.ts b/src/main/runtime/runtime-mobile-notification-controller.ts index 73578412618..a8a3434e85a 100644 --- a/src/main/runtime/runtime-mobile-notification-controller.ts +++ b/src/main/runtime/runtime-mobile-notification-controller.ts @@ -22,6 +22,7 @@ export type MobileNotificationDispatchEvent = { title: string body: string worktreeId?: string + paneKey?: string notificationId?: string notificationSeq?: number notificationEpoch?: string From 2ffac471bddf6b743512fcf39bbbbd1a437e2d37 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:05:30 -0700 Subject: [PATCH 11/17] fix(workspaces): seed shells only for blank selection (#19940) * fix(workspaces): seed shells only for blank selection * fix(workspaces): create runtime-owned launch surfaces * fix(workspaces): report runtime surface failures --------- Co-authored-by: Merge Sim --- .../runtime/runtime-folder-worktree-create.ts | 2 +- ...me-local-worktree-terminal-startup.test.ts | 74 ++++++ ...runtime-local-worktree-terminal-startup.ts | 2 +- .../runtime-remote-managed-worktree-create.ts | 2 +- .../folder-workspace-composer-submit.test.ts | 3 + .../folder-workspace-composer-submit.ts | 2 + .../composer-state/full-creation-execution.ts | 2 + .../full-creation-structured-launch.test.ts | 2 + .../full-creation-structured-launch.ts | 3 + ...eb-runtime-worktree-terminal-after-wake.ts | 110 +++++++-- ...ctivation-emptied-workspace-reseed.test.ts | 33 +++ .../worktree-activation-empty-remote.test.ts | 54 +++++ ...e-activation-surface-caller-wiring.test.ts | 5 +- .../worktree-activation-surface-selection.ts | 39 +++ .../worktree-activation-web-runtime.test.ts | 222 +++++++++++++++++- src/renderer/src/lib/worktree-activation.ts | 77 +++--- .../worktree-creation-agent-seeding.test.ts | 87 +++++++ .../src/lib/worktree-creation-flow-execute.ts | 13 +- .../src/lib/worktree-creation-flow.test.ts | 2 +- ...rktree-creation-structured-session.test.ts | 10 + .../worktree-creation-structured-session.ts | 27 ++- .../src/runtime/web-runtime-session-types.ts | 2 + .../web-runtime-terminal-create-operation.ts | 4 +- .../active-session-subscription.ts | 19 +- 24 files changed, 701 insertions(+), 95 deletions(-) create mode 100644 src/main/runtime/runtime-local-worktree-terminal-startup.test.ts create mode 100644 src/renderer/src/lib/worktree-activation-surface-selection.ts create mode 100644 src/renderer/src/lib/worktree-creation-agent-seeding.test.ts diff --git a/src/main/runtime/runtime-folder-worktree-create.ts b/src/main/runtime/runtime-folder-worktree-create.ts index efea3c22c72..ef7798c91f9 100644 --- a/src/main/runtime/runtime-folder-worktree-create.ts +++ b/src/main/runtime/runtime-folder-worktree-create.ts @@ -172,7 +172,7 @@ export async function createRuntimeFolderWorktree(args: { undefined, args.startup && !didSpawnStartup ? args.startup : undefined ) - } else if (deps.ptySpawnAvailable && !didSpawnStartup) { + } else if (deps.ptySpawnAvailable && !didSpawnStartup && !args.createdWithAgent) { try { await deps.createTerminal(`id:${worktree.id}`, { surfaceOwner: false }) } catch (error) { diff --git a/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts b/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts new file mode 100644 index 00000000000..1153b173553 --- /dev/null +++ b/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../shared/repo-types' +import type { Worktree } from '../../shared/worktree/types' +import { startRuntimeLocalWorktreeTerminals } from './runtime-local-worktree-terminal-startup' + +const repo: Repo = { + id: 'repo-1', + path: '/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 +} + +const worktree: Worktree = { + id: 'worktree-1', + repoId: repo.id, + path: '/worktree', + head: 'abc', + branch: 'feature', + isBare: false, + isMainWorktree: false, + displayName: 'feature', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1 +} + +type StartupArgs = Parameters[0] + +function createPorts() { + const createTerminal = vi.fn().mockResolvedValue({ + handle: 'term-1', + worktreeId: worktree.id, + title: null + }) + const ports: StartupArgs['ports'] = { + canSpawn: true, + markTrusted: vi.fn(), + createTerminal, + pasteDraft: vi.fn(), + sendFollowup: vi.fn(), + provision: vi.fn().mockResolvedValue({ setupSpawned: false, setupTerminalHandle: null }), + activate: vi.fn() + } + return { createTerminal, ports } +} + +describe('startRuntimeLocalWorktreeTerminals default shell seeding', () => { + it.each([ + ['Blank Terminal', undefined, 1], + ['an agent', 'codex' as const, 0] + ])('seeds a background shell for %s selection only', async (_label, agent, expectedCalls) => { + const { createTerminal, ports } = createPorts() + + await startRuntimeLocalWorktreeTerminals({ + request: { repoSelector: `id:${repo.id}`, name: worktree.displayName }, + repo, + worktree, + ...(agent ? { createdWithAgent: agent } : {}), + ports + }) + + expect(createTerminal).toHaveBeenCalledTimes(expectedCalls) + if (expectedCalls > 0) { + expect(createTerminal).toHaveBeenCalledWith(`id:${worktree.id}`, { surfaceOwner: false }) + } + }) +}) diff --git a/src/main/runtime/runtime-local-worktree-terminal-startup.ts b/src/main/runtime/runtime-local-worktree-terminal-startup.ts index 35985b53497..7babcd7da7d 100644 --- a/src/main/runtime/runtime-local-worktree-terminal-startup.ts +++ b/src/main/runtime/runtime-local-worktree-terminal-startup.ts @@ -163,7 +163,7 @@ export async function startRuntimeLocalWorktreeTerminals(args: { didSpawnSetup = true } } - } else if (ports.canSpawn) { + } else if (ports.canSpawn && !args.createdWithAgent) { try { await ports.createTerminal(`id:${worktree.id}`, { surfaceOwner: false }) } catch (error) { diff --git a/src/main/runtime/runtime-remote-managed-worktree-create.ts b/src/main/runtime/runtime-remote-managed-worktree-create.ts index 01a49142dc4..83de82b0594 100644 --- a/src/main/runtime/runtime-remote-managed-worktree-create.ts +++ b/src/main/runtime/runtime-remote-managed-worktree-create.ts @@ -222,7 +222,7 @@ export async function createRuntimeRemoteManagedWorktree( didSpawnSetup = true } } - } else if (!shouldActivate && deps.canSpawn()) { + } else if (!shouldActivate && deps.canSpawn() && !args.createdWithAgent) { try { await deps.createTerminal(`path:${result.worktree.path}`, { surfaceOwner: false }) } catch (err) { diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts index a96564b6652..ef27dfcc03b 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts @@ -116,6 +116,7 @@ describe('submitFolderWorkspaceCreate', () => { }) expect(onOpenChange).toHaveBeenCalledWith(false) expect(mocks.activateAndRevealFolderWorkspace).toHaveBeenCalledWith('folder-workspace-1', { + agent: null, runtimeEnvironmentId: null }) expect(consoleError).toHaveBeenCalledWith( @@ -532,6 +533,7 @@ describe('submitFolderWorkspaceCreate', () => { linkedTask: linkedWorkItem }) expect(mocks.activateAndRevealFolderWorkspace).toHaveBeenCalledWith('folder-workspace-1', { + agent: null, runtimeEnvironmentId: null }) expect(mocks.ensureAgentStartupInTerminal).not.toHaveBeenCalled() @@ -659,6 +661,7 @@ describe('submitFolderWorkspaceCreate', () => { }) expect(onOpenChange).toHaveBeenCalledWith(false) expect(mocks.activateAndRevealFolderWorkspace).toHaveBeenCalledWith('folder-workspace-1', { + agent: null, runtimeEnvironmentId: null }) }) diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index 34eab00e730..77ff1c193b8 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -207,6 +207,7 @@ export async function submitFolderWorkspaceCreate({ onOpenChange(false) try { let activation = activateAndRevealFolderWorkspace(workspace.id, { + agent: quickAgent, ...(!structuredLaunch && startup ? { startup } : {}), ...(structuredLaunch ? { providesInitialSurface: true } : {}), runtimeEnvironmentId @@ -229,6 +230,7 @@ export async function submitFolderWorkspaceCreate({ connectionId: workspace.connectionId ?? projectGroup.connectionId }) const fallbackActivation = activateAndRevealFolderWorkspace(workspace.id, { + agent: quickAgent, ...(startup ? { startup } : {}), runtimeEnvironmentId }) diff --git a/src/renderer/src/hooks/composer-state/full-creation-execution.ts b/src/renderer/src/hooks/composer-state/full-creation-execution.ts index 28961e355c6..1ad5a92abab 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-execution.ts @@ -219,6 +219,7 @@ export function useFullCreationExecution(input: FullCreationExecutionInput) { const initialActivation = activateAndRevealWorktree(worktree.id, { sidebarRevealBehavior: 'auto', + agent: tuiAgent, setup: result.setup, defaultTabs: result.defaultTabs, issueCommand, @@ -229,6 +230,7 @@ export function useFullCreationExecution(input: FullCreationExecutionInput) { const settlement = await settleFullCreationStructuredLaunch({ plan: launchPlan, + agent: tuiAgent, worktreeId: worktree.id, startup, pendingFirstAgentMessageRename, diff --git a/src/renderer/src/hooks/composer-state/full-creation-structured-launch.test.ts b/src/renderer/src/hooks/composer-state/full-creation-structured-launch.test.ts index 4a7b569bf79..d3808357ee8 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-structured-launch.test.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-structured-launch.test.ts @@ -36,6 +36,7 @@ const plan = (overrides: Partial = {}) => const baseArgs = { plan: plan(), + agent: 'codex' as const, worktreeId: 'worktree-1', startup: { command: 'codex' } as never, pendingFirstAgentMessageRename: true, @@ -94,6 +95,7 @@ describe('settleFullCreationStructuredLaunch', () => { }) expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('worktree-1', { sidebarRevealBehavior: 'auto', + agent: 'codex', createNewTerminalForStartup: true, startup: baseArgs.startup }) diff --git a/src/renderer/src/hooks/composer-state/full-creation-structured-launch.ts b/src/renderer/src/hooks/composer-state/full-creation-structured-launch.ts index c7e19026ba7..4293e97505a 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-structured-launch.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-structured-launch.ts @@ -3,12 +3,14 @@ import type { WorktreeStartupPayload } from '@/lib/worktree-startup-payload' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement' import { activateStructuredAgentSessionById } from '@/lib/structured-agent-session-tab-activation' +import type { TuiAgent } from '../../../../shared/tui-agent' /** Full-create dialog: the structured launch plus what this flow did before structured chat * existed. Returns null when the plan's route is not structured. */ export async function settleFullCreationStructuredLaunch(args: { /** Planned before the worktree existed; `worktreeId` names the one that was created. */ plan: AgentSessionLaunchPlan + agent: TuiAgent worktreeId: string startup: WorktreeStartupPayload | undefined pendingFirstAgentMessageRename: boolean @@ -27,6 +29,7 @@ export async function settleFullCreationStructuredLaunch(args: { } const activation = activateAndRevealWorktree(args.worktreeId, { sidebarRevealBehavior: 'auto', + agent: args.agent, createNewTerminalForStartup: true, ...(args.startup ? { startup: args.startup } : {}) }) diff --git a/src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts b/src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts index bf1664dc57c..bb3cf9f26a8 100644 --- a/src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts +++ b/src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts @@ -11,50 +11,114 @@ import { endWebRuntimeWakeTerminalRespawn } from '@/runtime/web-runtime-wake-terminal-respawn' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { + draftViewModeProps, + resolveStartupLaunchDraftText, + type WorktreeStartupPayload +} from '@/lib/worktree-startup-payload' +import type { TuiAgent } from '../../../shared/tui-agent' +import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' +import { getConnectionId } from '@/lib/connection-context' +import { toast } from 'sonner' -export function ensureWebRuntimeWorktreeTerminalAfterWake(worktreeId: string): void { - const state = useAppStore.getState() - const worktree = state.getKnownWorktreeById(worktreeId) - if (!worktree) { - return +export function ensureWebRuntimeWorktreeTerminalAfterWake( + worktreeId: string, + opts?: { + runtimeEnvironmentId?: string | null + startup?: WorktreeStartupPayload + agent?: TuiAgent | null + activate?: boolean } - const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktree.id) +): void { + const state = useAppStore.getState() + const runtimeEnvironmentId = + opts && 'runtimeEnvironmentId' in opts + ? (opts.runtimeEnvironmentId ?? null) + : getRuntimeEnvironmentIdForWorktree(state, worktreeId) if (!runtimeEnvironmentId || !isWebRuntimeSessionActive(runtimeEnvironmentId)) { return } const tabs = state.tabsByWorktree[worktreeId] ?? [] - const hasLivePty = tabs.some((tab) => tabHasLivePty(state.ptyIdsByTabId, tab.id)) - if (hasLivePty) { + const launchAgent = opts?.startup?.launchAgent ?? opts?.agent ?? undefined + if ( + launchAgent && + tabs.some( + (tab) => + tab.launchAgent === launchAgent && + (isWebTerminalSurfaceTabId(tab.id) || tabHasLivePty(state.ptyIdsByTabId, tab.id)) + ) + ) { return } - const hasMirroredHostTabs = tabs.some((tab) => isWebTerminalSurfaceTabId(tab.id)) - if (hasMirroredHostTabs) { - // Why: the host session still owns these tabs — wait for the mirror to repopulate PTY handles instead of duplicating a terminal. - return - } + if (!launchAgent) { + const hasLivePty = tabs.some((tab) => tabHasLivePty(state.ptyIdsByTabId, tab.id)) + if (hasLivePty) { + return + } - if (getLastKnownHostTerminalTabCount(runtimeEnvironmentId, worktreeId) > 0) { - return - } + const hasMirroredHostTabs = tabs.some((tab) => isWebTerminalSurfaceTabId(tab.id)) + if (hasMirroredHostTabs) { + // Why: the host session still owns these tabs — wait for the mirror to repopulate PTY handles instead of duplicating a terminal. + return + } - const { renderableTabCount } = state.reconcileWorktreeTabModel(worktreeId) - if (tabs.length > 0 && renderableTabCount === 0) { - return + if (getLastKnownHostTerminalTabCount(runtimeEnvironmentId, worktreeId) > 0) { + return + } + + const { renderableTabCount } = state.reconcileWorktreeTabModel(worktreeId) + if (tabs.length > 0 && renderableTabCount === 0) { + return + } } if (!beginWebRuntimeWakeTerminalRespawn(worktreeId)) { return } - // Why: sleep keeps tab rows but terminal.stop clears host PTYs, so a woke workspace can have tab chrome but no surface. + const startup = opts?.startup + const viewModeProps = launchAgent + ? initialAgentTabViewModeProps(state.settings, { + agent: launchAgent, + ...draftViewModeProps(resolveStartupLaunchDraftText(startup)), + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( + getConnectionId(worktreeId) + ) + }) + : {} + // Why: sleep keeps tab rows but terminal.stop clears host PTYs, while a failed create receipt leaves a selected agent with no host surface. void createWebRuntimeSessionTerminal({ worktreeId, environmentId: runtimeEnvironmentId, - activate: true, + ...viewModeProps, + ...(startup + ? { + command: startup.command, + ...(startup.env ? { env: startup.env } : {}), + ...(startup.launchConfig ? { launchConfig: startup.launchConfig } : {}), + ...(startup.launchToken ? { launchToken: startup.launchToken } : {}), + ...(launchAgent ? { launchAgent, preparedAgentCommand: true } : {}), + ...(startup.startupCommandDelivery + ? { startupCommandDelivery: startup.startupCommandDelivery } + : {}) + } + : launchAgent + ? { agent: launchAgent } + : {}), + activate: opts?.activate !== false, selectWorktree: false - }).finally(() => { - endWebRuntimeWakeTerminalRespawn(worktreeId) }) + .then((outcome) => { + if (outcome.status === 'failed') { + toast.error(outcome.message, { + id: `web-runtime-worktree-terminal:${runtimeEnvironmentId}:${worktreeId}` + }) + } + }) + .finally(() => { + endWebRuntimeWakeTerminalRespawn(worktreeId) + }) } diff --git a/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts b/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts index f9ebf55b0d2..ea4f3119bbc 100644 --- a/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts +++ b/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts @@ -32,6 +32,23 @@ function seedClosedLastTerminal(worktreeId: string): void { } describe('activating a workspace whose last terminal was closed', () => { + it.each([ + ['Blank Terminal', null, 1], + ['an agent', 'codex' as const, 0] + ])('seeds a default shell for %s selection only', (_label, agent, expectedTabCount) => { + const worktree = makeWorktree() + seedEmptyActivatableWorktree(worktree) + + const result = activateAndRevealWorktree(worktree.id, { + agent, + notifyHostRuntime: false + }) + + expect(result).not.toBe(false) + expect(result === false ? null : result.primaryTabId === null).toBe(expectedTabCount === 0) + expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(expectedTabCount) + }) + it.each([true, false])( 'forwards providesInitialSurface=%s through the async activation gate', async (providesInitialSurface) => { @@ -234,6 +251,22 @@ function seedEmptiedFolderWorkspaceOnTwoHosts(): void { } describe('activating a folder workspace whose last terminal was closed', () => { + it.each([ + ['Blank Terminal', null, 1], + ['an agent', 'codex' as const, 0] + ])('seeds a default shell for %s selection only', (_label, agent, expectedTabCount) => { + seedEmptiedFolderWorkspaceOnTwoHosts() + + const result = activateAndRevealFolderWorkspace(FOLDER_ID, { + agent, + executionHostId: 'local' + }) + + expect(result).not.toBe(false) + expect(useAppStore.getState().activeWorktreeId).toBe(FOLDER_KEY) + expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY]).toHaveLength(expectedTabCount) + }) + it.each([true, false])( 'forwards providesInitialSurface=%s through the async activation gate', async (providesInitialSurface) => { diff --git a/src/renderer/src/lib/worktree-activation-empty-remote.test.ts b/src/renderer/src/lib/worktree-activation-empty-remote.test.ts index 9326be93960..c16a8d6a448 100644 --- a/src/renderer/src/lib/worktree-activation-empty-remote.test.ts +++ b/src/renderer/src/lib/worktree-activation-empty-remote.test.ts @@ -6,6 +6,9 @@ import { resetWebRuntimeWakeTerminalRespawnForTests } from '@/runtime/web-runtim import { resetWebSessionTabsSnapshotFreshnessForTests } from '@/runtime/web-session-tabs-sync' import { useAppStore } from '@/store' import { ensureWebRuntimeWorktreeTerminalAfterWake } from './web-runtime-worktree-terminal-after-wake' +import { toast } from 'sonner' + +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) const initialAppStoreState = useAppStore.getState() const WORKTREE_PATH = path.join('workspace', 'feature') @@ -13,6 +16,7 @@ const REPO_PATH = path.join('workspace', 'repo') const ORCA_WORKSPACES_PATH = path.join('workspace', '.orca-workspaces') afterEach(() => { + vi.clearAllMocks() delete (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ vi.unstubAllGlobals() resetWebSessionTabsSnapshotFreshnessForTests() @@ -115,5 +119,55 @@ describe('empty remote worktree activation', () => { }) }) ) + expect(toast.error).not.toHaveBeenCalled() + }) + + it('surfaces a failed host terminal request without retrying ambiguously', async () => { + const worktree = makeWorktree() + const callRuntimeEnvironment = vi.fn().mockResolvedValueOnce({ + ok: false, + error: { code: 'terminal_create_failed', message: 'Host refused the terminal' } + }) + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: callRuntimeEnvironment, + subscribe: vi.fn() + } + } + }) + + useAppStore.setState({ + repos: [ + { + id: 'repo-1', + path: REPO_PATH, + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0 + } + ], + worktreesByRepo: { 'repo-1': [worktree] }, + tabsByWorktree: {}, + ptyIdsByTabId: {}, + settings: { + ...getDefaultSettings(ORCA_WORKSPACES_PATH), + activeRuntimeEnvironmentId: 'web-runtime-1' + }, + reconcileWorktreeTabModel: vi.fn(() => ({ + renderableTabCount: 0, + activeRenderableTabId: null + })) + }) + + ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id) + + await vi.waitFor(() => + expect(toast.error).toHaveBeenCalledWith('Host refused the terminal', { + id: `web-runtime-worktree-terminal:web-runtime-1:${worktree.id}` + }) + ) + expect(callRuntimeEnvironment).toHaveBeenCalledTimes(1) }) }) diff --git a/src/renderer/src/lib/worktree-activation-surface-caller-wiring.test.ts b/src/renderer/src/lib/worktree-activation-surface-caller-wiring.test.ts index ff2d5c802c1..4e7e02336ca 100644 --- a/src/renderer/src/lib/worktree-activation-surface-caller-wiring.test.ts +++ b/src/renderer/src/lib/worktree-activation-surface-caller-wiring.test.ts @@ -23,7 +23,10 @@ const SURFACE_PROVIDING_CALLERS = [ ] // The activation seam itself: declares the option and forwards it into the tombstone gate. -const SEAM_FILES = ['src/renderer/src/lib/worktree-activation.ts'] +const SEAM_FILES = [ + 'src/renderer/src/lib/worktree-activation-surface-selection.ts', + 'src/renderer/src/lib/worktree-activation.ts' +] function listSourceFiles(dir: string): string[] { return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { diff --git a/src/renderer/src/lib/worktree-activation-surface-selection.ts b/src/renderer/src/lib/worktree-activation-surface-selection.ts new file mode 100644 index 00000000000..ef4b5c1386a --- /dev/null +++ b/src/renderer/src/lib/worktree-activation-surface-selection.ts @@ -0,0 +1,39 @@ +import type { TuiAgent } from '../../../shared/tui-agent' +import type { + WorktreeDefaultTabsLaunch, + WorktreeSetupLaunch +} from '../../../shared/worktree/launch-types' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { PendingSidebarWorktreeReveal } from '@/store/slices/ui' +import type { WorktreeStartupPayload } from '@/lib/worktree-startup-payload' +import type { IssueCommandLaunch } from '@/lib/worktree-setup-issue-command-queue' + +export type WorktreeActivationSurfaceSelection = { + /** The create picker's selection; null means Blank Terminal. */ + agent?: TuiAgent | null + /** A navigation caller is about to open its own editor, diff, or other non-terminal surface. */ + providesInitialSurface?: boolean +} + +export type WorktreeActivationOptions = WorktreeActivationSurfaceSelection & { + startup?: WorktreeStartupPayload + initialCwd?: string + setup?: WorktreeSetupLaunch + defaultTabs?: WorktreeDefaultTabsLaunch + issueCommand?: IssueCommandLaunch + sidebarRevealBehavior?: PendingSidebarWorktreeReveal['behavior'] + notifyHostRuntime?: boolean + revealInSidebar?: boolean + executionHostId?: ExecutionHostId + backendStartupTerminalSpawned?: boolean + /** Install a preserved fallback startup beside setup/default terminals already seeded. */ + createNewTerminalForStartup?: boolean + /** Keep sidebar filters intact when navigating to a hidden target. */ + clearSidebarFilters?: boolean +} + +export function activationProvidesInitialSurface( + selection?: WorktreeActivationSurfaceSelection +): boolean { + return selection?.providesInitialSurface === true || selection?.agent != null +} diff --git a/src/renderer/src/lib/worktree-activation-web-runtime.test.ts b/src/renderer/src/lib/worktree-activation-web-runtime.test.ts index 98a7679ae6d..2daee618eba 100644 --- a/src/renderer/src/lib/worktree-activation-web-runtime.test.ts +++ b/src/renderer/src/lib/worktree-activation-web-runtime.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { activateAndRevealFolderWorkspace, activateAndRevealWorktree } from './worktree-activation' import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-seeding' import type { AppStoreState } from './worktree-activation-test-harness' import { @@ -6,9 +7,228 @@ import { registerWorktreeActivationReset } from './worktree-activation-test-harness' import { useAppStore } from '@/store' +import { + makeCreatedAgentWorktree, + seedEmptyActivatableWorktree +} from './worktree-activation-created-agent-test-state' +import { + resetWebRuntimeWakeTerminalRespawnForTests, + shouldSkipWebRuntimeWakeTerminalRespawn +} from '@/runtime/web-runtime-wake-terminal-respawn' registerWorktreeActivationReset() +afterEach(() => { + vi.unstubAllGlobals() + resetWebRuntimeWakeTerminalRespawnForTests() +}) + +describe('activateAndRevealWorktree', () => { + it('asks the paired host for the prepared agent terminal when backend startup did not spawn', async () => { + const worktree = { + ...makeCreatedAgentWorktree(), + hostId: 'local' as const, + runtimeOwnerEnvironmentId: 'web-runtime-1' + } + const callRuntimeEnvironment = vi.fn( + async (request: { method: string; params?: Record }) => + request.method === 'session.tabs.createTerminal' + ? { + ok: true, + result: { + tab: { id: 'host-agent-tab', leafId: 'host-agent-leaf' }, + publicationEpoch: 'epoch-1', + snapshotVersion: 1 + } + } + : { ok: false, error: { code: 'test', message: 'stop after recording the request' } } + ) + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: callRuntimeEnvironment } } + }) + seedEmptyActivatableWorktree(worktree) + const settings = useAppStore.getState().settings + useAppStore.setState({ + settings: settings + ? { ...settings, activeRuntimeEnvironmentId: 'web-runtime-1' } + : ({ activeRuntimeEnvironmentId: 'web-runtime-1' } as unknown as typeof settings) + }) + useAppStore.setState({ + tabsByWorktree: { + [worktree.id]: [ + { + id: 'stale-local-agent-tab', + ptyId: 'stale-local-agent-pty', + worktreeId: worktree.id, + title: 'Codex', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + launchAgent: 'codex' + } + ] + } + }) + + activateAndRevealWorktree(worktree.id, { + agent: 'codex', + startup: { + command: "codex 'fix the ownership race'", + env: { ORCA_AGENT_PROFILE: 'review' }, + launchAgent: 'codex', + launchToken: 'launch-1' + } + }) + await vi.waitFor(() => + expect(callRuntimeEnvironment).toHaveBeenCalledWith( + expect.objectContaining({ method: 'worktree.activate' }) + ) + ) + + const createRequests = callRuntimeEnvironment.mock.calls.filter( + ([request]) => request.method === 'session.tabs.createTerminal' + ) + expect(createRequests).toHaveLength(1) + expect(createRequests[0]?.[0]).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + command: "codex 'fix the ownership race'", + env: { ORCA_AGENT_PROFILE: 'review' }, + launchAgent: 'codex', + launchToken: 'launch-1' + }) + }) + ) + await vi.waitFor(() => expect(shouldSkipWebRuntimeWakeTerminalRespawn(worktree.id)).toBe(false)) + }) + + it('does not request another host terminal when backend startup already spawned', async () => { + const worktree = { + ...makeCreatedAgentWorktree(), + hostId: 'local' as const, + runtimeOwnerEnvironmentId: 'web-runtime-1' + } + const callRuntimeEnvironment = vi.fn().mockResolvedValue({ + ok: false, + error: { code: 'test', message: 'stop after recording the request' } + }) + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: callRuntimeEnvironment } } + }) + seedEmptyActivatableWorktree(worktree) + useAppStore.setState((state) => ({ + settings: state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: 'web-runtime-1' } + : ({ activeRuntimeEnvironmentId: 'web-runtime-1' } as unknown as typeof state.settings) + })) + + activateAndRevealWorktree(worktree.id, { + agent: 'codex', + backendStartupTerminalSpawned: true + }) + await vi.waitFor(() => + expect(callRuntimeEnvironment).toHaveBeenCalledWith( + expect.objectContaining({ method: 'worktree.activate' }) + ) + ) + + expect( + callRuntimeEnvironment.mock.calls.filter( + ([request]) => request.method === 'session.tabs.createTerminal' + ) + ).toHaveLength(0) + }) +}) + +describe('activateAndRevealFolderWorkspace', () => { + it.each([ + [ + 'the selected agent', + { + agent: 'codex' as const, + startup: { command: 'codex', launchAgent: 'codex' as const, launchToken: 'launch-1' } + }, + { command: 'codex', launchAgent: 'codex', launchToken: 'launch-1' } + ], + ['Blank Terminal', { agent: null }, { command: undefined }] + ])('asks the runtime owner for exactly one %s surface', async (_label, activation, expected) => { + const callRuntimeEnvironment = vi.fn( + async (request: { method: string; params?: Record }) => + request.method === 'session.tabs.createTerminal' + ? { + ok: true, + result: { + tab: { id: 'host-tab', leafId: 'host-leaf' }, + publicationEpoch: 'epoch-1', + snapshotVersion: 1 + } + } + : { ok: false, error: { code: 'test', message: 'stop after recording the request' } } + ) + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: callRuntimeEnvironment } } + }) + const settings = useAppStore.getState().settings + useAppStore.setState({ + activeView: 'terminal', + folderWorkspaces: [ + { + id: 'folder-1', + projectGroupId: 'group-1', + name: 'runtime folder', + folderPath: '/workspace/runtime-folder', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + executionHostId: 'runtime:web-runtime-1' + } + ], + getFreshFolderWorkspacePathStatus: vi.fn(() => ({ exists: true })), + tabsByWorktree: {}, + settings: settings + ? { ...settings, activeRuntimeEnvironmentId: 'web-runtime-1' } + : ({ activeRuntimeEnvironmentId: 'web-runtime-1' } as unknown as typeof settings), + markWorktreeVisited: vi.fn(), + recordWorktreeVisit: vi.fn(), + revealWorktreeInSidebar: vi.fn() + } as unknown as Partial) + + activateAndRevealFolderWorkspace('folder-1', { + ...activation, + runtimeEnvironmentId: 'web-runtime-1' + }) + + await vi.waitFor(() => + expect( + callRuntimeEnvironment.mock.calls.filter( + ([request]) => request.method === 'session.tabs.createTerminal' + ) + ).toHaveLength(1) + ) + const createRequest = callRuntimeEnvironment.mock.calls.find( + ([request]) => request.method === 'session.tabs.createTerminal' + )?.[0] + expect(createRequest).toEqual( + expect.objectContaining({ + params: expect.objectContaining(expected) + }) + ) + if (activation.agent === null) { + expect(createRequest?.params).not.toHaveProperty('launchAgent') + } + await vi.waitFor(() => + expect(shouldSkipWebRuntimeWakeTerminalRespawn('folder:folder-1')).toBe(false) + ) + }) +}) + describe('ensureWorktreeHasInitialTerminal', () => { it('does not create a local fallback tab in the paired web runtime client', () => { ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index d2304c28b9d..a77a16682c8 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -1,8 +1,4 @@ import type { FolderWorkspace } from '../../../shared/folder-workspace-types' -import type { - WorktreeDefaultTabsLaunch, - WorktreeSetupLaunch -} from '../../../shared/worktree/launch-types' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import type { PendingSidebarWorktreeReveal } from '@/store/slices/ui' @@ -29,13 +25,17 @@ import { isDetachedHeadWorkspace } from '@/components/sidebar/visible-worktrees' import type { ExecutionHostId } from '../../../shared/execution-host' import { findFolderWorkspaceOwner } from './folder-workspace-runtime-owner' import type { WorktreeStartupPayload } from '@/lib/worktree-startup-payload' -import type { IssueCommandLaunch } from '@/lib/worktree-setup-issue-command-queue' import { ensureWorktreeHasInitialTerminal, reseedGatedEmptyWorkspace } from '@/lib/worktree-initial-terminal-seeding' import { ensureWebRuntimeWorktreeTerminalAfterWake } from '@/lib/web-runtime-worktree-terminal-after-wake' import { applyWorktreeNavViewEntry } from '@/lib/worktree-nav-view-history-replay' +import { + activationProvidesInitialSurface, + type WorktreeActivationOptions, + type WorktreeActivationSurfaceSelection +} from './worktree-activation-surface-selection' /** * Shared activation sequence used by the worktree palette and add-repo/worktree dialogs. @@ -80,14 +80,12 @@ function canInspectAgentActivationInventory(): boolean { export function activateAndRevealFolderWorkspace( folderWorkspaceId: string, - opts?: { + opts?: WorktreeActivationSurfaceSelection & { sidebarRevealBehavior?: PendingSidebarWorktreeReveal['behavior'] revealInSidebar?: boolean startup?: WorktreeStartupPayload runtimeEnvironmentId?: string | null executionHostId?: ExecutionHostId - /** See activateAndRevealWorktree — same contract for folder workspaces. */ - providesInitialSurface?: boolean } ): ActivateAndRevealResult | false { const state = useAppStore.getState() @@ -133,6 +131,7 @@ export function activateAndRevealFolderWorkspace( state.setActiveFolderWorkspace(folderWorkspaceId, opts?.executionHostId) const workspaceKey = folderWorkspaceKey(folderWorkspaceId) + const providesInitialSurface = activationProvidesInitialSurface(opts) state.markWorktreeVisited(workspaceKey) if (!state.isNavigatingHistory) { state.recordWorktreeVisit(workspaceKey) @@ -151,17 +150,13 @@ export function activateAndRevealFolderWorkspace( if (shouldGateAgentActivation) { void gateWorktreeAgentActivation(workspaceKey).then((outcome) => { if (outcome === 'empty') { - reseedGatedEmptyWorkspace(workspaceKey, opts?.providesInitialSurface) + reseedGatedEmptyWorkspace(workspaceKey, providesInitialSurface) } }) } const primaryTabId = shouldGateAgentActivation ? null - : ensureFolderWorkspaceInitialTerminal( - folderWorkspace, - opts?.startup, - opts?.providesInitialSurface - ) + : ensureFolderWorkspaceInitialTerminal(folderWorkspace, opts?.startup, providesInitialSurface) if (opts?.revealInSidebar !== false) { state.revealWorktreeInSidebar( @@ -170,33 +165,20 @@ export function activateAndRevealFolderWorkspace( ) } + if (opts?.providesInitialSurface !== true) { + ensureWebRuntimeWorktreeTerminalAfterWake(workspaceKey, { + runtimeEnvironmentId, + startup: opts?.startup, + agent: opts?.agent + }) + } + return { primaryTabId } } export function activateAndRevealWorktree( worktreeId: string, - opts?: { - startup?: WorktreeStartupPayload - initialCwd?: string - setup?: WorktreeSetupLaunch - defaultTabs?: WorktreeDefaultTabsLaunch - issueCommand?: IssueCommandLaunch - sidebarRevealBehavior?: PendingSidebarWorktreeReveal['behavior'] - notifyHostRuntime?: boolean - revealInSidebar?: boolean - executionHostId?: ExecutionHostId - backendStartupTerminalSpawned?: boolean - /** Install a preserved fallback startup beside setup/default terminals already seeded. */ - createNewTerminalForStartup?: boolean - /** Set by callers that navigate here only to open their own non-terminal surface - * (an editor file, a diff). Activation then leaves a closed-last-terminal workspace - * empty instead of adding a shell the user never asked for. Caveat: on a - * runtime-owned workspace with a live web session the host owns terminal creation, - * so ensureWebRuntimeWorktreeTerminalAfterWake may still seed one (matches main). */ - providesInitialSurface?: boolean - /** Keep sidebar filters intact when navigating to a hidden target. */ - clearSidebarFilters?: boolean - } + opts?: WorktreeActivationOptions ): ActivateAndRevealResult | false { const state = useAppStore.getState() const wt = state.getKnownWorktreeById(worktreeId, opts?.executionHostId) @@ -206,6 +188,7 @@ export function activateAndRevealWorktree( const hasActivationWork = Boolean( opts?.startup || opts?.setup || opts?.defaultTabs || opts?.issueCommand ) + const providesInitialSurface = activationProvidesInitialSurface(opts) // Why: a plain reselect should still reveal the sidebar row but must not restamp focus recency or wake persistence. const isPlainAlreadyActiveTerminal = !hasActivationWork && @@ -266,7 +249,7 @@ export function activateAndRevealWorktree( if (shouldGateAgentActivation) { void gateWorktreeAgentActivation(worktreeId).then((outcome) => { if (outcome === 'empty') { - reseedGatedEmptyWorkspace(worktreeId, opts?.providesInitialSurface) + reseedGatedEmptyWorkspace(worktreeId, providesInitialSurface) } }) } @@ -274,7 +257,7 @@ export function activateAndRevealWorktree( // 4. Ensure a focusable surface exists for externally-created worktrees const primaryTabId = shouldGateAgentActivation ? null - : opts?.providesInitialSurface === true && !hasActivationWork + : providesInitialSurface && !hasActivationWork ? null : ensureWorktreeHasInitialTerminal( useAppStore.getState(), @@ -286,8 +269,8 @@ export function activateAndRevealWorktree( { ...(opts?.backendStartupTerminalSpawned ? { backendStartupTerminalSpawned: true } : {}), ...(opts?.createNewTerminalForStartup ? { createNewTerminalForStartup: true } : {}), - ...(opts?.providesInitialSurface === true ? { callerProvidesSurface: true } : {}), - reseedEmptiedWorkspace: opts?.providesInitialSurface !== true + ...(providesInitialSurface ? { callerProvidesSurface: true } : {}), + reseedEmptiedWorkspace: !providesInitialSurface } ) if (primaryTabId && opts?.initialCwd) { @@ -325,8 +308,15 @@ export function activateAndRevealWorktree( } } - if (opts?.notifyHostRuntime !== false && !opts?.backendStartupTerminalSpawned) { - ensureWebRuntimeWorktreeTerminalAfterWake(worktreeId) + if ( + opts?.notifyHostRuntime !== false && + !opts?.backendStartupTerminalSpawned && + opts?.providesInitialSurface !== true + ) { + ensureWebRuntimeWorktreeTerminalAfterWake(worktreeId, { + startup: opts?.startup, + agent: opts?.agent + }) } return { primaryTabId } @@ -340,9 +330,8 @@ export function activateAndRevealWorktree( */ export function activateAndRevealWorkspace( workspaceId: string, - opts?: { + opts?: WorktreeActivationSurfaceSelection & { executionHostId?: ExecutionHostId - providesInitialSurface?: boolean revealInSidebar?: boolean /** Worktree-only: folder workspaces are never filter-hidden. */ clearSidebarFilters?: boolean diff --git a/src/renderer/src/lib/worktree-creation-agent-seeding.test.ts b/src/renderer/src/lib/worktree-creation-agent-seeding.test.ts new file mode 100644 index 00000000000..601ad837843 --- /dev/null +++ b/src/renderer/src/lib/worktree-creation-agent-seeding.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorktreeCreationRequest } from './pending-worktree-creation' + +const mocks = vi.hoisted(() => ({ + activateAndRevealWorktree: vi.fn(), + completeWorktreeCreation: vi.fn(), + ensureWorktreeHasInitialTerminal: vi.fn(), + ensureWebRuntimeWorktreeTerminalAfterWake: vi.fn() +})) + +const store = { + activePendingCreationId: null as string | null, + activeView: 'tasks' as 'tasks' | 'terminal', + createWorktree: vi.fn(), + pendingWorktreeCreations: {} as Record, + repos: [] +} + +vi.mock('@/store', () => ({ useAppStore: { getState: () => store } })) +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: mocks.activateAndRevealWorktree +})) +vi.mock('@/lib/worktree-initial-terminal-seeding', () => ({ + ensureWorktreeHasInitialTerminal: mocks.ensureWorktreeHasInitialTerminal +})) +vi.mock('@/lib/worktree-creation-completion', () => ({ + completeWorktreeCreation: mocks.completeWorktreeCreation +})) +vi.mock('@/lib/web-runtime-worktree-terminal-after-wake', () => ({ + ensureWebRuntimeWorktreeTerminalAfterWake: mocks.ensureWebRuntimeWorktreeTerminalAfterWake +})) + +import { executeWorktreeCreation } from './worktree-creation-flow-execute' + +const request: WorktreeCreationRequest = { + repoId: 'repo-1', + name: 'feature', + setupDecision: 'inherit', + agent: 'codex', + agentLaunchRoute: 'terminal-tui', + pendingFirstAgentMessageRename: false, + note: '', + startupPlan: null, + quickPrompt: '', + quickTelemetry: null +} + +describe('executeWorktreeCreation agent seeding', () => { + beforeEach(() => { + vi.clearAllMocks() + store.activePendingCreationId = null + store.activeView = 'tasks' + store.pendingWorktreeCreations = { 'creation-1': { creationId: 'creation-1' } } + store.createWorktree.mockResolvedValue({ + worktree: { id: 'worktree-1', repoId: request.repoId } + }) + }) + + it('routes a background agent selection through host-aware surface creation', async () => { + await executeWorktreeCreation('creation-1', request) + + expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + expect(mocks.ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled() + expect(mocks.ensureWebRuntimeWorktreeTerminalAfterWake).toHaveBeenCalledOnce() + expect(mocks.ensureWebRuntimeWorktreeTerminalAfterWake).toHaveBeenCalledWith('worktree-1', { + startup: undefined, + agent: 'codex', + activate: false + }) + expect(mocks.completeWorktreeCreation).toHaveBeenCalledWith( + expect.objectContaining({ primaryTabId: null }) + ) + }) + + it('passes the agent selection through an active reveal', async () => { + store.activePendingCreationId = 'creation-1' + store.activeView = 'terminal' + mocks.activateAndRevealWorktree.mockReturnValue({ primaryTabId: null }) + + await executeWorktreeCreation('creation-1', request) + + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith( + 'worktree-1', + expect.objectContaining({ agent: 'codex' }) + ) + }) +}) diff --git a/src/renderer/src/lib/worktree-creation-flow-execute.ts b/src/renderer/src/lib/worktree-creation-flow-execute.ts index 273b3ece1a8..839f454eef5 100644 --- a/src/renderer/src/lib/worktree-creation-flow-execute.ts +++ b/src/renderer/src/lib/worktree-creation-flow-execute.ts @@ -22,6 +22,7 @@ import { buildWorktreeCreationStartupOpt } from '@/lib/worktree-creation-flow-st import { launchStructuredWorktreeSession } from '@/lib/worktree-creation-structured-session' import { completeWorktreeCreation } from '@/lib/worktree-creation-completion' import { markStructuredWorktreeLaunchUnconfirmed } from '@/lib/worktree-creation-structured-recovery' +import { ensureWebRuntimeWorktreeTerminalAfterWake } from '@/lib/web-runtime-worktree-terminal-after-wake' // Why: activePendingCreationId can outlive the terminal route when the user // switches app views; only the terminal route renders the creation panel. @@ -168,6 +169,7 @@ export async function executeWorktreeCreation( if (shouldActivateOnCompletion && !structuredLaunch) { activation = activateAndRevealWorktree(worktree.id, { sidebarRevealBehavior: 'auto', + ...(preparedRequest.agent !== null ? { agent: preparedRequest.agent } : {}), ...(result.setup ? { setup: result.setup } : {}), ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}), ...(startupOpt ? { startup: startupOpt } : {}), @@ -181,7 +183,7 @@ export async function executeWorktreeCreation( startupOpt || result.setup || preparedRequest.issueCommand || result.defaultTabs ) primaryTabId = - structuredLaunch && !hasExplicitTerminalWork + preparedRequest.agent !== null && !hasExplicitTerminalWork ? null : ensureWorktreeHasInitialTerminal( useAppStore.getState(), @@ -192,10 +194,17 @@ export async function executeWorktreeCreation( result.defaultTabs, { activateCreatedTabs: false, - ...(structuredLaunch ? { callerProvidesSurface: true } : {}), + ...(preparedRequest.agent !== null ? { callerProvidesSurface: true } : {}), ...(backendSpawned ? { backendStartupTerminalSpawned: true } : {}) } ) + if (!structuredLaunch && !backendSpawned) { + ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id, { + startup: startupOpt, + agent: preparedRequest.agent, + activate: false + }) + } } let structuredLaunchAccepted = structuredLaunch diff --git a/src/renderer/src/lib/worktree-creation-flow.test.ts b/src/renderer/src/lib/worktree-creation-flow.test.ts index 84ac3a61b32..a599a265513 100644 --- a/src/renderer/src/lib/worktree-creation-flow.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow.test.ts @@ -606,7 +606,7 @@ describe('staged background worktree creation', () => { delete store.pendingWorktreeCreations['creation-1'] store.activePendingCreationId = null resolveTrust() - await vi.waitFor(() => expect(ensureWorktreeHasInitialTerminal).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(store.removePendingWorktreeCreation).toHaveBeenCalled()) expect(activateAndRevealWorktree).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/lib/worktree-creation-structured-session.test.ts b/src/renderer/src/lib/worktree-creation-structured-session.test.ts index b8e1d609ea8..f072c17d417 100644 --- a/src/renderer/src/lib/worktree-creation-structured-session.test.ts +++ b/src/renderer/src/lib/worktree-creation-structured-session.test.ts @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({ activateStructuredAgentSessionById: vi.fn(), activateAndRevealWorktree: vi.fn(), ensureWorktreeHasInitialTerminal: vi.fn(), + ensureWebRuntimeWorktreeTerminalAfterWake: vi.fn(), preflightAgentTrust: vi.fn(), updateWorktreeMeta: vi.fn() })) @@ -54,6 +55,10 @@ vi.mock('@/lib/worktree-initial-terminal-seeding', () => ({ ensureWorktreeHasInitialTerminal: mocks.ensureWorktreeHasInitialTerminal })) +vi.mock('@/lib/web-runtime-worktree-terminal-after-wake', () => ({ + ensureWebRuntimeWorktreeTerminalAfterWake: mocks.ensureWebRuntimeWorktreeTerminalAfterWake +})) + vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree: mocks.activateAndRevealWorktree })) @@ -351,6 +356,11 @@ describe('launchStructuredWorktreeSession', () => { undefined, { activateCreatedTabs: false, createNewTerminalForStartup: true } ) + expect(mocks.ensureWebRuntimeWorktreeTerminalAfterWake).toHaveBeenCalledWith('worktree-1', { + startup: undefined, + agent: 'codex', + activate: false + }) }) it('stops the fallback mid-way when the creation is dismissed and retires nothing', async () => { diff --git a/src/renderer/src/lib/worktree-creation-structured-session.ts b/src/renderer/src/lib/worktree-creation-structured-session.ts index ee7fd594502..df86f56efed 100644 --- a/src/renderer/src/lib/worktree-creation-structured-session.ts +++ b/src/renderer/src/lib/worktree-creation-structured-session.ts @@ -12,6 +12,7 @@ import type { WorktreeStartupPayload } from '@/lib/worktree-startup-payload' import { closeStructuredAgentSession } from '@/runtime/structured-agent-session-close' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' +import { ensureWebRuntimeWorktreeTerminalAfterWake } from '@/lib/web-runtime-worktree-terminal-after-wake' export type WorktreeCreationStructuredSessionResult = { accepted: boolean @@ -92,17 +93,21 @@ async function openLegacyWorktreeSurface( }) return { activation, primaryTabId: activation === false ? null : activation.primaryTabId } } - return { - primaryTabId: ensureWorktreeHasInitialTerminal( - useAppStore.getState(), - args.worktreeId, - args.fallbackStartupOpt, - undefined, - undefined, - undefined, - { activateCreatedTabs: false, createNewTerminalForStartup: true } - ) - } + const primaryTabId = ensureWorktreeHasInitialTerminal( + useAppStore.getState(), + args.worktreeId, + args.fallbackStartupOpt, + undefined, + undefined, + undefined, + { activateCreatedTabs: false, createNewTerminalForStartup: true } + ) + ensureWebRuntimeWorktreeTerminalAfterWake(args.worktreeId, { + startup: args.fallbackStartupOpt, + agent: args.request.agent, + activate: false + }) + return { primaryTabId } } export async function launchStructuredWorktreeSession( diff --git a/src/renderer/src/runtime/web-runtime-session-types.ts b/src/renderer/src/runtime/web-runtime-session-types.ts index 289562674cb..8664c9192ce 100644 --- a/src/renderer/src/runtime/web-runtime-session-types.ts +++ b/src/renderer/src/runtime/web-runtime-session-types.ts @@ -28,6 +28,8 @@ export type CreateWebRuntimeSessionTerminalArgs = { launchToken?: string agent?: TuiAgent launchAgent?: TuiAgent + /** The command already encodes the complete agent startup and prompt-delivery plan. */ + preparedAgentCommand?: boolean agentSessionKind?: 'fresh' | 'resume' prompt?: string promptDelivery?: AgentPromptDelivery diff --git a/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts b/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts index 601888e5f9b..d14adc5d889 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts @@ -88,7 +88,9 @@ export async function createWebRuntimeSessionTerminalResult( let legacyAlreadyPlacedInGroup = false // Why: structured creation cannot yet express afterTabId; keep the exact legacy placement contract until it can. // Why: focus belongs to the paired client; a headless execution host has no renderer to focus. - const hostAuthority = args.afterTabId + // Why: rebuilding a prepared command through host authority can discard its embedded prompt and delivery flags. + const mustUseLegacyAgentCreate = args.preparedAgentCommand || args.afterTabId + const hostAuthority = mustUseLegacyAgentCreate ? undefined : args.agentSessionKind === 'resume' ? args.providerSession diff --git a/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts b/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts index 71475ff2e79..83789643f1f 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts @@ -141,13 +141,16 @@ export function installActiveSessionTabsSubscription({ const hasLiveLocalPty = localTabs.some( (tab) => (syncState.ptyIdsByTabId[tab.id] ?? []).length > 0 ) - const bootstrap = shouldBootstrapInitialWebRuntimeTerminal({ - event: recoveredEvent, - activeWorktreeId, - requestedInitialTerminal, - snapshotIsFresh: decision.apply, - localTerminalCount - }) + const skipAutomaticTerminal = shouldSkipWebRuntimeWakeTerminalRespawn(activeWorktreeId) + const bootstrap = + !skipAutomaticTerminal && + shouldBootstrapInitialWebRuntimeTerminal({ + event: recoveredEvent, + activeWorktreeId, + requestedInitialTerminal, + snapshotIsFresh: decision.apply, + localTerminalCount + }) const respawn = shouldRespawnWebRuntimeTerminalAfterWake({ event: recoveredEvent, activeWorktreeId, @@ -155,7 +158,7 @@ export function installActiveSessionTabsSubscription({ snapshotIsFresh: decision.apply, localTerminalCount, hasLiveLocalPty, - skipWakeRespawn: shouldSkipWebRuntimeWakeTerminalRespawn(activeWorktreeId) + skipWakeRespawn: skipAutomaticTerminal }) let settle: HostSessionMirrorSettle | null = decision.apply ? null From 8acce092ef40a2ade46f794e96fa6bf2936b0537 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 10 Sep 2026 22:11:34 -0700 Subject: [PATCH 12/17] Hide desktop theme imports from paired web clients (#20015) * Hide Ghostty import from paired web clients - Ghostty import now respects showDesktopOnlySettings, matching Warp behavior - Consolidates desktop-only theme imports under a single showDesktopThemeImports flag - Adds showGhosttyImport option to control visibility across settings UI and search * Hide desktop theme imports from web clients Consolidate Warp and Ghostty import visibility behind a single `showDesktopThemeImports` flag. These theme import flows are desktop-only and should not appear on paired web clients. --------- Co-authored-by: m4air Co-authored-by: m4air --- .../components/settings/AppearancePane.tsx | 4 +- .../TerminalAppearanceSection.ghostty.test.ts | 28 ++++++++ .../settings/TerminalAppearanceSection.tsx | 64 ++++++++++--------- .../components/settings/appearance-search.ts | 16 +---- .../settings/terminal-search.test.ts | 17 +++-- .../components/settings/terminal-search.ts | 21 +++--- .../settings-navigation-interface-sections.ts | 2 +- 7 files changed, 89 insertions(+), 63 deletions(-) diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx index 3c460b32694..24bb19f278a 100644 --- a/src/renderer/src/components/settings/AppearancePane.tsx +++ b/src/renderer/src/components/settings/AppearancePane.tsx @@ -147,7 +147,9 @@ export function AppearancePane({ ] const terminalSearchEntries = [ { title: terminalTitle }, - ...getTerminalAppearanceSearchEntries({ showWarpImport: !isWebClient }) + ...getTerminalAppearanceSearchEntries({ + showDesktopThemeImports: !isWebClient + }) ] const windowSearchEntries = [ { diff --git a/src/renderer/src/components/settings/TerminalAppearanceSection.ghostty.test.ts b/src/renderer/src/components/settings/TerminalAppearanceSection.ghostty.test.ts index d5f2c28f73a..b8162992118 100644 --- a/src/renderer/src/components/settings/TerminalAppearanceSection.ghostty.test.ts +++ b/src/renderer/src/components/settings/TerminalAppearanceSection.ghostty.test.ts @@ -524,8 +524,36 @@ describe('TerminalAppearanceSection ghostty import wiring', () => { expect(findTerminalThemeCatalogSection(element)?.props.showThemeImport).toBe(false) expect(findWarpThemeImportModal(element)).toBeNull() + expect(findButtons(element).some((button) => button.text === 'Import from Ghostty')).toBe(false) + expect(findGhosttyImportModal(element)).toBeNull() }) + it.each([false, true])( + 'hides Ghostty search results on web clients with forceVisiblePrimary=%s', + (forceVisiblePrimary) => { + vi.stubGlobal('window', { __ORCA_WEB_CLIENT__: true }) + mockSettingsSearchQuery = 'ghostty' + + const element = TerminalAppearanceSection({ + settings: {} as never, + updateSettings: () => {}, + systemPrefersDark: true, + terminalFontSuggestions: [], + ghostty: ghosttyMock, + warpThemes: warpThemesMock, + forceVisiblePrimary + }) + + expect(findButtons(element).some((button) => button.text === 'Import from Ghostty')).toBe( + false + ) + expect(findGhosttyImportModal(element)).toBeNull() + if (!forceVisiblePrimary) { + expect(findComponentByTypeName(element, 'SettingsSubsectionHeader')).toBeNull() + } + } + ) + it('passes hook state to GhosttyImportModal', () => { const element = TerminalAppearanceSection({ settings: {} as never, diff --git a/src/renderer/src/components/settings/TerminalAppearanceSection.tsx b/src/renderer/src/components/settings/TerminalAppearanceSection.tsx index b13f535a83a..bf482ed373b 100644 --- a/src/renderer/src/components/settings/TerminalAppearanceSection.tsx +++ b/src/renderer/src/components/settings/TerminalAppearanceSection.tsx @@ -34,7 +34,7 @@ import { GhosttyImportModal } from './GhosttyImportModal' import type { UseGhosttyImportReturn } from './useGhosttyImport' import { WarpThemeImportModal } from './WarpThemeImportModal' import type { UseWarpThemeImportReturn } from './useWarpThemeImport' -import { isWebClientLocation } from '@/hooks/useSettingsNavigationMetadata' +import { isWebClientLocation } from '@/lib/web-client-location' import ghosttyIcon from '../../../../../resources/ghostty.svg' import { translate } from '@/i18n/i18n' @@ -83,7 +83,7 @@ export function TerminalAppearanceSection({ const isSearching = normalizeSettingsSearchQuery(searchQuery).length > 0 const [themeSearch, setThemeSearch] = useState('') const [previewFontFamily, setPreviewFontFamily] = useState(null) - const showWarpThemeImport = !isWebClientLocation() + const showDesktopThemeImports = !isWebClientLocation() const darkThemeSearchEntries = getTerminalDarkThemeSearchEntries() const lightThemeSearchEntries = getTerminalLightThemeSearchEntries() const terminalTypographyEntries = getTerminalTypographySearchEntries() @@ -92,7 +92,7 @@ export function TerminalAppearanceSection({ ...getTerminalThemeTargetSearchEntries(), ...darkThemeSearchEntries, ...lightThemeSearchEntries, - ...(showWarpThemeImport + ...(showDesktopThemeImports ? [...getTerminalWarpImportSearchEntries(), ...getTerminalYamlImportSearchEntries()] : []) ] @@ -116,14 +116,16 @@ export function TerminalAppearanceSection({ searchQuery, terminalTypographyEntries.slice(0, 2) ) - const ghosttyImportMatches = matchesSettingsSearch(searchQuery, ghosttyImportEntries) + const ghosttyImportMatches = + showDesktopThemeImports && matchesSettingsSearch(searchQuery, ghosttyImportEntries) const showPrimaryTypography = !isSearching || forceVisiblePrimary || primaryTypographyMatches || typographyMatches || ghosttyImportMatches - const showGhosttyImport = !isSearching || forceVisiblePrimary || ghosttyImportMatches + const showGhosttyImport = + showDesktopThemeImports && (!isSearching || forceVisiblePrimary || ghosttyImportMatches) const showTypographyAdvancedDisclosure = !isSearching || typographyMatches const advancedGroups = [ @@ -259,36 +261,38 @@ export function TerminalAppearanceSection({ previewFontFamily={previewFontFamily} importedHighlightSignal={warpThemes.importSignal} warpThemes={warpThemes} - showThemeImport={showWarpThemeImport} + showThemeImport={showDesktopThemeImports} preferredTarget={preferredThemeTarget} advancedContent={previewAdvancedContent} /> ) : null} - - {showWarpThemeImport ? ( - + {showDesktopThemeImports ? ( + <> + + + ) : null}
) diff --git a/src/renderer/src/components/settings/appearance-search.ts b/src/renderer/src/components/settings/appearance-search.ts index 726ecaa1b01..09685358145 100644 --- a/src/renderer/src/components/settings/appearance-search.ts +++ b/src/renderer/src/components/settings/appearance-search.ts @@ -223,13 +223,13 @@ const getAppearanceSectionEntries = createLocalizedCatalog((): SettingsSearchEnt ]) type AppearancePaneSearchOptions = { - showWarpImport?: boolean + showDesktopThemeImports?: boolean showSystemTray?: boolean showMenuBarIcon?: boolean } -function buildAppearancePaneSearchEntries( - options: AppearancePaneSearchOptions +export function getAppearancePaneSearchEntries( + options: AppearancePaneSearchOptions = {} ): SettingsSearchEntry[] { return [ ...getAppearanceSectionEntries(), @@ -247,13 +247,3 @@ function buildAppearancePaneSearchEntries( ...getMenuBarIconEntries(options) ] } - -export function getAppearancePaneSearchEntries( - options: AppearancePaneSearchOptions = {} -): SettingsSearchEntry[] { - return buildAppearancePaneSearchEntries({ - showWarpImport: options.showWarpImport ?? true, - showSystemTray: options.showSystemTray, - showMenuBarIcon: options.showMenuBarIcon - }) -} diff --git a/src/renderer/src/components/settings/terminal-search.test.ts b/src/renderer/src/components/settings/terminal-search.test.ts index b9d1ac76969..f86bcfa3f2c 100644 --- a/src/renderer/src/components/settings/terminal-search.test.ts +++ b/src/renderer/src/components/settings/terminal-search.test.ts @@ -156,14 +156,17 @@ describe('getTerminalPaneSearchEntries', () => { expect(matchesSettingsSearch(query, getAppearancePaneSearchEntries())).toBe(true) }) - it('omits the Warp import appearance entry when desktop-only controls are hidden', () => { - const desktopEntries = getAppearancePaneSearchEntries({ showWarpImport: true }) - const webEntries = getAppearancePaneSearchEntries({ showWarpImport: false }) + it.each(['ghostty', 'warp', 'yaml'])( + 'omits desktop-only %s search results on web clients', + (query) => { + const desktopEntries = getAppearancePaneSearchEntries() + const webEntries = getAppearancePaneSearchEntries({ showDesktopThemeImports: false }) - expect(desktopEntries.some((entry) => entry.title === 'Import from Warp')).toBe(true) - expect(webEntries.some((entry) => entry.title === 'Import from Warp')).toBe(false) - expect(webEntries.some((entry) => entry.title === 'Import from Ghostty')).toBe(true) - }) + expect(matchesSettingsSearch(query, desktopEntries)).toBe(true) + expect(matchesSettingsSearch(query, webEntries)).toBe(false) + expect(matchesSettingsSearch('font size', webEntries)).toBe(true) + } + ) it('includes the system tray appearance entry only when desktop tray controls are shown', () => { const desktopEntries = getAppearancePaneSearchEntries({ showSystemTray: true }) diff --git a/src/renderer/src/components/settings/terminal-search.ts b/src/renderer/src/components/settings/terminal-search.ts index 2a460bd64a1..39a66d65e51 100644 --- a/src/renderer/src/components/settings/terminal-search.ts +++ b/src/renderer/src/components/settings/terminal-search.ts @@ -63,10 +63,10 @@ export { } from './terminal-window-setup-search' type TerminalAppearanceSearchOptions = { - showWarpImport?: boolean + showDesktopThemeImports?: boolean } -const getTerminalAppearanceSearchEntriesWithoutWarp = createLocalizedCatalog( +const getTerminalAppearanceSearchEntriesWithoutImports = createLocalizedCatalog( (): SettingsSearchEntry[] => [ ...getTerminalTypographySearchEntries(), ...getTerminalCursorSearchEntries(), @@ -74,16 +74,15 @@ const getTerminalAppearanceSearchEntriesWithoutWarp = createLocalizedCatalog( ...getTerminalThemeTargetSearchEntries(), ...getTerminalDarkThemeSearchEntries(), ...getTerminalLightThemeSearchEntries(), - ...getTerminalWindowSearchEntries(), - ...getTerminalGhosttyImportSearchEntries() + ...getTerminalWindowSearchEntries() ] ) -// Why: compose rather than filter — entry titles are localized, so matching on -// an English title would leak the Warp entry back in under non-English locales. -const getTerminalAppearanceSearchEntriesWithWarp = createLocalizedCatalog( +// Compose catalogs because translated titles cannot reliably identify desktop-only entries. +const getTerminalAppearanceSearchEntriesWithImports = createLocalizedCatalog( (): SettingsSearchEntry[] => [ - ...getTerminalAppearanceSearchEntriesWithoutWarp(), + ...getTerminalAppearanceSearchEntriesWithoutImports(), + ...getTerminalGhosttyImportSearchEntries(), ...getTerminalWarpImportSearchEntries(), ...getTerminalYamlImportSearchEntries() ] @@ -92,9 +91,9 @@ const getTerminalAppearanceSearchEntriesWithWarp = createLocalizedCatalog( export function getTerminalAppearanceSearchEntries( options: TerminalAppearanceSearchOptions = {} ): SettingsSearchEntry[] { - return (options.showWarpImport ?? true) - ? getTerminalAppearanceSearchEntriesWithWarp() - : getTerminalAppearanceSearchEntriesWithoutWarp() + return (options.showDesktopThemeImports ?? true) + ? getTerminalAppearanceSearchEntriesWithImports() + : getTerminalAppearanceSearchEntriesWithoutImports() } export function getTerminalPaneSearchEntries(platform: { diff --git a/src/renderer/src/hooks/settings-navigation-interface-sections.ts b/src/renderer/src/hooks/settings-navigation-interface-sections.ts index 0208dd1f526..35d51532315 100644 --- a/src/renderer/src/hooks/settings-navigation-interface-sections.ts +++ b/src/renderer/src/hooks/settings-navigation-interface-sections.ts @@ -26,7 +26,7 @@ export function buildInterfaceSettingsSections({ ), icon: Palette, searchEntries: getAppearancePaneSearchEntries({ - showWarpImport: showDesktopOnlySettings, + showDesktopThemeImports: showDesktopOnlySettings, showSystemTray: showDesktopOnlySettings && isWindows, showMenuBarIcon: showDesktopOnlySettings && isMac }), From cf7408a37fe63af4543ea6a4ece9474a993d783f Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:11:37 -0400 Subject: [PATCH 13/17] feat(ai-vault-search): session search query engine over the index (#19750) 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 --- .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 | 218 ++++++++ .../session-search-engine-test-fixture.ts | 113 +++++ .../session-search-engine-types.ts | 157 ++++++ .../session-search-engine.test.ts | 471 ++++++++++++++++++ .../ai-vault-search/session-search-engine.ts | 349 +++++++++++++ .../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 | 320 ++++++++++++ .../session-search-index-generation.ts | 97 ++++ .../session-search-orphan-rows.test.ts | 186 +++++++ .../session-search-page-cursor.ts | 108 ++++ .../session-search-paging.test.ts | 309 ++++++++++++ .../session-search-query-log.test.ts | 61 +++ .../session-search-query-log.ts | 30 ++ .../session-search-query-planner.test.ts | 84 ++++ .../session-search-query-planner.ts | 140 ++++++ .../session-search-query-schema.ts | 79 +++ .../session-search-retrieval.ts | 251 ++++++++++ .../session-search-row-filter.test.ts | 137 +++++ .../session-search-row-filter.ts | 90 ++++ .../session-search-sidebar-parity.test.ts | 137 +++++ .../session-search-snippet-marks.test.ts | 112 +++++ .../ai-vault-search/session-search-snippet.ts | 126 +++++ .../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 ++--- 35 files changed, 5136 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-log.test.ts create mode 100644 src/main/ai-vault-search/session-search-query-log.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 913dfc4a045..5bb1fedcaee 100644 --- a/.gitignore +++ b/.gitignore @@ -103,6 +103,7 @@ docs/** !docs/agent-skill-sharing-implementation-checklist.md !docs/mobile-terminal-shortcut-bar.md !docs/reference/ +!docs/reference/agent-session-search-query-tuning.md !docs/reference/agent-status-store.md !docs/reference/git-compatibility.md !docs/reference/headless-linux-server.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..fcae799f8a6 --- /dev/null +++ b/docs/reference/agent-session-search-query-tuning.md @@ -0,0 +1,218 @@ +# 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 carries its own schema — the vocabulary, the +query log and the generation triggers — and re-creates whatever of it is missing +on every search, so a dropped object heals rather than degrading. + +The one it cannot re-create is the vocabulary's source, because `messages_fts` +is the store's. With one FTS table that is also the end of the degrade: there is +no second corpus to answer from, so an engine over an index mid-rebuild names +typo repair as unavailable and then fails on the table it cannot read, which is +the honest outcome — an empty page would read as an answer. `unavailable` can +therefore no longer be reported alongside a successful search, and PR 5 should +decide whether the field survives into the contract; it becomes reachable again +the day something opens the index read-only. 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..861130be59b --- /dev/null +++ b/src/main/ai-vault-search/session-search-engine-types.ts @@ -0,0 +1,157 @@ +import type { AiVaultAgent } from '../../shared/ai-vault-types' +import type { TranscriptMessageRole } from '../ai-vault/session-transcript-consumers' +import type { SessionSearchUnavailableFeature } from './session-search-query-schema' + +// 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[] + /** + * Engine features the index on disk cannot serve, empty on a current index. + * A route ladder missing its repair rung still answers; saying so is what + * keeps the answer honest. + */ + unavailable: readonly SessionSearchUnavailableFeature[] + 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..140f9449c3d --- /dev/null +++ b/src/main/ai-vault-search/session-search-engine.test.ts @@ -0,0 +1,471 @@ +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 and the log 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.unavailable).toEqual([]) + 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.unavailable).toEqual([]) + expect(after.planner.route).toBe('typo+or') + }) + + it('names the feature it cannot serve when the vocabulary has no source left', async () => { + // What an index being rebuilt by another handle looks like from here. The + // vocabulary can be created over a missing `messages_fts` and every query + // against it then fails, so the probe reads the source, not the view. + // + // With one FTS table there is no scope left to answer from, so this is now + // the boundary of the degrade: the engine names the feature and the search + // fails loudly on the table it cannot read, rather than returning an empty + // page that looks like an answer. + 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)).toEqual(['typo-repair']) + for (const scope of ['all', 'conversation'] as const) { + expect(() => engine.search({ query: 'coalesces', scope })).toThrow(/no such (fts5 )?table/i) + } + }) + + it('picks the feature back up when the source comes back', 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)).toEqual(['typo-repair']) + + 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.unavailable).toEqual([]) + 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) + }) +}) 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..4b6e11e407d --- /dev/null +++ b/src/main/ai-vault-search/session-search-engine.ts @@ -0,0 +1,349 @@ +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 { + decodeSessionSearchCursor, + encodeSessionSearchCursor, + sessionSearchPageKey +} from './session-search-page-cursor' +import { planSessionSearchQuery } from './session-search-query-planner' +import { logSessionSearchQuery } from './session-search-query-log' +import { + SessionSearchRetrieval, + type RetrievalScope, + type Retrieved +} from './session-search-retrieval' +import { sessionRowFilter } from './session-search-row-filter' +import { + ensureSessionSearchQuerySchema, + type SessionSearchUnavailableFeature +} 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 + /** Write each query to `search_log`. Off unless a caller asks (see query-log). */ + logQueries?: boolean +} + +/** + * Ranked session search over the PR 2 index. + * + * A library: it holds no timers, reads no settings, and knows nothing about + * Electron, IPC or a panel. It is handed a connection rather than opening one, + * because which process may open, rebuild or unlink the index file is PR 3b's + * decision and not a query engine's. + * + * **Every read here is a single statement, and no read transaction is ever + * open across an `await`.** There is no `BEGIN` on this path, no `.iterate()` + * outliving its statement, and `search` is synchronous end to end. That is a + * constraint PR 2 measured rather than a style: a reader that pins a WAL + * snapshot holds off every checkpoint behind it, and the same 47 MB of writes + * that leave a 9.9 MB WAL grew to 266 MB with one `BEGIN` + `SELECT` held open. + * + * One search is one synchronous pass, and every page of it is a slice of the + * same ranked list. That list is rebuilt per page rather than streamed, which + * is what makes a page repeatable: within one index generation the same request + * ranks the same way, and a cursor from any other generation is refused. + * + * That fence is strict on purpose, and the cost is worth stating plainly: any + * committed read moves the generation, so while a backfill is running an + * outstanding cursor will be refused, often within a second. Pagination is + * usable against a settled index and unreliable against one still filling. The + * rejection carries both generations, so a caller that sees `stale-generation` + * knows the index moved rather than that it holds a bad cursor, and can quietly + * re-issue page one instead of showing anyone an error. + */ +export class SessionSearchEngine { + private retrieval: SessionSearchRetrieval + private readonly candidateLimit: number + /** Re-probed whenever a query proves it stale; see `withCapabilityRetry`. */ + private unavailable: readonly SessionSearchUnavailableFeature[] + + 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. + this.unavailable = ensureSessionSearchQuerySchema(this.db) + this.retrieval = new SessionSearchRetrieval(this.db, !this.unavailable.includes('typo-repair')) + } + + search(request: SessionSearchRequest): SessionSearchResponse { + const startedAt = performance.now() + this.probeCapabilities() + 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 } = this.withCapabilityRetry(() => + 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 hasMore = ranked.length > offset + limit + const response: SessionSearchResponse = { + hits, + unavailable: this.unavailable, + 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 + } + if (this.options.logQueries) { + logSessionSearchQuery(this.db, { + query: request.query, + route: response.planner.route, + hits: hits.length, + durationMs: response.durationMs + }) + } + return response + } + + /** + * Where the engine's own schema is created and checked, once per search. + * + * A capability is a fact about the file, not about this object: another handle + * can rebuild the index under a live connection, so a verdict cached in the + * constructor is wrong for the rest of the engine's life in both directions — + * it would keep reaching for a table that went away, and never pick one back + * up when it returned. Retrieval is only rebuilt when the answer changes, so + * the steady-state cost is one indexed lookup and nothing else. + */ + private probeCapabilities(): void { + const unavailable = ensureSessionSearchQuerySchema(this.db) + if (unavailable.join() === this.unavailable.join()) { + return + } + this.unavailable = unavailable + this.retrieval = new SessionSearchRetrieval(this.db, !unavailable.includes('typo-repair')) + } + + /** + * Runs a retrieval, and re-probes once if it turns out the index no longer + * has what an earlier probe found. + * + * `probeCapabilities` already runs per search, so this only covers the window + * between that probe and the statement that reaches for the table. Losing a + * table there is a thrown error rather than a wrong verdict, so it re-probes + * and runs the search again. + */ + private withCapabilityRetry(run: () => RankedPage): RankedPage { + try { + return run() + } catch (error) { + if (!isMissingTableError(error)) { + throw error + } + this.probeCapabilities() + return run() + } + } + + /** + * 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])) + // Operators cut here, after retrieval, so the candidate count still reports + // what the SQL limit saw: that is what tells a caller the limit was binding. + const sessions = this.retrieval.loadSessions([...best.keys()], scope) + // Counted before the operator predicate and before fork folding: the SQL + // LIMIT is what could have hidden a session, and it saw the unfiltered set. + return { + ranked: rankSessionHits(sessions, best, sort), + retrieved, + incomplete: best.size >= this.candidateLimit + } + } + + /** 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 + } + } +} + +// SQLite reports a table that went away at the statement that reaches for it. +// `fts5` is in the message when the table is the vocabulary's target, which is +// the one an index rebuilt under a live connection loses first. +const MISSING_TABLE = /no such (fts5 )?table/i + +function isMissingTableError(error: unknown): boolean { + return error instanceof Error && MISSING_TABLE.test(error.message) +} + +/** + * 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..a3fffd2648f --- /dev/null +++ b/src/main/ai-vault-search/session-search-index-generation.test.ts @@ -0,0 +1,320 @@ +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 { + await indexOneTranscript(root, store) + const engine = new SessionSearchEngine(db) + db.exec('DROP TRIGGER search_generation_file_update') + engine.search({ query: 'needle' }) + + const restored = readIndexGeneration(db) + await indexOneTranscript(root, store) + 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..891608ed2e7 --- /dev/null +++ b/src/main/ai-vault-search/session-search-index-generation.ts @@ -0,0 +1,97 @@ +import type SyncDatabase from '../sqlite/sync-database' + +const GENERATION_KEY = 'index_generation' + +/** + * Names of the triggers that move the generation. Exported so the query schema + * can check they are all still there before an engine trusts a cursor. + */ +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;` + +/** + * The fence, as three triggers on `files`. + * + * Why `files`. Every transaction the store opens that can change what a search + * returns writes this table: a committed read upserts the file's cursor beside + * its rows, a chunk of a long read upserts the partial sentinel beside its + * prefix, `removeFile` deletes the row with the session, and retention deletes + * the file row in the same transaction as the session row. + * + * And why `messages` as well, for orphans only. Retention's second half + * reclaims rows whose session row is already gone, and touches neither table + * above. It was left unfenced on the argument that those rows answer nothing, + * which is true of retrieval and was not true of the whole engine: 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, and + * reclaiming them moved which word a query was repaired to. The repair now + * counts live rows instead, so the common case is fixed at its source; this + * trigger is what makes the fence true rather than nearly true, because the + * vocabulary still decides which candidates survive its scan limit. + * + * The `WHEN` clause is what keeps it free. `removeFile` deletes a session's + * rows while its `sessions` row still stands, so it does not fire here; a + * replace cuts the old `sessions` row loose and leaves its messages to the + * drain (PR 2 round 10). Both already bump through `files`; the drain is the + * only path that deletes a row whose session is gone, and it fires here. The cost of the fence is real and worth naming: a + * cursor outstanding while a purge runs is refused once per batch, which + * `SessionSearchCursorError` reports as `stale-generation` so a caller + * re-issues page one rather than showing anyone an error. + * + * A trigger rather than a call the writer makes, for two reasons. PR 4 does not + * own the writer, and more importantly the fence has to hold for writers this + * process cannot see: the triggers live in the file, so PR 3's indexer in the + * scanner child moves the generation without knowing a reader exists. + * + * Correctness comes from where the increment runs, not from what it counts. It + * is one statement inside the writer's own `BEGIN IMMEDIATE`, so it commits + * with the change it describes and two connections cannot mint one value twice. + * It over-counts in one harmless direction: a read that decoded no session from + * a file the index also held no session for advances a cursor and bumps + * anyway. That refuses a cursor early; it never honours one late. + */ +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; +` + +/** + * A monotone id for what the index currently publishes. + * + * A search page is a slice of one ranked list, so a cursor only means anything + * against the snapshot that produced it. Every change to what a read can return + * moves this on, and a cursor minted under an older value is refused rather + * than silently re-run against a list it no longer indexes into. + * + * Read from the database on every call, never cached in a process. The writer + * and the reader need not be the same one: PR 3's indexer runs in the scanner + * child while an engine reads elsewhere, and any number of handles may be open + * on one file. A generation cached in memory only ever tracks that process's + * own writes, so a reader would see another writer's deletions while its + * generation stood still, honour a stale cursor, and skip a session. + */ +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..838e5e1e3ab --- /dev/null +++ b/src/main/ai-vault-search/session-search-page-cursor.ts @@ -0,0 +1,108 @@ +import { createHash } from 'node:crypto' +import type { SessionSearchRequest } from './session-search-engine-types' + +export type SessionSearchCursorRejection = 'stale-generation' | 'different-query' | 'malformed' + +/** + * A cursor the engine refuses to honour. Typed, and thrown rather than + * swallowed: silently restarting at page one hands the caller a page it has + * already shown as if it were the next one, and silently re-running against a + * newer index hands it a slice of a list it never saw. + */ +export class SessionSearchCursorError extends Error { + constructor( + readonly rejection: SessionSearchCursorRejection, + /** + * The generation the index is at now. Always present: the engine knows it + * before it looks at the cursor at all. + */ + readonly actualGeneration: number, + /** + * The generation the cursor claims it was minted in. Absent only when the + * cursor could not be decoded far enough to carry a number, which is one of + * the `malformed` cases. + */ + readonly expectedGeneration?: number + ) { + super(`Search cursor 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. + const claimed = + typeof payload?.g === 'number' && Number.isFinite(payload.g) ? 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..319c5b0dba4 --- /dev/null +++ b/src/main/ai-vault-search/session-search-paging.test.ts @@ -0,0 +1,309 @@ +import { afterEach, describe, expect, it } 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] + ])('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) + }) +}) diff --git a/src/main/ai-vault-search/session-search-query-log.test.ts b/src/main/ai-vault-search/session-search-query-log.test.ts new file mode 100644 index 00000000000..7b88e628a83 --- /dev/null +++ b/src/main/ai-vault-search/session-search-query-log.test.ts @@ -0,0 +1,61 @@ +import { afterEach, expect, it } from 'vitest' +import { + addSyntheticSession, + openSessionSearchHarness, + type SessionSearchHarness +} from './session-search-engine-test-fixture' +import { logSessionSearchQuery } from './session-search-query-log' + +let harness: SessionSearchHarness | null = null + +afterEach(async () => { + await harness?.close() + harness = null +}) + +async function open(options = {}): Promise { + harness = await openSessionSearchHarness('ss-query-log', options) + addSyntheticSession(harness.db, { id: 1, text: 'needle' }) + return harness +} + +function loggedQueries(harness: SessionSearchHarness): string[] { + return ( + harness.db.prepare('SELECT query FROM search_log ORDER BY id').all() as { query: string }[] + ).map((row) => row.query) +} + +it('writes nothing on the query path unless the caller asked for a log', async () => { + const opened = await open() + opened.engine.search({ query: 'needle' }) + expect(loggedQueries(opened)).toEqual([]) +}) + +it('records the query and its route when logging is on', async () => { + const opened = await open({ logQueries: true }) + opened.engine.search({ query: 'needle' }) + const rows = opened.db.prepare('SELECT query, route, hits FROM search_log').all() as { + query: string + route: string + hits: number + }[] + expect(rows).toEqual([{ query: 'needle', route: 'or', hits: 1 }]) +}) + +it('stores the query as typed, the way the index stores content as written', async () => { + // PR 2 decided the index does not redact: it is a second copy of plaintext + // the user already holds under their own home directory. The same holds for + // what they typed into the search box. + const opened = await open({ logQueries: true }) + opened.engine.search({ query: 'Bearer abcdefghijklmnopqrstuvwxyz012345' }) + expect(loggedQueries(opened)[0]).toBe('Bearer abcdefghijklmnopqrstuvwxyz012345') +}) + +it('keeps the newest N and drops the rest, so the log cannot grow with use', async () => { + const opened = await open() + // The real ceiling is 5,000; the trim is the same statement at any size. + for (let n = 0; n < 12; n++) { + logSessionSearchQuery(opened.db, { query: `q${n}`, route: 'or', hits: 0, durationMs: 1 }, 5) + } + expect(loggedQueries(opened)).toEqual(['q7', 'q8', 'q9', 'q10', 'q11']) +}) diff --git a/src/main/ai-vault-search/session-search-query-log.ts b/src/main/ai-vault-search/session-search-query-log.ts new file mode 100644 index 00000000000..cdc5ce54425 --- /dev/null +++ b/src/main/ai-vault-search/session-search-query-log.ts @@ -0,0 +1,30 @@ +import type SyncDatabase from '../sqlite/sync-database' + +export const SEARCH_LOG_LIMIT = 5000 + +/** + * Local-only telemetry the eval set is rebuilt from. + * + * The query is stored as typed, for the reason PR 2 gives for not redacting + * transcript content: this file sits beside an index that already holds the + * user's own plaintext, so a second copy of what they typed is not a new + * exposure. What may leave the machine is a transport policy and belongs where + * the wire is. + * + * Nothing enables this by default: the engine writes a row only when its caller + * asked for it, because a log write on the query path is a write on what is + * otherwise a read-only lane. Who turns it on is PR 3b's settings decision. + */ +export function logSessionSearchQuery( + db: SyncDatabase, + entry: { query: string; route: string; hits: number; durationMs: number }, + limit: number = SEARCH_LOG_LIMIT +): void { + db.prepare( + 'INSERT INTO search_log(ts, query, route, hits, duration_ms) VALUES (?, ?, ?, ?, ?)' + ).run(new Date().toISOString(), entry.query, entry.route, entry.hits, entry.durationMs) + db.prepare( + `DELETE FROM search_log WHERE id <= ( + SELECT id FROM search_log ORDER BY id DESC LIMIT 1 OFFSET ?)` + ).run(limit) +} 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..f17b290e530 --- /dev/null +++ b/src/main/ai-vault-search/session-search-query-schema.ts @@ -0,0 +1,79 @@ +import type SyncDatabase from '../sqlite/sync-database' +import { + SESSION_SEARCH_GENERATION_SQL, + SESSION_SEARCH_GENERATION_TRIGGERS +} from './session-search-index-generation' + +/** An engine feature the index on disk cannot serve. */ +export type SessionSearchUnavailableFeature = 'typo-repair' + +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'); +-- Locally logged queries, stored as typed, bounded. Nothing writes here unless a +-- caller opts in; the eval set is rebuilt from it (see session-search-query-log). +CREATE TABLE IF NOT EXISTS search_log( + id INTEGER PRIMARY KEY, + ts TEXT NOT NULL, + query TEXT NOT NULL, + route TEXT NOT NULL, + hits INTEGER NOT NULL, + duration_ms REAL NOT NULL +); +${SESSION_SEARCH_GENERATION_SQL}` + +/** Everything the SQL above creates, so a missing one is what triggers a re-run. */ +const OWNED = ['messages_vocab', 'search_log', ...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] + +/** + * Creates whatever of the engine's own schema is missing, and reports what it + * still cannot serve. + * + * These objects are the query engine's, not the store's. Nothing on the write + * path reads any of them, so under the stack's YAGNI rule they do not belong in + * PR 2's schema, and an index built by a process that never opens an engine + * carries none of their cost. None of them needs a schema version either: every + * one is derived from what PR 2 already holds, so re-creating them over any of + * its files is correct, while a version bump would throw a whole index away to + * add a view over its own b-tree. + * + * Run per search, not once per engine. A capability is a fact about the file + * rather than about this object: another handle can rebuild the index under a + * live connection, so a verdict taken in a constructor is wrong for the rest of + * the engine's life in both directions — it would keep reaching for a table + * that went away and never pick one back up when it returned. The steady-state + * cost is the single indexed `sqlite_master` lookup below. + * + * A create that throws is not caught. The only way to reach one is an index + * whose `files` table is gone, which is a rebuild in flight — and an engine + * over that cannot report a hit's source either, so there is nothing to degrade + * to. Losing only the vocabulary's source is the case worth surviving, and that + * one is reported rather than thrown. + */ +export function ensureSessionSearchQuerySchema( + db: SyncDatabase +): readonly SessionSearchUnavailableFeature[] { + const present = presentNames(db) + if (OWNED.some((name) => !present.has(name))) { + db.exec(QUERY_SCHEMA_SQL) + } + return present.has(VOCABULARY_SOURCE) ? [] : ['typo-repair'] +} + +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..2bbac5d3e4e --- /dev/null +++ b/src/main/ai-vault-search/session-search-retrieval.ts @@ -0,0 +1,251 @@ +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' +// The conversation scope zeroes the two columns its filter already excludes. +// Measured, and stated because it is easy to over-read: these zeros change no +// score. FTS5's bm25 sums over the columns the query matched, and the filter +// has already kept the match out of those two, so the same rows come back with +// `1.0, 1.0` here. They are a statement of what the scope means, not the fence +// that enforces it — `scopedExpression` is the fence. +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 = { + rows: MessageRow[] + 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 { + /** Null when this index has no vocabulary to repair against; the rung is skipped. */ + private readonly typoRepair: SessionSearchTypoRepair | null + + constructor( + private readonly db: SyncDatabase, + canRepairTypos = true + ) { + this.typoRepair = canRepairTypos ? new SessionSearchTypoRepair(db) : null + } + + /** + * 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 { + const exact = this.literal(plan, scope) + if (exact) { + return { ...exact, plan } + } + const repaired = this.repair(plan, scope.scope) + const effective = repaired ?? plan + const literal = repaired ? this.literal(repaired, scope) : null + const found = literal ?? { + rows: this.match(orExpression(effective.terms), scope), + route: 'or' as const + } + return { + rows: found.rows, + 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 } + } + + /** + * Read in batches, because the id list is as long as the candidate limit and + * every id is a bound parameter, so a single statement scales with a knob the + * tuning doc invites a host to raise. + * + * Not a fix for a reachable failure, and worth saying so: SQLite has bound + * `SQLITE_MAX_VARIABLE_NUMBER` at 32,766 since 3.32, every runtime this stack + * supports is past that, and the measured limit on this one is higher still. + * A candidate limit that large is not a configuration anyone would choose. + * The batch is here so the ceiling belongs to this file rather than to + * whichever SQLite the process happened to link. + */ + 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 { + if (!this.typoRepair) { + return 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, + scope: RetrievalScope + ): { 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 = this.match(phraseExpression(plan.body), scope) + if (phrase.length > 0) { + return { rows: phrase, route: 'phrase' } + } + if (plan.body.length < 2) { + return null + } + const and = this.match(andExpression(plan.body), scope) + 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..dd7ca0d1e5c --- /dev/null +++ b/src/main/ai-vault-search/session-search-row-filter.test.ts @@ -0,0 +1,137 @@ +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('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..5015fa460e6 --- /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) + if (present.length > 0) { + filter.conditions.push(`(${present.join(' OR ')})`) + } + } + 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..081b16479ed --- /dev/null +++ b/src/main/ai-vault-search/session-search-sidebar-parity.test.ts @@ -0,0 +1,137 @@ +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' + } +] + +// 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' +] + +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, operators only: free text is FTS in the index. */ +function sidebarIds(query: string): string[] { + const operatorsOnly = query + .split(/\s+/) + .filter((token) => /^(repo|path):/i.test(token)) + .join(' ') + return filterAiVaultSessions(SESSIONS.map(asSession), { + query: operatorsOnly, + 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..71704b78fee --- /dev/null +++ b/src/main/ai-vault-search/session-search-snippet-marks.test.ts @@ -0,0 +1,112 @@ +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') +}) 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..2e23e707dd0 --- /dev/null +++ b/src/main/ai-vault-search/session-search-snippet.ts @@ -0,0 +1,126 @@ +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 marked = columns + .map((_column, index) => row[`c${index}`]) + .find((text, index) => text !== undefined && text !== row[`p${index}`]) + return marked === undefined ? EMPTY_SNIPPET : publicMarks(truncateSnippet(marked)) + } catch { + return EMPTY_SNIPPET + } +} + +/** The internal marks, swapped for the ones a caller sees, once and at the end. */ +function publicMarks(snippet: SessionSearchSnippet): SessionSearchSnippet { + return { + ...snippet, + text: snippet.text + .replaceAll(MARK_OPEN, SESSION_SEARCH_SNIPPET_MARK_OPEN) + .replaceAll(MARK_CLOSE, SESSION_SEARCH_SNIPPET_MARK_CLOSE) + } +} + +/** Cut on a code-point boundary, and never between a mark and its close. */ +export function truncateSnippet(text: string): SessionSearchSnippet { + if (text.length <= SNIPPET_MAX_CHARS) { + return { text, truncated: false } + } + const points = [...text] + if (points.length <= SNIPPET_MAX_CHARS) { + return { text, truncated: false } + } + const cut = points.slice(0, SNIPPET_MAX_CHARS).join('') + const opened = cut.lastIndexOf(MARK_OPEN) + // An open mark with no close hands the renderer something it can never close. + const balanced = opened !== -1 && !cut.includes(MARK_CLOSE, opened) ? cut.slice(0, opened) : cut + return { text: balanced, truncated: true } +} 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 37603a6cf3d1a022c8e940b971680b92bc26e459 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:15:46 -0700 Subject: [PATCH 14/17] refactor(shared): derive WellKnownAgentType from TuiAgent (#19645) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hand-written 22-member WellKnownAgentType union was a stale copy of the launchable-agent list, 15 members behind TuiAgent: aug, autohand, claude-agent-teams, cline, codebuff, continue, crush, goose, kilo, kimi, kiro, mistral-vibe, openclaw, qwen-code, rovo. All 21 non-'unknown' members it did carry were already TuiAgent members, so the union is now derived (`TuiAgent | 'unknown'`) and cannot drift again. 'unknown' stays outside TuiAgent: it is the "no agent identified yet" sentinel, not a launchable agent. AgentType is structurally unchanged. `(string & {})` absorbs the union, so AgentType was and remains `string` — this is a documentation/staleness fix with no behaviour change and zero consumers affected (WellKnownAgentType had none repo-wide beyond the AgentType alias itself). tui-agent.ts is a pure type union with no imports, so this adds no cycle. Adds a type-level coverage test that fails to compile if anyone reverts to a hand-written list. --- src/shared/agent-status-types.test.ts | 31 +++++++++++++++++++++++++++ src/shared/agent-status-types.ts | 28 ++++-------------------- 2 files changed, 35 insertions(+), 24 deletions(-) diff --git a/src/shared/agent-status-types.test.ts b/src/shared/agent-status-types.test.ts index b075f87ca86..f79f3c974f3 100644 --- a/src/shared/agent-status-types.test.ts +++ b/src/shared/agent-status-types.test.ts @@ -15,6 +15,8 @@ import { AGENT_STATUS_STATES, AGENT_TYPE_MAX_LENGTH } from './agent-status-types' +import type { AgentType, WellKnownAgentType } from './agent-status-types' +import type { TuiAgent } from './tui-agent' afterEach(() => { vi.restoreAllMocks() @@ -676,3 +678,32 @@ describe('normalizeAgentStatusPayload matches the JSON round trip', () => { } }) }) + +describe('WellKnownAgentType', () => { + // Compile-time proof the union is derived from TuiAgent rather than hand-copied: + // a literal list that misses any launchable agent id fails to typecheck here. + const widenTuiAgent = (agent: TuiAgent): WellKnownAgentType => agent + + it('covers every TuiAgent id plus the unknown sentinel', () => { + // ids the previous 22-member hand-written union had drifted past + const formerlyMissing: WellKnownAgentType[] = [ + 'qwen-code', + 'mistral-vibe', + 'claude-agent-teams' + ] + const sentinel: WellKnownAgentType = 'unknown' + + expect([...formerlyMissing, sentinel, widenTuiAgent('rovo')]).toEqual([ + 'qwen-code', + 'mistral-vibe', + 'claude-agent-teams', + 'unknown', + 'rovo' + ]) + }) + + it('keeps AgentType open to custom agent names', () => { + const custom: AgentType = 'some-in-house-agent' + expect(custom).toBe('some-in-house-agent') + }) +}) diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 666cedb5748..3a062f7069d 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -5,6 +5,7 @@ import type { AgentProviderSessionMetadata } from './agent-session-resume' import type { OrchestrationFleetAttention } from './orchestration-fleet-attention' import type { AgentStatusRowFacets } from './agent-status-observation' +import type { TuiAgent } from './tui-agent' import { normalizeInteractivePromptField, normalizeOptionalField, @@ -26,30 +27,9 @@ export const AGENT_STATUS_STATES = ['working', 'blocked', 'waiting', 'done'] as export type AgentStatusState = (typeof AGENT_STATUS_STATES)[number] export type AgentWorkingMode = 'monitoring' // Why: agent types aren't a fixed set (custom agents exist); any non-empty string is -// accepted — these well-known names are just a convenience union for pattern-matching. -export type WellKnownAgentType = - | 'claude' - | 'openclaude' - | 'codex' - | 'gemini' - | 'antigravity' - | 'amp' - | 'opencode' - | 'mimo-code' - | 'cursor' - | 'copilot' - | 'aider' - | 'pi' - | 'omp' - | 'prime-agent' - | 'droid' - | 'command-code' - | 'grok' - | 'hermes' - | 'devin' - | 'ante' - | 'trae' - | 'unknown' +// accepted — the well-known names are the launchable TuiAgent ids plus the 'unknown' +// sentinel (no agent identified yet), a convenience union for pattern-matching. +export type WellKnownAgentType = TuiAgent | 'unknown' export type AgentType = WellKnownAgentType | (string & {}) /** A snapshot of a previous agent state, used to render activity blocks. From f31bfa8fb79ac19386a2a3d6d1a3578e1e6c1f7d Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:24:08 -0400 Subject: [PATCH 15/17] Revert "feat(ai-vault-search): session search query engine over the index (#19750)" (#20023) This reverts commit cf7408a37fe63af4543ea6a4ece9474a993d783f. --- .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 | 218 -------- .../session-search-engine-test-fixture.ts | 113 ----- .../session-search-engine-types.ts | 157 ------ .../session-search-engine.test.ts | 471 ------------------ .../ai-vault-search/session-search-engine.ts | 349 ------------- .../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 | 320 ------------ .../session-search-index-generation.ts | 97 ---- .../session-search-orphan-rows.test.ts | 186 ------- .../session-search-page-cursor.ts | 108 ---- .../session-search-paging.test.ts | 309 ------------ .../session-search-query-log.test.ts | 61 --- .../session-search-query-log.ts | 30 -- .../session-search-query-planner.test.ts | 84 ---- .../session-search-query-planner.ts | 140 ------ .../session-search-query-schema.ts | 79 --- .../session-search-retrieval.ts | 251 ---------- .../session-search-row-filter.test.ts | 137 ----- .../session-search-row-filter.ts | 90 ---- .../session-search-sidebar-parity.test.ts | 137 ----- .../session-search-snippet-marks.test.ts | 112 ----- .../ai-vault-search/session-search-snippet.ts | 126 ----- .../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 +++-- 35 files changed, 63 insertions(+), 5136 deletions(-) delete mode 100644 config/scripts/session-search-query-benchmark.ts delete mode 100644 config/scripts/session-search-scope-benchmark.ts delete mode 100644 config/scripts/session-search-tool-heavy-corpus.ts delete mode 100644 docs/reference/agent-session-search-query-tuning.md delete mode 100644 src/main/ai-vault-search/session-search-engine-test-fixture.ts delete mode 100644 src/main/ai-vault-search/session-search-engine-types.ts delete mode 100644 src/main/ai-vault-search/session-search-engine.test.ts delete mode 100644 src/main/ai-vault-search/session-search-engine.ts delete mode 100644 src/main/ai-vault-search/session-search-fts5-contract.test.ts delete mode 100644 src/main/ai-vault-search/session-search-hit-ranking.test.ts delete mode 100644 src/main/ai-vault-search/session-search-hit-ranking.ts delete mode 100644 src/main/ai-vault-search/session-search-index-generation.test.ts delete mode 100644 src/main/ai-vault-search/session-search-index-generation.ts delete mode 100644 src/main/ai-vault-search/session-search-orphan-rows.test.ts delete mode 100644 src/main/ai-vault-search/session-search-page-cursor.ts delete mode 100644 src/main/ai-vault-search/session-search-paging.test.ts delete mode 100644 src/main/ai-vault-search/session-search-query-log.test.ts delete mode 100644 src/main/ai-vault-search/session-search-query-log.ts delete mode 100644 src/main/ai-vault-search/session-search-query-planner.test.ts delete mode 100644 src/main/ai-vault-search/session-search-query-planner.ts delete mode 100644 src/main/ai-vault-search/session-search-query-schema.ts delete mode 100644 src/main/ai-vault-search/session-search-retrieval.ts delete mode 100644 src/main/ai-vault-search/session-search-row-filter.test.ts delete mode 100644 src/main/ai-vault-search/session-search-row-filter.ts delete mode 100644 src/main/ai-vault-search/session-search-sidebar-parity.test.ts delete mode 100644 src/main/ai-vault-search/session-search-snippet-marks.test.ts delete mode 100644 src/main/ai-vault-search/session-search-snippet.ts delete mode 100644 src/main/ai-vault-search/session-search-source-presence.ts delete mode 100644 src/main/ai-vault-search/session-search-typo-policy.test.ts delete mode 100644 src/main/ai-vault-search/session-search-typo-repair.ts delete mode 100644 src/main/ai-vault-search/session-search-typo-scope.test.ts delete mode 100644 src/shared/ai-vault-search-query-operators.test.ts delete mode 100644 src/shared/ai-vault-search-query-operators.ts diff --git a/.gitignore b/.gitignore index 5bb1fedcaee..913dfc4a045 100644 --- a/.gitignore +++ b/.gitignore @@ -103,7 +103,6 @@ docs/** !docs/agent-skill-sharing-implementation-checklist.md !docs/mobile-terminal-shortcut-bar.md !docs/reference/ -!docs/reference/agent-session-search-query-tuning.md !docs/reference/agent-status-store.md !docs/reference/git-compatibility.md !docs/reference/headless-linux-server.md diff --git a/config/scripts/session-search-query-benchmark.ts b/config/scripts/session-search-query-benchmark.ts deleted file mode 100644 index 1471a0a17bd..00000000000 --- a/config/scripts/session-search-query-benchmark.ts +++ /dev/null @@ -1,192 +0,0 @@ -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 deleted file mode 100644 index 306387cbdda..00000000000 --- a/config/scripts/session-search-scope-benchmark.ts +++ /dev/null @@ -1,205 +0,0 @@ -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 deleted file mode 100644 index 050535a00cf..00000000000 --- a/config/scripts/session-search-tool-heavy-corpus.ts +++ /dev/null @@ -1,152 +0,0 @@ -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 deleted file mode 100644 index fcae799f8a6..00000000000 --- a/docs/reference/agent-session-search-query-tuning.md +++ /dev/null @@ -1,218 +0,0 @@ -# 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 carries its own schema — the vocabulary, the -query log and the generation triggers — and re-creates whatever of it is missing -on every search, so a dropped object heals rather than degrading. - -The one it cannot re-create is the vocabulary's source, because `messages_fts` -is the store's. With one FTS table that is also the end of the degrade: there is -no second corpus to answer from, so an engine over an index mid-rebuild names -typo repair as unavailable and then fails on the table it cannot read, which is -the honest outcome — an empty page would read as an answer. `unavailable` can -therefore no longer be reported alongside a successful search, and PR 5 should -decide whether the field survives into the contract; it becomes reachable again -the day something opens the index read-only. 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 deleted file mode 100644 index 694a3d6397f..00000000000 --- a/src/main/ai-vault-search/session-search-engine-test-fixture.ts +++ /dev/null @@ -1,113 +0,0 @@ -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 deleted file mode 100644 index 861130be59b..00000000000 --- a/src/main/ai-vault-search/session-search-engine-types.ts +++ /dev/null @@ -1,157 +0,0 @@ -import type { AiVaultAgent } from '../../shared/ai-vault-types' -import type { TranscriptMessageRole } from '../ai-vault/session-transcript-consumers' -import type { SessionSearchUnavailableFeature } from './session-search-query-schema' - -// 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[] - /** - * Engine features the index on disk cannot serve, empty on a current index. - * A route ladder missing its repair rung still answers; saying so is what - * keeps the answer honest. - */ - unavailable: readonly SessionSearchUnavailableFeature[] - 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 deleted file mode 100644 index 140f9449c3d..00000000000 --- a/src/main/ai-vault-search/session-search-engine.test.ts +++ /dev/null @@ -1,471 +0,0 @@ -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 and the log 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.unavailable).toEqual([]) - 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.unavailable).toEqual([]) - expect(after.planner.route).toBe('typo+or') - }) - - it('names the feature it cannot serve when the vocabulary has no source left', async () => { - // What an index being rebuilt by another handle looks like from here. The - // vocabulary can be created over a missing `messages_fts` and every query - // against it then fails, so the probe reads the source, not the view. - // - // With one FTS table there is no scope left to answer from, so this is now - // the boundary of the degrade: the engine names the feature and the search - // fails loudly on the table it cannot read, rather than returning an empty - // page that looks like an answer. - 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)).toEqual(['typo-repair']) - for (const scope of ['all', 'conversation'] as const) { - expect(() => engine.search({ query: 'coalesces', scope })).toThrow(/no such (fts5 )?table/i) - } - }) - - it('picks the feature back up when the source comes back', 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)).toEqual(['typo-repair']) - - 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.unavailable).toEqual([]) - 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) - }) -}) diff --git a/src/main/ai-vault-search/session-search-engine.ts b/src/main/ai-vault-search/session-search-engine.ts deleted file mode 100644 index 4b6e11e407d..00000000000 --- a/src/main/ai-vault-search/session-search-engine.ts +++ /dev/null @@ -1,349 +0,0 @@ -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 { - decodeSessionSearchCursor, - encodeSessionSearchCursor, - sessionSearchPageKey -} from './session-search-page-cursor' -import { planSessionSearchQuery } from './session-search-query-planner' -import { logSessionSearchQuery } from './session-search-query-log' -import { - SessionSearchRetrieval, - type RetrievalScope, - type Retrieved -} from './session-search-retrieval' -import { sessionRowFilter } from './session-search-row-filter' -import { - ensureSessionSearchQuerySchema, - type SessionSearchUnavailableFeature -} 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 - /** Write each query to `search_log`. Off unless a caller asks (see query-log). */ - logQueries?: boolean -} - -/** - * Ranked session search over the PR 2 index. - * - * A library: it holds no timers, reads no settings, and knows nothing about - * Electron, IPC or a panel. It is handed a connection rather than opening one, - * because which process may open, rebuild or unlink the index file is PR 3b's - * decision and not a query engine's. - * - * **Every read here is a single statement, and no read transaction is ever - * open across an `await`.** There is no `BEGIN` on this path, no `.iterate()` - * outliving its statement, and `search` is synchronous end to end. That is a - * constraint PR 2 measured rather than a style: a reader that pins a WAL - * snapshot holds off every checkpoint behind it, and the same 47 MB of writes - * that leave a 9.9 MB WAL grew to 266 MB with one `BEGIN` + `SELECT` held open. - * - * One search is one synchronous pass, and every page of it is a slice of the - * same ranked list. That list is rebuilt per page rather than streamed, which - * is what makes a page repeatable: within one index generation the same request - * ranks the same way, and a cursor from any other generation is refused. - * - * That fence is strict on purpose, and the cost is worth stating plainly: any - * committed read moves the generation, so while a backfill is running an - * outstanding cursor will be refused, often within a second. Pagination is - * usable against a settled index and unreliable against one still filling. The - * rejection carries both generations, so a caller that sees `stale-generation` - * knows the index moved rather than that it holds a bad cursor, and can quietly - * re-issue page one instead of showing anyone an error. - */ -export class SessionSearchEngine { - private retrieval: SessionSearchRetrieval - private readonly candidateLimit: number - /** Re-probed whenever a query proves it stale; see `withCapabilityRetry`. */ - private unavailable: readonly SessionSearchUnavailableFeature[] - - 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. - this.unavailable = ensureSessionSearchQuerySchema(this.db) - this.retrieval = new SessionSearchRetrieval(this.db, !this.unavailable.includes('typo-repair')) - } - - search(request: SessionSearchRequest): SessionSearchResponse { - const startedAt = performance.now() - this.probeCapabilities() - 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 } = this.withCapabilityRetry(() => - 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 hasMore = ranked.length > offset + limit - const response: SessionSearchResponse = { - hits, - unavailable: this.unavailable, - 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 - } - if (this.options.logQueries) { - logSessionSearchQuery(this.db, { - query: request.query, - route: response.planner.route, - hits: hits.length, - durationMs: response.durationMs - }) - } - return response - } - - /** - * Where the engine's own schema is created and checked, once per search. - * - * A capability is a fact about the file, not about this object: another handle - * can rebuild the index under a live connection, so a verdict cached in the - * constructor is wrong for the rest of the engine's life in both directions — - * it would keep reaching for a table that went away, and never pick one back - * up when it returned. Retrieval is only rebuilt when the answer changes, so - * the steady-state cost is one indexed lookup and nothing else. - */ - private probeCapabilities(): void { - const unavailable = ensureSessionSearchQuerySchema(this.db) - if (unavailable.join() === this.unavailable.join()) { - return - } - this.unavailable = unavailable - this.retrieval = new SessionSearchRetrieval(this.db, !unavailable.includes('typo-repair')) - } - - /** - * Runs a retrieval, and re-probes once if it turns out the index no longer - * has what an earlier probe found. - * - * `probeCapabilities` already runs per search, so this only covers the window - * between that probe and the statement that reaches for the table. Losing a - * table there is a thrown error rather than a wrong verdict, so it re-probes - * and runs the search again. - */ - private withCapabilityRetry(run: () => RankedPage): RankedPage { - try { - return run() - } catch (error) { - if (!isMissingTableError(error)) { - throw error - } - this.probeCapabilities() - return run() - } - } - - /** - * 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])) - // Operators cut here, after retrieval, so the candidate count still reports - // what the SQL limit saw: that is what tells a caller the limit was binding. - const sessions = this.retrieval.loadSessions([...best.keys()], scope) - // Counted before the operator predicate and before fork folding: the SQL - // LIMIT is what could have hidden a session, and it saw the unfiltered set. - return { - ranked: rankSessionHits(sessions, best, sort), - retrieved, - incomplete: best.size >= this.candidateLimit - } - } - - /** 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 - } - } -} - -// SQLite reports a table that went away at the statement that reaches for it. -// `fts5` is in the message when the table is the vocabulary's target, which is -// the one an index rebuilt under a live connection loses first. -const MISSING_TABLE = /no such (fts5 )?table/i - -function isMissingTableError(error: unknown): boolean { - return error instanceof Error && MISSING_TABLE.test(error.message) -} - -/** - * 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 deleted file mode 100644 index be815623ce7..00000000000 --- a/src/main/ai-vault-search/session-search-fts5-contract.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -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 deleted file mode 100644 index 54919bace0f..00000000000 --- a/src/main/ai-vault-search/session-search-hit-ranking.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -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 deleted file mode 100644 index 364858ea650..00000000000 --- a/src/main/ai-vault-search/session-search-hit-ranking.ts +++ /dev/null @@ -1,109 +0,0 @@ -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 deleted file mode 100644 index a3fffd2648f..00000000000 --- a/src/main/ai-vault-search/session-search-index-generation.test.ts +++ /dev/null @@ -1,320 +0,0 @@ -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 { - await indexOneTranscript(root, store) - const engine = new SessionSearchEngine(db) - db.exec('DROP TRIGGER search_generation_file_update') - engine.search({ query: 'needle' }) - - const restored = readIndexGeneration(db) - await indexOneTranscript(root, store) - 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 deleted file mode 100644 index 891608ed2e7..00000000000 --- a/src/main/ai-vault-search/session-search-index-generation.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type SyncDatabase from '../sqlite/sync-database' - -const GENERATION_KEY = 'index_generation' - -/** - * Names of the triggers that move the generation. Exported so the query schema - * can check they are all still there before an engine trusts a cursor. - */ -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;` - -/** - * The fence, as three triggers on `files`. - * - * Why `files`. Every transaction the store opens that can change what a search - * returns writes this table: a committed read upserts the file's cursor beside - * its rows, a chunk of a long read upserts the partial sentinel beside its - * prefix, `removeFile` deletes the row with the session, and retention deletes - * the file row in the same transaction as the session row. - * - * And why `messages` as well, for orphans only. Retention's second half - * reclaims rows whose session row is already gone, and touches neither table - * above. It was left unfenced on the argument that those rows answer nothing, - * which is true of retrieval and was not true of the whole engine: 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, and - * reclaiming them moved which word a query was repaired to. The repair now - * counts live rows instead, so the common case is fixed at its source; this - * trigger is what makes the fence true rather than nearly true, because the - * vocabulary still decides which candidates survive its scan limit. - * - * The `WHEN` clause is what keeps it free. `removeFile` deletes a session's - * rows while its `sessions` row still stands, so it does not fire here; a - * replace cuts the old `sessions` row loose and leaves its messages to the - * drain (PR 2 round 10). Both already bump through `files`; the drain is the - * only path that deletes a row whose session is gone, and it fires here. The cost of the fence is real and worth naming: a - * cursor outstanding while a purge runs is refused once per batch, which - * `SessionSearchCursorError` reports as `stale-generation` so a caller - * re-issues page one rather than showing anyone an error. - * - * A trigger rather than a call the writer makes, for two reasons. PR 4 does not - * own the writer, and more importantly the fence has to hold for writers this - * process cannot see: the triggers live in the file, so PR 3's indexer in the - * scanner child moves the generation without knowing a reader exists. - * - * Correctness comes from where the increment runs, not from what it counts. It - * is one statement inside the writer's own `BEGIN IMMEDIATE`, so it commits - * with the change it describes and two connections cannot mint one value twice. - * It over-counts in one harmless direction: a read that decoded no session from - * a file the index also held no session for advances a cursor and bumps - * anyway. That refuses a cursor early; it never honours one late. - */ -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; -` - -/** - * A monotone id for what the index currently publishes. - * - * A search page is a slice of one ranked list, so a cursor only means anything - * against the snapshot that produced it. Every change to what a read can return - * moves this on, and a cursor minted under an older value is refused rather - * than silently re-run against a list it no longer indexes into. - * - * Read from the database on every call, never cached in a process. The writer - * and the reader need not be the same one: PR 3's indexer runs in the scanner - * child while an engine reads elsewhere, and any number of handles may be open - * on one file. A generation cached in memory only ever tracks that process's - * own writes, so a reader would see another writer's deletions while its - * generation stood still, honour a stale cursor, and skip a session. - */ -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 deleted file mode 100644 index dfda2303104..00000000000 --- a/src/main/ai-vault-search/session-search-orphan-rows.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -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 deleted file mode 100644 index 838e5e1e3ab..00000000000 --- a/src/main/ai-vault-search/session-search-page-cursor.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { createHash } from 'node:crypto' -import type { SessionSearchRequest } from './session-search-engine-types' - -export type SessionSearchCursorRejection = 'stale-generation' | 'different-query' | 'malformed' - -/** - * A cursor the engine refuses to honour. Typed, and thrown rather than - * swallowed: silently restarting at page one hands the caller a page it has - * already shown as if it were the next one, and silently re-running against a - * newer index hands it a slice of a list it never saw. - */ -export class SessionSearchCursorError extends Error { - constructor( - readonly rejection: SessionSearchCursorRejection, - /** - * The generation the index is at now. Always present: the engine knows it - * before it looks at the cursor at all. - */ - readonly actualGeneration: number, - /** - * The generation the cursor claims it was minted in. Absent only when the - * cursor could not be decoded far enough to carry a number, which is one of - * the `malformed` cases. - */ - readonly expectedGeneration?: number - ) { - super(`Search cursor 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. - const claimed = - typeof payload?.g === 'number' && Number.isFinite(payload.g) ? 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 deleted file mode 100644 index 319c5b0dba4..00000000000 --- a/src/main/ai-vault-search/session-search-paging.test.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { afterEach, describe, expect, it } 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] - ])('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) - }) -}) diff --git a/src/main/ai-vault-search/session-search-query-log.test.ts b/src/main/ai-vault-search/session-search-query-log.test.ts deleted file mode 100644 index 7b88e628a83..00000000000 --- a/src/main/ai-vault-search/session-search-query-log.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { afterEach, expect, it } from 'vitest' -import { - addSyntheticSession, - openSessionSearchHarness, - type SessionSearchHarness -} from './session-search-engine-test-fixture' -import { logSessionSearchQuery } from './session-search-query-log' - -let harness: SessionSearchHarness | null = null - -afterEach(async () => { - await harness?.close() - harness = null -}) - -async function open(options = {}): Promise { - harness = await openSessionSearchHarness('ss-query-log', options) - addSyntheticSession(harness.db, { id: 1, text: 'needle' }) - return harness -} - -function loggedQueries(harness: SessionSearchHarness): string[] { - return ( - harness.db.prepare('SELECT query FROM search_log ORDER BY id').all() as { query: string }[] - ).map((row) => row.query) -} - -it('writes nothing on the query path unless the caller asked for a log', async () => { - const opened = await open() - opened.engine.search({ query: 'needle' }) - expect(loggedQueries(opened)).toEqual([]) -}) - -it('records the query and its route when logging is on', async () => { - const opened = await open({ logQueries: true }) - opened.engine.search({ query: 'needle' }) - const rows = opened.db.prepare('SELECT query, route, hits FROM search_log').all() as { - query: string - route: string - hits: number - }[] - expect(rows).toEqual([{ query: 'needle', route: 'or', hits: 1 }]) -}) - -it('stores the query as typed, the way the index stores content as written', async () => { - // PR 2 decided the index does not redact: it is a second copy of plaintext - // the user already holds under their own home directory. The same holds for - // what they typed into the search box. - const opened = await open({ logQueries: true }) - opened.engine.search({ query: 'Bearer abcdefghijklmnopqrstuvwxyz012345' }) - expect(loggedQueries(opened)[0]).toBe('Bearer abcdefghijklmnopqrstuvwxyz012345') -}) - -it('keeps the newest N and drops the rest, so the log cannot grow with use', async () => { - const opened = await open() - // The real ceiling is 5,000; the trim is the same statement at any size. - for (let n = 0; n < 12; n++) { - logSessionSearchQuery(opened.db, { query: `q${n}`, route: 'or', hits: 0, durationMs: 1 }, 5) - } - expect(loggedQueries(opened)).toEqual(['q7', 'q8', 'q9', 'q10', 'q11']) -}) diff --git a/src/main/ai-vault-search/session-search-query-log.ts b/src/main/ai-vault-search/session-search-query-log.ts deleted file mode 100644 index cdc5ce54425..00000000000 --- a/src/main/ai-vault-search/session-search-query-log.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type SyncDatabase from '../sqlite/sync-database' - -export const SEARCH_LOG_LIMIT = 5000 - -/** - * Local-only telemetry the eval set is rebuilt from. - * - * The query is stored as typed, for the reason PR 2 gives for not redacting - * transcript content: this file sits beside an index that already holds the - * user's own plaintext, so a second copy of what they typed is not a new - * exposure. What may leave the machine is a transport policy and belongs where - * the wire is. - * - * Nothing enables this by default: the engine writes a row only when its caller - * asked for it, because a log write on the query path is a write on what is - * otherwise a read-only lane. Who turns it on is PR 3b's settings decision. - */ -export function logSessionSearchQuery( - db: SyncDatabase, - entry: { query: string; route: string; hits: number; durationMs: number }, - limit: number = SEARCH_LOG_LIMIT -): void { - db.prepare( - 'INSERT INTO search_log(ts, query, route, hits, duration_ms) VALUES (?, ?, ?, ?, ?)' - ).run(new Date().toISOString(), entry.query, entry.route, entry.hits, entry.durationMs) - db.prepare( - `DELETE FROM search_log WHERE id <= ( - SELECT id FROM search_log ORDER BY id DESC LIMIT 1 OFFSET ?)` - ).run(limit) -} 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 deleted file mode 100644 index ac4874b1d10..00000000000 --- a/src/main/ai-vault-search/session-search-query-planner.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -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 deleted file mode 100644 index 6c2c2f3b91c..00000000000 --- a/src/main/ai-vault-search/session-search-query-planner.ts +++ /dev/null @@ -1,140 +0,0 @@ -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 deleted file mode 100644 index f17b290e530..00000000000 --- a/src/main/ai-vault-search/session-search-query-schema.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type SyncDatabase from '../sqlite/sync-database' -import { - SESSION_SEARCH_GENERATION_SQL, - SESSION_SEARCH_GENERATION_TRIGGERS -} from './session-search-index-generation' - -/** An engine feature the index on disk cannot serve. */ -export type SessionSearchUnavailableFeature = 'typo-repair' - -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'); --- Locally logged queries, stored as typed, bounded. Nothing writes here unless a --- caller opts in; the eval set is rebuilt from it (see session-search-query-log). -CREATE TABLE IF NOT EXISTS search_log( - id INTEGER PRIMARY KEY, - ts TEXT NOT NULL, - query TEXT NOT NULL, - route TEXT NOT NULL, - hits INTEGER NOT NULL, - duration_ms REAL NOT NULL -); -${SESSION_SEARCH_GENERATION_SQL}` - -/** Everything the SQL above creates, so a missing one is what triggers a re-run. */ -const OWNED = ['messages_vocab', 'search_log', ...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] - -/** - * Creates whatever of the engine's own schema is missing, and reports what it - * still cannot serve. - * - * These objects are the query engine's, not the store's. Nothing on the write - * path reads any of them, so under the stack's YAGNI rule they do not belong in - * PR 2's schema, and an index built by a process that never opens an engine - * carries none of their cost. None of them needs a schema version either: every - * one is derived from what PR 2 already holds, so re-creating them over any of - * its files is correct, while a version bump would throw a whole index away to - * add a view over its own b-tree. - * - * Run per search, not once per engine. A capability is a fact about the file - * rather than about this object: another handle can rebuild the index under a - * live connection, so a verdict taken in a constructor is wrong for the rest of - * the engine's life in both directions — it would keep reaching for a table - * that went away and never pick one back up when it returned. The steady-state - * cost is the single indexed `sqlite_master` lookup below. - * - * A create that throws is not caught. The only way to reach one is an index - * whose `files` table is gone, which is a rebuild in flight — and an engine - * over that cannot report a hit's source either, so there is nothing to degrade - * to. Losing only the vocabulary's source is the case worth surviving, and that - * one is reported rather than thrown. - */ -export function ensureSessionSearchQuerySchema( - db: SyncDatabase -): readonly SessionSearchUnavailableFeature[] { - const present = presentNames(db) - if (OWNED.some((name) => !present.has(name))) { - db.exec(QUERY_SCHEMA_SQL) - } - return present.has(VOCABULARY_SOURCE) ? [] : ['typo-repair'] -} - -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 deleted file mode 100644 index 2bbac5d3e4e..00000000000 --- a/src/main/ai-vault-search/session-search-retrieval.ts +++ /dev/null @@ -1,251 +0,0 @@ -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' -// The conversation scope zeroes the two columns its filter already excludes. -// Measured, and stated because it is easy to over-read: these zeros change no -// score. FTS5's bm25 sums over the columns the query matched, and the filter -// has already kept the match out of those two, so the same rows come back with -// `1.0, 1.0` here. They are a statement of what the scope means, not the fence -// that enforces it — `scopedExpression` is the fence. -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 = { - rows: MessageRow[] - 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 { - /** Null when this index has no vocabulary to repair against; the rung is skipped. */ - private readonly typoRepair: SessionSearchTypoRepair | null - - constructor( - private readonly db: SyncDatabase, - canRepairTypos = true - ) { - this.typoRepair = canRepairTypos ? new SessionSearchTypoRepair(db) : null - } - - /** - * 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 { - const exact = this.literal(plan, scope) - if (exact) { - return { ...exact, plan } - } - const repaired = this.repair(plan, scope.scope) - const effective = repaired ?? plan - const literal = repaired ? this.literal(repaired, scope) : null - const found = literal ?? { - rows: this.match(orExpression(effective.terms), scope), - route: 'or' as const - } - return { - rows: found.rows, - 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 } - } - - /** - * Read in batches, because the id list is as long as the candidate limit and - * every id is a bound parameter, so a single statement scales with a knob the - * tuning doc invites a host to raise. - * - * Not a fix for a reachable failure, and worth saying so: SQLite has bound - * `SQLITE_MAX_VARIABLE_NUMBER` at 32,766 since 3.32, every runtime this stack - * supports is past that, and the measured limit on this one is higher still. - * A candidate limit that large is not a configuration anyone would choose. - * The batch is here so the ceiling belongs to this file rather than to - * whichever SQLite the process happened to link. - */ - 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 { - if (!this.typoRepair) { - return 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, - scope: RetrievalScope - ): { 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 = this.match(phraseExpression(plan.body), scope) - if (phrase.length > 0) { - return { rows: phrase, route: 'phrase' } - } - if (plan.body.length < 2) { - return null - } - const and = this.match(andExpression(plan.body), scope) - 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 deleted file mode 100644 index dd7ca0d1e5c..00000000000 --- a/src/main/ai-vault-search/session-search-row-filter.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -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('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 deleted file mode 100644 index 5015fa460e6..00000000000 --- a/src/main/ai-vault-search/session-search-row-filter.ts +++ /dev/null @@ -1,90 +0,0 @@ -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) - if (present.length > 0) { - filter.conditions.push(`(${present.join(' OR ')})`) - } - } - 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 deleted file mode 100644 index 081b16479ed..00000000000 --- a/src/main/ai-vault-search/session-search-sidebar-parity.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -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' - } -] - -// 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' -] - -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, operators only: free text is FTS in the index. */ -function sidebarIds(query: string): string[] { - const operatorsOnly = query - .split(/\s+/) - .filter((token) => /^(repo|path):/i.test(token)) - .join(' ') - return filterAiVaultSessions(SESSIONS.map(asSession), { - query: operatorsOnly, - 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 deleted file mode 100644 index 71704b78fee..00000000000 --- a/src/main/ai-vault-search/session-search-snippet-marks.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -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') -}) diff --git a/src/main/ai-vault-search/session-search-snippet.ts b/src/main/ai-vault-search/session-search-snippet.ts deleted file mode 100644 index 2e23e707dd0..00000000000 --- a/src/main/ai-vault-search/session-search-snippet.ts +++ /dev/null @@ -1,126 +0,0 @@ -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 marked = columns - .map((_column, index) => row[`c${index}`]) - .find((text, index) => text !== undefined && text !== row[`p${index}`]) - return marked === undefined ? EMPTY_SNIPPET : publicMarks(truncateSnippet(marked)) - } catch { - return EMPTY_SNIPPET - } -} - -/** The internal marks, swapped for the ones a caller sees, once and at the end. */ -function publicMarks(snippet: SessionSearchSnippet): SessionSearchSnippet { - return { - ...snippet, - text: snippet.text - .replaceAll(MARK_OPEN, SESSION_SEARCH_SNIPPET_MARK_OPEN) - .replaceAll(MARK_CLOSE, SESSION_SEARCH_SNIPPET_MARK_CLOSE) - } -} - -/** Cut on a code-point boundary, and never between a mark and its close. */ -export function truncateSnippet(text: string): SessionSearchSnippet { - if (text.length <= SNIPPET_MAX_CHARS) { - return { text, truncated: false } - } - const points = [...text] - if (points.length <= SNIPPET_MAX_CHARS) { - return { text, truncated: false } - } - const cut = points.slice(0, SNIPPET_MAX_CHARS).join('') - const opened = cut.lastIndexOf(MARK_OPEN) - // An open mark with no close hands the renderer something it can never close. - const balanced = opened !== -1 && !cut.includes(MARK_CLOSE, opened) ? cut.slice(0, opened) : cut - return { text: balanced, truncated: true } -} diff --git a/src/main/ai-vault-search/session-search-source-presence.ts b/src/main/ai-vault-search/session-search-source-presence.ts deleted file mode 100644 index cc7155fccc8..00000000000 --- a/src/main/ai-vault-search/session-search-source-presence.ts +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index 938c1e1fc3e..00000000000 --- a/src/main/ai-vault-search/session-search-typo-policy.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -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 deleted file mode 100644 index 1aed90e2991..00000000000 --- a/src/main/ai-vault-search/session-search-typo-repair.ts +++ /dev/null @@ -1,163 +0,0 @@ -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 deleted file mode 100644 index 98e3938178e..00000000000 --- a/src/main/ai-vault-search/session-search-typo-scope.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -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 deleted file mode 100644 index 0bb5cbc463b..00000000000 --- a/src/shared/ai-vault-search-query-operators.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -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 deleted file mode 100644 index a75da8c769e..00000000000 --- a/src/shared/ai-vault-search-query-operators.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** 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 39aedaf4626..7a0708151ed 100644 --- a/src/shared/ai-vault-session-filters.ts +++ b/src/shared/ai-vault-session-filters.ts @@ -8,7 +8,6 @@ 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, @@ -180,61 +179,31 @@ 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 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) - } -} + const terms: string[] = [] + const repoTerms: string[] = [] + const pathTerms: string[] = [] -/** 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 -} + 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) + } -/** - * 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 + return { terms, repoTerms, pathTerms } } function matchesQuery( @@ -260,18 +229,25 @@ function matchesQuery( return false } } - 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 - ) + 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 } function sessionSortTime(session: AiVaultSession, sort: AiVaultSort): number { @@ -315,3 +291,25 @@ 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 1fedda14caa29bf88b609c2035fd1fc04d6daea6 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:24:22 -0400 Subject: [PATCH 16/17] feat(ai-vault-search): session search indexer library owning backfill, reconcile and retention (#19751) * feat(ai-vault-search): expose the seams a scheduler needs to stay honest Three questions a lifecycle owner has to be able to ask, none of which had an answer: how many unfinished writes this open tombstoned, what the index believes it holds, and whether the session list's cursor already covers a file. The last one is not a nicety. The reader only opens a transcript the list still needs, so any file the list scanned before the index existed would be reused from cache and never reach the index at all. Naming the rule where it lives keeps one spelling of it instead of two. * feat(ai-vault-search): the bounds an unasked background index has to respect An injected clock, a retention window, a per-cycle allowance in files and bytes, a bounded re-read queue, and the load-aware pacing from the original branch. The allowance deliberately does not carry unspent room forward: accumulating it would let a long idle stretch buy one unbounded cycle, which is the stall the budget exists to prevent. Work that does not fit is queued, not dropped, and a transcript larger than a whole cycle's bytes is admitted alone rather than being refused forever. * feat(ai-vault-search): read a candidate set into the index, and say what happened One pass over a candidate list, plus the four things a caller has to be told about afterwards: what was discovered, which roots could not be read, which sources are provably gone, and where the whole run stands. Two absence rules are load-bearing. The file walker swallows a readdir failure and returns, so an unreadable root and an uninstalled agent both arrive as "no files"; only a root that yielded nothing is re-probed, and only ENOENT counts as absent. A source is retired on the same evidence and no weaker: an EACCES, an EIO or a stalled distro keeps its rows. Progress lives in the index's own files table, so a pass skips what it already covers and an interrupted run resumes instead of starting over. * feat(ai-vault-search): a whole-machine sweep and a recency-window cycle The sweep enumerates every root without a limit and is the only pass that can retire a source deleted while nothing was running. The cycle re-stats the newest N per agent, which is the sidebar's own rule rather than a second one, folds in the store's stale set and anything a caller invalidated, and reads what changed inside the cycle's allowance. A replaced file gets a whole re-read in the cycle that notices it. Waiting for the index to decline an append and mark itself stale would cost a second cycle and, because the reader resumes from the session list's cursor, would decline again every cycle after that. * feat(ai-vault-search): SessionSearchIndexer, with its freshness claim tested The object that owns freshness for the index: a full sweep on start, a timer that reconciles the recent window, retention that purges when it narrows and re-sweeps when it widens, and a pause that stops the writers rather than only the timer. A library, not a service. No Electron, no app lifecycle, no settings read, no IPC, and nothing constructs it. The clock is injected because a guarantee stated in wall time is a claim until a test can advance the clock and watch it hold: a transcript in the recent window that grows, is rename-replaced, or is deleted is reflected within one interval, each with its own test. * test(ai-vault-search): index a conversation held in Orca's own chat Reviewer F4 and the plan's fourth open decision. A conversation held in the panel writes the same file in the same place as one held in the terminal, so it must be searchable through the same path with nothing else running. The test constructs the indexer over an isolated root, writes and then appends native-chat-shaped rows, advances the clock one interval, and reads the rows back through the published views. * fix(ai-vault-search): ask the store before reading its cursor The pass read the index's own file row to decide how to read a candidate, and only then asked whether the store would accept it. A store that is paused or already closed answers no to everything, so those cursor reads were against a handle it had given up. * feat(ai-vault-search): take the reader's whole-read seam and PR 2's pause rules `requestWholeTranscriptRead` replaces the local invalidation: the reader owns the resume point, so asking it is the honest way to say the index needs a span the session list has already moved past. Two callers, and the second is the one no decline can reach. A forced path is one the store handed back from `takeStale` or a caller invalidated, and it has to arrive as a `replace` or the consumer declines the same append forever. But when the list's cursor already sits at a file's current stat the parse opens nothing at all, so no consumer is asked and there is nothing to record. That is every transcript on the machine the first time the index is switched on inside a running app, so it gets a test on both the sweep and the cycle path. Pausing now keeps the store's re-read set, so `filesPending` and `droppedPending` add both bounded queues together: a caller cannot act on one of them alone. The schema creates the index directory, so the indexer no longer does. * refactor(ai-vault-search): one ceiling for both re-read queues The indexer's scheduled-work queue carried a bound of its own beside the store's STALE_PATH_LIMIT, so `droppedPending` summed two numbers that meant two different things. It now shares the store's ceiling, set by the indexer, which is the only thing holding both queues. They stay separate queues. The store records that the index has a hole in a file; this records work scheduled and not yet done. Merging them by pushing budget leftovers through `markStale` would turn every deferred append into a whole re-read. * fix(ai-vault-search): stop the indexer from going quiet and calling it current Seven review findings, all in the seam between "stopped working" and "finished". One commit because they meet in the same three files. A pause part way through a backfill abandoned it. The flag was cleared on entry, the abort was swallowed as a normal stop, and resume only re-swept when the pause outlasted an interval. A sweep is now due until one completes, and work drained out of a queue and then not read goes back: a stale or invalidated path is a hole in the index, not finished work. The retention cutoff was set once at construction while purges took a fresh one, so with a one-day window a sweep three days later deleted a row the very next accept check re-indexed. It is refreshed from the clock before any accept decision in a pass. An invalidated path the index had never held was dropped unread, because it was resolved only through rows the index already had. It now resolves the agent from the same root table discovery reads, and what still cannot be resolved stays queued and counted rather than vanishing. Status told four small lies: a file count that tallied attempts and grew past the total, `current` while work was queued or before any sweep had finished, `current` after close, and a declined read counted as indexed because the parse returned without throwing. It now counts what the store holds, requires all three conditions for `current`, has a `closed` phase, and counts a file only when the index's own cursor moved. The cursor drop ran outside the per-path parse lane, so an overlapping sidebar parse could store its entry in between and turn a forced whole read back into a cache reuse. The decision moves inside the lane as a read requirement, which is the only place that is atomic against it. A sweep retired every indexed file it did not discover. An unmounted volume ENOENTs its whole tree at once, so that deleted a user's searchable history for a detached drive. A root that cannot be read, or that lists nothing where it listed transcripts before, is degraded, and a degraded root's files are never retired however loudly the filesystem says they are gone. The pass loop moves to `session-search-work-loop.ts`: one task at a time, one pending tick, one abort. It is the part with no opinion about transcripts, and the indexer was over the line limit with it inline. * fix(ai-vault-search): count files owed a read once, and pick the ceiling on purpose `filesPending` summed the store's re-read set and the indexer's queue, and a path sits in both the moment a read is declined during a pause and a caller then invalidates the same file. One transcript read as two, with nothing to distinguish that from two transcripts. It is a union by path now. The drop counts stay a sum, because a drop is an event rather than a membership and nothing retains the paths to deduplicate afterwards. The previous commit raised this queue's cap from 2,000 to the store's 20,000 as a side effect of trying to make one number out of two, which the double-count shows it never was. Both queues retain a candidate per entry, so that quietly doubled the worst-case memory of a background feature. Back to 2,000, with the reason written down: the store's set is filled by the reader at machine speed during a pause and needs headroom proportional to the transcripts on the disk, while this one is filled by a cycle's budget rollover, bounded by one recent window at a few hundred, and by `invalidate()`, where 2,000 outstanding requests is already a malfunctioning caller. * fix(ai-vault-search): apply the round-1 guards on the cycle path too Two of the round-1 fixes were written on the sweep and the cycle walked around them twenty seconds later. The degraded-root fence now lives inside the retirement function itself rather than at one call site, so both passes get it from one place and the cycle cannot delete what the sweep just protected. The cycle also reads the sweep's root counts, so it can see a tree that went to zero at all; it does not write them back, because a recent-window discovery is not a census. The N-to-zero alarm was single-shot: the degraded sweep's own zero became the baseline, so the next sweep compared zero with zero and retired the tree it had just spared. A root keeps its last healthy count until one lists it non-empty again. Taking a file no longer means the cursor moved. A forced whole re-read of an unchanged file writes an identical cursor, so an invalidated file that turned out not to have changed was never settled: owed forever, re-read whole every interval, status pinned. It means the index now covers the file at this stat, which is the same question the skip at the top of the pass asks, and it is the same function. An aborted cycle handed the store's re-read set to a queue a tenth its size, which silently discarded the difference. What came from the store goes back to the store, under its own bound and its own retention rule. Whether a sweep finished is now an argument rather than a call site's position, so an aborted one cannot latch `current` by being reported a line too early. * fix(ai-vault-search): fence real directories, and let the alarm release The degraded-root fence did nothing at all for OpenClaw, the one agent whose roots are alternates for a single install. Discovery reports those as one discovery whose rootDir is every path joined by the platform's path delimiter, and that string is not a directory: the probe readdir'd it and got ENOENT, the containment check never matched a file under it, and a scan issue recorded against a real root never compared equal to it. So the agent most likely to live on a mounted volume was the one an unmount deleted, and the degraded root it reported was not a path anyone could act on. Health now runs on the constituent directories, taken from the same source table discovery reads rather than by splitting the joined string back apart, which would be its own bug: a directory may legally contain the delimiter. Files are attributed to the root they actually live under, so one alternate can be unreadable while the other keeps indexing and retiring normally. The N-to-zero alarm also never released. Carrying only counts above zero meant a root the user legitimately emptied stayed degraded for the life of the process, its rows never retired and the phase pinned. The rule is now explicit: a root that cannot be listed keeps its last healthy count and stays degraded indefinitely, while one that lists successfully and empty on two consecutive full sweeps is believed. One sweep is not enough, because that is also what a freshly unmounted volume looks like, and only a sweep counts: a recent-window cycle can see a root at zero but is not a census. The allowance still charges a forced read at the size discovery saw. That is an under-count when a file grows mid-cycle, and it is deliberate: the budget paces a cycle rather than accounting for it, and the error is bounded by what one cycle's writers appended. * fix(ai-vault-search): prove a root once held transcripts from the index, not memory The fence was inert on the first sweep of every process. The evidence that a root had ever held anything lived only in memory, so after a restart it was empty, and a missing directory is what a detached volume and an agent that was never installed both look like. Index a transcript, close, detach the volume, open the same database: every row retired on that one sweep, with no degraded root reported. The evidence now comes from the store, which is the thing that actually outlives the process, through a range scan on the path key. A root that is missing while the index holds files under it is degraded; only one the index holds nothing under is absent. Consecutive also has to mean consecutive. An unreadable sweep left the tally alone rather than breaking it, so empty, unreadable, empty added up to a deletion nobody performed. Anything that is not a successful empty listing now resets the run. Rows under no configured root were immortal: nothing refreshed them, nothing retired them, nothing reported them, and searches still returned them. That happens when a profile moves or a root is reconfigured. One rule, written at the function: such a file is retired exactly like any other if its path answers ENOENT, because that is proof, and otherwise the rows stay and `orphanedFiles` reports them. An index holding content the current configuration cannot reach is a configuration problem to surface, not a licence to delete history. An aborted sweep no longer publishes findings it never gathered. It stops probing on abort, so its empty degraded list would have cleared a live alarm, and its partial view must not count toward emptying a root either. It now skips the health pass entirely and carries the previous state forward. * fix(ai-vault-search): an empty mountpoint is not an emptied root either Round 4 moved the missing-root branch onto the store and left the other one on memory. An unmount on Linux, WSL or sshfs does not remove the mountpoint: it leaves it present and empty, so a detached volume takes the listable-but-empty branch, and that branch armed its two-sweep grace from a count that is zero on the first sweep of every process. Index three transcripts, close, detach: all three retired on that one sweep, with no alarm and a phase of `current`. Both branches now ask the store, which is the only thing that outlives the process. Nothing re-armed a sweep when a root came back. A root absent at start is correctly ignored, but after it returns a cycle only reads the newest N per agent, so one file was indexed and the rest stayed unreachable for the life of the process. A cycle that sees a root listing again where a pass judged it absent or degraded now asks for a sweep. Only after one sweep has completed: before that, a root with no recorded count has simply never been censused, and treating that as a recovery would turn every early cycle into a full sweep. `hasIndexedFilesUnder` was already bounded at a path segment; nothing pinned it, which is why the bare-prefix mutation lived. It has a test now, on both separators, including that a root is not held under itself. * refactor(ai-vault-search): prove a deletion by walking to the root, not by remembering Retirement had grown a root-health state machine: a per-root healthy count, a two-consecutive-empty-sweeps tally, a census flag, a store query for whether the index had ever held files under a root, and a fence every call site had to remember to apply. Four review rounds found the same bug in four shapes, because each shape was a new way for the machine to conclude "empty" from something that was not. The rule is structural now. A row retires only when a directory between the file and its configured root lists successfully and the next component toward the file is absent from that listing; a directory that ENOENTs is walked up, and any other failure is unverifiable at once. The walk stops at the configured root, so everything above it -- a home on an unmounted volume, a detached drive, a dropped SSH mount -- is out of scope by construction rather than by memory, and the rule reads the same on the first pass of a process as on the thousandth. The invariants are written at the top of the module and each is a test. One bit per root survives: a root that held transcripts on the previous pass and holds none on this one gets a pass of grace, so a directory swapped out for a moment cannot retire a tree. What that does not cover is stated in the module and pinned by two tests. degradedRoots becomes a per-pass signal with no memory: roots discovery recorded an issue against, roots the walk could not read through, and roots that yielded nothing and refuse to list at all. * fix(ai-vault-search): close the loop, budget the backfill, and let a pause mean it Five lifecycle defects, all of them cases where a call did more or less than it says. close() disarmed the timer and aborted the task in flight but left the queue running, so a clear() queued a moment earlier would go on to delete the database, open a new one and register a consumer against it, behind an indexer whose caller had finished with it. Closing the work loop makes every queued task a no-op. The backfill was the one pass that read transcript bytes without a budget: a first run over a large disk owned the process until it finished. It now spends an allowance of its own and hands back the rest of its plan, which the passes that follow drain without re-discovering. The allowance is separate from the cycle's and much larger, because a first run has a backlog and steady state does not: 128 MB a pass drains 20 GB in about 53 minutes where the cycle budget would take about 14 hours. A pause now stops purges and compaction too: narrowing the history window while paused records that a purge is owed and runs it on the first pass allowed to write. resume() no longer sweeps on its own, however long the pause was; every read declined while paused is already in the store's re-read set, which the next cycle drains. start() while paused arms on resume instead of queueing a pass that returns immediately and resolves as though one had run. A root that recovers still buys a full sweep, but at most one per recovery: it has to be listed healthy on the pass after the one that re-armed before it can buy another, so a root flapping every interval costs one sweep rather than one a flap. Smaller: clear() resets the swept flag, so an emptied index is not reported as current before the sweep that refills it; a sweep watches only what it could not settle rather than every path it discovered; and the progress pair is measured against one population, so a ratio cannot exceed 100 percent. The round-6 lifecycle matrix lives in the repository now: 11 operations against 3 unreachable-root shapes against both ways discovery reports a root, 66 cells on four invariants. * test(ai-vault-search): drop the tests the old retirement rule owned Two of them asserted the same behaviour as the emptied-root tests that replaced them, and both were named for a rule that no longer exists: retirement waiting for a second sweep to agree, and an unmounted volume being recognised by its root listing empty. Comments that pointed at review rounds rather than at the behaviour go with them, and the merged-root test is named for what it covers now that there is no root-health module for it to be about. * fix(ai-vault-search): stop a sweep from erasing the request that arrived during it Four round-8 findings, all in round-7 code. A full-sweep request raised while a sweep was running was erased by the sweep it arrived during. The flag stayed set across the await and was cleared on the way out, so widening the history window or calling reconcile({ full: true }) part way through a backfill left status reading `current` with the widened-in transcripts never read. The pass takes the flag on entry now; an unfinished sweep is what puts it back. clear() followed by close() left the database on disk. The removal was queued on the work loop, close() makes queued tasks no-ops, and clear()'s promise resolved anyway -- a privacy action that reports success without doing anything. The store, its consumer registration and its file on disk now have one owner and one lifetime, and closing performs a removal that is still owed. The backfill drain bounded bytes but not wall time. The pacer backs off 15 seconds a batch on a loaded host, so a pass could hold the loop for a quarter of an hour without going near its byte budget, and since the drain runs inside the reconcile cycle that is the recent-N-per-interval promise gone. Every read pass now stops at one interval and hands the rest back. A row whose path names an entry inside a container rather than a file of its own was proven only against the container, so an entry deleted inside it could never be retired. Such a row is now proven by the container's own enumeration, under the same bar a directory listing has to meet: exhaustive, successful, and not empty. Nothing in this PR can hold such a row yet -- the index pass refuses a source whose messages the channel cannot reach, which is every OpenCode SQLite session -- so this is the guard for the day that changes, and a test pins the precondition. Also: status() reports `idle` before start() rather than describing work no timer was going to do, and reconcile() before start() is refused rather than writing the index once and leaving it to go stale. * test(ai-vault-search): date the widened-in transcript on the clock retention reads The transcript meant to sit outside a 30-day window was dated against wall time while the window is measured against the test clock, which runs a year behind it, so the file was inside the window and the test proved nothing: it passed with the fix reverted. * fix(ai-vault-search): meet the rewritten store where PR 2 left it The rebase onto the one-transaction-per-file store: re-add the cursor predicate PR 2 dropped, read `sessions`/`messages` now the visibility views are gone, and delete `recoveredRows` -- there is no tombstone table left for a crashed writer to leave rows in, so the counter could only ever read zero. * refactor(ai-vault-search): make the indexer immutable, with one queue and one bound A configuration change is now "close it, construct a new one", so the object has one store, one registration and one lifetime. `pause`, `resume`, `clear`, `setHistoryDays` and `invalidate` are gone, and with them every flag that only existed to keep a second lifetime in step: `paused`, `pausedAt`, `purgeDue`, the deferred-purge path, the resume-sweep rules and the start-while-paused case. Throwing the index away is close, `removeSessionSearchDatabase`, and a new instance; widening retention is a new instance whose opening sweep admits the older files, and narrowing is the purge that opens every full sweep. One queue, not three. The indexer's pending queue and the sweep remainder are deleted; the store's re-read set is the one bounded queue, already the place a declined read lands, and `filesPending` is its size rather than a union across queues that could count one transcript twice. One pacer, not three. The files-and-bytes allowance and the load-average back-off are deleted; a pass reads until its wall-clock deadline and hands the rest back. A pass that runs out of time defers only files it would actually have read, so a truncated pass cannot buy a whole re-read for a file the index already covers. The sweep cadence subsumes root recovery: a full sweep runs on start and every `fullSweepEveryCycles` after it, so a root that comes back is picked up by the next one instead of by a flap-bounded re-arm rule. * test(ai-vault-search): prove close disarms the timer, not only the store Removing `loop.close()` from `close()` failed no test: the unregister already stopped a later scan reaching the index, so the surviving timer and the task queued behind it were invisible. The close test now asserts the timer is gone and that advancing the clock past it reports nothing, which is what a pass running against a shut store would have done. * refactor(ai-vault-search): drop the options and fields nothing reads `retirementChecksPerCycle` had no caller in the stack, so the reconciler keeps its own constant; the error reporter is only needed while the store and the loop are being built, so it stops being a field. * refactor(ai-vault-search): let indexedSources walk the table it is asked for The per-path arm existed for `invalidate()`, which had to resolve a path the index might never have held. Only the sweep reads this now, and it reads all of it. * fix(ai-vault-search): never call a half-written file current PR 2 now reports a file a chunked read left half written with a null cursor under the whole file's mtime and size, so the freshness check has to start at `requiresWholeRead`: comparing only the stat calls a prefix current and leaves it in the index for good. Two consequences, one test each. The skip check no longer skips such a file, and the took-it check no longer reports it indexed, so it stays owed until a read finishes it. The pass reads it whole rather than appending, which repairs it in one pass instead of waiting for the consumer to decline an append it was never going to take. * fix(ai-vault-search): four ways a pass reached the wrong conclusion Round 10, each reproduced on 6a1bcfdf49 first and each repro kept as a test. A sweep watched only what it could not settle, which is nothing on a healthy machine, so the cycle after a sweep had no deletion candidates and the cycle after that no longer remembered the file. A transcript deleted in that interval survived until the next periodic sweep, five minutes later. The sweep now seeds the watch set with its own recency window, taken from its own discoveries through the same class discovery selects with, so there is no second spelling of the rule and no second walk of the trees. A transcript the reader cannot open was recorded stale by the consumer on every attempt and re-read every cycle for ever. Three failures at one unchanged stat now hold a file out until that stat moves, which is the only thing that can mean it changed. `failures`, a tally of attempts that climbed without bound, becomes `unreadableFiles`, a gauge of files being held; a non-zero value is degradation, because waiting will not close that gap. `close()` part way through a pass left the pass reading a shut handle and reported three database errors to the owner who asked for the close, and `status()` afterwards opened it again to answer zero files. The pass stops at the cancellation the close raises, and a closed indexer reports what it last knew. `indexedSources` throws rather than answering with an empty list: its caller is a sweep deciding what nothing rediscovered, and an empty answer is the one conclusion an unreadable handle must not reach. A sweep that threw puts its own flag back, so something is still armed to try again. `droppedPending` was a lifetime tally under a doc that promised it meant the queue was incomplete until the next sweep. A completed sweep now clears it, and `bytesIndexed` resets per pass rather than per sweep, so it stops sawtoothing every fifteen cycles. Two indexers on one database both registered with the reader and wrote every transcript twice. The second construction throws. * test(ai-vault-search): prove the three conclusions a broken pass must not reach Three round-10 mutations survived the first pass of tests, all of them the same shape: a pass that failed still reached a verdict, and nothing checked. The drop reset moves into the sweep itself, where a test holding the store can overflow the queue and watch a completed sweep clear it; from the indexer the call was unreachable without twenty thousand files. A sweep whose held-file read throws now has a test that the failure travels rather than being folded into an empty list, and a sweep that threw part way has one that the flag saying a sweep is owed comes back. * feat(ai-vault-search): put what a file still owes on the file's own row Three additive columns on `files`, and the consumer writes them. `state` is 'current', 'due' or 'failed'; `fail_count` and `failed_mtime_ms` are what stop an unreadable transcript being retried on every pass for ever. Schema version 4, so a stale index rebuilds. Every refusal now leaves its record on the row rather than in a map beside it. A declined append is 'due': the index is behind on a span no append reaches, so the next pass reads the file whole. A read that started and did not commit is 'failed', counted, and stamped with the stat it failed at, because a transcript the reader cannot open fails identically every time and only a change to that stat can mean the file itself changed. A path the file table does not name needs no record at all: the next pass reads it because the index holds nothing for it. Deleted with the in-memory set they served: `markStale`, `takeStale`, `pendingFileCount`, `droppedPendingFileCount`, `forgetDroppedPending`, `setAcceptingWrites`, `acceptsCandidate`, `STALE_PATH_LIMIT`. Added: `files()`, `setFileState()`, `stateCounts()`, `retentionCutoff`. The retention gate moves into `beginWrite`, because the consumer observes every read the session list makes and not only the ones the index asked for. The indexer rewrite that consumes this surface is the commit after; this one is kept to the store, the schema and the consumer so PR 2's own final commits can rebase over it. * fix(ai-vault-search): count a failure for a file the index never held The common unreadable transcript is one no read ever got through: a file behind the wrong mode bits fails on its first attempt, so there is no row to count the failure on and it would be read again on every pass for the life of the process. The failure now inserts its own row, holding a zero cursor and no session, which is what "the index holds nothing for this file" already looked like. * refactor(ai-vault-search): make the store the indexer's only memory Design v3. Every question a pass asks between passes is a row in `files`: what is owed a read, what has failed and how often, what the index holds and therefore what may have been deleted, what to report. Two things outlive a pass and are not rows -- the timer, and one bit per root for the retirement walk's grace -- and both are named in the class doc. One loop, four steps. Discover: the only filesystem walk, every root on a sweep and the newest N per agent on a cycle. Decide: the candidate's stat against its row, as one pure function with its own test. Retire: the rows discovery did not return, inside the scope it covered, through the unchanged walk. Report: a `GROUP BY state` over the same rows. `session-search-backfill.ts` and `session-search-reconciler.ts` become one `session-search-pass.ts`, because the two differed only in discovery scope. Deleted with them: the watch set redefined three times, the hold-out map, the `sweptClean` latch, `session-search-indexing-status.ts` and every counter with a rule about when to reset, `session-search-unreadable-files.ts`, and PR 3's addition to `session-search-file-cursor.ts`, which the decide step replaced. Two things the design did not anticipate, both found by its own tests. A cycle lists the newest N per agent, so a backlog outside that window is invisible to it and a first run would have crawled: a pass that runs out of time now asks for a sweep, which is self-limiting because the first pass that finishes its reads hands the interval back. And a cycle's retirement candidates are the newest rows under the roots it listed, capped, so a deletion inside the recency window is proven on the next cycle whenever it happened rather than waiting for a sweep. * test(ai-vault-search): make the retirement cap test prove its ordering Removing the newest-first sort from a cycle's retirement scope failed nothing: the fixture wrote its transcripts oldest first and the sweep indexed them newest first, so the table's own row order already put the oldest last and an unsorted slice happened to reach the same answer. The oldest file is now indexed on its own first, which makes it the earliest row as well as the oldest file, and the two orders disagree. * fix(ai-vault-search): take schema version 5 for the three files columns PR 2's final pass took version 4 when it dropped conversation_fts, and the rebase merged both bumps into one number. The columns take 5. Two smaller things the same rebase left behind: the stub store in the identity test still declared the two methods the consumer no longer calls, and STALE_PATH_LIMIT outlived the set it bounded. This sits one commit above the columns rather than inside them, because the rebase onto abeccc5905 was the one history rewrite that was authorised. Whoever cherry-picks the store commit needs both. * refactor(ai-vault-search): drop the file count nothing reads, and say what memory is left `store.indexedFileCount` has no caller: the status reports the state counts and PR 4 reads sessions. The class doc claimed two things outlive a pass. Six do, and each is now named with the reason it cannot be a row: the grace bit, the two timer fields, the three the last pass observed for `status()` to answer between passes, and one cached query result read only after a close. A slogan that undercounts is worse than a list, because the next round has to rediscover what it left out. * fix(ai-vault-search): release the database path when the open throws The claim on a database path was staked before the store opened it, and a construction that throws has no close() to release it. One failed open -- a directory where the file should be, a corrupt header, a permission -- left the path owned by an object that does not exist, and every later construction was refused for the life of the process, including the one that would have fixed whatever broke the open. * fix(ai-vault-search): record a source no read can ever index An OpenCode SQLite session decodes where the message channel cannot reach it, so no read of one will ever commit a row, and the consumer declined it without writing anything. That left the file table silent about a source discovery returns on every pass: the decide step saw a path the index held nothing for and asked for a read, and asking for one over a warm cache drops the session list's own resume point. Every OpenCode session was fully decoded on every pass, and the sidebar's cached fold was thrown away with it -- the cache STA-1278 and STA-1417 added. The decline now writes the row the store already has a shape for: a read that went through and decoded no session, cursor at the file's size, no session row. The decide step skips it until its stat moves, and the retirement walk retires it like any other row. Nothing in the decide step knows what OpenCode is. * fix(ai-vault-search): bound the retirement walk by directories, not by rows A directory that cannot be listed answers `unverifiable` for every row under it, on every pass, for as long as the permission stays wrong. Counting rows against the cap let five hundred such rows spend the whole budget on one readdir's worth of verdicts: a row for a file the user really deleted, sorted behind them, was never reached on any pass. Six full sweeps and it was still held; at a hundred rows the same file retires on the first. The cap now counts the directories a walk asks for, which is what actually costs a read, and it is spent only by a row that starts somewhere the walk has not been. Rows sharing a directory are one read and then map lookups, so a block of them can no longer crowd out anything. The comment claiming the leftovers "keep being watched" went with it. There is no watch set: a row the walk did not reach is simply still undiscovered on the next pass, which is what makes finishing over several passes safe. * fix(ai-vault-search): normalize indexer ownership paths --- .../ai-vault-search/session-search-clock.ts | 24 + .../session-search-degraded-roots.ts | 88 ++ .../session-search-deleted-sources.test.ts | 356 ++++++ .../session-search-deleted-sources.ts | 263 ++++ .../session-search-directory-listings.test.ts | 61 + .../session-search-directory-listings.ts | 70 ++ .../session-search-file-write.test.ts | 17 +- .../session-search-index-consumer.test.ts | 85 +- .../session-search-index-consumer.ts | 66 +- .../session-search-index-pass.test.ts | 194 +++ .../session-search-index-pass.ts | 84 ++ .../session-search-index-writer.test.ts | 6 +- .../session-search-indexer-options.ts | 49 + .../session-search-indexer-test-fixture.ts | 168 +++ .../session-search-indexer.test.ts | 1090 +++++++++++++++++ .../ai-vault-search/session-search-indexer.ts | 325 +++++ .../session-search-lifecycle-matrix.test.ts | 378 ++++++ .../session-search-live-transcript.test.ts | 12 +- .../session-search-merged-roots.test.ts | 135 ++ ...ession-search-native-chat-indexing.test.ts | 113 ++ .../session-search-opencode-decline.test.ts | 177 +++ .../ai-vault-search/session-search-pass.ts | 216 ++++ .../session-search-read-decision.test.ts | 117 ++ .../session-search-read-decision.ts | 100 ++ .../session-search-retention-policy.test.ts | 26 + .../session-search-retention-policy.ts | 25 + .../session-search-scan-roots.test.ts | 58 + .../session-search-scan-roots.ts | 135 ++ .../ai-vault-search/session-search-schema.ts | 13 +- .../session-search-store-is-memory.test.ts | 265 ++++ .../ai-vault-search/session-search-store.ts | 199 +-- .../session-search-synthetic-sources.ts | 56 + .../session-search-work-loop.ts | 87 ++ .../ai-vault/session-scanner-parse-cache.ts | 57 +- 34 files changed, 4945 insertions(+), 170 deletions(-) create mode 100644 src/main/ai-vault-search/session-search-clock.ts create mode 100644 src/main/ai-vault-search/session-search-degraded-roots.ts create mode 100644 src/main/ai-vault-search/session-search-deleted-sources.test.ts create mode 100644 src/main/ai-vault-search/session-search-deleted-sources.ts create mode 100644 src/main/ai-vault-search/session-search-directory-listings.test.ts create mode 100644 src/main/ai-vault-search/session-search-directory-listings.ts create mode 100644 src/main/ai-vault-search/session-search-index-pass.test.ts create mode 100644 src/main/ai-vault-search/session-search-index-pass.ts create mode 100644 src/main/ai-vault-search/session-search-indexer-options.ts create mode 100644 src/main/ai-vault-search/session-search-indexer-test-fixture.ts create mode 100644 src/main/ai-vault-search/session-search-indexer.test.ts create mode 100644 src/main/ai-vault-search/session-search-indexer.ts create mode 100644 src/main/ai-vault-search/session-search-lifecycle-matrix.test.ts create mode 100644 src/main/ai-vault-search/session-search-merged-roots.test.ts create mode 100644 src/main/ai-vault-search/session-search-native-chat-indexing.test.ts create mode 100644 src/main/ai-vault-search/session-search-opencode-decline.test.ts create mode 100644 src/main/ai-vault-search/session-search-pass.ts create mode 100644 src/main/ai-vault-search/session-search-read-decision.test.ts create mode 100644 src/main/ai-vault-search/session-search-read-decision.ts create mode 100644 src/main/ai-vault-search/session-search-retention-policy.test.ts create mode 100644 src/main/ai-vault-search/session-search-retention-policy.ts create mode 100644 src/main/ai-vault-search/session-search-scan-roots.test.ts create mode 100644 src/main/ai-vault-search/session-search-scan-roots.ts create mode 100644 src/main/ai-vault-search/session-search-store-is-memory.test.ts create mode 100644 src/main/ai-vault-search/session-search-synthetic-sources.ts create mode 100644 src/main/ai-vault-search/session-search-work-loop.ts diff --git a/src/main/ai-vault-search/session-search-clock.ts b/src/main/ai-vault-search/session-search-clock.ts new file mode 100644 index 00000000000..c9eaf609a34 --- /dev/null +++ b/src/main/ai-vault-search/session-search-clock.ts @@ -0,0 +1,24 @@ +// Why injected rather than the globals: every freshness guarantee this indexer +// makes is "within one reconcile interval", and a guarantee stated in wall time +// is only a claim until a test can advance the clock and watch it hold. + +/** Opaque to the indexer; a fake clock hands back whatever it likes. */ +export type SessionSearchTimerHandle = object | number + +export type SessionSearchClock = { + now(): number + setTimeout(callback: () => void, ms: number): SessionSearchTimerHandle + clearTimeout(handle: SessionSearchTimerHandle): void +} + +export const systemSessionSearchClock: SessionSearchClock = { + now: () => Date.now(), + setTimeout: (callback, ms) => { + const timer = setTimeout(callback, ms) + // Nothing here should hold the process open: the index is a cache, and a + // pending reconcile is never a reason to keep a CLI or a child alive. + timer.unref?.() + return timer + }, + clearTimeout: (handle) => clearTimeout(handle as NodeJS.Timeout) +} diff --git a/src/main/ai-vault-search/session-search-degraded-roots.ts b/src/main/ai-vault-search/session-search-degraded-roots.ts new file mode 100644 index 00000000000..0432cdbc99f --- /dev/null +++ b/src/main/ai-vault-search/session-search-degraded-roots.ts @@ -0,0 +1,88 @@ +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import type { SessionSearchDirectoryReader } from './session-search-directory-listings' + +/** A scan root this pass could not read through, and what stopped it. */ +export type SessionSearchDegradedRoot = { root: string; reason: string } + +/** + * Roots a pass could not read, derived from that pass alone. + * + * There is no root-health state machine any more and nothing is carried between + * passes: "degraded" now means one of two things this pass observed, both of + * which are readdir results. + * + * 1. Discovery recorded a scan issue against the root itself — a stalled WSL + * distro, a gate refusal, an unreadable tree. + * 2. The retirement walk could not prove a file the index holds under that root + * either present or gone, because a directory between the file and the root + * refused to list, or because the root itself is not there. + * 3. A root that yielded no transcripts refuses to list at all. The file walker + * swallows a readdir failure and returns, so without this an EACCES root and + * an agent that was never installed both arrive as "no files" — reporting + * the first as an empty index is the loss-of-contact-as-absence mistake + * docs/reference/ssh-execution-boundary.md forbids. + * + * The second is what reports a detached volume, and it needs no memory of + * previous passes: the evidence is the index's own rows plus this pass's + * readdir errors. A root the index holds nothing under and cannot list is + * reported by the third; a root that is simply missing is not reported at all, + * because that is what an agent nobody installed looks like. + */ +export function scanIssueDegradedRoots( + roots: readonly string[], + issues: readonly AiVaultScanIssue[] +): SessionSearchDegradedRoot[] { + const degraded = new Map() + for (const issue of issues) { + // 'notice' rows are scanner commentary; a per-file failure is not a root's. + if (issue.kind !== 'notice' && roots.includes(issue.path)) { + degraded.set(issue.path, issue.message) + } + } + return [...degraded].map(([root, reason]) => ({ root, reason })) +} + +/** One entry per root, first reason kept, so a pass reports each root once. */ +export function mergeDegradedRoots( + ...groups: readonly (readonly SessionSearchDegradedRoot[])[] +): SessionSearchDegradedRoot[] { + const merged = new Map() + for (const group of groups) { + for (const degraded of group) { + if (!merged.has(degraded.root)) { + merged.set(degraded.root, degraded.reason) + } + } + } + return [...merged].map(([root, reason]) => ({ root, reason })) +} + +// A missing root is not a broken one: an uninstalled agent's root answers +// exactly this, and the index holding rows under it is what the retirement +// walk reports instead. +const MISSING_ROOT = new Set(['ENOENT', 'ENOTDIR']) + +/** + * Roots that yielded no transcripts and cannot be listed either. + * + * Only roots a pass found empty are read: one that returned files is readable + * by construction. The read shares the pass's listing cache, so a root the + * retirement walk also has to ask about costs one readdir between them. + */ +export async function unreadableRoots( + roots: readonly string[], + listings: SessionSearchDirectoryReader, + signal?: AbortSignal +): Promise { + const degraded: SessionSearchDegradedRoot[] = [] + for (const root of roots) { + if (signal?.aborted) { + break + } + const listing = await listings.namesIn(root, signal) + if (!listing.listed && !(listing.code !== null && MISSING_ROOT.has(listing.code))) { + degraded.push({ root, reason: listing.message }) + } + } + return degraded +} diff --git a/src/main/ai-vault-search/session-search-deleted-sources.test.ts b/src/main/ai-vault-search/session-search-deleted-sources.test.ts new file mode 100644 index 00000000000..d1c367d632f --- /dev/null +++ b/src/main/ai-vault-search/session-search-deleted-sources.test.ts @@ -0,0 +1,356 @@ +import { chmod, mkdir, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { parserPublishesMessages } from '../ai-vault/session-scanner-agent-parser' +import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers' +import { retireDeletedSessionSearchSources } from './session-search-deleted-sources' +import { + SessionSearchDirectoryListings, + type SessionSearchDirectoryListing, + type SessionSearchDirectoryReader +} from './session-search-directory-listings' +import { + openSessionSearchIndexerHarness, + type SessionSearchIndexerHarness +} from './session-search-indexer-test-fixture' +import { SessionSearchStore } from './session-search-store' + +// The invariants this file exists to pin are written at the top of +// session-search-deleted-sources.ts. Each one is named in the tests below. + +const CAN_DENY_READ = process.platform !== 'win32' && process.getuid?.() !== 0 + +let harness: SessionSearchIndexerHarness +let store: SessionSearchStore +let removed: string[] + +beforeEach(async () => { + resetTranscriptConsumersForTests() + harness = await openSessionSearchIndexerHarness('ss-deleted-sources') + removed = [] + store = new SessionSearchStore(harness.databasePath) + // Only the removal matters here; the store's own removal path has its own tests. + store.removeFile = (path: string) => removed.push(path) +}) + +afterEach(async () => { + store.close() + await harness.cleanup() +}) + +/** A reader that answers with whatever a stalled mount would, per directory. */ +function readerAnswering( + answers: Record +): SessionSearchDirectoryReader { + return { + namesIn: (directory) => + Promise.resolve( + answers[directory] ?? { listed: false, code: 'ENOENT', message: 'no such directory' } + ) + } +} + +function retire( + paths: readonly string[], + options: { + roots?: readonly string[] + emptiedRoots?: ReadonlySet + enumeratedContainers?: ReadonlyMap> + listings?: SessionSearchDirectoryReader + directoryLimit?: number + } = {} +) { + return retireDeletedSessionSearchSources({ + store, + paths, + roots: options.roots ?? [harness.roots.claudeProjectsDir ?? ''], + emptiedRoots: options.emptiedRoots, + enumeratedContainers: options.enumeratedContainers, + listings: options.listings ?? new SessionSearchDirectoryListings(), + directoryLimit: options.directoryLimit + }) +} + +// I4: a file the user deleted retires on the first pass that proves it, with no +// waiting period, because its directory listed and it was not in the listing. +it('retires a deleted file the moment its own directory lists without it', async () => { + const kept = join(harness.claudeProjectDir, 'kept.jsonl') + await mkdir(harness.claudeProjectDir, { recursive: true }) + await writeFile(kept, '{}') + const deleted = join(harness.claudeProjectDir, 'deleted.jsonl') + + const result = await retire([kept, deleted]) + expect(result.retired).toEqual([deleted]) + expect(removed).toEqual([deleted]) + // A file that is still there is settled, not watched: it is neither retired + // nor carried into the next pass as unfinished business. + expect(result.unverifiable).toEqual([]) + expect(result.degradedRoots).toEqual([]) +}) + +// I4, the other shape: the directory itself is gone, so the question moves up +// one level and the root answers it. +it('retires a whole project directory the user deleted', async () => { + const sibling = join(harness.roots.claudeProjectsDir ?? '', 'other', 'kept.jsonl') + await mkdir(join(harness.roots.claudeProjectsDir ?? '', 'other'), { recursive: true }) + await writeFile(sibling, '{}') + const gone = join(harness.claudeProjectDir, 'inside-a-deleted-project.jsonl') + + const result = await retire([gone]) + expect(result.retired).toEqual([gone]) + expect(result.unverifiable).toEqual([]) +}) + +// I1 and I2: a root that is not there proves nothing. The walk stops at the +// configured root and never asks what is above it, so a home directory on an +// unmounted volume — the shape a detached drive or a dropped SSH mount takes — +// leaves every row exactly where it was. +it('keeps every row under a root that is not there', async () => { + const root = harness.roots.claudeProjectsDir ?? '' + const held = [join(harness.claudeProjectDir, 'one.jsonl'), join(root, 'flat.jsonl')] + + const result = await retire(held) + expect(result.retired).toEqual([]) + expect(result.unverifiable).toEqual(held) + // The root is named, once, so a caller can say which tree is unreachable. + expect(result.degradedRoots).toEqual([{ root, reason: `${root} could not be listed.` }]) +}) + +// I3: the same answer with no memory at all. Nothing here is carried from a +// previous pass, which is what makes the first sweep after a restart — when a +// volume is most likely to be missing — behave like every other pass. +it('keeps a missing root on a pass that has seen nothing before it', async () => { + const root = harness.roots.claudeProjectsDir ?? '' + const held = join(harness.claudeProjectDir, 'one.jsonl') + const first = await retire([held], { emptiedRoots: new Set() }) + const second = await retire([held], { emptiedRoots: new Set() }) + expect([first.retired, second.retired]).toEqual([[], []]) + expect(second.degradedRoots.map((one) => one.root)).toEqual([root]) +}) + +// I2: an unreadable directory is not an empty one. EACCES stops the walk where +// it is rather than being walked up like a missing component. +it.skipIf(!CAN_DENY_READ)('keeps rows under a directory that refuses to list', async () => { + const blocked = join(harness.roots.claudeProjectsDir ?? '', 'blocked') + await mkdir(blocked, { recursive: true }) + const hidden = join(blocked, 'hidden.jsonl') + await writeFile(hidden, '{}') + await chmod(blocked, 0o000) + try { + const result = await retire([hidden]) + expect(result.retired).toEqual([]) + expect(result.unverifiable).toEqual([hidden]) + expect(result.degradedRoots.map((one) => one.root)).toEqual([harness.roots.claudeProjectsDir]) + } finally { + await chmod(blocked, 0o755) + } +}) + +// I2, without needing a filesystem that can produce it: a stalled network mount +// answers EIO or a WSL gate refusal, and neither is ENOENT. This is the SSH and +// WSL case — loss of contact is never evidence of absence. +it('keeps rows when a directory answers with a transport failure', async () => { + const root = harness.roots.claudeProjectsDir ?? '' + const held = join(harness.claudeProjectDir, 'one.jsonl') + for (const listing of [ + { listed: false as const, code: 'EIO', message: 'input/output error' }, + { listed: false as const, code: 'ETIMEDOUT', message: 'the mount stopped answering' }, + { listed: false as const, code: null, message: 'The distro stopped responding.' } + ]) { + const result = await retire([held], { + listings: readerAnswering({ [harness.claudeProjectDir]: listing }) + }) + expect(result.retired).toEqual([]) + expect(result.degradedRoots).toEqual([{ root, reason: listing.message }]) + } +}) + +// The one bit of memory, and the only thing it buys: a root that held +// transcripts on the previous pass and lists empty on this one gets one pass of +// grace, so a directory swapped out for a moment cannot retire a tree. +it('holds a root that went from holding transcripts to empty in one pass', async () => { + const root = harness.roots.claudeProjectsDir ?? '' + await mkdir(root, { recursive: true }) + const held = join(harness.claudeProjectDir, 'one.jsonl') + + const grace = await retire([held], { emptiedRoots: new Set([root]) }) + expect(grace.retired).toEqual([]) + expect(grace.unverifiable).toEqual([held]) + + // The next pass has no transition to point at, so the empty listing is what + // it says it is: the user emptied the root. + const after = await retire([held], { emptiedRoots: new Set() }) + expect(after.retired).toEqual([held]) +}) + +// A flat-layout agent, where the mountpoint IS the session directory, is the +// one shape the grace exists for: there is no intermediate directory whose +// absence could stop the walk. +it('holds a flat root that emptied in one pass, and retires it on the next', async () => { + const root = harness.roots.copilotSessionsDir ?? '' + await mkdir(root, { recursive: true }) + const held = join(root, 'session.jsonl') + + expect((await retire([held], { roots: [root], emptiedRoots: new Set([root]) })).retired).toEqual( + [] + ) + expect((await retire([held], { roots: [root] })).retired).toEqual([held]) +}) + +// OpenClaw's discovery merges two directories into one delimiter-joined label. +// Roots reach this function as the real directories behind that label, so one +// of them being unreachable never touches the other's rows. +it('judges each merged-root directory on its own', async () => { + const current = join(harness.roots.openclawStateDir ?? '', 'agents') + const legacy = join(harness.roots.openclawLegacyStateDir ?? '', 'agents') + const onMissing = join(current, 'main', 'sessions', 'mounted.jsonl') + const deleted = join(legacy, 'main', 'sessions', 'deleted.jsonl') + await mkdir(join(legacy, 'main', 'sessions'), { recursive: true }) + + const result = await retire([onMissing, deleted], { roots: [current, legacy] }) + expect(result.retired).toEqual([deleted]) + expect(result.unverifiable).toEqual([onMissing]) + expect(result.degradedRoots.map((one) => one.root)).toEqual([current]) +}) + +// A row under no configured root is judged by its own directory and nothing +// above it, so a moved profile is never retired on the strength of a root that +// no longer covers it. +it('judges a row under no configured root by its own directory', async () => { + const orphanDir = join(harness.root, 'moved-profile') + await mkdir(orphanDir, { recursive: true }) + const gone = join(orphanDir, 'gone.jsonl') + const present = join(orphanDir, 'present.jsonl') + await writeFile(present, '{}') + + const result = await retire([gone, present], { roots: [] }) + expect(result.retired).toEqual([gone]) + // No configured root owns it, so nothing is reported as degraded for it. + expect(result.degradedRoots).toEqual([]) +}) + +// I8. A synthetic row names a container and an entry inside it. Walking the +// row's own path would report every one of them gone, and walking only the +// container proves nothing about the entry: a session deleted inside a database +// that is still there would never be retired at all. +it('proves a synthetic row against its container, not against its own path', async () => { + const db = join(harness.root, 'opencode.db') + await writeFile(db, '') + const kept = `${db}#session-1` + const deleted = `${db}#session-2` + const enumeratedContainers = new Map([[db, new Set(['session-1'])]]) + + const result = await retire([kept, deleted], { roots: [], enumeratedContainers }) + expect(result.retired).toEqual([deleted]) + expect(result.unverifiable).toEqual([]) +}) + +it('keeps a synthetic row when this pass did not enumerate its container', async () => { + const db = join(harness.root, 'opencode.db') + await writeFile(db, '') + const row = `${db}#session-1` + + // A cycle asks for the newest N per agent, so a row it did not return may be + // the one after them. It enumerates nothing and therefore proves nothing. + await expect(retire([row], { roots: [] })).resolves.toMatchObject({ + retired: [], + unverifiable: [row] + }) + + // An enumeration that returned nothing at all is not evidence either: a + // database whose schema this scanner no longer recognises reads as empty + // with no error, and believing it would retire every session in one pass. + await expect( + retire([row], { roots: [], enumeratedContainers: new Map([[db, new Set()]]) }) + ).resolves.toMatchObject({ retired: [], unverifiable: [row] }) +}) + +it('retires a synthetic row when the container it came from is gone', async () => { + const db = join(harness.root, 'opencode.db') + await writeFile(db, '') + const row = `${db}#session-1` + const enumeratedContainers = new Map([[db, new Set(['session-1'])]]) + await expect(retire([row], { roots: [], enumeratedContainers })).resolves.toMatchObject({ + retired: [] + }) + + await rm(db) + await expect(retire([row], { roots: [], enumeratedContainers })).resolves.toMatchObject({ + retired: [row] + }) +}) + +// Nothing in this PR can hold a synthetic row: the index pass refuses a source +// whose parser decodes its messages where the message channel cannot reach +// them, and OpenCode's SQLite sessions are read on a worker thread. The rule +// above is the guard for the day that changes -- without it the walk would read +// `#` as a filename and retire every such row the moment it appeared. +it('does not index a source whose messages the channel cannot reach', () => { + const db = join(harness.root, 'opencode.db') + expect( + parserPublishesMessages({ + agent: 'opencode', + codexHome: null, + file: { path: `${db}#session-1`, mtimeMs: 1, modifiedAt: '', sizeBytes: 0 } + }) + ).toBe(false) +}) + +// Round 12, F1. The cap counts directories because that is what costs: rows +// sharing one are a single read and then map lookups. +it('caps the directories one pass reads, not the rows it answers', async () => { + const roots = [harness.claudeProjectDir] + const inside = (folder: string, name: string): string => + join(harness.claudeProjectDir, folder, name) + for (const folder of ['one', 'two', 'three']) { + await mkdir(join(harness.claudeProjectDir, folder), { recursive: true }) + } + // Four rows in each of three directories: three reads, twelve answers. + const paths = ['one', 'two', 'three'].flatMap((folder) => + ['a', 'b', 'c', 'd'].map((name) => inside(folder, name)) + ) + + const result = await retire(paths, { roots, directoryLimit: 2 }) + + // Two directories' worth answered, all eight of their rows, and the third + // directory's four left for the pass after this one. + expect(result.retired).toEqual(paths.slice(0, 8)) + expect(result.unchecked).toEqual(paths.slice(8)) +}) + +// The starvation this replaced: an unreadable directory answers `unverifiable` +// for every row under it and never becomes readable, so a cap on rows let one +// such directory hold the walk for as long as the permission stayed wrong. +it.skipIf(!CAN_DENY_READ)( + 'is not starved by many rows under one unreadable directory', + async () => { + const locked = join(harness.claudeProjectDir, 'locked') + await mkdir(locked, { recursive: true }) + const blocked = Array.from({ length: 520 }, (_unused, index) => + join(locked, `locked-${index}.jsonl`) + ) + const deleted = join(harness.claudeProjectDir, 'deleted.jsonl') + await chmod(locked, 0o000) + try { + const result = await retire([...blocked, deleted], { directoryLimit: 512 }) + + expect(result.retired).toEqual([deleted]) + expect(result.unverifiable).toHaveLength(blocked.length) + expect(result.unchecked).toEqual([]) + } finally { + await chmod(locked, 0o700) + } + } +) + +it('reads each directory once however many files it is asked about', async () => { + await mkdir(harness.claudeProjectDir, { recursive: true }) + const listings = new SessionSearchDirectoryListings() + await retire( + Array.from({ length: 50 }, (_unused, index) => + join(harness.claudeProjectDir, `gone-${index}.jsonl`) + ), + { listings } + ) + expect(listings.size).toBe(1) +}) diff --git a/src/main/ai-vault-search/session-search-deleted-sources.ts b/src/main/ai-vault-search/session-search-deleted-sources.ts new file mode 100644 index 00000000000..e600cd3c4b1 --- /dev/null +++ b/src/main/ai-vault-search/session-search-deleted-sources.ts @@ -0,0 +1,263 @@ +import { basename, dirname } from 'node:path' +import type { SessionSearchDegradedRoot } from './session-search-degraded-roots' +import type { SessionSearchDirectoryReader } from './session-search-directory-listings' +import { isUnderScanRoot } from './session-search-scan-roots' +import { splitSyntheticSessionSource } from './session-search-synthetic-sources' +import type { SessionSearchStore } from './session-search-store' + +/* + * Retirement invariants. Every one of these is a test; changing this file means + * changing the list, not working around it. + * + * I1. A row is retired only when its file is PROVEN gone: some directory + * between the file and its configured root lists successfully, and the next + * path component toward the file is absent from that listing. + * I2. If no directory from the file's parent up to the configured root can be + * listed, nothing is proven and no row is dropped. ENOENT/ENOTDIR is walked + * up (the directory itself is a missing component of some ancestor); + * EACCES, EIO, a WSL gate refusal, anything else, is unverifiable at once. + * I3. The rule is the same on the first pass after a process start and on every + * later pass. It needs no memory of what previous passes saw, because the + * walk is bounded at the configured root and never reasons about what is + * above it. + * I4. A file, or a project directory, the user really deleted retires on the + * first pass that proves it. There is no waiting period and no census. + * I8. A row whose path names an entry inside a container rather than a file of + * its own is proven the same way, one level up: the container must be + * present, and the pass must have enumerated it in full and successfully. + * A listing is a listing whether it comes from readdir or from a database. + * + * What I3 costs, stated rather than hidden: a volume mounted at exactly a + * configured root, unmounted so that the mountpoint stays present and lists + * empty, is indistinguishable from a root the user emptied. It retires. The + * realistic unmount shapes do not: a mount above the root leaves the root + * itself missing (the walk stops at the root boundary), and an unreadable root + * is an error, not a listing. One bit per root buys the remaining grace: a root + * that held transcripts on the previous pass and holds none on this one is + * unverifiable for that pass, so a single flap cannot retire a tree. + */ + +// Walked up rather than believed: a directory that ENOENTs is itself the +// missing component its parent has to be asked about. +const MISSING_DIRECTORY = new Set(['ENOENT', 'ENOTDIR']) + +export type SessionSearchRetirement = { + /** Paths proven gone and dropped from the index. */ + retired: string[] + /** Rows kept: this pass could prove the file neither present nor gone. */ + unverifiable: string[] + /** Paths the per-pass cap left for next time. */ + unchecked: string[] + /** Roots owning at least one unverifiable verdict, with the reason. */ + degradedRoots: SessionSearchDegradedRoot[] +} + +export type SessionSearchRetirementArgs = { + store: SessionSearchStore + /** Held paths this pass did not discover; everything else is still there. */ + paths: readonly string[] + /** The real directories this pass walked; the longest one containing a path bounds its walk. */ + roots: readonly string[] + /** Roots that listed transcripts on the previous pass and none on this one. */ + emptiedRoots?: ReadonlySet + /** + * Containers this pass enumerated in full, with the ids each holds. Only a + * census builds it; see session-search-synthetic-sources.ts for the bar a + * container has to meet before it appears here. + */ + enumeratedContainers?: ReadonlyMap> + /** One readdir per directory per pass, shared with the rest of the pass. */ + listings: SessionSearchDirectoryReader + /** + * Directories this walk may read before the pass moves on. + * + * Directories, not rows. A row whose walk finds its directory already read is + * answered from the pass's cache and costs nothing, so counting rows made an + * unreadable directory able to starve the whole walk: five hundred rows under + * one EACCES directory are one readdir and five hundred identical + * unverifiable verdicts, and a row for a file the user really deleted, sorted + * behind them, was never reached on any pass. + */ + directoryLimit?: number + signal?: AbortSignal +} + +type SessionSearchSourceVerdict = + | { verdict: 'gone' } + | { verdict: 'present' } + | { verdict: 'unverifiable'; reason: string } + +/** + * Retires index rows for sources that are provably gone. + * + * One function, called by both the sweep and the cycle, because either one + * alone deleting a user's history the first time a mount is missing is the bug + * this feature kept shipping. There is no separate root fence: the walk cannot + * reach a verdict of `gone` without a successful listing, so an unreadable or + * missing root produces `unverifiable` structurally rather than by a guard + * somebody has to remember to call (docs/reference/ssh-execution-boundary.md: + * loss of contact is never evidence of absence). + */ +export async function retireDeletedSessionSearchSources( + args: SessionSearchRetirementArgs +): Promise { + const { store, paths, signal } = args + const emptiedRoots = args.emptiedRoots ?? new Set() + const directoryLimit = args.directoryLimit ?? Number.POSITIVE_INFINITY + // Every directory this walk asked for, whether the pass had already read it + // or not. What it bounds is real work: a repeat of one already in here is a + // map lookup, and only a name that is new to it can cost a readdir. + const asked = new Set() + const listings: SessionSearchDirectoryReader = { + namesIn: (directory, signal) => { + asked.add(directory) + return args.listings.namesIn(directory, signal) + } + } + const retirement: SessionSearchRetirement = { + retired: [], + unverifiable: [], + unchecked: [], + degradedRoots: [] + } + const degraded = new Map() + for (const [index, path] of paths.entries()) { + // A synthetic row names a container and an entry inside it, never a file of + // its own; walking the row's own path would report every one of them gone. + const synthetic = splitSyntheticSessionSource(path) + const filePath = synthetic?.container ?? path + // Why capped at all: the sweep hands over every path it holds and did not + // discover, and under an unmount that is the whole index. What is left is + // simply still undiscovered next pass, so the walk finishes over the ones + // that follow rather than holding this one. + // + // Spent past the bound only by a row that starts somewhere new. One this + // walk has already read is answered from the map, so refusing it would buy + // nothing and would leave the budget hostage to whichever directory the + // rows happened to be sorted by. + if (signal?.aborted || (asked.size >= directoryLimit && !asked.has(dirname(filePath)))) { + retirement.unchecked.push(...paths.slice(index)) + break + } + const root = configuredRootFor(filePath, args.roots) + const containerProof = await proveSource(filePath, root ?? dirname(filePath), { + listings, + emptiedRoots, + signal + }) + const proof = synthetic + ? proveSyntheticSource(synthetic, containerProof, args.enumeratedContainers) + : containerProof + if (proof.verdict === 'gone') { + store.removeFile(path) + retirement.retired.push(path) + continue + } + if (proof.verdict === 'present') { + continue + } + retirement.unverifiable.push(path) + // Only a configured root is an alarm worth raising: a row under no root + // this scan walks is already reported on its own, as an orphan. + if (root !== null && !degraded.has(root)) { + degraded.set(root, proof.reason) + } + } + retirement.degradedRoots = [...degraded].map(([root, reason]) => ({ root, reason })) + return retirement +} + +/** + * Walks from the file toward its configured root, asking each directory whether + * the next component toward the file is there. The first directory that answers + * decides; a directory that is itself missing moves the question up one level. + * + * The loop cannot pass the configured root, which is what makes the whole thing + * memoryless: everything above the root — a home directory on an unmounted + * volume, a detached drive, an SSH mount that is not there — is out of scope by + * construction rather than by a state machine that has to remember it. + */ +async function proveSource( + path: string, + root: string, + context: { + listings: SessionSearchDirectoryReader + emptiedRoots: ReadonlySet + signal?: AbortSignal + } +): Promise { + let directory = dirname(path) + let child = basename(path) + while (directory === root || isUnderScanRoot(directory, root)) { + const listing = await context.listings.namesIn(directory, context.signal) + if (!listing.listed) { + if (listing.code !== null && MISSING_DIRECTORY.has(listing.code)) { + const parent = dirname(directory) + if (parent === directory) { + break + } + child = basename(directory) + directory = parent + continue + } + return { verdict: 'unverifiable', reason: listing.message } + } + if (listing.names.has(child)) { + return { verdict: 'present' } + } + if (directory === root && context.emptiedRoots.has(root)) { + // One pass of grace, so a root that blinks empty for a moment — a sync + // client mid-swap, a mount that has not settled — cannot retire a tree. + return { + verdict: 'unverifiable', + reason: 'Listed no transcripts where it listed some on the previous pass.' + } + } + return { verdict: 'gone' } + } + return { verdict: 'unverifiable', reason: `${root} could not be listed.` } +} + +/** + * A synthetic row is proven by its container's own enumeration, one level above + * where the filesystem walk stops. + * + * The container has to be present first: a database on a volume that is not + * there proves nothing about the sessions inside it, and a database that is + * gone takes its sessions with it. Only then does the enumeration decide, and + * only when this pass made one that was exhaustive and successful -- a cycle + * asks for the newest N per agent, so an id it did not return may just be the + * one after them. + */ +function proveSyntheticSource( + synthetic: { container: string; id: string }, + containerProof: SessionSearchSourceVerdict, + enumerated?: ReadonlyMap> +): SessionSearchSourceVerdict { + if (containerProof.verdict !== 'present') { + return containerProof + } + const ids = enumerated?.get(synthetic.container) + // An enumeration that returned nothing at all is not evidence that the + // container holds nothing: a source whose schema this scanner no longer + // recognises reads as empty with no error to see, and believing it would + // retire every entry in one pass. + if (!ids || ids.size === 0) { + return { + verdict: 'unverifiable', + reason: `${synthetic.container} was not enumerated in full this pass.` + } + } + return ids.has(synthetic.id) ? { verdict: 'present' } : { verdict: 'gone' } +} + +/** Longest configured root containing the path, or null for a row under none. */ +function configuredRootFor(path: string, roots: readonly string[]): string | null { + let owner: string | null = null + for (const root of roots) { + if (isUnderScanRoot(path, root) && (owner === null || root.length > owner.length)) { + owner = root + } + } + return owner +} diff --git a/src/main/ai-vault-search/session-search-directory-listings.test.ts b/src/main/ai-vault-search/session-search-directory-listings.test.ts new file mode 100644 index 00000000000..e0143cd7a27 --- /dev/null +++ b/src/main/ai-vault-search/session-search-directory-listings.test.ts @@ -0,0 +1,61 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { beforeEach, expect, it, vi } from 'vitest' +import { SessionSearchDirectoryListings } from './session-search-directory-listings' + +const { readdir } = vi.hoisted(() => ({ readdir: vi.fn() })) + +vi.mock('../native-chat/wsl-transcript-fs-access', () => ({ + wslGatedReaddir: readdir +})) + +beforeEach(() => { + readdir.mockReset() +}) + +// A WSL root is a UNC path into the distro, and reading it with raw `fs` is +// what makes a stalled distro look like an empty directory. The gated primitive +// is the same one discovery walks with, so a refusal arrives as an error the +// walk treats as unverifiable rather than as "nothing here". +it('reads through the gated primitive, on the scan lane', async () => { + const unc = '\\\\wsl$\\Ubuntu\\home\\me\\.claude\\projects' + readdir.mockResolvedValueOnce([{ name: 'one.jsonl' }]) + const listings = new SessionSearchDirectoryListings() + + const listing = await listings.namesIn(unc) + + expect(readdir).toHaveBeenCalledWith(unc, 'scan', undefined) + expect(listing).toEqual({ listed: true, names: new Set(['one.jsonl']) }) +}) + +it('reports the code a failed read carried, so ENOENT and EACCES stay apart', async () => { + readdir.mockRejectedValueOnce(Object.assign(new Error('permission denied'), { code: 'EACCES' })) + const listings = new SessionSearchDirectoryListings() + expect(await listings.namesIn('/blocked')).toEqual({ + listed: false, + code: 'EACCES', + message: 'permission denied' + }) +}) + +it('reads a directory once per pass, error or not', async () => { + readdir.mockRejectedValue(Object.assign(new Error('gone'), { code: 'ENOENT' })) + const listings = new SessionSearchDirectoryListings() + await listings.namesIn('/gone') + await listings.namesIn('/gone') + expect(readdir).toHaveBeenCalledTimes(1) + expect(listings.size).toBe(1) +}) + +it('is a real directory read when nothing is mocked out from under it', async () => { + readdir.mockImplementation(async (path: string) => { + const { readdir: real } = await import('node:fs/promises') + return (await real(path, { withFileTypes: true })) as unknown + }) + const root = join(tmpdir(), `ss-listings-${process.pid}`) + await mkdir(root, { recursive: true }) + await writeFile(join(root, 'present.jsonl'), '{}') + const listing = await new SessionSearchDirectoryListings().namesIn(root) + expect(listing.listed && listing.names.has('present.jsonl')).toBe(true) +}) diff --git a/src/main/ai-vault-search/session-search-directory-listings.ts b/src/main/ai-vault-search/session-search-directory-listings.ts new file mode 100644 index 00000000000..f2cb13f9168 --- /dev/null +++ b/src/main/ai-vault-search/session-search-directory-listings.ts @@ -0,0 +1,70 @@ +import { wslGatedReaddir } from '../native-chat/wsl-transcript-fs-access' + +/** One directory read: the names it holds, or what stopped the read. */ +export type SessionSearchDirectoryListing = + | { listed: true; names: ReadonlySet } + | { listed: false; code: string | null; message: string } + +/** + * What the retirement walk needs of a directory: its names, or why not. + * + * An interface rather than the class, so a test can hand the walk an EIO or a + * gate refusal — the shapes a stalled network mount answers with, which no + * temporary directory can be made to produce. + */ +export type SessionSearchDirectoryReader = { + namesIn(directory: string, signal?: AbortSignal): Promise +} + +/** + * Every directory one pass had to read, read once. + * + * The retirement walk asks the same directories about many files — a project + * directory holds hundreds of transcripts — and under an unmount every path + * under a root walks up through the same ancestors. One readdir per directory + * per pass keeps that bounded, and it also makes the pass self-consistent: two + * files in one directory cannot get contradictory verdicts because the + * directory changed between them. + * + * Reads go through the same gated primitive discovery uses, so a WSL UNC path + * is routed to the distro's helper process rather than read with raw fs, and a + * gate refusal arrives as an error rather than as an empty directory. + */ +export class SessionSearchDirectoryListings implements SessionSearchDirectoryReader { + private readonly listings = new Map() + + async namesIn(directory: string, signal?: AbortSignal): Promise { + const cached = this.listings.get(directory) + if (cached) { + return cached + } + const listing = await readDirectory(directory, signal) + this.listings.set(directory, listing) + return listing + } + + /** Directories read this pass; only tests and cost accounting need it. */ + get size(): number { + return this.listings.size + } +} + +async function readDirectory( + directory: string, + signal?: AbortSignal +): Promise { + try { + const entries = await wslGatedReaddir(directory, 'scan', signal) + return { listed: true, names: new Set(entries.map((entry) => entry.name)) } + } catch (error) { + const code = + error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' + ? error.code + : null + return { + listed: false, + code, + message: error instanceof Error ? error.message : String(error) + } + } +} diff --git a/src/main/ai-vault-search/session-search-file-write.test.ts b/src/main/ai-vault-search/session-search-file-write.test.ts index 942565e18d6..2c0be88b026 100644 --- a/src/main/ai-vault-search/session-search-file-write.test.ts +++ b/src/main/ai-vault-search/session-search-file-write.test.ts @@ -137,8 +137,11 @@ it('rolls a whole file back when a write throws part way through its transaction expect(matches(index.db, 'messages_fts', 'firstgeneration')).toBe(3) expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset).toBe(40) expect(errors).toHaveLength(1) - // The file is owed a re-read, which is the only reason anything was lost. - expect(store.takeStale().map((candidate) => candidate.file.path)).toEqual([SYNTHETIC_TRANSCRIPT]) + // The row itself says the read failed, which is the only reason anything was + // lost and the only record that outlives this read. + expect( + index.db.prepare('SELECT state, fail_count FROM files WHERE path = ?').get(SYNTHETIC_TRANSCRIPT) + ).toMatchObject({ state: 'failed', fail_count: 1 }) // And the connection is usable again: a transaction left open by the failure // would take down every write after it, not just the one that threw. @@ -590,10 +593,16 @@ it('writes nothing for an incomplete read and owes the file a whole re-read', () expect(counts(index.db)).toMatchObject({ sessions: 0, messages: 0, - files: 0, full: 0 }) - expect(store.pendingFileCount).toBe(1) + // One row, holding nothing but the failure: an incomplete read indexes no + // content, and the count of how often it has happened at this stat is the + // only thing that stops the file being read again on every pass. + expect(index.db.prepare('SELECT byte_offset, state, fail_count FROM files').get()).toMatchObject({ + byte_offset: 0, + state: 'failed', + fail_count: 1 + }) expect(errors).toEqual([]) }) diff --git a/src/main/ai-vault-search/session-search-index-consumer.test.ts b/src/main/ai-vault-search/session-search-index-consumer.test.ts index ca02333cf0b..ee855409fb7 100644 --- a/src/main/ai-vault-search/session-search-index-consumer.test.ts +++ b/src/main/ai-vault-search/session-search-index-consumer.test.ts @@ -10,7 +10,7 @@ import { userMessages, type SessionSearchIndexFile } from './session-search-index-test-fixture' -import { SessionSearchStore, STALE_PATH_LIMIT } from './session-search-store' +import { SessionSearchStore } from './session-search-store' let index: SessionSearchIndexFile let store: SessionSearchStore @@ -41,6 +41,13 @@ function cursor(): number | null | undefined { return store.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset } +/** What the row itself says it still owes, which is the only record there is. */ +function owed(): { state: string; fail_count: number } | undefined { + return index.db + .prepare('SELECT state, fail_count FROM files WHERE path = ?') + .get(SYNTHETIC_TRANSCRIPT) as { state: string; fail_count: number } | undefined +} + it('appends onto its own cursor and carries the content hash forward', async () => { replayTranscriptRead({ messages: userMessages('first half', 3), @@ -64,7 +71,7 @@ it('appends onto its own cursor and carries the content hash forward', async () .get() as { hash: string; count: number } expect(second.count).toBe(first.count + 2) expect(second.hash).not.toBe(first.hash) - expect(store.takeStale()).toEqual([]) + expect(owed()).toMatchObject({ state: 'current', fail_count: 0 }) }) it('appends onto a file it read through and decoded no session from', async () => { @@ -76,7 +83,7 @@ it('appends onto a file it read through and decoded no session from', async () = outcome: { session: null, byteOffset: 100 } }) expect(cursor()).toBe(100) - expect(store.takeStale()).toEqual([]) + expect(owed()).toMatchObject({ state: 'current', fail_count: 0 }) replayTranscriptRead({ mode: 'append', @@ -87,7 +94,7 @@ it('appends onto a file it read through and decoded no session from', async () = expect(indexedMessages()).toBe(2) expect(cursor()).toBe(220) - expect(store.takeStale()).toEqual([]) + expect(owed()).toMatchObject({ state: 'current', fail_count: 0 }) }) it('declines an append that starts past its own cursor and records the file', async () => { @@ -107,7 +114,7 @@ it('declines an append that starts past its own cursor and records the file', as expect(indexedMessages()).toBe(3) expect(cursor()).toBe(100) - expect(store.takeStale().map((candidate) => candidate.file.path)).toEqual([SYNTHETIC_TRANSCRIPT]) + expect(owed()).toMatchObject({ state: 'due' }) }) it('declines a file whose identity changed under the same path', async () => { @@ -127,7 +134,7 @@ it('declines a file whose identity changed under the same path', async () => { }) expect(indexedMessages()).toBe(2) - expect(store.takeStale()).toHaveLength(1) + expect(owed()?.state).not.toBe('current') }) it('never advances the cursor for an incomplete read', async () => { @@ -152,7 +159,7 @@ it('never advances the cursor for an incomplete read', async () => { } ).n ).toBe(3) - expect(store.takeStale()).toHaveLength(1) + expect(owed()?.state).not.toBe('current') }) it('indexes nothing at all from a read that was incomplete from the start', async () => { @@ -167,7 +174,11 @@ it('indexes nothing at all from a read that was incomplete from the start', asyn expect(index.db.prepare('SELECT count(*) AS n FROM messages').get()).toEqual({ n: 0 }) - expect(cursor()).toBeUndefined() + // No cursor, because nothing was read through. The row exists all the same: + // it is where the failure is counted, and a file that fails on its first read + // is exactly the one that has no row of its own to count on. + expect(cursor()).toBe(0) + expect(owed()).toMatchObject({ state: 'failed', fail_count: 1 }) }) it('drops a file whose parser returned no session', async () => { @@ -207,7 +218,9 @@ it('writes nothing for a source whose parser cannot reach the channel', async () expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({ n: 0 }) - expect(store.takeStale()).toEqual([]) + // No row at all, which is the record: the next pass reads a path the + // file table does not name. + expect(owed()).toBeUndefined() }) it('ignores a candidate older than the retention cutoff', async () => { @@ -217,53 +230,9 @@ it('ignores a candidate older than the retention cutoff', async () => { expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({ n: 0 }) - expect(store.takeStale()).toEqual([]) -}) - -it('stops writing while the store refuses writes, but remembers what it skipped', async () => { - store.setAcceptingWrites(false) - replayTranscriptRead({ messages: userMessages('paused', 3) }) - - expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({ - n: 0 - }) - expect(errors).toEqual([]) - // A pause is exactly the window in which every read is declined. Forgetting - // them would leave the whole paused span unindexed with nothing to replay it. - expect(store.takeStale().map((candidate) => candidate.file.path)).toEqual([SYNTHETIC_TRANSCRIPT]) -}) - -it('keeps the paused re-read set when the retention window is reconfigured', async () => { - store.setAcceptingWrites(false) - replayTranscriptRead({ messages: userMessages('paused', 2) }) - expect(store.pendingFileCount).toBe(1) - - // The set records what still has to be read, not what is worth keeping. A - // window that now excludes this file is enforced where the re-read is - // dispatched, so nothing is written and the file leaves the set there. - store.setRetentionCutoffMs(Date.now()) - expect(store.pendingFileCount).toBe(1) - - store.setAcceptingWrites(true) - expect(store.takeStale()).toHaveLength(1) - replayTranscriptRead({ messages: userMessages('outside the window now', 2) }) - expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({ - n: 0 - }) - expect(store.pendingFileCount).toBe(0) -}) - -it('drops the oldest record rather than growing without a bound, and says so', () => { - store.setAcceptingWrites(false) - for (let index = 0; index < STALE_PATH_LIMIT + 5; index++) { - store.markStale(syntheticCandidate({ path: `/transcript-${index}.jsonl` })) - } - - expect(store.pendingFileCount).toBe(STALE_PATH_LIMIT) - expect(store.droppedPendingFileCount).toBe(5) - const kept = store.takeStale().map((candidate) => candidate.file.path) - expect(kept).not.toContain('/transcript-0.jsonl') - expect(kept).toContain(`/transcript-${STALE_PATH_LIMIT + 4}.jsonl`) + // No row at all, which is the record: the next pass reads a path the + // file table does not name. + expect(owed()).toBeUndefined() }) it('keeps the session list running when the index write fails', async () => { @@ -282,7 +251,7 @@ it('keeps the session list running when the index write fails', async () => { }) ).not.toThrow() expect(errors.length).toBeGreaterThan(0) - expect(store.takeStale()).toHaveLength(1) + expect(owed()?.state).not.toBe('current') }) it('unregisters cleanly, leaving later reads unindexed', async () => { @@ -369,5 +338,5 @@ it('keeps a proven file identity when a later read cannot stat it', async () => expect(indexedMessages()).toBe(4) expect(cursor()).toBe(200) - expect(store.takeStale()).toHaveLength(1) + expect(owed()?.state).not.toBe('current') }) diff --git a/src/main/ai-vault-search/session-search-index-consumer.ts b/src/main/ai-vault-search/session-search-index-consumer.ts index e4fa6da4a8e..a457ca5e7c3 100644 --- a/src/main/ai-vault-search/session-search-index-consumer.ts +++ b/src/main/ai-vault-search/session-search-index-consumer.ts @@ -7,6 +7,7 @@ import { type TranscriptReadOutcome, type TranscriptReadStart } from '../ai-vault/session-transcript-consumers' +import type { SessionFileCandidate } from '../ai-vault/session-scanner-types' import { fileIdentity } from './session-search-file-cursor' import type { SessionSearchFileWrite } from './session-search-index-writer' import type { SessionSearchStore } from './session-search-store' @@ -16,30 +17,22 @@ import type { SessionSearchStore } from './session-search-store' * * It keeps its own cursor in the `files` table and never consults the parse * cache: the two answer different questions and diverge the moment either - * declines a read. Three refusals, each of which leaves the cursor where it - * was and records the file for a later whole re-read: + * declines a read. * - * - `beginRead` returns null when this index's cursor is behind the offset an - * `append` continues from, or when the file's identity changed. - * - a buffering failure stops the read's rows without failing the session list. - * - an `incomplete` outcome never commits; those rows are not the whole span. + * Every refusal leaves the cursor where it was and writes what the next pass + * needs on the row itself, because the row is the only thing that outlives this + * read. A declined append is `due`: the index is behind on a span no append + * reaches, so the file has to be read whole. A read that started and did not + * commit is `failed`, counted, and stamped with the stat it failed at, which is + * what stops an unreadable transcript being retried on every pass for ever. */ export class SessionSearchIndexConsumer implements TranscriptConsumer { constructor(private readonly store: SessionSearchStore) {} beginRead(start: TranscriptReadStart): TranscriptReadConsumer | null { const { candidate } = start - if (!this.store.acceptsCandidate(candidate)) { - // A pause is a reason not to write now, not a reason to forget the read. - // `markStale` applies the retention rule itself, so a candidate that is - // out of scope rather than merely paused is still dropped here. - this.store.markStale(candidate) - return null - } - // A parser that decodes where the channel cannot reach it reports every read - // as incomplete. Declining here is not the same as being behind: no re-read - // would help, so the file is not recorded either. if (!parserPublishesMessages(candidate)) { + this.noteUnreachableParser(candidate) return null } if (start.mode === 'append') { @@ -48,7 +41,8 @@ export class SessionSearchIndexConsumer implements TranscriptConsumer { // This index never saw the span before `previousByteOffset`; appending // here would leave a hole no later read can fill. A null cursor is the // file a chunked read left half written, which no offset continues. - this.store.markStale(candidate) + // Either way the next pass has to read this file from the start. + this.store.setFileState(candidate.file.path, 'due') return null } } @@ -59,11 +53,42 @@ export class SessionSearchIndexConsumer implements TranscriptConsumer { start.identity ) if (!write) { - this.store.markStale(candidate) + // A closed store, a candidate outside the retention window, or a row that + // moved under this read. Only a row that exists has anything to record. + this.store.setFileState(candidate.file.path, 'due') return null } return new SessionSearchReadConsumer(this.store, start, write) } + + /** + * A source no read can ever index, recorded as one this index has seen. + * + * A parser that decodes where the message channel cannot reach it -- OpenCode's + * SQLite sessions today -- publishes nothing, so no read of it will ever + * commit a row. Leaving the file table silent about it is not free: the next + * pass sees a path the index holds nothing for, asks for a read, and asking + * over a warm cache drops the session list's own resume point. The sidebar's + * fold is thrown away and the whole database is decoded again, on every pass, + * for ever. + * + * The row written is the shape the store already has for a read that went + * through and decoded no session: cursor at the file's size, no session row. + * The decide step then skips it until its stat moves, and the retirement walk + * retires it like any other row when it goes. + */ + private noteUnreachableParser(candidate: SessionFileCandidate): void { + const write = this.store.beginWrite(candidate, 'replace', 0) + const committed = + write?.commit({ + session: null, + byteOffset: candidate.file.sizeBytes ?? 0, + incomplete: false + }) === true + if (committed) { + this.store.writeCommitted(candidate) + } + } } class SessionSearchReadConsumer implements TranscriptReadConsumer { @@ -105,7 +130,10 @@ class SessionSearchReadConsumer implements TranscriptReadConsumer { this.store.writeCommitted(candidate) return } - this.store.markStale(candidate) + // Counted against the stat it failed at, not merely recorded: a transcript + // the reader cannot open fails identically on every pass, and only a change + // to this stat can mean the file itself changed. + this.store.setFileState(candidate.file.path, 'failed', candidate.file.mtimeMs) } } diff --git a/src/main/ai-vault-search/session-search-index-pass.test.ts b/src/main/ai-vault-search/session-search-index-pass.test.ts new file mode 100644 index 00000000000..c32eec59a96 --- /dev/null +++ b/src/main/ai-vault-search/session-search-index-pass.test.ts @@ -0,0 +1,194 @@ +import { appendFile, rm, stat, utimes } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache' +import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers' +import { registerSessionSearchIndexConsumer } from './session-search-index-consumer' +import { runSessionSearchIndexPass } from './session-search-index-pass' +import { parseTranscript } from './session-search-transcript-fixtures' +import { + claudeLines, + openSessionSearchIndexerHarness, + writeClaudeTranscript, + type SessionSearchIndexerHarness +} from './session-search-indexer-test-fixture' +import { discoverSessionSearchCandidates } from './session-search-scan-roots' +import { SessionSearchStore } from './session-search-store' + +const FIRST = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' +const SECOND = 'bbbbbbbb-cccc-4ddd-8eee-ffffffffffff' + +let harness: SessionSearchIndexerHarness +let store: SessionSearchStore +let errors: unknown[] + +beforeEach(async () => { + resetSessionParseCacheForTests() + resetTranscriptConsumersForTests() + errors = [] + harness = await openSessionSearchIndexerHarness('ss-index-pass') + await writeClaudeTranscript(transcript(FIRST), ['the first transcript'], FIRST) + await writeClaudeTranscript(transcript(SECOND), ['the second transcript'], SECOND) + store = openStore() +}) + +afterEach(async () => { + resetTranscriptConsumersForTests() + store.close() + await harness.cleanup() +}) + +function transcript(sessionId: string): string { + return join(harness.claudeProjectDir, `${sessionId}.jsonl`) +} + +function openStore(): SessionSearchStore { + const opened = new SessionSearchStore(harness.databasePath, (error) => errors.push(error)) + registerSessionSearchIndexConsumer(opened) + return opened +} + +async function candidates() { + return ( + await discoverSessionSearchCandidates(harness.roots, { + limitPerAgent: Number.POSITIVE_INFINITY + }) + ).candidates +} + +/** What a pass hands the read loop: the store's rows, read once. */ +function rows() { + return new Map(store.files().map((row) => [row.path, row])) +} + +function pass(options: { overdue?: () => boolean } = {}) { + return runSessionSearchIndexPass(store, [], { rows: rows(), ...options }) +} + +async function passOverAll(options: { overdue?: () => boolean } = {}) { + return runSessionSearchIndexPass(store, await candidates(), { rows: rows(), ...options }) +} + +function states(): Record { + return Object.fromEntries(store.files().map((row) => [row.path, row.state])) +} + +it('re-reads nothing it already holds, even with a cold session-list cache', async () => { + const first = await passOverAll() + expect(first.stats.fullParses).toBe(2) + + // A restart: the parse cache is gone, the index's `files` table is not. + store.close() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + store = openStore() + + const second = await passOverAll() + expect(second.stats).toMatchObject({ fullParses: 0, incremental: 0, reused: 0, bytesRead: 0 }) + expect(errors).toEqual([]) +}) + +it('resumes into a grown transcript instead of re-reading it whole', async () => { + await passOverAll() + await appendFile(transcript(FIRST), `${claudeLines(['a later turn'], FIRST, 10).join('\n')}\n`) + + const second = await passOverAll() + expect(second.stats).toMatchObject({ incremental: 1, fullParses: 0 }) +}) + +// Nothing is recorded about what a deadline cut off, because being owed is a +// fact about the row: the file is read on the next pass for the same reason it +// was owed on this one. +it('leaves what it ran out of time for owed, with nothing written down', async () => { + const all = await candidates() + const cut = await runSessionSearchIndexPass(store, all, { rows: rows(), overdue: () => true }) + + expect(cut.outOfTime).toBe(true) + expect(store.files()).toHaveLength(1) + const second = await passOverAll() + expect(second.stats.fullParses).toBe(1) + expect(store.files()).toHaveLength(2) +}) + +// The deadline is never applied before the pass has read anything, so a single +// transcript larger than one deadline is read alone rather than starved. +it('reads one file even when the deadline has already expired', async () => { + const only = (await candidates()).slice(0, 1) + const alone = await runSessionSearchIndexPass(store, only, { rows: rows(), overdue: () => true }) + + expect(alone.outOfTime).toBe(false) + expect(store.files()).toHaveLength(1) +}) + +it('skips a source the reader cannot even open without failing the pass', async () => { + const all = await candidates() + await rm(transcript(FIRST)) + await runSessionSearchIndexPass(store, all, { rows: rows() }) + + // One session indexed, and the missing one recorded as a failed read rather + // than as content the index holds. + expect(harness.read((db) => db.prepare('SELECT count(*) AS n FROM sessions').get())).toEqual({ + n: 1 + }) + expect(states()[transcript(FIRST)]).toBe('failed') +}) + +// Finding 6: mtime alone is not the freshness key. A transcript that grows +// while keeping its mtime (a same-second append, a restored timestamp) is a +// different file to the index, and reading only mtime would skip it forever. +it('re-reads a file that grew without its mtime moving', async () => { + const path = transcript(FIRST) + // A whole-millisecond stamp, so restoring it later reproduces it exactly. + const frozen = new Date(1_740_000_000_000) + await utimes(path, frozen, frozen) + await passOverAll() + + await appendFile(path, `${claudeLines(['a same-mtime append'], FIRST, 20).join('\n')}\n`) + await utimes(path, frozen, frozen) + expect((await stat(path)).mtimeMs).toBe(frozen.getTime()) + + const second = await passOverAll() + expect(second.stats.fullParses + second.stats.incremental).toBe(1) +}) + +// Finding 5: the decision reads the session list's cache and then changes it, +// so outside the per-path lane an overlapping list parse stores its entry in +// between and the forced read degrades into a reuse. +it('is not overtaken by a list parse racing the same path', async () => { + const path = transcript(FIRST) + const all = await candidates() + const only = all.filter((candidate) => candidate.file.path === path) + + // The list parses this path first, so its cursor covers the file, and again + // concurrently with the index's pass so the two interleave. + await parseTranscript(path) + await Promise.all([ + parseTranscript(path), + runSessionSearchIndexPass(store, only, { rows: rows() }) + ]) + + expect(harness.read((db) => db.prepare('SELECT count(*) AS n FROM sessions').get())).toEqual({ + n: 1 + }) +}) + +// Finding 4d: a declined read is a parse that returns normally and indexes +// nothing. It has to leave the row owing a read, not looking covered. +it('leaves a declined read owed rather than recorded as held', async () => { + const only = (await candidates()).slice(0, 1) + // What a store that refuses a write looks like from the consumer's side: the + // read runs, and nothing is written. + store.beginWrite = () => null + + const stats = await runSessionSearchIndexPass(store, only, { rows: rows() }) + + expect(stats.stats.fullParses).toBe(1) + expect(harness.read((db) => db.prepare('SELECT count(*) AS n FROM sessions').get())).toEqual({ + n: 0 + }) + expect(store.files()).toEqual([]) +}) + +it('reads nothing when there is nothing to read', async () => { + expect((await pass()).stats).toMatchObject({ fullParses: 0 }) +}) diff --git a/src/main/ai-vault-search/session-search-index-pass.ts b/src/main/ai-vault-search/session-search-index-pass.ts new file mode 100644 index 00000000000..05ccea1a314 --- /dev/null +++ b/src/main/ai-vault-search/session-search-index-pass.ts @@ -0,0 +1,84 @@ +import { throwIfAiVaultScanCancelled } from '../ai-vault/ai-vault-scan-cancellation' +import { + createSessionParseStats, + parseAgentSessionFileCached, + type SessionParseStats +} from '../ai-vault/session-scanner-parse-cache' +import type { SessionFileCandidate } from '../ai-vault/session-scanner-types' +import { fileIdentity } from './session-search-file-cursor' +import { sessionSearchReadDecision } from './session-search-read-decision' +import type { SessionSearchFileRow, SessionSearchStore } from './session-search-store' + +export type SessionSearchIndexPassOptions = { + signal?: AbortSignal + /** The store's rows for this pass, read once. Absent means the index holds nothing. */ + rows: ReadonlyMap + /** + * True once the pass has spent its wall-clock deadline. The one bound on how + * long a pass reads for: files and bytes are proxies for time, and the thing + * worth capping is the share of the wall clock an unasked background index + * takes. Never applied before the pass has read anything, so an oversized + * transcript is read alone rather than deferred for ever. + */ + overdue?: () => boolean +} + +/** + * Reads whatever the decide step says is owed, until the deadline. + * + * Nothing is recorded about what it did not reach. A candidate the deadline cut + * off is still owed on the next pass for the same reason it was owed on this + * one — its row says so — so there is no queue to keep, nothing to bound, and + * nothing to drop. What the reads themselves leave behind is written by the + * index consumer onto the rows. + */ +export async function runSessionSearchIndexPass( + store: SessionSearchStore, + candidates: readonly SessionFileCandidate[], + options: SessionSearchIndexPassOptions +): Promise<{ stats: SessionParseStats; outOfTime: boolean }> { + const stats = createSessionParseStats() + const cutoffMs = store.retentionCutoff + let read = 0 + let outOfTime = false + for (const candidate of candidates) { + throwIfAiVaultScanCancelled(options.signal) + const path = candidate.file.path + const row = options.rows.get(path) + const decision = sessionSearchReadDecision({ + candidate, + row, + // Only asked for a path the index holds something for; for the rest the + // decision is already made and this would be a query per new file. + cursor: row ? store.indexedFile(path, fileIdentity(candidate.file)) : null, + cutoffMs + }) + if (decision === 'skip') { + continue + } + // The decide step is one cursor lookup, so it runs for the whole list even + // once the deadline has gone: knowing what is owed costs nothing, and the + // count of what a pass left is worth more than the microseconds. + outOfTime ||= read > 0 && options.overdue?.() === true + if (outOfTime) { + continue + } + // The clock the deadline reads is one the owner may close behind: the read + // below writes to the store, so stop here rather than on a shut handle. + throwIfAiVaultScanCancelled(options.signal) + read += 1 + try { + await parseAgentSessionFileCached(candidate, process.platform, stats, decision) + } catch (error) { + throwIfAiVaultScanCancelled(options.signal) + // The reader reports a read it could not finish to the consumer, which is + // what records the failure on the row; nothing is counted here. + console.warn( + '[ai-vault-search] indexing skipped', + candidate.agent, + error instanceof Error ? error.name : 'ParseError' + ) + } + } + return { stats, outOfTime } +} diff --git a/src/main/ai-vault-search/session-search-index-writer.test.ts b/src/main/ai-vault-search/session-search-index-writer.test.ts index 46d147dcce0..1be12e35bc7 100644 --- a/src/main/ai-vault-search/session-search-index-writer.test.ts +++ b/src/main/ai-vault-search/session-search-index-writer.test.ts @@ -112,13 +112,12 @@ it('refuses to commit a write whose file was removed mid-read', () => { it('declines a behind cursor in beginRead before it ever reaches the store', () => { const attempted: number[] = [] const stub = { - acceptsCandidate: () => true, indexedFile: () => ({ byteOffset: 100, mtimeMs: 1, sizeBytes: 1 }), beginWrite: (_candidate: unknown, _mode: unknown, previousByteOffset: number) => { attempted.push(previousByteOffset) return { add: () => undefined, commit: () => true } }, - markStale: () => undefined + setFileState: () => undefined } as unknown as SessionSearchStore const consumer = new SessionSearchIndexConsumer(stub) @@ -144,7 +143,6 @@ it('declines a behind cursor in beginRead before it ever reaches the store', () it("hands the read's identity accessor to the store", () => { const captured: unknown[] = [] const stub = { - acceptsCandidate: () => true, indexedFile: () => null, beginWrite: ( _candidate: unknown, @@ -155,7 +153,7 @@ it("hands the read's identity accessor to the store", () => { captured.push(identity) return { add: () => undefined, commit: () => true } }, - markStale: () => undefined + setFileState: () => undefined } as unknown as SessionSearchStore const identity = (): null => null diff --git a/src/main/ai-vault-search/session-search-indexer-options.ts b/src/main/ai-vault-search/session-search-indexer-options.ts new file mode 100644 index 00000000000..08eb4aeb2af --- /dev/null +++ b/src/main/ai-vault-search/session-search-indexer-options.ts @@ -0,0 +1,49 @@ +import type { SessionSearchClock } from './session-search-clock' +import type { SessionSearchScanRoots } from './session-search-scan-roots' + +/** Default cycle. Long enough that a machine with thousands of transcripts is + * not re-statting continuously, short enough that a live conversation shows up + * while the user is still in it. */ +export const DEFAULT_SESSION_SEARCH_RECONCILE_INTERVAL_MS = 20_000 +/** Newest-N per agent root: the same recency rule the session sidebar applies. */ +export const DEFAULT_SESSION_SEARCH_RECENT_PER_AGENT = 12 +/** + * A quarter of the interval: the only bound on how long one pass reads for. + * + * The timer re-arms after a pass settles, so a pass that spends its whole + * deadline is followed by a full interval of quiet — five seconds of reading in + * every twenty-five, a fifth of the wall clock, and the stated ceiling is a + * quarter. Files the deadline cut off go back on the queue at full speed rather + * than being read slowly, which is what a load-average back-off did instead. + */ +export const DEFAULT_SESSION_SEARCH_PASS_DEADLINE_FRACTION = 4 +/** + * Cycles between whole-machine sweeps: five minutes at the default interval. + * + * A sweep is the only pass that sees a file nothing has told the indexer about + * — an old transcript deleted, a root that came back, a tree restored from a + * backup — so the cadence is what replaces every re-arm-on-recovery rule. A + * warm sweep is stats and readdirs, not reads, because the pass skips anything + * the index already covers at its current stat. + */ +export const DEFAULT_SESSION_SEARCH_FULL_SWEEP_EVERY_CYCLES = 15 + +/** + * Everything an indexer is. Immutable after construction: a settings change is + * `close()` and a new instance, which is also how the index is thrown away + * (`close()`, `removeSessionSearchDatabase(databasePath)`, construct again). + */ +export type SessionSearchIndexerOptions = { + databasePath: string + roots: SessionSearchScanRoots + /** null = all history; otherwise only transcripts modified within this many days. */ + historyDays: number | null + clock?: SessionSearchClock + reconcileIntervalMs?: number + recentPerAgent?: number + /** Wall time one pass may read for; the rest goes back on the queue. */ + passDeadlineMs?: number + /** Cycles between whole-machine sweeps. */ + fullSweepEveryCycles?: number + onError?: (error: unknown) => void +} diff --git a/src/main/ai-vault-search/session-search-indexer-test-fixture.ts b/src/main/ai-vault-search/session-search-indexer-test-fixture.ts new file mode 100644 index 00000000000..f8510510807 --- /dev/null +++ b/src/main/ai-vault-search/session-search-indexer-test-fixture.ts @@ -0,0 +1,168 @@ +import { mkdir, mkdtemp, rename, rm, stat, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import SyncDatabase from '../sqlite/sync-database' +import { isolatedScanRoots } from '../ai-vault/session-scanner-test-fixtures' +import type { SessionSearchClock, SessionSearchTimerHandle } from './session-search-clock' +import type { SessionSearchScanRoots } from './session-search-scan-roots' +import { assistantRecord, userRecord } from './session-search-transcript-fixtures' + +const CLOCK_EPOCH_MS = 1_740_000_000_000 + +/** Wall time the indexer's guarantee is stated in, under the test's control. */ +export class FakeSessionSearchClock implements SessionSearchClock { + private time = CLOCK_EPOCH_MS + private nextId = 1 + private nowCalls = 0 + private readonly timers = new Map void }>() + + /** + * What each `now()` reading costs. A pass reads the clock once per file it is + * about to read, so this is how a test spends a pass's deadline without + * waiting: it is the wall time the reads themselves take. + */ + costPerNowMs = 0 + + /** + * Runs on every `now()`, with the call number. The only synchronous seam into + * a running pass: the deadline check is what a pass consults between files. + */ + onNow: ((call: number) => void) | null = null + + now(): number { + const at = this.time + this.time += this.costPerNowMs + this.onNow?.(++this.nowCalls) + return at + } + + setTimeout(callback: () => void, ms: number): SessionSearchTimerHandle { + const id = this.nextId++ + this.timers.set(id, { at: this.time + ms, callback }) + return id + } + + clearTimeout(handle: SessionSearchTimerHandle): void { + this.timers.delete(handle as number) + } + + /** Moves time forward and fires every timer that came due, in order. */ + advance(ms: number): void { + this.time += ms + for (const [id, timer] of [...this.timers].sort((left, right) => left[1].at - right[1].at)) { + if (timer.at <= this.time) { + this.timers.delete(id) + timer.callback() + } + } + } + + get pendingTimers(): number { + return this.timers.size + } +} + +export type SessionSearchIndexerHarness = { + root: string + databasePath: string + roots: SessionSearchScanRoots + claudeProjectDir: string + /** A second connection: the store keeps its own private. */ + read: (query: (db: SyncDatabase) => T) => T + /** Plants what a killed writer would have left; nothing in the app writes here. */ + write: (query: (db: SyncDatabase) => T) => T + cleanup: () => Promise +} + +export async function openSessionSearchIndexerHarness( + name: string +): Promise { + const root = await mkdtemp(join(tmpdir(), `${name}-`)) + const roots = isolatedScanRoots(root) + const databasePath = join(root, 'index', 'index.sqlite') + return { + root, + databasePath, + roots, + claudeProjectDir: join(roots.claudeProjectsDir, 'project'), + read: (query) => withConnection(databasePath, true, query), + write: (query) => withConnection(databasePath, false, query), + cleanup: () => rm(root, { recursive: true, force: true }) + } +} + +function withConnection( + path: string, + readonlyConnection: boolean, + query: (db: SyncDatabase) => T +): T { + const db = new SyncDatabase(path, { readonly: readonlyConnection }) + try { + return query(db) + } finally { + db.close() + } +} + +/** A native-chat-shaped Claude transcript: the same records the app itself writes. */ +export async function writeClaudeTranscript( + path: string, + turns: readonly string[], + sessionId: string +): Promise { + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, `${claudeLines(turns, sessionId, 0).join('\n')}\n`) +} + +export function claudeLines( + turns: readonly string[], + sessionId: string, + startIndex: number +): string[] { + return turns.flatMap((turn, offset) => [ + userRecord(startIndex + offset * 2, turn, sessionId), + assistantRecord(startIndex + offset * 2 + 1, `noted: ${turn}`, sessionId) + ]) +} + +/** + * Replaces a transcript the way an editor or a sync client does: a new inode + * renamed over the old name. Same byte length on purpose, so the only thing + * that can tell the two files apart is their filesystem identity. + */ +export async function renameReplaceTranscript( + path: string, + turns: readonly string[], + sessionId: string +): Promise { + const before = await stat(path) + const replacement = `${path}.replacement` + await writeClaudeTranscript(replacement, turns, sessionId) + await rename(replacement, path) + const later = new Date(before.mtimeMs + 5_000) + await utimes(path, later, later) +} + +/** + * A message-graph transcript, the shape OpenClaw, Pi, OMP and Prime Agent + * write. The session id comes from the file name, so callers name the file. + */ +export async function writeMessageGraphTranscript( + path: string, + turns: readonly string[] +): Promise { + await mkdir(dirname(path), { recursive: true }) + const lines = turns.flatMap((turn, index) => [ + JSON.stringify({ + type: 'message', + timestamp: new Date(CLOCK_EPOCH_MS + index * 120_000).toISOString(), + message: { role: 'user', content: turn } + }), + JSON.stringify({ + type: 'message', + timestamp: new Date(CLOCK_EPOCH_MS + index * 120_000 + 60_000).toISOString(), + message: { role: 'assistant', content: `noted: ${turn}` } + }) + ]) + await writeFile(path, `${lines.join('\n')}\n`) +} diff --git a/src/main/ai-vault-search/session-search-indexer.test.ts b/src/main/ai-vault-search/session-search-indexer.test.ts new file mode 100644 index 00000000000..5985628ba2d --- /dev/null +++ b/src/main/ai-vault-search/session-search-indexer.test.ts @@ -0,0 +1,1090 @@ +import { existsSync, mkdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs' +import { appendFile, chmod, mkdir, rm, stat, utimes } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache' +import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers' +import type SyncDatabase from '../sqlite/sync-database' +import { SessionSearchIndexer } from './session-search-indexer' +import { removeSessionSearchDatabase } from './session-search-schema' +import { parseTranscript } from './session-search-transcript-fixtures' +import { + claudeLines, + FakeSessionSearchClock, + openSessionSearchIndexerHarness, + renameReplaceTranscript, + writeClaudeTranscript, + type SessionSearchIndexerHarness +} from './session-search-indexer-test-fixture' + +const INTERVAL_MS = 20_000 +// chmod cannot deny root, and Windows ignores the mode bits entirely, so the +// two refusal tests would assert on an unreached branch there. +const CAN_DENY_READ = process.platform !== 'win32' && process.getuid?.() !== 0 +const SESSION_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' +const OTHER_SESSION_ID = 'bbbbbbbb-cccc-4ddd-8eee-ffffffffffff' +const SETTLED_SESSION_ID = 'dddddddd-cccc-4ddd-8eee-ffffffffffff' + +let harness: SessionSearchIndexerHarness +let clock: FakeSessionSearchClock +let indexer: SessionSearchIndexer | null +let errors: unknown[] + +beforeEach(async () => { + resetSessionParseCacheForTests() + resetTranscriptConsumersForTests() + errors = [] + clock = new FakeSessionSearchClock() + harness = await openSessionSearchIndexerHarness('ss-indexer') + indexer = null +}) + +afterEach(async () => { + indexer?.close() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + await harness.cleanup() +}) + +function newIndexer( + overrides: Partial[0]> = {} +) { + indexer = new SessionSearchIndexer({ + databasePath: harness.databasePath, + roots: harness.roots, + historyDays: null, + clock, + reconcileIntervalMs: INTERVAL_MS, + onError: (error) => errors.push(error), + ...overrides + }) + return indexer +} + +/** Sessions a published-view read returns for one term, the only legal shape. */ +function sessionsMatching(term: string): string[] { + return harness.read((db: SyncDatabase) => + ( + db + .prepare( + `SELECT DISTINCT s.session_id AS 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 ? ORDER BY s.session_id` + ) + .all(term) as { id: string }[] + ).map((row) => row.id) + ) +} + +function indexedSessionCount(): number { + return harness.read( + (db: SyncDatabase) => + (db.prepare('SELECT count(*) AS n FROM sessions').get() as { n: number }).n + ) +} + +/** The row the store holds for a path, which is the indexer's whole memory of it. */ +function rowFor(path: string) { + return harness.read((db: SyncDatabase) => + db.prepare('SELECT state, fail_count AS failCount FROM files WHERE path = ?').get(path) + ) as { state: string; failCount: number } | undefined +} + +function fileState(path: string): string | undefined { + return rowFor(path)?.state +} + +/** The byte offset the index recorded; PR 2 stores -1 for a half-written file. */ +function indexedByteOffset(path: string): number | undefined { + return harness.read( + (db: SyncDatabase) => + ( + db.prepare('SELECT byte_offset AS offset FROM files WHERE path = ?').get(path) as + | { offset: number } + | undefined + )?.offset + ) +} + +/** What a chunk of a read that never finished leaves on the file row. */ +function plantPartialCursor(path: string): void { + harness.write((db: SyncDatabase) => + db.prepare('UPDATE files SET byte_offset = -1 WHERE path = ?').run(path) + ) +} + +function indexedCursor(path: string): { mtime_ms: number; size_bytes: number } | undefined { + return harness.read( + (db: SyncDatabase) => + db.prepare('SELECT mtime_ms, size_bytes FROM files WHERE path = ?').get(path) as + | { mtime_ms: number; size_bytes: number } + | undefined + ) +} + +function transcriptPath(name = SESSION_ID): string { + return join(harness.claudeProjectDir, `${name}.jsonl`) +} + +/** + * Starts the indexer over a root that already holds one indexed transcript, so + * the opening sweep is behind us and `reconcile()` runs a cycle. It is dated + * ahead of everything the caller writes afterwards, so it stays inside any + * recency window and is skipped rather than read. + */ +async function startAfterASweep( + overrides: Partial[0]> = {} +): Promise { + const settled = transcriptPath(SETTLED_SESSION_ID) + await writeClaudeTranscript(settled, ['a conversation from before'], SETTLED_SESSION_ID) + // Wall time, not the fake clock: recency is decided by real file mtimes. + const ahead = new Date(Date.now() + 3_600_000) + await utimes(settled, ahead, ahead) + await newIndexer(overrides).start() +} + +/** + * Makes every pass stop after `files` reads: the pass consults the clock once + * per file it is about to read, and each reading costs a quarter of the + * deadline it is measured against. + */ +function readsPerPass(files: number): { passDeadlineMs: number } { + clock.costPerNowMs = 1_000 + return { passDeadlineMs: files * 1_000 } +} + +/** Advances one reconcile interval and waits for the cycle it fires. */ +async function nextCycle(): Promise { + clock.advance(INTERVAL_MS) + await indexer?.settled() +} + +it('reflects a grown transcript within one reconcile interval', async () => { + const path = transcriptPath() + await writeClaudeTranscript(path, ['find the flaky terminal reattach'], SESSION_ID) + await newIndexer().start() + expect(sessionsMatching('reattach')).toEqual([SESSION_ID]) + expect(sessionsMatching('quarantine')).toEqual([]) + + await appendFile( + path, + `${claudeLines(['quarantine the leaking pty'], SESSION_ID, 10).join('\n')}\n` + ) + await nextCycle() + + expect(sessionsMatching('quarantine')).toEqual([SESSION_ID]) + expect(errors).toEqual([]) +}) + +it('reflects a rename-replaced transcript within one reconcile interval', async () => { + const path = transcriptPath() + await writeClaudeTranscript(path, ['original content aaaa'], SESSION_ID) + await newIndexer().start() + expect(sessionsMatching('original')).toEqual([SESSION_ID]) + const original = await stat(path) + + await renameReplaceTranscript(path, ['swapped content bbbbb'], SESSION_ID) + // Same length, different inode: only the identity check can tell them apart. + expect((await stat(path)).size).toBe(original.size) + await nextCycle() + + expect(sessionsMatching('swapped')).toEqual([SESSION_ID]) + expect(sessionsMatching('original')).toEqual([]) + expect(errors).toEqual([]) +}) + +it('retires a deleted transcript within one reconcile interval', async () => { + const path = transcriptPath() + await writeClaudeTranscript(path, ['a session about to be deleted'], SESSION_ID) + await writeClaudeTranscript( + transcriptPath(OTHER_SESSION_ID), + ['a surviving session'], + OTHER_SESSION_ID + ) + await newIndexer().start() + await nextCycle() + expect(sessionsMatching('deleted')).toEqual([SESSION_ID]) + + await rm(path) + await nextCycle() + + expect(sessionsMatching('deleted')).toEqual([]) + expect(sessionsMatching('surviving')).toEqual([OTHER_SESSION_ID]) +}) + +it.skipIf(!CAN_DENY_READ)( + 'keeps rows for a source it cannot stat, because loss of contact is not deletion', + async () => { + const path = transcriptPath() + await writeClaudeTranscript(path, ['an unverifiable session'], SESSION_ID) + await newIndexer().start() + await nextCycle() + + // The tree is gone from discovery's point of view, but the transcript itself + // was never proven absent: an unreadable parent is not a deleted file. + await chmod(harness.claudeProjectDir, 0o000) + try { + await nextCycle() + expect(sessionsMatching('unverifiable')).toEqual([SESSION_ID]) + } finally { + await chmod(harness.claudeProjectDir, 0o755) + } + } +) + +it('resumes after close and reopen without re-reading what it already indexed', async () => { + await writeClaudeTranscript(transcriptPath(), ['first indexed session'], SESSION_ID) + await writeClaudeTranscript( + transcriptPath(OTHER_SESSION_ID), + ['second indexed session'], + OTHER_SESSION_ID + ) + await newIndexer().start() + const indexedRows = harness.read((db: SyncDatabase) => + db.prepare('SELECT count(*) AS n FROM messages').get() + ) + indexer?.close() + + // A restart is a cold parse cache over a warm index; only the `files` table + // can say what has already been read. + resetSessionParseCacheForTests() + resetTranscriptConsumersForTests() + const reopened = newIndexer() + await reopened.start() + + // `filesIndexed` is the count of rows the index holds at their current stat, + // so it stays 2. That nothing was opened again is the read loop's own test. + expect(reopened.status()).toMatchObject({ filesIndexed: 2, filesDue: 0 }) + expect( + harness.read((db: SyncDatabase) => db.prepare('SELECT count(*) AS n FROM messages').get()) + ).toEqual(indexedRows) + expect(sessionsMatching('indexed')).toEqual([SESSION_ID, OTHER_SESSION_ID].sort()) +}) + +// F12, as the immutable design states it: the history window is a construction +// argument, so widening it is a new instance whose opening sweep admits the +// older files, and narrowing it is the purge that opens every full sweep. +it('widens history by constructing a new instance and narrows by purging on its first sweep', async () => { + const fresh = transcriptPath() + const old = transcriptPath(OTHER_SESSION_ID) + await writeClaudeTranscript(fresh, ['a recent conversation'], SESSION_ID) + await writeClaudeTranscript(old, ['an ancient conversation'], OTHER_SESSION_ID) + const longAgo = new Date(clock.now() - 120 * 86_400_000) + await utimes(old, longAgo, longAgo) + + // Newest-one per root, so the widened-in transcript is outside the recency + // window a cycle re-stats: only a full sweep can reach it. + await newIndexer({ historyDays: 30, recentPerAgent: 1 }).start() + expect(sessionsMatching('recent')).toEqual([SESSION_ID]) + expect(sessionsMatching('ancient')).toEqual([]) + + // Widening cannot be served from the index: those files were never read. + indexer?.close() + await newIndexer({ historyDays: null, recentPerAgent: 1 }).start() + expect(sessionsMatching('ancient')).toEqual([OTHER_SESSION_ID]) + + indexer?.close() + await newIndexer({ historyDays: 30, recentPerAgent: 1 }).start() + expect(sessionsMatching('ancient')).toEqual([]) + expect(sessionsMatching('recent')).toEqual([SESSION_ID]) +}) + +it.skipIf(!CAN_DENY_READ)( + 'names an unreadable root as degraded and keeps indexing the others', + async () => { + const blocked = join(harness.roots.codexSessionsDir ?? '', 'blocked') + await mkdir(blocked, { recursive: true }) + await writeClaudeTranscript(transcriptPath(), ['a readable claude session'], SESSION_ID) + await chmod(harness.roots.codexSessionsDir ?? '', 0o000) + try { + await newIndexer().start() + const status = indexer?.status() + expect(status?.phase).toBe('degraded') + expect(status?.degradedRoots.map((root) => root.root)).toContain( + harness.roots.codexSessionsDir + ) + expect(status?.degradedRoots[0]?.reason).toBeTruthy() + // A degraded root is not a degraded index: everything else still lands. + expect(sessionsMatching('readable')).toEqual([SESSION_ID]) + } finally { + await chmod(harness.roots.codexSessionsDir ?? '', 0o755) + } + } +) + +// The one bound on a pass. What it does not reach is owed on the next pass for +// the same reason it was owed on this one -- its row says so, or it has no row +// -- so nothing is written down and nothing can be lost. +it('reads what one pass has time for and finishes the rest on the next', async () => { + // The sweep is behind us, so this is the reconciler fitting four new files + // into a deadline that stops it after two. + await startAfterASweep(readsPerPass(2)) + for (let index = 0; index < 4; index++) { + const session = `0000000${index}-bbbb-4ccc-8ddd-eeeeeeeeeeee` + await writeClaudeTranscript( + transcriptPath(session), + [`deadlined session number ${index}`], + session + ) + } + await indexer?.reconcile() + expect(indexer?.status()).toMatchObject({ filesIndexed: 3, filesDue: 0 }) + + await indexer?.reconcile() + expect(sessionsMatching('deadlined')).toHaveLength(4) + expect(indexer?.status().filesIndexed).toBe(5) + + // Settled, and it stays settled: nothing changed, so the cycle after this + // one opens none of them. + clock.costPerNowMs = 0 + await nextCycle() + expect(indexer?.status()).toMatchObject({ filesIndexed: 5, phase: 'current' }) +}) + +// First enablement inside a running app is the normal case, not an edge: the +// session list has been scanning since launch, so every transcript already has +// a cursor sitting at its current stat and the index has nothing at all. +it('fills an empty index over a warm session-list cache on the first reconcile', async () => { + await startAfterASweep() + const path = transcriptPath() + await writeClaudeTranscript(path, ['scanned before the index existed'], SESSION_ID) + // An ordinary parse now reuses its cached fold and opens no file, so no + // consumer is asked and there is nothing for a decline to record. + await parseTranscript(path) + + await indexer?.reconcile() + + expect(sessionsMatching('scanned')).toEqual([SESSION_ID]) +}) + +it('fills an empty index over a warm session-list cache on the first sweep', async () => { + const path = transcriptPath() + await writeClaudeTranscript(path, ['scanned before the index existed'], SESSION_ID) + await parseTranscript(path) + + await newIndexer().start() + + expect(sessionsMatching('scanned')).toEqual([SESSION_ID]) +}) + +// Finding 1: a sweep cut short used to be abandoned part way through. A pass +// that hands reads back is not an unfinished sweep -- its discovery and its +// retirement both completed -- so it must not re-arm one, and the queue is what +// carries the reads it did not reach until the whole machine is covered. +it('covers the whole machine over the passes that follow a truncated sweep', async () => { + const sessions = Array.from( + { length: 20 }, + (_unused, index) => `0000${String(index).padStart(4, '0')}-bbbb-4ccc-8ddd-eeeeeeeeeeee` + ) + for (const session of sessions) { + await writeClaudeTranscript(transcriptPath(session), [`sweepwide session ${session}`], session) + } + + // One transcript a pass, so the opening sweep reaches a twentieth of them. + await newIndexer(readsPerPass(1)).start() + expect(indexedSessionCount()).toBeGreaterThan(0) + expect(indexedSessionCount()).toBeLessThan(sessions.length) + + for (let cycle = 0; cycle < sessions.length; cycle++) { + await nextCycle() + } + + expect(indexedSessionCount()).toBe(sessions.length) + expect(indexer?.status().phase).toBe('current') +}) + +// Finding 2: the store's cutoff was set once at construction while purges used +// a fresh one, so a sweep deleted the row and the accept check re-indexed it. +it('moves the retention window with the clock instead of freezing it at construction', async () => { + const path = transcriptPath() + await writeClaudeTranscript(path, ['an entry that ages out'], SESSION_ID) + // Dated on the same clock the retention window is measured against. + const now = new Date(clock.now()) + await utimes(path, now, now) + await newIndexer({ historyDays: 1 }).start() + expect(sessionsMatching('ages')).toEqual([SESSION_ID]) + + clock.advance(3 * 86_400_000) + await indexer?.reconcile({ full: true }) + + expect(sessionsMatching('ages')).toEqual([]) + await nextCycle() + expect(sessionsMatching('ages')).toEqual([]) +}) + +// Round 10, H1. A cycle proves a deletion by comparing what the previous pass +// watched against what it discovers. A sweep used to watch only what it could +// not settle, which is nothing on a healthy machine, so the cycle after a sweep +// had no candidates at all and the cycle after that no longer remembered the +// file: a transcript deleted in that interval survived until the next sweep, +// up to `fullSweepEveryCycles` later. +it('retires a transcript deleted between a sweep and the cycle after it', async () => { + const going = transcriptPath() + const staying = transcriptPath(OTHER_SESSION_ID) + await writeClaudeTranscript(going, ['a session deleted right after the sweep'], SESSION_ID) + await writeClaudeTranscript(staying, ['a surviving session'], OTHER_SESSION_ID) + await newIndexer().start() + expect(sessionsMatching('deleted')).toEqual([SESSION_ID]) + + // No cycle in between: the sweep is the only pass that has seen this file. + await rm(going) + await nextCycle() + + expect(sessionsMatching('deleted')).toEqual([]) + expect(sessionsMatching('surviving')).toEqual([OTHER_SESSION_ID]) +}) + +// Round 10, M2. A sweep that throws part way learned nothing, and the flag that +// says one is owed was taken on entry. Losing it there leaves nothing armed to +// try again, so the machine outside the recency window goes unread until +// something else happens to ask for a sweep. +it('keeps a sweep due when the one that was running threw', async () => { + const older = transcriptPath(OTHER_SESSION_ID) + await writeClaudeTranscript(older, ['an older conversation'], OTHER_SESSION_ID) + const yesterday = new Date(Date.now() - 86_400_000) + await utimes(older, yesterday, yesterday) + await writeClaudeTranscript(transcriptPath(), ['the newest conversation'], SESSION_ID) + + // Newest-one per root, so only a sweep can reach the older file. The clock is + // read inside the pass, which is where a failure part way through lands. + newIndexer({ recentPerAgent: 1 }) + let thrown = false + clock.onNow = () => { + if (thrown || indexedSessionCount() === 0) { + return + } + thrown = true + throw new Error('the sweep fell over') + } + await indexer?.start() + await indexer?.settled() + clock.onNow = null + + expect(errors.map((error) => (error as Error).message)).toEqual(['the sweep fell over']) + expect(sessionsMatching('older')).toEqual([]) + + // The pass after it is a sweep, not a cycle: a cycle reads one file per root. + await nextCycle() + expect(sessionsMatching('older')).toEqual([OTHER_SESSION_ID]) +}) + +// Round 10, M1. A transcript the reader cannot open is recorded stale by the +// consumer on every attempt, so it was re-read every cycle for ever: pending +// stuck at one, a failure count climbing without bound, and a phase that never +// left `indexing`. One file with the wrong mode bits read as a real backlog. +it.skipIf(!CAN_DENY_READ)('stops re-reading a transcript it cannot read', async () => { + const path = transcriptPath() + await writeClaudeTranscript(path, ['a session behind the wrong mode bits'], SESSION_ID) + await chmod(path, 0o000) + try { + await newIndexer().start() + for (let cycle = 0; cycle < 4; cycle++) { + await nextCycle() + } + + // Held out by its own row: three failures at one unchanged stat, counted on + // the row itself, and a phase that says the index knows it is not covering + // something rather than one that describes work it will never do. + expect(indexer?.status()).toMatchObject({ filesDue: 0, filesFailed: 1, phase: 'degraded' }) + expect(rowFor(path)?.failCount).toBeGreaterThanOrEqual(3) + + // And the hold is released by the only thing that can mean the file + // changed: its stat. + await chmod(path, 0o644) + const later = new Date(Date.now() + 60_000) + await utimes(path, later, later) + await nextCycle() + + expect(sessionsMatching('mode')).toEqual([SESSION_ID]) + expect(indexer?.status()).toMatchObject({ filesFailed: 0, phase: 'current' }) + } finally { + await chmod(path, 0o644) + } +}) + +// Round 10, M2. `close()` mid-pass left the pass reading a shut handle: three +// `database is not open` errors reached the owner, for a close they asked for. +it('reports nothing to its owner when it is closed part way through a pass', async () => { + await writeClaudeTranscript(transcriptPath(), ['one'], SESSION_ID) + const other = transcriptPath(OTHER_SESSION_ID) + await writeClaudeTranscript(other, ['two'], OTHER_SESSION_ID) + const later = new Date(Date.now() + 60_000) + await utimes(other, later, later) + newIndexer() + + // Between two files: the pass reads the clock once per file it is about to + // read, and closing there is what a quit during a sweep looks like. + let closed = false + clock.onNow = () => { + if (closed || indexedSessionCount() === 0) { + return + } + closed = true + indexer?.close() + } + await indexer?.start() + await indexer?.settled() + clock.onNow = null + + expect(errors).toEqual([]) +}) + +// Round 10, M2, the other half: `status()` on a closed indexer opened a shut +// database, reported the failure, and answered zero files. +it('reports what it last knew after it is closed, without reading the database', async () => { + await writeClaudeTranscript(transcriptPath(), ['indexed before the close'], SESSION_ID) + await newIndexer().start() + expect(indexer?.status().filesIndexed).toBe(1) + + indexer?.close() + + expect(indexer?.status()).toMatchObject({ phase: 'closed', filesIndexed: 1 }) + expect(errors).toEqual([]) +}) + +// Round 10, L1. Two indexers on one database both register with the reader, so +// every transcript is read and written twice and the second write is fenced by +// the first at random. The recipe for every configuration change is +// close-then-construct, so the ordering that causes this is the one the recipe +// rules out; this is what says so rather than letting it corrupt quietly. +// Round 12, F2. The claim was staked before the store opened, so an open that +// threw left the path owned by an object that does not exist and every later +// construction was refused -- including the one that fixes whatever broke it. +it('releases the database path when the open itself throws', () => { + // A directory where the database file goes: the open fails, nothing is owned. + mkdirSync(harness.databasePath, { recursive: true }) + expect(() => newIndexer()).toThrow() + + rmSync(harness.databasePath, { recursive: true, force: true }) + expect(() => newIndexer()).not.toThrow() +}) + +it('refuses a second indexer on a database one already owns', () => { + newIndexer() + expect(() => newIndexer()).toThrow(/already has a live indexer/) +}) + +// PR 2 records a cursor no append continues for a file a chunked read left half +// written, and reports it as a null offset. The mtime and size on that row are +// the whole file's, so a freshness check comparing only those calls a prefix +// current and leaves it in the index for good. +it('re-reads a file a chunked read left half written, and settles it in one pass', async () => { + const path = transcriptPath() + await writeClaudeTranscript(path, ['the committed half'], SESSION_ID) + await newIndexer().start() + const whole = (await stat(path)).size + expect(indexedByteOffset(path)).toBe(whole) + indexer?.close() + + plantPartialCursor(path) + await newIndexer().start() + + // Nothing about the file changed, and it was read anyway: the whole of it, + // because there is no cursor to continue from. + expect(indexedByteOffset(path)).toBe(whole) + expect(fileState(path)).toBe('current') + expect(indexer?.status().phase).toBe('current') + indexer?.close() + + // A half-written file that also grew is repaired by one pass rather than two. + // The session list's resume point would have the reader offer an append here, + // and an append onto a partial cursor is a read the consumer declines. + plantPartialCursor(path) + await appendFile(path, `${claudeLines(['the lost half'], SESSION_ID, 10).join('\n')}\n`) + await newIndexer().start() + + expect(sessionsMatching('lost')).toEqual([SESSION_ID]) + expect(indexer?.status()).toMatchObject({ filesDue: 0, phase: 'current' }) +}) + +it('reports closed once it is closed, whatever it was doing before', async () => { + await writeClaudeTranscript(transcriptPath(), ['before the close'], SESSION_ID) + await newIndexer().start() + expect(indexer?.status().phase).toBe('current') + indexer?.close() + expect(indexer?.status().phase).toBe('closed') +}) + +// Finding 6: a queued entry carries the stat it was recorded with. Reading at +// that stat writes a cursor describing a file that no longer looks like this, +// so the next cycle distrusts it and re-reads it, forever. +it('reads a deferred file at its current stat, not the one the pass first saw', async () => { + // One file a pass, so the older one is left for the pass after this. + await startAfterASweep(readsPerPass(1)) + const older = transcriptPath(OTHER_SESSION_ID) + await writeClaudeTranscript(older, ['the deferred conversation'], OTHER_SESSION_ID) + await writeClaudeTranscript(transcriptPath(), ['the newer conversation'], SESSION_ID) + const ahead = new Date((await stat(transcriptPath())).mtimeMs + 60_000) + await utimes(transcriptPath(), ahead, ahead) + + await indexer?.reconcile() + // No row for it at all, which is exactly why the next pass reads it. + expect(rowFor(older)).toBeUndefined() + + await appendFile( + older, + `${claudeLines(['appended while deferred'], OTHER_SESSION_ID, 10).join('\n')}\n` + ) + await indexer?.reconcile() + + expect(sessionsMatching('appended')).toEqual([OTHER_SESSION_ID]) + // The cursor has to describe the file as it is now; recorded against the + // stat the earlier pass saw it would be re-read on every cycle from here on. + const cursor = indexedCursor(older) + const current = await stat(older) + expect(cursor).toEqual({ mtime_ms: current.mtimeMs, size_bytes: current.size }) +}) + +// A declined read records the stat it was declined at. By the time the store +// hands it back the file has usually moved on again, and reading at the +// recorded stat writes a cursor the next cycle immediately distrusts. +it('reads a declined file at its current stat, not the one it was recorded with', async () => { + const path = transcriptPath() + await writeClaudeTranscript(path, ['the recorded conversation'], SESSION_ID) + await newIndexer().start() + + // A warm session-list cache over an empty index: the reader offers an append + // continuing an offset this index has never seen, so the consumer declines it + // and records the stat it declined at. + indexer?.close() + removeSessionSearchDatabase(harness.databasePath) + newIndexer() + await appendFile(path, `${claudeLines(['declined turn'], SESSION_ID, 10).join('\n')}\n`) + await parseTranscript(path) + // The index holds nothing for it, which is the record: a path the file table + // does not name is read from the start by the next pass. + expect(indexer?.status().filesIndexed).toBe(0) + + await appendFile(path, `${claudeLines(['later turn'], SESSION_ID, 20).join('\n')}\n`) + // The sweep is declined too -- the list's cursor is still ahead of the index + // -- so it is the pass after it that reads the file whole. + await indexer?.start() + await nextCycle() + + expect(sessionsMatching('later')).toEqual([SESSION_ID]) + const current = await stat(path) + expect(indexedCursor(path)).toEqual({ mtime_ms: current.mtimeMs, size_bytes: current.size }) +}) + +// Round 2, item 1: the sweep kept the rows and a cycle twenty seconds later +// deleted them, because the degraded-root fence was on the sweep path only. +it.skipIf(!CAN_DENY_READ)( + 'keeps an unlistable root through the cycles that follow the sweep', + async () => { + await writeClaudeTranscript(transcriptPath(), ['a session on a removable volume'], SESSION_ID) + await newIndexer().start() + expect(sessionsMatching('removable')).toEqual([SESSION_ID]) + + await chmod(harness.roots.claudeProjectsDir ?? '', 0o000) + try { + await indexer?.reconcile({ full: true }) + expect(sessionsMatching('removable')).toEqual([SESSION_ID]) + + await nextCycle() + expect(sessionsMatching('removable')).toEqual([SESSION_ID]) + expect(indexer?.status().phase).toBe('degraded') + } finally { + await chmod(harness.roots.claudeProjectsDir ?? '', 0o755) + } + } +) + +// A root that cannot be listed is never believed to be empty, however many +// times it is asked: an error is not a listing, and only a listing is proof. +it.skipIf(!CAN_DENY_READ)('keeps an unlistable root degraded across repeated sweeps', async () => { + await writeClaudeTranscript(transcriptPath(), ['a session on a removable volume'], SESSION_ID) + await newIndexer().start() + + await chmod(harness.roots.claudeProjectsDir ?? '', 0o000) + try { + for (let sweep = 0; sweep < 5; sweep++) { + await indexer?.reconcile({ full: true }) + } + expect(sessionsMatching('removable')).toEqual([SESSION_ID]) + expect(indexer?.status().phase).toBe('degraded') + } finally { + await chmod(harness.roots.claudeProjectsDir ?? '', 0o755) + } +}) + +// The first sweep of every process is exactly when a volume is most likely to +// be detached, and it is the pass with nothing behind it to compare against. +it('keeps a root that is gone at the first sweep after a restart', async () => { + await writeClaudeTranscript(transcriptPath(), ['a session on a removable volume'], SESSION_ID) + await newIndexer().start() + indexer?.close() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + + // The volume is not there when the process comes back. + await rm(harness.roots.claudeProjectsDir ?? '', { recursive: true, force: true }) + await newIndexer().start() + + const status = indexer?.status() + expect(status?.phase).toBe('degraded') + expect(status?.degradedRoots.map((root) => root.root)).toContain(harness.roots.claudeProjectsDir) + expect(sessionsMatching('removable')).toEqual([SESSION_ID]) + + // And it clears once the volume is back. + await writeClaudeTranscript(transcriptPath(), ['a session on a removable volume'], SESSION_ID) + await indexer?.reconcile({ full: true }) + expect(indexer?.status()).toMatchObject({ phase: 'current', degradedRoots: [] }) +}) + +// Round 7: what the stateless walk costs, stated rather than hidden. A volume +// mounted at EXACTLY a configured root, unmounted so the mountpoint stays +// present and lists empty, is indistinguishable from a root the user emptied: +// there is no directory left whose absence could stop the walk. Inside one +// process the transition buys a pass of grace; across a restart there is no +// transition to see and the rows retire. The unmounts that actually happen are +// above the root, and the next test is the one that covers them. +it('retires an emptied configured root, one pass after it emptied', async () => { + await writeClaudeTranscript(transcriptPath(), ['a session on the mounted volume'], SESSION_ID) + await newIndexer().start() + + // The transcripts go; the root itself stays there and stays readable. + await rm(harness.claudeProjectDir, { recursive: true, force: true }) + await indexer?.reconcile({ full: true }) + expect(sessionsMatching('mounted')).toEqual([SESSION_ID]) + expect(indexer?.status().phase).toBe('degraded') + + await indexer?.reconcile({ full: true }) + expect(sessionsMatching('mounted')).toEqual([]) + expect(indexer?.status()).toMatchObject({ phase: 'current', degradedRoots: [] }) +}) + +// The same root, with no previous pass to compare against: nothing carries the +// transition across a restart, and the empty listing is proof on its own. +it('retires an emptied configured root at once on the first pass of a process', async () => { + await writeClaudeTranscript(transcriptPath(), ['a session on the mounted volume'], SESSION_ID) + await newIndexer().start() + expect(sessionsMatching('mounted')).toEqual([SESSION_ID]) + indexer?.close() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + + await rm(harness.claudeProjectDir, { recursive: true, force: true }) + await newIndexer().start() + expect(sessionsMatching('mounted')).toEqual([]) +}) + +// The shape a real unmount takes: on Linux, WSL and sshfs the mountpoint is +// above the agent's root, so the root itself is missing. The walk stops at the +// root boundary and never asks the empty parent anything, which is what makes +// this hold with no memory on the first pass of a process. +it('proves nothing from an empty directory above the configured root', async () => { + for (let index = 0; index < 3; index++) { + const session = `0000000${index}-bbbb-4ccc-8ddd-eeeeeeeeeeee` + await writeClaudeTranscript(transcriptPath(session), [`mounted session ${index}`], session) + } + await newIndexer().start() + expect(sessionsMatching('mounted')).toHaveLength(3) + indexer?.close() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + + // The volume that carried the agent's root is gone; what it was mounted + // under is still there, still listable, and empty of it. + await rm(harness.roots.claudeProjectsDir ?? '', { recursive: true, force: true }) + await newIndexer().start() + + const status = indexer?.status() + expect(status?.phase).toBe('degraded') + expect(status?.degradedRoots.map((root) => root.root)).toContain(harness.roots.claudeProjectsDir) + expect(sessionsMatching('mounted')).toHaveLength(3) +}) + +// A cycle only reads the newest N per agent, so a remounted volume would give +// up its newest transcript and keep the rest unreachable. Nothing watches for a +// recovery any more: the sweep cadence is what reaches it. +it('reads a root that came back on the next periodic sweep', async () => { + // Detached before anything was ever indexed, so the sweep correctly finds + // nothing and reports no alarm. + await newIndexer({ recentPerAgent: 1, fullSweepEveryCycles: 2 }).start() + expect(indexer?.status()).toMatchObject({ degradedRoots: [], filesIndexed: 0 }) + + for (let index = 0; index < 3; index++) { + const session = `0000000${index}-bbbb-4ccc-8ddd-eeeeeeeeeeee` + await writeClaudeTranscript(transcriptPath(session), [`remounted session ${index}`], session) + } + + // Two cycles reach the newest one each; the sweep they are counting down to + // reads the rest. + await nextCycle() + await nextCycle() + expect(sessionsMatching('remounted')).toHaveLength(1) + + await nextCycle() + expect(sessionsMatching('remounted')).toHaveLength(3) +}) + +// Round 4, item 3: rows under no configured root. The walk judges each row on +// its own directory and proves nothing about one it cannot reach, so a profile +// that moved keeps its history rather than losing it. +it('keeps rows under no configured root, and retires them only when gone', async () => { + const moved = transcriptPath() + await writeClaudeTranscript(moved, ['a session in the old profile'], SESSION_ID) + await newIndexer().start() + indexer?.close() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + + // The profile moves: same index, a root that no longer covers those rows. + const elsewhere = join(harness.root, 'moved-profile') + newIndexer({ roots: { ...harness.roots, claudeProjectsDir: elsewhere } }) + await indexer?.start() + // Still on disk, so the rows stay: this is a configuration problem, not a + // licence to delete a user's history. + expect(sessionsMatching('profile')).toEqual([SESSION_ID]) + + await rm(moved) + await indexer?.reconcile({ full: true }) + expect(sessionsMatching('profile')).toEqual([]) +}) + +// Round 7 replaced "only a census may conclude" with "whoever can prove it". +// A cycle walks the same directories and reaches the same verdict, so a project +// directory the user deleted does not wait for the next sweep. +it('lets a cycle retire a project directory the user deleted', async () => { + await writeClaudeTranscript(transcriptPath(), ['a session about to vanish'], SESSION_ID) + await newIndexer().start() + + await rm(harness.claudeProjectDir, { recursive: true, force: true }) + // The pass that sees the root go from holding transcripts to holding none + // gives it one pass of grace, whether it is a sweep or a cycle. + await indexer?.reconcile({ full: true }) + expect(sessionsMatching('vanish')).toEqual([SESSION_ID]) + + await nextCycle() + expect(sessionsMatching('vanish')).toEqual([]) + expect(indexer?.status()).toMatchObject({ phase: 'current', degradedRoots: [] }) +}) + +// C1: `close()` disarmed the timer and aborted the task in flight, but left the +// queue running, so a task queued a moment earlier still reopened a store and +// registered a consumer behind an indexer whose caller had finished with it. +it('stops everything on close, including work already queued', async () => { + await writeClaudeTranscript(transcriptPath(), ['indexed before the close'], SESSION_ID) + await newIndexer().start() + + const queued = indexer?.reconcile({ full: true }) + indexer?.close() + await queued + + // The queued pass never ran: had it run, it would have reached for a store + // this close had already shut, and reported the failure. + expect(errors).toEqual([]) + // And the timer is gone with it, so no later tick can queue another. + expect(clock.pendingTimers).toBe(0) + clock.advance(5 * INTERVAL_MS) + await indexer?.settled() + expect(errors).toEqual([]) + + // No store and no consumer: a scan after the close writes nothing. + const after = transcriptPath(OTHER_SESSION_ID) + await writeClaudeTranscript(after, ['written after the close'], OTHER_SESSION_ID) + await parseTranscript(after) + expect(sessionsMatching('written')).toEqual([]) + expect(sessionsMatching('indexed')).toEqual([SESSION_ID]) +}) + +// What replaced `clear()`, exactly as the PR body documents it. The recipe is +// three statements because the indexer owns one store for one lifetime; the +// method it replaces owned a second one and had to keep the two in step. +it('throws the index away and rebuilds it by constructing a new instance', async () => { + await writeClaudeTranscript(transcriptPath(), ['indexed before the clear'], SESSION_ID) + await newIndexer().start() + expect(existsSync(harness.databasePath)).toBe(true) + + indexer?.close() + removeSessionSearchDatabase(harness.databasePath) + expect(existsSync(harness.databasePath)).toBe(false) + + // The session list's cache is warm, which is what a clear inside a running + // app leaves behind; the sweep reads whole rather than trusting it. + await newIndexer().start() + expect(sessionsMatching('indexed')).toEqual([SESSION_ID]) +}) + +it('refuses a reconcile before it is started and after it is closed', async () => { + newIndexer() + expect(() => indexer?.reconcile()).toThrow(/start\(\) first/) + + await indexer?.start() + await indexer?.reconcile() + indexer?.close() + expect(() => indexer?.reconcile()).toThrow(/closed/) +}) + +// I7: the sweep reads transcript bytes, so it stops at the same deadline every +// other pass does. It plans the whole machine and hands back what it had no +// time for; the passes that follow drain the plan without re-discovering. +it('stops the opening sweep at its deadline and drains the rest over the passes that follow', async () => { + for (let index = 0; index < 5; index++) { + const session = `0000000${index}-bbbb-4ccc-8ddd-eeeeeeeeeeee` + await writeClaudeTranscript(transcriptPath(session), [`backlogged session ${index}`], session) + } + await newIndexer(readsPerPass(2)).start() + expect(indexer?.status().filesIndexed).toBe(2) + + await nextCycle() + expect(indexer?.status().filesIndexed).toBe(4) + + await nextCycle() + expect(sessionsMatching('backlogged')).toHaveLength(5) + expect(indexer?.status()).toMatchObject({ filesIndexed: 5, filesDue: 0 }) +}) + +// The sweep cadence, with nobody asking for it: a file outside the recency +// window that appears after the opening sweep is unreachable until the next +// periodic one, and the count of cycles is the whole rule. +it('sweeps on its cadence without anyone asking', async () => { + await writeClaudeTranscript(transcriptPath(), ['the newest conversation'], SESSION_ID) + await newIndexer({ recentPerAgent: 1, fullSweepEveryCycles: 2 }).start() + + const older = transcriptPath(OTHER_SESSION_ID) + await writeClaudeTranscript(older, ['an older conversation'], OTHER_SESSION_ID) + const yesterday = new Date(Date.now() - 86_400_000) + await utimes(older, yesterday, yesterday) + + await nextCycle() + await nextCycle() + expect(sessionsMatching('older')).toEqual([]) + + await nextCycle() + expect(sessionsMatching('older')).toEqual([OTHER_SESSION_ID]) +}) + +// A cycle lists the newest N per agent, so every older row it holds is +// undiscovered and would be walked every twenty seconds. It proves the newest +// slice of them instead, capped: a transcript recent enough for the window is +// recent enough to be in the slice, and the rest are the next sweep's to reach. +// Round 12, F1. A directory that cannot be listed answers `unverifiable` for +// every row under it, on every pass, for as long as the permission stays wrong. +// With the walk capped at rows rather than at directories, five hundred such +// rows spent the whole budget on one readdir's worth of verdicts and a row for +// a file the user really deleted, sorted behind them, was never reached: six +// full sweeps and it was still held. +it.skipIf(!CAN_DENY_READ)('retires a deleted file behind a block of unreadable rows', async () => { + // A healthy project directory, so the root never looks emptied. + await writeClaudeTranscript(transcriptPath(), ['a live conversation'], SESSION_ID) + const locked = join(harness.roots.claudeProjectsDir ?? '', 'locked') + await mkdir(locked, { recursive: true }) + newIndexer() + + // What an unreadable tree leaves behind: rows the walk can never settle, + // planted ahead of the deleted one in the order the table returns them. + harness.write((db: SyncDatabase) => { + const insert = db.prepare( + `INSERT INTO files(path, byte_offset, mtime_ms, size_bytes, state) + VALUES (?, 0, ?, 10, 'current')` + ) + for (let index = 0; index < 520; index++) { + insert.run(join(locked, `locked-${index}.jsonl`), 1_700_000_000_000 + index) + } + return insert.run(join(harness.claudeProjectDir, 'deleted.jsonl'), 1_700_000_999_000) + }) + const deleted = join(harness.claudeProjectDir, 'deleted.jsonl') + const holdsDeleted = (): boolean => rowFor(deleted) !== undefined + + await chmod(locked, 0o000) + try { + await indexer?.start() + + expect(holdsDeleted()).toBe(false) + // And the block itself is neither retired nor forgotten: unreadable is not + // deleted, and the root is named as degraded rather than emptied. + expect(indexer?.status().filesIndexed).toBe(521) + expect(indexer?.status().phase).toBe('degraded') + } finally { + await chmod(locked, 0o700) + } +}) + +it('proves deletions for the newest rows it holds, and leaves the tail to a sweep', async () => { + const total = 530 + const oldest = transcriptPath('00000000-bbbb-4ccc-8ddd-eeeeeeeeeeee') + await writeClaudeTranscript( + oldest, + ['the oldest session'], + '00000000-bbbb-4ccc-8ddd-eeeeeeeeeeee' + ) + const longAgo = new Date(Date.now() - total * 60_000) + await utimes(oldest, longAgo, longAgo) + // Indexed on its own first, so it is the earliest row in the table as well as + // the oldest file. A slice that trusted the table's own order rather than the + // mtime would take it, and take it first. + await newIndexer().start() + + for (let index = 1; index < total; index++) { + const session = `0000${String(index).padStart(4, '0')}-bbbb-4ccc-8ddd-eeeeeeeeeeee` + const path = transcriptPath(session) + await writeClaudeTranscript(path, [`capped session ${index}`], session) + const at = new Date(Date.now() - (total - index) * 60_000) + await utimes(path, at, at) + } + await indexer?.reconcile({ full: true }) + expect(indexedSessionCount()).toBe(total) + + // Older than the cap reaches: 530 rows, twelve of them rediscovered by the + // cycle, leaves 518 undiscovered against a cap of 512. + await rm(oldest) + await nextCycle() + expect(indexedSessionCount()).toBe(total) + + await indexer?.reconcile({ full: true }) + expect(indexedSessionCount()).toBe(total - 1) +}) + +// F1: `fullSweepDue` stayed set across the sweep's await and was cleared on the +// way out, so a request raised while a sweep was running was erased by the +// sweep it arrived during. The pass takes the flag on entry now, and an +// unfinished sweep is what puts it back. +it('runs another sweep when one is asked for during a sweep', async () => { + for (let index = 0; index < 20; index++) { + const session = `0000${String(index).padStart(4, '0')}-bbbb-4ccc-8ddd-eeeeeeeeeeee` + await writeClaudeTranscript(transcriptPath(session), [`recent session ${index}`], session) + } + const late = transcriptPath(OTHER_SESSION_ID) + + // Newest-one per root, so nothing but a second sweep can reach a file that + // appears after this sweep's discovery has already run. The clock is the one + // synchronous seam into a pass: it is read between files. + newIndexer({ recentPerAgent: 1 }) + let armed = false + // Once a row has landed the pass is provably inside its read loop, which is + // after it took the sweep flag and before it hands its verdicts back. + clock.onNow = () => { + if (armed || indexedSessionCount() === 0) { + return + } + armed = true + mkdirSync(dirname(late), { recursive: true }) + writeFileSync(late, `${claudeLines(['a late conversation'], OTHER_SESSION_ID, 0).join('\n')}\n`) + const backdated = new Date(Date.now() - 86_400_000) + utimesSync(late, backdated, backdated) + void indexer?.reconcile({ full: true }) + } + await indexer?.start() + await indexer?.settled() + + expect(sessionsMatching('late')).toEqual([OTHER_SESSION_ID]) +}) + +// The duty cycle, as a test: a pass reads for at most its deadline and hands +// the rest back, and the timer only re-arms once the pass has settled, so the +// share of the wall clock the index takes is bounded by construction. +it('hands the rest of a pass back when it runs out of wall time', async () => { + for (let index = 0; index < 20; index++) { + const session = `0000${String(index).padStart(4, '0')}-bbbb-4ccc-8ddd-eeeeeeeeeeee` + await writeClaudeTranscript(transcriptPath(session), [`deadlined session ${index}`], session) + } + await newIndexer(readsPerPass(16)).start() + + expect(indexer?.status().filesIndexed).toBe(16) + + // And the pass after it picks up exactly the four it did not reach. + await nextCycle() + expect(indexer?.status()).toMatchObject({ filesIndexed: 20, filesDue: 0 }) +}) diff --git a/src/main/ai-vault-search/session-search-indexer.ts b/src/main/ai-vault-search/session-search-indexer.ts new file mode 100644 index 00000000000..26213242c49 --- /dev/null +++ b/src/main/ai-vault-search/session-search-indexer.ts @@ -0,0 +1,325 @@ +import { systemSessionSearchClock, type SessionSearchClock } from './session-search-clock' +import { + DEFAULT_SESSION_SEARCH_FULL_SWEEP_EVERY_CYCLES, + DEFAULT_SESSION_SEARCH_PASS_DEADLINE_FRACTION, + DEFAULT_SESSION_SEARCH_RECENT_PER_AGENT, + DEFAULT_SESSION_SEARCH_RECONCILE_INTERVAL_MS, + type SessionSearchIndexerOptions +} from './session-search-indexer-options' +import { SessionSearchDirectoryListings } from './session-search-directory-listings' +import { registerSessionSearchIndexConsumer } from './session-search-index-consumer' +import { runSessionSearchPass } from './session-search-pass' +import { sessionSearchHistoryCutoffMs } from './session-search-retention-policy' +import { SessionSearchStore, type SessionSearchStateCounts } from './session-search-store' +import type { SessionSearchDegradedRoot } from './session-search-degraded-roots' +import { SessionSearchWorkLoop } from './session-search-work-loop' + +/** + * Database paths a live indexer already owns. + * + * One process, one writer, one consumer registration per index. Two indexers on + * one path both register with the reader, so every transcript is read and + * written twice and the second write is fenced by the first at random. The + * recipe for every configuration change is close-then-construct, so the + * ordering that causes this is the one the recipe already rules out; this is + * what says so rather than letting it corrupt quietly. + */ +const liveIndexerPaths = new Set() + +export type SessionSearchIndexPhase = 'idle' | 'indexing' | 'current' | 'degraded' | 'closed' + +export type SessionSearchIndexStatus = { + phase: SessionSearchIndexPhase + /** Rows whose content matches the file at the stat the row records. */ + filesIndexed: number + /** Rows owed a whole read: a declined append, or a window that widened. */ + filesDue: number + /** Rows whose last read did not commit. */ + filesFailed: number + degradedRoots: SessionSearchDegradedRoot[] + lastReconcileAt: number | null + /** When a whole-machine sweep last finished; null until one has. */ + lastSweepCompletedAt: number | null +} + +/** + * Owns freshness for the index store: a whole-machine sweep, then a timer that + * keeps the newest N transcripts per agent reconciled and sweeps again every + * `fullSweepEveryCycles`. + * + * A library, not a service. It knows nothing about Electron, the app lifecycle, + * settings storage, IPC or the panel, and nothing here reads a setting or + * registers itself anywhere. Whoever constructs it decides all of that. + * + * **The store is the only memory.** Every question a pass asks between passes — + * what is owed a read, what has failed and how often, what the index holds and + * therefore what may have been deleted, what to report — is answered by a row + * in the `files` table. There is no queue, no watch set, no hold-out map and no + * counter with a reset rule. + * + * What is left here, and why none of it can be a row: + * - `previousRootsWithFiles`, the one bit per root the retirement walk's grace + * needs. Deliberately not durable: see the mountpoint trade in + * `session-search-deleted-sources.ts`. + * - `cyclesSinceSweep` and `sweepNext`, which are about the timer rather than + * about any file, and mean nothing to a second process. + * - `degradedRoots`, `lastReconcileAt` and `lastSweepCompletedAt`: what the last + * pass observed, held so `status()` can answer between passes. + * - `lastCounts`, the one cached query result, read only after `close()` so that + * describing what happened does not reopen a handle the owner has finished + * with. While the indexer is open every call re-queries. + * + * **Immutable after construction.** There is no `pause`, `resume`, `clear` or + * `setHistoryDays`. A configuration change is `close()` and a new instance; + * throwing the index away is + * `close(); removeSessionSearchDatabase(databasePath);` and a new instance. + * Widening retention is a new instance whose opening sweep admits the older + * files; narrowing is the purge that opens every full sweep. + * + * The guarantee it makes: while started, a transcript among the newest N per + * agent that grows, is replaced or is deleted is reflected in the index within + * one reconcile interval. Everything else is reached by the periodic sweep. + */ +export class SessionSearchIndexer { + private readonly ownershipPath: string + private readonly clock: SessionSearchClock + private readonly intervalMs: number + private readonly passDeadlineMs: number + private readonly recentPerAgent: number + private readonly fullSweepEveryCycles: number + private readonly onError: (error: unknown) => void + + private readonly loop: SessionSearchWorkLoop + private readonly store: SessionSearchStore + private readonly unregister: () => void + /** Null until a pass has recorded one; an empty set is a real observation. */ + private previousRootsWithFiles: ReadonlySet | null = null + private degradedRoots: SessionSearchDegradedRoot[] = [] + private lastReconcileAt: number | null = null + private lastSweepCompletedAt: number | null = null + private lastCounts: SessionSearchStateCounts | null = null + private cyclesSinceSweep = 0 + private sweepNext = false + private started = false + private closed = false + + constructor(private readonly options: SessionSearchIndexerOptions) { + this.ownershipPath = resolve(options.databasePath) + this.clock = options.clock ?? systemSessionSearchClock + this.intervalMs = options.reconcileIntervalMs ?? DEFAULT_SESSION_SEARCH_RECONCILE_INTERVAL_MS + this.passDeadlineMs = + options.passDeadlineMs ?? + Math.max(1, Math.floor(this.intervalMs / DEFAULT_SESSION_SEARCH_PASS_DEADLINE_FRACTION)) + this.recentPerAgent = options.recentPerAgent ?? DEFAULT_SESSION_SEARCH_RECENT_PER_AGENT + this.fullSweepEveryCycles = Math.max( + 1, + options.fullSweepEveryCycles ?? DEFAULT_SESSION_SEARCH_FULL_SWEEP_EVERY_CYCLES + ) + const onError = options.onError ?? ((error) => console.warn('[ai-vault-search]', error)) + this.onError = onError + this.loop = new SessionSearchWorkLoop({ + clock: this.clock, + intervalMs: this.intervalMs, + onFailure: onError + }) + if (liveIndexerPaths.has(this.ownershipPath)) { + throw new Error( + `SessionSearchIndexer: ${options.databasePath} already has a live indexer; close it first` + ) + } + // Store, registration and indexer share one lifetime, which is what makes + // the object immutable: there is no second open to get out of step with. + // Claimed only once the store is open, because a construction that throws + // has no `close()` to release the claim: registering first would leave the + // path owned by an object that does not exist, and every later attempt at + // it -- including the one that fixes whatever broke the open -- would be + // refused for the life of the process. + this.store = new SessionSearchStore(options.databasePath, onError) + liveIndexerPaths.add(this.ownershipPath) + this.store.setRetentionCutoffMs(this.cutoffMs()) + this.unregister = registerSessionSearchIndexConsumer(this.store) + } + + /** Runs a full sweep, then reconciles on the interval until closed. */ + start(): Promise { + if (this.closed || this.started) { + return this.loop.settled + } + this.started = true + this.sweepNext = true + return this.tick() + } + + /** + * Runs one pass now, off the timer. A full pass sweeps every root. + * + * Refused before `start()` and after `close()`: a pass against an indexer + * nobody started writes the index once and leaves it to go stale with no + * timer armed to notice the next change, and a pass against a closed one has + * no store to write to. Both are caller bugs, so both throw rather than + * resolving as though a pass had run. + */ + reconcile(options: { full?: boolean } = {}): Promise { + if (this.closed) { + throw new Error('SessionSearchIndexer.reconcile: the indexer is closed') + } + if (!this.started) { + throw new Error('SessionSearchIndexer.reconcile: start() first') + } + this.sweepNext ||= options.full === true + return this.tick() + } + + /** + * What the index holds, read from the rows rather than tallied. + * + * A second connection can compute every number here with one `GROUP BY`, + * which is the point: nothing is counted as it happens, so nothing can drift + * from what the database actually holds or need a rule about when to reset. + */ + status(): SessionSearchIndexStatus { + // A closed indexer reports what it last knew: opening a shut handle to + // answer a call whose whole job is to describe what happened is how a close + // came to report a database error to the owner who asked for it. + const settled = (this.closed ? this.lastCounts : this.readCounts()) ?? { + current: 0, + due: 0, + failed: 0 + } + return { + phase: this.phase(settled), + filesIndexed: settled.current, + filesDue: settled.due, + filesFailed: settled.failed, + degradedRoots: this.degradedRoots.map((root) => ({ ...root })), + lastReconcileAt: this.lastReconcileAt, + lastSweepCompletedAt: this.lastSweepCompletedAt + } + } + + /** Stops everything. Nothing queued before this call may run afterwards. */ + close(): void { + if (this.closed) { + return + } + // Read before the handle goes, so a status call afterwards reports what the + // index last held rather than opening a database its owner has finished with. + this.lastCounts = this.readCounts() ?? this.lastCounts + this.closed = true + // The loop, not just its timer: a task queued before this call would + // otherwise still run against a store this line is about to close. + this.loop.close() + this.unregister() + this.store.close() + liveIndexerPaths.delete(this.ownershipPath) + } + + /** Tests only: everything else drives this through the timer. */ + settled(): Promise { + return this.loop.settled + } + + private readCounts(): SessionSearchStateCounts | null { + try { + const counts = this.store.stateCounts() + this.lastCounts = counts + return counts + } catch (error) { + this.onError(error) + return this.lastCounts + } + } + + /** + * `current` is a claim, so it takes all three: no row owed a read, no row + * whose last read failed, and a whole sweep that finished. `idle` is the + * other end of it — an indexer nobody started has not promised to index + * anything, and calling that `current` would claim an index nobody built is + * up to date. + */ + private phase(counts: SessionSearchStateCounts): SessionSearchIndexPhase { + if (this.closed) { + return 'closed' + } + if (!this.started) { + return 'idle' + } + // A root the pass could not read, or a file it could not read: both are gaps + // the index knows about and cannot close on its own. + if (this.degradedRoots.length > 0 || counts.failed > 0) { + return 'degraded' + } + return counts.due === 0 && this.lastSweepCompletedAt !== null ? 'current' : 'indexing' + } + + private tick(): Promise { + return this.loop.queue( + (signal) => this.pass(signal), + () => void this.tick() + ) + } + + private async pass(signal: AbortSignal): Promise { + // The window moves with the clock, and the decide step reads it from the + // store. Setting it once at construction leaves a sweep purging rows that + // the very next candidate check happily re-indexes. + this.store.setRetentionCutoffMs(this.cutoffMs()) + // The one bound on a pass: wall time. What it does not reach is still owed, + // because a row says so and nothing had to be written down. + const startedAt = this.clock.now() + const full = this.sweepNext + // Taken on entry, not cleared on the way out: a `reconcile({ full: true })` + // raised while this pass is running sets it again, and clearing it at the + // end would erase that request along with this pass's own. + this.sweepNext = false + try { + const result = await runSessionSearchPass({ + store: this.store, + roots: this.options.roots, + full, + recentPerAgent: this.recentPerAgent, + previousRootsWithFiles: this.previousRootsWithFiles ?? undefined, + overdue: () => this.clock.now() - startedAt >= this.passDeadlineMs, + // One readdir per directory for the whole pass, shared by every step. + listings: new SessionSearchDirectoryListings(), + signal + }) + if (!result.completed) { + // A pass cut short learned nothing about root health, and publishing its + // empty findings would clear a live alarm. A sweep stays owed. + this.sweepNext ||= full + return + } + this.degradedRoots = result.degradedRoots + this.previousRootsWithFiles = result.rootsWithFiles + this.lastReconcileAt = this.clock.now() + // A backlog outside the recency window is only visible to a sweep, so a + // pass that ran out of time asks for one. It is self-limiting: the first + // pass that finishes its reads hands the interval back to cycles. + this.sweepNext ||= result.outOfTime + if (full) { + this.lastSweepCompletedAt = this.lastReconcileAt + this.cyclesSinceSweep = 0 + return + } + // A root that came back, a tree restored from a backup, an old transcript + // deleted: only a sweep sees any of it, and the count of cycles is the + // whole rule for when one is owed. + this.cyclesSinceSweep += 1 + if (this.cyclesSinceSweep >= this.fullSweepEveryCycles) { + this.sweepNext = true + } + } catch (error) { + // The flag is this method's to hold, so it is this method's to give back: + // a pass that threw part way learned nothing, and losing it here would + // leave nothing armed to try again. + this.sweepNext ||= full + throw error + } + } + + private cutoffMs(): number | null { + return sessionSearchHistoryCutoffMs(this.options.historyDays, this.clock.now()) + } +} +import { resolve } from 'node:path' diff --git a/src/main/ai-vault-search/session-search-lifecycle-matrix.test.ts b/src/main/ai-vault-search/session-search-lifecycle-matrix.test.ts new file mode 100644 index 00000000000..79cbe70eb7f --- /dev/null +++ b/src/main/ai-vault-search/session-search-lifecycle-matrix.test.ts @@ -0,0 +1,378 @@ +import { chmod, mkdir, rename, rm } from 'node:fs/promises' +import { delimiter, dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache' +import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers' +import type SyncDatabase from '../sqlite/sync-database' +import { SessionSearchIndexer } from './session-search-indexer' +import type { SessionSearchIndexerOptions } from './session-search-indexer-options' +import { removeSessionSearchDatabase } from './session-search-schema' +import { + FakeSessionSearchClock, + openSessionSearchIndexerHarness, + writeClaudeTranscript, + writeMessageGraphTranscript, + type SessionSearchIndexerHarness +} from './session-search-indexer-test-fixture' + +/* + * The lifecycle matrix: every operation a caller can perform, against every + * shape an unreachable root takes, against both ways discovery reports a root. + * + * The indexer is immutable, so "every operation" is a shorter list than it was: + * `pause`, `resume`, `clear`, `setHistoryDays` and `invalidate` are gone, and + * the two of them a caller still needs — a settings change and throwing the + * index away — are here as what replaced them, a new instance over the same + * path. In their place are the two passes the immutable design added: the + * periodic sweep, and a pass whose wall-clock deadline expires on its first file. + * + * What each cell asserts: + * A. No row is retired for a file that still exists. Throwing the index away + * is the one exception, and it is stated per operation rather than excused. + * B. The unreachable root is named in `degradedRoots`, by a real directory + * path — never the delimiter-joined label a merged discovery reports. + * C. The phase is never `current` while a root is degraded. + * D. Once the root is reachable again, a sweep indexes everything under it. + * + * Round 6 ran this as a throwaway harness on the previous design; it lives in + * the repository now. Two of its shapes changed with the stateless walk. The + * "present but empty mountpoint" shape is gone, because a readable root that + * lists nothing is no longer treated as unreachable — that is a root the user + * emptied, and `session-search-deleted-sources.ts` states the trade. In its + * place is a root whose transcripts sit behind an unreadable subdirectory, + * which is the partial-tree case the old shape never covered. + */ + +const CAN_DENY_READ = process.platform !== 'win32' && process.getuid?.() !== 0 +const INTERVAL_MS = 20_000 +const SESSIONS = ['aaaaaaaa', 'bbbbbbbb', 'cccccccc'] + +type RootShape = { + name: string + /** Where the unreachable root's transcripts live, and where its files go. */ + detachedRoot: (harness: SessionSearchIndexerHarness) => string + detachedFile: (harness: SessionSearchIndexerHarness, session: string) => string + writeDetached: (path: string, session: string) => Promise + healthyFile: (harness: SessionSearchIndexerHarness, session: string) => string + writeHealthy: (path: string, session: string) => Promise +} + +const OPENCLAW_SESSION_DIR = join('agents', 'main', 'sessions') + +const ROOT_SHAPES: RootShape[] = [ + { + name: 'roots discovery reports one per directory', + detachedRoot: (harness) => harness.roots.claudeProjectsDir ?? '', + detachedFile: (harness, session) => join(harness.claudeProjectDir, `${session}.jsonl`), + writeDetached: (path, session) => + writeClaudeTranscript(path, [`detached ${session}`], fullSessionId(session)), + healthyFile: (harness, session) => join(harness.roots.piSessionsDir ?? '', `${session}.jsonl`), + writeHealthy: (path, session) => writeMessageGraphTranscript(path, [`healthy ${session}`]) + }, + { + name: 'roots a merged discovery joins into one label', + detachedRoot: (harness) => join(harness.roots.openclawStateDir ?? '', 'agents'), + detachedFile: (harness, session) => + join(harness.roots.openclawStateDir ?? '', OPENCLAW_SESSION_DIR, `${session}.jsonl`), + writeDetached: (path, session) => writeMessageGraphTranscript(path, [`detached ${session}`]), + healthyFile: (harness, session) => + join(harness.roots.openclawLegacyStateDir ?? '', OPENCLAW_SESSION_DIR, `${session}.jsonl`), + writeHealthy: (path, session) => writeMessageGraphTranscript(path, [`healthy ${session}`]) + } +] + +type UnreachableShape = { + name: string + needsDeniedRead: boolean + /** + * Whether an empty index can see this at all. Reading the root itself is the + * one probe a pass makes with no rows to go on: a root that answers ENOENT is + * what an uninstalled agent answers too, and a readable root with an + * unreadable subdirectory is swallowed by the file walker, which returns + * rather than reporting. Both are invisible until the index holds a row under + * the root, which is the evidence the retirement walk runs on. + */ + visibleWithNoRows: boolean + detach: (root: string, transcriptDir: string, parked: string) => Promise + attach: (root: string, transcriptDir: string, parked: string) => Promise +} + +const UNREACHABLE_SHAPES: UnreachableShape[] = [ + { + name: 'the root itself is not there', + needsDeniedRead: false, + visibleWithNoRows: false, + detach: (root, _transcriptDir, parked) => rename(root, parked), + attach: (root, _transcriptDir, parked) => rename(parked, root) + }, + { + name: 'the root refuses to list', + needsDeniedRead: true, + visibleWithNoRows: true, + detach: (root) => chmod(root, 0o000), + attach: (root) => chmod(root, 0o755) + }, + { + name: 'the transcripts sit behind a directory that refuses to list', + needsDeniedRead: true, + visibleWithNoRows: false, + detach: (_root, transcriptDir) => chmod(transcriptDir, 0o000), + attach: (_root, transcriptDir) => chmod(transcriptDir, 0o755) + } +] + +type Operation = { + name: string + /** True when the operation throws the index away, so no row survives it. */ + clearsIndex?: boolean + /** Healthy-root sessions the operation deletes from disk. */ + deletes?: readonly string[] + /** Construction options for every indexer this cell opens. */ + options?: Partial + run: (context: MatrixContext) => Promise +} + +const OPERATIONS: Operation[] = [ + { name: 'one cycle', run: (context) => context.cycle() }, + { + name: 'two cycles', + run: async (context) => { + await context.cycle() + await context.cycle() + } + }, + { + name: 'close and restart', + run: (context) => context.reopen() + }, + { + name: 'two full reconciles', + run: async (context) => { + await context.indexer().reconcile({ full: true }) + await context.indexer().reconcile({ full: true }) + } + }, + { + name: 'one healthy transcript deleted', + deletes: SESSIONS.slice(0, 1), + run: (context) => context.cycle() + }, + { + name: 'every healthy transcript deleted', + deletes: SESSIONS, + run: async (context) => { + // Twice: a root that goes from holding transcripts to holding none in one + // pass is unverifiable for that pass, so the second is the proving one. + await context.indexer().reconcile({ full: true }) + await context.indexer().reconcile({ full: true }) + } + }, + { + // The cadence that replaced every re-arm-on-recovery rule: no caller asks + // for this sweep, so the cell drives it off the timer alone. + name: 'the periodic sweep comes round', + options: { fullSweepEveryCycles: 2 }, + run: async (context) => { + await context.cycle() + await context.cycle() + await context.cycle() + } + }, + { + // Every pass is out of wall time from its first file, so each one hands + // almost all of its work back. A pass that read almost nothing must still + // not conclude anything about what it did not reach. + name: 'every pass out of time at its first file', + options: { passDeadlineMs: 0 }, + run: async (context) => { + await context.cycle() + await context.cycle() + } + }, + { + // What replaced `setHistoryDays`: a new instance over the same database. + // Every transcript here was written just now, so a 30-day window holds all + // of them and no row may be purged. + name: 'reconstructed for a narrower history window', + run: (context) => context.reopen({ historyDays: 30 }) + }, + { + // What replaced `clear()`, exactly as the PR body documents it. + name: 'the index thrown away and rebuilt', + clearsIndex: true, + run: (context) => context.reopen({ removeDatabase: true }) + } +] + +type MatrixContext = { + indexer: () => SessionSearchIndexer + /** Closes and constructs again over the same path: the immutable design's one edit. */ + reopen: (args?: { historyDays?: number | null; removeDatabase?: boolean }) => Promise + cycle: () => Promise + detachedRoot: string + detachedPaths: string[] +} + +function fullSessionId(prefix: string): string { + return `${prefix}-bbbb-4ccc-8ddd-eeeeeeeeeeee` +} + +let harness: SessionSearchIndexerHarness +let clock: FakeSessionSearchClock +let indexer: SessionSearchIndexer | null + +beforeEach(async () => { + resetSessionParseCacheForTests() + resetTranscriptConsumersForTests() + clock = new FakeSessionSearchClock() + harness = await openSessionSearchIndexerHarness('ss-lifecycle') + indexer = null +}) + +afterEach(async () => { + indexer?.close() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + await harness.cleanup() +}) + +function open(overrides: Partial = {}): SessionSearchIndexer { + indexer = new SessionSearchIndexer({ + databasePath: harness.databasePath, + roots: harness.roots, + historyDays: null, + clock, + reconcileIntervalMs: INTERVAL_MS, + ...overrides + }) + return indexer +} + +/** + * Runs cycles until the index stops growing. Every operation but the + * out-of-time one settles on the first call; that one reads a transcript a pass. + */ +async function driveUntilIndexed(maxCycles: number): Promise { + let held = indexedSessions().length + for (let cycle = 0; cycle < maxCycles; cycle++) { + clock.advance(INTERVAL_MS) + await indexer?.settled() + const now = indexedSessions().length + if (now === held) { + return + } + held = now + } +} + +/** Session ids the index answers for, whichever agent wrote them. */ +function indexedSessions(): string[] { + return harness + .read( + (db: SyncDatabase) => + db.prepare('SELECT session_id AS id FROM sessions').all() as { id: string }[] + ) + .map((row) => row.id) + .sort() +} + +for (const roots of ROOT_SHAPES) { + for (const unreachable of UNREACHABLE_SHAPES) { + describe.skipIf(unreachable.needsDeniedRead && !CAN_DENY_READ)( + `${roots.name}, ${unreachable.name}`, + () => { + for (const operation of OPERATIONS) { + it(operation.name, async () => { + const detachedRoot = roots.detachedRoot(harness) + const detachedPaths = SESSIONS.map((session) => roots.detachedFile(harness, session)) + const healthyPaths = SESSIONS.map((session) => roots.healthyFile(harness, session)) + for (const [index, session] of SESSIONS.entries()) { + await roots.writeDetached(detachedPaths[index] ?? '', session) + await roots.writeHealthy(healthyPaths[index] ?? '', session) + } + const transcriptDir = dirname(detachedPaths[0] ?? '') + const parked = join(harness.root, 'parked-root') + + await open(operation.options).start() + // A deadline that expires on the first file reads one transcript a + // pass, so the setup drives passes until the index has caught up. + await driveUntilIndexed(SESSIONS.length * 2) + const detachedIds = detachedPaths.map((_path, index) => + roots === ROOT_SHAPES[0] + ? fullSessionId(SESSIONS[index] ?? '') + : (SESSIONS[index] ?? '') + ) + const healthyIds = SESSIONS.map((session) => session) + expect(indexedSessions()).toEqual([...detachedIds, ...healthyIds].sort()) + // One cycle so the watch set holds the recency window, which is the + // state a running indexer is in when a volume goes away. + clock.advance(INTERVAL_MS) + await indexer?.settled() + + await unreachable.detach(detachedRoot, transcriptDir, parked) + try { + const kept = SESSIONS.filter((session) => !operation.deletes?.includes(session)) + for (const session of operation.deletes ?? []) { + await rm(healthyPaths[SESSIONS.indexOf(session)] ?? '') + } + await operation.run({ + indexer: () => indexer as SessionSearchIndexer, + reopen: async (args = {}) => { + indexer?.close() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + if (args.removeDatabase === true) { + removeSessionSearchDatabase(harness.databasePath) + } + const overrides = { ...operation.options } + if ('historyDays' in args) { + overrides.historyDays = args.historyDays + } + await open(overrides).start() + await driveUntilIndexed(SESSIONS.length * 2) + }, + cycle: async () => { + clock.advance(INTERVAL_MS) + await indexer?.settled() + }, + detachedRoot, + detachedPaths + }) + + // A: nothing that still exists lost its rows. + const survivingDetached = operation.clearsIndex ? [] : detachedIds + expect(indexedSessions()).toEqual([...survivingDetached, ...kept].sort()) + + const status = indexer?.status() + const degraded = status?.degradedRoots.map((root) => root.root) ?? [] + // With no rows under it, the only thing a pass can go on is + // whether the root itself refuses to list. + if (operation.clearsIndex && !unreachable.visibleWithNoRows) { + expect(degraded).not.toContain(detachedRoot) + } else { + // B: named, by a real directory rather than a joined label. + expect(degraded).toContain(detachedRoot) + expect(degraded.every((root) => !root.includes(delimiter))).toBe(true) + // C: not current while a root is degraded. + expect(status?.phase).not.toBe('current') + } + } finally { + await unreachable.attach(detachedRoot, transcriptDir, parked) + } + + // D: reachable again, a sweep reads the whole tree back. + await mkdir(dirname(healthyPaths[0] ?? ''), { recursive: true }) + await indexer?.reconcile({ full: true }) + await driveUntilIndexed(SESSIONS.length * 2) + expect(indexedSessions()).toEqual( + [ + ...detachedIds, + ...SESSIONS.filter((session) => !operation.deletes?.includes(session)) + ].sort() + ) + }) + } + } + ) + } +} diff --git a/src/main/ai-vault-search/session-search-live-transcript.test.ts b/src/main/ai-vault-search/session-search-live-transcript.test.ts index 7f37ca662cb..1ea58ca5283 100644 --- a/src/main/ai-vault-search/session-search-live-transcript.test.ts +++ b/src/main/ai-vault-search/session-search-live-transcript.test.ts @@ -193,16 +193,16 @@ it('indexes a file the session list already read past, once a whole read is aske // The append continued from a byte offset the index never saw, so it declined. expect(sessionsMatching('zygomorphic')).toEqual([]) - const behind = store.takeStale() - expect(behind.map((candidate) => candidate.file.path)).toEqual([path]) - for (const candidate of behind) { - requestWholeTranscriptRead(candidate.file.path) - } + // The index holds no row for this file at all, and that is the record: a + // path the file table does not name is read from the start by the next pass, + // which is what asks the reader to drop the session list's resume point. + expect(store.files()).toEqual([]) + requestWholeTranscriptRead(path) const reread = await parseTranscript(path) expect(reread.stats).toMatchObject({ incremental: 0, fullParses: 1 }) expect(errors).toEqual([]) expect(sessionsMatching('zygomorphic')).toEqual([SESSION_ID]) expect(sessionsMatching('opening')).toEqual([SESSION_ID]) - expect(store.takeStale()).toEqual([]) + expect(store.files().map((row) => row.state)).toEqual(['current']) }) diff --git a/src/main/ai-vault-search/session-search-merged-roots.test.ts b/src/main/ai-vault-search/session-search-merged-roots.test.ts new file mode 100644 index 00000000000..8d41b047cae --- /dev/null +++ b/src/main/ai-vault-search/session-search-merged-roots.test.ts @@ -0,0 +1,135 @@ +import { chmod, rm } from 'node:fs/promises' +import { delimiter, join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache' +import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers' +import type SyncDatabase from '../sqlite/sync-database' +import { SessionSearchIndexer } from './session-search-indexer' +import { + FakeSessionSearchClock, + openSessionSearchIndexerHarness, + writeMessageGraphTranscript, + type SessionSearchIndexerHarness +} from './session-search-indexer-test-fixture' + +// OpenClaw is the one agent whose roots are alternates for a single install, so +// discovery reports them as ONE discovery whose rootDir is every path joined by +// the platform's path delimiter. That string is not a directory: readdir on it +// answers ENOENT, containment never matches a real file, and a scan issue +// recorded against a real root never compares equal to it. Everything that +// judges a root works on the constituent directories, taken from the same +// source table discovery reads, never by splitting the label -- a directory may +// legally contain the delimiter. + +const CAN_DENY_READ = process.platform !== 'win32' && process.getuid?.() !== 0 +const INTERVAL_MS = 20_000 + +let harness: SessionSearchIndexerHarness +let clock: FakeSessionSearchClock +let indexer: SessionSearchIndexer + +beforeEach(async () => { + resetSessionParseCacheForTests() + resetTranscriptConsumersForTests() + clock = new FakeSessionSearchClock() + harness = await openSessionSearchIndexerHarness('ss-merged-roots') +}) + +afterEach(async () => { + indexer.close() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + await harness.cleanup() +}) + +/** OpenClaw reads `/agents/**` and keeps only paths through `sessions`. */ +function openclawTranscript(stateDir: string, name: string): string { + return join(stateDir, 'agents', 'main', 'sessions', `${name}.jsonl`) +} + +function sessionsMatching(term: string): string[] { + return harness.read((db: SyncDatabase) => + ( + db + .prepare( + `SELECT DISTINCT s.session_id AS 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 ? ORDER BY s.session_id` + ) + .all(term) as { id: string }[] + ).map((row) => row.id) + ) +} + +it.skipIf(!CAN_DENY_READ)('fences one merged root without taking its partner down', async () => { + const current = harness.roots.openclawStateDir ?? '' + const legacy = harness.roots.openclawLegacyStateDir ?? '' + const mounted = openclawTranscript(current, 'mounted-session') + const local = openclawTranscript(legacy, 'local-session') + await writeMessageGraphTranscript(mounted, ['a conversation on the mounted volume']) + await writeMessageGraphTranscript(local, ['a conversation on local disk']) + + indexer = new SessionSearchIndexer({ + databasePath: harness.databasePath, + roots: harness.roots, + historyDays: null, + clock, + reconcileIntervalMs: INTERVAL_MS + }) + await indexer.start() + expect(sessionsMatching('conversation').sort()).toEqual(['local-session', 'mounted-session']) + + // One of the two roots goes away; the other is untouched. + await chmod(join(current, 'agents'), 0o000) + try { + await indexer.reconcile({ full: true }) + + const status = indexer.status() + const degraded = status.degradedRoots.map((root) => root.root) + // A real directory, not the joined string discovery reports. + expect(degraded).toContain(join(current, 'agents')) + expect(degraded.every((root) => !root.includes(delimiter))).toBe(true) + // Unprovable, so the unreadable root keeps its rows. + expect(sessionsMatching('mounted')).toEqual(['mounted-session']) + } finally { + await chmod(join(current, 'agents'), 0o755) + } +}) + +it('retires from one merged root while its partner is healthy', async () => { + const current = harness.roots.openclawStateDir ?? '' + const legacy = harness.roots.openclawLegacyStateDir ?? '' + const going = openclawTranscript(current, 'going-session') + await writeMessageGraphTranscript(going, ['a conversation about to be deleted']) + // A sibling in the same root, so deleting one leaves the root listing files + // and therefore healthy: this is a deletion, not an unmount. + await writeMessageGraphTranscript(openclawTranscript(current, 'sibling-session'), [ + 'a conversation beside it' + ]) + await writeMessageGraphTranscript(openclawTranscript(legacy, 'staying-session'), [ + 'a conversation that stays' + ]) + + indexer = new SessionSearchIndexer({ + databasePath: harness.databasePath, + roots: harness.roots, + historyDays: null, + clock, + reconcileIntervalMs: INTERVAL_MS + }) + await indexer.start() + expect(sessionsMatching('conversation').sort()).toEqual([ + 'going-session', + 'sibling-session', + 'staying-session' + ]) + + // A genuine deletion inside a healthy root still retires normally. + await rm(going) + await indexer.reconcile({ full: true }) + + expect(sessionsMatching('deleted')).toEqual([]) + expect(indexer.status().degradedRoots).toEqual([]) + expect(sessionsMatching('conversation').sort()).toEqual(['sibling-session', 'staying-session']) +}) diff --git a/src/main/ai-vault-search/session-search-native-chat-indexing.test.ts b/src/main/ai-vault-search/session-search-native-chat-indexing.test.ts new file mode 100644 index 00000000000..deab4e8b785 --- /dev/null +++ b/src/main/ai-vault-search/session-search-native-chat-indexing.test.ts @@ -0,0 +1,113 @@ +import { appendFile, mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache' +import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers' +import type SyncDatabase from '../sqlite/sync-database' +import { SessionSearchIndexer } from './session-search-indexer' +import { + FakeSessionSearchClock, + openSessionSearchIndexerHarness, + type SessionSearchIndexerHarness +} from './session-search-indexer-test-fixture' + +// Reviewer F4, and the plan's fourth open decision: a conversation held in +// Orca's own chat is the same file in the same place as one held in the +// terminal, so it must be searchable through the same path with no panel +// mounted, no scanner service running, and nobody calling refresh. Everything +// below is the library and the filesystem. + +const INTERVAL_MS = 20_000 +const SESSION_ID = 'cccccccc-dddd-4eee-8fff-000000000000' +const CWD = '/repo/orca' + +let harness: SessionSearchIndexerHarness +let clock: FakeSessionSearchClock +let indexer: SessionSearchIndexer + +beforeEach(async () => { + resetSessionParseCacheForTests() + resetTranscriptConsumersForTests() + clock = new FakeSessionSearchClock() + harness = await openSessionSearchIndexerHarness('ss-native-chat') +}) + +afterEach(async () => { + indexer.close() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + await harness.cleanup() +}) + +/** The rows Orca's native chat writes: uuid, block content, cwd on the first turn. */ +function nativeChatTurn(uuid: string, role: 'user' | 'assistant', text: string): string { + const timestamp = new Date(1_740_000_000_000 + Number(uuid.slice(-2)) * 60_000).toISOString() + return JSON.stringify({ + type: role, + uuid, + sessionId: SESSION_ID, + timestamp, + cwd: CWD, + gitBranch: 'main', + message: { + role, + ...(role === 'assistant' ? { model: 'claude-fable-5' } : {}), + content: [{ type: 'text', text }] + } + }) +} + +function messageTexts(term: string): { role: string; session: string }[] { + return harness.read( + (db: SyncDatabase) => + db + .prepare( + `SELECT m.role AS role, s.session_id AS session 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 ? ORDER BY m.id` + ) + .all(term) as { role: string; session: string }[] + ) +} + +it('indexes a native-chat conversation and its later turns with no panel and no service', async () => { + const path = join(harness.claudeProjectDir, `${SESSION_ID}.jsonl`) + await mkdir(harness.claudeProjectDir, { recursive: true }) + await writeFile( + path, + `${[ + nativeChatTurn('turn-01', 'user', 'why does the relay drop the lease at 105 seconds'), + nativeChatTurn('turn-02', 'assistant', 'that is the client silence watchdog, not a cliff') + ].join('\n')}\n` + ) + + indexer = new SessionSearchIndexer({ + databasePath: harness.databasePath, + roots: harness.roots, + historyDays: null, + clock, + reconcileIntervalMs: INTERVAL_MS + }) + await indexer.start() + + expect(messageTexts('watchdog')).toEqual([{ role: 'assistant', session: SESSION_ID }]) + expect(harness.read((db: SyncDatabase) => db.prepare('SELECT cwd FROM sessions').get())).toEqual({ + cwd: CWD + }) + + // The conversation continues in the panel; nothing tells the index about it. + await appendFile( + path, + `${[ + nativeChatTurn('turn-03', 'user', 'and the fleetwide 4408 bursts'), + nativeChatTurn('turn-04', 'assistant', 'those are desktop lease rotations, cohort waves') + ].join('\n')}\n` + ) + clock.advance(INTERVAL_MS) + await indexer.settled() + + expect(messageTexts('cohort')).toEqual([{ role: 'assistant', session: SESSION_ID }]) + expect(messageTexts('4408')).toEqual([{ role: 'user', session: SESSION_ID }]) + expect(indexer.status().phase).toBe('current') +}) diff --git a/src/main/ai-vault-search/session-search-opencode-decline.test.ts b/src/main/ai-vault-search/session-search-opencode-decline.test.ts new file mode 100644 index 00000000000..1ed3a201727 --- /dev/null +++ b/src/main/ai-vault-search/session-search-opencode-decline.test.ts @@ -0,0 +1,177 @@ +import { mkdirSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +// Only the thread hop is replaced: both implementations below are the repo's +// own in-process readers, which the worker entry calls on the other side. +export const openCodeParseCalls: string[] = [] +vi.mock('../ai-vault/session-scanner-opencode-sqlite-worker-spawn', async () => { + const list = await import('../ai-vault/session-scanner-opencode-sqlite-list') + const parse = await import('../ai-vault/session-scanner-opencode-sqlite') + const own = await import('./session-search-opencode-decline.test') + return { + resolveOpenCodeSqliteWorkerEntryPath: () => null, + listOpenCodeSqliteSessionsViaWorker: ( + args: Parameters[0] + ) => list.listOpenCodeSqliteSessions(args), + parseOpenCodeSqliteSessionViaWorker: ( + args: Parameters[0] + ) => { + own.openCodeParseCalls.push(args.sessionId) + return parse.parseOpenCodeSqliteSession(args) + } + } +}) +import Database from '../sqlite/sync-database' +import { getSessionParseCacheEntry } from '../ai-vault/session-parse-cache-store' +import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache' +import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers' +import { buildOpenCodeSqliteCandidatePath } from '../ai-vault/session-scanner-opencode-sqlite-paths' +import { SessionSearchIndexer } from './session-search-indexer' +import { + FakeSessionSearchClock, + openSessionSearchIndexerHarness, + writeClaudeTranscript, + type SessionSearchIndexerHarness +} from './session-search-indexer-test-fixture' + +/* + * Round 12, F3. An OpenCode SQLite session decodes where the message channel + * cannot reach it, so no read of one will ever commit a row. The consumer + * declined it and wrote nothing, which left the file table silent about a + * source discovery returns on every pass: the decide step saw a path the index + * held nothing for, asked for a read, and asking for one over a warm cache + * drops the session list's own resume point. Every OpenCode session was fully + * decoded on every pass and the sidebar's fold was thrown away with it, which + * is the cache STA-1278 and STA-1417 added. + */ + +const SESSION = 'ses_r12' +const CLAUDE_SESSION = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + +let harness: SessionSearchIndexerHarness +let clock: FakeSessionSearchClock +let indexer: SessionSearchIndexer | null = null + +beforeEach(async () => { + resetSessionParseCacheForTests() + resetTranscriptConsumersForTests() + clock = new FakeSessionSearchClock() + harness = await openSessionSearchIndexerHarness('ss-opencode-decline') + indexer = null + openCodeParseCalls.length = 0 +}) + +afterEach(async () => { + indexer?.close() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + await harness.cleanup() +}) + +function writeOpenCodeDb(path: string, sessionId: string): void { + const db = new Database(path) + db.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT, slug TEXT NOT NULL, + directory TEXT NOT NULL, title TEXT NOT NULL, version TEXT NOT NULL, share_url TEXT, + summary_additions INTEGER, summary_deletions INTEGER, summary_files INTEGER, + summary_diffs TEXT, revert TEXT, permission TEXT, + time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, time_compacting INTEGER, + time_archived INTEGER, workspace_id TEXT, path TEXT, agent TEXT, model TEXT, + cost REAL DEFAULT 0 NOT NULL, tokens_input INTEGER DEFAULT 0 NOT NULL, + tokens_output INTEGER DEFAULT 0 NOT NULL, tokens_reasoning INTEGER DEFAULT 0 NOT NULL, + tokens_cache_read INTEGER DEFAULT 0 NOT NULL, tokens_cache_write INTEGER DEFAULT 0 NOT NULL, + metadata TEXT + ); + CREATE TABLE message ( + id TEXT PRIMARY KEY, session_id TEXT NOT NULL, time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, data TEXT NOT NULL + ); + CREATE TABLE project ( + id TEXT PRIMARY KEY, worktree TEXT NOT NULL, vcs TEXT, name TEXT, icon_url TEXT, + icon_color TEXT, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, + time_initialized INTEGER, sandboxes TEXT NOT NULL, commands TEXT, icon_url_override TEXT + ); + CREATE TABLE part ( + id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, data TEXT NOT NULL + ); + `) + db.prepare( + `INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, + time_created, time_updated, agent, model, cost, tokens_input, tokens_output, + tokens_reasoning, tokens_cache_read, tokens_cache_write) + VALUES (?, 'proj-1', NULL, 'slug-1', '/tmp/opencode', 'OpenCode title', '1.0.0', + ?, ?, 'build', '{"id":"glm"}', 0, 1, 1, 0, 0, 0)` + ).run(sessionId, 1_740_000_000_000, 1_740_000_100_000) + db.prepare( + `INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)` + ).run( + 'msg-1', + sessionId, + 1_740_000_000_000, + 1_740_000_000_000, + JSON.stringify({ role: 'user', time: { created: 1_740_000_000_000 } }) + ) + db.prepare( + `INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)` + ).run( + 'part-1', + 'msg-1', + sessionId, + 1_740_000_000_000, + 1_740_000_000_000, + JSON.stringify({ type: 'text', text: 'hello opencode' }) + ) + db.prepare( + `INSERT INTO project (id, worktree, name, time_created, time_updated, sandboxes) + VALUES ('proj-1', '/tmp/opencode', 'proj', ?, ?, '[]')` + ).run(1_740_000_000_000, 1_740_000_000_000) + db.close() +} + +it('reads an OpenCode session once, not on every pass', async () => { + const dbPath = join(harness.root, 'opencode-db', 'opencode.db') + mkdirSync(join(harness.root, 'opencode-db'), { recursive: true }) + writeOpenCodeDb(dbPath, SESSION) + const claudePath = join(harness.claudeProjectDir, 'control.jsonl') + await writeClaudeTranscript(claudePath, ['control turn'], CLAUDE_SESSION) + + indexer = new SessionSearchIndexer({ + databasePath: harness.databasePath, + roots: { ...harness.roots, opencodeDbPaths: [dbPath] }, + historyDays: null, + clock, + reconcileIntervalMs: 20_000, + onError: () => undefined + }) + await indexer.start() + + const syntheticPath = buildOpenCodeSqliteCandidatePath(dbPath, SESSION) + const openCodeAfterFirst = getSessionParseCacheEntry(syntheticPath) + const claudeAfterFirst = getSessionParseCacheEntry(claudePath) + + await indexer.reconcile() + await indexer.reconcile() + + // One decode across three passes, and the session list's cached fold for it + // is the same object it was after the first: nothing invalidated it. + expect(openCodeParseCalls).toHaveLength(1) + expect(getSessionParseCacheEntry(syntheticPath)).toBe(openCodeAfterFirst) + // The control, which the index really does hold, is untouched either way. + expect(getSessionParseCacheEntry(claudePath)).toBe(claudeAfterFirst) + + // What makes it skippable: a row saying the index has seen this source and + // holds no session for it, which is the shape a read-through-with-no-session + // already leaves. + const rows = harness.read((db) => + db.prepare('SELECT path, state, session_row_id FROM files ORDER BY path').all() + ) as { path: string; state: string; session_row_id: number | null }[] + expect(rows).toHaveLength(2) + expect(rows.find((row) => row.path === syntheticPath)).toMatchObject({ + state: 'current', + session_row_id: null + }) + expect(indexer.status()).toMatchObject({ filesDue: 0, filesFailed: 0, phase: 'current' }) +}) diff --git a/src/main/ai-vault-search/session-search-pass.ts b/src/main/ai-vault-search/session-search-pass.ts new file mode 100644 index 00000000000..d4f36015226 --- /dev/null +++ b/src/main/ai-vault-search/session-search-pass.ts @@ -0,0 +1,216 @@ +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { ensureSessionParseCacheLoaded } from '../ai-vault/session-parse-cache-persistence' +import { + cursorChatMetaRefusals, + withCursorChatMetaScan +} from '../ai-vault/session-scanner-cursor-chat-meta' +import { recordSessionScanIssue } from '../ai-vault/session-scan-issues' +import { + mergeDegradedRoots, + scanIssueDegradedRoots, + unreadableRoots, + type SessionSearchDegradedRoot +} from './session-search-degraded-roots' +import { retireDeletedSessionSearchSources } from './session-search-deleted-sources' +import type { SessionSearchDirectoryReader } from './session-search-directory-listings' +import { runSessionSearchIndexPass } from './session-search-index-pass' +import { + discoverSessionSearchCandidates, + isUnderScanRoot, + sessionSearchEmptiedRoots, + sessionSearchRootListings, + type SessionSearchScanRoots +} from './session-search-scan-roots' +import type { SessionSearchFileRow, SessionSearchStore } from './session-search-store' +import { sessionSearchEnumeratedContainers } from './session-search-synthetic-sources' + +/** + * Rows a cycle proves present or gone, newest first. + * + * Why bounded and why newest first: a cycle lists the newest N per agent, so + * every older row it holds is undiscovered and would otherwise be walked every + * twenty seconds. Newest first is what makes the guarantee hold — a transcript + * recent enough for the window to cover is recent enough to be in this slice, + * so its deletion is proven on the very next cycle whenever it happened. + */ +const RETIREMENT_ROWS_PER_CYCLE = 512 + +/** + * Directories either pass may read proving deletions. + * + * The bound on the walk is readdirs, not rows: rows sharing a directory are one + * read and then map lookups, and a directory that answers an error answers it + * once for every row under it. Counting rows instead let one unreadable + * directory hold the whole walk for as long as it stayed unreadable. + */ +const RETIREMENT_DIRECTORIES_PER_PASS = 512 + +export type SessionSearchPassArgs = { + store: SessionSearchStore + roots: SessionSearchScanRoots + /** A sweep lists every root; a cycle lists the newest N per agent. */ + full: boolean + recentPerAgent: number + /** Real roots that listed transcripts on the previous pass; undefined before the first. */ + previousRootsWithFiles?: ReadonlySet + /** True once the pass is out of wall time; reads stop, everything else finishes. */ + overdue?: () => boolean + /** One readdir per directory for the whole pass, shared by every step. */ + listings: SessionSearchDirectoryReader + signal?: AbortSignal +} + +export type SessionSearchPassResult = { + /** Real roots this pass listed transcripts under, for the next pass to compare against. */ + rootsWithFiles: Set + degradedRoots: SessionSearchDegradedRoot[] + /** False when the pass was cut short; its conclusions are not to be recorded. */ + completed: boolean + /** + * True when the deadline stopped the reads with candidates still owed. + * + * The caller's one use for it: a cycle lists the newest N per agent, so a + * backlog outside that window is only *visible* to a sweep. Without this a + * first run would index the recency window in its opening pass and then crawl, + * making progress only on the periodic sweep every five minutes. + */ + outOfTime: boolean +} + +/** + * One pass. Four steps, the same four whether it sweeps or cycles. + * + * 1. **Discover.** The only filesystem walk: every root on a sweep, the newest + * N per agent on a cycle. Everything below is decided from what it returns. + * 2. **Decide and read.** Per candidate, its stat against its row. Reads stop + * at the deadline and nothing is recorded about what was left, because being + * owed is a fact about the row and not an entry in a queue. + * 3. **Retire.** Candidates are the rows this pass's discovery did not return, + * inside the scope that discovery covered. The stateless walk proves each + * one gone, present or unverifiable; only `gone` deletes. + * 4. **Report.** Root health for this pass. The counts are a query, made by the + * caller against the same rows, so nothing here is tallied. + * + * The pass keeps nothing. Everything it learns is either on a row or in the + * result the caller compares against the next pass. + */ +export async function runSessionSearchPass( + args: SessionSearchPassArgs +): Promise { + const { store, signal } = args + if (args.full) { + // Every sweep opens with the purge, so a window narrower than the last + // instance held is applied by the first sweep of this one. + await store.purgeOlderThan(store.retentionCutoff, signal) + } + await ensureSessionParseCacheLoaded() + return withCursorChatMetaScan(async () => { + const swept = await discoverSessionSearchCandidates(args.roots, { + limitPerAgent: args.full ? Number.POSITIVE_INFINITY : args.recentPerAgent, + signal + }) + const issues: AiVaultScanIssue[] = [...swept.issues] + + let completed = true + let outOfTime = false + const rows = new Map(store.files().map((row) => [row.path, row])) + try { + const read = await runSessionSearchIndexPass(store, swept.candidates, { + signal, + rows, + overdue: args.overdue + }) + outOfTime = read.outOfTime + } catch (error) { + if (!signal?.aborted) { + throw error + } + completed = false + } + + const listings = sessionSearchRootListings(args.roots, swept.discoveries) + const roots = listings.map((listing) => listing.root) + const rootsWithFiles = new Set( + listings.filter((listing) => listing.files > 0).map((listing) => listing.root) + ) + // Undefined, not empty, before any pass has recorded one: an empty set is a + // real observation and this is the absence of one. + const previousRootsWithFiles = args.previousRootsWithFiles + // A pass cut short saw part of the machine, so its silence about a path is + // not evidence; it retires nothing and publishes no verdicts. + const retirement = completed + ? await retireDeletedSessionSearchSources({ + store, + paths: retirementCandidates(rows, swept, roots, args.full), + roots, + // Only a sweep enumerates without a per-agent limit, so only a sweep + // may prove a synthetic row's container holds it no longer. + enumeratedContainers: args.full + ? sessionSearchEnumeratedContainers(swept.candidates, issues) + : undefined, + emptiedRoots: previousRootsWithFiles + ? sessionSearchEmptiedRoots(previousRootsWithFiles, rootsWithFiles) + : new Set(), + listings: args.listings, + directoryLimit: RETIREMENT_DIRECTORIES_PER_PASS, + signal + }) + : { retired: [], unverifiable: [], unchecked: [], degradedRoots: [] } + + for (const refusal of cursorChatMetaRefusals()) { + // One issue per refused chats root, not one per Cursor transcript. + recordSessionScanIssue(issues, { + agent: 'cursor', + path: refusal.chatsRoot, + message: refusal.message + }) + } + // Roots that listed no transcripts and cannot be listed either: the walker + // swallows a readdir failure, so this is the only place it surfaces. + const unlistable = completed + ? await unreadableRoots( + roots.filter((root) => !rootsWithFiles.has(root)), + args.listings, + signal + ) + : [] + + return { + rootsWithFiles, + degradedRoots: mergeDegradedRoots( + scanIssueDegradedRoots(roots, issues), + retirement.degradedRoots, + unlistable + ), + completed, + outOfTime + } + }) +} + +/** + * Rows this pass's discovery did not return, inside the scope it covered. + * + * A sweep covers everything, so every undiscovered row is a candidate. A cycle + * covers the newest N per agent, so it may only judge rows under a root it + * actually listed, and it takes the newest of those: an older row is not + * evidence of anything a cycle looked for, and the next sweep is what reaches + * it. This is the whole of what used to be a watch set carried between passes. + */ +function retirementCandidates( + rows: ReadonlyMap, + swept: { candidates: readonly { file: { path: string } }[] }, + roots: readonly string[], + full: boolean +): string[] { + const discovered = new Set(swept.candidates.map((candidate) => candidate.file.path)) + const undiscovered = [...rows.values()].filter((row) => !discovered.has(row.path)) + if (full) { + return undiscovered.map((row) => row.path) + } + return undiscovered + .filter((row) => roots.some((root) => isUnderScanRoot(row.path, root))) + .sort((left, right) => right.mtimeMs - left.mtimeMs) + .slice(0, RETIREMENT_ROWS_PER_CYCLE) + .map((row) => row.path) +} diff --git a/src/main/ai-vault-search/session-search-read-decision.test.ts b/src/main/ai-vault-search/session-search-read-decision.test.ts new file mode 100644 index 00000000000..64eb8866d02 --- /dev/null +++ b/src/main/ai-vault-search/session-search-read-decision.test.ts @@ -0,0 +1,117 @@ +import { expect, it } from 'vitest' +import type { SessionFileCandidate } from '../ai-vault/session-scanner-types' +import type { SessionSearchIndexedFile } from './session-search-file-cursor' +import { + SESSION_SEARCH_FAILURES_BEFORE_HELD_OUT, + sessionSearchReadDecision +} from './session-search-read-decision' +import type { SessionSearchFileRow } from './session-search-store' + +const PATH = '/transcripts/one.jsonl' +const MTIME = 1_740_000_000_000 + +function candidate(overrides: Partial = {}): SessionFileCandidate { + return { + agent: 'claude', + codexHome: null, + file: { + path: PATH, + mtimeMs: MTIME, + modifiedAt: new Date(MTIME).toISOString(), + sizeBytes: 100, + ...overrides + } + } +} + +function row(overrides: Partial = {}): SessionSearchFileRow { + return { + path: PATH, + identity: null, + mtimeMs: MTIME, + sizeBytes: 100, + state: 'current', + failCount: 0, + failedMtimeMs: null, + ...overrides + } +} + +const cursor: SessionSearchIndexedFile = { byteOffset: 100, mtimeMs: MTIME, sizeBytes: 100 } + +function decide(args: { + file?: Partial + row?: SessionSearchFileRow | undefined + cursor?: SessionSearchIndexedFile | null + cutoffMs?: number | null +}) { + return sessionSearchReadDecision({ + candidate: candidate(args.file), + row: 'row' in args ? args.row : row(), + cursor: 'cursor' in args ? (args.cursor ?? null) : cursor, + cutoffMs: args.cutoffMs ?? null + }) +} + +it('reads a path the index holds nothing for, and lets the reader continue where it can', () => { + // Not `whole`: there is no span this index has to reach past, and the first + // enablement inside a running app has a warm list cursor to make use of. + expect(decide({ row: undefined })).toBe('any') +}) + +it('skips a file the index already covers at this stat', () => { + expect(decide({})).toBe('skip') +}) + +it('reads a file whose stat moved, however it moved', () => { + expect(decide({ file: { mtimeMs: MTIME + 1 } })).toBe('any') + // Grown without its mtime moving: a same-second append, or a restored stamp. + expect(decide({ file: { sizeBytes: 200 } })).toBe('any') +}) + +it('reads a file outside the retention window not at all', () => { + expect(decide({ row: undefined, cutoffMs: MTIME + 1 })).toBe('skip') + // And retention wins over everything else that would have asked for a read. + expect(decide({ row: row({ state: 'due' }), cutoffMs: MTIME + 1 })).toBe('skip') +}) + +it('reads a row owed a whole read from the start', () => { + expect(decide({ row: row({ state: 'due' }) })).toBe('whole') +}) + +it('reads whole rather than appending onto a cursor that continues nothing', () => { + // A different file at the same name: the identity check hands back no cursor. + expect(decide({ cursor: null })).toBe('whole') + // A chunked read that committed a prefix and no offset any append continues. + expect(decide({ cursor: { byteOffset: null, mtimeMs: MTIME, sizeBytes: 100 } })).toBe('whole') + // Shorter than the index read to, so this is not that file any more. + expect(decide({ file: { sizeBytes: 40 }, cursor })).toBe('whole') +}) + +it('retries a failed read until it has failed enough times at one stat', () => { + for (let failures = 1; failures < SESSION_SEARCH_FAILURES_BEFORE_HELD_OUT; failures++) { + expect( + decide({ row: row({ state: 'failed', failCount: failures, failedMtimeMs: MTIME }) }) + ).toBe('any') + } + expect( + decide({ + row: row({ + state: 'failed', + failCount: SESSION_SEARCH_FAILURES_BEFORE_HELD_OUT, + failedMtimeMs: MTIME + }) + }) + ).toBe('skip') +}) + +it('starts trying again the moment a held-out file changes', () => { + // The stat is the whole release condition, so nothing has to remember when + // the failures happened or schedule a retry. + expect( + decide({ + file: { mtimeMs: MTIME + 1 }, + row: row({ state: 'failed', failCount: 9, failedMtimeMs: MTIME }) + }) + ).toBe('any') +}) diff --git a/src/main/ai-vault-search/session-search-read-decision.ts b/src/main/ai-vault-search/session-search-read-decision.ts new file mode 100644 index 00000000000..cb25ad27ccb --- /dev/null +++ b/src/main/ai-vault-search/session-search-read-decision.ts @@ -0,0 +1,100 @@ +import type { SessionFileCandidate } from '../ai-vault/session-scanner-types' +import type { SessionParseReadRequirement } from '../ai-vault/session-scanner-parse-cache' +import { requiresWholeRead, type SessionSearchIndexedFile } from './session-search-file-cursor' +import type { SessionSearchFileRow } from './session-search-store' + +/** + * Failures at one unchanged stat before a file is left alone. + * + * Three rather than one, because a single failure is often a transcript being + * rewritten under the read; three at the same mtime is not. The retry policy is + * the stat itself: an edit, a restore, or a `touch` after a `chmod` all move it, + * and nothing else does, so no timer is needed and none is kept. + */ +export const SESSION_SEARCH_FAILURES_BEFORE_HELD_OUT = 3 + +/** + * What a pass owes one candidate: nothing, a read, or a read from the start. + * + * `any` and `whole` are the reader's own lanes. `whole` drops the session + * list's resume point, which is the only way to reach a span this index never + * saw; `any` asks for some bytes and lets the reader continue where it can, + * which is what the first enablement inside a running app needs — a warm list + * cursor sitting at the file's current stat would otherwise open nothing. + */ +export type SessionSearchReadDecision = 'skip' | SessionParseReadRequirement + +/** + * The whole of the indexer's decide step, as a function of the candidate's stat + * and the row the store holds for it. No pass state, no queue, no memory: the + * same inputs give the same answer on the first pass after a restart as on the + * hundredth of a long-running process, which is what lets a deadline cut a pass + * short with nothing to record. What did not get read is still owed, because + * being owed is a fact about the row. + */ +export function sessionSearchReadDecision(args: { + candidate: SessionFileCandidate + /** The file table's row, or undefined when the index holds nothing for it. */ + row: SessionSearchFileRow | undefined + /** The cursor for this candidate's identity; null when it is not continuable. */ + cursor: SessionSearchIndexedFile | null + /** Oldest transcript mtime worth holding rows for, or null for all history. */ + cutoffMs: number | null +}): SessionSearchReadDecision { + const { candidate, row, cursor, cutoffMs } = args + const file = candidate.file + // Retention first: a file outside the window is not worth reading whatever + // else is true of it, and the purge is what removes any row it still has. + if (cutoffMs !== null && file.mtimeMs < cutoffMs) { + return 'skip' + } + if (!row) { + // Nothing held for this path. Not `whole`, because the reader can continue + // from wherever it likes: there is no span this index has to reach past. + return 'any' + } + if (heldOut(row, file.mtimeMs)) { + return 'skip' + } + if (row.state === 'due') { + // The index is behind on a span no append reaches: a declined append, or a + // window that widened to admit this file. + return 'whole' + } + if (cursor === null || requiresWholeRead(cursor)) { + // A different file at the same name, or a chunked read that left a prefix + // and no cursor. Appending onto either would splice two spans together. + return 'whole' + } + const size = file.sizeBytes + if (typeof size === 'number' && cursor.byteOffset !== null && cursor.byteOffset > size) { + // Shorter than the index read to: this is not the file that cursor came from. + return 'whole' + } + if (row.state === 'failed') { + // Still within its retries, or the stat moved since it last failed. + return 'any' + } + return statMatches(row, file) ? 'skip' : 'any' +} + +/** + * True when this file has failed enough times at exactly this stat to stop + * trying. The stat is the whole release condition, so a file nobody touches is + * never read again and one that changes is read on the next pass that sees it. + */ +function heldOut(row: SessionSearchFileRow, mtimeMs: number): boolean { + return ( + row.state === 'failed' && + row.failCount >= SESSION_SEARCH_FAILURES_BEFORE_HELD_OUT && + row.failedMtimeMs === mtimeMs + ) +} + +/** The row already describes the file as it is now. */ +function statMatches(row: SessionSearchFileRow, file: SessionFileCandidate['file']): boolean { + return ( + row.mtimeMs === file.mtimeMs && + (row.sizeBytes === null || file.sizeBytes === undefined || row.sizeBytes === file.sizeBytes) + ) +} diff --git a/src/main/ai-vault-search/session-search-retention-policy.test.ts b/src/main/ai-vault-search/session-search-retention-policy.test.ts new file mode 100644 index 00000000000..71c6cdb0862 --- /dev/null +++ b/src/main/ai-vault-search/session-search-retention-policy.test.ts @@ -0,0 +1,26 @@ +import { expect, it } from 'vitest' +import { sessionSearchHistoryCutoffMs } from './session-search-retention-policy' + +const NOW = 1_740_000_000_000 + +it('treats a fractional or non-positive day count as no bound at all', () => { + // A day count that floors to zero would read as "all history" in one place + // and "cutoff is now" in the other; both sides answer null. + expect(sessionSearchHistoryCutoffMs(0.4, NOW)).toBeNull() + expect(sessionSearchHistoryCutoffMs(0, NOW)).toBeNull() + expect(sessionSearchHistoryCutoffMs(-30, NOW)).toBeNull() + expect(sessionSearchHistoryCutoffMs(30, NOW)).toBe(NOW - 30 * 86_400_000) + // Clamped rather than unbounded: a caller asking for three thousand years of + // history gets the ceiling, not an mtime before the epoch. + expect(sessionSearchHistoryCutoffMs(999_999, NOW)).toBe(NOW - 3_650 * 86_400_000) +}) + +// The cutoff is read from the clock on every pass, not frozen at construction: +// a purge and the accept check that follows it must not disagree about where +// the window is, or the sweep deletes rows the next candidate re-indexes. +it('moves the cutoff with the clock', () => { + const later = NOW + 86_400_000 + expect(sessionSearchHistoryCutoffMs(30, later)).toBe( + (sessionSearchHistoryCutoffMs(30, NOW) ?? 0) + 86_400_000 + ) +}) diff --git a/src/main/ai-vault-search/session-search-retention-policy.ts b/src/main/ai-vault-search/session-search-retention-policy.ts new file mode 100644 index 00000000000..c7fa8a0b6d1 --- /dev/null +++ b/src/main/ai-vault-search/session-search-retention-policy.ts @@ -0,0 +1,25 @@ +const DAY_MS = 86_400_000 +const HISTORY_DAYS_MAX = 3_650 + +/** + * The retention window, as the indexer's callers state it and as the store + * consumes it. Settings storage is PR 3b's problem; this is the arithmetic. + */ +function normalizeSessionSearchHistoryDays(value: number | null): number | null { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + return null + } + // Why floor then re-check: a fractional day floors to 0, which reads as "all + // history" on one side and "now" on the other; make the two agree. + const days = Math.floor(value) + return days <= 0 ? null : Math.min(HISTORY_DAYS_MAX, days) +} + +/** The oldest transcript mtime worth indexing; null means no bound. */ +export function sessionSearchHistoryCutoffMs( + historyDays: number | null, + nowMs: number +): number | null { + const days = normalizeSessionSearchHistoryDays(historyDays) + return days === null ? null : nowMs - days * DAY_MS +} diff --git a/src/main/ai-vault-search/session-search-scan-roots.test.ts b/src/main/ai-vault-search/session-search-scan-roots.test.ts new file mode 100644 index 00000000000..51b7381c78b --- /dev/null +++ b/src/main/ai-vault-search/session-search-scan-roots.test.ts @@ -0,0 +1,58 @@ +import { expect, it } from 'vitest' +import { delimiter, join } from 'node:path' +import type { SessionFileDiscovery } from '../ai-vault/session-scanner-types' +import { sessionSearchRootListings } from './session-search-scan-roots' + +const STATE = '/tmp/ss-roots/openclaw-state' +const LEGACY = '/tmp/ss-roots/openclaw-legacy' + +function file(path: string): SessionFileDiscovery['files'][number] { + return { path, mtimeMs: 0, modifiedAt: new Date(0).toISOString() } +} + +it('splits a merged discovery into the real directories behind it', () => { + const current = join(STATE, 'agents') + const legacy = join(LEGACY, 'agents') + const listings = sessionSearchRootListings( + { openclawStateDir: STATE, openclawLegacyStateDir: LEGACY }, + [ + { + agent: 'openclaw', + // What discovery reports for an agent whose roots are alternates. + rootDir: [current, legacy].join(delimiter), + files: [ + file(join(current, 'a', 'sessions', 'one.jsonl')), + file(join(current, 'a', 'sessions', 'two.jsonl')), + file(join(legacy, 'b', 'sessions', 'three.jsonl')) + ] + } + ] + ) + + const byRoot = Object.fromEntries(listings.map((one) => [one.root, one.files])) + expect(byRoot[current]).toBe(2) + expect(byRoot[legacy]).toBe(1) + // The joined string is never reported as a directory. + expect(listings.every((one) => !one.root.includes(delimiter))).toBe(true) +}) + +it('attributes a file by path segment, not by string prefix', () => { + const agents = join(STATE, 'agents') + const legacy = join(LEGACY, 'agents') + const listings = sessionSearchRootListings( + { openclawStateDir: STATE, openclawLegacyStateDir: LEGACY }, + [ + { + agent: 'openclaw', + rootDir: [agents, legacy].join(delimiter), + // A sibling directory whose name merely starts with a root's name. It + // is under no root, so it belongs to none of them. + files: [file(join(`${agents}-old`, 'b', 'sessions', 'two.jsonl'))] + } + ] + ) + + const byRoot = Object.fromEntries(listings.map((one) => [one.root, one.files])) + expect(byRoot[agents]).toBe(0) + expect(byRoot[legacy]).toBe(0) +}) diff --git a/src/main/ai-vault-search/session-search-scan-roots.ts b/src/main/ai-vault-search/session-search-scan-roots.ts new file mode 100644 index 00000000000..8df3510199e --- /dev/null +++ b/src/main/ai-vault-search/session-search-scan-roots.ts @@ -0,0 +1,135 @@ +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { AI_VAULT_AGENT_SOURCES } from '../ai-vault/session-scanner-agent-sources' +import { normalizedWslHomeDirs } from '../ai-vault/session-scanner-roots' +import { sessionCandidatesFromDiscoveries } from '../ai-vault/session-scanner-candidates' +import { discoverAiVaultSessionSources } from '../ai-vault/session-scanner-source-discovery' +import type { + AiVaultScanOptions, + SessionFileCandidate, + SessionFileDiscovery +} from '../ai-vault/session-scanner-types' + +/** One real directory a scan walked, and what it listed there. */ +export type SessionSearchRootListing = { root: string; files: number } + +/** + * Where the indexer looks. The caller resolves these so the index enumerates + * exactly the trees the session list does; the indexer owns the bounds + * (`limit`, `limitPerAgent`, `unlimited`) and its own cancellation, so those + * are not the caller's to set. + */ +export type SessionSearchScanRoots = Omit< + AiVaultScanOptions, + 'signal' | 'limit' | 'unlimited' | 'limitPerAgent' | 'scopePaths' +> + +export type SessionSearchDiscovery = { + /** Newest first, Codex hardlink aliases collapsed, exactly as a list scan sees them. */ + candidates: SessionFileCandidate[] + discoveries: SessionFileDiscovery[] + issues: AiVaultScanIssue[] +} + +/** + * The discovery half of a list scan, without the parse. `limitPerAgent` is the + * sidebar's own recency rule (`SessionNewestFiles` keeps the newest N per root); + * passing Infinity is what makes a sweep whole. + */ +export async function discoverSessionSearchCandidates( + roots: SessionSearchScanRoots, + args: { limitPerAgent: number; signal?: AbortSignal } +): Promise { + const issues: AiVaultScanIssue[] = [] + const options: AiVaultScanOptions = { ...roots, signal: args.signal } + const discoveries = await discoverAiVaultSessionSources({ + options, + limitPerAgent: args.limitPerAgent, + issues + }) + const candidates = await sessionCandidatesFromDiscoveries(discoveries, options) + return { candidates, discoveries, issues } +} + +/** + * Containment on path segments, not on string prefix, and on both separators: + * discovery joins with the platform's, a configured root can arrive spelled + * with the other, and `/a/agents-old` is not inside `/a/agents`. + */ +export function isUnderScanRoot(path: string, root: string): boolean { + return root.length > 0 && (path.startsWith(`${root}/`) || path.startsWith(`${root}\\`)) +} + +/** + * The real directories behind a scan's discoveries, with their file counts. + * + * Why this exists: an agent whose roots are alternates for one install reports + * them as a single discovery whose `rootDir` is every path joined by the + * platform's path delimiter. That string is not a directory. Health probes + * readdir it and get ENOENT, a containment check never matches a file under it, + * and a scan issue recorded against a real root never equals it — so the fence + * meant to protect an unmounted tree is inert for exactly the agent most likely + * to have one. Splitting the joined string back apart would be worse: a + * directory may legally contain the delimiter. The constituent paths come from + * the same source table discovery read. + */ +export function sessionSearchRootListings( + roots: SessionSearchScanRoots, + discoveries: readonly SessionFileDiscovery[] +): SessionSearchRootListing[] { + const wslHomeDirs = normalizedWslHomeDirs(roots.wslHomeDirs) + const counts = new Map() + for (const discovery of discoveries) { + const constituents = constituentRoots(roots, wslHomeDirs, discovery) + for (const root of constituents) { + counts.set(root, counts.get(root) ?? 0) + } + for (const file of discovery.files) { + const owner = owningRoot(constituents, file.path) + if (owner !== null) { + counts.set(owner, (counts.get(owner) ?? 0) + 1) + } + } + } + return [...counts].map(([root, files]) => ({ root, files })) +} + +function constituentRoots( + roots: SessionSearchScanRoots, + wslHomeDirs: readonly string[], + discovery: SessionFileDiscovery +): string[] { + const declared = AI_VAULT_AGENT_SOURCES[discovery.agent]?.rootDirs(roots, wslHomeDirs) ?? [] + if (declared.includes(discovery.rootDir)) { + return [discovery.rootDir] + } + // Either a merged discovery, whose rootDir is the joined string, or a source + // that builds its own discoveries (OpenCode, Antigravity) and reports a real + // directory that this table does not list. + return declared.length > 0 ? declared : [discovery.rootDir] +} + +function owningRoot(constituents: readonly string[], path: string): string | null { + let owner: string | null = null + for (const root of constituents) { + if (isUnderScanRoot(path, root) && (owner === null || root.length > owner.length)) { + owner = root + } + } + return owner +} + +/** + * Roots that listed transcripts on the previous pass and list none on this one. + * + * The one bit of memory the retirement walk gets, and what it buys: a root that + * blinks empty for a single pass is unverifiable rather than proven gone, so a + * sync client swapping a directory out cannot retire a tree. It is deliberately + * not evidence that survives the process — see the invariant block in + * `session-search-deleted-sources.ts` for what that costs and why. + */ +export function sessionSearchEmptiedRoots( + previous: ReadonlySet, + current: ReadonlySet +): Set { + return new Set([...previous].filter((root) => !current.has(root))) +} diff --git a/src/main/ai-vault-search/session-search-schema.ts b/src/main/ai-vault-search/session-search-schema.ts index 2da1e64bc11..ca525c47c0e 100644 --- a/src/main/ai-vault-search/session-search-schema.ts +++ b/src/main/ai-vault-search/session-search-schema.ts @@ -57,7 +57,18 @@ CREATE TABLE IF NOT EXISTS files( byte_offset INTEGER NOT NULL, mtime_ms REAL NOT NULL, size_bytes INTEGER, - session_row_id INTEGER + session_row_id INTEGER, + -- What this row still owes a reader, so that nothing has to be remembered + -- between passes. 'current': the rows match the file at the stat recorded + -- here. 'due': the index is behind on content it cannot reach by appending, + -- so the next pass reads the file whole. 'failed': the last read did not + -- commit, and the two columns below are what stop it being retried for ever. + state TEXT NOT NULL DEFAULT 'current', + fail_count INTEGER NOT NULL DEFAULT 0, + -- The mtime the failures were observed at. A file that fails at one stat is + -- left alone once it has failed enough times, and only a change to this stat + -- can mean the file itself changed, so it is the whole retry policy. + failed_mtime_ms REAL ); -- Retention walks the expiring end of this column; without it that is a full scan and a sort. CREATE INDEX IF NOT EXISTS files_mtime ON files(mtime_ms); diff --git a/src/main/ai-vault-search/session-search-store-is-memory.test.ts b/src/main/ai-vault-search/session-search-store-is-memory.test.ts new file mode 100644 index 00000000000..2e90a75862f --- /dev/null +++ b/src/main/ai-vault-search/session-search-store-is-memory.test.ts @@ -0,0 +1,265 @@ +import { chmod, rm, utimes } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache' +import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers' +import type SyncDatabase from '../sqlite/sync-database' +import { SessionSearchIndexer } from './session-search-indexer' +import { + FakeSessionSearchClock, + openSessionSearchIndexerHarness, + writeClaudeTranscript, + type SessionSearchIndexerHarness +} from './session-search-indexer-test-fixture' + +/* + * S1-S5: the store is the only memory. + * + * Every question the indexer answers between passes -- what is owed a read, + * what has failed and how often, what it holds and therefore what may have been + * deleted, what to report -- is a row in the `files` table. These tests check + * that from outside the object: a second connection, hand-written SQL, and the + * clock. Two things outlive a pass and are not rows, and both are named here: + * the timer, and one bit per root for the retirement walk's grace. + */ + +const INTERVAL_MS = 20_000 +const CAN_DENY_READ = process.platform !== 'win32' && process.getuid?.() !== 0 +const FIRST = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' +const SECOND = 'bbbbbbbb-cccc-4ddd-8eee-ffffffffffff' +const THIRD = 'cccccccc-dddd-4eee-8fff-000000000000' + +let harness: SessionSearchIndexerHarness +let clock: FakeSessionSearchClock +let indexer: SessionSearchIndexer | null +let errors: unknown[] + +beforeEach(async () => { + resetSessionParseCacheForTests() + resetTranscriptConsumersForTests() + errors = [] + clock = new FakeSessionSearchClock() + harness = await openSessionSearchIndexerHarness('ss-memory') + indexer = null +}) + +afterEach(async () => { + indexer?.close() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + await harness.cleanup() +}) + +function newIndexer( + overrides: Partial[0]> = {} +): SessionSearchIndexer { + indexer = new SessionSearchIndexer({ + databasePath: harness.databasePath, + roots: harness.roots, + historyDays: null, + clock, + reconcileIntervalMs: INTERVAL_MS, + onError: (error) => errors.push(error), + ...overrides + }) + return indexer +} + +function transcriptPath(name: string): string { + return join(harness.claudeProjectDir, `${name}.jsonl`) +} + +async function nextCycle(): Promise { + clock.advance(INTERVAL_MS) + await indexer?.settled() +} + +/** The whole `files` table as a second connection sees it, ordered for comparison. */ +function fileTable(): unknown[] { + return harness.read((db: SyncDatabase) => + db + .prepare( + `SELECT path, dev, ino, byte_offset, mtime_ms, size_bytes, session_row_id, + state, fail_count, failed_mtime_ms + FROM files ORDER BY path` + ) + .all() + ) +} + +// S1. The status is a query. A counter kept beside the rows is what needs a +// rule about when to reset, and every such rule this feature grew was wrong. +it('S1: reports exactly what a hand-written query over the rows reports', async () => { + await writeClaudeTranscript(transcriptPath(FIRST), ['one'], FIRST) + await writeClaudeTranscript(transcriptPath(SECOND), ['two'], SECOND) + await newIndexer().start() + + const bySql = (): Record => + Object.fromEntries( + ( + harness.read((db: SyncDatabase) => + db.prepare('SELECT state, count(*) AS n FROM files GROUP BY state').all() + ) as { state: string; n: number }[] + ).map((row) => [row.state, Number(row.n)]) + ) + + const reported = indexer?.status() + const counted = bySql() + expect(reported?.filesIndexed).toBe(counted.current ?? 0) + expect(reported?.filesDue).toBe(counted.due ?? 0) + expect(reported?.filesFailed).toBe(counted.failed ?? 0) + expect(reported?.filesIndexed).toBe(2) + + // And it stays a query: delete a row behind the indexer's back and the very + // next call reports the table, not a number it remembered. + harness.write((db: SyncDatabase) => + db.prepare('DELETE FROM files WHERE path = ?').run(transcriptPath(FIRST)) + ) + expect(indexer?.status().filesIndexed).toBe(1) +}) + +// S2. A deletion is proven by comparing the rows against what discovery +// returned, so the moment it happened does not matter. Every boundary a pass +// has is a moment a file can go. +it('S2: retires a file deleted right after the opening sweep', async () => { + await writeClaudeTranscript(transcriptPath(FIRST), ['going'], FIRST) + await writeClaudeTranscript(transcriptPath(SECOND), ['staying'], SECOND) + await newIndexer().start() + + await rm(transcriptPath(FIRST)) + await nextCycle() + + expect(fileTable()).toHaveLength(1) +}) + +it('S2: retires a file deleted right after a cycle', async () => { + await writeClaudeTranscript(transcriptPath(FIRST), ['going'], FIRST) + await writeClaudeTranscript(transcriptPath(SECOND), ['staying'], SECOND) + await newIndexer().start() + await nextCycle() + + await rm(transcriptPath(FIRST)) + await nextCycle() + + expect(fileTable()).toHaveLength(1) +}) + +it('S2: retires a file deleted right after a periodic sweep', async () => { + await writeClaudeTranscript(transcriptPath(FIRST), ['going'], FIRST) + await writeClaudeTranscript(transcriptPath(SECOND), ['staying'], SECOND) + await newIndexer({ fullSweepEveryCycles: 2 }).start() + await nextCycle() + await nextCycle() + // The third pass is the periodic sweep; the file goes the moment it ends. + await nextCycle() + + await rm(transcriptPath(FIRST)) + await nextCycle() + + expect(fileTable()).toHaveLength(1) +}) + +it('S2: retires a file deleted while a pass was out of time', async () => { + await writeClaudeTranscript(transcriptPath(FIRST), ['going'], FIRST) + await writeClaudeTranscript(transcriptPath(SECOND), ['staying'], SECOND) + await writeClaudeTranscript(transcriptPath(THIRD), ['also staying'], THIRD) + // One transcript a pass: the opening sweep leaves two of the three unread. + clock.costPerNowMs = 1_000 + await newIndexer({ passDeadlineMs: 1_000 }).start() + expect(fileTable()).toHaveLength(1) + + await rm(transcriptPath(FIRST)) + await nextCycle() + await nextCycle() + + // Read what it could, and proved the deletion in the same pass it was still + // catching up in: retirement is not what the deadline bounds. + expect((fileTable() as { path: string }[]).map((row) => row.path)).not.toContain( + transcriptPath(FIRST) + ) +}) + +// S3. The stat is the whole retry policy: a file that fails at one stat stops +// being read, and only a change to that stat starts it again. +it.skipIf(!CAN_DENY_READ)( + 'S3: stops reading a file that fails three times at one stat', + async () => { + const path = transcriptPath(FIRST) + await writeClaudeTranscript(path, ['behind the wrong mode bits'], FIRST) + await chmod(path, 0o000) + try { + await newIndexer().start() + for (let cycle = 0; cycle < 4; cycle++) { + await nextCycle() + } + + const row = harness.read((db: SyncDatabase) => + db.prepare('SELECT state, fail_count AS failCount FROM files WHERE path = ?').get(path) + ) as { state: string; failCount: number } + // Three, not four and not seven: the pass after the third costs nothing. + expect(row).toEqual({ state: 'failed', failCount: 3 }) + expect(indexer?.status()).toMatchObject({ filesFailed: 1, phase: 'degraded' }) + + // Only the stat releases it. + await chmod(path, 0o644) + const later = new Date(Date.now() + 60_000) + await utimes(path, later, later) + await nextCycle() + + expect(indexer?.status()).toMatchObject({ filesIndexed: 1, filesFailed: 0 }) + } finally { + await chmod(path, 0o644) + } + } +) + +// S4. Two passes over an unchanged filesystem leave the table byte for byte as +// they found it. Anything that drifted would be state the rows do not hold. +it('S4: leaves the file table identical across passes with no change on disk', async () => { + await writeClaudeTranscript(transcriptPath(FIRST), ['one'], FIRST) + await writeClaudeTranscript(transcriptPath(SECOND), ['two'], SECOND) + await newIndexer({ fullSweepEveryCycles: 2 }).start() + + const afterSweep = fileTable() + await nextCycle() + expect(fileTable()).toEqual(afterSweep) + await nextCycle() + expect(fileTable()).toEqual(afterSweep) + // Including across the periodic sweep, which reads the same rows again. + await nextCycle() + expect(fileTable()).toEqual(afterSweep) + expect(errors).toEqual([]) +}) + +// S5. Nothing a close interrupts needs repairing: the next instance reads the +// rows as they stand and decides from them alone. +it('S5: leaves the store consistent when a close interrupts a pass', async () => { + await writeClaudeTranscript(transcriptPath(FIRST), ['one'], FIRST) + await writeClaudeTranscript(transcriptPath(SECOND), ['two'], SECOND) + await writeClaudeTranscript(transcriptPath(THIRD), ['three'], THIRD) + newIndexer() + let closed = false + clock.onNow = () => { + if (closed || fileTable().length === 0) { + return + } + closed = true + indexer?.close() + } + await indexer?.start() + await indexer?.settled() + clock.onNow = null + + const interrupted = fileTable() + expect(interrupted.length).toBeGreaterThan(0) + expect(interrupted.length).toBeLessThan(3) + expect(errors).toEqual([]) + + // A new instance over the same database: no repair pass, no recovery, just + // the rows and what they say is owed. + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + await newIndexer().start() + + expect(indexer?.status()).toMatchObject({ filesIndexed: 3, filesDue: 0, filesFailed: 0 }) +}) diff --git a/src/main/ai-vault-search/session-search-store.ts b/src/main/ai-vault-search/session-search-store.ts index da9f4d6f731..daa9267561b 100644 --- a/src/main/ai-vault-search/session-search-store.ts +++ b/src/main/ai-vault-search/session-search-store.ts @@ -13,10 +13,36 @@ import { import { deleteExpiredSearchFiles, drainOrphanedMessages } from './session-search-retention-delete' import { openSessionSearchDatabase } from './session-search-schema' -// A paused store keeps recording what it declined, so the set needs a ceiling. -// Above it the oldest record goes and the drop is counted, because a re-read set -// that silently forgets is worse than one that says it is incomplete. -export const STALE_PATH_LIMIT = 20_000 +/** + * What a row still owes a reader. + * + * `current`: the rows match the file at the stat this row records. + * `due`: the index is behind on a span it cannot reach by appending, so the + * next pass must read the file whole. + * `failed`: the last read did not commit; `failCount` and `failedMtimeMs` are + * what stop it being retried for ever. + */ +export type SessionSearchFileState = 'current' | 'due' | 'failed' + +/** + * One row of the index's own file table. + * + * This is the indexer's whole memory between passes: what it holds, at what + * stat, and what each row still owes. Nothing it decides is answered from + * anywhere else, which is why a second connection can check its status. + */ +export type SessionSearchFileRow = { + path: string + identity: SessionSearchFileIdentity + mtimeMs: number + sizeBytes: number | null + state: SessionSearchFileState + failCount: number + failedMtimeMs: number | null +} + +/** How many rows are in each state; the whole of the indexer's progress report. */ +export type SessionSearchStateCounts = { current: number; due: number; failed: number } /** * Owns the index database. PR 2 scope: the write half only — the transcript @@ -27,12 +53,7 @@ export class SessionSearchStore { private readonly db: SyncDatabase private readonly writer: SessionSearchIndexWriter private closed = false - private acceptingWrites = true private retentionCutoffMs: number | null = null - // Files this index knows it is behind on. Filled by a declined or abandoned - // read; PR 3's indexer drains it. Nothing here schedules the re-read. - private readonly stale = new Map() - private droppedStalePaths = 0 // One drain at a time. A replace that commits while one is running asks for // another pass rather than starting a second walk of the same rows. private draining = false @@ -104,23 +125,26 @@ export class SessionSearchStore { return this.db } - setAcceptingWrites(accept: boolean): void { - this.acceptingWrites = accept - } - /** The oldest transcript mtime worth indexing; PR 3 derives it from the retention setting. */ setRetentionCutoffMs(cutoffMs: number | null): void { this.retentionCutoffMs = cutoffMs } - /** Whether this candidate is new enough to be worth holding rows for at all. */ - private withinRetention(candidate: SessionFileCandidate): boolean { - return this.retentionCutoffMs === null || candidate.file.mtimeMs >= this.retentionCutoffMs + /** The cutoff a caller's own decide step compares a candidate's mtime against. */ + get retentionCutoff(): number | null { + return this.retentionCutoffMs } - /** Whether a write for this candidate may start right now. */ - acceptsCandidate(candidate: SessionFileCandidate): boolean { - return !this.closed && this.acceptingWrites && this.withinRetention(candidate) + /** + * Whether this candidate is new enough to hold rows for. + * + * Enforced here as well as in the indexer's decide step, and not only there: + * the consumer observes every read the session list makes, not only the ones + * the index asked for, so a sidebar scan of a transcript outside the window + * would otherwise index rows the next purge deletes again. + */ + private withinRetention(candidate: SessionFileCandidate): boolean { + return this.retentionCutoffMs === null || candidate.file.mtimeMs >= this.retentionCutoffMs } indexedFile(path: string, identity: SessionSearchFileIdentity): SessionSearchIndexedFile | null { @@ -139,7 +163,7 @@ export class SessionSearchStore { previousByteOffset: number, identity?: () => TranscriptSessionIdentity | null ): SessionSearchFileWrite | null { - if (!this.acceptsCandidate(candidate)) { + if (this.closed || !this.withinRetention(candidate)) { return null } try { @@ -150,10 +174,14 @@ export class SessionSearchStore { } } + /** + * A read that landed. Written after the commit rather than inside it: the + * transaction owns the rows and the cursor, and a crash between the two + * leaves a row that says `failed` over content that is in fact current, which + * the next pass fixes by reading a file it did not have to. + */ writeCommitted(candidate: SessionFileCandidate): void { - // Why: a list scan queues every file the backfill has not reached yet; once - // one lands, a later pass must not re-read the whole queue. - this.stale.delete(candidate.file.path) + this.setFileState(candidate.file.path, 'current') } reportWriteFailure(error: unknown): void { @@ -161,55 +189,89 @@ export class SessionSearchStore { } /** - * Records a file whose content the index is behind on, for a later whole - * re-read. Recorded while paused too: a pause is exactly the window in which - * reads are declined, so refusing to remember them would lose every file the - * pause covered. - */ - markStale(candidate: SessionFileCandidate): void { - if (this.closed || !this.withinRetention(candidate)) { - return - } - // Re-inserting moves the path to the end, so the oldest record is the one - // dropped when a long pause overruns the bound. - this.stale.delete(candidate.file.path) - this.stale.set(candidate.file.path, candidate) - while (this.stale.size > STALE_PATH_LIMIT) { - const oldest = this.stale.keys().next() - if (oldest.done) { - break - } - this.stale.delete(oldest.value) - this.droppedStalePaths += 1 - } - } - - /** - * Files the index knew it was behind on and could not keep a record of. A - * non-zero count means the re-read set is incomplete, so coverage cannot be - * reported as whole until a full pass runs. - */ - get droppedPendingFileCount(): number { - return this.droppedStalePaths - } - - /** - * Hands the re-read set to its scheduler and clears it. + * Every row this index holds. The candidate list for retirement and the whole + * of the status, read in one query so that no pass has to carry either. * - * These paths are behind, not merely dirty: the index declined their last read - * because it covered a span the index never saw. Re-dispatching a scan is not - * enough on its own, because the reader picks `append` from the session list's - * resume point and the consumer will decline again. The caller must pass each - * path to `requestWholeTranscriptRead` first. + * The cursor is deliberately not here: whether a row can be continued is + * `indexedFile`'s question, and one spelling of the half-written sentinel is + * enough. */ - takeStale(): SessionFileCandidate[] { - const candidates = [...this.stale.values()] - this.stale.clear() - return candidates + files(): SessionSearchFileRow[] { + return ( + this.db + .prepare( + `SELECT path, dev, ino, mtime_ms AS mtimeMs, size_bytes AS sizeBytes, + state, fail_count AS failCount, failed_mtime_ms AS failedMtimeMs + FROM files` + ) + .all() as (Omit & { + dev: number | null + ino: number | null + })[] + ).map((row) => ({ + path: row.path, + identity: + typeof row.dev === 'number' && typeof row.ino === 'number' + ? { dev: row.dev, ino: row.ino } + : null, + mtimeMs: row.mtimeMs, + sizeBytes: row.sizeBytes, + state: row.state, + failCount: row.failCount, + failedMtimeMs: row.failedMtimeMs + })) } - get pendingFileCount(): number { - return this.stale.size + /** + * Moves a row's read state. + * + * `failed` also counts the failure and records the stat it happened at, which + * is what lets the next pass tell "this file has never worked" from "this + * file has changed since it last failed". A path with no row is a no-op: the + * next pass reads it because the index holds nothing for it. + */ + setFileState(path: string, state: SessionSearchFileState, atMtimeMs?: number): void { + try { + if (state === 'failed') { + // Inserted when there is no row, because the common unreadable file is + // one the index never managed to hold: a transcript behind the wrong + // mode bits fails on its very first read, and with nowhere to write the + // count it would be read again on every pass for the life of the + // process. The cursor is zero and there is no session, which is what + // "the index holds nothing for this file" already looks like. + this.db + .prepare( + `INSERT INTO files(path, byte_offset, mtime_ms, state, fail_count, failed_mtime_ms) + VALUES (?, 0, ?, 'failed', 1, ?) + ON CONFLICT(path) DO UPDATE SET + state = 'failed', + fail_count = files.fail_count + 1, + failed_mtime_ms = excluded.failed_mtime_ms` + ) + .run(path, atMtimeMs ?? 0, atMtimeMs ?? null) + return + } + this.db + .prepare( + 'UPDATE files SET state = ?, fail_count = 0, failed_mtime_ms = NULL WHERE path = ?' + ) + .run(state, path) + } catch (error) { + this.onError(error) + } + } + + /** Rows per state. The status is this query and the pass's own degraded roots. */ + stateCounts(): SessionSearchStateCounts { + const rows = this.db.prepare('SELECT state, count(*) AS n FROM files GROUP BY state').all() as { + state: SessionSearchFileState + n: number + }[] + const counts: SessionSearchStateCounts = { current: 0, due: 0, failed: 0 } + for (const row of rows) { + counts[row.state] = Number(row.n) + } + return counts } /** @@ -218,7 +280,6 @@ export class SessionSearchStore { * (docs/reference/ssh-execution-boundary.md). */ removeFile(path: string): void { - this.stale.delete(path) try { this.writer.removeFile(path) } catch (error) { diff --git a/src/main/ai-vault-search/session-search-synthetic-sources.ts b/src/main/ai-vault-search/session-search-synthetic-sources.ts new file mode 100644 index 00000000000..86222b9c5fe --- /dev/null +++ b/src/main/ai-vault-search/session-search-synthetic-sources.ts @@ -0,0 +1,56 @@ +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { splitOpenCodeSqliteCandidate } from '../ai-vault/session-scanner-opencode-sqlite-paths' +import type { SessionFileCandidate } from '../ai-vault/session-scanner-types' + +/** + * A row whose path names a container and an entry inside it rather than a file + * of its own. OpenCode's SQLite sessions are the one shape today + * (`#`), which is why this reads through that source's + * own splitter rather than reinventing the encoding. + */ +export type SessionSearchSyntheticSource = { container: string; id: string } + +export function splitSyntheticSessionSource(path: string): SessionSearchSyntheticSource | null { + const openCode = splitOpenCodeSqliteCandidate(path) + return openCode ? { container: openCode.dbPath, id: openCode.sessionId } : null +} + +/** + * Which containers a pass enumerated in full, and every id each of them held. + * + * This is the synthetic equivalent of a directory listing, and it has to meet + * the same bar before the retirement walk may prove anything from it: + * + * - **Exhaustive.** Only a sweep enumerates without a per-agent limit. A cycle + * asks for the newest N, so an id it did not return may simply be the N+1th. + * Callers that are not a census do not build this at all. + * - **Successful.** A container a scan issue names could not be read, and a + * read that failed returns no ids rather than an error the walk can see. A + * named container is left out, so its rows stay unverifiable. + * - **Non-empty.** A container that returned nothing is not evidence that it + * holds nothing: a database whose schema this scanner no longer recognises + * returns an empty list with no error at all, and believing it would retire + * every session in one pass. The cost is one stale row per container whose + * last entry the user deletes, until the container gains an entry or goes. + */ +export function sessionSearchEnumeratedContainers( + candidates: readonly SessionFileCandidate[], + issues: readonly AiVaultScanIssue[] +): Map> { + const containers = new Map>() + for (const candidate of candidates) { + const synthetic = splitSyntheticSessionSource(candidate.file.path) + if (!synthetic) { + continue + } + const ids = containers.get(synthetic.container) ?? new Set() + ids.add(synthetic.id) + containers.set(synthetic.container, ids) + } + for (const issue of issues) { + if (issue.kind !== 'notice') { + containers.delete(issue.path) + } + } + return containers +} diff --git a/src/main/ai-vault-search/session-search-work-loop.ts b/src/main/ai-vault-search/session-search-work-loop.ts new file mode 100644 index 00000000000..41a0ad98497 --- /dev/null +++ b/src/main/ai-vault-search/session-search-work-loop.ts @@ -0,0 +1,87 @@ +import type { SessionSearchClock, SessionSearchTimerHandle } from './session-search-clock' + +export type SessionSearchWorkLoopOptions = { + clock: SessionSearchClock + intervalMs: number + /** A task that threw for a reason other than its own abort. */ + onFailure: (error: unknown) => void +} + +/** + * Runs the indexer's passes one at a time, on an interval, until it is closed. + * + * Separate from the indexer because it is the part with no opinion about + * transcripts: a task chain that never overlaps itself, a timer that only ever + * has one pending tick, and a close that cancels both. Arming inside the chain + * rather than beside it is what makes `settled` mean "everything queued so far + * has finished, including the re-arm", which is what a fake-clock test needs. + */ +export class SessionSearchWorkLoop { + private timer: SessionSearchTimerHandle | null = null + private controller: AbortController | null = null + private chain: Promise = Promise.resolve() + private closed = false + + constructor(private readonly options: SessionSearchWorkLoopOptions) {} + + /** Everything queued so far. Never rejects: a task's failure is reported, not thrown. */ + get settled(): Promise { + return this.chain + } + + /** Queues `work` behind whatever is running, then re-arms the interval. */ + queue(work: (signal: AbortSignal) => Promise, tick: () => void): Promise { + const chained = this.chain + .then( + () => this.run(work), + () => this.run(work) + ) + .then(() => this.arm(tick)) + this.chain = chained + return chained + } + + /** + * Stops the timer, the task in flight and everything queued behind it. Nothing + * queued before this call may run afterwards: that is what lets the indexer + * close its store here and know no pass will reach for it. + */ + close(): void { + this.closed = true + if (this.timer !== null) { + this.options.clock.clearTimeout(this.timer) + this.timer = null + } + this.controller?.abort() + } + + private arm(tick: () => void): void { + if (this.closed || this.timer !== null) { + return + } + this.timer = this.options.clock.setTimeout(() => { + this.timer = null + tick() + }, this.options.intervalMs) + } + + private async run(work: (signal: AbortSignal) => Promise): Promise { + if (this.closed) { + return + } + const controller = new AbortController() + this.controller = controller + try { + await work(controller.signal) + } catch (error) { + // An aborted task is a close, never a failure. + if (!controller.signal.aborted) { + this.options.onFailure(error) + } + } finally { + if (this.controller === controller) { + this.controller = null + } + } + } +} diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index 46fb4754a32..6ebb1a76a2b 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -26,6 +26,7 @@ import { import { readResumableTranscript, readWholeTranscript, + requestWholeTranscriptRead, type TranscriptReadStats } from './session-transcript-reader' @@ -104,29 +105,67 @@ export function createSessionParseStats(): SessionParseStats { export async function parseAgentSessionFileCached( candidate: SessionFileCandidate, platform: NodeJS.Platform, - stats?: SessionParseStats + stats?: SessionParseStats, + requireRead?: SessionParseReadRequirement ): Promise { // The whole lookup-read-store sequence runs in the lane: a concurrent parse of // the same path shares this entry's resume point and its message channel. return inSessionParseFileLane(candidate.file.path, () => - parseCachedInLane(candidate, platform, stats) + parseCachedInLane(candidate, platform, stats, requireRead) + ) +} + +/** + * What a caller other than the session list needs out of this parse. + * + * `any`: some bytes must be read. A cursor already at the file's current stat + * is dropped so the reader opens it; one that is merely behind is left alone, + * because an append is a read. + * + * `whole`: the file must be re-read from zero, for a consumer whose own cursor + * covers a span this one does not. + * + * Why it is a parameter and not two calls around this one: the decision reads + * cache state and then changes it, so outside the per-path lane an overlapping + * list parse can store its entry in between and the forced read silently + * degrades to a reuse. + */ +export type SessionParseReadRequirement = 'any' | 'whole' + +/** + * True when this cursor already sits at the transcript's current stat, so a + * parse would reuse the cached fold and read no bytes at all. + */ +function sessionParseCacheCoversTranscript( + candidate: SessionFileCandidate, + platform: NodeJS.Platform +): boolean { + const { file } = candidate + const entry = getSessionParseCacheEntry(file.path) + return ( + entry !== undefined && + entry.platform === platform && + entry.mtimeMs === file.mtimeMs && + (entry.sizeBytes === null || file.sizeBytes === undefined || entry.sizeBytes === file.sizeBytes) ) } async function parseCachedInLane( candidate: SessionFileCandidate, platform: NodeJS.Platform, - stats?: SessionParseStats + stats?: SessionParseStats, + requireRead?: SessionParseReadRequirement ): Promise { const { file } = candidate + if ( + requireRead === 'whole' || + (requireRead === 'any' && sessionParseCacheCoversTranscript(candidate, platform)) + ) { + requestWholeTranscriptRead(file.path) + } const entry = getSessionParseCacheEntry(file.path) - const transcriptUnchanged = - entry !== undefined && - entry.platform === platform && - entry.mtimeMs === file.mtimeMs && - (entry.sizeBytes === null || file.sizeBytes === undefined || entry.sizeBytes === file.sizeBytes) - if (transcriptUnchanged) { + if (entry !== undefined && sessionParseCacheCoversTranscript(candidate, platform)) { if (sidecarUnchanged(entry.sidecar, file.sidecar)) { return reuseCachedSession(candidate, entry, stats) } From 2ecde717b4561cae1701a27615f704434232399a Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:32:56 -0400 Subject: [PATCH 17/17] refactor(rpc): make defineMethod preserve the method name and handler result (#20016) --- .../push/push-registration-rpc.test.ts | 4 +- .../rpc/core-typed-method-contract.test.ts | 114 +++++++++++++++++ src/main/runtime/rpc/core.ts | 115 ++++++++++++------ .../runtime/rpc/dispatcher-request-parsing.ts | 4 +- src/main/runtime/rpc/dispatcher.ts | 7 +- src/main/runtime/rpc/methods/accounts.test.ts | 4 +- src/main/runtime/rpc/methods/accounts.ts | 4 +- .../runtime/rpc/methods/agent-hooks.test.ts | 4 +- src/main/runtime/rpc/methods/agent-hooks.ts | 4 +- src/main/runtime/rpc/methods/agent-session.ts | 4 +- src/main/runtime/rpc/methods/ai-vault.ts | 4 +- src/main/runtime/rpc/methods/artifacts.ts | 4 +- .../automation-scoped-list-methods.test.ts | 4 +- src/main/runtime/rpc/methods/automations.ts | 4 +- .../methods/browser-client-file-channel.ts | 4 +- .../rpc/methods/browser-client-host.ts | 4 +- src/main/runtime/rpc/methods/browser-core.ts | 4 +- .../runtime/rpc/methods/browser-extras.ts | 4 +- .../rpc/methods/browser-network-tunnel.ts | 4 +- .../runtime/rpc/methods/browser-screencast.ts | 4 +- .../rpc/methods/browser-text-rpc-methods.ts | 4 +- .../runtime/rpc/methods/client-events.test.ts | 9 +- src/main/runtime/rpc/methods/client-events.ts | 4 +- src/main/runtime/rpc/methods/client-ui.ts | 4 +- src/main/runtime/rpc/methods/clipboard.ts | 4 +- .../rpc/methods/computer-actions.test.ts | 3 +- src/main/runtime/rpc/methods/computer.test.ts | 4 +- src/main/runtime/rpc/methods/computer.ts | 4 +- src/main/runtime/rpc/methods/diagnostics.ts | 4 +- src/main/runtime/rpc/methods/emulator.ts | 4 +- .../rpc/methods/files-mutation-methods.ts | 4 +- .../files-terminal-artifact-methods.ts | 4 +- src/main/runtime/rpc/methods/files.ts | 4 +- .../runtime/rpc/methods/folder-workspace.ts | 4 +- .../git-commit-message-generation-methods.ts | 4 +- .../runtime/rpc/methods/git-diff-methods.ts | 4 +- src/main/runtime/rpc/methods/git.ts | 4 +- .../rpc/methods/github-issue-methods.ts | 4 +- .../rpc/methods/github-project-methods.ts | 4 +- .../methods/github-pull-request-methods.ts | 4 +- .../github-pull-request-update-methods.ts | 4 +- .../methods/github-repo-work-item-methods.ts | 4 +- src/main/runtime/rpc/methods/github.ts | 3 +- src/main/runtime/rpc/methods/gitlab.ts | 4 +- .../runtime/rpc/methods/host-capabilities.ts | 4 +- src/main/runtime/rpc/methods/hosted-review.ts | 4 +- src/main/runtime/rpc/methods/index.ts | 3 +- src/main/runtime/rpc/methods/jira.ts | 4 +- .../rpc/methods/linear-agent-access.ts | 4 +- .../rpc/methods/linear-project-create.ts | 4 +- src/main/runtime/rpc/methods/linear.ts | 4 +- .../methods/mobile-markdown-tab-methods.ts | 4 +- src/main/runtime/rpc/methods/native-chat.ts | 4 +- src/main/runtime/rpc/methods/notifications.ts | 4 +- src/main/runtime/rpc/methods/orchestration.ts | 3 +- .../federated-release-safety.test.ts | 5 +- .../federation/federation-control.ts | 4 +- .../federation-liveness-verdict.test.ts | 9 +- .../federation/federation-methods.ts | 3 +- .../federation/federation-relay.ts | 2 +- .../orchestration/federation/federation.ts | 4 +- .../rpc/methods/orchestration/gates/gates.ts | 4 +- .../orchestration/messaging/ask-methods.ts | 4 +- .../orchestration/messaging/check-methods.ts | 4 +- .../check-worker-federated-attachment.test.ts | 6 +- .../messaging/message-methods.ts | 4 +- .../orchestration/messaging/send-methods.ts | 4 +- .../methods/orchestration/rpc-test-harness.ts | 4 +- .../orchestration/runs/dispatch-methods.ts | 4 +- .../runs/mutation-request-show.ts | 4 +- .../orchestration/runs/reset-methods.ts | 4 +- .../rpc/methods/orchestration/runs/runs.ts | 4 +- .../manual-dispatch-observation.test.ts | 15 ++- .../worker/manual-dispatch-release.test.ts | 5 +- .../orchestration/worker/worker-control.ts | 4 +- .../worker/worker-list-method.ts | 4 +- .../orchestration/worker/worker-methods.ts | 3 +- .../worker-release-mobile-report.test.ts | 4 +- .../worker/worker-release-recovery.test.ts | 6 +- .../worker/worker-release.test-support.ts | 4 +- .../orchestration/worker/worker-release.ts | 4 +- .../worker-stop-liveness-verdict.test.ts | 5 +- .../orchestration/worker/worker-stop.ts | 4 +- .../worker/workers-recovery.test.ts | 5 +- .../methods/orchestration/worker/workers.ts | 4 +- src/main/runtime/rpc/methods/pairing.ts | 4 +- src/main/runtime/rpc/methods/plugins.test.ts | 4 +- src/main/runtime/rpc/methods/plugins.ts | 4 +- src/main/runtime/rpc/methods/preflight.ts | 4 +- .../methods/project-runtime-rpc-methods.ts | 4 +- src/main/runtime/rpc/methods/repo.ts | 4 +- .../methods/runtime-client-capabilities.ts | 4 +- .../rpc/methods/session-tab-close-methods.ts | 4 +- .../methods/session-tab-markdown-methods.ts | 4 +- .../methods/session-tab-mutation-methods.ts | 4 +- src/main/runtime/rpc/methods/session-tabs.ts | 4 +- src/main/runtime/rpc/methods/skills.test.ts | 8 +- src/main/runtime/rpc/methods/skills.ts | 4 +- src/main/runtime/rpc/methods/speech.ts | 4 +- src/main/runtime/rpc/methods/ssh.ts | 4 +- src/main/runtime/rpc/methods/stats.ts | 4 +- src/main/runtime/rpc/methods/status.ts | 4 +- .../methods/structured-agent-session-hold.ts | 4 +- .../structured-agent-session-reveal.ts | 4 +- .../structured-agent-session-status-stream.ts | 4 +- .../rpc/methods/structured-agent-session.ts | 4 +- .../structured-worker-stop-receipt.test.ts | 5 +- .../terminal-create-idempotency.test.ts | 14 ++- ...terminal-manifest-characterization.test.ts | 5 +- .../runtime/rpc/methods/terminal-orphan.ts | 4 +- src/main/runtime/rpc/methods/terminal.ts | 3 +- .../terminal-inspect-process-params.test.ts | 7 +- .../terminal/terminal-lifecycle-methods.ts | 4 +- .../terminal/terminal-multiplex-method.ts | 4 +- .../terminal/terminal-query-methods.ts | 4 +- .../methods/terminal/terminal-send-method.ts | 4 +- .../terminal/terminal-subscribe-method.ts | 4 +- .../terminal/terminal-viewport-methods.ts | 6 +- src/main/runtime/rpc/methods/updater.test.ts | 5 +- src/main/runtime/rpc/methods/updater.ts | 4 +- .../runtime/rpc/methods/workspace-ports.ts | 4 +- .../rpc/methods/worktree-catalog-methods.ts | 4 +- src/main/runtime/rpc/methods/worktree.ts | 4 +- .../runtime-rpc/runtime-rpc-pairing-types.ts | 4 +- 124 files changed, 482 insertions(+), 280 deletions(-) create mode 100644 src/main/runtime/rpc/core-typed-method-contract.test.ts diff --git a/src/main/runtime/push/push-registration-rpc.test.ts b/src/main/runtime/push/push-registration-rpc.test.ts index d01b561417d..78a91a238fa 100644 --- a/src/main/runtime/push/push-registration-rpc.test.ts +++ b/src/main/runtime/push/push-registration-rpc.test.ts @@ -2,14 +2,14 @@ import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import type { RpcContext, RpcMethod } from '../rpc/core' +import { eraseRpcMethods, type RpcContext, type RpcMethod } from '../rpc/core' import { NOTIFICATION_METHODS } from '../rpc/methods/notifications' import { DeviceRegistry } from '../device-registry' import { OrcaRuntimeRpcServer } from '../runtime-rpc' import { OrcaRuntimeService } from '../orca-runtime' function method(name: string): RpcMethod { - const found = NOTIFICATION_METHODS.find((candidate) => candidate.name === name) + const found = eraseRpcMethods(NOTIFICATION_METHODS).find((candidate) => candidate.name === name) if (!found || 'stream' in found) { throw new Error(`${name} is not a one-shot RPC method`) } diff --git a/src/main/runtime/rpc/core-typed-method-contract.test.ts b/src/main/runtime/rpc/core-typed-method-contract.test.ts new file mode 100644 index 00000000000..fc1cdd1f97f --- /dev/null +++ b/src/main/runtime/rpc/core-typed-method-contract.test.ts @@ -0,0 +1,114 @@ +// The preserved types are the whole point of defineMethod, so they are asserted here: if a name +// widens to `string` or a result to `unknown`, these assertions fail at typecheck, not at runtime. +import { describe, expect, expectTypeOf, it } from 'vitest' +import { z } from 'zod' +import { + buildRegistry, + defineMethod, + defineStreamingMethod, + eraseRpcMethods, + isStreamingMethod, + type RpcContext, + type RpcMethod, + type RpcStreamingMethod +} from './core' +import type { ALL_RPC_METHODS } from './methods' +import { STATUS_METHODS } from './methods/status' +import type { HOST_CAPABILITY_METHODS } from './methods/host-capabilities' + +const ProbeParams = z.object({ id: z.string(), count: z.number().optional() }) + +const probe = defineMethod({ + name: 'test.typedProbe', + params: ProbeParams, + handler: (params) => ({ id: params.id, count: params.count ?? 0 }) +}) + +const schemalessProbe = defineMethod({ + name: 'test.schemalessProbe', + params: null, + handler: () => ['a', 'b'] +}) + +const streamingProbe = defineStreamingMethod({ + name: 'test.streamingProbe', + params: ProbeParams, + handler: async (params, _ctx, emit) => { + emit(params.id) + } +}) + +type ByName = Extract + +describe('defineMethod preserves the declared contract', () => { + it('keeps the literal method name', () => { + expectTypeOf(probe.name).toEqualTypeOf<'test.typedProbe'>() + expectTypeOf(streamingProbe.name).toEqualTypeOf<'test.streamingProbe'>() + expect(probe.name).toBe('test.typedProbe') + }) + + it('keeps the producer result type', () => { + expectTypeOf(probe.handler).returns.toEqualTypeOf<{ id: string; count: number }>() + expectTypeOf(schemalessProbe.handler).returns.toEqualTypeOf() + }) + + it('infers parsed params from the schema, and `void` without one', () => { + expectTypeOf(probe.handler) + .parameter(0) + .toEqualTypeOf<{ id: string; count?: number | undefined }>() + expectTypeOf(schemalessProbe.handler).parameter(0).toEqualTypeOf() + expectTypeOf(streamingProbe.handler) + .parameter(0) + .toEqualTypeOf<{ id: string; count?: number | undefined }>() + expectTypeOf(probe.params).toEqualTypeOf() + }) + + it('keeps a registered method addressable by its literal name', () => { + type StatusGet = ByName<(typeof STATUS_METHODS)[number], 'status.get'> + type ListDistros = ByName<(typeof HOST_CAPABILITY_METHODS)[number], 'host.wsl.listDistros'> + expectTypeOf().not.toBeNever() + expectTypeOf().returns.toExtend<{ runtimeId: string }>() + expectTypeOf().returns.toEqualTypeOf>() + // The manifest is the erasure boundary's input, so the literal names have to survive it too. + expectTypeOf>().not.toBeNever() + }) +}) + +describe('eraseRpcMethods is the registry boundary', () => { + it('erases to the shape the dispatcher calls, keeping the streaming split', () => { + expectTypeOf(eraseRpcMethods([probe])).toEqualTypeOf() + expectTypeOf(eraseRpcMethods([streamingProbe])).toEqualTypeOf() + expectTypeOf(eraseRpcMethods(STATUS_METHODS)).toEqualTypeOf() + expectTypeOf(eraseRpcMethods([probe])[0]!.handler) + .parameter(0) + .toEqualTypeOf() + }) + + it('returns the same methods, so nothing about the runtime value changes', () => { + const erased = eraseRpcMethods([probe, streamingProbe]) + + expect(erased[0]).toBe(probe) + expect(erased[1]).toBe(streamingProbe) + }) + + it('produces methods the registry accepts and the dispatcher can invoke', async () => { + const registry = buildRegistry([probe, streamingProbe, ...STATUS_METHODS]) + const registered = registry.get('test.typedProbe') + + expect(registered).toBe(probe) + expect(registry.get('status.get')).toBe(STATUS_METHODS[0]) + expect(isStreamingMethod(registry.get('test.streamingProbe')!)).toBe(true) + expect(registered && isStreamingMethod(registered)).toBe(false) + // The dispatcher parses params itself and then calls the erased handler with `unknown`. + const parsed: unknown = probe.params.parse({ id: 'a' }) + expect( + registered && !isStreamingMethod(registered) + ? await registered.handler(parsed, {} as RpcContext) + : undefined + ).toEqual({ id: 'a', count: 0 }) + }) + + it('rejects a duplicate name before erasure hides it', () => { + expect(() => buildRegistry([probe, probe])).toThrow('duplicate_rpc_method:test.typedProbe') + }) +}) diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index 702ea1b3aaa..c59c8da64d3 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -119,28 +119,27 @@ export type RpcContext = { ) => () => void } -export type RpcHandler = (params: TParams, ctx: RpcContext) => unknown +export type RpcHandler = (params: TParams, ctx: RpcContext) => TResult -// Why: RpcMethod erases the param type; centralizing the cast in defineMethod sidesteps RpcHandler's contravariance. -export type RpcMethod = { - readonly name: string - readonly params: ZodType | null - readonly handler: (params: unknown, ctx: RpcContext) => unknown +// Why: a schema-less method takes no params, so its handler must not be able to read the first argument. +type RpcParsedParams = TSchema extends ZodType + ? TSchema['_output'] + : void + +// Why: the authored shape — literal name, params schema, and producer result all survive for compile-time contracts. +export type RpcTypedMethod = { + readonly name: TName + readonly params: TSchema + readonly handler: RpcHandler, TResult> } -type DefineMethodSpec = { - name: string - params: TSchema - handler: RpcHandler -} - -export function defineMethod( - spec: DefineMethodSpec -): RpcMethod { +export function defineMethod( + spec: RpcTypedMethod +): RpcTypedMethod { return { name: spec.name, params: spec.params, - handler: spec.handler as RpcMethod['handler'] + handler: spec.handler } } @@ -150,6 +149,53 @@ export type RpcStreamingHandler = ( emit: (result: unknown) => void ) => Promise +// Why: emitted values stay `unknown` — the emit callback is an input, so there is no return position to infer them from. +export type RpcTypedStreamingMethod = { + readonly name: TName + readonly params: TSchema + readonly stream: true + readonly handler: RpcStreamingHandler> +} + +export function defineStreamingMethod( + spec: Omit, 'stream'> +): RpcTypedStreamingMethod { + return { + name: spec.name, + params: spec.params, + stream: true, + handler: spec.handler + } +} + +// Why `never` params: it makes the declaration a supertype of every parsed-params handler, so typed methods +// travel to the registry boundary — and only there get erased — without a cast in each methods module. +export type RpcMethodDeclaration = { + readonly name: string + readonly params: ZodType | null + readonly handler: (params: never, ctx: RpcContext) => unknown +} + +export type RpcStreamingMethodDeclaration = { + readonly name: string + readonly params: ZodType | null + readonly stream: true + readonly handler: ( + params: never, + ctx: RpcContext, + emit: (result: unknown) => void + ) => Promise +} + +export type RpcAnyMethodDeclaration = RpcMethodDeclaration | RpcStreamingMethodDeclaration + +// Why: RpcMethod is the registry's erased view; the dispatcher parses params itself and hands handlers `unknown`. +export type RpcMethod = { + readonly name: string + readonly params: ZodType | null + readonly handler: (params: unknown, ctx: RpcContext) => unknown +} + // Why: the `stream` flag lets the dispatcher route these to the emit-based path instead of the one-shot Promise path. export type RpcStreamingMethod = { readonly name: string @@ -162,34 +208,33 @@ export type RpcStreamingMethod = { ) => Promise } -type DefineStreamingMethodSpec = { - name: string - params: TSchema - handler: RpcStreamingHandler -} - -export function defineStreamingMethod( - spec: DefineStreamingMethodSpec -): RpcStreamingMethod { - return { - name: spec.name, - params: spec.params, - stream: true, - handler: spec.handler as RpcStreamingMethod['handler'] - } -} - export type RpcAnyMethod = RpcMethod | RpcStreamingMethod +// Why the overloads: erasure drops the parsed-params type, not the one-shot/streaming split the dispatcher routes on. +export function eraseRpcMethods(methods: readonly RpcMethodDeclaration[]): readonly RpcMethod[] +export function eraseRpcMethods( + methods: readonly RpcStreamingMethodDeclaration[] +): readonly RpcStreamingMethod[] +export function eraseRpcMethods( + methods: readonly RpcAnyMethodDeclaration[] +): readonly RpcAnyMethod[] +// Why: the one place the parsed-params type is dropped — contravariance makes it uncastable by assignment, and +// the dispatcher only ever calls a handler with an already-parsed `unknown`. Runtime value is untouched. +export function eraseRpcMethods( + methods: readonly RpcAnyMethodDeclaration[] +): readonly RpcAnyMethod[] { + return methods as readonly RpcAnyMethod[] +} + export function isStreamingMethod(method: RpcAnyMethod): method is RpcStreamingMethod { return 'stream' in method && method.stream === true } export type RpcRegistry = ReadonlyMap -export function buildRegistry(methods: readonly RpcAnyMethod[]): RpcRegistry { +export function buildRegistry(methods: readonly RpcAnyMethodDeclaration[]): RpcRegistry { const registry = new Map() - for (const method of methods) { + for (const method of eraseRpcMethods(methods)) { if (registry.has(method.name)) { throw new Error(`duplicate_rpc_method:${method.name}`) } diff --git a/src/main/runtime/rpc/dispatcher-request-parsing.ts b/src/main/runtime/rpc/dispatcher-request-parsing.ts index da4ec510e75..a4ada6df7c4 100644 --- a/src/main/runtime/rpc/dispatcher-request-parsing.ts +++ b/src/main/runtime/rpc/dispatcher-request-parsing.ts @@ -1,7 +1,7 @@ import { compile, type ZodType } from 'zod' import { formatZodError, - type RpcAnyMethod, + type RpcAnyMethodDeclaration, type RpcEnvelopeMeta, type RpcRequest, type RpcResponse @@ -12,7 +12,7 @@ const compiledParams = new WeakMap() export function parseRpcRequestParams( request: RpcRequest, - method: RpcAnyMethod, + method: RpcAnyMethodDeclaration, meta: RpcEnvelopeMeta ): { value: unknown; error?: undefined } | { value?: undefined; error: RpcResponse } { if (method.params === null) { diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index 73cfa596dd5..2c407207197 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -1,7 +1,7 @@ import { buildRegistry, isStreamingMethod, - type RpcAnyMethod, + type RpcAnyMethodDeclaration, type RpcEnvelopeMeta, type RpcRegistry, type RpcRequest, @@ -24,7 +24,10 @@ import { parseRpcRequestParams } from './dispatcher-request-parsing' import { RpcStreamingDispatcher } from './rpc-streaming-dispatcher' import { invokeDispatcherUnaryMethod } from './dispatcher-unary-method-invocation' -export type DispatcherOptions = { runtime: OrcaRuntimeService; methods?: readonly RpcAnyMethod[] } +export type DispatcherOptions = { + runtime: OrcaRuntimeService + methods?: readonly RpcAnyMethodDeclaration[] +} type DispatchCallOptions = RpcDispatchStreamingOptions diff --git a/src/main/runtime/rpc/methods/accounts.test.ts b/src/main/runtime/rpc/methods/accounts.test.ts index dc09f93fc22..ac8948422ac 100644 --- a/src/main/runtime/rpc/methods/accounts.test.ts +++ b/src/main/runtime/rpc/methods/accounts.test.ts @@ -2,11 +2,11 @@ import { describe, expect, it, vi } from 'vitest' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { OrcaRuntimeService } from '../../orca-runtime' -import { isStreamingMethod } from '../core' +import { eraseRpcMethods, isStreamingMethod } from '../core' import { ACCOUNT_METHODS } from './accounts' function method(name: string) { - const found = ACCOUNT_METHODS.find((candidate) => candidate.name === name) + const found = eraseRpcMethods(ACCOUNT_METHODS).find((candidate) => candidate.name === name) if (!found) { throw new Error(`Missing method ${name}`) } diff --git a/src/main/runtime/rpc/methods/accounts.ts b/src/main/runtime/rpc/methods/accounts.ts index 328276518d7..f7fe0af90ec 100644 --- a/src/main/runtime/rpc/methods/accounts.ts +++ b/src/main/runtime/rpc/methods/accounts.ts @@ -1,4 +1,4 @@ -import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' +import { defineMethod, defineStreamingMethod } from '../core' import { AccountsUnsubscribeParams, AddClaudeFromConfigDirParams, @@ -24,7 +24,7 @@ let accountsSubscriptionSeq = 0 // captures an already-authenticated CLAUDE_CONFIG_DIR (no PTY) so the local // `orca account add` CLI can register accounts on a headless host; it is gated // to the local runtime connection, never a mobile device token. See #1438. -export const ACCOUNT_METHODS: readonly RpcAnyMethod[] = [ +export const ACCOUNT_METHODS = [ defineMethod({ name: 'accounts.list', params: ListAccountsParams, diff --git a/src/main/runtime/rpc/methods/agent-hooks.test.ts b/src/main/runtime/rpc/methods/agent-hooks.test.ts index 5781045a1f7..f78e7709d6e 100644 --- a/src/main/runtime/rpc/methods/agent-hooks.test.ts +++ b/src/main/runtime/rpc/methods/agent-hooks.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../../orca-runtime' -import { isStreamingMethod, type RpcContext } from '../core' +import { eraseRpcMethods, isStreamingMethod, type RpcContext } from '../core' const { installForRuntimeHomeSerializedMock, realpathMock } = vi.hoisted(() => ({ installForRuntimeHomeSerializedMock: vi.fn(), @@ -23,7 +23,7 @@ const RUNTIME_HOME = '\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.local\\share\\orca\\codex-runtime-home\\home' function prepareMethod() { - const method = AGENT_HOOK_METHODS.find( + const method = eraseRpcMethods(AGENT_HOOK_METHODS).find( (candidate) => candidate.name === 'agentHooks.prepareCodexForWslPane' ) if (!method || isStreamingMethod(method)) { diff --git a/src/main/runtime/rpc/methods/agent-hooks.ts b/src/main/runtime/rpc/methods/agent-hooks.ts index e68c7df05df..4d9f4a7706c 100644 --- a/src/main/runtime/rpc/methods/agent-hooks.ts +++ b/src/main/runtime/rpc/methods/agent-hooks.ts @@ -1,8 +1,8 @@ import { prepareManagedWslCodexHomeBeforeShellLaunch } from '../../../codex/managed-wsl-home-shell-preflight' -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { PrepareCodexForWslPaneParams } from '../../../../shared/rpc-contract/agent-hooks-params' -export const AGENT_HOOK_METHODS: readonly RpcMethod[] = [ +export const AGENT_HOOK_METHODS = [ defineMethod({ name: 'agentHooks.prepareCodexForWslPane', params: PrepareCodexForWslPaneParams, diff --git a/src/main/runtime/rpc/methods/agent-session.ts b/src/main/runtime/rpc/methods/agent-session.ts index 79da783a939..84e008a7f55 100644 --- a/src/main/runtime/rpc/methods/agent-session.ts +++ b/src/main/runtime/rpc/methods/agent-session.ts @@ -10,7 +10,7 @@ import { parseAgentSessionOperationTimestamp } from '../../../../shared/agent-session-host-authority' import type { OrcaRuntimeService } from '../../orca-runtime' -import { defineMethod, type RpcAnyMethod } from '../core' +import { defineMethod } from '../core' import { CreateAgentSessionParams, EnsureAgentSessionParams @@ -58,7 +58,7 @@ function assertOperationTimestampWithinFutureSkew(clientOperationId: string): vo } } -export const AGENT_SESSION_METHODS: RpcAnyMethod[] = [ +export const AGENT_SESSION_METHODS = [ defineMethod({ name: 'terminal.ensureAgentSession', params: EnsureAgentSessionParams, diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index d5f52bafded..abc3aff2650 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { restampAiVaultListResult } from '../../../ai-vault/session-list-results' import type { AiVaultPrepareSessionResumeArgs } from '../../../../shared/ai-vault-resume-preparation' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' @@ -15,7 +15,7 @@ import { } from '../../../../shared/rpc-contract/ai-vault-params' export { AiVaultListSessionsParams, AiVaultPrepareSessionResumeParams, AiVaultSessionTitlesParams } -export const AI_VAULT_METHODS: RpcMethod[] = [ +export const AI_VAULT_METHODS = [ defineMethod({ name: 'aiVault.resolveSessionTitles', params: AiVaultSessionTitlesParams, diff --git a/src/main/runtime/rpc/methods/artifacts.ts b/src/main/runtime/rpc/methods/artifacts.ts index c627380d612..b6b91af8b0b 100644 --- a/src/main/runtime/rpc/methods/artifacts.ts +++ b/src/main/runtime/rpc/methods/artifacts.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcAnyMethod } from '../core' +import { defineMethod } from '../core' import { ArtifactsDeleteParams, ListOptions, @@ -6,7 +6,7 @@ import { WriteRequest } from '../../../../shared/rpc-contract/artifacts-params' -export const ARTIFACT_METHODS: readonly RpcAnyMethod[] = [ +export const ARTIFACT_METHODS = [ defineMethod({ name: 'artifacts.list', params: ListOptions, diff --git a/src/main/runtime/rpc/methods/automation-scoped-list-methods.test.ts b/src/main/runtime/rpc/methods/automation-scoped-list-methods.test.ts index f1d2081dc6b..6af7ba467e9 100644 --- a/src/main/runtime/rpc/methods/automation-scoped-list-methods.test.ts +++ b/src/main/runtime/rpc/methods/automation-scoped-list-methods.test.ts @@ -4,14 +4,14 @@ * current callers also receive owner metadata. */ import { describe, expect, it, vi } from 'vitest' -import type { RpcContext, RpcRequest } from '../core' +import { eraseRpcMethods, type RpcContext, type RpcRequest } from '../core' import { RpcDispatcher } from '../dispatcher' import type { OrcaRuntimeService } from '../../orca-runtime' import { AUTOMATION_METHODS } from './automations' import { AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' function method(name: string) { - const found = AUTOMATION_METHODS.find((entry) => entry.name === name) + const found = eraseRpcMethods(AUTOMATION_METHODS).find((entry) => entry.name === name) if (!found?.params) { throw new Error(`missing method ${name}`) } diff --git a/src/main/runtime/rpc/methods/automations.ts b/src/main/runtime/rpc/methods/automations.ts index ff5daca315c..b1e3eebe6eb 100644 --- a/src/main/runtime/rpc/methods/automations.ts +++ b/src/main/runtime/rpc/methods/automations.ts @@ -1,6 +1,6 @@ import { AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { AutomationOwnerPrecondition } from '../../../../shared/automation-owner-precondition' -import { defineMethod, type RpcContext, type RpcMethod } from '../core' +import { defineMethod, type RpcContext } from '../core' import { AutomationCreate, AutomationId, @@ -25,7 +25,7 @@ function mutationOwner( return context.runtime.automationOwnerPrecondition(id) ?? undefined } -export const AUTOMATION_METHODS: RpcMethod[] = [ +export const AUTOMATION_METHODS = [ defineMethod({ name: 'automation.list', params: AutomationList, diff --git a/src/main/runtime/rpc/methods/browser-client-file-channel.ts b/src/main/runtime/rpc/methods/browser-client-file-channel.ts index b2020488af2..362a1990531 100644 --- a/src/main/runtime/rpc/methods/browser-client-file-channel.ts +++ b/src/main/runtime/rpc/methods/browser-client-file-channel.ts @@ -7,7 +7,7 @@ import { BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY } from '../../../../shared/proto import { getBrowserClientDownloadTransferStore } from '../../browser-client-download-transfer-store' import { getBrowserHostLeaseRegistry } from '../../browser-host-lease-registry-instance' import { getRuntimeBrowserPageRegistry } from '../../runtime-browser-page-registry' -import { defineMethod, type RpcAnyMethod, type RpcContext } from '../core' +import { defineMethod, type RpcContext } from '../core' type FileChannelAuthorityParams = { browserHostClientId: string @@ -64,7 +64,7 @@ function requireFileChannelPage( return page } -export const BROWSER_CLIENT_FILE_CHANNEL_METHODS: RpcAnyMethod[] = [ +export const BROWSER_CLIENT_FILE_CHANNEL_METHODS = [ defineMethod({ name: 'browser.clientHost.fileChannel.read', params: BrowserClientFileChannelReadParams, diff --git a/src/main/runtime/rpc/methods/browser-client-host.ts b/src/main/runtime/rpc/methods/browser-client-host.ts index 525fd4fde96..e06632f7959 100644 --- a/src/main/runtime/rpc/methods/browser-client-host.ts +++ b/src/main/runtime/rpc/methods/browser-client-host.ts @@ -14,9 +14,9 @@ import { getRuntimeBrowserPageRegistry } from '../../runtime-browser-page-regist import { adoptRuntimeBrowserClientPagesFromInventory } from '../../runtime-browser-client-page-adoption' import { recoverUnavailableRuntimeBrowserClientPages } from '../../runtime-browser-client-page-recovery' import { releaseRuntimeBrowserClientPageRecord } from '../../runtime-browser-client-page-release' -import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' +import { defineMethod, defineStreamingMethod } from '../core' -export const BROWSER_CLIENT_HOST_METHODS: RpcAnyMethod[] = [ +export const BROWSER_CLIENT_HOST_METHODS = [ defineStreamingMethod({ name: 'browser.clientHost.attach', params: BrowserClientHostAttachParams, diff --git a/src/main/runtime/rpc/methods/browser-core.ts b/src/main/runtime/rpc/methods/browser-core.ts index 596c30a31c8..780f40fb373 100644 --- a/src/main/runtime/rpc/methods/browser-core.ts +++ b/src/main/runtime/rpc/methods/browser-core.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { BrowserTarget } from '../schemas' import { Check, @@ -35,7 +35,7 @@ import { BrowserOpenUrlParams, BrowserTabCreateParams } from './browser-tab-crea import { BROWSER_TEXT_METHODS } from './browser-text-rpc-methods' import { CertificateProceed } from '../../../../shared/rpc-contract/browser-core-params' -export const BROWSER_CORE_METHODS: RpcMethod[] = [ +export const BROWSER_CORE_METHODS = [ defineMethod({ name: 'browser.snapshot', params: BrowserTarget, diff --git a/src/main/runtime/rpc/methods/browser-extras.ts b/src/main/runtime/rpc/methods/browser-extras.ts index fe87bde950f..000838c4938 100644 --- a/src/main/runtime/rpc/methods/browser-extras.ts +++ b/src/main/runtime/rpc/methods/browser-extras.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { assertRpcClipboardTextWriteWithinLimit } from '../rpc-clipboard-text-validation' import { BrowserTarget } from '../schemas' import { @@ -23,7 +23,7 @@ import { } from './browser-schemas' import { MouseClick } from '../../../../shared/rpc-contract/browser-extras-params' -export const BROWSER_EXTRA_METHODS: RpcMethod[] = [ +export const BROWSER_EXTRA_METHODS = [ defineMethod({ name: 'browser.cookie.get', params: CookieGet, diff --git a/src/main/runtime/rpc/methods/browser-network-tunnel.ts b/src/main/runtime/rpc/methods/browser-network-tunnel.ts index 405419deef0..d610824b1f2 100644 --- a/src/main/runtime/rpc/methods/browser-network-tunnel.ts +++ b/src/main/runtime/rpc/methods/browser-network-tunnel.ts @@ -12,14 +12,14 @@ import { BROWSER_NETWORK_TUNNEL_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { getBrowserHostLeaseRegistry } from '../../browser-host-lease-registry-instance' -import { defineStreamingMethod, type RpcAnyMethod } from '../core' +import { defineStreamingMethod } from '../core' const outboundMemoryBudgets = new BrowserNetworkTunnelOutboundMemoryBudgetRegistry() export function createBrowserNetworkTunnelMethods( memoryBudgets: BrowserNetworkTunnelOutboundMemoryBudgetRegistry = outboundMemoryBudgets, resolveExecutionRoute: BrowserNetworkExecutionRouteResolver = resolveBrowserNetworkExecutionRoute -): RpcAnyMethod[] { +) { return [ defineStreamingMethod({ name: 'network.browserTunnel', diff --git a/src/main/runtime/rpc/methods/browser-screencast.ts b/src/main/runtime/rpc/methods/browser-screencast.ts index ed16e9d0edf..798e1ed84d2 100644 --- a/src/main/runtime/rpc/methods/browser-screencast.ts +++ b/src/main/runtime/rpc/methods/browser-screencast.ts @@ -1,11 +1,11 @@ -import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' +import { defineMethod, defineStreamingMethod } from '../core' import { Screencast } from './browser-schemas' import { BrowserError } from '../../../browser/browser-error' import { BROWSER_UNAVAILABLE_ERROR_CODE } from '../../../../shared/runtime-types' import { runtimeBrowserCommandsFactoryIsAvailable } from '../../runtime-browser-commands-factory' import { ScreencastUnsubscribe } from '../../../../shared/rpc-contract/browser-screencast-params' -export const BROWSER_SCREENCAST_METHODS: RpcAnyMethod[] = [ +export const BROWSER_SCREENCAST_METHODS = [ defineStreamingMethod({ name: 'browser.screencast', params: Screencast, diff --git a/src/main/runtime/rpc/methods/browser-text-rpc-methods.ts b/src/main/runtime/rpc/methods/browser-text-rpc-methods.ts index 813dbe9d2e2..18fd19a0e6a 100644 --- a/src/main/runtime/rpc/methods/browser-text-rpc-methods.ts +++ b/src/main/runtime/rpc/methods/browser-text-rpc-methods.ts @@ -1,8 +1,8 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { assertRpcClipboardTextWriteWithinLimit } from '../rpc-clipboard-text-validation' import { Fill, KeyboardInsert, Type } from './browser-schemas' -export const BROWSER_TEXT_METHODS: RpcMethod[] = [ +export const BROWSER_TEXT_METHODS = [ defineMethod({ name: 'browser.fill', params: Fill, diff --git a/src/main/runtime/rpc/methods/client-events.test.ts b/src/main/runtime/rpc/methods/client-events.test.ts index bf5026c819e..cae682ea4b4 100644 --- a/src/main/runtime/rpc/methods/client-events.test.ts +++ b/src/main/runtime/rpc/methods/client-events.test.ts @@ -1,11 +1,16 @@ import { describe, expect, it, vi } from 'vitest' import type { RuntimeClientEvent } from '../../../../shared/runtime-client-events' import type { OrcaRuntimeService } from '../../orca-runtime' -import { isStreamingMethod, type RpcContext, type RpcStreamingMethod } from '../core' +import { + eraseRpcMethods, + isStreamingMethod, + type RpcContext, + type RpcStreamingMethod +} from '../core' // Why: importing client-events directly trips its module-init cycle through ipc/ssh; the index resolves it. import { ALL_RPC_METHODS } from './index' -const subscribeMethod = ALL_RPC_METHODS.find( +const subscribeMethod = eraseRpcMethods(ALL_RPC_METHODS).find( (method) => method.name === 'runtime.clientEvents.subscribe' && isStreamingMethod(method) ) as RpcStreamingMethod diff --git a/src/main/runtime/rpc/methods/client-events.ts b/src/main/runtime/rpc/methods/client-events.ts index 6c23ed9f15d..e7506ad2f59 100644 --- a/src/main/runtime/rpc/methods/client-events.ts +++ b/src/main/runtime/rpc/methods/client-events.ts @@ -1,11 +1,11 @@ import { getRegisteredSshState, listRegisteredSshTargets } from '../../../ssh/ssh-target-registry' import { getPublicSshState } from '../../public-ssh-state' -import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' +import { defineMethod, defineStreamingMethod } from '../core' import { ClientEventsUnsubscribeParams } from '../../../../shared/rpc-contract/client-events-params' let clientEventSubscriptionSeq = 0 -export const CLIENT_EVENT_METHODS: readonly RpcAnyMethod[] = [ +export const CLIENT_EVENT_METHODS = [ defineStreamingMethod({ name: 'runtime.clientEvents.subscribe', params: null, diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index ffd964b6be6..6ed36a6fe83 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -1,6 +1,6 @@ import { omitPairingLocalUiFields } from '../../../../shared/pairing-local-ui-fields' import type { PersistedUIState } from '../../../../shared/persisted-ui-state-types' -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { NativeChatSessionOptionsMutation, PRBotAuthorOverrideUpdate, @@ -12,7 +12,7 @@ import { FeatureInteractionIdParam, UiUpdate } from './client-ui-schemas' import { TerminalQuickCommandsUpdate } from './terminal-quick-command-rpc-schema' -export const CLIENT_UI_METHODS: RpcMethod[] = [ +export const CLIENT_UI_METHODS = [ defineMethod({ name: 'settings.get', params: null, diff --git a/src/main/runtime/rpc/methods/clipboard.ts b/src/main/runtime/rpc/methods/clipboard.ts index e27b1cf1607..3ec78265dc6 100644 --- a/src/main/runtime/rpc/methods/clipboard.ts +++ b/src/main/runtime/rpc/methods/clipboard.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcContext, type RpcMethod } from '../core' +import { defineMethod, type RpcContext } from '../core' import { saveClipboardImageBufferAsTempFile } from '../../../window/clipboard-image-temp-file' import { randomUUID } from 'node:crypto' import { recordMobileClipboardImagePath } from '../mobile-clipboard-image-provenance' @@ -95,7 +95,7 @@ function assertValidBase64Content(value: string): void { } } -export const CLIPBOARD_METHODS: RpcMethod[] = [ +export const CLIPBOARD_METHODS = [ defineMethod({ name: 'clipboard.saveImageAsTempFile', params: SaveImageAsTempFile, diff --git a/src/main/runtime/rpc/methods/computer-actions.test.ts b/src/main/runtime/rpc/methods/computer-actions.test.ts index 96dc374e459..fa2b7d83ee1 100644 --- a/src/main/runtime/rpc/methods/computer-actions.test.ts +++ b/src/main/runtime/rpc/methods/computer-actions.test.ts @@ -27,6 +27,7 @@ vi.mock('../../../computer/macos-computer-use-permissions', () => ({ })) import { COMPUTER_METHODS, resetComputerSessionsForTest } from './computer' +import { eraseRpcMethods } from '../core' describe('computer action RPC methods', () => { beforeEach(() => { @@ -269,7 +270,7 @@ describe('computer action RPC methods', () => { }) function findMethod(name: string) { - const method = COMPUTER_METHODS.find((candidate) => candidate.name === name) + const method = eraseRpcMethods(COMPUTER_METHODS).find((candidate) => candidate.name === name) if (!method) { throw new Error(`missing method ${name}`) } diff --git a/src/main/runtime/rpc/methods/computer.test.ts b/src/main/runtime/rpc/methods/computer.test.ts index 6a01fac7d0a..073a1c0a363 100644 --- a/src/main/runtime/rpc/methods/computer.test.ts +++ b/src/main/runtime/rpc/methods/computer.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { buildRegistry } from '../core' +import { eraseRpcMethods, buildRegistry } from '../core' import { CLIPBOARD_TEXT_WRITE_MAX_BYTES } from '../../../../shared/clipboard-text' const computerMocks = vi.hoisted(() => ({ @@ -249,7 +249,7 @@ describe('computer RPC methods', () => { }) function findMethod(name: string) { - const method = COMPUTER_METHODS.find((candidate) => candidate.name === name) + const method = eraseRpcMethods(COMPUTER_METHODS).find((candidate) => candidate.name === name) if (!method) { throw new Error(`missing method ${name}`) } diff --git a/src/main/runtime/rpc/methods/computer.ts b/src/main/runtime/rpc/methods/computer.ts index 07459427bd4..e2708667633 100644 --- a/src/main/runtime/rpc/methods/computer.ts +++ b/src/main/runtime/rpc/methods/computer.ts @@ -6,7 +6,7 @@ import { callComputerSidecarSnapshot, resetComputerSidecarForTest } from '../../../computer/sidecar-client' -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { Click, ComputerObserveTarget, @@ -31,7 +31,7 @@ export function resetComputerSessionsForTest(): void { resetComputerSidecarForTest() } -export const COMPUTER_METHODS: RpcMethod[] = [ +export const COMPUTER_METHODS = [ defineMethod({ name: 'computer.capabilities', params: ComputerCapabilitiesParams, diff --git a/src/main/runtime/rpc/methods/diagnostics.ts b/src/main/runtime/rpc/methods/diagnostics.ts index 4d158d98f63..953eccd5d51 100644 --- a/src/main/runtime/rpc/methods/diagnostics.ts +++ b/src/main/runtime/rpc/methods/diagnostics.ts @@ -1,6 +1,6 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' -export const DIAGNOSTICS_METHODS: RpcMethod[] = [ +export const DIAGNOSTICS_METHODS = [ defineMethod({ name: 'diagnostics.memory', params: null, diff --git a/src/main/runtime/rpc/methods/emulator.ts b/src/main/runtime/rpc/methods/emulator.ts index 00c239658d9..b472e539603 100644 --- a/src/main/runtime/rpc/methods/emulator.ts +++ b/src/main/runtime/rpc/methods/emulator.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import path from 'node:path' import { z } from 'zod' import { @@ -32,7 +32,7 @@ const InstallParams = z.object({ worktree: z.string().optional() }) -export const EMULATOR_METHODS: RpcMethod[] = [ +export const EMULATOR_METHODS = [ defineMethod({ name: 'emulator.list', params: ListParams, diff --git a/src/main/runtime/rpc/methods/files-mutation-methods.ts b/src/main/runtime/rpc/methods/files-mutation-methods.ts index 3c7e4231b96..eb734d5c8d6 100644 --- a/src/main/runtime/rpc/methods/files-mutation-methods.ts +++ b/src/main/runtime/rpc/methods/files-mutation-methods.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcAnyMethod } from '../core' +import { defineMethod } from '../core' import { FileCommitUpload, FileCopy, @@ -33,7 +33,7 @@ function sshMutationArguments( ] } -export const FILE_MUTATION_METHODS: RpcAnyMethod[] = [ +export const FILE_MUTATION_METHODS = [ defineMethod({ name: 'files.write', params: FileWrite, diff --git a/src/main/runtime/rpc/methods/files-terminal-artifact-methods.ts b/src/main/runtime/rpc/methods/files-terminal-artifact-methods.ts index cc343dd3ca1..20d52eb424d 100644 --- a/src/main/runtime/rpc/methods/files-terminal-artifact-methods.ts +++ b/src/main/runtime/rpc/methods/files-terminal-artifact-methods.ts @@ -1,11 +1,11 @@ -import { defineMethod, type RpcAnyMethod } from '../core' +import { defineMethod } from '../core' import { remoteFileContentBudget } from './files-remote-content-budget' import { TerminalArtifactFile, TerminalArtifactFileWrite } from '../../../../shared/rpc-contract/files-terminal-artifact-params' -export const FILE_TERMINAL_ARTIFACT_METHODS: RpcAnyMethod[] = [ +export const FILE_TERMINAL_ARTIFACT_METHODS = [ defineMethod({ name: 'files.readTerminalArtifact', params: TerminalArtifactFile, diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts index 6f5cdbe4b34..055c02b3265 100644 --- a/src/main/runtime/rpc/methods/files.ts +++ b/src/main/runtime/rpc/methods/files.ts @@ -1,4 +1,4 @@ -import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' +import { defineMethod, defineStreamingMethod } from '../core' import { runFileWatchStream } from './file-watch-stream-lifecycle' import { FILE_MUTATION_METHODS } from './files-mutation-methods' import { remoteFileContentBudget } from './files-remote-content-budget' @@ -21,7 +21,7 @@ import { let filesWatchSubscriptionSeq = 0 -export const FILE_METHODS: RpcAnyMethod[] = [ +export const FILE_METHODS = [ defineMethod({ name: 'files.list', params: WorktreeSelector, diff --git a/src/main/runtime/rpc/methods/folder-workspace.ts b/src/main/runtime/rpc/methods/folder-workspace.ts index e0f780e710a..a2e178b8ed9 100644 --- a/src/main/runtime/rpc/methods/folder-workspace.ts +++ b/src/main/runtime/rpc/methods/folder-workspace.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { resolveRpcWorkspaceCreatorProvenance } from '../workspace-creator-context' import { FolderWorkspaceCreate, @@ -7,7 +7,7 @@ import { FolderWorkspaceUpdate } from '../../../../shared/rpc-contract/folder-workspace-params' -export const FOLDER_WORKSPACE_METHODS: RpcMethod[] = [ +export const FOLDER_WORKSPACE_METHODS = [ defineMethod({ name: 'folderWorkspace.list', params: null, diff --git a/src/main/runtime/rpc/methods/git-commit-message-generation-methods.ts b/src/main/runtime/rpc/methods/git-commit-message-generation-methods.ts index 787c8581dea..1dffcfb2211 100644 --- a/src/main/runtime/rpc/methods/git-commit-message-generation-methods.ts +++ b/src/main/runtime/rpc/methods/git-commit-message-generation-methods.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { ResolvedSourceControlAiGenerationParams } from '../../../../shared/source-control-ai' import { @@ -58,7 +58,7 @@ function buildCommitMessageGenerationOverride(params: { } } -export const GIT_COMMIT_MESSAGE_GENERATION_METHODS: RpcMethod[] = [ +export const GIT_COMMIT_MESSAGE_GENERATION_METHODS = [ defineMethod({ name: 'git.generateCommitMessage', params: GitGenerateCommitMessage, diff --git a/src/main/runtime/rpc/methods/git-diff-methods.ts b/src/main/runtime/rpc/methods/git-diff-methods.ts index 7b5c0661c0e..edcaebbdf42 100644 --- a/src/main/runtime/rpc/methods/git-diff-methods.ts +++ b/src/main/runtime/rpc/methods/git-diff-methods.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { remoteRpcContentBudget } from '../../../../shared/remote-rpc-content-budget' import { GitBranchDiff, GitCommitDiff, GitDiff } from './git-params' @@ -11,7 +11,7 @@ function remoteDiffContentBudget( return clientKind && requestId ? remoteRpcContentBudget(requestId) : undefined } -export const GIT_DIFF_METHODS: RpcMethod[] = [ +export const GIT_DIFF_METHODS = [ defineMethod({ name: 'git.diff', params: GitDiff, diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index ddfbe7bf273..20102d71e28 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { GIT_COMMIT_MESSAGE_GENERATION_METHODS } from './git-commit-message-generation-methods' import { GIT_DIFF_METHODS } from './git-diff-methods' import { @@ -21,7 +21,7 @@ import { WorktreeSelector } from './git-params' -export const GIT_METHODS: RpcMethod[] = [ +export const GIT_METHODS = [ defineMethod({ name: 'git.status', params: GitStatusParams, diff --git a/src/main/runtime/rpc/methods/github-issue-methods.ts b/src/main/runtime/rpc/methods/github-issue-methods.ts index 40ed162f28b..86300017741 100644 --- a/src/main/runtime/rpc/methods/github-issue-methods.ts +++ b/src/main/runtime/rpc/methods/github-issue-methods.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { CreateIssue, Issue, @@ -6,7 +6,7 @@ import { UpdateIssue } from '../../../../shared/rpc-contract/github-issue-params' -export const GITHUB_ISSUE_METHODS: RpcMethod[] = [ +export const GITHUB_ISSUE_METHODS = [ defineMethod({ name: 'github.issue', params: Issue, diff --git a/src/main/runtime/rpc/methods/github-project-methods.ts b/src/main/runtime/rpc/methods/github-project-methods.ts index 4cd2641b11a..c05086decbf 100644 --- a/src/main/runtime/rpc/methods/github-project-methods.ts +++ b/src/main/runtime/rpc/methods/github-project-methods.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { SlugRepo } from './github-repo-target-schemas' import { ClearProjectItemField, @@ -17,7 +17,7 @@ import { SlugPullRequestUpdate } from '../../../../shared/rpc-contract/github-project-params' -export const GITHUB_PROJECT_METHODS: RpcMethod[] = [ +export const GITHUB_PROJECT_METHODS = [ defineMethod({ name: 'github.project.listAccessible', params: GithubProjectListAccessibleParams, diff --git a/src/main/runtime/rpc/methods/github-pull-request-methods.ts b/src/main/runtime/rpc/methods/github-pull-request-methods.ts index f7e66d41008..0a7f2efd522 100644 --- a/src/main/runtime/rpc/methods/github-pull-request-methods.ts +++ b/src/main/runtime/rpc/methods/github-pull-request-methods.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { PRCommentReaction, PrForBranch, @@ -11,7 +11,7 @@ import { ReviewThread } from '../../../../shared/rpc-contract/github-pull-request-params' -export const GITHUB_PULL_REQUEST_METHODS: RpcMethod[] = [ +export const GITHUB_PULL_REQUEST_METHODS = [ defineMethod({ name: 'github.prForBranch', params: PrForBranch, diff --git a/src/main/runtime/rpc/methods/github-pull-request-update-methods.ts b/src/main/runtime/rpc/methods/github-pull-request-update-methods.ts index ca5bdcabbab..59089b82015 100644 --- a/src/main/runtime/rpc/methods/github-pull-request-update-methods.ts +++ b/src/main/runtime/rpc/methods/github-pull-request-update-methods.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { MarkPrReadyForReview, MergePr, @@ -12,7 +12,7 @@ import { UpdatePrTitle } from '../../../../shared/rpc-contract/github-pull-request-update-params' -export const GITHUB_PULL_REQUEST_UPDATE_METHODS: RpcMethod[] = [ +export const GITHUB_PULL_REQUEST_UPDATE_METHODS = [ defineMethod({ name: 'github.updatePRTitle', params: UpdatePrTitle, diff --git a/src/main/runtime/rpc/methods/github-repo-work-item-methods.ts b/src/main/runtime/rpc/methods/github-repo-work-item-methods.ts index d4722ec1466..6b2d83988d7 100644 --- a/src/main/runtime/rpc/methods/github-repo-work-item-methods.ts +++ b/src/main/runtime/rpc/methods/github-repo-work-item-methods.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { RepoSelector } from './github-repo-target-schemas' import { IssuesList, @@ -10,7 +10,7 @@ import { WorkItemsList } from '../../../../shared/rpc-contract/github-repo-work-item-params' -export const GITHUB_REPO_WORK_ITEM_METHODS: RpcMethod[] = [ +export const GITHUB_REPO_WORK_ITEM_METHODS = [ defineMethod({ name: 'github.repoSlug', params: RepoSelector, diff --git a/src/main/runtime/rpc/methods/github.ts b/src/main/runtime/rpc/methods/github.ts index 1dd4cb28823..d2113f9ac22 100644 --- a/src/main/runtime/rpc/methods/github.ts +++ b/src/main/runtime/rpc/methods/github.ts @@ -1,11 +1,10 @@ -import type { RpcMethod } from '../core' import { GITHUB_ISSUE_METHODS } from './github-issue-methods' import { GITHUB_PROJECT_METHODS } from './github-project-methods' import { GITHUB_PULL_REQUEST_METHODS } from './github-pull-request-methods' import { GITHUB_PULL_REQUEST_UPDATE_METHODS } from './github-pull-request-update-methods' import { GITHUB_REPO_WORK_ITEM_METHODS } from './github-repo-work-item-methods' -export const GITHUB_METHODS: RpcMethod[] = [ +export const GITHUB_METHODS = [ ...GITHUB_REPO_WORK_ITEM_METHODS, ...GITHUB_ISSUE_METHODS, ...GITHUB_PULL_REQUEST_METHODS, diff --git a/src/main/runtime/rpc/methods/gitlab.ts b/src/main/runtime/rpc/methods/gitlab.ts index ba840447cfd..93f73d5f05c 100644 --- a/src/main/runtime/rpc/methods/gitlab.ts +++ b/src/main/runtime/rpc/methods/gitlab.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { normalizeGitLabIssueListArgs } from '../../../gitlab/gitlab-preload-args' import { toGitLabJobLogExcerptResult } from '../../../../shared/gitlab-job-log-excerpt' import { @@ -23,7 +23,7 @@ import { WorkItemsList } from '../../../../shared/rpc-contract/gitlab-params' -export const GITLAB_METHODS: RpcMethod[] = [ +export const GITLAB_METHODS = [ defineMethod({ name: 'gitlab.listMRs', params: WorkItemsList, diff --git a/src/main/runtime/rpc/methods/host-capabilities.ts b/src/main/runtime/rpc/methods/host-capabilities.ts index afa1af88474..85a32fd1e5c 100644 --- a/src/main/runtime/rpc/methods/host-capabilities.ts +++ b/src/main/runtime/rpc/methods/host-capabilities.ts @@ -1,9 +1,9 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { isPwshAvailableAsync } from '../../../pwsh' import { isWslAvailableAsync, listWslDistrosAsync } from '../../../wsl' import { isGitBashAvailable } from '../../../git-bash' -export const HOST_CAPABILITY_METHODS: RpcMethod[] = [ +export const HOST_CAPABILITY_METHODS = [ defineMethod({ name: 'host.platform', params: null, diff --git a/src/main/runtime/rpc/methods/hosted-review.ts b/src/main/runtime/rpc/methods/hosted-review.ts index 84b297ffa4e..51d663210d8 100644 --- a/src/main/runtime/rpc/methods/hosted-review.ts +++ b/src/main/runtime/rpc/methods/hosted-review.ts @@ -1,11 +1,11 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { HostedReviewCreate, HostedReviewCreationEligibility, HostedReviewForBranch } from '../../../../shared/rpc-contract/hosted-review-params' -export const HOSTED_REVIEW_METHODS: RpcMethod[] = [ +export const HOSTED_REVIEW_METHODS = [ defineMethod({ name: 'hostedReview.forBranch', params: HostedReviewForBranch, diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index ba77b94803e..3a53bccb9ce 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -1,4 +1,3 @@ -import type { RpcAnyMethod } from '../core' import { STATUS_METHODS } from './status' import { AI_VAULT_METHODS } from './ai-vault' import { AUTOMATION_METHODS } from './automations' @@ -50,7 +49,7 @@ import { AGENT_HOOK_METHODS } from './agent-hooks' // Why: a flat manifest keeps registration order explicit and provides one // grep-point for "what methods does the RPC server expose?" — useful when // auditing the security boundary or wiring new CLI commands. -export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ +export const ALL_RPC_METHODS = [ ...STATUS_METHODS, ...AGENT_HOOK_METHODS, ...AI_VAULT_METHODS, diff --git a/src/main/runtime/rpc/methods/jira.ts b/src/main/runtime/rpc/methods/jira.ts index 4463c969919..0e959cf0f5e 100644 --- a/src/main/runtime/rpc/methods/jira.ts +++ b/src/main/runtime/rpc/methods/jira.ts @@ -2,7 +2,7 @@ import { JIRA_PAYLOAD_CHUNK_CHARS, JIRA_PAYLOAD_MAX_CHARS } from '../../../../shared/jira-payload-stream' -import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' +import { defineMethod, defineStreamingMethod } from '../core' import { AssignableUsers, Connect, @@ -34,7 +34,7 @@ function emitJiraPayload(value: unknown, emit: (result: unknown) => void): void emit({ type: 'end' }) } -export const JIRA_METHODS: RpcAnyMethod[] = [ +export const JIRA_METHODS = [ defineMethod({ name: 'jira.connect', params: Connect, diff --git a/src/main/runtime/rpc/methods/linear-agent-access.ts b/src/main/runtime/rpc/methods/linear-agent-access.ts index e76c843dede..2c46face39d 100644 --- a/src/main/runtime/rpc/methods/linear-agent-access.ts +++ b/src/main/runtime/rpc/methods/linear-agent-access.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { linearError } from '../../../linear/issue-context-errors' import { isLinearUuid } from '../../../../shared/linear/uuid' import { @@ -28,7 +28,7 @@ function parseLinearWriteId(writeId: string | undefined): string | undefined { return writeId } -export const LINEAR_AGENT_ACCESS_METHODS: RpcMethod[] = [ +export const LINEAR_AGENT_ACCESS_METHODS = [ defineMethod({ name: 'linear.saveIssue', params: LinearSaveIssue, diff --git a/src/main/runtime/rpc/methods/linear-project-create.ts b/src/main/runtime/rpc/methods/linear-project-create.ts index f2c020f6656..8377b69acdf 100644 --- a/src/main/runtime/rpc/methods/linear-project-create.ts +++ b/src/main/runtime/rpc/methods/linear-project-create.ts @@ -1,7 +1,7 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { CreateProject } from '../../../../shared/rpc-contract/linear-project-create-params' -export const LINEAR_PROJECT_CREATE_METHOD: RpcMethod = defineMethod({ +export const LINEAR_PROJECT_CREATE_METHOD = defineMethod({ name: 'linear.createProject', params: CreateProject, handler: async (params, { runtime }) => diff --git a/src/main/runtime/rpc/methods/linear.ts b/src/main/runtime/rpc/methods/linear.ts index f0ec2106830..456b4c5b4e9 100644 --- a/src/main/runtime/rpc/methods/linear.ts +++ b/src/main/runtime/rpc/methods/linear.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { LINEAR_PROJECT_CREATE_METHOD } from './linear-project-create' import { LINEAR_ISSUE_LIST_METHOD, LINEAR_MCP_ISSUE_LIST_METHOD } from './linear-issue-list-method' import { @@ -20,7 +20,7 @@ import { WorkspaceSelection } from '../../../../shared/rpc-contract/linear-params' -export const LINEAR_METHODS: RpcMethod[] = [ +export const LINEAR_METHODS = [ defineMethod({ name: 'linear.connect', params: Connect, diff --git a/src/main/runtime/rpc/methods/mobile-markdown-tab-methods.ts b/src/main/runtime/rpc/methods/mobile-markdown-tab-methods.ts index dcba8b7b64e..756d4719f22 100644 --- a/src/main/runtime/rpc/methods/mobile-markdown-tab-methods.ts +++ b/src/main/runtime/rpc/methods/mobile-markdown-tab-methods.ts @@ -1,7 +1,7 @@ -import { defineMethod, type RpcAnyMethod } from '../core' +import { defineMethod } from '../core' import { ActivateTab, SaveMarkdownTab } from './session-tabs-schemas' -export const MOBILE_MARKDOWN_TAB_METHODS: RpcAnyMethod[] = [ +export const MOBILE_MARKDOWN_TAB_METHODS = [ defineMethod({ name: 'markdown.readTab', params: ActivateTab, diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts index 305e8adb771..05f8f7f8726 100644 --- a/src/main/runtime/rpc/methods/native-chat.ts +++ b/src/main/runtime/rpc/methods/native-chat.ts @@ -5,7 +5,7 @@ import { type NativeChatTranscriptSubscription, type SubscribeNativeChatTranscriptArgs } from '../../../native-chat/transcript-watch' -import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core' +import { defineMethod, defineStreamingMethod, type RpcContext } from '../core' import { sanitizeNativeChatRpcBlock } from './native-chat-rpc-block-sanitize' import { MOBILE_NATIVE_CHAT_MAX_WINDOW, @@ -63,7 +63,7 @@ function windowForClient( return windowed.map((message) => sanitizeMessage(message, clientKind)) } -export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ +export const NATIVE_CHAT_METHODS = [ defineMethod({ name: 'nativeChat.readSession', params: NativeChatSession, diff --git a/src/main/runtime/rpc/methods/notifications.ts b/src/main/runtime/rpc/methods/notifications.ts index 2b9e05fb6f5..8168da70cae 100644 --- a/src/main/runtime/rpc/methods/notifications.ts +++ b/src/main/runtime/rpc/methods/notifications.ts @@ -1,5 +1,5 @@ import { createNotificationStreamFilter } from './notification-stream-policy' -import { defineStreamingMethod, defineMethod, type RpcAnyMethod } from '../core' +import { defineStreamingMethod, defineMethod } from '../core' import { NotificationGetMissedSinceParams, NotificationRegisterPushParams, @@ -13,7 +13,7 @@ import { let notificationsSubscriptionSeq = 0 // Legacy callers retain filtered socket alerts; push clients opt into the full event stream. -export const NOTIFICATION_METHODS: readonly RpcAnyMethod[] = [ +export const NOTIFICATION_METHODS = [ defineStreamingMethod({ name: 'notifications.subscribe', params: NotificationsSubscribeParams, diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index ed89ae4519d..ab80b91e830 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -1,4 +1,3 @@ -import type { RpcMethod } from '../core' import { ORCHESTRATION_RUN_METHODS } from './orchestration/runs/runs' import { ORCHESTRATION_WORKER_METHODS } from './orchestration/worker/worker-methods' import { ORCHESTRATION_FEDERATION_METHODS } from './orchestration/federation/federation-methods' @@ -11,7 +10,7 @@ import { ORCHESTRATION_ASK_METHODS } from './orchestration/messaging/ask-methods import { ORCHESTRATION_GATE_METHODS } from './orchestration/gates/gates' import { ORCHESTRATION_RESET_METHODS } from './orchestration/runs/reset-methods' -export const ORCHESTRATION_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_METHODS = [ ...ORCHESTRATION_RUN_METHODS, ...ORCHESTRATION_WORKER_METHODS, ...ORCHESTRATION_FEDERATION_METHODS, diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federated-release-safety.test.ts b/src/main/runtime/rpc/methods/orchestration/federation/federated-release-safety.test.ts index d5150dc052e..87ac8f65356 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federated-release-safety.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federated-release-safety.test.ts @@ -3,6 +3,7 @@ import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../../../shared/protoco import { OrcaRuntimeService } from '../../../../orca-runtime' import { OrchestrationDb } from '../../../../orchestration/db' import { ORCHESTRATION_METHODS } from '../../orchestration' +import { eraseRpcMethods } from '../../../core' const HOME_FINGERPRINT = 'home-peer' const PANE_KEY = 'tab_remote:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' @@ -191,7 +192,9 @@ describe('federated worker release ownership', () => { dispatchId: string, params: Record = { dispatchId } ): Promise { - const method = ORCHESTRATION_METHODS.find((candidate) => candidate.name === name) + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find( + (candidate) => candidate.name === name + ) if (!method) { throw new Error(`Method not found: ${name}`) } diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-control.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-control.ts index 0ab3cce34aa..caec51d462e 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation-control.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-control.ts @@ -1,6 +1,6 @@ import { OrchestrationError } from '../../../../orchestration/orchestration-error' import type { RemoteDispatchAttachmentRow } from '../../../../orchestration/types' -import { defineMethod, type RpcMethod } from '../../../core' +import { defineMethod } from '../../../core' import { mapWithConcurrency } from '../../../../../../shared/map-with-concurrency' import { readExactWorkerOutput } from '../worker/worker-output' import { describeUnconfirmedAgentStop } from '../../../../../../shared/pty-liveness-verdict' @@ -16,7 +16,7 @@ import { FederationReadParams } from '../../../../../../shared/rpc-contract/orchestration-federation-control-params' -export const ORCHESTRATION_FEDERATION_CONTROL_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_FEDERATION_CONTROL_METHODS = [ defineMethod({ name: 'orchestration.federationFleetSnapshot', params: FederationFleetSnapshotParams, diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-liveness-verdict.test.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-liveness-verdict.test.ts index 7c75c52eb6c..dc5989fff2a 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation-liveness-verdict.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-liveness-verdict.test.ts @@ -4,6 +4,7 @@ import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../../../shared/protoco import { OrcaRuntimeService } from '../../../../orca-runtime' import { OrchestrationDb } from '../../../../orchestration/db' import { ORCHESTRATION_METHODS } from '../../orchestration' +import { eraseRpcMethods } from '../../../core' // The federation host runs its own copy of the observation and stop logic, so // it needs the same rule: lost contact with a worker's host is not an exit, and @@ -87,7 +88,9 @@ describe('federation host liveness verdicts', () => { afterEach(() => db.close()) async function call(name: string, params: Record) { - const method = ORCHESTRATION_METHODS.find((candidate) => candidate.name === name) + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find( + (candidate) => candidate.name === name + ) if (!method) { throw new Error(`Method not found: ${name}`) } @@ -138,7 +141,9 @@ describe('federation host liveness verdicts', () => { }) hostDb.markRemoteAttachmentReady(DISPATCH_ID) const callHost = async (name: string, params: Record) => { - const method = ORCHESTRATION_METHODS.find((candidate) => candidate.name === name) + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find( + (candidate) => candidate.name === name + ) if (!method) { throw new Error(`Method not found: ${name}`) } diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-methods.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-methods.ts index fbdbda6c7ca..589b69f3934 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-methods.ts @@ -1,9 +1,8 @@ -import type { RpcMethod } from '../../../core' import { ORCHESTRATION_FEDERATION_CONTROL_METHODS } from './federation-control' import { ORCHESTRATION_FEDERATION_RELAY_METHODS } from './federation-relay' import { ORCHESTRATION_FEDERATION_ATTACH_METHODS } from './federation' -export const ORCHESTRATION_FEDERATION_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_FEDERATION_METHODS = [ ...ORCHESTRATION_FEDERATION_ATTACH_METHODS, ...ORCHESTRATION_FEDERATION_RELAY_METHODS, ...ORCHESTRATION_FEDERATION_CONTROL_METHODS diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-relay.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-relay.ts index 58561e5e044..721232f194a 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation-relay.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-relay.ts @@ -13,7 +13,7 @@ import { FederationPullParams } from '../../../../../../shared/rpc-contract/orchestration-federation-relay-params' -export const ORCHESTRATION_FEDERATION_RELAY_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_FEDERATION_RELAY_METHODS = [ defineMethod({ name: 'orchestration.federationPull', params: FederationPullParams, diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts index 785f6a67eec..dbf6a5f4b5e 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts @@ -1,7 +1,7 @@ import type { TuiAgent } from '../../../../../../shared/tui-agent' import { buildDispatchPreamble } from '../../../../orchestration/preamble' import { OrchestrationError } from '../../../../orchestration/orchestration-error' -import { defineMethod, type RpcMethod } from '../../../core' +import { defineMethod } from '../../../core' import { assertOrchestrationWorktreeCreationSupported } from '../worker/folder-worktree-placement' import { appendFederationSetupEffect, @@ -24,7 +24,7 @@ import { } from '../../../../../../shared/orchestration-timing-budgets' import { assertWorkerStartTaskSpecWithinPromptBudget } from '../worker/worker-start-prompt-budget' -export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_FEDERATION_ATTACH_METHODS = [ defineMethod({ name: 'orchestration.federationAttachStart', params: FederationAttachStartParams, diff --git a/src/main/runtime/rpc/methods/orchestration/gates/gates.ts b/src/main/runtime/rpc/methods/orchestration/gates/gates.ts index 6bb8750628e..29be91b4324 100644 --- a/src/main/runtime/rpc/methods/orchestration/gates/gates.ts +++ b/src/main/runtime/rpc/methods/orchestration/gates/gates.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../../../core' +import { defineMethod } from '../../../core' import type { GateStatus } from '../../../../orchestration/db' import { Coordinator } from '../../../../orchestration/coordinator' import { resolveRunScope } from '../runs/run-scope' @@ -16,7 +16,7 @@ import { // the DB's active-run check), so a single reference suffices. let activeCoordinator: Coordinator | null = null -export const ORCHESTRATION_GATE_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_GATE_METHODS = [ // Why: Section 4.12 — orchestration.run returns immediately with a run ID. // The coordinator loop runs in the background; progress is queried via // orchestration.taskList. This prevents the RPC call from blocking the diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/ask-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/ask-methods.ts index f622cc9e67a..ef191250a8b 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/ask-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/ask-methods.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../../../core' +import { defineMethod } from '../../../core' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import { clampOrchestrationAskTimeoutMs } from '../../../../../../shared/orchestration-ask-timeout' import { isGroupAddress } from '../../../../orchestration/groups' @@ -6,7 +6,7 @@ import { AskParams } from '../schemas' import { rejectFederatedExplicitTarget } from '../routing' import { askRemoteRunHome } from './ask-remote' -export const ORCHESTRATION_ASK_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_ASK_METHODS = [ defineMethod({ name: 'orchestration.ask', params: AskParams, diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts index a12428253c3..20ac25e6512 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../../../core' +import { defineMethod } from '../../../core' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import { CheckParams } from '../schemas' import { parseMessageTypes } from '../routing' @@ -12,7 +12,7 @@ import { isSupersededDispatch } from './dispatch-mailbox-fence' -export const ORCHESTRATION_CHECK_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_CHECK_METHODS = [ defineMethod({ name: 'orchestration.check', params: CheckParams, diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/check-worker-federated-attachment.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/check-worker-federated-attachment.test.ts index 58e0b3aa0ca..f0d5e4933a6 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/check-worker-federated-attachment.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/check-worker-federated-attachment.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { ORCHESTRATION_METHODS } from '../../orchestration' -import type { RpcContext } from '../../../core' +import { eraseRpcMethods, type RpcContext } from '../../../core' import { OrchestrationDb } from '../../../../orchestration/db' import { OrcaRuntimeService } from '../../../../orca-runtime' import { @@ -55,7 +55,9 @@ describe('orchestration.check on a federated attachment across a restart', () => } function check(ctx: RpcContext, params: Record = {}): Promise { - const method = ORCHESTRATION_METHODS.find((entry) => entry.name === 'orchestration.check') + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find( + (entry) => entry.name === 'orchestration.check' + ) if (!method) { throw new Error('orchestration.check is not registered') } diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/message-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/message-methods.ts index 61808c19aed..51a9d9bc0c5 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/message-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/message-methods.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../../../core' +import { defineMethod } from '../../../core' import type { TaskStatus } from '../../../../orchestration/db' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../../../../shared/orchestration-rpc-contract' @@ -19,7 +19,7 @@ import { TaskUpdateParams } from '../schemas' -export const ORCHESTRATION_MESSAGE_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_MESSAGE_METHODS = [ defineMethod({ name: 'orchestration.reply', params: ReplyParams, diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts index 567149e69d8..7d1edb77602 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../../../core' +import { defineMethod } from '../../../core' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import { isGroupAddress } from '../../../../orchestration/groups' import { orchestrationSkillRecoveryData } from '../../../../../../shared/orchestration-rpc-contract' @@ -20,7 +20,7 @@ import { sendPointToPointMessage } from './send-point-to-point' import { sendGroupMessage } from './send-group' import { sendFederatedControlMail } from './send-control-mail' -export const ORCHESTRATION_SEND_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_SEND_METHODS = [ defineMethod({ name: 'orchestration.send', params: SendParams, diff --git a/src/main/runtime/rpc/methods/orchestration/rpc-test-harness.ts b/src/main/runtime/rpc/methods/orchestration/rpc-test-harness.ts index dfba4bd143f..19e636eca21 100644 --- a/src/main/runtime/rpc/methods/orchestration/rpc-test-harness.ts +++ b/src/main/runtime/rpc/methods/orchestration/rpc-test-harness.ts @@ -1,6 +1,6 @@ import { vi } from 'vitest' import { ORCHESTRATION_METHODS } from '../orchestration' -import type { RpcContext } from '../../core' +import { eraseRpcMethods, type RpcContext } from '../../core' import { OrchestrationDb } from '../../../orchestration/db' import { OrcaRuntimeService } from '../../../orca-runtime' @@ -66,7 +66,7 @@ export function createOrchestrationRpcHarness() { } function findMethod(name: string) { - const method = ORCHESTRATION_METHODS.find((m) => m.name === name) + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find((m) => m.name === name) if (!method) { throw new Error(`Method not found: ${name}`) } diff --git a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts index abc941bf98c..a4e6b427d7d 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../../../core' +import { defineMethod } from '../../../core' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import { buildDispatchPreamble } from '../../../../orchestration/preamble' import { resolveDispatchCreator } from './dispatch-creator' @@ -10,7 +10,7 @@ import { import { resolveRunScope } from './run-scope' import { DispatchParams, DispatchShowParams } from '../schemas' -export const ORCHESTRATION_DISPATCH_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_DISPATCH_METHODS = [ defineMethod({ name: 'orchestration.dispatch', params: DispatchParams, diff --git a/src/main/runtime/rpc/methods/orchestration/runs/mutation-request-show.ts b/src/main/runtime/rpc/methods/orchestration/runs/mutation-request-show.ts index 8ca5333f307..a1cf43ab424 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/mutation-request-show.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/mutation-request-show.ts @@ -2,10 +2,10 @@ import { describeMutationRequestState, type OrchestrationMutationRequestShowResult } from '../../../../../../shared/orchestration-mutation-request' -import { defineMethod, type RpcMethod } from '../../../core' +import { defineMethod } from '../../../core' import { RequestShowParams } from '../../../../../../shared/rpc-contract/orchestration-runs-mutation-request-show-params' -export const ORCHESTRATION_MUTATION_REQUEST_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_MUTATION_REQUEST_METHODS = [ defineMethod({ name: 'orchestration.requestShow', params: RequestShowParams, diff --git a/src/main/runtime/rpc/methods/orchestration/runs/reset-methods.ts b/src/main/runtime/rpc/methods/orchestration/runs/reset-methods.ts index b4be53ecad5..6946606651f 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/reset-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/reset-methods.ts @@ -1,7 +1,7 @@ -import { defineMethod, type RpcMethod } from '../../../core' +import { defineMethod } from '../../../core' import { ResetParams } from '../schemas' -export const ORCHESTRATION_RESET_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_RESET_METHODS = [ defineMethod({ name: 'orchestration.reset', params: ResetParams, diff --git a/src/main/runtime/rpc/methods/orchestration/runs/runs.ts b/src/main/runtime/rpc/methods/orchestration/runs/runs.ts index dc0b112a168..eab6eb2913e 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/runs.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/runs.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../../../core' +import { defineMethod } from '../../../core' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import { assertCallerHandleMatchesEvidence, resolveOrchestrationCaller } from './run-scope' import { exposeRun } from './run-receipt' @@ -10,7 +10,7 @@ import { RunUseParams } from '../../../../../../shared/rpc-contract/orchestration-runs-params' -export const ORCHESTRATION_RUN_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_RUN_METHODS = [ defineMethod({ name: 'orchestration.runCreate', params: RunCreateParams, diff --git a/src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-observation.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-observation.test.ts index 2890fa08938..25da0b311a3 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-observation.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-observation.test.ts @@ -3,6 +3,7 @@ import { OrcaRuntimeService } from '../../../../orca-runtime' import { OrchestrationDb } from '../../../../orchestration/db' import { ORCHESTRATION_METHODS } from '../../orchestration' import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' +import { eraseRpcMethods } from '../../../core' describe('manual Dispatch observation', () => { let db: OrchestrationDb | undefined @@ -51,7 +52,7 @@ describe('manual Dispatch observation', () => { coordinatorPaneKey }) const task = db.createTask({ spec: 'injected lane', runId: run.id }) - const dispatchMethod = ORCHESTRATION_METHODS.find( + const dispatchMethod = eraseRpcMethods(ORCHESTRATION_METHODS).find( (candidate) => candidate.name === 'orchestration.dispatch' ) if (!dispatchMethod) { @@ -77,7 +78,7 @@ describe('manual Dispatch observation', () => { capability_hash: expect.any(String) }) - const workerShowMethod = ORCHESTRATION_METHODS.find( + const workerShowMethod = eraseRpcMethods(ORCHESTRATION_METHODS).find( (candidate) => candidate.name === 'orchestration.workerShow' ) if (!workerShowMethod) { @@ -132,7 +133,9 @@ describe('manual Dispatch observation', () => { }) const context = { runtime } const call = async (name: string, params: Record) => { - const method = ORCHESTRATION_METHODS.find((candidate) => candidate.name === name) + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find( + (candidate) => candidate.name === name + ) if (!method) { throw new Error(`Missing method ${name}`) } @@ -233,7 +236,7 @@ describe('manual Dispatch observation', () => { const task = db.createTask({ spec: 'operator lane', runId: run.id }) const dispatch = createRootDispatch(db, task.id, 'term_worker', 'tab_worker:leaf_worker') - const workerListMethod = ORCHESTRATION_METHODS.find( + const workerListMethod = eraseRpcMethods(ORCHESTRATION_METHODS).find( (candidate) => candidate.name === 'orchestration.workerList' ) if (!workerListMethod) { @@ -280,7 +283,9 @@ describe('manual Dispatch observation', () => { 'launch-hash', 'runtime_test:term_worker:1' ) - const method = ORCHESTRATION_METHODS.find((candidate) => candidate.name === name) + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find( + (candidate) => candidate.name === name + ) if (!method) { throw new Error(`Missing method ${name}`) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-release.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-release.test.ts index ee1f5a3162a..e340e51b01d 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-release.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-release.test.ts @@ -3,6 +3,7 @@ import type Database from '../../../../../sqlite/sync-database' import { OrcaRuntimeService } from '../../../../orca-runtime' import { OrchestrationDb } from '../../../../orchestration/db' import { ORCHESTRATION_METHODS } from '../../orchestration' +import { eraseRpcMethods } from '../../../core' const COORDINATOR = 'term_coordinator' const TARGET = 'term_target' @@ -177,7 +178,9 @@ describe('manual Dispatch release', () => { } async function call(name: string, params: Record): Promise { - const method = ORCHESTRATION_METHODS.find((candidate) => candidate.name === name) + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find( + (candidate) => candidate.name === name + ) if (!method) { throw new Error(`Method not found: ${name}`) } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts index 3e27e12eba0..5e8babf3c5e 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts @@ -1,6 +1,6 @@ import { contextOnlyAbandonWarning } from '../../../../orchestration/context-only-dispatch-release' import { OrchestrationError } from '../../../../orchestration/orchestration-error' -import { defineMethod, type RpcMethod } from '../../../core' +import { defineMethod } from '../../../core' import { exposeDispatchContext, exposeObservation, @@ -22,7 +22,7 @@ import { WorkerReadParams } from '../../../../../../shared/rpc-contract/orchestration-worker-control-params' -export const ORCHESTRATION_WORKER_CONTROL_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_WORKER_CONTROL_METHODS = [ defineMethod({ name: 'orchestration.workerShow', params: WorkerDispatchParams, diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-list-method.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-list-method.ts index 5c8ae65fa86..c0f8097a690 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-list-method.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-list-method.ts @@ -4,7 +4,7 @@ import type { OrchestrationDb } from '../../../../orchestration/db' import { WORKER_LIST_CURSOR_EXPIRED_MESSAGE } from '../../../../orchestration/db/worker-terminal/worker-terminal-listing' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import type { OrcaRuntimeService } from '../../../../orca-runtime' -import { defineMethod, type RpcMethod } from '../../../core' +import { defineMethod } from '../../../core' import { applyFederatedFleetObservations, readFederatedFleetSnapshots @@ -24,7 +24,7 @@ import { projectWorkerFleet, type WorkerListPageParams } from './worker-list-pro import { exposeWorkerTerminalResource } from './worker-release-completion' import { WORKER_TERMINAL_LIST_STATES, WorkerListParams } from './worker-release-schemas' -export const ORCHESTRATION_WORKER_LIST_METHOD: RpcMethod = defineMethod({ +export const ORCHESTRATION_WORKER_LIST_METHOD = defineMethod({ name: 'orchestration.workerList', params: WorkerListParams, handler: async (params, { runtime }) => { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-methods.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-methods.ts index 238ad12fad8..7c324cdf798 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-methods.ts @@ -1,10 +1,9 @@ -import type { RpcMethod } from '../../../core' import { ORCHESTRATION_WORKER_CONTROL_METHODS } from './worker-control' import { ORCHESTRATION_WORKER_RELEASE_METHODS } from './worker-release' import { ORCHESTRATION_WORKER_STOP_METHODS } from './worker-stop' import { ORCHESTRATION_WORKER_START_METHODS } from './workers' -export const ORCHESTRATION_WORKER_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_WORKER_METHODS = [ ...ORCHESTRATION_WORKER_START_METHODS, ...ORCHESTRATION_WORKER_CONTROL_METHODS, ...ORCHESTRATION_WORKER_STOP_METHODS, diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts index 68d40b4a04e..dd4ce2522ea 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' import { TERMINAL_SEND_METHODS } from '../../terminal/terminal-send-method' import { sendTerminalStreamInput } from '../../terminal/terminal-input-delivery' -import { isStreamingMethod, type RpcMethod } from '../../../core' +import { eraseRpcMethods, isStreamingMethod, type RpcMethod } from '../../../core' const h = createOrchestrationWorkerReleaseHarness() beforeEach(() => h.setup()) @@ -93,7 +93,7 @@ it.each(['unary', 'stream'])('mobile %s bytes do no orchestration database work' 'delivered' ) } else { - const method = TERMINAL_SEND_METHODS.find( + const method = eraseRpcMethods(TERMINAL_SEND_METHODS).find( (m): m is RpcMethod => m.name === 'terminal.send' && !isStreamingMethod(m) )! await expect( diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts index a7481ea3b68..177eb479d42 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { OrchestrationDb } from '../../../../orchestration/db' import { reconcileRequestedWorkerTerminalReleases } from '../../../../orchestration/worker-terminal-release-reconciliation' import { OrcaRuntimeService } from '../../../../orca-runtime' -import type { RpcContext } from '../../../core' +import { eraseRpcMethods, type RpcContext } from '../../../core' import { ORCHESTRATION_METHODS } from '../../orchestration' function deferred(): { promise: Promise; resolve: (value: T) => void } { @@ -101,7 +101,9 @@ describe('orchestration worker release recovery', () => { }) async function call(name: string, params: Record) { - const method = ORCHESTRATION_METHODS.find((candidate) => candidate.name === name) + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find( + (candidate) => candidate.name === name + ) if (!method) { throw new Error(`Method not found: ${name}`) } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release.test-support.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release.test-support.ts index ff1ea59a263..a13ea320670 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release.test-support.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release.test-support.ts @@ -1,6 +1,6 @@ import { expect, vi } from 'vitest' import { ORCHESTRATION_METHODS } from '../../orchestration' -import type { RpcContext } from '../../../core' +import { eraseRpcMethods, type RpcContext } from '../../../core' import { OrchestrationDb } from '../../../../orchestration/db' import { OrcaRuntimeService } from '../../../../orca-runtime' @@ -130,7 +130,7 @@ export function createOrchestrationWorkerReleaseHarness(): OrchestrationWorkerRe } function findMethod(name: string) { - const method = ORCHESTRATION_METHODS.find((m) => m.name === name) + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find((m) => m.name === name) if (!method) { throw new Error(`Method not found: ${name}`) } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release.ts index 236dc7cf76f..f641485e757 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release.ts @@ -1,5 +1,5 @@ import { OrchestrationError } from '../../../../orchestration/orchestration-error' -import { defineMethod, type RpcMethod } from '../../../core' +import { defineMethod } from '../../../core' import { releaseFederatedWorker } from '../federation/federated-worker-release' import { ORCHESTRATION_WORKER_LIST_METHOD } from './worker-list-method' import { resolvePinnedFederatedServer } from './worker-observation' @@ -11,7 +11,7 @@ import { import { WorkerDispatchParams, WorkerRetainParams } from './worker-release-schemas' import { OrchestrationWorkerTerminalUserInputParams } from '../../../../../../shared/rpc-contract/orchestration-worker-release-params' -export const ORCHESTRATION_WORKER_RELEASE_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_WORKER_RELEASE_METHODS = [ defineMethod({ name: 'orchestration.workerRelease', params: WorkerDispatchParams, diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-stop-liveness-verdict.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-stop-liveness-verdict.test.ts index f0b65281df1..c8dec854e4c 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-stop-liveness-verdict.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-stop-liveness-verdict.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from '../../../../orca-runtime' import { OrchestrationDb } from '../../../../orchestration/db' import { ORCHESTRATION_METHODS } from '../../orchestration' +import { eraseRpcMethods } from '../../../core' // The aggregate terminal inventory only iterates registered providers, so a // dropped relay clears `connected` for every remote PTY at once. That is lost @@ -29,7 +30,9 @@ describe('worker-stop against a terminal we lost contact with', () => { afterEach(() => db.close()) async function call(name: string, params: Record) { - const method = ORCHESTRATION_METHODS.find((candidate) => candidate.name === name) + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find( + (candidate) => candidate.name === name + ) if (!method) { throw new Error(`Method not found: ${name}`) } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-stop.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-stop.ts index 79a51ca506b..98d3c376f61 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-stop.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-stop.ts @@ -1,5 +1,5 @@ import { OrchestrationError } from '../../../../orchestration/orchestration-error' -import { defineMethod, type RpcMethod } from '../../../core' +import { defineMethod } from '../../../core' import { describeUnconfirmedAgentStop } from '../../../../../../shared/pty-liveness-verdict' import { ORCHESTRATION_WORKER_STOP_VERDICT_RUNTIME_CAPABILITY } from '../../../../../../shared/protocol-version' import type { RuntimeStatus } from '../../../../../../shared/runtime-types' @@ -12,7 +12,7 @@ import { import { isStructuredWorkerHandle } from '../../../../structured-worker-identity' import { WorkerDispatchParams } from '../../../../../../shared/rpc-contract/orchestration-worker-stop-params' -export const ORCHESTRATION_WORKER_STOP_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_WORKER_STOP_METHODS = [ defineMethod({ name: 'orchestration.workerStop', params: WorkerDispatchParams, diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers-recovery.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers-recovery.test.ts index a635a316b23..9e95d7f33e2 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers-recovery.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers-recovery.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from '../../../../orca-runtime' import { OrchestrationDb } from '../../../../orchestration/db' import { ORCHESTRATION_METHODS } from '../../orchestration' +import { eraseRpcMethods } from '../../../core' function deferred(): { promise: Promise; resolve: (value: T) => void } { let resolve!: (value: T) => void @@ -46,7 +47,9 @@ describe('orchestration worker recovery', () => { afterEach(() => db.close()) async function call(name: string, params: Record) { - const method = ORCHESTRATION_METHODS.find((candidate) => candidate.name === name) + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find( + (candidate) => candidate.name === name + ) if (!method) { throw new Error(`Method not found: ${name}`) } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers.ts index 8b14ec044cf..b1a1f40d45a 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers.ts @@ -1,5 +1,5 @@ import { OrchestrationError } from '../../../../orchestration/orchestration-error' -import { defineMethod, type RpcMethod } from '../../../core' +import { defineMethod } from '../../../core' import { startFederatedWorker } from '../federation/federated-worker-start' import { startLocalWorker } from './local-worker-start' import { @@ -14,7 +14,7 @@ import { } from '../../../../../../shared/orchestration-timing-budgets' import { assertWorkerStartTaskSpecWithinPromptBudget } from './worker-start-prompt-budget' -export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_WORKER_START_METHODS = [ defineMethod({ name: 'orchestration.workerStart', params: WorkerStartParams, diff --git a/src/main/runtime/rpc/methods/pairing.ts b/src/main/runtime/rpc/methods/pairing.ts index 7762881ba37..5a32ddab62f 100644 --- a/src/main/runtime/rpc/methods/pairing.ts +++ b/src/main/runtime/rpc/methods/pairing.ts @@ -1,10 +1,10 @@ -import { defineMethod, type RpcAnyMethod } from '../core' +import { defineMethod } from '../core' import { PairingGetEndpointsParamsSchema, PairingProvisionRelayParamsSchema } from '../../../../shared/mobile-relay-credential-contract' -export const PAIRING_METHODS: readonly RpcAnyMethod[] = [ +export const PAIRING_METHODS = [ defineMethod({ name: 'pairing.getEndpoints', params: PairingGetEndpointsParamsSchema, diff --git a/src/main/runtime/rpc/methods/plugins.test.ts b/src/main/runtime/rpc/methods/plugins.test.ts index bf67d29f19a..e44570bab56 100644 --- a/src/main/runtime/rpc/methods/plugins.test.ts +++ b/src/main/runtime/rpc/methods/plugins.test.ts @@ -1,12 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RpcContext, RpcMethod } from '../core' +import { eraseRpcMethods, type RpcContext, type RpcMethod } from '../core' import type { PluginService } from '../../../plugins/plugin-service' import { PLUGIN_METHODS, setPluginServiceForRpc } from './plugins' const SESSION_TOKEN = 's'.repeat(43) function method(name: string): RpcMethod { - const found = PLUGIN_METHODS.find((entry) => entry.name === name) + const found = eraseRpcMethods(PLUGIN_METHODS).find((entry) => entry.name === name) if (!found) { throw new Error(`missing ${name}`) } diff --git a/src/main/runtime/rpc/methods/plugins.ts b/src/main/runtime/rpc/methods/plugins.ts index 4d1e8d597ca..667aff9179d 100644 --- a/src/main/runtime/rpc/methods/plugins.ts +++ b/src/main/runtime/rpc/methods/plugins.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcContext, type RpcMethod } from '../core' +import { defineMethod, type RpcContext } from '../core' import type { PluginPanelEntry } from '../../../../shared/plugins/plugin-panel-bridge' import { listPluginsForClients } from '../../../plugins/plugin-client-list' import type { PluginListEntry } from '../../../plugins/plugin-list-projection' @@ -65,7 +65,7 @@ function bindRpcPanelOwner(service: PluginService, context: RpcContext): string return ownerKey } -export const PLUGIN_METHODS: readonly RpcMethod[] = [ +export const PLUGIN_METHODS = [ defineMethod({ name: 'plugins.list', params: null, diff --git a/src/main/runtime/rpc/methods/preflight.ts b/src/main/runtime/rpc/methods/preflight.ts index 9a5af8776f1..cc1dd5705c3 100644 --- a/src/main/runtime/rpc/methods/preflight.ts +++ b/src/main/runtime/rpc/methods/preflight.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { detectRemoteAgents, detectRemoteWindowsTerminalCapabilities, @@ -12,7 +12,7 @@ import { PreflightDetectRemoteWindowsTerminalCapabilities } from '../../../../shared/rpc-contract/preflight-params' -export const PREFLIGHT_METHODS: RpcMethod[] = [ +export const PREFLIGHT_METHODS = [ defineMethod({ name: 'preflight.check', params: PreflightCheck, diff --git a/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts b/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts index 2ef04798813..f67705f6cdd 100644 --- a/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts +++ b/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { projectRepoResultVisibilityForClient } from '../repo-visibility-projection' import { ProjectHostSetupClone, @@ -9,7 +9,7 @@ import { ProjectUpdate } from '../../../../shared/rpc-contract/project-runtime-params' -export const PROJECT_RUNTIME_METHODS: RpcMethod[] = [ +export const PROJECT_RUNTIME_METHODS = [ defineMethod({ name: 'project.list', params: null, diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index 31fc871e897..6498de8a4f3 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { PROJECT_RUNTIME_METHODS } from './project-runtime-rpc-methods' import { FOLDER_WORKSPACE_METHODS } from './folder-workspace' import { RepoSelector } from './github-repo-target-schemas' @@ -24,7 +24,7 @@ import { RepoUpdate } from '../../../../shared/rpc-contract/repo-params' -export const REPO_METHODS: RpcMethod[] = [ +export const REPO_METHODS = [ defineMethod({ name: 'repo.list', params: null, diff --git a/src/main/runtime/rpc/methods/runtime-client-capabilities.ts b/src/main/runtime/rpc/methods/runtime-client-capabilities.ts index fe152fa6f27..2fa62b53934 100644 --- a/src/main/runtime/rpc/methods/runtime-client-capabilities.ts +++ b/src/main/runtime/rpc/methods/runtime-client-capabilities.ts @@ -1,8 +1,8 @@ import type { RuntimeCapability } from '../../../../shared/protocol-version' -import { defineMethod, type RpcAnyMethod } from '../core' +import { defineMethod } from '../core' import { ClientCapabilitiesUpdate } from '../../../../shared/rpc-contract/runtime-client-capabilities-params' -export const RUNTIME_CLIENT_CAPABILITY_METHODS: RpcAnyMethod[] = [ +export const RUNTIME_CLIENT_CAPABILITY_METHODS = [ defineMethod({ name: 'runtime.clientCapabilities.update', params: ClientCapabilitiesUpdate, diff --git a/src/main/runtime/rpc/methods/session-tab-close-methods.ts b/src/main/runtime/rpc/methods/session-tab-close-methods.ts index 4800b7d33c1..a7065cf6ba2 100644 --- a/src/main/runtime/rpc/methods/session-tab-close-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-close-methods.ts @@ -1,13 +1,13 @@ import { withSpan } from '../../../observability/tracer' import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import { defineMethod, type RpcAnyMethod } from '../core' +import { defineMethod } from '../core' import { CloseLifecycleTab, CloseTab } from './session-tabs-schemas' import { assertProjectedSessionTabVisible } from './session-tab-browser-placement-projection' import { assertAgentSessionTabDestructiveMutationSupported } from './session-tab-agent-status-projection' import { projectSessionTabsForClient } from './session-tabs-inventory' import { isStructuredNativeChatEnabled } from './structured-agent-session-policy' -export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [ +export const SESSION_TAB_CLOSE_METHODS = [ defineMethod({ name: 'session.tabs.close', params: CloseTab, diff --git a/src/main/runtime/rpc/methods/session-tab-markdown-methods.ts b/src/main/runtime/rpc/methods/session-tab-markdown-methods.ts index 6144cd3e546..f2be1d4a61d 100644 --- a/src/main/runtime/rpc/methods/session-tab-markdown-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-markdown-methods.ts @@ -1,7 +1,7 @@ -import { defineMethod, type RpcAnyMethod } from '../core' +import { defineMethod } from '../core' import { ActivateTab, SaveMarkdownTab } from './session-tabs-schemas' -export const SESSION_TAB_MARKDOWN_METHODS: RpcAnyMethod[] = [ +export const SESSION_TAB_MARKDOWN_METHODS = [ defineMethod({ name: 'markdown.readTab', params: ActivateTab, diff --git a/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts b/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts index 462d00d869d..d62f50be595 100644 --- a/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts @@ -1,6 +1,6 @@ import { resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation' import type { OrcaRuntimeService } from '../../orca-runtime' -import { defineMethod, type RpcAnyMethod } from '../core' +import { defineMethod } from '../core' import { assertProjectedSessionTabVisible, translateProjectedSessionTabMove @@ -9,7 +9,7 @@ import { projectSessionTabsForClient } from './session-tabs-inventory' import { isStructuredNativeChatEnabled } from './structured-agent-session-policy' import { ActivateTab, MoveTab, SetTabProps, UpdatePaneLayout } from './session-tabs-schemas' -export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [ +export const SESSION_TAB_MUTATION_METHODS = [ defineMethod({ name: 'session.tabs.activate', params: ActivateTab, diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 1f4019bad7c..d6441ee84cb 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -1,5 +1,5 @@ import { resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation' -import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' +import { defineMethod, defineStreamingMethod } from '../core' import { CreateTerminalTab, SessionTabsUnsubscribe, @@ -19,7 +19,7 @@ import { isStructuredNativeChatEnabled } from './structured-agent-session-policy import { assertLegacyAiVaultResumeCommandAllowed } from '../../../ai-vault/structured-session-ownership' import { SessionTabsUnsubscribeAllParams } from '../../../../shared/rpc-contract/session-tabs-params' -export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ +export const SESSION_TAB_METHODS = [ defineMethod({ name: 'session.tabs.list', params: WorktreeTabSelector, diff --git a/src/main/runtime/rpc/methods/skills.test.ts b/src/main/runtime/rpc/methods/skills.test.ts index 0425e5922eb..9bc5a647c82 100644 --- a/src/main/runtime/rpc/methods/skills.test.ts +++ b/src/main/runtime/rpc/methods/skills.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { RpcContext } from '../core' +import { eraseRpcMethods, type RpcContext } from '../core' vi.mock('electron', () => ({ app: { getPath: () => '/orca-state', isPackaged: true } @@ -41,7 +41,7 @@ function makeContext(overrides: { } function discoverMethod() { - const method = SKILL_METHODS.find((entry) => entry.name === 'skills.discover') + const method = eraseRpcMethods(SKILL_METHODS).find((entry) => entry.name === 'skills.discover') if (!method) { throw new Error('skills.discover method not registered') } @@ -49,7 +49,7 @@ function discoverMethod() { } function installMethod() { - const method = SKILL_METHODS.find((entry) => entry.name === 'skills.install') + const method = eraseRpcMethods(SKILL_METHODS).find((entry) => entry.name === 'skills.install') if (!method) { throw new Error('skills.install method not registered') } @@ -57,7 +57,7 @@ function installMethod() { } function method(name: string) { - const value = SKILL_METHODS.find((entry) => entry.name === name) + const value = eraseRpcMethods(SKILL_METHODS).find((entry) => entry.name === name) if (!value) { throw new Error(`${name} method not registered`) } diff --git a/src/main/runtime/rpc/methods/skills.ts b/src/main/runtime/rpc/methods/skills.ts index 01caa64baa6..39a3a73eb7c 100644 --- a/src/main/runtime/rpc/methods/skills.ts +++ b/src/main/runtime/rpc/methods/skills.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import type { z } from 'zod' import { getAppEnvironment } from '../../../../shared/app-environment' import { SkillDeleteRequestSchema } from '../../../../shared/skill-delete-contract' @@ -64,7 +64,7 @@ function skillDeleteDependencies( } } -export const SKILL_METHODS: RpcMethod[] = [ +export const SKILL_METHODS = [ defineMethod({ name: 'skills.discover', params: SkillsDiscoverParams, diff --git a/src/main/runtime/rpc/methods/speech.ts b/src/main/runtime/rpc/methods/speech.ts index 8e5e8bdbd4d..086a528ab0e 100644 --- a/src/main/runtime/rpc/methods/speech.ts +++ b/src/main/runtime/rpc/methods/speech.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { DictationChunk, DictationHandle, @@ -7,7 +7,7 @@ import { SpeechModelAction } from '../../../../shared/rpc-contract/speech-params' -export const SPEECH_METHODS: RpcMethod[] = [ +export const SPEECH_METHODS = [ defineMethod({ name: 'speech.models.list', params: null, diff --git a/src/main/runtime/rpc/methods/ssh.ts b/src/main/runtime/rpc/methods/ssh.ts index 8988ab3a569..2c8e2520f9b 100644 --- a/src/main/runtime/rpc/methods/ssh.ts +++ b/src/main/runtime/rpc/methods/ssh.ts @@ -4,7 +4,7 @@ import { listRegisteredRemovedSshTargetLabels, listRegisteredSshTargets } from '../../../ssh/ssh-target-registry' -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { getPublicSshError, getPublicSshState } from '../../public-ssh-state' import type { SshTargetSummary } from '../../../../shared/ssh-types' import { SshTarget } from '../../../../shared/rpc-contract/ssh-params' @@ -25,7 +25,7 @@ function listRegisteredSshTargetSummaries(): SshTargetSummary[] { }) } -export const SSH_METHODS: RpcMethod[] = [ +export const SSH_METHODS = [ defineMethod({ name: 'ssh.getState', params: SshTarget, diff --git a/src/main/runtime/rpc/methods/stats.ts b/src/main/runtime/rpc/methods/stats.ts index 59f71701c3a..9cdfe5269d3 100644 --- a/src/main/runtime/rpc/methods/stats.ts +++ b/src/main/runtime/rpc/methods/stats.ts @@ -1,6 +1,6 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' -export const STATS_METHODS: RpcMethod[] = [ +export const STATS_METHODS = [ defineMethod({ name: 'stats.summary', params: null, diff --git a/src/main/runtime/rpc/methods/status.ts b/src/main/runtime/rpc/methods/status.ts index 03d66f84fb1..dac38097b7a 100644 --- a/src/main/runtime/rpc/methods/status.ts +++ b/src/main/runtime/rpc/methods/status.ts @@ -1,7 +1,7 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { getRemoteServerUpdaterSnapshot } from '../../remote-server-updater' -export const STATUS_METHODS: RpcMethod[] = [ +export const STATUS_METHODS = [ defineMethod({ name: 'status.get', params: null, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.ts index 280804711e6..18fb600a949 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.ts @@ -9,7 +9,7 @@ // the hold is deliberate: re-registering an id runs the previous cleanup synchronously, so the // stale release lands before this hold rather than after it. -import { defineMethod, type RpcAnyMethod, type RpcContext } from '../core' +import { defineMethod, type RpcContext } from '../core' import { ensureStructuredHostInstalled, requireStructuredCleanupHost, @@ -28,7 +28,7 @@ function holdCleanupIdFor(sessionId: string, holderKey: string): string { return `${HOLD_CLEANUP_PREFIX}:${holderKey}:${sessionId}` } -export const STRUCTURED_AGENT_SESSION_HOLD_METHODS: RpcAnyMethod[] = [ +export const STRUCTURED_AGENT_SESSION_HOLD_METHODS = [ defineMethod({ name: 'agentSession.hold', params: HoldParams, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts index 5f2ab0e8cac..47f30a5030d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts @@ -12,7 +12,7 @@ import { isAgentSessionWireRefusalCode } from '../../../../shared/agent-session-wire' import type { StructuredAgentSessionReveal } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' import { refuseAgentSessionMutation } from '../../../native-chat/agent-session-wire/structured-agent-session-mutation-admission' -import { defineMethod, type RpcAnyMethod } from '../core' +import { defineMethod } from '../core' import { ensureStructuredHostInstalled, requireStructuredCapability, @@ -20,7 +20,7 @@ import { } from './structured-agent-session-gate' import { OptionsParams } from './structured-agent-session-schemas' -export const STRUCTURED_AGENT_SESSION_REVEAL_METHODS: RpcAnyMethod[] = [ +export const STRUCTURED_AGENT_SESSION_REVEAL_METHODS = [ defineMethod({ name: 'agentSession.reveal', params: OptionsParams, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts b/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts index 8637089c254..c93401e0e22 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts @@ -3,7 +3,7 @@ // Session lists read turn state from here instead of replaying transcripts: one stream per client // covers every session, and unlike a transcript subscription it retains none of them. -import { defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core' +import { defineStreamingMethod, type RpcContext } from '../core' import { requireStructuredHost as requireHost } from './structured-agent-session-gate' import { structuredAgentSessionStatusSubscriptionId } from './structured-agent-session-subscription-id' @@ -41,7 +41,7 @@ export function bindStructuredAgentSessionStream( return { isClosed: () => closed } } -export const STRUCTURED_AGENT_SESSION_STATUS_METHODS: RpcAnyMethod[] = [ +export const STRUCTURED_AGENT_SESSION_STATUS_METHODS = [ defineStreamingMethod({ name: 'agentSession.subscribeStatus', params: null, diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index c09ae4cfcbf..f1d0fc59ec5 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -18,7 +18,7 @@ import { projectTurnItemEvent, projectTurnItemHistory } from './structured-agent-session-turn-item-capability' -import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core' +import { defineMethod, defineStreamingMethod, type RpcContext } from '../core' import { ensureStructuredHostInstalled as ensureHostInstalled, requireStructuredCapability, @@ -91,7 +91,7 @@ async function attachClientSuppliedLocation( return host.attach(callerFor(ctx), attachParams) } -export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ +export const STRUCTURED_AGENT_SESSION_METHODS = [ defineMethod({ name: 'agentSession.rewind', params: RewindParams, diff --git a/src/main/runtime/rpc/methods/structured-worker-stop-receipt.test.ts b/src/main/runtime/rpc/methods/structured-worker-stop-receipt.test.ts index 151285ffb1e..aebb420fa8b 100644 --- a/src/main/runtime/rpc/methods/structured-worker-stop-receipt.test.ts +++ b/src/main/runtime/rpc/methods/structured-worker-stop-receipt.test.ts @@ -16,6 +16,7 @@ import { structuredWorkerProcessIncarnation } from '../../structured-worker-identity' import { ORCHESTRATION_METHODS } from './orchestration' +import { eraseRpcMethods } from '../core' const SESSION = 'session-stop-receipt' const HANDLE = 'structworker_22222222-2222-4222-a222-222222222222' @@ -43,7 +44,9 @@ describe('worker-stop on a structured worker this runtime cannot reach', () => { }) async function call(name: string, params: Record) { - const method = ORCHESTRATION_METHODS.find((candidate) => candidate.name === name) + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find( + (candidate) => candidate.name === name + ) if (!method) { throw new Error(`Method not found: ${name}`) } diff --git a/src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts b/src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts index 51001605be0..a1e221d5a7a 100644 --- a/src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts +++ b/src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import type { RpcContext } from '../core' +import { eraseRpcMethods, type RpcContext } from '../core' import { TERMINAL_METHODS } from './terminal' describe('terminal.create RPC idempotency', () => { @@ -15,7 +15,9 @@ describe('terminal.create RPC idempotency', () => { run: (worktree: string | undefined, handle: string | undefined) => Promise ) => run('id:worktree-1', 'term_stable') ) - const method = TERMINAL_METHODS.find((candidate) => candidate.name === 'terminal.create') + const method = eraseRpcMethods(TERMINAL_METHODS).find( + (candidate) => candidate.name === 'terminal.create' + ) if (!method) { throw new Error('terminal.create method missing') } @@ -73,7 +75,9 @@ describe('terminal.create RPC idempotency', () => { run: (worktree: string | undefined, handle: string | undefined) => Promise ) => run('id:worktree-1', undefined) ) - const method = TERMINAL_METHODS.find((candidate) => candidate.name === 'terminal.create') + const method = eraseRpcMethods(TERMINAL_METHODS).find( + (candidate) => candidate.name === 'terminal.create' + ) if (!method) { throw new Error('terminal.create method missing') } @@ -114,7 +118,9 @@ describe('terminal.create RPC idempotency', () => { run: (worktree: string | undefined, handle: string | undefined) => Promise ) => run('id:worktree-1', undefined) ) - const method = TERMINAL_METHODS.find((candidate) => candidate.name === 'terminal.create') + const method = eraseRpcMethods(TERMINAL_METHODS).find( + (candidate) => candidate.name === 'terminal.create' + ) if (!method) { throw new Error('terminal.create method missing') } diff --git a/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts b/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts index ccdcf5fb7b1..e26788d5d46 100644 --- a/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts +++ b/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../../orca-runtime' import { TERMINAL_METHODS } from './terminal' +import { eraseRpcMethods } from '../core' import { TerminalMultiplexLegacyAckFrame, TerminalMultiplexSourceRangeAckFrame, @@ -50,14 +51,14 @@ const METHOD_CASES: readonly (readonly [string, unknown, boolean])[] = [ ] function schemaFor(name: string) { - const method = TERMINAL_METHODS.find((candidate) => candidate.name === name) + const method = eraseRpcMethods(TERMINAL_METHODS).find((candidate) => candidate.name === name) if (!method?.params) { throw new Error(`Missing terminal schema: ${name}`) } return method.params } async function invoke(name: string, params: unknown, runtime: Partial) { - const method = TERMINAL_METHODS.find((candidate) => candidate.name === name) + const method = eraseRpcMethods(TERMINAL_METHODS).find((candidate) => candidate.name === name) if (!method?.params || 'stream' in method) { throw new Error(`Missing unary terminal method: ${name}`) } diff --git a/src/main/runtime/rpc/methods/terminal-orphan.ts b/src/main/runtime/rpc/methods/terminal-orphan.ts index 7ca7cd771b1..0b728629dcb 100644 --- a/src/main/runtime/rpc/methods/terminal-orphan.ts +++ b/src/main/runtime/rpc/methods/terminal-orphan.ts @@ -1,7 +1,7 @@ -import { defineMethod, type RpcAnyMethod } from '../core' +import { defineMethod } from '../core' import { TerminalAdoptOrphans } from '../../../../shared/rpc-contract/terminal-orphan-params' -export const TERMINAL_ORPHAN_METHODS: RpcAnyMethod[] = [ +export const TERMINAL_ORPHAN_METHODS = [ defineMethod({ name: 'terminal.adoptOrphans', params: TerminalAdoptOrphans, diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index e80d07773dc..607a29329cd 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -1,4 +1,3 @@ -import type { RpcAnyMethod } from '../core' import { TERMINAL_LIFECYCLE_METHODS } from './terminal/terminal-lifecycle-methods' import { TERMINAL_MULTIPLEX_METHODS } from './terminal/terminal-multiplex-method' import { TERMINAL_QUERY_METHODS } from './terminal/terminal-query-methods' @@ -11,7 +10,7 @@ import { // The manifest order is part of the released RPC contract. Keep composition here so the // public entry point owns registration rather than forwarding an aggregated child export. -export const TERMINAL_METHODS: RpcAnyMethod[] = [ +export const TERMINAL_METHODS = [ ...TERMINAL_QUERY_METHODS, ...TERMINAL_SEND_METHODS, ...TERMINAL_LIFECYCLE_METHODS, diff --git a/src/main/runtime/rpc/methods/terminal/terminal-inspect-process-params.test.ts b/src/main/runtime/rpc/methods/terminal/terminal-inspect-process-params.test.ts index 48649bc372c..8377f923ecc 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-inspect-process-params.test.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-inspect-process-params.test.ts @@ -6,10 +6,13 @@ import { describe, expect, it, vi } from 'vitest' import type { ZodType } from 'zod' import { TERMINAL_QUERY_METHODS } from './terminal-query-methods' import { TerminalHandle, TerminalInspectProcess } from './unary-schemas' +import { eraseRpcMethods } from '../../core' /** The method as registered, so a schema swap on the definition cannot pass unseen. */ function inspectProcessMethod() { - const method = TERMINAL_QUERY_METHODS.find((entry) => entry.name === 'terminal.inspectProcess') + const method = eraseRpcMethods(TERMINAL_QUERY_METHODS).find( + (entry) => entry.name === 'terminal.inspectProcess' + ) if (!method) { throw new Error('terminal.inspectProcess is not registered') } @@ -25,7 +28,7 @@ async function callRegisteredHandler( foregroundProcess: null, hasChildProcesses: false })) - await method.handler(parsed, { runtime: { inspectTerminalProcess } } as never, undefined as never) + await method.handler(parsed, { runtime: { inspectTerminalProcess } } as never) const [terminal, options] = inspectTerminalProcess.mock.calls[0] as unknown as [string, unknown] return { terminal, options } } diff --git a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts index 51dde7df4d8..891ed65a13f 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcAnyMethod } from '../../core' +import { defineMethod } from '../../core' import { navigationTargetsHost, resolveRuntimeNavigationTarget @@ -19,7 +19,7 @@ import { } from './unary-schemas' import { TerminalResizeForClient } from './stream-schemas' -export const TERMINAL_LIFECYCLE_METHODS: RpcAnyMethod[] = [ +export const TERMINAL_LIFECYCLE_METHODS = [ defineMethod({ name: 'terminal.wait', params: TerminalWait, diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts index 11fd0c4d039..e811614e400 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts @@ -1,4 +1,4 @@ -import { defineStreamingMethod, type RpcAnyMethod } from '../../core' +import { defineStreamingMethod } from '../../core' import { TerminalStreamOpcode } from '../../../../../shared/terminal-stream-protocol' import { TERMINAL_MULTIPLEX_ACK_TOTAL_INITIAL_WINDOW_BYTES } from '../../../../../shared/terminal-multiplex-flow-control' import { TerminalSourceRangeRegistry } from '../../terminal-source-range-registry' @@ -11,7 +11,7 @@ import { installMultiplexCleanup } from './terminal-multiplex-cleanup' import { installMultiplexSlotFrames } from './terminal-multiplex-slot-frames' import { installMultiplexSubscribeFrame } from './terminal-multiplex-subscribe-frame' -export const TERMINAL_MULTIPLEX_METHODS: RpcAnyMethod[] = [ +export const TERMINAL_MULTIPLEX_METHODS = [ defineStreamingMethod({ name: 'terminal.multiplex', params: TerminalMultiplex, diff --git a/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts index 82edd55cd79..52c1063b0cc 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcAnyMethod } from '../../core' +import { defineMethod } from '../../core' import { TerminalHandle, TerminalInspectProcess, @@ -10,7 +10,7 @@ import { TerminalResolvePane } from './unary-schemas' -export const TERMINAL_QUERY_METHODS: RpcAnyMethod[] = [ +export const TERMINAL_QUERY_METHODS = [ defineMethod({ name: 'terminal.list', params: TerminalListParams, diff --git a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts index ad471098e49..c62c70c5905 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts @@ -1,6 +1,6 @@ import { isAgentSessionPtyWriteRefusedError } from '../../../../../shared/agent-session-pty-write-admission' import { assertLegacyAiVaultResumeCommandAllowed } from '../../../../ai-vault/structured-session-ownership' -import { InvalidArgumentError, defineMethod, type RpcAnyMethod } from '../../core' +import { InvalidArgumentError, defineMethod } from '../../core' import { isTerminalQueryReply } from '../../../../../shared/terminal-query-reply' import { assertTerminalAgentSendable } from '../../terminal-agent-send-guard' import { TerminalSend } from './unary-schemas' @@ -20,7 +20,7 @@ import { observeReplayedTerminalPrompt } from './terminal-prompt-receipt' -export const TERMINAL_SEND_METHODS: RpcAnyMethod[] = [ +export const TERMINAL_SEND_METHODS = [ defineMethod({ name: 'terminal.send', params: TerminalSend, diff --git a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts index bd16382d747..7572d41496f 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts @@ -1,4 +1,4 @@ -import { defineStreamingMethod, type RpcAnyMethod } from '../../core' +import { defineStreamingMethod } from '../../core' import { TerminalSubscribe } from './stream-schemas' import { isTerminalReadPayloadIncomplete } from './terminal-stream-replay' import { runTerminalBinarySubscription } from './terminal-legacy-subscribe-binary' @@ -8,7 +8,7 @@ import { } from './terminal-legacy-simple-subscriptions' import type { TerminalSubscriptionArgs } from './terminal-legacy-subscription-types' -export const TERMINAL_SUBSCRIBE_METHODS: RpcAnyMethod[] = [ +export const TERMINAL_SUBSCRIBE_METHODS = [ // Streams live terminal output over WebSocket; mobile clients pass client+viewport for server-side auto-fit. defineStreamingMethod({ name: 'terminal.subscribe', diff --git a/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts index d71ac53e249..91bdf25d84d 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcAnyMethod } from '../../core' +import { defineMethod } from '../../core' import { TerminalHandle } from './unary-schemas' import { TerminalSetAutoRestoreFit, @@ -9,7 +9,7 @@ import { import { updateViewportForClient } from './terminal-viewport-update' import { TerminalGetAutoRestoreFitParams } from '../../../../../shared/rpc-contract/terminal-viewport-methods-params' -export const TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS: RpcAnyMethod[] = [ +export const TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS = [ defineMethod({ name: 'terminal.setDisplayMode', params: TerminalSetDisplayMode, @@ -78,7 +78,7 @@ export const TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS: RpcAnyMethod[] = [ }) ] -export const TERMINAL_VIEWPORT_METHODS_AFTER_STREAMS: RpcAnyMethod[] = [ +export const TERMINAL_VIEWPORT_METHODS_AFTER_STREAMS = [ defineMethod({ name: 'terminal.unsubscribe', params: TerminalUnsubscribe, diff --git a/src/main/runtime/rpc/methods/updater.test.ts b/src/main/runtime/rpc/methods/updater.test.ts index 9c3ce0ef810..1a925cbe767 100644 --- a/src/main/runtime/rpc/methods/updater.test.ts +++ b/src/main/runtime/rpc/methods/updater.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { eraseRpcMethods, type RpcMethodDeclaration } from '../core' import { configureRemoteServerUpdater } from '../../remote-server-updater' import { STATUS_METHODS } from './status' import { UPDATER_METHODS } from './updater' @@ -10,8 +11,8 @@ const snapshot = { status: { state: 'available', version: '1.5.1', changelog: null } } as const -function handler(methods: typeof UPDATER_METHODS, name: string) { - const method = methods.find((candidate) => candidate.name === name) +function handler(methods: readonly RpcMethodDeclaration[], name: string) { + const method = eraseRpcMethods(methods).find((candidate) => candidate.name === name) if (!method) { throw new Error(`Missing method ${name}`) } diff --git a/src/main/runtime/rpc/methods/updater.ts b/src/main/runtime/rpc/methods/updater.ts index 357f07a8c09..a14fb5ab6a4 100644 --- a/src/main/runtime/rpc/methods/updater.ts +++ b/src/main/runtime/rpc/methods/updater.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { checkRemoteServerUpdater, downloadRemoteServerUpdater, @@ -7,7 +7,7 @@ import { } from '../../remote-server-updater' import { UpdaterCheckParams } from '../../../../shared/rpc-contract/updater-params' -export const UPDATER_METHODS: RpcMethod[] = [ +export const UPDATER_METHODS = [ defineMethod({ name: 'updater.getStatus', params: null, diff --git a/src/main/runtime/rpc/methods/workspace-ports.ts b/src/main/runtime/rpc/methods/workspace-ports.ts index 5778f25732f..c96c91b436d 100644 --- a/src/main/runtime/rpc/methods/workspace-ports.ts +++ b/src/main/runtime/rpc/methods/workspace-ports.ts @@ -1,10 +1,10 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { WorkspacePortKillParams, WorkspacePortScanParams } from '../../../../shared/rpc-contract/workspace-ports-params' -export const WORKSPACE_PORT_METHODS: RpcMethod[] = [ +export const WORKSPACE_PORT_METHODS = [ defineMethod({ name: 'workspacePorts.scan', params: WorkspacePortScanParams, diff --git a/src/main/runtime/rpc/methods/worktree-catalog-methods.ts b/src/main/runtime/rpc/methods/worktree-catalog-methods.ts index 2010a219b7c..8b230c169d8 100644 --- a/src/main/runtime/rpc/methods/worktree-catalog-methods.ts +++ b/src/main/runtime/rpc/methods/worktree-catalog-methods.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { resolveWorktreeCatalogSnapshot } from '../worktree-catalog-snapshot' import { supportsWorktreeVisibilitySourceDefaults } from '../worktree-visibility-client-capability' import { @@ -7,7 +7,7 @@ import { WorktreePsParams } from './worktree-schemas' -export const WORKTREE_CATALOG_METHODS: RpcMethod[] = [ +export const WORKTREE_CATALOG_METHODS = [ defineMethod({ name: 'worktree.ps', params: WorktreePsParams, diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index b3d816496c3..be8a0983036 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -5,7 +5,7 @@ import { } from '../../../automations/workspace-provenance' import { buildCliWorkspaceProvenance } from '../../../../shared/cli-workspace-provenance' import { displayNameUpdatePinsLabel } from '../../../../shared/worktree/display-name-provenance' -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { buildManagedWorktreeCreateArgs } from './worktree-create-args' import { resolvePairedCallerHostId } from './paired-caller-host-id' import { resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation' @@ -24,7 +24,7 @@ import { } from './worktree-schemas' import { WORKTREE_CATALOG_METHODS } from './worktree-catalog-methods' -export const WORKTREE_METHODS: RpcMethod[] = [ +export const WORKTREE_METHODS = [ ...WORKTREE_CATALOG_METHODS, defineMethod({ name: 'worktree.teardownMissingTerminals', diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-pairing-types.ts b/src/main/runtime/runtime-rpc/runtime-rpc-pairing-types.ts index 3cd3c1a54fb..4923dec1dec 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-pairing-types.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-pairing-types.ts @@ -1,5 +1,5 @@ import type { OrcaRuntimeService } from '../orca-runtime' -import type { RpcAnyMethod } from '../rpc/core' +import type { RpcAnyMethodDeclaration } from '../rpc/core' import type { DeviceRegistry } from '../device-registry' import type { E2EEKeypair } from '../e2ee-keypair' import type { MobileSocketTransportMetadata } from '../rpc/mobile-socket-wiring' @@ -56,7 +56,7 @@ export type OrcaRuntimeRpcServerOptions = { // Why: test-only override for the ownership reclaim cadence. metadataOwnershipPollMs?: number // Why: tests may inject inert protocol stages before production authorization registers them. - methods?: readonly RpcAnyMethod[] + methods?: readonly RpcAnyMethodDeclaration[] } export type PairingOfferUnavailableReason =