diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 4ad9f4f7672b..227004b4882e 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -24,6 +24,8 @@ name: Mobile EAS Production # into the void. # workflow_dispatch remains as a manual override for both modes (e.g. to # retry an errored build or force an OTA). +# Manual v2-preview builds keep the production app identity and disable OTA. +# Select the v2 branch when dispatching a preview build. on: workflow_dispatch: inputs: @@ -35,6 +37,14 @@ on: options: - build - update + profile: + description: "Store build profile (v2-preview supports build mode only)" + required: true + type: choice + default: production + options: + - production + - v2-preview platform: description: "Target platform" required: true @@ -75,7 +85,7 @@ concurrency: jobs: production: - name: EAS Production ${{ github.event_name == 'push' && 'auto' || inputs.mode }} + name: EAS ${{ inputs.profile || 'production' }} ${{ github.event_name == 'push' && 'auto' || inputs.mode }} runs-on: blacksmith-8vcpu-ubuntu-2404 permissions: contents: read @@ -83,6 +93,12 @@ jobs: APP_VARIANT: production NODE_OPTIONS: --max-old-space-size=8192 steps: + - name: Reject preview OTA updates + if: github.event_name == 'workflow_dispatch' && inputs.profile == 'v2-preview' && inputs.mode == 'update' + run: | + echo "::error::V2 previews use store builds only. Select mode=build." + exit 1 + - id: expo-token name: Check for EXPO_TOKEN env: @@ -125,6 +141,16 @@ jobs: args: - --filter=@t3tools/mobile... + - name: Verify v2 preview source + if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.profile == 'v2-preview' + run: | + node --input-type=module -e ' + import * as environment from "./packages/contracts/src/environment.ts"; + if (environment.ORCHESTRATION_PROTOCOL_VERSION !== 2) { + throw new Error("V2 previews require a v2 source branch. Select the v2 branch when running this workflow."); + } + ' + - name: Expose pnpm if: steps.expo-token.outputs.present == 'true' run: | @@ -199,10 +225,12 @@ jobs: - name: Summarize manual build version if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' working-directory: apps/mobile + env: + MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }} run: | version="$(npx expo config --json --type public | jq -r '.version')" { - echo "## Manual production build" + echo "## Manual $MOBILE_BUILD_PROFILE build" echo echo "- App version: \`$version\`" echo "- Platform: \`${{ inputs.platform }}\`" @@ -215,7 +243,8 @@ jobs: working-directory: apps/mobile env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - run: eas build --platform ${{ inputs.platform }} --profile production --auto-submit --non-interactive --no-wait + MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }} + run: eas build --platform ${{ inputs.platform }} --profile "$MOBILE_BUILD_PROFILE" --auto-submit --non-interactive --no-wait - name: Publish OTA update (manual) if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'update' diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index bf0c4c3eff6e..4b06f5ee510e 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -183,6 +183,37 @@ describe("DesktopApplicationMenu", () => { }), ); + // Chromium pastes as plain text for the accelerator on its own. Dispatching + // the action as well injects a second paste, which doubles the pasted text. + it.effect("leaves the accelerator to Chromium instead of injecting a paste", () => + Effect.gen(function* () { + const selectedAction = yield* Deferred.make(); + const applicationMenuTemplate = + yield* Deferred.make(); + + yield* configureMenu(selectedAction, applicationMenuTemplate); + + const template = yield* Deferred.await(applicationMenuTemplate); + const editMenu = template.find((item) => item.label === "Edit"); + if (!Array.isArray(editMenu?.submenu)) { + throw new Error("Expected Edit menu submenu to be an array."); + } + const pasteAsTextItem = editMenu.submenu.find((item) => item.label === "Paste as Text"); + if (typeof pasteAsTextItem?.click !== "function") { + throw new Error("Expected Paste as Text menu item to have a click handler."); + } + + pasteAsTextItem.click( + {} as Electron.MenuItem, + {} as Electron.BrowserWindow, + { + triggeredByAccelerator: true, + } as unknown as KeyboardEvent, + ); + assert.isFalse(yield* Deferred.isDone(selectedAction)); + }), + ); + // Zoom must route through DesktopWindow.zoomMain instead of the Electron // zoom roles: the roles zoom whichever webContents has focus, which breaks // app zoom while an embedded preview WebContentsView holds focus. diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index a90b9ca63231..d3b8db895352 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -137,7 +137,17 @@ export const make = Effect.gen(function* () { const settingsClick = () => { runMenuEffect("open-settings", dispatchMenuAction("open-settings")); }; - const pasteAsTextClick = () => { + // Chromium already pastes as plain text for this chord, so the accelerator + // needs nothing from the menu: the composer and the terminal each arm + // themselves from the same keydown. Routing it through the renderer anyway + // lands a second, injected paste and doubles the text. Only a menu click, + // which produces no keystroke for them to see, needs that round trip. + const pasteAsTextClick = ( + _item: Electron.MenuItem, + _window: Electron.BaseWindow | undefined, + event: Electron.KeyboardEvent, + ) => { + if (event.triggeredByAccelerator === true) return; runMenuEffect("paste-as-text", dispatchMenuAction("paste-as-text")); }; const zoomClick = (direction: DesktopWindow.MainWindowZoomDirection) => () => { diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index a1d315b7e149..77a2a6427e1d 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -40,6 +40,14 @@ "buildType": "apk" } }, + "v2-preview": { + "extends": "production", + "distribution": "store", + "channel": "v2-preview", + "env": { + "T3CODE_MOBILE_UPDATES_ENABLED": "0" + } + }, "production": { "corepack": true, "env": { @@ -52,6 +60,13 @@ } }, "submit": { + "v2-preview": { + "extends": "production", + "android": { + "track": "alpha", + "releaseStatus": "completed" + } + }, "production": { "ios": { "ascAppId": "6787819824" diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 18fb67319176..b1d736621407 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -115,13 +115,13 @@ "react-native-keyboard-controller": "1.21.13", "react-native-nitro-markdown": "^0.5.0", "react-native-nitro-modules": "0.35.9", - "react-native-reanimated": "4.5.1", + "react-native-reanimated": "4.5.5", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.26.0", "react-native-shiki-engine": "^0.3.12", "react-native-svg": "15.15.4", "react-native-webview": "^13.16.1", - "react-native-worklets": "0.10.1", + "react-native-worklets": "0.11.4", "shiki": "4.2.0", "tailwind-merge": "^3.5.0", "uniwind": "1.11.0" diff --git a/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts b/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts index 05a34cc9835f..ebd418da7b48 100644 --- a/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts +++ b/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts @@ -1,4 +1,4 @@ -import { EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId, ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts"; import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay"; import { describe, expect, it } from "vite-plus/test"; @@ -24,6 +24,32 @@ function relayStatus( } describe("available cloud environment presentation", () => { + it("shows an incompatible discovered server before any connection attempt", () => { + const onlineStatus = relayStatus("online"); + const status = { + ...onlineStatus, + descriptor: { + environmentId: onlineStatus.environmentId, + label: "Preview server", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "2.0.0", + orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION + 1, + capabilities: { repositoryIdentity: true }, + }, + } satisfies RelayEnvironmentStatusResponse; + expect( + availableCloudEnvironmentPresentation({ + isStatusPending: false, + status, + statusError: null, + statusErrorTraceId: null, + }), + ).toMatchObject({ + connectionState: "unsupported", + statusText: "Client not supported", + }); + }); + it("presents an online unsaved environment as available, not connected", () => { expect( availableCloudEnvironmentPresentation({ diff --git a/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.ts b/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.ts index 8a734c9b9352..3e958b6453d1 100644 --- a/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.ts +++ b/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.ts @@ -1,5 +1,8 @@ import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay"; -import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import { + orchestrationProtocolCompatibilityError, + type EnvironmentConnectionPhase, +} from "@t3tools/client-runtime/connection"; export interface AvailableCloudEnvironmentPresentation { readonly connectionError: string | null; @@ -14,6 +17,18 @@ export function availableCloudEnvironmentPresentation(input: { readonly statusError: string | null; readonly statusErrorTraceId: string | null; }): AvailableCloudEnvironmentPresentation { + const compatibilityError = + input.status?.descriptor === undefined + ? null + : orchestrationProtocolCompatibilityError(input.status.descriptor); + if (compatibilityError !== null) { + return { + connectionError: compatibilityError.message, + connectionErrorTraceId: null, + connectionState: "unsupported", + statusText: "Client not supported", + }; + } if (input.status?.status === "online") { return { connectionError: null, diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index b243580e5fb7..00518b97adbf 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -219,7 +219,8 @@ function ConnectedCloudEnvironmentRow(props: { const serverConfig = useAtomValue( serverEnvironment.configValueAtom(props.environment.environmentId), ); - const enabled = props.environment.isEnabled; + const unsupported = props.environment.connectionState === "unsupported"; + const enabled = props.environment.isEnabled && !unsupported; return ( @@ -270,6 +272,7 @@ function CloudEnvironmentRow(props: { } }} onToggleError={props.onToggleError} + disabled={presentation.connectionState === "unsupported"} statusText={presentation.statusText} value={false} /> diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index c8eaa8cff04e..f572cec33dbc 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -19,7 +19,7 @@ import { serverEnvironment } from "../../state/server"; import { ConnectionStatusDot } from "./ConnectionStatusDot"; function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null { - if (!environment.isEnabled) { + if (!environment.isEnabled && environment.connectionState !== "unsupported") { return "Off"; } return connectionStatusText({ @@ -46,10 +46,12 @@ export function ConnectionEnvironmentRow(props: { const serverConfig = useAtomValue( serverEnvironment.configValueAtom(props.environment.environmentId), ); - const enabled = props.environment.isEnabled; + const unsupported = props.environment.connectionState === "unsupported"; + const enabled = props.environment.isEnabled && !unsupported; const statusLabel = connectionStatusLabel(props.environment); const statusTraceId = enabled ? props.environment.connectionErrorTraceId : null; - const hasConnectionFailure = enabled && props.environment.connectionError !== null; + const hasConnectionFailure = + (enabled || unsupported) && props.environment.connectionError !== null; const isRetrying = enabled && (props.environment.connectionState === "connecting" || @@ -77,7 +79,7 @@ export function ConnectionEnvironmentRow(props: { onPress={props.onToggle} > @@ -133,6 +135,7 @@ export function ConnectionEnvironmentRow(props: { props.onSetEnabled(props.environment.environmentId, next)} value={enabled} /> diff --git a/apps/mobile/src/features/connection/ConnectionStatusDot.tsx b/apps/mobile/src/features/connection/ConnectionStatusDot.tsx index ce5c6a6419e1..beed3f99e176 100644 --- a/apps/mobile/src/features/connection/ConnectionStatusDot.tsx +++ b/apps/mobile/src/features/connection/ConnectionStatusDot.tsx @@ -35,6 +35,7 @@ function statusDotTone(state: ConnectionStatusDotState): { haloColor: "rgba(245,158,11,0.5)", }; case "offline": + case "unsupported": case "error": return { dotColor: "#ef4444", diff --git a/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx b/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx index 4bb15fc9872a..ca799a0c8192 100644 --- a/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx +++ b/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx @@ -16,6 +16,8 @@ function noticeTitle(phase: EnvironmentConnectionPhase, environmentLabel: string return `Connecting to ${environmentLabel}...`; case "reconnecting": return `Reconnecting to ${environmentLabel}...`; + case "unsupported": + return "Client not supported"; case "error": return `${environmentLabel} is unavailable`; case "available": @@ -31,7 +33,7 @@ function noticeDetail( error: string | null, ): string { if (error) { - return `The app will keep retrying automatically. ${error}`; + return phase === "reconnecting" ? `The app will keep retrying automatically. ${error}` : error; } switch (phase) { @@ -40,6 +42,8 @@ function noticeDetail( case "connecting": case "reconnecting": return `The ${resourceName} will load as soon as the environment is ready.`; + case "unsupported": + return "Use compatible versions of the app and server to connect."; case "available": case "error": return `Reconnect the environment to load the ${resourceName}.`; @@ -95,7 +99,7 @@ export function EnvironmentConnectionNotice(props: { ) : null} - {props.connection.phase !== "offline" ? ( + {props.connection.phase !== "offline" && props.connection.phase !== "unsupported" ? ( ({ + values: [] as unknown[], + cursor: 0, + presentations: new Map(), + refreshProviders: vi.fn(), + autoRefresh: async () => {}, + refreshingRef: { current: false }, +})); +vi.mock("react", () => ({ + useState: (initial: unknown) => { + const index = state.cursor++; + if (!(index in state.values)) { + state.values[index] = typeof initial === "function" ? initial() : initial; + } + return [ + state.values[index], + (next: unknown) => { + state.values[index] = typeof next === "function" ? next(state.values[index]) : next; + }, + ]; + }, + useRef: () => state.refreshingRef, + useEffect: () => {}, + useEffectEvent: (callback: () => Promise) => { + state.autoRefresh = callback; + return callback; + }, +})); +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.presentations })); +vi.mock("react-native", () => ({ Alert: {}, Pressable: "button", View: "div" })); +vi.mock("../../components/AppText", () => ({ AppText: "span" })); +vi.mock("../../components/ProviderIcon", () => ({ ProviderIcon: () => null })); +vi.mock("./usageProviders", () => ({ useProviderColors: () => ({}) })); +vi.mock("../../state/presentation", () => ({ + environmentPresentations: { presentationsAtom: null }, +})); +vi.mock("../../state/server", () => ({ serverEnvironment: { refreshProviders: null } })); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.refreshProviders })); + +import { useRefreshLimits } from "./UsageLimitsSection"; +import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage"; + +beforeEach(() => { + state.values = []; + state.cursor = 0; + state.refreshingRef.current = false; + state.refreshProviders.mockReset(); +}); + +it("keeps a newer environment failure when an older refresh finishes", async () => { + const a = EnvironmentId.make("mobile-limits-a"); + const b = EnvironmentId.make("mobile-limits-b"); + const pending = Promise.withResolvers<{ _tag: string }>(); + const read = () => { + state.cursor = 0; + return useRefreshLimits(); + }; + const presentation = (label: string) => ({ + connection: { phase: "connected" }, + entry: { target: { label } }, + }); + state.presentations = new Map([[a, presentation("A")]]); + state.refreshProviders.mockImplementation(({ environmentId }) => + environmentId === a ? pending.promise : Promise.resolve({ _tag: "Failure" }), + ); + const first = read().refresh(); + state.presentations = new Map([ + [a, presentation("A")], + [b, presentation("B")], + ]); + read(); + await state.autoRefresh(); + expect(read().failedLabels).toEqual(["B"]); + pending.resolve({ _tag: "Success" }); + await first; + expect(read().failedLabels).toEqual(["B"]); +}); + +it("does not let an older multi-environment batch clear a newer failure for the same environment", async () => { + const a = EnvironmentId.make("mobile-limits-race-a"); + const b = EnvironmentId.make("mobile-limits-race-b"); + const aFirst = Promise.withResolvers<{ _tag: string }>(); + const bFirst = Promise.withResolvers<{ _tag: string }>(); + const read = (selected: ReadonlySet | null = null) => { + state.cursor = 0; + return useRefreshLimits(selected); + }; + const presentation = (label: string) => ({ + connection: { phase: "connected" }, + entry: { target: { label } }, + }); + state.presentations = new Map([ + [a, presentation("A")], + [b, presentation("B")], + ]); + let aCalls = 0; + state.refreshProviders.mockImplementation(({ environmentId }) => + environmentId === b + ? bFirst.promise + : ++aCalls === 1 + ? aFirst.promise + : Promise.resolve({ _tag: "Failure" }), + ); + read(); + const older = state.autoRefresh(); + aFirst.resolve({ _tag: "Success" }); + await refreshUsageLimits(a, () => aFirst.promise); + const selected = new Set([a]); + await read(selected).refresh(); + expect(read(selected).failedLabels).toEqual(["A"]); + bFirst.resolve({ _tag: "Success" }); + await older; + expect(read(selected).failedLabels).toEqual(["A"]); +}); diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index e7afe114a566..5383602869bb 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -16,7 +16,8 @@ import { paceOf, remainingPercent, } from "@t3tools/shared/usageLimits"; -import { type ReactNode, useState } from "react"; +import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react"; +import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage"; import { Alert, Pressable, View } from "react-native"; import { AppText as Text } from "../../components/AppText"; @@ -279,47 +280,79 @@ export function ResetCredits(props: { * Environments whose probe failed are named, since their rows keep showing * the previous quota with nothing else to say so. */ -export function useRefreshLimits(selectedEnvironmentIds: ReadonlySet | null = null) { +export function useRefreshLimits( + selectedEnvironmentIds: ReadonlySet | null = null, + active = false, +) { const presentations = useAtomValue(environmentPresentations.presentationsAtom); const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false, }); const [now, setNow] = useState(() => Date.now()); const [refreshing, setRefreshing] = useState(false); + const refreshingRef = useRef(false); const [failedEnvironments, setFailedEnvironments] = useState< readonly { environmentId: EnvironmentId; label: string }[] >([]); - // Always toggles `refreshing`, even with nothing to probe: Android's - // RefreshControl keeps its spinner up until it sees true then false. - const refresh = async () => { + const refresh = async (automatic = false) => { const connected = [...presentations].filter( ([environmentId, presentation]) => presentation.connection.phase === "connected" && (selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)), ); - setRefreshing(true); try { - const results = await Promise.all( - connected.map(([environmentId]) => refreshProviders({ environmentId, input: {} })), - ); - setFailedEnvironments( - connected - .filter((_, index) => results[index]?._tag === "Failure") - .map(([environmentId, presentation]) => ({ + await Promise.all( + connected.map(async ([environmentId, presentation]) => { + const result = await refreshUsageLimits( environmentId, - label: presentation.entry.target.label, - })), + () => refreshProviders({ environmentId, input: {} }), + automatic, + ); + if (result === undefined) return; + setFailedEnvironments((previous) => [ + ...previous.filter((failed) => failed.environmentId !== environmentId), + ...(result._tag === "Failure" + ? [{ environmentId, label: presentation.entry.target.label }] + : []), + ]); + }), ); } finally { setNow(Date.now()); + } + }; + // Always toggles `refreshing`, even with nothing to probe: Android's + // RefreshControl keeps its spinner up until it sees true then false. + const refreshManually = async () => { + if (refreshingRef.current) return; + refreshingRef.current = true; + setRefreshing(true); + try { + await refresh(); + } finally { + refreshingRef.current = false; setRefreshing(false); } }; + const connectedLimitsEnvironments = [...presentations] + .filter( + ([environmentId, presentation]) => + presentation.connection.phase === "connected" && + (selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)), + ) + .map(([environmentId]) => environmentId) + .sort() + .join(","); + const autoRefreshLimits = useEffectEvent(() => refresh(true)); + useEffect(() => { + if (active && connectedLimitsEnvironments) void autoRefreshLimits(); + }, [active, connectedLimitsEnvironments]); + const failedLabels = failedEnvironments .filter( ({ environmentId }) => selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId), ) .map(({ label }) => label); - return { now, refreshing, failedLabels, refresh }; + return { now, refreshing, failedLabels, refresh: refreshManually }; } diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 59910c295547..6f00300f1acc 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -1,5 +1,5 @@ import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; -import { type RouteProp, useNavigation, useRoute } from "@react-navigation/native"; +import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native"; import { isCompatibleUsageContractVersion, isModelCostUnknown, @@ -95,7 +95,8 @@ export function UsageRouteScreen() { window, selectedEnvironmentIds, ); - const limits = useRefreshLimits(selectedEnvironmentIds); + const isFocused = useIsFocused(); + const limits = useRefreshLimits(selectedEnvironmentIds, isFocused && tab === "limits"); const days = useMemo( () => enumerateDays(window.sinceDay, window.untilDay), diff --git a/apps/mobile/src/state/asset-url-state.ts b/apps/mobile/src/state/asset-url-state.ts index d70e42410fa4..e0b9d433c8a3 100644 --- a/apps/mobile/src/state/asset-url-state.ts +++ b/apps/mobile/src/state/asset-url-state.ts @@ -21,7 +21,8 @@ export function deriveAssetUrlState(input: { if ( input.connectionPhase === "offline" || input.connectionPhase === "reconnecting" || - input.connectionPhase === "error" + input.connectionPhase === "error" || + input.connectionPhase === "unsupported" ) { return { _tag: "Failure", reason: "disconnected" }; } diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index 16bea31999b3..1664dc7461aa 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -128,7 +128,16 @@ export function useRemoteConnections() { const error = Cause.squash(result.cause); const message = error instanceof Error ? error.message : "Failed to pair with the environment."; - setPendingConnectionError(message); + if ( + error !== null && + typeof error === "object" && + "reason" in error && + error.reason === "unsupported" + ) { + Alert.alert("Client not supported", message); + } else { + setPendingConnectionError(message); + } } else { appAtomRegistry.set(connectionPairingUrlAtom, ""); } diff --git a/apps/mobile/src/state/workspaceModel.ts b/apps/mobile/src/state/workspaceModel.ts index 334f6a1643f9..66bf6f49d964 100644 --- a/apps/mobile/src/state/workspaceModel.ts +++ b/apps/mobile/src/state/workspaceModel.ts @@ -65,6 +65,9 @@ function overallConnectionState( if (environments.some((environment) => environment.connectionState === "connecting")) { return "connecting"; } + if (environments.some((environment) => environment.connectionState === "unsupported")) { + return "unsupported"; + } if (environments.some((environment) => environment.connectionState === "error")) { return "error"; } diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 4d8a384ee997..510478a57a02 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -352,7 +352,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge( Layer.mock(PullRequestService.PullRequestService)({ - refreshAfterTurn: Effect.void, + refreshAfterTurn: () => Effect.void, }), ), Layer.provideMerge( diff --git a/apps/server/src/cloud/servicePreflight.test.ts b/apps/server/src/cloud/servicePreflight.test.ts index 2eb2e02015d0..1b9cf8e46c41 100644 --- a/apps/server/src/cloud/servicePreflight.test.ts +++ b/apps/server/src/cloud/servicePreflight.test.ts @@ -3,15 +3,22 @@ import { expect, it } from "@effect/vitest"; import { runServicePreflight } from "./servicePreflight.ts"; import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts"; -it("requires the database-snapshot launcher protocol", () => { +it.each([1, 2])("blocks legacy launcher protocol %i", (launcherProtocol) => { expect( runServicePreflight({ databasePath: "/missing/state.sqlite", - launcherProtocol: SERVICE_LAUNCHER_PROTOCOL - 1, + launcherProtocol, version: "1.2.3", }), - ).toMatchObject({ status: "blocked", version: "1.2.3" }); + ).toEqual({ + status: "blocked", + version: "1.2.3", + reason: + "This release requires a newer T3 Code service launcher. Update it on the server machine.", + }); +}); +it("accepts the current launcher protocol", () => { expect( runServicePreflight({ databasePath: "/missing/state.sqlite", diff --git a/apps/server/src/cloud/serviceProtocol.ts b/apps/server/src/cloud/serviceProtocol.ts index a008cbc2030b..2d32a996ee2c 100644 --- a/apps/server/src/cloud/serviceProtocol.ts +++ b/apps/server/src/cloud/serviceProtocol.ts @@ -1,7 +1,8 @@ import type { ServerSelfUpdateOutcome } from "@t3tools/contracts"; -/** Protocol 2 snapshots SQLite before trials so migrations can be rolled back safely. */ -export const SERVICE_LAUNCHER_PROTOCOL = 2 as const; +// Protocol 3 requires the standalone executable layout. Bump when runtimePaths +// or the installed runtime tree changes incompatibly; launchers survive self-updates. +export const SERVICE_LAUNCHER_PROTOCOL = 3 as const; export const SERVICE_LAUNCHER_CONTEXT_ENV = "T3_SERVICE_LAUNCHER_CONTEXT"; export const SERVICE_STATE_FILE = "service-state.json"; /** Written by the launcher just before an explicit stop kills its child, so diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 2577866838b1..f2299cdd599c 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -1,4 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; @@ -163,6 +164,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir))); expect(first.environmentId).toBe(second.environmentId); + expect(first.orchestrationProtocolVersion).toBe(ORCHESTRATION_PROTOCOL_VERSION); expect(second.capabilities.repositoryIdentity).toBe(true); expect(second.capabilities.connectionProbe).toBe(true); expect(second.capabilities.attachmentUploads).toBe(true); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 9c25767245df..64d8dfab1733 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -1,5 +1,6 @@ import { EnvironmentId, + ORCHESTRATION_PROTOCOL_VERSION, PROVIDER_SEND_TURN_MAX_FILE_BYTES, type ExecutionEnvironmentDescriptor, } from "@t3tools/contracts"; @@ -212,6 +213,7 @@ export const make = Effect.gen(function* () { ...(machine === null ? {} : { machine }), }, serverVersion: packageJson.version, + orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION, capabilities: { repositoryIdentity: true, connectionProbe: true, diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 07fb9cdcc0cd..2cc7a4399915 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -336,7 +336,7 @@ describe("CheckpointReactor", () => { prefix: "t3-checkpoint-reactor-test-", }); const pullRequestRefreshes: number[] = []; - const refreshAfterTurn = Effect.sync(() => void pullRequestRefreshes.push(1)); + const refreshAfterTurn = () => Effect.sync(() => void pullRequestRefreshes.push(1)); const vcsStatusBroadcasterLayer = Layer.succeed(VcsStatusBroadcaster, { getStatus: () => Effect.die("getStatus should not be called in this test"), refreshLocalStatus: (cwd: string) => diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 38868430ca01..d0d867fdd25e 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -886,7 +886,7 @@ const make = Effect.gen(function* () { (startedTurnId === undefined && !thread.session?.activeTurnId)) ) { pending.delete(event.threadId); - yield* pullRequests.refreshAfterTurn; + yield* pullRequests.refreshAfterTurn(thread.projectId); } if ( event.type === "turn.aborted" && diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 90f7470a8c5c..864e3171a213 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -4371,14 +4371,19 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { type: "project.meta.update", commandId: CommandId.make("cmd-monogram-save"), projectId, - projectIcon: { kind: "lucide", name: "folder-code", color: "violet", monogram: "T3" }, + projectIcon: { kind: "monogram", text: "T3", color: "violet" }, }); const saved = yield* sql<{ readonly icon: string | null; }>`SELECT project_icon_json AS icon FROM projection_projects WHERE project_id = ${projectId}`; assert.deepEqual(saved, [ - { icon: '{"kind":"lucide","name":"folder-code","color":"violet","monogram":"T3"}' }, + { icon: '{"kind":"lucide","name":"folder-code","color":"violet","monogramText":"T3"}' }, ]); + const persisted = yield* sql<{ readonly icon: string }>` + SELECT json_extract(payload_json, '$.projectIcon') AS icon FROM orchestration_events + WHERE command_id = ${CommandId.make("cmd-monogram-save")} + `; + assert.deepEqual(persisted, saved); yield* engine.dispatch({ type: "project.meta.update", commandId: CommandId.make("cmd-monogram-clear"), diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index 732605d00217..14169d067053 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -248,6 +248,35 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { name: "alarm-clock", color: "violet", }); + + for (const text of ["T3", "e\u0301", "किखि", "क्ष्म", "\u1100\u1161\u11a8"]) { + const monogram = { kind: "monogram", text, color: "violet" } as const; + const result = yield* decideOrchestrationCommand({ + command: { + type: "project.meta.update", + commandId: CommandId.make("cmd-monogram"), + projectId: asProjectId("project-favicon"), + projectIcon: monogram, + }, + readModel, + }); + const updated = Array.isArray(result) ? result[0] : result; + expect(updated.payload).toMatchObject({ projectIcon: monogram }); + } + for (const text of ["ABC", "किखिगि"]) { + const failure = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "project.meta.update", + commandId: CommandId.make("cmd-monogram-invalid"), + projectId: asProjectId("project-favicon"), + projectIcon: { kind: "monogram", text, color: "violet" }, + }, + readModel, + }), + ); + expect(failure).toMatchObject({ _tag: "OrchestrationCommandInvariantError" }); + } }), ); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 86be0610f804..45c4937a5e3f 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -47,6 +47,8 @@ import { import { projectEvent } from "./projector.ts"; import { threadHasQueuedTurnStart } from "./ThreadSettlementPolicy.ts"; +const monogramSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + const isScriptRunCommand = Schema.is(SCRIPT_RUN_COMMAND_PATTERN); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -264,6 +266,15 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, projectId: command.projectId, }); + if ( + command.projectIcon?.kind === "monogram" && + Array.from(monogramSegmenter.segment(command.projectIcon.text)).length > 2 + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Project monograms must contain at most two characters.", + }); + } if (command.scripts !== undefined) { // Persisted IDs predate shortcut validation. Let users edit or remove them // without allowing another invalid ID to enter the project. diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts index a13b4f77e39e..d995e81422d0 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts @@ -9,6 +9,7 @@ import { OrchestrationEventMetadata, OrchestrationEventType, ProjectId, + ProjectIconOverride, ThreadId, } from "@t3tools/contracts"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -29,6 +30,7 @@ import { type OrchestrationEventStoreShape, } from "../Services/OrchestrationEventStore.ts"; +const encodeProjectIcon = Schema.encodeSync(ProjectIconOverride); const decodeEvent = Schema.decodeUnknownEffect(OrchestrationEvent); const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); const EventMetadataFromJsonString = Schema.fromJsonString(OrchestrationEventMetadata); @@ -263,7 +265,10 @@ const makeEventStore = Effect.gen(function* () { actorKind: inferActorKind(event), occurredAt: event.occurredAt, commandId: event.commandId, - payloadJson: event.payload, + payloadJson: + "projectIcon" in event.payload && event.payload.projectIcon + ? { ...event.payload, projectIcon: encodeProjectIcon(event.payload.projectIcon) } + : event.payload, metadataJson: event.metadata, }).pipe( Effect.mapError( diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index a62d1ed21970..2f90c5b79553 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -9,6 +9,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import * as GitHubCli from "../sourceControl/GitHubCli.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts"; @@ -202,6 +203,7 @@ it.effect( let activeToken = "broad-credential"; const commands: VcsProcess.VcsProcessInput[] = []; const github = yield* GitHubCli.make.pipe( + Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer)), Effect.provideService(VcsProcess.VcsProcess, { run: (input) => Effect.sync(() => { diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 2558f6695f1a..1b2efa7ec70e 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -1106,6 +1106,7 @@ export const make = Effect.gen(function* () { captureVerifiedCredential(input).pipe( Effect.flatMap(({ host, token, accountId, viewer, credentialFingerprint }) => use({ accountId, viewer, credentialFingerprint }).pipe( + Effect.provideService(SourceControlRateLimit.CredentialScope, credentialFingerprint), Effect.provideService(GitHubCli.PinnedGitHubCredential, { host, token, diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index f8019ca36b4f..1d003a970ee8 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -109,7 +109,11 @@ export function gitHubProviderFailure( ): PullRequestProviderFailure { if (error._tag === "GitHubCliUnavailableError") return { reason: "missing-tool" }; if (error._tag === "GitHubCliAuthenticationError") return { reason: "unauthenticated" }; - if (error._tag === "GitHubCliRateLimitError") return { reason: "rate-limited" }; + if (error._tag === "GitHubCliRateLimitError") + return { + reason: "rate-limited", + ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), + }; if (error._tag === "SourceControlRateLimitPausedError") { return { reason: "rate-limited", retryAt: error.retryAt }; } diff --git a/apps/server/src/pullRequest/PullRequestReadCache.test.ts b/apps/server/src/pullRequest/PullRequestReadCache.test.ts index f94ff3cf586d..03bfe88abfd0 100644 --- a/apps/server/src/pullRequest/PullRequestReadCache.test.ts +++ b/apps/server/src/pullRequest/PullRequestReadCache.test.ts @@ -5,18 +5,12 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; import * as TestClock from "effect/testing/TestClock"; import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"; -import * as Persistence from "effect/unstable/persistence/Persistence"; import * as PullRequestReadCache from "./PullRequestReadCache.ts"; const cacheLayer = (directory: string) => - PullRequestReadCache.make.pipe( - Effect.provide( - Persistence.layerKvs.pipe(Layer.provideMerge(KeyValueStore.layerFileSystem(directory))), - ), - ); + PullRequestReadCache.make.pipe(Effect.provide(KeyValueStore.layerFileSystem(directory))); it.layer(NodeServices.layer)("PR filesystem cache", (it) => { it.effect("reuses files after restart and respects the original expiry", () => @@ -52,15 +46,106 @@ it.layer(NodeServices.layer)("PR filesystem cache", (it) => { Effect.andThen(Deferred.await(release)), Effect.as("old"), ), + ["pr"], ) .pipe(Effect.forkChild); yield* Deferred.await(started); - const invalidate = yield* cache.invalidate.pipe(Effect.forkChild({ startImmediately: true })); + const invalidate = yield* cache + .invalidate("pr") + .pipe(Effect.forkChild({ startImmediately: true })); yield* Deferred.succeed(release, undefined); yield* Fiber.join(read); yield* Fiber.join(invalidate); const restarted = yield* cacheLayer(directory); - assert.strictEqual(yield* restarted.get("summary", Effect.succeed("new")), "new"); + assert.strictEqual(yield* restarted.get("summary", Effect.succeed("new"), ["pr"]), "new"); + }), + ); + + it.effect("invalidates only the changed scope across restarts and coalesces its next reads", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-cache-" }); + let reads = 0; + const lookup = Effect.sync(() => String(++reads)); + const cache = yield* cacheLayer(directory); + yield* cache.get("first", lookup, ["project", "pr-1"]); + yield* cache.get("second", lookup, ["project", "pr-2"]); + yield* cache.get("third", lookup, ["other-project", "pr-3"]); + yield* cache.invalidate("pr-1"); + const restarted = yield* cacheLayer(directory); + const answers = yield* Effect.all( + Array.from({ length: 10 }, () => restarted.get("first", lookup, ["project", "pr-1"])), + { concurrency: 10 }, + ); + assert.deepStrictEqual(answers, Array(10).fill("4")); + assert.strictEqual(yield* restarted.get("second", lookup, ["project", "pr-2"]), "2"); + yield* restarted.invalidate("project"); + const again = yield* cacheLayer(directory); + assert.strictEqual(yield* again.get("second", lookup, ["project", "pr-2"]), "5"); + assert.strictEqual(yield* again.get("third", lookup, ["other-project", "pr-3"]), "3"); + assert.strictEqual(reads, 5); + const files = (yield* fs.readDirectory(directory)).length; + for (let index = 0; index < 3; index++) { + yield* again.invalidate("pr-1"); + yield* again.get("first", lookup, ["project", "pr-1"]); + } + assert.strictEqual((yield* fs.readDirectory(directory)).length, files); + }), + ); + + it.effect("shares a pending refresh without blocking an unrelated cached PR", () => + Effect.gen(function* () { + const cache = yield* PullRequestReadCache.make; + yield* cache.get("first", Effect.succeed("old"), ["pr-1"]); + yield* cache.get("second", Effect.succeed("warm"), ["pr-2"]); + yield* cache.invalidate("pr-1"); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + let reads = 0; + const refresh = cache.get( + "first", + Effect.gen(function* () { + reads++; + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + return "fresh"; + }), + ["pr-1"], + ); + const pending = yield* Effect.all( + Array.from({ length: 10 }, () => refresh), + { + concurrency: 10, + }, + ).pipe(Effect.forkChild); + yield* Deferred.await(started); + assert.strictEqual(yield* cache.get("second", Effect.die("cache miss"), ["pr-2"]), "warm"); + yield* Deferred.succeed(release, undefined); + assert.deepStrictEqual(yield* Fiber.join(pending), Array(10).fill("fresh")); + assert.strictEqual(reads, 1); + }).pipe(Effect.provide(KeyValueStore.layerMemory)), + ); + + it.effect("compacts expired scope records without discarding fresh PR data", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-cache-" }); + const cache = yield* cacheLayer(directory); + yield* cache.get("summary", Effect.succeed("old"), ["pr"]); + yield* cache.invalidate("pr"); + for (let index = 0; index < 100; index++) yield* cache.invalidate(`pr-${index}`); + assert.strictEqual((yield* fs.readDirectory(directory)).length, 2); + const before = (yield* fs.stat(`${directory}/revisions`)).size; + yield* TestClock.adjust("59 seconds"); + assert.strictEqual(yield* cache.get("summary", Effect.succeed("fresh"), ["pr"]), "fresh"); + yield* TestClock.adjust("1 second"); + yield* cache.invalidate("other-pr"); + assert.isTrue((yield* fs.stat(`${directory}/revisions`)).size < before); + const restarted = yield* cacheLayer(directory); + assert.strictEqual( + yield* restarted.get("summary", Effect.die("cache miss"), ["pr"]), + "fresh", + ); }), ); @@ -75,4 +160,85 @@ it.layer(NodeServices.layer)("PR filesystem cache", (it) => { assert.strictEqual(yield* restarted.get("summary", Effect.succeed("recovered")), "recovered"); }), ); + + it.effect("resumes caching after a failed scope read", () => + Effect.gen(function* () { + const backing = yield* KeyValueStore.KeyValueStore; + let fail = true; + let reads = 0; + const cache = yield* PullRequestReadCache.make.pipe( + Effect.provideService(KeyValueStore.KeyValueStore, { + ...backing, + get: (key) => + Effect.suspend(() => { + if (!fail) return backing.get(key); + fail = false; + return Effect.fail( + new KeyValueStore.KeyValueStoreError({ method: "get", message: "unavailable" }), + ); + }), + }), + ); + const read = cache.get( + "summary", + Effect.sync(() => String(++reads)), + ["pr"], + ); + assert.strictEqual(yield* read, "1"); + assert.strictEqual(yield* read, "2"); + assert.strictEqual(yield* read, "2"); + }).pipe(Effect.provide(KeyValueStore.layerMemory)), + ); + + it.effect("cancels abandoned reads without blocking invalidation", () => + Effect.gen(function* () { + const cache = yield* PullRequestReadCache.make; + const started = yield* Deferred.make(); + const read = yield* cache + .get("summary", Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), [ + "pr", + ]) + .pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* Fiber.interrupt(read); + yield* cache.invalidate("pr"); + assert.strictEqual(yield* cache.get("summary", Effect.succeed("fresh"), ["pr"]), "fresh"); + }).pipe(Effect.provide(KeyValueStore.layerMemory)), + ); + + it.effect( + "finishes the in-memory revision update when invalidation is canceled after writing", + () => + Effect.gen(function* () { + const backing = yield* KeyValueStore.KeyValueStore; + const written = yield* Deferred.make(); + const release = yield* Deferred.make(); + const cache = yield* PullRequestReadCache.make.pipe( + Effect.provideService(KeyValueStore.KeyValueStore, { + ...backing, + set: (key, value) => + backing + .set(key, value) + .pipe( + Effect.andThen( + key === "revisions" + ? Deferred.succeed(written, undefined).pipe( + Effect.andThen(Deferred.await(release)), + ) + : Effect.void, + ), + ), + }), + ); + yield* cache.get("summary", Effect.succeed("old"), ["pr"]); + const invalidation = yield* cache.invalidate("pr").pipe(Effect.forkChild); + yield* Deferred.await(written); + const interrupt = yield* Fiber.interrupt(invalidation).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(interrupt); + assert.strictEqual(yield* cache.get("summary", Effect.succeed("fresh"), ["pr"]), "fresh"); + }).pipe(Effect.provide(KeyValueStore.layerMemory)), + ); }); diff --git a/apps/server/src/pullRequest/PullRequestReadCache.ts b/apps/server/src/pullRequest/PullRequestReadCache.ts index 62d1cffc3c83..1b4ded39b953 100644 --- a/apps/server/src/pullRequest/PullRequestReadCache.ts +++ b/apps/server/src/pullRequest/PullRequestReadCache.ts @@ -5,7 +5,6 @@ import * as Hash from "effect/Hash"; import { PullRequestOperationError, PullRequestUnavailableError } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as Encoding from "effect/Encoding"; -import * as Option from "effect/Option"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -22,19 +21,35 @@ import { ServerConfig } from "../config.ts"; const CONCURRENT_READS = 512; type ReadError = PullRequestOperationError | PullRequestUnavailableError; - +const revisionCodec = Schema.fromJsonString( + Schema.Record( + Schema.String, + Schema.Struct({ revision: Schema.String, expiresAt: Schema.Finite }), + ), +); class Read extends Persistable.Class<{ - payload: { key: string; lookup: Effect.Effect }; + payload: { key: string; revision: string; lookup: Effect.Effect }; }>()("PullRequestRead", { primaryKey: ({ key }) => key, - success: Schema.Struct({ payload: Schema.String, expiresAt: Schema.Finite }), + success: Schema.Struct({ + payload: Schema.String, + expiresAt: Schema.Finite, + revision: Schema.optionalKey(Schema.String), + }), error: Schema.Union([PullRequestOperationError, PullRequestUnavailableError]), }) { + matchesRevision(revision: string | undefined): boolean { + const stored = revision?.split(":") ?? []; + return this.revision + .split(":") + .every((value, index) => value === "" || value === stored[index]); + } + [Equal.symbol](that: unknown): boolean { - return that instanceof Read && that.key === this.key; + return that instanceof Read && that.key === this.key && that.revision === this.revision; } [Hash.symbol](): number { - return Hash.string(this.key); + return Hash.string(`${this.key}:${this.revision}`); } } @@ -44,8 +59,9 @@ export class PullRequestReadCache extends Context.Service< readonly get: ( key: string, lookup: Effect.Effect, + scopes?: ReadonlyArray, ) => Effect.Effect; - readonly invalidate: Effect.Effect; + readonly invalidate: (scope: string) => Effect.Effect; } >()("t3/pullRequest/PullRequestReadCache") {} @@ -55,6 +71,18 @@ export const make = Effect.gen(function* () { const clock = yield* Clock.Clock; let enabled = true; const lock = yield* Semaphore.make(CONCURRENT_READS); + const digest = (key: string) => + crypto.digest("SHA-256", new TextEncoder().encode(key)).pipe(Effect.map(Encoding.encodeHex)); + const revisions = yield* Cache.makeWith( + () => + backing + .get("revisions") + .pipe(Effect.flatMap((raw) => Schema.decodeUnknownEffect(revisionCodec)(raw ?? "{}"))), + { + capacity: 1, + timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero), + }, + ); const timeToLive: Persistable.TimeToLiveFn = (exit) => Exit.isSuccess(exit) ? Duration.millis(Math.max(0, exit.value.expiresAt - clock.currentTimeMillisUnsafe())) @@ -62,7 +90,11 @@ export const make = Effect.gen(function* () { const cache = yield* PersistedCache.make( (request: Read) => request.lookup.pipe( - Effect.map((payload) => ({ payload, expiresAt: clock.currentTimeMillisUnsafe() + 60_000 })), + Effect.map((payload) => ({ + payload, + expiresAt: clock.currentTimeMillisUnsafe() + 60_000, + revision: request.revision, + })), ), { storeId: "pr-v2", @@ -70,36 +102,63 @@ export const make = Effect.gen(function* () { inMemoryTTL: timeToLive, inMemoryCapacity: CONCURRENT_READS, }, + ).pipe(Effect.provide(Persistence.layerKvs)); + const refreshes = yield* Cache.makeWith( + Effect.fn("PullRequestReadCache.refresh")(function* (request: Read) { + const stored = yield* cache.get(request); + if (request.matchesRevision(stored.revision)) return stored; + yield* cache.invalidate(request); + return yield* cache.get(request); + }), + { capacity: CONCURRENT_READS, timeToLive: () => Duration.zero }, ); return PullRequestReadCache.of({ - get: Effect.fn("PullRequestReadCache.get")(function* (key, lookup) { + get: Effect.fn("PullRequestReadCache.get")(function* (key, lookup, scopes = []) { if (!enabled) return yield* lookup; - const digest = yield* crypto - .digest("SHA-256", new TextEncoder().encode(key)) - .pipe(Effect.option); - if (Option.isNone(digest)) return yield* lookup; const read = yield* Effect.cached(lookup); - return yield* cache - .get(new Read({ key: Encoding.encodeHex(digest.value), lookup: read })) - .pipe( - Effect.map((result) => result.payload), - Effect.catchTags({ - PersistenceError: () => read, - SchemaError: () => read, - }), - Effect.uninterruptible, - lock.withPermits(1), - ); + return yield* Effect.gen(function* () { + const current = yield* Cache.get(revisions, undefined); + const now = clock.currentTimeMillisUnsafe(); + const revision = scopes + .map((scope) => { + const value = current[scope]; + return value !== undefined && value.expiresAt > now ? value.revision : ""; + }) + .join(":"); + const request = new Read({ key: yield* digest(key), revision, lookup: read }); + const stored = yield* cache.get(request); + return ( + request.matchesRevision(stored.revision) ? stored : yield* Cache.get(refreshes, request) + ).payload; + }).pipe( + Effect.catchTags({ + PlatformError: () => read, + KeyValueStoreError: () => read, + PersistenceError: () => read, + SchemaError: () => read, + }), + lock.withPermits(1), + ); }), - // Let existing reads finish before clearing, so they cannot repopulate stale entries. - invalidate: Cache.invalidateAll(cache.inMemory).pipe( - Effect.andThen(backing.clear), - Effect.catch(() => { - enabled = false; - return Effect.logWarning("PR cache disabled after clearing failed"); - }), - lock.withPermits(CONCURRENT_READS), - ), + invalidate: (scope) => + Effect.gen(function* () { + const now = clock.currentTimeMillisUnsafe(); + const current = yield* Cache.get(revisions, undefined); + const next = Object.fromEntries( + Object.entries(current).filter(([, value]) => value.expiresAt > now), + ); + next[scope] = { revision: yield* crypto.randomUUIDv4, expiresAt: now + 60_000 }; + const encoded = yield* Schema.encodeEffect(revisionCodec)(next); + yield* backing.set("revisions", encoded); + yield* Cache.set(revisions, undefined, next); + }).pipe( + Effect.catch(() => { + enabled = false; + return Effect.logWarning("PR cache disabled after clearing failed"); + }), + Effect.uninterruptible, + lock.withPermits(CONCURRENT_READS), + ), }); }); @@ -108,7 +167,6 @@ export const layer = Layer.unwrap( const config = yield* ServerConfig; const path = yield* Path.Path; return Layer.effect(PullRequestReadCache, make).pipe( - Layer.provide(Persistence.layerKvs), Layer.provide( KeyValueStore.layerFileSystem( path.join(config.providerStatusCacheDir, "pull-requests"), diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 9a1009c243de..c576101fa5a9 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1,6 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"; -import * as Persistence from "effect/unstable/persistence/Persistence"; import { assert, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -204,7 +203,6 @@ function makeService(input: { }), SourceControlRateLimit.layer, Layer.effect(PullRequestReadCache.PullRequestReadCache, PullRequestReadCache.make).pipe( - Layer.provide(Persistence.layerKvs), Layer.provide(KeyValueStore.layerMemory), Layer.provide(NodeServices.layer), ), @@ -3117,6 +3115,107 @@ it.effect("a listing narrowed to some projects is its own cache entry", () => }), ); +it.effect("keeps unrelated PRs warm after a mutation, explicit refresh, and project turn", () => + Effect.gen(function* () { + const calls: string[] = []; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }), + ], + providers: [ + fakeProvider("github", { + getChangeRequest: (input) => + Effect.sync(() => { + calls.push(`${input.repository}/${input.number}`); + return { ...hostedChangeRequest("body"), number: input.number }; + }), + }), + ], + }); + const refs = [ + { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }, + { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 }, + { projectId: "p2" as ProjectId, repository: "acme/docs", number: 3 }, + ]; + const readAll = Effect.forEach(refs, (ref) => service.summary({ ...ref, allowStale: false })); + yield* readAll; + yield* service.invalidate({ reference: { ...refs[0]!, host: "github.com" } }); + yield* readAll; + assert.deepStrictEqual(calls, ["acme/web/1", "acme/web/2", "acme/docs/3", "acme/web/1"]); + yield* service.comment({ ...refs[0]!, body: "hello" }); + yield* readAll; + assert.deepStrictEqual(calls.slice(4), ["acme/web/1"]); + yield* service.refreshAfterTurn("p1" as ProjectId); + yield* readAll; + assert.deepStrictEqual(calls.slice(5), ["acme/web/1", "acme/web/2"]); + }), +); + +it.effect( + "keeps matching PR numbers on different hosts separate and refreshes the serving project", + () => + Effect.gen(function* () { + const hosts: string[] = []; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "public", workspaceRoot: "/a", repository: "acme/web" }), + project({ + id: "p2", + title: "enterprise", + workspaceRoot: "/b", + repository: "acme/web", + host: "enterprise.test", + }), + ], + providers: [ + fakeProvider("github", { + getChangeRequest: (input) => + Effect.sync(() => { + hosts.push(input.host); + return hostedChangeRequest("body"); + }), + }), + ], + }); + const own = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const other = { ...own, host: "enterprise.test" }; + const readBoth = Effect.all([service.summary(own), service.summary(other)]); + yield* readBoth; + yield* service.invalidate({ reference: { ...own, host: "github.com" } }); + yield* readBoth; + assert.deepStrictEqual(hosts, ["github.com", "enterprise.test", "github.com"]); + yield* service.invalidate({ reference: own }); + yield* readBoth; + assert.deepStrictEqual(hosts.slice(3), ["github.com"]); + yield* service.refreshAfterTurn("p2" as ProjectId); + yield* readBoth; + assert.deepStrictEqual(hosts.slice(4), ["enterprise.test"]); + }), +); + +it.effect("does not revive old summaries when project epochs are evicted", () => + Effect.gen(function* () { + let title = "old"; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("body"), title }), + }), + ], + }); + const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + assert.strictEqual((yield* service.summary(ref))?.title, "old"); + title = "new"; + yield* service.refreshAfterTurn(ref.projectId); + assert.strictEqual((yield* service.summary(ref))?.title, "new"); + for (let index = 0; index < 2048; index++) + yield* service.refreshAfterTurn(`project-${index}` as ProjectId); + assert.strictEqual((yield* service.summary(ref))?.title, "new"); + }), +); + it.effect("explicit and turn invalidations make the next listing ask the host again", () => Effect.gen(function* () { let hostCalls = 0; @@ -3148,7 +3247,7 @@ it.effect("explicit and turn invalidations make the next listing ask the host ag yield* service.invalidate({ reference }); yield* service.list({ state: "open" }); assert.strictEqual(hostCalls, 2); - yield* service.refreshAfterTurn; + yield* service.refreshAfterTurn("p1" as ProjectId); const refresh = Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes)); yield* service.list({ state: "open" }); assert.isAbove(refresh, 0); @@ -3551,7 +3650,7 @@ it.effect( yield* service.listStats({ refs: [ref(1), ref(2), ref(3)] }); assert.deepStrictEqual(asked, [[1, 2], [3], [2], [1, 2, 3]]); - yield* service.refreshAfterTurn; + yield* service.refreshAfterTurn("p1" as ProjectId); yield* service.listStats({ refs: [ref(1)] }); assert.deepStrictEqual(asked, [[1, 2], [3], [2], [1, 2, 3], [1]]); @@ -3768,9 +3867,9 @@ it.effect("shares linked summaries and reuses them for display without asking th }), ); -it.effect("keeps routed summaries and details separate when the GitHub account changes", () => +it.effect("keeps routed reads separate when the GitHub account changes", () => Effect.gen(function* () { - for (const operation of ["summary", "detail"] as const) { + for (const operation of ["summary", "detail", "diff"] as const) { let failing = false; let calls = 0; const read = () => @@ -3785,16 +3884,27 @@ it.effect("keeps routed summaries and details separate when the GitHub account c project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { getChangeRequestSummary: read, getChangeRequest: read }), + fakeProvider("github", { + getChangeRequestSummary: read, + getChangeRequest: read, + getDiff: () => + read().pipe( + Effect.as({ patch: "private patch", truncated: false, nextCursor: null }), + ), + }), ], }); + const readOperation = (input: Parameters[0]) => + Effect.gen(function* () { + yield* service[operation](input); + }); const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - yield* service[operation]({ ...reference, expectedAccountId: "101" }); + yield* readOperation({ ...reference, expectedAccountId: "101" }); failing = true; for (const allowStale of [false, true]) { const error = yield* Effect.flip( - service[operation]({ ...reference, expectedAccountId: "202", allowStale }), + readOperation({ ...reference, expectedAccountId: "202", allowStale }), ); assert.strictEqual(error._tag, "PullRequestOperationError"); } @@ -3805,7 +3915,7 @@ it.effect("keeps routed summaries and details separate when the GitHub account c it.effect("isolates routed caches for two credentials belonging to the same account", () => Effect.gen(function* () { - for (const operation of ["summary", "detail"] as const) { + for (const operation of ["summary", "detail", "diff"] as const) { let credential = "broad"; let calls = 0; const read = () => @@ -3831,9 +3941,17 @@ it.effect("isolates routed caches for two credentials belonging to the same acco ), getChangeRequest: read, getChangeRequestSummary: read, + getDiff: () => + read().pipe( + Effect.as({ patch: "private patch", truncated: false, nextCursor: null }), + ), }), ], }); + const readOperation = (input: Parameters[0]) => + Effect.gen(function* () { + yield* service[operation](input); + }); const reference = { projectId: "p1" as ProjectId, repository: "acme/web", @@ -3841,14 +3959,11 @@ it.effect("isolates routed caches for two credentials belonging to the same acco host: "github.com", expectedAccountId: "101", }; - yield* service.withRoutingCredential(reference, service[operation](reference)); + yield* service.withRoutingCredential(reference, readOperation(reference)); credential = "restricted"; for (const allowStale of [false, true]) { const error = yield* Effect.flip( - service.withRoutingCredential( - reference, - service[operation]({ ...reference, allowStale }), - ), + service.withRoutingCredential(reference, readOperation({ ...reference, allowStale })), ); assert.strictEqual(error._tag, "PullRequestOperationError"); } @@ -4354,90 +4469,104 @@ it.effect('resolves an author filter of "me" to the viewer before narrowing a ho }), ); -it.effect("authorizes stack rebases independently of whether the selected layer is behind", () => - Effect.gen(function* () { - let taken = 0; - let summaryReads = 0; - let mutationFails = false; - let stackRebase = true; - let stackActions = true; - const capabilities = { - diff: true, - comment: true, - actions: ["update-branch"] as const, - mergeMethods: ["merge"] as const, - updateMethods: ["rebase"] as const, - get stackActions() { - return stackActions; - }, - search: true, - reactions: true, - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider("github", { - capabilities, - getViewerPermissions: () => - Effect.succeed({ - actions: [], - stackRebase, - comment: true, - resolve: false, - verdicts: [], - requestReviewers: false, - }), - getChangeRequestSummary: () => - Effect.sync(() => { - summaryReads++; - return changeRequest(8, "2026-07-01T00:00:00Z"); +for (const crossHost of [false, true]) { + it.effect( + `authorizes stack rebases and refreshes sibling layers (cross-host: ${crossHost})`, + () => + Effect.gen(function* () { + let taken = 0; + let summaryReads = 0; + let mutationFails = false; + let stackRebase = true; + let stackActions = true; + const capabilities = { + diff: true, + comment: true, + actions: ["update-branch"] as const, + mergeMethods: ["merge"] as const, + updateMethods: ["rebase"] as const, + get stackActions() { + return stackActions; + }, + search: true, + reactions: true, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + project({ + id: "p2", + title: "enterprise", + workspaceRoot: "/b", + repository: "acme/web", + host: "enterprise.test", }), - runAction: () => - Effect.gen(function* () { - taken++; - if (mutationFails) return yield* requestFailed; + ], + providers: [ + fakeProvider("github", { + capabilities, + getViewerPermissions: () => + Effect.succeed({ + actions: [], + stackRebase, + comment: true, + resolve: false, + verdicts: [], + requestReviewers: false, + }), + getChangeRequestSummary: () => + Effect.sync(() => { + summaryReads++; + return changeRequest(8, "2026-07-01T00:00:00Z"); + }), + runAction: () => + Effect.gen(function* () { + taken++; + if (mutationFails) return yield* requestFailed; + }), }), - }), - ], - }); - const input = { - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 3, - action: "update-branch" as const, - updateMethod: "rebase" as const, - stackNumber: 50, - expectedStackHeads: [{ number: 3, headSha: "ccc" }], - }; - yield* service.runAction(input); - assert.strictEqual(taken, 1); - const unrelated = { ...input, number: 8 }; - yield* service.summary(unrelated); - assert.strictEqual(summaryReads, 1); - stackRebase = false; - assert.strictEqual( - (yield* Effect.flip(service.runAction(input)))._tag, - "PullRequestOperationError", - ); - stackRebase = true; - stackActions = false; - assert.strictEqual( - (yield* Effect.flip(service.runAction(input)))._tag, - "PullRequestOperationError", - ); - assert.strictEqual(taken, 1); - yield* service.summary(unrelated); - assert.strictEqual(summaryReads, 1); - stackActions = true; - mutationFails = true; - yield* Effect.flip(service.runAction(input)); - assert.strictEqual(taken, 2); - yield* service.summary(unrelated); - assert.strictEqual(summaryReads, 2); - }), -); + ], + }); + const input = { + ...(crossHost ? { host: "enterprise.test" } : {}), + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 3, + action: "update-branch" as const, + updateMethod: "rebase" as const, + stackNumber: 50, + expectedStackHeads: [{ number: 3, headSha: "ccc" }], + }; + yield* service.runAction(input); + assert.strictEqual(taken, 1); + const unrelated = { ...input, number: 8 }; + yield* service.summary(unrelated); + assert.strictEqual(summaryReads, 1); + stackRebase = false; + assert.strictEqual( + (yield* Effect.flip(service.runAction(input)))._tag, + "PullRequestOperationError", + ); + stackRebase = true; + stackActions = false; + assert.strictEqual( + (yield* Effect.flip(service.runAction(input)))._tag, + "PullRequestOperationError", + ); + assert.strictEqual(taken, 1); + yield* service.summary(unrelated); + assert.strictEqual(summaryReads, 1); + stackActions = true; + mutationFails = true; + yield* Effect.flip(service.runAction(input)); + assert.strictEqual(taken, 2); + yield* service.summary(unrelated); + assert.strictEqual(summaryReads, 2); + }), + ); +} it.effect("refuses a way of updating a branch that the host or the viewer does not allow", () => Effect.gen(function* () { @@ -4773,7 +4902,7 @@ it.effect("forgets the cached detail after a rewrite or terminal turn", () => yield* service.detail(reference); assert.strictEqual(coreCalls, 2); - yield* service.refreshAfterTurn; + yield* service.refreshAfterTurn("p1" as ProjectId); yield* service.detail(reference); assert.strictEqual(coreCalls, 3); }), diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index e4bd807cbefd..d0d57f4ae8a6 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -24,6 +24,7 @@ import { pullRequestProviderRequirement, resolvePullRequestAuthorFilter, type OrchestrationProjectShell, + type ProjectId, type PullRequestAction, type PullRequestActionInput, type PullRequestActivity, @@ -67,6 +68,7 @@ import { } from "@t3tools/contracts"; import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; +import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; @@ -186,7 +188,7 @@ export class PullRequestService extends Context.Service< Scope.Scope >; readonly subscribeRefreshes: Stream.Stream; - readonly refreshAfterTurn: Effect.Effect; + readonly refreshAfterTurn: (projectId: ProjectId) => Effect.Effect; readonly detail: (input: PullRequestRef) => Effect.Effect; readonly activity: ( input: PullRequestRef, @@ -464,6 +466,7 @@ function withRateLimitBackoff( ), Effect.flatMap((lease) => effect.pipe( + Effect.provideService(AllowGitHubReserve, allowPaused), Effect.tap(() => limits.recordSuccess({ ...key, lease })), Effect.tapError((error) => error.reason === "rate-limited" @@ -794,6 +797,18 @@ export const make = Effect.gen(function* () { }), ); + const canonicalRef = Effect.fn("PullRequestService.canonicalRef")(function* < + I extends PullRequestRef, + >(input: I) { + const project = yield* requireProject(input); + return { + ...input, + projectId: project.project.id, + host: project.host, + repository: project.repository, + }; + }); + /** * What the signed-in account may do with this change request, asked of the host itself. Every * write goes through it: the page hides what a viewer may not do, and a request that arrived @@ -1783,7 +1798,11 @@ export const make = Effect.gen(function* () { .pipe( // Once the authorized provider action starts, a failure may leave partial // remote updates. Validation and permission failures above changed nothing. - Effect.ensuring(input.stackNumber === undefined ? Effect.void : refreshAfterTurn), + Effect.ensuring( + input.stackNumber === undefined + ? Effect.void + : refreshAfterTurn(project.project.id), + ), Effect.mapError(toPullRequestError("runAction")), Effect.as( project.api.kind === "azure-devops" @@ -2416,13 +2435,22 @@ export const make = Effect.gen(function* () { // scope re-entering `refEpochs` after eviction can never mint a key an old entry still has. let epochCounter = 0; let listingsEpoch = 0; - let turnRefreshEpoch = 0; const refEpochs = new Map(); + const projectEpochs = new Map(); + let projectEpochFloor = 0; const REF_EPOCH_CAPACITY = 2_048; const refScope = (ref: PullRequestRef) => - `${ref.projectId} ${ref.host?.toLowerCase() ?? ""} ${ref.repository.toLowerCase()} ${ref.number}`; + JSON.stringify([ + ref.projectId, + ref.host?.toLowerCase() ?? "", + ref.repository.toLowerCase(), + ref.number, + ]); const refEpoch = (ref: PullRequestRef) => - Math.max(turnRefreshEpoch, refEpochs.get(refScope(ref)) ?? 0); + Math.max( + projectEpochs.get(ref.projectId) ?? projectEpochFloor, + refEpochs.get(refScope(ref)) ?? 0, + ); // Keys carry the reference back out of the cache loader, so the slot layout is shared with // `refOfCacheKey` rather than read positionally at every loader. const refCacheKey = (ref: CredentialRef) => @@ -2524,7 +2552,10 @@ export const make = Effect.gen(function* () { ), ), ); - const payload = yield* readCache.get(key, encodedRead); + const payload = yield* readCache.get(key, encodedRead, [ + `project:${input.projectId}`, + refScope(input), + ]); const decoded = yield* Schema.decodeUnknownEffect(codec)(payload).pipe(Effect.option); return Option.isSome(decoded) ? decoded.value : yield* lookup; }); @@ -2723,20 +2754,9 @@ export const make = Effect.gen(function* () { const diffCache = yield* Cache.makeWith( (key: string) => { - const [, projectId, host, repository, number, cursor, commit] = JSON.parse(key) as [ - number, - string, - string | null, - string, - number, - string | null, - string | null, - ]; + const [reference, cursor, commit] = JSON.parse(key) as [string, string | null, string | null]; return diffUncached({ - projectId, - ...(host === null ? {} : { host }), - repository, - number, + ...refOfCacheKey(reference), ...(cursor === null ? {} : { cursor }), ...(commit === null ? {} : { commit }), } as PullRequestDiffInput); @@ -2745,18 +2765,14 @@ export const make = Effect.gen(function* () { capacity: DIFF_CACHE_CAPACITY, timeToLive: (exit, key) => { if (!Exit.isSuccess(exit)) return Duration.zero; - const commit = (JSON.parse(key) as ReadonlyArray)[6]; + const commit = (JSON.parse(key) as ReadonlyArray)[2]; return commit === null ? DIFF_CACHE_TTL : COMMIT_DIFF_CACHE_TTL; }, }, ); const diff: PullRequestService["Service"]["diff"] = (input) => { const key = JSON.stringify([ - refEpoch(input), - input.projectId, - input.host?.toLowerCase() ?? null, - input.repository.toLowerCase(), - input.number, + refCacheKey(input), input.cursor ?? null, input.commit ?? null, input.commit === undefined @@ -2826,7 +2842,14 @@ export const make = Effect.gen(function* () { const invalidate: PullRequestService["Service"]["invalidate"] = (input) => { const reference = input.reference; if (reference !== undefined) { - return readCache.invalidate.pipe(Effect.andThen(Effect.sync(() => bumpRefEpoch(reference)))); + return canonicalRef(reference).pipe( + Effect.flatMap((ref) => + readCache + .invalidate(refScope(ref)) + .pipe(Effect.andThen(Effect.sync(() => bumpRefEpoch(ref)))), + ), + Effect.ignore, + ); } return Effect.sync(() => { listingsEpoch = ++epochCounter; @@ -2834,12 +2857,22 @@ export const make = Effect.gen(function* () { }).pipe(Effect.andThen(Cache.invalidateAll(viewerFlights))); }; - const refreshAfterTurn: PullRequestService["Service"]["refreshAfterTurn"] = Effect.suspend(() => { - turnRefreshEpoch = listingsEpoch = ++epochCounter; - return readCache.invalidate.pipe( - Effect.andThen(SubscriptionRef.set(pullRequestRefreshes, turnRefreshEpoch)), - ); - }); + const refreshAfterTurn: PullRequestService["Service"]["refreshAfterTurn"] = (projectId) => + Effect.suspend(() => { + listingsEpoch = ++epochCounter; + projectEpochs.delete(projectId); + if (projectEpochs.size >= REF_EPOCH_CAPACITY) { + const oldest = projectEpochs.keys().next().value; + if (oldest !== undefined) { + projectEpochFloor = projectEpochs.get(oldest)!; + projectEpochs.delete(oldest); + } + } + projectEpochs.set(projectId, listingsEpoch); + return readCache + .invalidate(`project:${projectId}`) + .pipe(Effect.andThen(SubscriptionRef.set(pullRequestRefreshes, listingsEpoch))); + }); // A mutation's own client re-reads right after it, and every other client's next read must // see the action too — so a write forgets the change request it touched and the listings its @@ -2849,22 +2882,28 @@ export const make = Effect.gen(function* () { method: (input: I) => Effect.Effect, ): ((input: I) => Effect.Effect) => (input) => - readCache.invalidate.pipe( - Effect.andThen(method(input)), - Effect.ensuring(readCache.invalidate), - Effect.tap(() => - Effect.sync(() => { - bumpRefEpoch(input); - listingsEpoch = ++epochCounter; - }), - ), - ); + Effect.gen(function* () { + const ref = yield* canonicalRef(input); + yield* readCache.invalidate(refScope(ref)).pipe( + Effect.andThen(method(input)), + Effect.ensuring(readCache.invalidate(refScope(ref))), + Effect.tap(() => + Effect.sync(() => { + bumpRefEpoch(ref); + listingsEpoch = ++epochCounter; + }), + ), + ); + }); const runActionAndInvalidate: PullRequestService["Service"]["runAction"] = Effect.fn( "PullRequestService.runActionAndInvalidate", )(function* (input) { - yield* readCache.invalidate; - const repository = yield* runAction(input).pipe(Effect.ensuring(readCache.invalidate)); - bumpRefEpoch({ ...input, repository }); + const ref = yield* canonicalRef(input); + yield* readCache.invalidate(refScope(ref)); + const repository = yield* runAction(input).pipe( + Effect.ensuring(readCache.invalidate(refScope(ref))), + ); + bumpRefEpoch({ ...ref, repository }); listingsEpoch = ++epochCounter; if (input.action === "merge") { // A successful merge action can merely enqueue the PR or enable auto-merge. @@ -2890,26 +2929,26 @@ export const make = Effect.gen(function* () { read: (input: I, ...args: Args) => Effect.Effect, ) => (input: I, ...args: Args) => - routingCredential.pipe( - Effect.flatMap((credential) => - read( - credential === null - ? input - : { - ...input, - [credentialNamespace]: credential.credentialFingerprint, - }, - ...args, - ), - ), - ); + Effect.gen(function* () { + const ref = yield* canonicalRef(input); + const credential = yield* routingCredential; + return yield* read( + credential === null + ? ref + : { ...ref, [credentialNamespace]: credential.credentialFingerprint }, + ...args, + ); + }); return PullRequestService.of({ routing, routingIdentity, withRoutingCredential, list, - listStats, + listStats: (input) => + Effect.forEach(input.refs, (ref) => canonicalRef(ref).pipe(Effect.option)).pipe( + Effect.flatMap((refs) => listStats({ ...input, refs: refs.flatMap(Option.toArray) })), + ), summary: credentialCached(summary), stack: credentialCached(stack), subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe( @@ -2922,7 +2961,7 @@ export const make = Effect.gen(function* () { detail: credentialCached(detail), activity: credentialCached(activity), threadComments, - diff, + diff: credentialCached(diff), diffFileContents, runAction: runActionAndInvalidate, update: invalidatedByMutation(update), diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index d3fe840fa64c..5893c21ff772 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -1,5 +1,8 @@ import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; import * as Cache from "effect/Cache"; +import * as TestClock from "effect/testing/TestClock"; +import * as Clock from "effect/Clock"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; @@ -10,6 +13,8 @@ import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubCli from "./GitHubCli.ts"; +import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; +import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; const encodeGitHubCliError = Schema.encodeEffect(Schema.fromJsonString(GitHubCli.GitHubCliError)); @@ -21,12 +26,18 @@ const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ stderrTruncated: false, }); +const quotaOutput = (remaining = 5000, resetAt = "2099-01-01T00:00:00Z") => + processOutput( + JSON.stringify({ data: { rateLimit: { cost: 1, limit: 5000, remaining, resetAt } } }), + ); + const mockRun = vi.fn(); const layer = GitHubCli.layer.pipe( Layer.provide( Layer.mock(VcsProcess.VcsProcess)({ - run: mockRun, + run: (input) => + input.args[1] === "rate_limit" ? Effect.succeed(quotaOutput()) : mockRun(input), }), ), ); @@ -35,7 +46,98 @@ afterEach(() => { mockRun.mockReset(); }); +it.effect("shares quota checks, preserves the reserve, and resumes after reset", () => + Effect.gen(function* () { + let probes = 0; + const commands: string[] = []; + let remaining = 501; + let resetAt = DateTime.formatIso( + DateTime.makeUnsafe((yield* Clock.currentTimeMillis) + 60_000), + ); + const gh = yield* GitHubCli.make.pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => + Effect.sync(() => { + if (input.args[1] === "rate_limit") { + probes++; + assert.strictEqual(input.args[3], "enterprise.test"); + return quotaOutput(remaining, resetAt); + } + commands.push(input.args.slice(0, 2).join(" ")); + return processOutput("[]"); + }), + }), + ); + const read = (command: string) => + gh.execute({ + cwd: "/repo", + args: + command === "repo" + ? ["repo", "view", "enterprise.test/acme/web", "--json", "name"] + : ["pr", command, "--repo=enterprise.test/acme/web", "--json", "number"], + }); + yield* read("list"); + const failure = yield* read("view").pipe(Effect.flip); + assert.strictEqual(failure._tag, "GitHubCliRateLimitError"); + assert.strictEqual(probes, 1); + assert.deepStrictEqual(commands, ["pr list"]); + yield* read("view").pipe(Effect.provideService(GitHubCli.AllowGitHubReserve, true)); + yield* gh.execute({ cwd: "/repo", args: ["pr", "merge", "1"] }); + assert.deepStrictEqual(commands, ["pr list", "pr view", "pr merge"]); + remaining = 0; + yield* TestClock.adjust("30 seconds"); + yield* read("repo").pipe(Effect.flip); + assert.strictEqual(probes, 2); + yield* TestClock.adjust("30 seconds"); + remaining = 5000; + resetAt = DateTime.formatIso(DateTime.makeUnsafe((yield* Clock.currentTimeMillis) + 60_000)); + yield* Effect.all([read("list"), read("repo")], { concurrency: 2 }); + assert.strictEqual(probes, 3); + assert.deepStrictEqual(commands.slice(3).toSorted(), ["pr list", "repo view"]); + }).pipe(Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer))), +); + describe("GitHubCli.layer", () => { + it.effect("shares the registry budget with CLI reads through nested layer providers", () => + Effect.gen(function* () { + const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; + const gh = yield* GitHubCli.GitHubCli; + yield* budget.observe("github.com", quotaOutput(0).stdout); + const error = yield* gh.execute({ cwd: "/repo", args: ["pr", "list"] }).pipe(Effect.flip); + assert.strictEqual(error._tag, "GitHubCliRateLimitError"); + expect(mockRun).not.toHaveBeenCalled(); + }).pipe(Effect.provide(layer.pipe(Layer.provide(GitHubGraphQlBudget.layer)))), + ); + + it.effect("keeps quota snapshots separate for verified credentials on the same host", () => + Effect.gen(function* () { + let reads = 0; + const gh = yield* GitHubCli.make.pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => + Effect.sync(() => { + if (input.args[1] === "rate_limit") + return quotaOutput(input.env?.GH_TOKEN === "empty" ? 0 : 5000); + reads++; + return processOutput("[]"); + }), + }), + ); + const read = (token: string) => + gh.execute({ cwd: "/repo", args: ["pr", "list", "--repo", "github.com/acme/web"] }).pipe( + Effect.provideService(GitHubCli.PinnedGitHubCredential, { + host: "github.com", + token: Redacted.make(token), + credentialFingerprint: token, + }), + ); + yield* read("empty").pipe(Effect.flip); + yield* read("healthy"); + yield* read("empty").pipe(Effect.flip); + assert.strictEqual(reads, 1); + }).pipe(Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer))), + ); + it.effect("pins concurrent cached commands to their own verified credentials", () => Effect.gen(function* () { mockRun.mockImplementation((input) => @@ -523,6 +625,15 @@ describe("GitHubCli.layer", () => { assert.include(error.detail, "gh api rate_limit"); assert.strictEqual(error.cause, cause); assert.notInclude(error.message, "user ID"); + const paused = yield* gh + .execute({ cwd: "/other-repo", args: ["pr", "list"] }) + .pipe(Effect.flip); + assert.strictEqual(paused._tag, "GitHubCliRateLimitError"); + expect(mockRun).toHaveBeenCalledTimes(1); + yield* TestClock.adjust("30 seconds"); + mockRun.mockReturnValueOnce(Effect.succeed(processOutput("[]"))); + yield* gh.execute({ cwd: "/other-repo", args: ["pr", "list"] }); + expect(mockRun).toHaveBeenCalledTimes(2); }).pipe(Effect.provide(layer)), ); }); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 30b0e4a09231..c525740efeae 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -1,3 +1,6 @@ +import * as Cache from "effect/Cache"; +import * as Duration from "effect/Duration"; +import * as Exit from "effect/Exit"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -15,6 +18,8 @@ import { } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; +import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; import { decodeGitHubPullRequestJson, decodeGitHubPullRequestListJson, @@ -30,7 +35,12 @@ export const PinnedGitHubCredential = Context.Reference<{ readonly credentialFingerprint: string; } | null>("t3/sourceControl/PinnedGitHubCredential", { defaultValue: () => null }); -function targetsVerifiedHost(args: ReadonlyArray, host: string): boolean { +export const AllowGitHubReserve = Context.Reference( + "t3/sourceControl/AllowGitHubReserve", + { defaultValue: () => false }, +); + +function commandHosts(args: ReadonlyArray): Array { const hosts: Array = []; const repositoryHost = (repository: string | undefined) => { if (repository === undefined) return null; @@ -54,6 +64,11 @@ function targetsVerifiedHost(args: ReadonlyArray, host: string): boolean else if (arg.startsWith("-R")) hosts.push(repositoryHost(arg.slice(2))); else if (/^https?:\/\//i.test(arg)) hosts.push(repositoryHost(arg)); } + return hosts; +} + +function targetsVerifiedHost(args: ReadonlyArray, host: string): boolean { + const hosts = commandHosts(args); return hosts.length > 0 && hosts.every((target) => target === host); } @@ -91,7 +106,7 @@ export class GitHubCliAuthenticationError extends Schema.TaggedError()( "GitHubCliRateLimitError", - gitHubCliFailureFields, + { ...gitHubCliFailureFields, retryAt: Schema.optionalKey(Schema.Finite) }, ) { get detail(): string { return "GitHub API rate limit exceeded. Run `gh api rate_limit` to inspect the quota and reset time."; @@ -274,17 +289,21 @@ export class GitHubCli extends Context.Service< readonly stdin?: string; readonly env?: NodeJS.ProcessEnv; readonly maxOutputBytes?: number; + readonly rateLimitHost?: string; + readonly allowReserve?: boolean; }) => Effect.Effect; readonly listOpenPullRequests: (input: { readonly cwd: string; readonly headSelector: string; readonly limit?: number; + readonly rateLimitHost?: string; }) => Effect.Effect, GitHubCliError>; readonly getPullRequest: (input: { readonly cwd: string; readonly reference: string; + readonly rateLimitHost?: string; }) => Effect.Effect; readonly getRepositoryCloneUrls: (input: { @@ -308,6 +327,7 @@ export class GitHubCli extends Context.Service< readonly getDefaultBranch: (input: { readonly cwd: string; + readonly rateLimitHost?: string; }) => Effect.Effect; readonly checkoutPullRequest: (input: { @@ -377,8 +397,10 @@ function deriveRepositoryCloneUrlsFromCreateOutput( /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; + const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; + const limits = yield* SourceControlRateLimit.SourceControlRateLimit; - const execute: GitHubCli["Service"]["execute"] = Effect.fn("GitHubCli.execute")( + const executeRaw: GitHubCli["Service"]["execute"] = Effect.fn("GitHubCli.executeRaw")( function* (input) { const credential = yield* PinnedGitHubCredential; if (credential !== null && !targetsVerifiedHost(input.args, credential.host)) { @@ -416,11 +438,94 @@ export const make = Effect.gen(function* () { }, ); + const quota = yield* Cache.makeWith( + (key: string) => { + const host = key.split("\0")[0]!; + return executeRaw({ + cwd: globalThis.process.cwd(), + args: [ + "api", + "rate_limit", + "--hostname", + host, + "--jq", + ".resources.graphql | {data:{rateLimit:{cost:1,limit:.limit,remaining:.remaining,resetAt:(.reset|todateiso8601)}}}", + ], + }).pipe( + Effect.tap((result) => budget.observe(host, result.stdout)), + Effect.asVoid, + ); + }, + { + capacity: 32, + timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.seconds(30) : Duration.zero), + }, + ); + const execute: GitHubCli["Service"]["execute"] = Effect.fn("GitHubCli.execute")( + function* (input) { + const [command, action] = input.args; + if ( + !( + (command === "pr" && (action === "list" || action === "view")) || + (command === "repo" && action === "view") + ) + ) + return yield* executeRaw(input); + const credential = yield* PinnedGitHubCredential; + if (credential !== null && !targetsVerifiedHost(input.args, credential.host)) + return yield* executeRaw(input); + const allowReserve = input.allowReserve ?? (yield* AllowGitHubReserve); + const host = ( + credential?.host ?? + commandHosts(input.args).find((host) => host !== null) ?? + input.rateLimitHost ?? + input.env?.GH_HOST ?? + globalThis.process.env.GH_HOST ?? + "github.com" + ).toLowerCase(); + const key = { provider: "github" as const, host }; + const guarded = Effect.gen(function* () { + const lease = yield* limits.check(key, allowReserve ? { allowPaused: true } : undefined); + return yield* Effect.gen(function* () { + yield* Cache.get(quota, `${host}\0${credential?.credentialFingerprint ?? ""}`); + yield* budget.query(host, "query {}", allowReserve ? { allowReserve: true } : undefined); + return yield* executeRaw(input); + }).pipe( + Effect.tap(() => limits.recordSuccess({ ...key, lease })), + Effect.tapError((error) => + error._tag === "GitHubCliRateLimitError" + ? limits.recordRateLimit({ ...key, lease }) + : Effect.void, + ), + ); + }); + return yield* guarded.pipe( + Effect.provideService( + SourceControlRateLimit.CredentialScope, + credential?.credentialFingerprint ?? (yield* SourceControlRateLimit.CredentialScope), + ), + Effect.catchTags({ + SourceControlRateLimitPausedError: (cause) => + Effect.fail( + new GitHubCliRateLimitError({ + command: "gh", + cwd: input.cwd, + retryAt: cause.retryAt, + cause, + }), + ), + }), + ); + }, + ); + return GitHubCli.of({ execute, listOpenPullRequests: (input) => execute({ cwd: input.cwd, + ...(input.rateLimitHost === undefined ? {} : { rateLimitHost: input.rateLimitHost }), + allowReserve: true, args: [ "pr", "list", @@ -458,6 +563,8 @@ export const make = Effect.gen(function* () { getPullRequest: (input) => execute({ cwd: input.cwd, + ...(input.rateLimitHost === undefined ? {} : { rateLimitHost: input.rateLimitHost }), + allowReserve: true, args: [ "pr", "view", @@ -533,6 +640,7 @@ export const make = Effect.gen(function* () { getDefaultBranch: (input) => execute({ cwd: input.cwd, + ...(input.rateLimitHost === undefined ? {} : { rateLimitHost: input.rateLimitHost }), args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], }).pipe( Effect.map((value) => { @@ -548,4 +656,7 @@ export const make = Effect.gen(function* () { }); }); -export const layer = Layer.effect(GitHubCli, make); +export const layer = Layer.effect(GitHubCli, make).pipe( + Layer.provideMerge(GitHubGraphQlBudget.layer), + Layer.provideMerge(SourceControlRateLimit.layer), +); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 4c41b323f17b..0d46b6eab9ea 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -30,6 +30,33 @@ function makeProvider(github: Partial) { ); } +it.effect("uses the enterprise quota for a current-repository default branch read", () => + Effect.gen(function* () { + const provider = yield* GitHubSourceControlProvider.make.pipe( + Effect.provide(GitHubCli.layer), + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => + Effect.sync(() => { + if (input.args[1] !== "rate_limit") return processResult("main"); + assert.strictEqual(input.args[3], "enterprise.test"); + return processResult( + '{"data":{"rateLimit":{"cost":1,"limit":5000,"remaining":5000,"resetAt":"2099-01-01T00:00:00Z"}}}', + ); + }), + }), + ); + const branch = yield* provider.getDefaultBranch({ + cwd: "/enterprise-repo", + context: { + provider: { kind: "github", name: "GitHub Enterprise", baseUrl: "https://enterprise.test" }, + remoteName: "origin", + remoteUrl: "https://enterprise.test/acme/web.git", + }, + }); + assert.strictEqual(branch, "main"); + }), +); + it.effect("maps GitHub PR summaries into provider-neutral change requests", () => Effect.gen(function* () { const provider = yield* makeProvider({ diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index bb8662928688..372d2a032d79 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -127,6 +127,9 @@ export const make = Effect.gen(function* () { .listOpenPullRequests({ cwd: input.cwd, headSelector: input.headSelector, + ...(input.context === undefined + ? {} + : { rateLimitHost: new URL(input.context.provider.baseUrl).host }), ...(input.limit !== undefined ? { limit: input.limit } : {}), }) .pipe( @@ -152,6 +155,9 @@ export const make = Effect.gen(function* () { return github .execute({ cwd: input.cwd, + ...(input.context === undefined + ? {} + : { rateLimitHost: new URL(input.context.provider.baseUrl).host }), args: [ "pr", "list", @@ -267,23 +273,30 @@ export const make = Effect.gen(function* () { }, listChangeRequests, getChangeRequest: (input) => - github.getPullRequest(input).pipe( - Effect.map(toChangeRequest), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "getChangeRequest", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.reference, - ), - detail: error.detail, - cause: error, - }), + github + .getPullRequest({ + ...input, + ...(input.context === undefined + ? {} + : { rateLimitHost: new URL(input.context.provider.baseUrl).host }), + }) + .pipe( + Effect.map(toChangeRequest), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "getChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), ), - ), createChangeRequest: (input) => github .createPullRequest({ @@ -344,19 +357,26 @@ export const make = Effect.gen(function* () { ), ), getDefaultBranch: (input) => - github.getDefaultBranch(input).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "getDefaultBranch", - command: error.command, - cwd: input.cwd, - detail: error.detail, - cause: error, - }), + github + .getDefaultBranch({ + ...input, + ...(input.context === undefined + ? {} + : { rateLimitHost: new URL(input.context.provider.baseUrl).host }), + }) + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "getDefaultBranch", + command: error.command, + cwd: input.cwd, + detail: error.detail, + cause: error, + }), + ), ), - ), checkoutChangeRequest: (input) => github.checkoutPullRequest(input).pipe( Effect.mapError( diff --git a/apps/server/src/sourceControl/SourceControlRateLimit.test.ts b/apps/server/src/sourceControl/SourceControlRateLimit.test.ts index 5ee233a367dd..e46005c73f0e 100644 --- a/apps/server/src/sourceControl/SourceControlRateLimit.test.ts +++ b/apps/server/src/sourceControl/SourceControlRateLimit.test.ts @@ -6,6 +6,22 @@ import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; const github = { provider: "github" as const, host: "github.com" }; +it.effect("isolates cooldowns for verified credentials on the same host", () => + Effect.gen(function* () { + const limits = yield* SourceControlRateLimit.SourceControlRateLimit; + yield* limits + .recordRateLimit({ ...github, lease: 0 }) + .pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, "first")); + yield* limits + .check(github) + .pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, "second")); + const error = yield* limits + .check(github) + .pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, "first"), Effect.flip); + assert.strictEqual(error._tag, "SourceControlRateLimitPausedError"); + }).pipe(Effect.provide(SourceControlRateLimit.layer)), +); + it("parses Retry-After seconds and HTTP dates", () => { assert.equal(SourceControlRateLimit.retryAtFromHeader("120", 1_000), 121_000); assert.equal( diff --git a/apps/server/src/sourceControl/SourceControlRateLimit.ts b/apps/server/src/sourceControl/SourceControlRateLimit.ts index b936c456079b..dc6242a60eb6 100644 --- a/apps/server/src/sourceControl/SourceControlRateLimit.ts +++ b/apps/server/src/sourceControl/SourceControlRateLimit.ts @@ -13,6 +13,10 @@ import { const FALLBACK_COOLDOWN = Duration.seconds(30); const MAX_FALLBACK_COOLDOWN = Duration.minutes(15); +export const CredentialScope = Context.Reference("t3/sourceControl/CredentialScope", { + defaultValue: () => "", +}); + interface RateLimitKey { readonly provider: SourceControlProviderKind; readonly host: string; @@ -59,8 +63,8 @@ export class SourceControlRateLimit extends Context.Service< } >()("t3/sourceControl/SourceControlRateLimit") {} -function normalizedKey(key: RateLimitKey): string { - return `${key.provider}\0${key.host.trim().toLowerCase()}`; +function normalizedKey(key: RateLimitKey, scope: string): string { + return `${key.provider}\0${key.host.trim().toLowerCase()}\0${scope}`; } function fallbackCooldownMs(attempt: number): number { @@ -90,7 +94,8 @@ export const make = Effect.gen(function* () { "SourceControlRateLimit.check", )(function* (input, options) { const now = yield* Clock.currentTimeMillis; - const entry = (yield* Ref.get(entries)).get(normalizedKey(input)); + const key = normalizedKey(input, yield* CredentialScope); + const entry = (yield* Ref.get(entries)).get(key); if (entry !== undefined && entry.retryAt > now && options?.allowPaused !== true) { return yield* new SourceControlRateLimitPausedError({ provider: input.provider, @@ -105,8 +110,8 @@ export const make = Effect.gen(function* () { "SourceControlRateLimit.recordRateLimit", )(function* (input) { const now = yield* Clock.currentTimeMillis; + const key = normalizedKey(input, yield* CredentialScope); yield* Ref.update(entries, (current) => { - const key = normalizedKey(input); const previous = current.get(key); if (previous !== undefined && previous.generation > input.lease) { if (previous.retryAt <= now && (input.retryAt === undefined || input.retryAt <= now)) { @@ -145,8 +150,8 @@ export const make = Effect.gen(function* () { "SourceControlRateLimit.recordSuccess", )(function* (input) { const now = yield* Clock.currentTimeMillis; + const key = normalizedKey(input, yield* CredentialScope); yield* Ref.update(entries, (current) => { - const key = normalizedKey(input); const previous = current.get(key); if (previous === undefined || previous.generation !== input.lease || previous.retryAt > now) { return current; diff --git a/apps/server/src/sourceControl/githubGraphQlBudget.test.ts b/apps/server/src/sourceControl/githubGraphQlBudget.test.ts index a5b0680fafc5..26f9747f3202 100644 --- a/apps/server/src/sourceControl/githubGraphQlBudget.test.ts +++ b/apps/server/src/sourceControl/githubGraphQlBudget.test.ts @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; import * as TestClock from "effect/testing/TestClock"; import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; +import { CredentialScope } from "./SourceControlRateLimit.ts"; const RESET_AT = "2026-08-13T14:00:00.000Z"; const NEXT_RESET_AT = "2026-08-13T15:00:00.000Z"; @@ -71,6 +72,23 @@ describe("GitHub GraphQL budget", () => { }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), ); + it.effect("isolates query reservations and observations by credential", () => + Effect.gen(function* () { + yield* TestClock.setTime(BEFORE_RESET); + const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; + const query = budget.query("github.com", "query { viewer { login } }"); + yield* budget + .observe("github.com", rateLimit(0)) + .pipe(Effect.provideService(CredentialScope, "first")); + yield* budget + .observe("github.com", rateLimit(5000)) + .pipe(Effect.provideService(CredentialScope, "second")); + yield* query.pipe(Effect.provideService(CredentialScope, "second")); + const error = yield* query.pipe(Effect.provideService(CredentialScope, "first"), Effect.flip); + expect(error.retryAt).toBe(Date.parse(RESET_AT)); + }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), + ); + it.effect("keeps the lower remaining value from out-of-order responses", () => Effect.gen(function* () { yield* TestClock.setTime(BEFORE_RESET); @@ -181,6 +199,19 @@ describe("GitHub GraphQL budget", () => { }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), ); + it.effect("stops interactive reads when the reserve is exhausted", () => + Effect.gen(function* () { + yield* TestClock.setTime(BEFORE_RESET); + const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; + yield* budget.observe("github.com", rateLimit(1, 5000, RESET_AT, 1)); + yield* budget.query("github.com", "query { viewer { login } }", { allowReserve: true }); + const error = yield* budget + .query("github.com", "query { viewer { login } }", { allowReserve: true }) + .pipe(Effect.flip); + expect(error.retryAt).toBe(Date.parse(RESET_AT)); + }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), + ); + it.effect("ignores malformed or partial rate metadata", () => Effect.gen(function* () { yield* TestClock.setTime(BEFORE_RESET); diff --git a/apps/server/src/sourceControl/githubGraphQlBudget.ts b/apps/server/src/sourceControl/githubGraphQlBudget.ts index 05e4b3a5ce24..1021f6cde9fa 100644 --- a/apps/server/src/sourceControl/githubGraphQlBudget.ts +++ b/apps/server/src/sourceControl/githubGraphQlBudget.ts @@ -85,8 +85,8 @@ export const make = Effect.gen(function* () { function* (host, document, options) { if (!isReadOperation(document)) return document; const now = yield* Clock.currentTimeMillis; + const key = `${hostKey(host)}\0${yield* SourceControlRateLimit.CredentialScope}`; const retryAt = yield* Ref.modify(snapshots, (current) => { - const key = hostKey(host); const snapshot = current.get(key); if (snapshot === undefined) return [null, current] as const; if (snapshot.resetAtMs <= now) { @@ -94,8 +94,11 @@ export const make = Effect.gen(function* () { next.delete(key); return [null, next] as const; } - const remaining = Math.max(0, snapshot.remaining - Math.max(1, snapshot.cost)); - if (options?.allowReserve !== true && remaining < snapshot.limit * GRAPHQL_RESERVE_RATIO) { + const remaining = snapshot.remaining - Math.max(1, snapshot.cost); + if ( + remaining < 0 || + (options?.allowReserve !== true && remaining < snapshot.limit * GRAPHQL_RESERVE_RATIO) + ) { return [snapshot.resetAtMs, current] as const; } const next = new Map(current); @@ -118,8 +121,8 @@ export const make = Effect.gen(function* () { )(function* (host, raw) { const snapshot = snapshotFrom(raw); if (snapshot === null) return; + const key = `${hostKey(host)}\0${yield* SourceControlRateLimit.CredentialScope}`; yield* Ref.update(snapshots, (current) => { - const key = hostKey(host); const previous = current.get(key); // Concurrent reads can finish out of order. Quota only falls within one reset window, and // an answer from an older window must not replace the current one. diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 5c7e2e1360af..9373d8aabdb8 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -65,6 +65,243 @@ runVcsDriverContractSuite({ }, }); +const makeCheckpointFixture = Effect.fn("makeCheckpointFixture")(function* ( + driver: Effect.Success>, + cwd: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const git = (args: ReadonlyArray) => + driver.execute({ operation: "checkpoint-test", cwd, args }); + yield* git(["init"]); + yield* git(["config", "user.name", "Test"]); + yield* git(["config", "user.email", "test@test.com"]); + yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "initial\n"); + yield* git(["add", "."]); + yield* git(["commit", "-m", "initial"]); + const checkpointRef = CheckpointRef.make("refs/t3/checkpoints/test"); + yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "staged\n"); + yield* git(["add", "."]); + yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "unstaged\n"); + return { git, checkpointRef }; +}); + +it.effect("checkpoint capture does not rerun clean filters for unchanged indexed files", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-cache-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + yield* fileSystem.writeFileString( + path.join(cwd, ".gitattributes"), + "stable.txt filter=probe\n", + ); + yield* fileSystem.writeFileString(path.join(cwd, "stable.txt"), "unchanged\n"); + yield* fileSystem.writeFileString( + path.join(cwd, ".git", "filter.cjs"), + 'require("node:fs").appendFileSync(".git/filter-runs", "read\\n"); process.stdin.pipe(process.stdout);', + ); + yield* git(["config", "filter.probe.clean", "node .git/filter.cjs"]); + yield* fileSystem.utimes(path.join(cwd, "stable.txt"), 1_700_000_000, 1_700_000_000); + yield* git(["add", "."]); + yield* git(["commit", "-m", "record stable file"]); + yield* fileSystem.writeFileString(path.join(cwd, ".git", "filter-runs"), ""); + yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "changed\n"); + const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index")); + + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + + assert.strictEqual(yield* fileSystem.readFileString(path.join(cwd, ".git", "filter-runs")), ""); + assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "changed\n"); + assert.strictEqual((yield* git(["show", `${checkpointRef}:stable.txt`])).stdout, "unchanged\n"); + assert.deepEqual(yield* fileSystem.readFile(path.join(cwd, ".git", "index")), originalIndex); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), +); + +for (const timestamp of [1_700_000_000, 1_700_000_000.9999]) { + it.effect( + `checkpoint capture preserves same-size edits with racy index timestamps (${timestamp})`, + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-racy-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + const filePath = path.join(cwd, "file.txt"); + const indexPath = path.join(cwd, ".git", "index"); + yield* git(["config", "core.trustctime", "false"]); + yield* fileSystem.writeFileString(filePath, "before\n"); + yield* fileSystem.utimes(filePath, timestamp, timestamp); + yield* git(["add", "file.txt"]); + yield* git(["commit", "-m", "record racy file"]); + yield* fileSystem.utimes(indexPath, timestamp, timestamp); + const originalIndex = yield* fileSystem.readFile(indexPath); + const originalIndexMtime = (yield* fileSystem.stat(indexPath)).mtime; + yield* fileSystem.writeFileString(filePath, "after!\n"); + yield* fileSystem.utimes(filePath, timestamp, timestamp); + + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + + assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "after!\n"); + assert.deepEqual(yield* fileSystem.readFile(indexPath), originalIndex); + assert.deepEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), + ); +} + +it.effect("checkpoint capture preserves racy edits made after resetting the index", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const liveProcess = yield* VcsProcess.VcsProcess; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-racy-reset-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + const racyPath = path.join(cwd, "racy.txt"); + const indexPath = path.join(cwd, ".git", "index"); + const timestamp = 1_700_000_000; + yield* git(["config", "core.trustctime", "false"]); + yield* fileSystem.writeFileString(racyPath, "before\n"); + yield* fileSystem.utimes(racyPath, timestamp, timestamp); + yield* git(["add", "."]); + yield* git(["commit", "-m", "record racy file"]); + yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "staged\n"); + yield* git(["add", "file.txt"]); + yield* fileSystem.utimes(indexPath, timestamp, timestamp); + const originalIndex = yield* fileSystem.readFile(indexPath); + const originalIndexMtime = (yield* fileSystem.stat(indexPath)).mtime; + const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: Effect.fn(function* (input: VcsProcess.VcsProcessInput) { + const result = yield* liveProcess.run(input); + if (input.args.includes("read-tree") && input.args.includes("--reset")) { + yield* fileSystem.writeFileString(racyPath, "after!\n").pipe(Effect.orDie); + yield* fileSystem.utimes(racyPath, timestamp, timestamp).pipe(Effect.orDie); + } + return result; + }), + }), + ); + + yield* captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + + assert.strictEqual((yield* git(["show", `${checkpointRef}:racy.txt`])).stdout, "after!\n"); + assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "staged\n"); + assert.deepEqual(yield* fileSystem.readFile(indexPath), originalIndex); + assert.deepEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), +); + +for (const nested of [false, true]) { + for (const indexMode of ["normal", "flags", "split"] as const) { + it.effect( + `checkpoint index reuse preserves two turns (nested=${nested}, index=${indexMode})`, + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-turns-" }); + const { git } = yield* makeCheckpointFixture(driver, cwd); + const write = (name: string, contents: string) => + fileSystem.writeFileString(path.join(cwd, name), contents); + yield* fileSystem.makeDirectory(path.join(cwd, "scope")); + for (const name of [ + "scope/staged", + "scope/deleted", + "scope/assumed", + "scope/skipped", + "outside", + ]) { + yield* write(name, "original\n"); + } + yield* git(["add", "."]); + yield* git(["commit", "-m", "initial scoped files"]); + yield* write("scope/staged", "staged\n"); + yield* write("scope/new-deleted", "staged then deleted\n"); + yield* write("outside", "staged outside\n"); + yield* git(["add", "."]); + if (indexMode === "flags") { + yield* git(["update-index", "--assume-unchanged", "scope/assumed"]); + yield* git(["update-index", "--skip-worktree", "scope/skipped"]); + } + if (indexMode === "split") { + yield* git(["update-index", "--split-index"]); + } + const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index")); + for (const name of ["scope/staged", "scope/assumed", "scope/skipped", "outside"]) { + yield* write(name, "working\n"); + } + yield* write("scope/new", "first\n"); + yield* fileSystem.remove(path.join(cwd, "scope/deleted")); + yield* fileSystem.remove(path.join(cwd, "scope/new-deleted")); + const captureCwd = nested ? path.join(cwd, "scope") : cwd; + const first = CheckpointRef.make("refs/t3/checkpoints/turns/1"); + const second = CheckpointRef.make("refs/t3/checkpoints/turns/2"); + yield* driver.checkpoints.captureCheckpoint({ cwd: captureCwd, checkpointRef: first }); + for (const name of ["scope/staged", "scope/assumed", "scope/skipped"]) { + assert.strictEqual((yield* git(["show", `${first}:${name}`])).stdout, "working\n"); + } + assert.strictEqual( + (yield* git(["show", `${first}:outside`])).stdout, + nested ? "original\n" : "working\n", + ); + const files = (yield* git(["ls-tree", "-r", "--name-only", first])).stdout.split("\n"); + assert.notInclude(files, "scope/deleted"); + assert.notInclude(files, "scope/new-deleted"); + assert.include(files, "scope/new"); + + yield* write("scope/staged", "second\n"); + yield* fileSystem.remove(path.join(cwd, "scope/new")); + yield* write("scope/second", "added in second turn\n"); + yield* driver.checkpoints.captureCheckpoint({ cwd: captureCwd, checkpointRef: second }); + assert.strictEqual( + (yield* git(["diff", "--name-only", first, second])).stdout, + "scope/new\nscope/second\nscope/staged\n", + ); + assert.strictEqual((yield* git(["show", `${second}:scope/staged`])).stdout, "second\n"); + assert.strictEqual( + (yield* git(["show", `${second}:scope/second`])).stdout, + "added in second turn\n", + ); + assert.deepEqual( + yield* fileSystem.readFile(path.join(cwd, ".git", "index")), + originalIndex, + ); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), + ); + } +} + +for (const indexState of ["missing", "invalid"] as const) { + it.effect(`checkpoint capture falls back when the user index is ${indexState}`, () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-index-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + const indexPath = path.join(cwd, ".git", "index"); + if (indexState === "missing") { + yield* fileSystem.remove(indexPath); + } else { + yield* fileSystem.writeFileString(indexPath, "invalid index"); + } + + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + + assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "unstaged\n"); + if (indexState === "missing") { + assert.isFalse(yield* fileSystem.exists(indexPath)); + } else { + assert.strictEqual(yield* fileSystem.readFileString(indexPath), "invalid index"); + } + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), + ); +} + it.effect("restores empty checkpoints without changing paths outside the workspace", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 4a064a395700..84dd763150fa 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -773,12 +773,45 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( yield* Effect.gen(function* () { const headExists = yield* hasHeadCommit(input.cwd); if (headExists) { - yield* execute({ - operation, - cwd: input.cwd, - args: ["read-tree", "HEAD"], - env: commitEnv, - }); + const reusedIndex = yield* Effect.gen(function* () { + const indexPath = yield* execute({ + operation, + cwd: input.cwd, + args: ["rev-parse", "--path-format=absolute", "--git-path", "index"], + }); + const { mtime } = yield* fileSystem.stat(indexPath.stdout.trim()); + if (Option.isNone(mtime)) return false; + // Stay below the source timestamp even if Date rounded up, preserving Git's racy check. + const indexTime = Math.floor((mtime.value.getTime() - 1) / 1000); + if (indexTime <= 0) return false; + yield* fileSystem.copyFile(indexPath.stdout.trim(), tempIndexPath); + // Retain stat data only where the copied index already matches HEAD. + yield* execute({ + operation, + cwd: input.cwd, + args: ["-c", "core.fsmonitor=false", "read-tree", "--reset", "HEAD"], + env: commitEnv, + }); + // read-tree can rewrite the index, so restore its racy timestamp afterward. + yield* fileSystem.utimes(tempIndexPath, indexTime, indexTime); + const entries = yield* execute({ + operation, + cwd: input.cwd, + args: ["ls-files", "-v"], + env: commitEnv, + maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, + }); + // A fresh index must still capture assume-unchanged/skip-worktree files. + return !entries.stdoutTruncated && !/^[a-zS] /m.test(entries.stdout); + }).pipe(Effect.orElseSucceed(() => false)); + if (!reusedIndex) { + yield* execute({ + operation, + cwd: input.cwd, + args: ["read-tree", "HEAD"], + env: commitEnv, + }); + } } yield* execute({ diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d0d05146c489..38f773cf037e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6781,6 +6781,17 @@ export default function ChatView(props: ChatViewProps) { return; } + if (command === "thread.steerQueuedMessage") { + const message = activeThreadKey + ? useQueuedMessageStore.getState().queuesByThreadKey[activeThreadKey]?.[0] + : undefined; + if (!message) return; + event.preventDefault(); + event.stopPropagation(); + if (!event.repeat) queuedMessageActionsRef.current.steer(message.id); + return; + } + if (command === "thread.stop") { // An unavailable command should not shadow contextual shortcuts such as Escape to close a dialog. if (!canInterruptRunningThread) return; @@ -6810,6 +6821,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadPinned, activeThreadSettled, canInterruptRunningThread, + activeThreadKey, terminalUiState.terminalOpen, terminalUiState.activeTerminalId, activeThreadId, @@ -7497,11 +7509,13 @@ export default function ChatView(props: ChatViewProps) { ); return; } - // A send during a running turn waits in the queue. It leaves on the next - // tool boundary, when the turn ends, or when the user clicks Steer. The - // provider treats a mid-turn send as a steer of the active turn, so the - // dispatch below is the same either way. - if (!queuedMessage && !directAnnotation && phase === "running" && activeThreadKey) { + if ( + !queuedMessage && + !directAnnotation && + phase === "running" && + activeThreadKey && + settings.followUpBehavior === "queue" + ) { if (composerRef.current?.validateProviderInput(promptForSend) === false) { return; } @@ -9501,6 +9515,11 @@ export default function ChatView(props: ChatViewProps) { loadEarlier={paintOnlyDisplayedTimeline ? null : loadEarlierTurns} queuedMessages={paintOnlyDisplayedTimeline ? EMPTY_QUEUED_MESSAGES : queuedMessages} onSteerQueuedMessage={onSteerQueuedMessage} + steerQueuedMessageShortcutLabel={shortcutLabelForCommand( + keybindings, + "thread.steerQueuedMessage", + { context: { terminalFocus: false } }, + )} onRemoveQueuedMessage={onRemoveQueuedMessage} /> diff --git a/apps/web/src/components/ConnectionStatusDot.tsx b/apps/web/src/components/ConnectionStatusDot.tsx index 6a23a0532873..382e6d25b86e 100644 --- a/apps/web/src/components/ConnectionStatusDot.tsx +++ b/apps/web/src/components/ConnectionStatusDot.tsx @@ -11,6 +11,7 @@ export function connectionPhaseDotClassName(phase: EnvironmentConnectionPhase): case "connecting": case "reconnecting": return "bg-warning"; + case "unsupported": case "error": return "bg-destructive"; default: diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index edf9f23e23a7..cf88d4965f09 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -43,10 +43,10 @@ export function ProjectFavicon(input: { faviconPath: project.faviconPath, }), ); - if (project.projectIcon?.kind === "lucide" && project.projectIcon.monogram) { + if (project.projectIcon?.kind === "monogram") { return ( diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.environments.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.environments.ts index 2fb9c16b654a..3c4a443c44a2 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.environments.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.environments.ts @@ -29,6 +29,7 @@ function normalizeConnectionState(phase: string | undefined): EnvironmentUpdateC case "connecting": case "reconnecting": return "connecting"; + case "unsupported": case "error": return "error"; case "offline": diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 61381ddb71f7..307a893342f3 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -285,6 +285,7 @@ interface TimelineRowSharedState { onWorktreeSetupWorkLocally: (() => void) | null; onOpenWorktreeSetupTerminal: ((terminalId: string) => void) | null; onSteerQueuedMessage: (id: string) => void; + steerQueuedMessageShortcutLabel: string | null; onRemoveQueuedMessage: (id: string) => void; } @@ -441,6 +442,7 @@ interface MessagesTimelineProps { /** Messages sent during the running turn. They render as ghost bubbles after the live rows. */ queuedMessages?: ReadonlyArray; onSteerQueuedMessage?: (id: string) => void; + steerQueuedMessageShortcutLabel?: string | null; onRemoveQueuedMessage?: (id: string) => void; } @@ -496,6 +498,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ loadEarlier = null, queuedMessages = EMPTY_QUEUED_MESSAGES, onSteerQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION, + steerQueuedMessageShortcutLabel = null, onRemoveQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION, }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); @@ -940,6 +943,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onWorktreeSetupWorkLocally: onWorktreeSetupWorkLocally ?? null, onOpenWorktreeSetupTerminal: onOpenWorktreeSetupTerminal ?? null, onSteerQueuedMessage, + steerQueuedMessageShortcutLabel, onRemoveQueuedMessage, }), [ @@ -972,6 +976,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onWorktreeSetupWorkLocally, onOpenWorktreeSetupTerminal, onSteerQueuedMessage, + steerQueuedMessageShortcutLabel, onRemoveQueuedMessage, ], ); @@ -1564,7 +1569,12 @@ function QueuedMessageTimelineRow({ > - Send now + + Send now + {row.isNext && ctx.steerQueuedMessageShortcutLabel + ? ` (${ctx.steerQueuedMessageShortcutLabel})` + : null} + { expect(renderer!.root.findByType("button").children).toEqual(["Add"]); }); + it("keeps incompatible discoveries unselected until the user enables a compatible server", async () => { + const base = linkedMachines.get(newMachineId)!; + const entry = (protocolVersion: number) => ({ + ...base, + status: Option.some({ + environmentId: newMachineId, + endpoint: base.environment.endpoint, + status: "online" as const, + checkedAt: "2026-09-15T00:00:00Z", + descriptor: { + environmentId: newMachineId, + label: base.environment.label, + platform: { os: "linux" as const, arch: "x64" as const }, + serverVersion: "1.0.0", + orchestrationProtocolVersion: protocolVersion, + capabilities: { repositoryIdentity: true }, + }, + }), + }); + discovery.listEnvironments.mockResolvedValue( + new Map([[newMachineId, entry(ORCHESTRATION_PROTOCOL_VERSION + 1)]]), + ); + const onSelectionChange = vi.fn(); + const autoSelectedComputers = new Set(); + function Setup() { + const [selectedIds, setSelectedIds] = useState>( + new Set([newMachineId]), + ); + return ( + { + onSelectionChange(id, checked); + setSelectedIds((current) => { + const next = new Set(current); + if (checked) next.add(id); + else next.delete(id); + return next; + }); + }, + }} + /> + ); + } + await act(async () => { + renderer = create(); + }); + expect(discovery.register).not.toHaveBeenCalled(); + expect(onSelectionChange).toHaveBeenCalledWith(newMachineId, false); + expect(renderer!.root.findByType("input").props.checked).toBe(false); + expect(renderer!.root.findByType("input").props.disabled).toBe(true); + expect(renderer!.root.findAllByType("span").flatMap((span) => span.children)).toContain( + "Client not supported", + ); + await act(async () => { + await renderer!.root.findByType("input").props.onChange({ target: { checked: true } }); + }); + expect(discovery.register).not.toHaveBeenCalled(); + await act(async () => + publish({ + ...discovery.state!, + environments: new Map([[newMachineId, entry(ORCHESTRATION_PROTOCOL_VERSION)]]), + }), + ); + expect(discovery.register).not.toHaveBeenCalled(); + expect(renderer!.root.findByType("input").props.checked).toBe(false); + expect(renderer!.root.findByType("input").props.disabled).toBe(false); + await act(async () => { + await renderer!.root.findByType("input").props.onChange({ target: { checked: true } }); + }); + expect(discovery.register).toHaveBeenCalledTimes(1); + }); + it("connects and selects discovered computers by default without overwriting deselection", async () => { discovery.listEnvironments.mockResolvedValue(linkedMachines); const autoSelectedComputers = new Set(); diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx index 776306f96ce6..1fe874d249e5 100644 --- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx @@ -3,13 +3,17 @@ import { type EnvironmentConnectionPresentation, RelayConnectionRegistration, RelayConnectionTarget, + orchestrationProtocolCompatibilityError, } from "@t3tools/client-runtime/connection"; import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId } from "@t3tools/contracts"; -import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; +import type { + RelayClientEnvironmentRecord, + RelayEnvironmentStatusResponse, +} from "@t3tools/contracts/relay"; import * as Option from "effect/Option"; import { type ReactNode, useCallback, useEffect, useEffectEvent, useState } from "react"; @@ -29,6 +33,13 @@ import { presentSavedCloudEnvironmentConnection } from "./cloudEnvironmentConnec const EMPTY_DISCOVERY_REFRESH_INTERVAL_MS = 5_000; +function discoveredCompatibilityError( + status: Option.Option | undefined, +) { + const descriptor = status === undefined ? undefined : Option.getOrNull(status)?.descriptor; + return descriptor === undefined ? null : orchestrationProtocolCompatibilityError(descriptor); +} + export interface SavedCloudEnvironmentConnection { readonly environmentId: EnvironmentId; readonly connection: EnvironmentConnectionPresentation; @@ -119,6 +130,12 @@ export function CloudEnvironmentConnectRows({ }, [refreshRelayEnvironments, refreshWhileEmpty, onDiscoveryReady]); const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => { + if ( + discoveredCompatibilityError( + environmentsState.environments.get(environment.environmentId)?.status, + ) !== null + ) + return false; setConnectingEnvironmentIds((current) => new Set([...current, environment.environmentId])); const result = await connectRelayEnvironment(environment); setConnectingEnvironmentIds((current) => { @@ -166,8 +183,17 @@ export function CloudEnvironmentConnectRows({ const selectNewComputers = useEffectEvent(() => { const seen = selection?.autoSelectedComputers; if (!selection || !seen) return; - for (const { environment } of visibleEnvironments) { + for (const { environment, status, availability } of visibleEnvironments) { const id = environment.environmentId; + if (availability === "checking") continue; + if ( + discoveredCompatibilityError(status) !== null || + savedById.get(id)?.connection.phase === "unsupported" + ) { + seen.add(id); + if (selection.selectedIds.has(id)) selection.onChange(id, false); + continue; + } if (seen.has(id)) continue; seen.add(id); selection.onChange(id, true); @@ -265,11 +291,20 @@ export function CloudEnvironmentConnectRows({ return empty; } - return visibleEnvironments.map(({ environment, availability, error }) => { + return visibleEnvironments.map(({ environment, availability, error, status }) => { const savedEnvironment = savedById.get(environment.environmentId); - const savedConnection = savedEnvironment - ? presentSavedCloudEnvironmentConnection(savedEnvironment.connection) - : null; + const compatibilityError = discoveredCompatibilityError(status); + const unsupported = + compatibilityError !== null || savedEnvironment?.connection.phase === "unsupported"; + const savedConnection = unsupported + ? presentSavedCloudEnvironmentConnection({ + phase: "unsupported", + error: compatibilityError?.message ?? savedEnvironment?.connection.error ?? null, + traceId: null, + }) + : savedEnvironment + ? presentSavedCloudEnvironmentConnection(savedEnvironment.connection) + : null; const dotClassName = savedConnection ? savedConnection.tone === "connected" ? "bg-success" @@ -302,9 +337,10 @@ export function CloudEnvironmentConnectRows({ className="flex cursor-pointer items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 has-disabled:cursor-default" > { + if (unsupported) return; selection.onChange(environment.environmentId, checked); if (checked && !savedEnvironment) { const connected = await connectEnvironment(environment); diff --git a/apps/web/src/components/cloud/cloudEnvironmentConnectionPresentation.ts b/apps/web/src/components/cloud/cloudEnvironmentConnectionPresentation.ts index f2f3395f6d54..119a3366ac63 100644 --- a/apps/web/src/components/cloud/cloudEnvironmentConnectionPresentation.ts +++ b/apps/web/src/components/cloud/cloudEnvironmentConnectionPresentation.ts @@ -36,9 +36,11 @@ export function presentSavedCloudEnvironmentConnection( statusText: connectionStatusText(connection), tone: "connecting", }; + case "unsupported": case "error": return { - buttonLabel: "Connection failed", + buttonLabel: + connection.phase === "unsupported" ? "Client not supported" : "Connection failed", statusText: connectionStatusText(connection), tone: "error", }; diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 59f40f955528..54cfc0258686 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -38,7 +38,6 @@ import { orderDiffFiles } from "./pullRequestFileOrder.logic"; import { buildFileDiffRenderKey, fnv1a32, - getDiffLineStat, getRenderablePatch, resolveDiffThemeName, resolveFileDiffPath, @@ -546,7 +545,6 @@ function PullRequestCodeTab({ toggledFiles, ], ); - const lineStat = useMemo(() => getDiffLineStat(files), [files]); const omittedFileStats = useMemo( () => new Map( @@ -1040,7 +1038,7 @@ function PullRequestCodeTab({ {orderedCommits.length > 0 ? ( {scopeLabel} @@ -1089,7 +1087,7 @@ function PullRequestCodeTab({ ) : null} {/* One count, and the caveats as icons that carry their own words. Spelled out they competed for a strip this narrow and every one of them truncated to nothing. */} - + {files.length} {files.length === 1 ? "file" : "files"} {nextCursor === null ? "" : "+"} @@ -1124,11 +1122,6 @@ function PullRequestCodeTab({
- {fileKeys.length > 0 ? ( } @@ -1551,7 +1558,7 @@ function SavedBackendListRow({ {subtitleText} - {enabled ? connectionStatusText(environment.connection) : "Switched off"} + {enabled || unsupported ? connectionStatusText(environment.connection) : "Switched off"} {versionMismatch ? `\nUpdate available: ${versionMismatch.serverVersion} → ${versionMismatch.clientVersion}` : ""} @@ -1584,13 +1591,15 @@ function SavedBackendListRow({ onSetEnabled(environmentId, checked)} /> } /> - {enabled ? "Switch off" : "Switch on"} + + {unsupported ? "Client not supported" : enabled ? "Switch off" : "Switch on"} + void; }) { const automatic = deriveProjectIdentity(projectName); - const [mode, setMode] = useState( - current?.kind === "lucide" && current.monogram ? "monogram" : (current?.kind ?? "lucide"), - ); + const [mode, setMode] = useState(current?.kind ?? "lucide"); const [iconName, setIconName] = useState( current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON, ); @@ -64,7 +62,7 @@ export function ProjectIconPickerDialog({ current && current.kind !== "emoji" ? current.color : automatic.color, ); const [letters, setLetters] = useState( - current?.kind === "lucide" && current.monogram ? current.monogram : automatic.monogram, + current?.kind === "monogram" ? current.text : automatic.monogram, ); const [emoji, setEmoji] = useState(current?.kind === "emoji" ? current.emoji : "💻"); const [query, setQuery] = useState(""); @@ -73,14 +71,10 @@ export function ProjectIconPickerDialog({ useEffect(() => { if (open && !previousOpenRef.current) { - setMode( - current?.kind === "lucide" && current.monogram ? "monogram" : (current?.kind ?? "lucide"), - ); + setMode(current?.kind ?? "lucide"); setIconName(current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON); setColor(current && current.kind !== "emoji" ? current.color : automatic.color); - setLetters( - current?.kind === "lucide" && current.monogram ? current.monogram : automatic.monogram, - ); + setLetters(current?.kind === "monogram" ? current.text : automatic.monogram); setEmoji(current?.kind === "emoji" ? current.emoji : "💻"); setQuery(""); setCustomEmoji(""); @@ -96,7 +90,7 @@ export function ProjectIconPickerDialog({ if (mode === "monogram" && !validMonogram) return; onSelect( mode === "monogram" - ? { kind: "lucide", name: DEFAULT_ICON, monogram, color } + ? { kind: "monogram", text: monogram, color } : mode === "lucide" ? { kind: "lucide", name: iconName, color } : { kind: "emoji", emoji }, diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 3e4cfd22a937..c31edf0f7e3d 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -435,10 +435,12 @@ function ProjectDetail({ title="Project icon" description={ projectIcon?.kind === "lucide" - ? `${projectIcon.monogram ?? projectIcon.name} · ${projectIcon.color}` - : projectIcon?.kind === "emoji" - ? projectIcon.emoji - : (faviconPath ?? "Automatic") + ? `${projectIcon.name} · ${projectIcon.color}` + : projectIcon?.kind === "monogram" + ? `${projectIcon.text} · ${projectIcon.color}` + : projectIcon?.kind === "emoji" + ? projectIcon.emoji + : (faviconPath ?? "Automatic") } resetAction={ group.memberProjects.some( diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 6baa561a91fe..5238cf19e190 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -578,6 +578,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.composerCollapseOnScroll !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll ? ["Collapse composer on scroll"] : []), + ...(settings.followUpBehavior !== DEFAULT_UNIFIED_SETTINGS.followUpBehavior + ? ["Follow-up behavior"] + : []), ...(settings.contextWindowMeterEnabled !== DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled ? ["Context window indicator"] : []), @@ -636,6 +639,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.confirmThreadDelete, settings.confirmThreadUnpin, settings.composerCollapseOnScroll, + settings.followUpBehavior, settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, settings.newWorktreesStartFromOrigin, @@ -748,6 +752,7 @@ export function useSettingsRestore(onRestored?: () => void) { proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled, showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu, composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll, + followUpBehavior: DEFAULT_UNIFIED_SETTINGS.followUpBehavior, contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled, environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, @@ -2589,6 +2594,47 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + followUpBehavior: DEFAULT_UNIFIED_SETTINGS.followUpBehavior, + }) + } + /> + ) : null + } + control={ + + } + /> + ({ presentations: new Map(), refreshProviders: vi.fn(async () => undefined), + metric: "limits", })); vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.presentations })); vi.mock("../../state/presentation", () => ({ @@ -43,7 +44,7 @@ vi.mock("../../state/usage", () => ({ }), })); vi.mock("./usagePagePreferences", () => ({ - readUsagePagePreferences: () => ({ metric: "limits", windowDays: 30 }), + readUsagePagePreferences: () => ({ metric: state.metric, windowDays: 30 }), saveUsagePagePreferences: vi.fn(), })); vi.mock("../ui/button", () => ({ Button: "button" })); @@ -83,13 +84,16 @@ vi.mock("../settings/providerDriverMeta", () => ({ getDriverOption: () => ({ lab import { UsagePage } from "./UsagePage"; let renderer: ReactTestRenderer; +let environmentNumber = 0; beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-09-11T12:00:00Z")); + environmentNumber += 1; + state.metric = "limits"; state.refreshProviders.mockClear(); state.presentations = new Map([ [ - EnvironmentId.make("test"), + EnvironmentId.make(`test-${environmentNumber}`), { entry: { target: { label: "Test" } }, connection: { phase: "connected" }, @@ -150,7 +154,10 @@ it.each([0, 1])( .at(buttonIndex)! .props.onClick(); }); - expect(state.refreshProviders).toHaveBeenCalledWith({ environmentId: "test", input: {} }); + expect(state.refreshProviders).toHaveBeenCalledWith({ + environmentId: `test-${environmentNumber}`, + input: {}, + }); expect( JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)), ).toContain("in 1h 30m"); @@ -175,5 +182,91 @@ it("uses the current time when returning to limits from tokens", async () => { expect( JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)), ).toContain("in 1h 0m"); + expect(state.refreshProviders).toHaveBeenCalledTimes(2); +}); + +it("refreshes once on opening Limits and suppresses rapid returns and remounts", async () => { + state.metric = "tokens"; + await act(() => { + renderer = create( + + + , + ); + }); expect(state.refreshProviders).not.toHaveBeenCalled(); + const selectMetric = (metric: string) => + renderer.root + .findAll((node) => node.type === "div" && node.props["aria-label"] === "Usage metric")[0]! + .props.onValueChange([metric]); + await act(() => selectMetric("limits")); + expect(state.refreshProviders).toHaveBeenCalledTimes(1); + await act(() => selectMetric("tokens")); + await act(() => selectMetric("limits")); + await act(() => renderer.unmount()); + state.metric = "limits"; + await act(() => { + renderer = create( + + + , + ); + }); + expect(state.refreshProviders).toHaveBeenCalledTimes(1); + await act(() => selectMetric("tokens")); + vi.mocked(Date.now).mockReturnValue(Date.parse("2026-09-11T12:05:00Z")); + await act(() => selectMetric("limits")); + expect(state.refreshProviders).toHaveBeenCalledTimes(2); +}); + +it("waits for connection and refreshes new environments during a slow refresh", async () => { + const [id, presentation] = [...state.presentations][0]!; + state.presentations = new Map([[id, { ...presentation, connection: { phase: "disconnected" } }]]); + await act(() => { + renderer = create(); + }); + expect(state.refreshProviders).not.toHaveBeenCalled(); + let finishRefresh!: () => void; + state.refreshProviders.mockImplementationOnce( + () => + new Promise((resolve) => { + finishRefresh = () => resolve(undefined); + }), + ); + state.presentations = new Map([[id, presentation]]); + await act(() => renderer.update()); + expect(state.refreshProviders).toHaveBeenCalledTimes(1); + const nextId = EnvironmentId.make(`${id}-next`); + state.presentations = new Map([...state.presentations, [nextId, presentation]]); + await act(() => renderer.update()); + expect(state.refreshProviders).toHaveBeenCalledTimes(2); + expect(state.refreshProviders).toHaveBeenLastCalledWith({ environmentId: nextId, input: {} }); + await act(() => finishRefresh()); +}); + +it("keeps manual refresh busy until the already-running automatic check settles", async () => { + let finishRefresh!: () => void; + const pending = new Promise((resolve) => { + finishRefresh = () => resolve(undefined); + }); + state.refreshProviders.mockImplementationOnce(() => pending); + await act(() => { + renderer = create(); + }); + const button = () => + renderer.root.findAll( + (node) => node.type === "button" && node.props["aria-label"] === "Refresh limits", + )[0]!; + expect(state.refreshProviders).toHaveBeenCalledTimes(1); + await act(() => button().props.onClick()); + try { + expect(button().props["aria-busy"]).toBe(true); + expect(state.refreshProviders).toHaveBeenCalledTimes(1); + } finally { + await act(async () => { + finishRefresh(); + await pending; + }); + } + expect(button().props["aria-busy"]).toBe(false); }); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 2da7414d9337..95fee0e4b52a 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -11,7 +11,8 @@ import { CircleDashedIcon, SlidersHorizontalIcon, } from "lucide-react"; -import { useMemo, useRef, useState } from "react"; +import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; +import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage"; import { isCompatibleUsageContractVersion, @@ -165,21 +166,31 @@ export function UsagePage() { setPreferences(nextPreferences); saveUsagePagePreferences(nextPreferences); }; + const refreshLimits = async (automatic = false) => { + try { + await Promise.all( + Array.from(presentations, ([environmentId, presentation]) => { + if (selectedEnvironmentIds !== null && !selectedEnvironmentIds.has(environmentId)) return; + if (presentation.connection.phase === "connected" && presentation.serverConfig !== null) { + return refreshUsageLimits( + environmentId, + () => refreshProviders({ environmentId, input: {} }), + automatic, + ); + } + }), + ); + } finally { + setLimitsNow(Date.now()); + } + }; const refreshWindow = () => { if (refreshingRef.current) return; if (showingLimits) { refreshingRef.current = true; setIsRefreshing(true); - void Promise.all( - Array.from(presentations, ([environmentId, presentation]) => { - if (selectedEnvironmentIds !== null && !selectedEnvironmentIds.has(environmentId)) return; - if (presentation.connection.phase === "connected" && presentation.serverConfig !== null) { - return refreshProviders({ environmentId, input: {} }); - } - }), - ).finally(() => { - setLimitsNow(Date.now()); + void refreshLimits().finally(() => { refreshingRef.current = false; setIsRefreshing(false); }); @@ -201,6 +212,23 @@ export function UsagePage() { setIsRefreshing(false); }); }; + const connectedLimitsEnvironments = [...presentations] + .filter( + ([environmentId, presentation]) => + presentation.connection.phase === "connected" && + presentation.serverConfig !== null && + (selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)), + ) + .map(([environmentId]) => environmentId) + .sort() + .join(","); + const autoRefreshLimits = useEffectEvent(() => { + void refreshLimits(true); + }); + useEffect(() => { + if (showingLimits && connectedLimitsEnvironments) autoRefreshLimits(); + }, [showingLimits, connectedLimitsEnvironments]); + const windowLabel = isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}` diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 1e9e8adc7baf..d335c68869bb 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -1119,6 +1119,7 @@ describe("composer and pull request shortcuts", () => { ["l", "composer.previousWorktree"], ["c", "thread.copyReference"], ["k", "pullRequest.copyNumber"], + ["Enter", "thread.steerQueuedMessage"], ] as const; for (const platform of ["MacIntel", "Win32", "Linux"]) { diff --git a/docs/user/composer.md b/docs/user/composer.md index 8ed45837388d..f3be7a5f342d 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -31,12 +31,21 @@ See [images and videos](#images-and-videos-in-messages) for previewing and savin ## Send while the agent is working -A message sent during a running turn waits at the end of the conversation as a +On web and desktop, a message sent during a running turn waits at the end of the conversation as a dashed bubble. It goes out on its own when the agent finishes its next tool call, or when the turn ends. Use the arrow under the bubble to send it right away, or the X to move it back into the composer. Stop returns every queued message to the composer. +In **Settings → General → Follow-up behavior**, choose **Queue** to keep this +behavior or **Steer** to send new messages immediately. This setting applies to +the current client. Messages already queued keep their place. + +Use `Cmd+Shift+Enter` on macOS or `Ctrl+Shift+Enter` on Windows and Linux to send +the oldest queued message now. Change `thread.steerQueuedMessage` in +**Settings → Keybindings** to use another shortcut. It leaves the current draft +in the composer and waits if the agent needs an approval or an answer. + ## Queue messages offline on mobile Mobile keeps local copies of draft attachments, so you can preview them and queue diff --git a/docs/user/usage.md b/docs/user/usage.md index 9b0f449ee757..dbef802509b3 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -60,7 +60,9 @@ the bar show each account's quota, countdown, and credits. Tap a row to open its The same account signed in on more than one environment, or reported by a hub as well, counts once. Filter with the environment dropdown to see what a single machine has. -If a window looks stale, refresh Limits to re-check every provider and hub. +Opening Limits checks the selected connected environments automatically. Each client waits at +least five minutes between automatic checks of an environment, including after a failed check. +If a window still looks stale, refresh Limits to re-check every provider and hub. Pick `/usage-limits` from the composer's command menu, or send it as a message, to check the current model's limits without leaving the conversation. The result opens above the composer and diff --git a/oxlint-plugin-t3code/index.ts b/oxlint-plugin-t3code/index.ts index a2d1997e43f9..075d16d5b390 100644 --- a/oxlint-plugin-t3code/index.ts +++ b/oxlint-plugin-t3code/index.ts @@ -2,7 +2,7 @@ import { definePlugin } from "@oxlint/plugins"; import namespaceNodeImports from "./rules/namespace-node-imports.ts"; import noGlobalProcessRuntime from "./rules/no-global-process-runtime.ts"; -import noHermesUnsupportedArrayMethods from "./rules/no-hermes-unsupported-array-methods.ts"; +import noHermesUnsupportedApis from "./rules/no-hermes-unsupported-apis.ts"; import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts"; import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts"; import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-escape-hatches.ts"; @@ -15,7 +15,7 @@ export default definePlugin({ rules: { "namespace-node-imports": namespaceNodeImports, "no-global-process-runtime": noGlobalProcessRuntime, - "no-hermes-unsupported-array-methods": noHermesUnsupportedArrayMethods, + "no-hermes-unsupported-apis": noHermesUnsupportedApis, "no-inline-schema-compile": noInlineSchemaCompile, "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests, "no-mobile-uniwind-theme-escape-hatches": noMobileUniwindThemeEscapeHatches, diff --git a/oxlint-plugin-t3code/rules/no-hermes-unsupported-array-methods.test.ts b/oxlint-plugin-t3code/rules/no-hermes-unsupported-apis.test.ts similarity index 62% rename from oxlint-plugin-t3code/rules/no-hermes-unsupported-array-methods.test.ts rename to oxlint-plugin-t3code/rules/no-hermes-unsupported-apis.test.ts index 4bd751c13c9a..ceccf8d32c33 100644 --- a/oxlint-plugin-t3code/rules/no-hermes-unsupported-array-methods.test.ts +++ b/oxlint-plugin-t3code/rules/no-hermes-unsupported-apis.test.ts @@ -2,11 +2,11 @@ import { assert, describe } from "@effect/vitest"; import { createOxlintRuleHarness } from "../test/utils.ts"; -const rule = createOxlintRuleHarness("t3code/no-hermes-unsupported-array-methods", { +const rule = createOxlintRuleHarness("t3code/no-hermes-unsupported-apis", { filename: "fixture.ts", }); -describe("t3code/no-hermes-unsupported-array-methods", () => { +describe("t3code/no-hermes-unsupported-apis", () => { rule.valid("allows in-place sort on a copy", `const sorted = [...items].sort(compare);`); rule.valid("allows in-place reverse on a copy", `const reversed = [...items].reverse();`); @@ -54,4 +54,26 @@ describe("t3code/no-hermes-unsupported-array-methods", () => { "ignores a template-literal property with substitutions", "const value = items[`to${suffix}`]();", ); + + rule.valid("allows supported Intl constructors", `new Intl.NumberFormat();`); + rule.valid("allows feature detection", `const supported = typeof Intl.Segmenter === "function";`); + rule.valid("allows unrelated Segmenter constructors", `new custom.Segmenter();`); + rule.valid("ignores dynamic computed names", `new Intl[Segmenter](); items[toSorted]();`); + + for (const expression of [ + "new Intl.Segmenter(undefined, { granularity: 'grapheme' })", + "new Intl['Segmenter']()", + "new Intl[`Segmenter`]()", + "new globalThis.Intl.Segmenter()", + "new globalThis['Intl']['Segmenter']()", + "new global.Intl.Segmenter()", + "new window.Intl.Segmenter()", + "Intl.Segmenter()", + "Intl.Segmenter?.()", + ]) { + rule.invalid(`reports ${expression}`, `${expression};`, (output) => { + assert.match(output, /Hermes does not implement Intl\.Segmenter/); + assert.match(output, /portable implementation/); + }); + } }); diff --git a/oxlint-plugin-t3code/rules/no-hermes-unsupported-apis.ts b/oxlint-plugin-t3code/rules/no-hermes-unsupported-apis.ts new file mode 100644 index 000000000000..b06b522376e0 --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-hermes-unsupported-apis.ts @@ -0,0 +1,70 @@ +import { defineRule, type ESTree } from "@oxlint/plugins"; + +// Add global APIs by dotted path, or instance methods by name. Values explain the replacement. +const UNSUPPORTED_GLOBAL_APIS = new Map([ + [ + "Intl.Segmenter", + "Use a portable implementation or a simpler character-counting approximation.", + ], +]); + +const UNSUPPORTED_METHODS = new Map([ + [ + "toSorted", + "Hermes does not implement Array#toSorted. Copy the array first: [...array].sort(...).", + ], + [ + "toReversed", + "Hermes does not implement Array#toReversed. Copy the array first: [...array].reverse().", + ], + // splice returns the removed elements, so the copy itself is the result. + [ + "toSpliced", + "Hermes does not implement Array#toSpliced. Copy the array first: const copy = [...array]; copy.splice(...); use copy.", + ], +]); + +function memberName(node: ESTree.MemberExpression): string | null { + const { property } = node; + if (!node.computed && property.type === "Identifier") return property.name; + if (property.type === "Literal" && typeof property.value === "string") return property.value; + if (property.type === "TemplateLiteral" && property.expressions.length === 0) + return property.quasis[0]?.value.cooked ?? null; + return null; +} + +function globalApiPath(node: ESTree.Node): string | null { + if (node.type === "Identifier") return node.name; + if (node.type !== "MemberExpression") return null; + const object = globalApiPath(node.object); + const property = memberName(node); + if (object === null || property === null) return null; + return ["globalThis", "global", "window"].includes(object) ? property : `${object}.${property}`; +} + +export default defineRule({ + meta: { + type: "problem", + docs: { + description: "Disallow APIs that Hermes does not implement in mobile and shared client code.", + }, + }, + create(context) { + function checkApi(node: ESTree.CallExpression | ESTree.NewExpression) { + const path = globalApiPath(node.callee); + const replacement = path === null ? undefined : UNSUPPORTED_GLOBAL_APIS.get(path); + if (replacement !== undefined) { + context.report({ + node: node.callee, + message: `Hermes does not implement ${path}. ${replacement}`, + }); + return; + } + if (node.type !== "CallExpression" || node.callee.type !== "MemberExpression") return; + const name = memberName(node.callee); + const message = name === null ? undefined : UNSUPPORTED_METHODS.get(name); + if (message !== undefined) context.report({ node: node.callee.property, message }); + } + return { NewExpression: checkApi, CallExpression: checkApi }; + }, +}); diff --git a/oxlint-plugin-t3code/rules/no-hermes-unsupported-array-methods.ts b/oxlint-plugin-t3code/rules/no-hermes-unsupported-array-methods.ts deleted file mode 100644 index e5dd8867e696..000000000000 --- a/oxlint-plugin-t3code/rules/no-hermes-unsupported-array-methods.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { defineRule } from "@oxlint/plugins"; - -// ES2023 change-array-by-copy methods. Hermes does not implement them, and -// tsconfig targets ESNext, so nothing but this rule stands between a call and a -// TypeError that is fatal on every mobile launch that reaches it. -const UNSUPPORTED_METHODS = new Map([ - ["toSorted", "[...array].sort(...)"], - ["toReversed", "[...array].reverse()"], - // splice returns the removed elements, so the copy itself is the result. - ["toSpliced", "const copy = [...array]; copy.splice(...); use copy"], -]); - -export default defineRule({ - meta: { - type: "problem", - docs: { - description: - "Disallow ES2023 array-by-copy methods (toSorted, toReversed, toSpliced) in code that runs on Hermes.", - }, - }, - create(context) { - return { - CallExpression(node) { - if (node.callee.type !== "MemberExpression") return; - const { property } = node.callee; - const name = - property.type === "Identifier" - ? property.name - : property.type === "Literal" && typeof property.value === "string" - ? property.value - : property.type === "TemplateLiteral" && property.expressions.length === 0 - ? (property.quasis[0]?.value.cooked ?? null) - : null; - if (name === null) return; - const replacement = UNSUPPORTED_METHODS.get(name); - if (replacement === undefined) return; - - context.report({ - node: property, - message: `Hermes does not implement Array#${name}. Copy the array first: ${replacement}.`, - }); - }, - }; - }, -}); diff --git a/packages/client-runtime/src/connection/catalog.ts b/packages/client-runtime/src/connection/catalog.ts index 5b6ccd791b96..8295f05f29ec 100644 --- a/packages/client-runtime/src/connection/catalog.ts +++ b/packages/client-runtime/src/connection/catalog.ts @@ -41,6 +41,8 @@ export interface ConnectionCatalogEntry { readonly profile: Option.Option; /** False when the user switched the environment off: saved, but never connects. */ readonly enabled: boolean; + /** Discovery rejection stays visible while the saved connection is switched off. */ + readonly unsupportedReason?: string; } export class BearerConnectionCredential extends Schema.TaggedClass()( diff --git a/packages/client-runtime/src/connection/compatibility.test.ts b/packages/client-runtime/src/connection/compatibility.test.ts new file mode 100644 index 000000000000..e6a2cf3ca18f --- /dev/null +++ b/packages/client-runtime/src/connection/compatibility.test.ts @@ -0,0 +1,54 @@ +import { + EnvironmentId, + ORCHESTRATION_PROTOCOL_VERSION, + type ExecutionEnvironmentDescriptor, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + appendOrchestrationProtocol, + orchestrationProtocolCompatibilityError, +} from "./compatibility.ts"; + +const descriptor = (orchestrationProtocolVersion?: number): ExecutionEnvironmentDescriptor => ({ + environmentId: EnvironmentId.make("environment-remote"), + label: "Build Mac", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "9.0.0", + ...(orchestrationProtocolVersion === undefined ? {} : { orchestrationProtocolVersion }), + capabilities: { repositoryIdentity: true }, +}); + +describe("orchestration protocol compatibility", () => { + it("accepts the current protocol and announces it without disturbing socket credentials", () => { + expect( + orchestrationProtocolCompatibilityError(descriptor(ORCHESTRATION_PROTOCOL_VERSION)), + ).toBeNull(); + + const socketUrl = new URL( + appendOrchestrationProtocol("wss://host.test/ws?wsTicket=secret&connectionMethod=relay"), + ); + expect(socketUrl.searchParams.get("orchestrationProtocol")).toBe( + String(ORCHESTRATION_PROTOCOL_VERSION), + ); + expect(socketUrl.searchParams.get("wsTicket")).toBe("secret"); + expect(socketUrl.searchParams.get("connectionMethod")).toBe("relay"); + }); + + it("treats missing metadata as protocol 1", () => { + const error = orchestrationProtocolCompatibilityError(descriptor()); + if (Number(ORCHESTRATION_PROTOCOL_VERSION) === 1) { + expect(error).toBeNull(); + } else { + expect(error).toMatchObject({ reason: "unsupported" }); + } + }); + + it("blocks a different protocol before connecting", () => { + const error = orchestrationProtocolCompatibilityError( + descriptor(ORCHESTRATION_PROTOCOL_VERSION + 1), + ); + expect(error).toMatchObject({ reason: "unsupported" }); + expect(error?.message).toContain("This client is not supported"); + }); +}); diff --git a/packages/client-runtime/src/connection/compatibility.ts b/packages/client-runtime/src/connection/compatibility.ts new file mode 100644 index 000000000000..0a08ddb2acfa --- /dev/null +++ b/packages/client-runtime/src/connection/compatibility.ts @@ -0,0 +1,30 @@ +import { + ORCHESTRATION_PROTOCOL_QUERY_PARAM, + ORCHESTRATION_PROTOCOL_VERSION, + type ExecutionEnvironmentDescriptor, +} from "@t3tools/contracts"; + +import { ConnectionBlockedError } from "./model.ts"; + +export function orchestrationProtocolCompatibilityError( + descriptor: ExecutionEnvironmentDescriptor, +): ConnectionBlockedError | null { + // Servers shipped before negotiation use the original wire protocol. + const serverProtocolVersion = descriptor.orchestrationProtocolVersion ?? 1; + if (serverProtocolVersion === ORCHESTRATION_PROTOCOL_VERSION) { + return null; + } + return new ConnectionBlockedError({ + reason: "unsupported", + detail: + serverProtocolVersion > ORCHESTRATION_PROTOCOL_VERSION + ? `This client is not supported by this server. Update your app or use a compatible release to connect to ${descriptor.label}.` + : `This client requires a newer server. Update T3 Code on ${descriptor.label} to connect.`, + }); +} + +export function appendOrchestrationProtocol(socketUrl: string): string { + const url = new URL(socketUrl); + url.searchParams.set(ORCHESTRATION_PROTOCOL_QUERY_PARAM, String(ORCHESTRATION_PROTOCOL_VERSION)); + return url.toString(); +} diff --git a/packages/client-runtime/src/connection/index.ts b/packages/client-runtime/src/connection/index.ts index 5367c7f6b820..cf8385aa132b 100644 --- a/packages/client-runtime/src/connection/index.ts +++ b/packages/client-runtime/src/connection/index.ts @@ -21,3 +21,5 @@ export { } from "./registry.ts"; export { EnvironmentSupervisor, type EnvironmentSupervisorOptions } from "./supervisor.ts"; export * as Wakeups from "./wakeups.ts"; + +export { orchestrationProtocolCompatibilityError } from "./compatibility.ts"; diff --git a/packages/client-runtime/src/connection/layer.ts b/packages/client-runtime/src/connection/layer.ts index 43153838df35..46dcdcf569b9 100644 --- a/packages/client-runtime/src/connection/layer.ts +++ b/packages/client-runtime/src/connection/layer.ts @@ -1,6 +1,10 @@ +import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; +import * as Option from "effect/Option"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { orchestrationProtocolCompatibilityError } from "./compatibility.ts"; import * as ConnectionResolver from "./resolver.ts"; import * as ConnectionDriver from "./driver.ts"; @@ -11,6 +15,49 @@ import * as RelayEnvironmentDiscovery from "../relay/discovery.ts"; import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; import * as RpcSession from "../rpc/session.ts"; +export const watchDiscoveredCompatibility = Effect.fn("connection.watchDiscoveredCompatibility")( + function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery; + const seenChecks = new Map(); + yield* Stream.merge( + SubscriptionRef.changes(discovery.state), + SubscriptionRef.changes(registry.entries), + ).pipe( + Stream.runForEach(() => + Effect.gen(function* () { + const current = yield* SubscriptionRef.get(discovery.state); + if (!current.refreshing) { + for (const environmentId of seenChecks.keys()) { + if (!current.environments.has(environmentId)) seenChecks.delete(environmentId); + } + } + for (const entry of current.environments.values()) { + const status = Option.getOrNull(entry.status); + const descriptor = status?.descriptor; + if (status === null || descriptor === undefined) continue; + const environmentId = entry.environment.environmentId; + const previous = seenChecks.get(environmentId); + const fresh = + previous?.checkedAt !== status.checkedAt || + (previous.descriptor?.orchestrationProtocolVersion ?? 1) !== + (descriptor.orchestrationProtocolVersion ?? 1) || + previous.descriptor?.serverVersion !== descriptor.serverVersion; + const error = orchestrationProtocolCompatibilityError(descriptor); + // A replayed health result must not clear a newer socket rejection. + if (error !== null || fresh) yield* registry.setCompatibility(environmentId, error); + seenChecks.set(environmentId, status); + } + }).pipe( + Effect.catch((error) => + Effect.logWarning("Could not apply discovered environment compatibility.", { error }), + ), + ), + ), + ); + }, +); + export function layerWithOptions(options: RpcSession.RpcSessionOptions) { const driverLayer = ConnectionDriver.layer.pipe( Layer.provide(Layer.mergeAll(ConnectionResolver.layer, RpcSession.layerWithOptions(options))), @@ -26,6 +73,7 @@ export function layerWithOptions(options: RpcSession.RpcSessionOptions) { Effect.gen(function* () { const registry = yield* EnvironmentRegistry.EnvironmentRegistry; const platformSource = yield* PlatformConnectionSource.PlatformConnectionSource; + yield* watchDiscoveredCompatibility().pipe(Effect.forkScoped); yield* registry.start; yield* platformSource.registrations.pipe( Stream.runForEach(registry.reconcilePlatform), diff --git a/packages/client-runtime/src/connection/onboarding.test.ts b/packages/client-runtime/src/connection/onboarding.test.ts index a0c73d2bba87..5e252df51884 100644 --- a/packages/client-runtime/src/connection/onboarding.test.ts +++ b/packages/client-runtime/src/connection/onboarding.test.ts @@ -1,4 +1,8 @@ -import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts"; +import { + AuthStandardClientScopes, + EnvironmentId, + ORCHESTRATION_PROTOCOL_VERSION, +} from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -28,7 +32,7 @@ const CLIENT_PRESENTATION_LAYER = Layer.succeed( function pairingHttpLayer( calls: Array<{ readonly url: string; readonly init: RequestInit }>, - options?: { readonly failDescriptor?: boolean }, + options?: { readonly failDescriptor?: boolean; readonly protocolVersion?: number }, ) { const fetchFn = ((input, init = {}) => { const url = String(input); @@ -49,6 +53,7 @@ function pairingHttpLayer( arch: "x64", }, serverVersion: "0.0.0-test", + orchestrationProtocolVersion: options?.protocolVersion ?? ORCHESTRATION_PROTOCOL_VERSION, capabilities: { repositoryIdentity: true, }, @@ -118,6 +123,28 @@ describe("connection onboarding", () => { }), ); + it.effect("rejects an incompatible server without consuming the pairing credential", () => + Effect.gen(function* () { + const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; + const error = yield* preparePairingRegistration({ + host: "remote.example.test", + pairingCode: "pairing-token", + }).pipe( + Effect.provide( + Layer.mergeAll( + CLIENT_PRESENTATION_LAYER, + pairingHttpLayer(calls, { protocolVersion: ORCHESTRATION_PROTOCOL_VERSION + 1 }), + ), + ), + Effect.flip, + ); + expect(error).toMatchObject({ reason: "unsupported" }); + expect(calls.map((call) => call.url)).toEqual([ + "https://remote.example.test/.well-known/t3/environment", + ]); + }), + ); + it.effect("does not consume a pairing credential when descriptor discovery fails", () => Effect.gen(function* () { const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; diff --git a/packages/client-runtime/src/connection/onboarding.ts b/packages/client-runtime/src/connection/onboarding.ts index 3bc0e56dca82..24c03addfa66 100644 --- a/packages/client-runtime/src/connection/onboarding.ts +++ b/packages/client-runtime/src/connection/onboarding.ts @@ -31,6 +31,7 @@ import { } from "./model.ts"; import * as Persistence from "../platform/persistence.ts"; import * as EnvironmentRegistry from "./registry.ts"; +import { orchestrationProtocolCompatibilityError } from "./compatibility.ts"; export interface PairingConnectionInput { readonly pairingUrl?: string; @@ -91,6 +92,8 @@ export const preparePairingRegistration = Effect.fn( const descriptor = yield* fetchRemoteEnvironmentDescriptor({ httpBaseUrl: target.httpBaseUrl, }).pipe(Effect.mapError(mapRemoteEnvironmentError)); + const compatibilityError = orchestrationProtocolCompatibilityError(descriptor); + if (compatibilityError !== null) return yield* compatibilityError; const access = yield* bootstrapRemoteBearerSession({ httpBaseUrl: target.httpBaseUrl, credential: target.credential, diff --git a/packages/client-runtime/src/connection/presentation.test.ts b/packages/client-runtime/src/connection/presentation.test.ts index 979b6adb4003..fabc47034599 100644 --- a/packages/client-runtime/src/connection/presentation.test.ts +++ b/packages/client-runtime/src/connection/presentation.test.ts @@ -5,6 +5,7 @@ import * as Option from "effect/Option"; import { BearerConnectionProfile, type ConnectionCatalogEntry } from "./catalog.ts"; import { BearerConnectionTarget, + ConnectionBlockedError, ConnectionTransientError, type SupervisorConnectionState, } from "./model.ts"; @@ -51,6 +52,21 @@ function supervisorState(overrides: Partial): Supervi } describe("connection presentation", () => { + it("labels a blocked protocol as unsupported", () => { + const connection = presentConnectionState( + supervisorState({ + phase: "blocked", + lastFailure: new ConnectionBlockedError({ + reason: "unsupported", + detail: "Update your app.", + }), + }), + ); + expect(connection.phase).toBe("unsupported"); + expect(connection.error).toBe("Update your app."); + expect(connectionStatusText(connection)).toBe("Client not supported"); + }); + it("preserves profile display information without exposing credentials", () => { expect(connectionCatalogDisplayUrl(ENTRY)).toBe("https://environment.example.test"); }); diff --git a/packages/client-runtime/src/connection/presentation.ts b/packages/client-runtime/src/connection/presentation.ts index 4093167d333c..f7586c5e3dbf 100644 --- a/packages/client-runtime/src/connection/presentation.ts +++ b/packages/client-runtime/src/connection/presentation.ts @@ -10,7 +10,8 @@ export type EnvironmentConnectionPhase = | "connecting" | "reconnecting" | "connected" - | "error"; + | "error" + | "unsupported"; export interface EnvironmentConnectionPresentation { readonly phase: EnvironmentConnectionPhase; @@ -48,7 +49,7 @@ export function presentConnectionState( }; case "blocked": return { - phase: "error", + phase: state.lastFailure?.reason === "unsupported" ? "unsupported" : "error", error: state.lastFailure?.message ?? null, traceId: state.lastFailure?.traceId ?? null, }; @@ -69,6 +70,8 @@ export function connectionStatusText(connection: EnvironmentConnectionPresentati : "Reconnecting..."; case "connected": return "Connected"; + case "unsupported": + return "Client not supported"; case "error": return connection.error ? `Connection failed. Reason: ${connection.error}` diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index 5773acf5ce1f..729d27060187 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -1,6 +1,8 @@ import { type DesktopSshEnvironmentTarget, EnvironmentId, + ORCHESTRATION_PROTOCOL_VERSION, + type ExecutionEnvironmentDescriptor, type OrchestrationShellSnapshot, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; @@ -36,6 +38,7 @@ import * as ConnectionCredentialStore from "./credentialStore.ts"; import * as ConnectionDriver from "./driver.ts"; import { ConnectionTransientError, + ConnectionBlockedError, BearerConnectionTarget, PrimaryConnectionTarget, RelayConnectionTarget, @@ -54,6 +57,9 @@ import { import * as RpcSession from "../rpc/session.ts"; import * as EnvironmentSupervisor from "./supervisor.ts"; import * as ConnectionWakeups from "./wakeups.ts"; +import { watchDiscoveredCompatibility } from "./layer.ts"; +import * as RelayEnvironmentDiscovery from "../relay/discovery.ts"; +import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay"; import { runDesktopCommitWithReconnectObserver } from "../state/server.ts"; const TARGET = new PrimaryConnectionTarget({ @@ -137,6 +143,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( initialProfiles: ReadonlyArray = [], initialCredentials: ReadonlyArray = [], options?: { + readonly prepareError?: ConnectionBlockedError; readonly beforeSessionConnect?: (environmentId: EnvironmentId) => Effect.Effect; readonly beforeRegistrationRegister?: ( registration: ConnectionRegistration, @@ -368,6 +375,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( target, }; yield* reportProgress({ stage: "preparing" }); + if (options?.prepareError) return yield* options.prepareError; yield* reportProgress({ stage: "opening", prepared }); yield* options?.beforeSessionConnect?.(target.environmentId) ?? Effect.void; const closed = yield* Deferred.make(); @@ -673,6 +681,212 @@ describe("EnvironmentRegistry", () => { }), ); + it.effect("only a fresh health check for the rejected environment unlocks it", () => + Effect.gen(function* () { + const harness = yield* makeHarness([RELAY_TARGET], [], [], { + initialDisabled: [RELAY_TARGET.environmentId], + }); + const descriptor = (environmentId: EnvironmentId): ExecutionEnvironmentDescriptor => ({ + environmentId, + label: "Server", + platform: { os: "linux", arch: "x64" }, + serverVersion: "1.0.0", + orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION, + capabilities: { repositoryIdentity: true }, + }); + const discovered = ( + value: ExecutionEnvironmentDescriptor, + checkedAt = "2026-09-15T00:00:00Z", + ) => { + const environment = { + environmentId: value.environmentId, + label: value.label, + endpoint: { + httpBaseUrl: "https://relay.example.test", + wsBaseUrl: "wss://relay.example.test", + providerKind: "manual" as const, + }, + linkedAt: "2026-09-15T00:00:00Z", + }; + const status: RelayEnvironmentStatusResponse = { + environmentId: value.environmentId, + endpoint: environment.endpoint, + status: "online", + checkedAt, + descriptor: value, + }; + return { + environment, + availability: "online" as const, + status: Option.some(status), + error: Option.none(), + }; + }; + const original = discovered(descriptor(RELAY_TARGET.environmentId)); + const discoveryState = + yield* SubscriptionRef.make({ + ...RelayEnvironmentDiscovery.EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE, + environments: new Map([[RELAY_TARGET.environmentId, original]]), + }); + const initial = yield* Deferred.make(); + const unrelated = yield* Deferred.make(); + const replayed = yield* Deferred.make(); + const refreshed = yield* Deferred.make(); + let firstEnvironmentCalls = 0; + let secondEnvironmentCalls = 0; + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* watchDiscoveredCompatibility().pipe( + Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, { + ...registry, + setCompatibility: (environmentId, error) => + registry.setCompatibility(environmentId, error).pipe( + Effect.andThen( + Effect.gen(function* () { + if (environmentId === RELAY_TARGET.environmentId) { + firstEnvironmentCalls += 1; + yield* Deferred.succeed( + firstEnvironmentCalls === 1 ? initial : refreshed, + undefined, + ); + } else { + secondEnvironmentCalls += 1; + yield* Deferred.succeed( + secondEnvironmentCalls === 1 ? unrelated : replayed, + undefined, + ); + } + }), + ), + ), + }), + Effect.provideService( + RelayEnvironmentDiscovery.RelayEnvironmentDiscovery, + RelayEnvironmentDiscovery.RelayEnvironmentDiscovery.of({ + state: discoveryState, + refresh: Effect.void, + }), + ), + Effect.forkScoped, + ); + yield* Deferred.await(initial); + const error = new ConnectionBlockedError({ + reason: "unsupported", + detail: "Socket discovered a newer protocol.", + }); + yield* registry.setCompatibility(RELAY_TARGET.environmentId, error); + yield* SubscriptionRef.update(discoveryState, (state) => ({ + ...state, + environments: new Map(state.environments).set( + SECOND_TARGET.environmentId, + discovered(descriptor(SECOND_TARGET.environmentId)), + ), + })); + yield* Deferred.await(unrelated); + expect( + (yield* SubscriptionRef.get(registry.entries)).get(RELAY_TARGET.environmentId) + ?.unsupportedReason, + ).toBe(error.message); + yield* SubscriptionRef.update(discoveryState, (state) => ({ + ...state, + refreshing: true, + environments: new Map(), + })); + yield* SubscriptionRef.update(discoveryState, (state) => ({ + ...state, + refreshing: false, + environments: new Map([ + [RELAY_TARGET.environmentId, discovered(descriptor(RELAY_TARGET.environmentId))], + [ + SECOND_TARGET.environmentId, + discovered(descriptor(SECOND_TARGET.environmentId), "2026-09-15T00:01:00Z"), + ], + ]), + })); + yield* Deferred.await(replayed); + expect( + (yield* SubscriptionRef.get(registry.entries)).get(RELAY_TARGET.environmentId) + ?.unsupportedReason, + ).toBe(error.message); + yield* SubscriptionRef.update(discoveryState, (state) => ({ + ...state, + environments: new Map(state.environments).set( + RELAY_TARGET.environmentId, + discovered(descriptor(RELAY_TARGET.environmentId), "2026-09-15T00:02:00Z"), + ), + })); + yield* Deferred.await(refreshed); + expect( + (yield* SubscriptionRef.get(registry.entries)).get(RELAY_TARGET.environmentId), + ).toMatchObject({ enabled: false }); + expect( + (yield* SubscriptionRef.get(registry.entries)).get(RELAY_TARGET.environmentId) + ?.unsupportedReason, + ).toBeUndefined(); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("discovery keeps unsupported environments off until compatibility changes", () => + Effect.gen(function* () { + const harness = yield* makeHarness([RELAY_TARGET], [], [], { + initialDisabled: [RELAY_TARGET.environmentId], + }); + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.start; + const error = new ConnectionBlockedError({ + reason: "unsupported", + detail: "Use a compatible client.", + }); + yield* registry.setCompatibility(RELAY_TARGET.environmentId, error); + const entry = (yield* SubscriptionRef.get(registry.entries)).get( + RELAY_TARGET.environmentId, + ); + expect(entry).toMatchObject({ enabled: false, unsupportedReason: error.message }); + expect( + yield* Effect.flip(registry.setEnabled(RELAY_TARGET.environmentId, true)), + ).toMatchObject({ reason: "unsupported" }); + expect(yield* Ref.get(harness.sessions)).toHaveLength(0); + yield* registry.setCompatibility(RELAY_TARGET.environmentId, null); + expect( + (yield* SubscriptionRef.get(registry.entries)).get(RELAY_TARGET.environmentId)?.enabled, + ).toBe(false); + yield* registry.setEnabled(RELAY_TARGET.environmentId, true); + yield* awaitConnectionState( + registry, + RELAY_TARGET.environmentId, + (state) => state.phase === "connected", + ); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("a socket preflight rejection persists the connection as switched off", () => + Effect.gen(function* () { + const error = new ConnectionBlockedError({ + reason: "unsupported", + detail: "Use a compatible client.", + }); + const harness = yield* makeHarness([RELAY_TARGET], [], [], { prepareError: error }); + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.start; + yield* SubscriptionRef.changes(registry.entries).pipe( + Stream.filter((entries) => entries.get(RELAY_TARGET.environmentId)?.enabled === false), + Stream.take(1), + Stream.runDrain, + ); + expect((yield* Ref.get(harness.storedDisabled)).has(RELAY_TARGET.environmentId)).toBe(true); + expect( + (yield* SubscriptionRef.get(registry.entries)).get(RELAY_TARGET.environmentId) + ?.unsupportedReason, + ).toBe(error.message); + expect(yield* Ref.get(harness.sessions)).toHaveLength(0); + }).pipe(Effect.provide(harness.layer)); + }), + ); + it.effect("switching an environment off disconnects it and persists the flag", () => Effect.gen(function* () { const harness = yield* makeHarness([RELAY_TARGET]); @@ -1095,6 +1309,55 @@ describe("EnvironmentRegistry", () => { }), ); + it.effect("platform refreshes preserve unsupported state for the same endpoint", () => + Effect.gen(function* () { + const harness = yield* makeHarness([]); + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const registration = new PrimaryConnectionRegistration({ target: TARGET }); + yield* registry.registerPlatform(registration); + yield* awaitConnectionState( + registry, + TARGET.environmentId, + (state) => state.phase === "connected", + ); + const error = new ConnectionBlockedError({ + reason: "unsupported", + detail: "Use a compatible client.", + }); + yield* registry.setCompatibility(TARGET.environmentId, error); + yield* awaitConnectionState( + registry, + TARGET.environmentId, + (state) => state.phase === "available", + ); + yield* registry.registerPlatform(registration); + yield* registry.reconcilePlatform([registration]); + expect( + (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId), + ).toMatchObject({ enabled: false, unsupportedReason: error.message }); + expect(yield* Ref.get(harness.sessions)).toHaveLength(1); + yield* registry.registerPlatform( + new PrimaryConnectionRegistration({ + target: new PrimaryConnectionTarget({ + ...TARGET, + httpBaseUrl: "https://changed.example.test", + }), + }), + ); + yield* awaitConnectionState( + registry, + TARGET.environmentId, + (state) => state.phase === "connected", + ); + expect( + (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId) + ?.unsupportedReason, + ).toBeUndefined(); + }).pipe(Effect.provide(harness.layer)); + }), + ); + it.effect("retains a healthy runtime when the platform repeats an identical registration", () => Effect.gen(function* () { const harness = yield* makeHarness([]); diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index 0949916356a0..af1cc46faa9b 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -30,6 +30,7 @@ import type { NetworkStatus, SupervisorConnectionState, } from "./model.ts"; +import { ConnectionBlockedError } from "./model.ts"; import * as Persistence from "../platform/persistence.ts"; import * as EnvironmentSupervisor from "./supervisor.ts"; import * as ConnectionDriver from "./driver.ts"; @@ -104,8 +105,14 @@ export class EnvironmentRegistry extends Context.Service< enabled: boolean, ) => Effect.Effect< void, - EnvironmentNotRegisteredError | Persistence.ConnectionPersistenceError + | EnvironmentNotRegisteredError + | Persistence.ConnectionPersistenceError + | ConnectionBlockedError >; + readonly setCompatibility: ( + environmentId: EnvironmentId, + error: ConnectionBlockedError | null, + ) => Effect.Effect; readonly state: ( environmentId: EnvironmentId, ) => Effect.Effect; @@ -291,6 +298,21 @@ export const make = Effect.gen(function* () { next.set(environmentId, { entry, supervisor, scope }); return next; }); + yield* SubscriptionRef.changes(supervisor.state).pipe( + Stream.runForEach((state) => + state.phase === "blocked" && state.lastFailure?.reason === "unsupported" + ? setCompatibility(environmentId, state.lastFailure).pipe( + Effect.catch((error) => + Effect.logWarning("Could not disable an unsupported environment.", { + environmentId, + error, + }), + ), + ) + : Effect.void, + ), + Effect.forkIn(scope), + ); return supervisor; }), ), @@ -426,7 +448,16 @@ export const make = Effect.gen(function* () { // Editing a saved environment must preserve its disabled state. const previous = (yield* SubscriptionRef.get(entries)).get(environmentId); const entry: ConnectionCatalogEntry = - previous === undefined ? registered : { ...registered, enabled: previous.enabled }; + previous === undefined + ? registered + : { + ...registered, + enabled: previous.enabled, + ...(previous.unsupportedReason !== undefined && + gitHubRoutingConnectionKey(previous) === gitHubRoutingConnectionKey(registered) + ? { unsupportedReason: previous.unsupportedReason } + : {}), + }; if ( previous !== undefined && gitHubRoutingConnectionKey(previous) !== gitHubRoutingConnectionKey(entry) @@ -454,12 +485,17 @@ export const make = Effect.gen(function* () { const installPlatformRegistration = Effect.fn("EnvironmentRegistry.installPlatformRegistration")( function* (registration: PlatformConnectionRegistration) { - const entry = connectionRegistrationCatalogEntry(registration); - const target = entry.target; + const registered = connectionRegistrationCatalogEntry(registration); + const target = registered.target; yield* withLeaseLock( target.environmentId, Effect.gen(function* () { const previous = (yield* SubscriptionRef.get(entries)).get(target.environmentId); + const entry: ConnectionCatalogEntry = + previous?.unsupportedReason !== undefined && + gitHubRoutingConnectionKey(previous) === gitHubRoutingConnectionKey(registered) + ? { ...registered, enabled: false, unsupportedReason: previous.unsupportedReason } + : registered; const persistedTarget = (yield* Ref.get(persistedTargetsByEnvironment)).get( target.environmentId, ); @@ -714,6 +750,12 @@ export const make = Effect.gen(function* () { environmentId, Effect.gen(function* () { const entry = yield* getEntry(environmentId); + if (enabled && entry.unsupportedReason !== undefined) { + return yield* new ConnectionBlockedError({ + reason: "unsupported", + detail: entry.unsupportedReason, + }); + } if (entry.enabled === enabled) { return; } @@ -794,6 +836,40 @@ export const make = Effect.gen(function* () { Effect.forkScoped, ); + const setCompatibility = Effect.fn("EnvironmentRegistry.setCompatibility")(function* ( + environmentId: EnvironmentId, + error: ConnectionBlockedError | null, + ) { + yield* withLeaseLock( + environmentId, + Effect.gen(function* () { + const entry = (yield* SubscriptionRef.get(entries)).get(environmentId); + if (entry === undefined || entry.unsupportedReason === (error?.message ?? undefined)) + return; + const { unsupportedReason: _previousReason, ...rest } = entry; + const next: ConnectionCatalogEntry = + error === null ? rest : { ...rest, enabled: false, unsupportedReason: error.message }; + if ( + error !== null && + entry.enabled && + !(yield* Ref.get(platformEnvironmentIds)).has(environmentId) + ) { + yield* registrations.setEnabled(environmentId, false); + } + const lease = (yield* SubscriptionRef.get(serviceScopes)).get(environmentId); + if (lease !== undefined) { + yield* SubscriptionRef.update(serviceScopes, (current) => + new Map(current).set(environmentId, { ...lease, entry: next }), + ); + if (error !== null) yield* lease.supervisor.disconnect; + } + yield* SubscriptionRef.update(entries, (current) => + new Map(current).set(environmentId, next), + ); + }), + ); + }); + return EnvironmentRegistry.of({ entries, networkStatus, @@ -805,6 +881,7 @@ export const make = Effect.gen(function* () { removeRelayEnvironments, retryNow, setEnabled, + setCompatibility, state, stateChanges, run, diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index ecc5d7153914..faad768811d9 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -1,4 +1,8 @@ -import { EnvironmentId, type DesktopSshEnvironmentTarget } from "@t3tools/contracts"; +import { + EnvironmentId, + ORCHESTRATION_PROTOCOL_VERSION, + type DesktopSshEnvironmentTarget, +} from "@t3tools/contracts"; import { RelayClientTracer } from "@t3tools/shared/relayTracing"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -28,6 +32,7 @@ import { type ConnectionTarget, } from "./model.ts"; import * as ConnectionProfileStore from "./profileStore.ts"; +import { remoteHttpClientLayer } from "../rpc/http.ts"; import { GitHubRoutingPermissions, gitHubRoutingConnectionKey, @@ -75,6 +80,7 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o readonly authorizeDpop?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeDpop"]; readonly primaryBearerToken?: string; readonly prepareSsh?: ClientCapabilities.SshEnvironmentGateway["Service"]["prepare"]; + readonly descriptorProtocolVersion?: number | null | undefined; }) => { const profiles = new Map( (options?.profiles ?? []).map((profile) => [profile.connectionId, profile]), @@ -140,6 +146,21 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o }); const dependencies = Layer.mergeAll( + remoteHttpClientLayer((() => + Promise.resolve( + Response.json({ + environmentId: ENVIRONMENT_ID, + label: "Compatible environment", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.0-test", + ...(options?.descriptorProtocolVersion === undefined + ? { orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION } + : options.descriptorProtocolVersion === null + ? {} + : { orchestrationProtocolVersion: options.descriptorProtocolVersion }), + capabilities: { repositoryIdentity: true }, + }), + )) satisfies typeof fetch), Layer.succeed( ConnectionProfileStore.ConnectionProfileStore, options?.profileStore ?? profileStore, @@ -166,6 +187,26 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o }); describe("ConnectionResolver", () => { + it.effect("blocks an incompatible host during discovery before opening orchestration RPC", () => + Effect.gen(function* () { + const brokerLayer = yield* makeDependencies({ + descriptorProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION + 1, + }); + const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); + const target = new PrimaryConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Primary", + httpBaseUrl: "http://127.0.0.1:3777", + wsBaseUrl: "ws://127.0.0.1:3777", + }); + + const error = yield* Effect.flip(broker.prepare(catalogEntry(target))); + + expect(error).toMatchObject({ reason: "unsupported" }); + expect(error.message).toContain("This client is not supported"); + }), + ); + it.effect("prepares a primary environment without remote capabilities", () => Effect.gen(function* () { const brokerLayer = yield* makeDependencies(); @@ -182,7 +223,7 @@ describe("ConnectionResolver", () => { label: "Primary", httpBaseUrl: "http://127.0.0.1:3777", socketUrl: - "ws://127.0.0.1:3777/ws?clientSurface=web&clientDeviceType=desktop&connectionMethod=direct", + "ws://127.0.0.1:3777/ws?clientSurface=web&clientDeviceType=desktop&connectionMethod=direct&orchestrationProtocol=1", httpAuthorization: null, target, }); @@ -220,7 +261,7 @@ describe("ConnectionResolver", () => { }); expect(yield* broker.prepare(catalogEntry(target))).toMatchObject({ - socketUrl: "ws://127.0.0.1:3777/ws?wsTicket=desktop", + socketUrl: "ws://127.0.0.1:3777/ws?wsTicket=desktop&orchestrationProtocol=1", httpAuthorization: { _tag: "Bearer", token: "desktop-bearer" }, target, }); @@ -284,7 +325,7 @@ describe("ConnectionResolver", () => { environmentId: ENVIRONMENT_ID, label: "Authorized relay environment", httpBaseUrl: ENDPOINT.httpBaseUrl, - socketUrl: "wss://authorized.example.test/ws?wsTicket=dpop", + socketUrl: `wss://authorized.example.test/ws?wsTicket=dpop&orchestrationProtocol=${ORCHESTRATION_PROTOCOL_VERSION}`, httpAuthorization: { _tag: "Dpop", accessToken: "dpop-access-token", diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts index f51c6ac607ba..af1a417fc594 100644 --- a/packages/client-runtime/src/connection/resolver.ts +++ b/packages/client-runtime/src/connection/resolver.ts @@ -2,6 +2,7 @@ import type { AuthClientPresentationMetadata } from "@t3tools/contracts"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -16,7 +17,12 @@ import { SshConnectionProfile, } from "./catalog.ts"; import * as ConnectionCredentialStore from "./credentialStore.ts"; -import { credentialMissingError, environmentMismatchError, profileMissingError } from "./errors.ts"; +import { + credentialMissingError, + environmentMismatchError, + mapRemoteEnvironmentError, + profileMissingError, +} from "./errors.ts"; import { GitHubRoutingPermissions, gitHubRoutingConnectionKey, @@ -31,6 +37,11 @@ import type { } from "./model.ts"; import { ConnectionBlockedError, type ConnectionAttemptError } from "./model.ts"; import * as ConnectionProfileStore from "./profileStore.ts"; +import { + appendOrchestrationProtocol, + orchestrationProtocolCompatibilityError, +} from "./compatibility.ts"; +import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; export class ConnectionResolver extends Context.Service< ConnectionResolver, @@ -234,6 +245,7 @@ export const make = Effect.gen(function* () { const bearer = yield* makeBearerBroker(); const relay = yield* makeRelayBroker(); const ssh = yield* makeSshBroker(); + const httpClient = yield* HttpClient.HttpClient; const prepare = Effect.fn("clientRuntime.connection.broker.prepare")(function* ( entry: ConnectionCatalogEntry, @@ -243,16 +255,35 @@ export const make = Effect.gen(function* () { "connection.environment.id": target.environmentId, "connection.target.kind": target._tag, }); - switch (target._tag) { - case "PrimaryConnectionTarget": - return yield* primary(target); - case "BearerConnectionTarget": - return yield* bearer({ ...entry, target }); - case "RelayConnectionTarget": - return yield* relay(target); - case "SshConnectionTarget": - return yield* ssh({ ...entry, target }); + const prepared = yield* (() => { + switch (target._tag) { + case "PrimaryConnectionTarget": + return primary(target); + case "BearerConnectionTarget": + return bearer({ ...entry, target }); + case "RelayConnectionTarget": + return relay(target); + case "SshConnectionTarget": + return ssh({ ...entry, target }); + } + })(); + const descriptor = yield* fetchRemoteEnvironmentDescriptor({ + httpBaseUrl: prepared.httpBaseUrl, + }).pipe( + Effect.mapError(mapRemoteEnvironmentError), + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + if (descriptor.environmentId !== target.environmentId) { + return yield* environmentMismatchError({ + expected: target.environmentId, + actual: descriptor.environmentId, + }); + } + const compatibilityError = orchestrationProtocolCompatibilityError(descriptor); + if (compatibilityError !== null) { + return yield* compatibilityError; } + return { ...prepared, socketUrl: appendOrchestrationProtocol(prepared.socketUrl) }; }); return ConnectionResolver.of({ prepare }); diff --git a/packages/client-runtime/src/state/presentation.ts b/packages/client-runtime/src/state/presentation.ts index d6fed0cf5ede..302be9f7cc13 100644 --- a/packages/client-runtime/src/state/presentation.ts +++ b/packages/client-runtime/src/state/presentation.ts @@ -41,7 +41,10 @@ export function createEnvironmentPresentationAtoms(input: { ); return { entry, - connection: presentEnvironmentConnection(state), + connection: + entry.unsupportedReason === undefined + ? presentEnvironmentConnection(state) + : { phase: "unsupported", error: entry.unsupportedReason, traceId: null }, serverConfig: get(input.serverConfigValueAtom(environmentId)), } satisfies EnvironmentPresentation; }).pipe(Atom.withLabel(`environment-presentation:${environmentId}`)), diff --git a/packages/client-runtime/src/state/pullRequestRouting.ts b/packages/client-runtime/src/state/pullRequestRouting.ts index d058f6a863a6..555c870d04ef 100644 --- a/packages/client-runtime/src/state/pullRequestRouting.ts +++ b/packages/client-runtime/src/state/pullRequestRouting.ts @@ -49,6 +49,17 @@ const writes = new Set([ ]); const isRef = Schema.is(PullRequestRef); const isInvalidation = Schema.is(PullRequestInvalidateInput); +const readTimeout = (environmentId: EnvironmentId) => + Effect.timeoutOrElse({ + duration: "30 seconds", + orElse: () => + Effect.fail( + new EnvironmentRpcUnavailableError({ + environmentId, + message: "The environment did not respond to the PR request.", + }), + ), + }); interface RoutedRead { origin: EnvironmentId; reference: PullRequestRef; @@ -318,7 +329,8 @@ export function createPullRequestRouter() { if (!(yield* routingAllowed(registry, origin.target.environmentId, id, writes.has(tag)))) return yield* visit(index + 1); } - return yield* run(id).pipe( + const operation = run(id); + return yield* (reads.has(tag) ? operation.pipe(readTimeout(id)) : operation).pipe( Effect.catch((error) => { if ( (reads.has(tag) || rejectedBeforeDispatch(error)) && @@ -366,12 +378,15 @@ export function createPullRequestRouter() { } if (!allowed) return yield* request(tag, input); const strictInput = { ...input, allowStale: false }; - const source = yield* Effect.cached(request(tag, strictInput)); - // Cached source reads usually finish before another environment can verify its account. - // Hedge slow reads only; never race mutations or retry an ambiguous write. - return yield* Effect.race( - source, - routedRequest(tag, strictInput, source).pipe(Effect.delay("75 millis")), + const source = yield* Effect.cached( + request(tag, strictInput).pipe(readTimeout(origin.target.environmentId)), + ); + const sourceEntry = entries.get(origin.target.environmentId); + const routed = routedRequest(tag, strictInput, source); + return yield* ( + sourceEntry !== undefined && isLocal(sourceEntry) + ? source.pipe(Effect.catch(() => routed)) + : routed ).pipe( Effect.catch((error) => input.allowStale !== false && diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts index 670575f6bbe1..5fcf5fdf681e 100644 --- a/packages/client-runtime/src/state/pullRequests.test.ts +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -7,6 +7,9 @@ import { } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Data from "effect/Data"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; import * as Effect from "effect/Effect"; import * as Latch from "effect/Latch"; import * as Layer from "effect/Layer"; @@ -52,7 +55,7 @@ for (const scenario of [ "prefers the local environment with the same github account", "falls back before mutation when the local account differs", "never retries an ambiguous mutation failure", - "returns a fast source read without checking alternate identities", + "returns a fast local source read without checking alternate identities", "keeps single-environment requests free of identity lookups", "keeps a local origin ahead of another local environment", "keeps mutations on an old origin server without retrying them", @@ -79,10 +82,12 @@ for (const scenario of [ switchedAccount; const ambiguous = scenario === "never retries an ambiguous mutation failure"; const reading = - scenario === "returns a fast source read without checking alternate identities"; + scenario === "returns a fast local source read without checking alternate identities"; const single = scenario === "keeps single-environment requests free of identity lookups"; const localOrigin = - scenario === "keeps a local origin ahead of another local environment" || switchedAccount; + scenario === "keeps a local origin ahead of another local environment" || + switchedAccount || + reading; const oldOrigin = scenario === "keeps mutations on an old origin server without retrying them"; const oldAlternate = @@ -557,15 +562,15 @@ for (const probe of ["origin", "alternate"] as const) { ); } -for (const source of ["pending", "pending-local", "failed", "offline"] as const) { - it.live( +for (const source of ["pending", "pending-local", "failed-local", "failed", "offline"] as const) { + it.effect( source === "offline" ? "returns held source data only after both fresh paths fail" - : `hedges a ${source} source read to local and interrupts the losing read`, + : `uses one shared reader with a ${source} source`, () => Effect.scoped( Effect.gen(function* () { - let interrupted = false; + const started = yield* Deferred.make(); const calls: string[] = []; const clientFor = (local: boolean) => ({ @@ -590,21 +595,16 @@ for (const source of ["pending", "pending-local", "failed", "offline"] as const) operation: "summary", detail: "github unreachable", }); - return yield* Effect.never.pipe( - Effect.onInterrupt(() => - Effect.sync(() => { - interrupted = true; - }), - ), - ); + yield* Deferred.succeed(started, undefined); + return yield* Effect.never; }), }) as unknown as WsRpcProtocolClient; const { environmentRegistry, supervisor } = yield* makeTestRuntime( clientFor(false), clientFor(true), - source === "pending-local", + source === "failed-local" || source === "pending-local", ); - const result = yield* createPullRequestRouter()(WS_METHODS.pullRequestsSummary, { + const request = createPullRequestRouter()(WS_METHODS.pullRequestsSummary, { projectId: ProjectId.make("project-1"), repository: "acme/web", number: 7, @@ -613,14 +613,23 @@ for (const source of ["pending", "pending-local", "failed", "offline"] as const) Effect.provideService(GitHubRoutingPermissions, trustedRouting), Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), ); + const fiber = yield* request.pipe(Effect.forkChild); + if (source === "pending-local") { + yield* Deferred.await(started); + yield* TestClock.adjust("30 seconds"); + } + const result = yield* Fiber.join(fiber); if (source === "offline") { expect(result).toEqual({ state: "open" }); - expect(calls).toEqual(["origin", "local", "held"]); + expect(calls).toEqual(["local", "origin", "held"]); } else { expect(result).toBeNull(); - expect(calls).toEqual(["origin", "local"]); + expect(calls).toEqual( + source === "failed-local" || source === "pending-local" + ? ["origin", "local"] + : ["local"], + ); } - expect(interrupted).toBe(source === "pending" || source === "pending-local"); }), ), ); diff --git a/packages/client-runtime/src/state/threads-atoms.test.ts b/packages/client-runtime/src/state/threads-atoms.test.ts index d65bb03c64f9..6fd61c8d1cfd 100644 --- a/packages/client-runtime/src/state/threads-atoms.test.ts +++ b/packages/client-runtime/src/state/threads-atoms.test.ts @@ -182,6 +182,7 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? removeRelayEnvironments: () => Effect.die("Unexpected environment removal"), retryNow: () => Effect.void, setEnabled: () => Effect.die("Unexpected environment toggle"), + setCompatibility: () => Effect.die("Unexpected compatibility update"), state: () => SubscriptionRef.get(supervisor.state), stateChanges: () => SubscriptionRef.changes(supervisor.state), run: (_environmentId, effect) => diff --git a/packages/client-runtime/src/state/threads-pagination.test.ts b/packages/client-runtime/src/state/threads-pagination.test.ts index 41e80666fd84..a583908e846c 100644 --- a/packages/client-runtime/src/state/threads-pagination.test.ts +++ b/packages/client-runtime/src/state/threads-pagination.test.ts @@ -398,6 +398,65 @@ describe("thread pagination state", () => { }), ); + it.effect("keeps a new page loading when a snapshot replaced a parked older page", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); + yield* harness.awaitState((value) => Option.isSome(value.page)); + requestOlderThreadTurns(TARGET.environmentId, THREAD_ID); + yield* harness.resolveNextPage( + Option.some({ + ...OLDER_PAGE, + snapshotSequence: 30, + page: { beforeCursor: null, hasMore: false, snapshotSequence: 30, threadSequence: 30 }, + }), + ); + yield* Queue.offer(harness.inputs, titleEvent("Waiting for old watermark", 11)); + yield* harness.awaitState((value) => + Option.exists(value.data, (thread) => thread.title === "Waiting for old watermark"), + ); + expect( + Option.getOrThrow((yield* SubscriptionRef.get(harness.threadState)).page).loadingOlder, + ).toBe(true); + + yield* Queue.offer(harness.inputs, { + kind: "snapshot", + snapshot: { + snapshotSequence: 20, + thread: { ...BASE_THREAD, title: "Replacement snapshot" }, + page: { beforeCursor: "cursor-2", hasMore: true, snapshotSequence: 20 }, + }, + }); + yield* harness.awaitState((value) => + Option.exists(value.data, (thread) => thread.title === "Replacement snapshot"), + ); + requestOlderThreadTurns(TARGET.environmentId, THREAD_ID); + yield* harness.awaitState((value) => + Option.exists(value.page, (page) => page.loadingOlder && page.beforeCursor === "cursor-2"), + ); + yield* Queue.offer(harness.inputs, titleEvent("New request still loading", 21)); + yield* harness.awaitState((value) => + Option.exists(value.data, (thread) => thread.title === "New request still loading"), + ); + const loading = yield* SubscriptionRef.get(harness.threadState); + expect(Option.getOrThrow(loading.page).loadingOlder).toBe(true); + expect(hasMessage(loading, "message-old")).toBe(false); + expect((yield* Ref.get(harness.loaderWindows)).map((window) => window?.beforeCursor)).toEqual( + [undefined, "cursor-1", "cursor-2"], + ); + + yield* harness.resolveNextPage( + Option.some({ + ...OLDER_PAGE, + snapshotSequence: 21, + page: { beforeCursor: null, hasMore: false, snapshotSequence: 21, threadSequence: 21 }, + }), + ); + const completed = yield* harness.awaitState((value) => hasMessage(value, "message-old")); + expect(Option.getOrThrow(completed.page).loadingOlder).toBe(false); + expect(Option.getOrThrow(completed.page).beforeCursor).toBeNull(); + }), + ); + it.effect("discards an older page read from a projection behind the loaded state", () => Effect.gen(function* () { const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index ef09c92aa8e9..c41651576dad 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -146,6 +146,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o const inputs = yield* Queue.unbounded(); const observed = yield* Queue.unbounded(); const latest = yield* Ref.make(EMPTY_ENVIRONMENT_THREAD_STATE); + const stateChangeCount = yield* Ref.make(0); const retryCount = yield* Ref.make(0); const subscriptionCount = yield* Ref.make(0); const loaderCalls = yield* Ref.make(0); @@ -157,11 +158,19 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o const supervisorState = yield* SubscriptionRef.make( AVAILABLE_CONNECTION_STATE, ); + // Preserve queued event batches while failing at the first error. const streamFrom = (queue: Queue.Queue) => Stream.fromQueue(queue).pipe( - Stream.mapEffect((input) => - input instanceof Error ? Effect.fail(input) : Effect.succeed(input), - ), + Stream.chunks, + Stream.flatMap((chunk) => { + const errorIndex = chunk.findIndex((input) => input instanceof Error); + if (errorIndex === -1) { + return Stream.fromArray(chunk as ReadonlyArray); + } + const prefix = chunk.slice(0, errorIndex) as ReadonlyArray; + const failure = Stream.fail(chunk[errorIndex] as Error); + return prefix.length === 0 ? failure : Stream.concat(Stream.fromArray(prefix), failure); + }), ); const client = { [ORCHESTRATION_WS_METHODS.subscribeThread]: (input: { @@ -244,7 +253,10 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o ); yield* SubscriptionRef.changes(threadState).pipe( Stream.runForEach((state) => - Ref.set(latest, state).pipe(Effect.andThen(Queue.offer(observed, state))), + Ref.update(stateChangeCount, (count) => count + 1).pipe( + Effect.andThen(Ref.set(latest, state)), + Effect.andThen(Queue.offer(observed, state)), + ), ), Effect.forkScoped, ); @@ -254,6 +266,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o inputs, observed, latest, + stateChangeCount, retryCount, subscriptionCount, loaderCalls, @@ -307,6 +320,38 @@ const titleUpdated = (title: string, sequence = 2): OrchestrationThreadStreamIte }, }); +const sessionSet = ( + status: "ready" | "running", + turnId: string, + sequence: number, +): OrchestrationThreadStreamItem => ({ + kind: "event", + event: { + eventId: EventId.make(`event-session-${status}-${sequence}`), + sequence, + occurredAt: "2026-04-01T03:00:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + aggregateKind: "thread", + aggregateId: THREAD_ID, + type: "thread.session-set", + payload: { + threadId: THREAD_ID, + session: { + threadId: THREAD_ID, + status, + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: status === "running" ? TurnId.make(turnId) : null, + lastError: null, + updatedAt: "2026-04-01T03:00:00.000Z", + }, + }, + }, +}); + const deleted = (): OrchestrationThreadStreamItem => ({ kind: "event", event: { @@ -989,4 +1034,36 @@ describe("EnvironmentThreads", () => { expect(yield* Ref.get(harness.subscriptionCount)).toBe(3); }), ); + + it.effect( + "persists a turn that settles mid-batch when the next turn starts in the same batch", + () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: ACTIVE_THREAD }); + yield* awaitThreadState(harness.observed, (value) => value.status === "live"); + const before = yield* Ref.get(harness.stateChangeCount); + + // Both events arrive in one transport batch: the session settles and the + // next turn starts before the fold publishes. + yield* Queue.offerAll(harness.inputs, [ + sessionSet("ready", "turn-1", CACHED_SNAPSHOT_SEQUENCE + 1), + sessionSet("running", "turn-2", CACHED_SNAPSHOT_SEQUENCE + 2), + ]); + yield* awaitThreadState( + harness.observed, + (value) => + Option.isSome(value.data) && + value.data.value.session?.activeTurnId === TurnId.make("turn-2"), + ); + expect((yield* Ref.get(harness.stateChangeCount)) - before).toBe(1); + yield* TestClock.adjust("500 millis"); + yield* Effect.yieldNow; + + // The settled state reached the cache under its own sequence even + // though the batch ended on a running session. + const saved = (yield* Ref.get(harness.savedThreads)).at(-1); + expect(saved?.thread.session?.status).toBe("ready"); + expect(saved?.snapshotSequence).toBe(CACHED_SNAPSHOT_SEQUENCE + 1); + }), + ); }); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 0a44302e41ec..ab723cef4857 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -353,6 +353,29 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ), ); + const offerThreadPersistence = Effect.fn("EnvironmentThreadState.offerThreadPersistence")( + function* (thread: OrchestrationThread, snapshotSequence: number) { + const currentPage = yield* SubscriptionRef.get(state).pipe(Effect.map((value) => value.page)); + yield* Queue.offer(persistence, { + snapshotSequence, + thread, + // Persist the window boundary with the window's content so a cache + // restore can keep paging from where the loaded history ends. + ...Option.match(currentPage, { + onNone: () => ({}), + onSome: (value) => + ({ + page: { + beforeCursor: value.beforeCursor, + hasMore: value.hasMore, + snapshotSequence, + }, + }) as const, + }), + }); + }, + ); + const setThread = Effect.fn("EnvironmentThreadState.setThread")(function* ( thread: OrchestrationThread, // "keep" preserves the current page state (live events touch only loaded @@ -376,24 +399,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make // persist once it settles so cache encoding stays off the streaming path. if (shouldPersistThread(thread)) { const snapshotSequence = yield* SubscriptionRef.get(lastSequence); - const currentPage = yield* SubscriptionRef.get(state).pipe(Effect.map((value) => value.page)); - yield* Queue.offer(persistence, { - snapshotSequence, - thread, - // Persist the window boundary with the window's content so a cache - // restore can keep paging from where the loaded history ends. - ...Option.match(currentPage, { - onNone: () => ({}), - onSome: (value) => - ({ - page: { - beforeCursor: value.beforeCursor, - hasMore: value.hasMore, - snapshotSequence, - }, - }) as const, - }), - }); + yield* offerThreadPersistence(thread, snapshotSequence); } }); @@ -441,6 +447,9 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make // in the preserved history with no event left to remove it. The // epoch bump discards any older-page fetch racing this snapshot. yield* Ref.update(historyEpoch, (epoch) => epoch + 1); + // A parked response must not clear loadingOlder on a request started + // from the replacement snapshot's cursor. + yield* Ref.set(pendingOlderPage, null); yield* SubscriptionRef.set(lastSequence, item.snapshot.snapshotSequence); yield* setThread(item.snapshot.thread, pageStateFromSnapshot(item.snapshot.page)); return; @@ -539,17 +548,26 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make let thread = current.data.value; let sequence = yield* SubscriptionRef.get(lastSequence); let synchronized = false; + // Retain the last settled state even if the next turn starts before + // this batch publishes. Its cursor must describe that settled content. + let persistable: { thread: OrchestrationThread; sequence: number } | undefined; for (const item of items) { if (item.kind === "synchronized") { synchronized = true; } else if (item.kind === "event" && item.event.sequence > sequence) { sequence = item.event.sequence; const result = applyThreadDetailEvent(thread, item.event); - if (result.kind === "updated") thread = result.thread; + if (result.kind === "updated") { + thread = result.thread; + if (shouldPersistThread(thread)) persistable = { thread, sequence }; + } } } yield* SubscriptionRef.set(lastSequence, sequence); if (thread !== current.data.value) yield* setThread(thread, "keep"); + if (persistable !== undefined && !shouldPersistThread(thread)) { + yield* offerThreadPersistence(persistable.thread, persistable.sequence); + } if (synchronized) yield* applyItemLocked({ kind: "synchronized" }); yield* remember; }), diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts index 29f029c9d863..98eb46e13ca8 100644 --- a/packages/client-runtime/src/state/usage.test.ts +++ b/packages/client-runtime/src/state/usage.test.ts @@ -6,11 +6,11 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; -import { afterEach, describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import type { EnvironmentPresentation } from "../connection/presentation.ts"; import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; -import { refreshUsage } from "./usage.ts"; +import { refreshUsage, refreshUsageLimits } from "./usage.ts"; const input = { sinceDay: UsageDay.make("2026-09-05"), @@ -182,3 +182,46 @@ describe("manual usage refresh", () => { unmount(); }); }); + +describe("limits refresh cooldown", () => { + it("joins manual calls and gates automatic refreshes after success or failure", async () => { + const clock = vi.spyOn(Date, "now").mockReturnValue(1_000); + try { + for (const fails of [false, true]) { + const id = EnvironmentId.make(`limits-${fails}`); + const pending = Promise.withResolvers(); + const refresh = vi.fn(() => pending.promise); + const first = refreshUsageLimits(id, refresh, true); + await refreshUsageLimits(id, refresh, true); + const manual = refreshUsageLimits(id, refresh); + const settled = vi.fn(); + void manual.then(settled, settled); + expect(settled).not.toHaveBeenCalled(); + expect(refresh).toHaveBeenCalledTimes(1); + if (fails) { + const firstFailure = expect(first).rejects.toThrow("unavailable"); + const manualFailure = expect(manual).rejects.toThrow("unavailable"); + pending.reject(new Error("unavailable")); + await Promise.all([firstFailure, manualFailure]); + } else { + pending.resolve("quota"); + expect(await first).toBe("quota"); + expect(await manual).toBe("quota"); + } + expect(settled).toHaveBeenCalledTimes(1); + const next = vi.fn(async () => undefined); + clock.mockReturnValue(300_999); + await refreshUsageLimits(id, next, true); + expect(next).not.toHaveBeenCalled(); + clock.mockReturnValue(301_000); + await refreshUsageLimits(id, next, true); + expect(next).toHaveBeenCalledTimes(1); + await refreshUsageLimits(id, next); + expect(next).toHaveBeenCalledTimes(2); + clock.mockReturnValue(1_000); + } + } finally { + clock.mockRestore(); + } + }); +}); diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index 10a565a0c24f..8a2b1a44a951 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -9,6 +9,33 @@ import type { createServerEnvironmentAtoms } from "./server.ts"; const isEnvironmentRpcUnavailable = Schema.is(EnvironmentRpcUnavailableError); +const limitsRefreshAfter = new Map(); +const limitsRefreshes = new Map>(); + +export async function refreshUsageLimits( + environmentId: EnvironmentId, + refresh: () => Promise, + automatic = false, +): Promise { + const pending = limitsRefreshes.get(environmentId); + if (pending !== undefined) { + // Manual refresh waits for the current check; automatic refresh does not repeat it. + return automatic ? undefined : ((await pending) as A); + } + const refreshAfter = limitsRefreshAfter.get(environmentId) ?? 0; + // @effect-diagnostics-next-line globalDate:off + if (automatic && Date.now() < refreshAfter) return; + const current = Promise.resolve() + .then(refresh) + .finally(() => { + limitsRefreshes.delete(environmentId); + // @effect-diagnostics-next-line globalDate:off + limitsRefreshAfter.set(environmentId, Date.now() + 5 * 60_000); + }); + limitsRefreshes.set(environmentId, current); + return await current; +} + /** Refresh pricing, then await each selected environment's rescan while it remains connected. */ export async function refreshUsage({ registry, diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index c8b8833ead86..6d5690187104 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -9,6 +9,10 @@ import { TrimmedNonEmptyString, } from "./baseSchemas.ts"; +/** Wire version for orchestration snapshots, streams, commands, and RPC payloads. */ +export const ORCHESTRATION_PROTOCOL_VERSION = 1; +export const ORCHESTRATION_PROTOCOL_QUERY_PARAM = "orchestrationProtocol"; + export const ExecutionEnvironmentPlatformOs = Schema.Literals([ "darwin", "linux", @@ -175,6 +179,8 @@ export const ExecutionEnvironmentDescriptor = Schema.Struct({ label: TrimmedNonEmptyString, platform: ExecutionEnvironmentPlatform, serverVersion: TrimmedNonEmptyString, + /** Missing metadata denotes protocol 1. Bump this for breaking wire changes. */ + orchestrationProtocolVersion: Schema.optionalKey(Schema.Int), capabilities: ExecutionEnvironmentCapabilities, }); export type ExecutionEnvironmentDescriptor = typeof ExecutionEnvironmentDescriptor.Type; diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index a285d2fcf4ac..1256dcc77786 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -36,6 +36,7 @@ export type ModelPickerJumpKeybindingCommand = const THREAD_KEYBINDING_COMMANDS = [ "thread.stop", + "thread.steerQueuedMessage", "thread.previous", "thread.next", "thread.copyReference", diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 5228e296d68f..1f605ecbd383 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -5,6 +5,7 @@ import * as Schema from "effect/Schema"; import { CommandId, ProjectId, ThreadId } from "./baseSchemas.ts"; import { + ProjectIconOverride, DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment, @@ -17,9 +18,8 @@ import { OrchestrationGetTurnDiffInput, OrchestrationLatestTurn, ProjectCreatedPayload, - OrchestrationProjectShell, - ProjectIconColor, ProjectMetaUpdatedPayload, + OrchestrationProjectShell, OrchestrationProposedPlan, OrchestrationSession, OrchestrationThread, @@ -42,18 +42,6 @@ import { ProviderInstanceId } from "./providerInstance.ts"; const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput); const decodeFullThreadDiffInput = Schema.decodeUnknownEffect(OrchestrationGetFullThreadDiffInput); const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff); -// The icon shape understood by clients released before monograms. -const legacyProjectIcon = Schema.Union([ - Schema.Struct({ kind: Schema.Literal("lucide"), name: Schema.String, color: ProjectIconColor }), - Schema.Struct({ kind: Schema.Literal("emoji"), emoji: Schema.String }), -]); -const decodeLegacyProjectShell = Schema.decodeUnknownEffect( - Schema.Struct({ - ...OrchestrationProjectShell.fields, - projectIcon: Schema.optional(Schema.NullOr(legacyProjectIcon)), - }), -); -const encodeProjectShell = Schema.encodeEffect(OrchestrationProjectShell); const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand); const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPayload); const decodeProjectMetaUpdatedPayload = Schema.decodeUnknownEffect(ProjectMetaUpdatedPayload); @@ -1508,31 +1496,13 @@ it.effect("project icon overrides accept Lucide icons, colors, and emoji", () => }), ); -it.effect("older clients decode monogram projects as their fallback icon", () => - Effect.gen(function* () { - const encoded = yield* encodeProjectShell({ - id: ProjectId.make("project-monogram"), - title: "Monogram", - workspaceRoot: "/tmp/monogram", - defaultModelSelection: null, - scripts: [], - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - projectIcon: { kind: "lucide", name: "folder-code", color: "violet", monogram: "T3" }, - }); - const decoded = yield* decodeLegacyProjectShell(encoded); - assert.deepEqual(decoded.projectIcon, { kind: "lucide", name: "folder-code", color: "violet" }); - }), -); - it.effect("project monograms validate text and palette colors", () => Effect.gen(function* () { for (const text of ["A", "T3", "É", "文書", "कि", "किखि", "e\u0301"]) { const projectIcon = { - kind: "lucide", - name: "folder-code", + kind: "monogram", color: "violet", - monogram: text, + text, } as const; const command = yield* decodeOrchestrationCommand({ type: "project.meta.update", @@ -1542,16 +1512,14 @@ it.effect("project monograms validate text and palette colors", () => }); assert.strictEqual(command.type, "project.meta.update"); if (command.type === "project.meta.update") - assert.deepEqual(command.projectIcon, projectIcon); + assert.deepEqual(command.projectIcon, { kind: "monogram", text, color: "violet" }); } for (const projectIcon of [ - { kind: "lucide", name: "folder-code", monogram: "", color: "blue" }, - { kind: "lucide", name: "folder-code", monogram: "ABC", color: "blue" }, - { kind: "lucide", name: "folder-code", monogram: "किखिगि", color: "blue" }, - { kind: "lucide", name: "folder-code", monogram: "\u0301", color: "blue" }, - { kind: "lucide", name: "folder-code", monogram: "A B", color: "blue" }, - { kind: "lucide", name: "folder-code", monogram: "🚀", color: "blue" }, - { kind: "lucide", name: "folder-code", monogram: "T3", color: "ultraviolet" }, + { kind: "monogram", text: "", color: "blue" }, + { kind: "monogram", text: "\u0301", color: "blue" }, + { kind: "monogram", text: "A B", color: "blue" }, + { kind: "monogram", text: "🚀", color: "blue" }, + { kind: "monogram", text: "T3", color: "ultraviolet" }, ]) { const result = yield* Effect.exit( decodeOrchestrationCommand({ @@ -1586,3 +1554,92 @@ it("isProviderSendTurnSupportedImageMimeType accepts raster formats and rejects assert.strictEqual(isProviderSendTurnSupportedImageMimeType("IMAGE/JPEG"), true); assert.strictEqual(isProviderSendTurnSupportedImageMimeType("image/svg+xml"), false); }); + +const decodeProjectIcon = Schema.decodeUnknownEffect(ProjectIconOverride); +const encodeProjectIcon = Schema.encodeEffect(ProjectIconOverride); + +// Pre-monogram clients reject unknown variants; nightly clients additionally validate monogram. +const decodeOldIcon = Schema.decodeUnknownEffect( + Schema.Union([ + Schema.Struct({ kind: Schema.Literal("lucide"), name: Schema.String, color: Schema.String }), + Schema.Struct({ kind: Schema.Literal("emoji"), emoji: Schema.String }), + ]), +); +const decodeNightlyIcon = Schema.decodeUnknownEffect( + Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("lucide"), + name: Schema.String, + color: Schema.String, + // Fail if this field is ever sent; old validators must never see the new text. + monogram: Schema.optional(Schema.Never), + }), + Schema.Struct({ kind: Schema.Literal("emoji"), emoji: Schema.String }), + ]), +); + +it.effect("sends monograms as fallback icons that old and nightly clients can decode", () => + Effect.gen(function* () { + const fallback = { kind: "lucide", name: "folder-code", color: "violet" } as const; + for (const text of ["T3", "क्ष्म", "e\u0301"]) { + const monogram = { kind: "monogram", text, color: "violet" } as const; + const wire = yield* encodeProjectIcon(monogram); + assert.deepEqual(wire, { ...fallback, monogramText: text }); + assert.deepEqual(yield* decodeOldIcon(wire), fallback); + assert.deepEqual(yield* decodeNightlyIcon(wire), fallback); + assert.deepEqual(yield* decodeProjectIcon(wire), monogram); + assert.deepEqual(yield* decodeProjectIcon(monogram), monogram); + assert.deepEqual(yield* decodeProjectIcon({ ...fallback, monogram: text }), monogram); + } + for (const icon of [ + { kind: "lucide", name: "alarm-clock", color: "blue" }, + { kind: "emoji", emoji: "🚀" }, + ] as const) { + assert.deepEqual(yield* decodeProjectIcon(icon), icon); + assert.deepEqual(yield* encodeProjectIcon(icon), icon); + } + }), +); + +const encodeProjectShell = Schema.encodeEffect(OrchestrationProjectShell); +const encodeClientCommand = Schema.encodeEffect(ClientOrchestrationCommand); +const decodeLegacyShell = Schema.decodeUnknownEffect( + Schema.Struct({ + ...OrchestrationProjectShell.fields, + projectIcon: Schema.optional( + Schema.NullOr( + Schema.Struct({ + kind: Schema.Literal("lucide"), + name: Schema.String, + color: Schema.String, + }), + ), + ), + }), +); + +it.effect("encodes compatible icons inside snapshots and client commands", () => + Effect.gen(function* () { + const projectIcon = { kind: "monogram", text: "क्ष्म", color: "violet" } as const; + const shell = yield* encodeProjectShell({ + id: ProjectId.make("monogram"), + title: "Monogram", + workspaceRoot: "/tmp/monogram", + defaultModelSelection: null, + scripts: [], + projectIcon, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + const fallback = { kind: "lucide", name: "folder-code", color: "violet" } as const; + assert.deepEqual((yield* decodeLegacyShell(shell)).projectIcon, fallback); + const command = yield* encodeClientCommand({ + type: "project.meta.update", + projectId: ProjectId.make("monogram"), + commandId: CommandId.make("monogram"), + projectIcon, + }); + if (command.type !== "project.meta.update") throw new Error("Unexpected command"); + assert.deepEqual(yield* decodeNightlyIcon(command.projectIcon), fallback); + }), +); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index dc8a0732198c..aade13375ce3 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -459,26 +459,62 @@ const ProjectLucideIconName = TrimmedNonEmptyString.check( const ProjectEmoji = TrimmedNonEmptyString.check(Schema.isMaxLength(32)); -const monogramSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); +// Grapheme-count validation belongs to the server command boundary, not snapshot decoding. export const ProjectMonogramText = TrimmedNonEmptyString.check( Schema.isMaxLength(32), Schema.isPattern(/^[\p{L}\p{N}][\p{L}\p{N}\p{M}\u200c\u200d]*$/u), - Schema.makeFilter((text) => Array.from(monogramSegmenter.segment(text)).length <= 2), ); +const ProjectLucideIcon = Schema.Struct({ + kind: Schema.Literal("lucide"), + name: ProjectLucideIconName, + color: ProjectIconColor, +}); +const ProjectEmojiIcon = Schema.Struct({ + kind: Schema.Literal("emoji"), + emoji: ProjectEmoji, +}); +const ProjectMonogramIcon = Schema.Struct({ + kind: Schema.Literal("monogram"), + text: ProjectMonogramText, + color: ProjectIconColor, +}); +const ProjectIcon = Schema.Union([ProjectLucideIcon, ProjectEmojiIcon, ProjectMonogramIcon]); +const ProjectLucideIconWire = Schema.Struct({ + ...ProjectLucideIcon.fields, + monogramText: Schema.optional(ProjectMonogramText), + monogram: Schema.optional(ProjectMonogramText), +}); + +// Older peers only know lucide/emoji. Keep monograms out of their validated +// `monogram` field too: old grapheme counters can reject otherwise valid text. export const ProjectIconOverride = Schema.Union([ - Schema.Struct({ - kind: Schema.Literal("lucide"), - name: ProjectLucideIconName, - color: ProjectIconColor, - // Older clients ignore this field and render the named Lucide icon instead. - monogram: Schema.optional(ProjectMonogramText), - }), - Schema.Struct({ - kind: Schema.Literal("emoji"), - emoji: ProjectEmoji, - }), -]); + ProjectLucideIconWire, + ProjectEmojiIcon, + ProjectMonogramIcon, +]).pipe( + Schema.decodeTo( + ProjectIcon, + SchemaTransformation.transform({ + decode: (icon): typeof ProjectIcon.Type => { + if (icon.kind !== "lucide") return icon; + const text = icon.monogramText ?? icon.monogram; + return text === undefined + ? { kind: "lucide", name: icon.name, color: icon.color } + : { kind: "monogram", text, color: icon.color }; + }, + encode: (icon) => + icon.kind === "monogram" + ? { + kind: "lucide" as const, + name: "folder-code", + color: icon.color, + monogramText: icon.text, + } + : icon, + }), + ), +); export type ProjectIconOverride = typeof ProjectIconOverride.Type; export const OrchestrationProject = Schema.Struct({ diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index b090b47fba5b..ba612edaf26b 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -529,6 +529,19 @@ describe("ClientSettings context window meter", () => { }); }); +describe("ClientSettings follow-up behavior", () => { + it("defaults to queue and accepts either behavior", () => { + expect(decodeClientSettings({}).followUpBehavior).toBe("queue"); + for (const followUpBehavior of ["queue", "steer"]) { + expect(decodeClientSettings({ followUpBehavior }).followUpBehavior).toBe(followUpBehavior); + expect(decodeClientSettingsPatch({ followUpBehavior }).followUpBehavior).toBe( + followUpBehavior, + ); + } + expect(() => decodeClientSettingsPatch({ followUpBehavior: "invalid" })).toThrow(); + }); +}); + describe("ClientSettings composer collapse", () => { it("collapses on scroll by default and accepts opting out", () => { expect(decodeClientSettings({}).composerCollapseOnScroll).toBe(true); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7b3715d704be..1c53b36037ba 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -430,6 +430,9 @@ export const ClientSettingsSchema = Schema.Struct({ // Desktop resting composer: scrolling an existing thread's conversation // settles the composer into its single-line layout. Losing focus never does. composerCollapseOnScroll: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + followUpBehavior: Schema.Literals(["queue", "steer"]).pipe( + Schema.withDecodingDefault(Effect.succeed("queue")), + ), proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), // Legacy sidebar (the original per-project tree). Deliberately a fresh key @@ -1499,6 +1502,7 @@ export const ClientSettingsPatch = Schema.Struct({ planModeEnabled: Schema.optionalKey(Schema.Boolean), contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean), composerCollapseOnScroll: Schema.optionalKey(Schema.Boolean), + followUpBehavior: Schema.optionalKey(Schema.Literals(["queue", "steer"])), proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean), showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 8d73c07f34ab..0cc18680e6d5 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -40,6 +40,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+f", command: "projectSearch.toggle", when: "!terminalFocus" }, { key: "mod+alt+shift+t", command: "themeEditor.toggle" }, { key: "mod+s", command: "composer.stash", when: "!terminalFocus" }, + { key: "mod+shift+enter", command: "thread.steerQueuedMessage", when: "!terminalFocus" }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, diff --git a/patches/@clerk__expo@4.6.6.patch b/patches/@clerk__expo@4.6.8.patch similarity index 100% rename from patches/@clerk__expo@4.6.6.patch rename to patches/@clerk__expo@4.6.8.patch diff --git a/patches/react-native-reanimated@4.5.1.patch b/patches/react-native-reanimated@4.5.5.patch similarity index 84% rename from patches/react-native-reanimated@4.5.1.patch rename to patches/react-native-reanimated@4.5.5.patch index 6bf44826d4fd..c0cc66e40789 100644 --- a/patches/react-native-reanimated@4.5.1.patch +++ b/patches/react-native-reanimated@4.5.5.patch @@ -1,5 +1,5 @@ diff --git a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h -index 86035915aa330d2011e7c3027ae315c689e40f58..3d43e949550e1bc1311c38d11a7920c237a41018 100644 +index 20d042b00e4655a6647eff1a9a0890fae7e45175..fd08d36fd6c1f79adcfa0dc675420e42a9815d25 100644 --- a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h +++ b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h @@ -22,6 +22,7 @@ struct LayoutAnimation { @@ -11,10 +11,10 @@ index 86035915aa330d2011e7c3027ae315c689e40f58..3d43e949550e1bc1311c38d11a7920c2 LayoutAnimation &operator=(const LayoutAnimation &other) = default; diff --git a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp -index bc62f8fc31a1d3b08353df514bf4f954c6279ceb..0c16264538a13ae8b7bd4fd11e1227d8580c5c65 100644 +index 0f08622518910486bb724b664e0a5dd692470b18..9f71dbc301e77656ab0c5dc453ade03eea7649f8 100644 --- a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp +++ b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp -@@ -290,6 +290,7 @@ std::optional LayoutAnimationsProxy_Experimental::endLayoutAnimation( +@@ -353,6 +353,7 @@ std::optional LayoutAnimationsProxy_Experimental::endLayoutAnimation( if (--layoutAnimation.count > 0) { return {}; } @@ -22,7 +22,7 @@ index bc62f8fc31a1d3b08353df514bf4f954c6279ceb..0c16264538a13ae8b7bd4fd11e1227d8 maybeSettledAnimationTags_.insert(tag); auto surfaceId = layoutAnimation.finalView.surfaceId; -@@ -407,7 +408,8 @@ void LayoutAnimationsProxy_Experimental::addOngoingAnimations(SurfaceId surfaceI +@@ -478,7 +479,8 @@ void LayoutAnimationsProxy_Experimental::addOngoingAnimations(SurfaceId surfaceI const auto layoutAnimationIt = layoutAnimations_.find(tag); @@ -32,7 +32,7 @@ index bc62f8fc31a1d3b08353df514bf4f954c6279ceb..0c16264538a13ae8b7bd4fd11e1227d8 continue; } -@@ -554,6 +556,8 @@ void LayoutAnimationsProxy_Experimental::maybeCancelAnimation(const int tag) con +@@ -635,6 +637,8 @@ void LayoutAnimationsProxy_Experimental::maybeCancelAnimation(const int tag) con } if (layoutAnimationIt->second.isSettled()) { // Already settled - cleanupAnimations will erase it together with its updateMap entry. @@ -42,10 +42,10 @@ index bc62f8fc31a1d3b08353df514bf4f954c6279ceb..0c16264538a13ae8b7bd4fd11e1227d8 } layoutAnimations_.erase(layoutAnimationIt); diff --git a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp -index 9402e3e5ba4859ed344901b8b5884b9f708dd82b..2b1e4294ef798b7b6aabd04cc6663aae8297d034 100644 +index f9711f4eb6185ea99e08280ff90edd45e4152e12..ca812666ca54ec55d9d4409910b21ae97c721f2f 100644 --- a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp +++ b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp -@@ -119,6 +119,7 @@ std::optional LayoutAnimationsProxy_Legacy::endLayoutAnimation(int ta +@@ -228,6 +228,7 @@ std::optional LayoutAnimationsProxy_Legacy::endLayoutAnimation(int ta if (--layoutAnimation.count > 0) { return {}; } @@ -53,7 +53,7 @@ index 9402e3e5ba4859ed344901b8b5884b9f708dd82b..2b1e4294ef798b7b6aabd04cc6663aae maybeSettledAnimationTags_.insert(tag); auto surfaceId = layoutAnimation.finalView.surfaceId; -@@ -414,12 +415,7 @@ void LayoutAnimationsProxy_Legacy::addOngoingAnimations(SurfaceId surfaceId, Sha +@@ -531,12 +532,7 @@ void LayoutAnimationsProxy_Legacy::addOngoingAnimations(SurfaceId surfaceId, Sha auto layoutAnimationIt = layoutAnimations_.find(tag); if (layoutAnimationIt == layoutAnimations_.end() || diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70ec9fe58a12..e970b9ee61b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,19 +45,19 @@ overrides: '@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-linux-x64-musl': '-' '@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-win32-arm64': '-' '@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-win32-x64': '-' - '@clerk/backend': 3.17.2 - '@clerk/clerk-js': 6.31.1 + '@clerk/backend': 3.18.1 + '@clerk/clerk-js': 6.32.1 '@clerk/clerk-js>@base-org/account': '-' '@clerk/clerk-js>@coinbase/wallet-sdk': '-' '@clerk/clerk-js>@solana/wallet-adapter-base': '-' '@clerk/clerk-js>@solana/wallet-adapter-react': '-' '@clerk/clerk-js>@solana/wallet-standard': '-' '@clerk/clerk-js>@wallet-standard/core': '-' - '@clerk/electron': 0.0.42 + '@clerk/electron': 0.0.44 '@clerk/electron-passkeys': 0.0.3 - '@clerk/expo': 4.6.6 - '@clerk/react': 6.15.2 - '@clerk/shared': 4.31.1 + '@clerk/expo': 4.6.8 + '@clerk/react': 6.16.1 + '@clerk/shared': 4.33.0 '@effect/atom-react': 4.0.0-rc.112 '@effect/platform-node': 4.0.0-rc.112 '@effect/platform-node-shared': 4.0.0-rc.112 @@ -85,7 +85,7 @@ overrides: packageExtensionsChecksum: sha256-k/dT9NFDl5hihRPaoFKeY11hzyutMFs5psfZLFiKJic= patchedDependencies: - '@clerk/expo@4.6.6': a82bb41039ee88a290a87d4ab58be7be4c49fa338603b17ea646e1ec140a1cfb + '@clerk/expo@4.6.8': a82bb41039ee88a290a87d4ab58be7be4c49fa338603b17ea646e1ec140a1cfb '@effect/vitest@4.0.0-rc.112': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2 '@ff-labs/fff-node@0.9.4': c4e3cc2420ceb9dc650f9d189e9c24baf7e83f342998bacda5f459a4ce7927a8 @@ -102,7 +102,7 @@ patchedDependencies: react-native-gesture-handler@2.32.0: 0579f8e4dad02bf3183d95b02620358412983c36f9bda7425dc8bcb9643b5ce2 react-native-keyboard-controller@1.21.13: 6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 - react-native-reanimated@4.5.1: a23baea5d82cbf1254720110aef6671e39d57a425f8be446756f91ab806eb72c + react-native-reanimated@4.5.5: bae9878a5bdba94e11c890e5ee164542feb622624fc81c13890063094608216d react-native-screens@4.26.2: 8156dd0f3407822404793cfdaa95639a36b62102f4507c981b8be83600bb382d uniwind@1.11.0: 17d92be2eec71bb6396b402e8d034968e54b28746876d7977cb3139655f42b90 @@ -129,8 +129,8 @@ importers: apps/desktop: dependencies: '@clerk/electron': - specifier: 0.0.42 - version: 0.0.42(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 0.0.44 + version: 0.0.44(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/electron-passkeys': specifier: 0.0.3 version: 0.0.3 @@ -230,8 +230,8 @@ importers: apps/mobile: dependencies: '@clerk/expo': - specifier: 4.6.6 - version: 4.6.6(patch_hash=a82bb41039ee88a290a87d4ab58be7be4c49fa338603b17ea646e1ec140a1cfb)(dbc31631339ce74330e188d5d7a88158) + specifier: 4.6.8 + version: 4.6.8(patch_hash=a82bb41039ee88a290a87d4ab58be7be4c49fa338603b17ea646e1ec140a1cfb)(dbc31631339ce74330e188d5d7a88158) '@effect/atom-react': specifier: 4.0.0-rc.112 version: 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(react@19.2.3)(scheduler@0.27.0) @@ -243,7 +243,7 @@ importers: version: 57.0.14(@expo/log-box@57.0.4)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/ui': specifier: ~57.0.14 - version: 57.0.14(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 57.0.14(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.11.4(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@legendapp/list': specifier: 'catalog:' version: 3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -315,7 +315,7 @@ importers: version: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) expo: specifier: ~57.0.18 - version: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + version: 57.0.18(41fd11498a34454c91128cdad32f22f8) expo-asset: specifier: ~57.0.15 version: 57.0.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@7.0.2) @@ -414,7 +414,7 @@ importers: version: 57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-widgets: specifier: 57.0.15 - version: 57.0.15(patch_hash=319a9ded5db49c5b5215c511a138b33f44c7ea2972eb418192e8d5342fe75ce6)(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 57.0.15(patch_hash=319a9ded5db49c5b5215c511a138b33f44c7ea2972eb418192e8d5342fe75ce6)(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.11.4(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react: specifier: 19.2.3 version: 19.2.3 @@ -432,7 +432,7 @@ importers: version: 0.2.2(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-keyboard-controller: specifier: 1.21.13 - version: 1.21.13(patch_hash=6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787)(react-native-reanimated@4.5.1(patch_hash=a23baea5d82cbf1254720110aef6671e39d57a425f8be446756f91ab806eb72c)(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 1.21.13(patch_hash=6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787)(react-native-reanimated@4.5.5(patch_hash=bae9878a5bdba94e11c890e5ee164542feb622624fc81c13890063094608216d)(react-native-worklets@0.11.4(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-nitro-markdown: specifier: ^0.5.0 version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -440,8 +440,8 @@ importers: specifier: 0.35.9 version: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-reanimated: - specifier: 4.5.1 - version: 4.5.1(patch_hash=a23baea5d82cbf1254720110aef6671e39d57a425f8be446756f91ab806eb72c)(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + specifier: 4.5.5 + version: 4.5.5(patch_hash=bae9878a5bdba94e11c890e5ee164542feb622624fc81c13890063094608216d)(react-native-worklets@0.11.4(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-safe-area-context: specifier: ~5.7.0 version: 5.7.0(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -458,8 +458,8 @@ importers: specifier: ^13.16.1 version: 13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-worklets: - specifier: 0.10.1 - version: 0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + specifier: 0.11.4 + version: 0.11.4(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) shiki: specifier: 4.2.0 version: 4.2.0 @@ -571,11 +571,11 @@ importers: specifier: ^1.4.1 version: 1.5.0(@date-fns/tz@1.5.0)(@types/react@19.2.16)(date-fns@4.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/electron': - specifier: 0.0.42 - version: 0.0.42(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 0.0.44 + version: 0.0.44(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/react': - specifier: 6.15.2 - version: 6.15.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 6.16.1 + version: 6.16.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@daypicker/react': specifier: ^10.0.1 version: 10.0.1(@types/react@19.2.16)(react@19.2.6) @@ -755,8 +755,8 @@ importers: infra/relay: dependencies: '@clerk/backend': - specifier: 3.17.2 - version: 3.17.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 3.18.1 + version: 3.18.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@effect/sql-pg': specifier: 4.0.0-rc.112 version: 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) @@ -1801,12 +1801,12 @@ packages: resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} engines: {node: '>= 20.12.0'} - '@clerk/backend@3.17.2': - resolution: {integrity: sha512-pQz/+ClFBcL4OijAX3gDcXQYNqr1JbabAqY8szuU8/Dcvuuk3WgUmQDGyIU0tIqmitdd17RLppDmhN088pADew==} + '@clerk/backend@3.18.1': + resolution: {integrity: sha512-JjaibgZ9OuYE/tcJUgU7w3c83KdCooHNkt9OBjKznbWUuTXI0lcZQQWyy4ZDw3kB1uszeDfs1WGbsOvVkZA/aQ==} engines: {node: '>=20.9.0'} - '@clerk/clerk-js@6.31.1': - resolution: {integrity: sha512-qSk35+vm0J7ZEf7dcbywBC4VjNtWgZDU4PMipgHS/PIEy9Txyddt0OFJ6U1Gzgvz69zUcUJGttP0I0KpbiSvhQ==} + '@clerk/clerk-js@6.32.1': + resolution: {integrity: sha512-WxzO4zGh6D/gMa4Eok1DHuKL3Gxq84mm6PQTueLupHzPqlts96c/QaoAHMxrRyUV96O9NZe4ogRJGE8BnFJssA==} engines: {node: '>=20.9.0'} '@clerk/electron-passkeys-darwin-arm64@0.0.3': @@ -1833,8 +1833,8 @@ packages: resolution: {integrity: sha512-OHhIe88qDL+FxyBalXdXNHAS5eEramr6Rerp+6iNkfkjqT8rx4hHNmfpmjg5/T1/am8QfknbOBZkqoXZlCrjPg==} engines: {node: '>=20.9.0'} - '@clerk/electron@0.0.42': - resolution: {integrity: sha512-8/1EPsSsnYFb3aEbmFPGThKrtBP/uRal1rO6aafnsn9lSHpeje2E/f61GRuvfan3ErG65BQf6g8grVUb507Nvg==} + '@clerk/electron@0.0.44': + resolution: {integrity: sha512-Pf9bs1ufGcgSJthKIW0AOQINbAwRSBly0YfRKgnoB7O4+Jc0tixZ0RdZCQ1HIzDC93GurIl+JRw0E1zz+pJrZA==} engines: {node: '>=20.9.0'} peerDependencies: '@clerk/electron-passkeys': 0.0.3 @@ -1850,8 +1850,8 @@ packages: react-dom: optional: true - '@clerk/expo@4.6.6': - resolution: {integrity: sha512-q+cRM0q1lY1SbxTrdxvDPzr/abmjb1OKHEf+m4Y2/cJeG5aQ7mf/mXNEc64V+QnwvxSgrd32arP6SIEUQI3ZAQ==} + '@clerk/expo@4.6.8': + resolution: {integrity: sha512-ewItpjpZV9qiK+YFvxZmSM3/xt4NNLvJTKE5gRhHlAlI3RoGnOQMuk8+3yomZ4s2dJp0Jtb+HDdK8H2Q8VuR0Q==} engines: {node: '>=20.9.0'} peerDependencies: '@clerk/expo-google-signin': '>=0.1.0' @@ -1889,15 +1889,15 @@ packages: react-dom: optional: true - '@clerk/react@6.15.2': - resolution: {integrity: sha512-7oI6Mcfzrlsnrz8JXNyXLgjK1uhO9mTEg2ut0bUfcgXwUSAqY6QG+/UGXUV/hHeeLKOrxrnOe6gQ8/TT4RQvwg==} + '@clerk/react@6.16.1': + resolution: {integrity: sha512-fYNiouRyVaEAKz8PZwWx7Y/jLnX0OxSplELfc+8VezgU36Xjx79Qu8xnr0XLhF/zwAuj+Avguvz60JbeIiiJ0Q==} engines: {node: '>=20.9.0'} peerDependencies: react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 - '@clerk/shared@4.31.1': - resolution: {integrity: sha512-j3cDEZ/j7r5tAv4mmo2JhpFRtL1z0JghjCgvBJjZSR6Q4ZVIlwd2Bv0loM+opKReeN17H30u1/WHEBl7pZuxrw==} + '@clerk/shared@4.33.0': + resolution: {integrity: sha512-7urfRaaXPHeIWJuEIqy70NvMoi2EmschcEQt36MtbEAswttvgYc9KzwkE5fm5RqSvfq1R057uz/FR9SaOmqoLA==} engines: {node: '>=20.9.0'} peerDependencies: react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 @@ -9765,12 +9765,12 @@ packages: react: '*' react-native: '*' - react-native-reanimated@4.5.1: - resolution: {integrity: sha512-RnMvtDuR+68ig864gAvZCOdZehqhC5rFmMo0kn+ARfgVSTvFeF6IFLBVgMPUu0KwihaapEyW24WRi6nEyy1kSA==} + react-native-reanimated@4.5.5: + resolution: {integrity: sha512-xtJXZRZ1vkec1AIUVG02St2sbyZS5jc7227TlDu/HonqUYPeZAfiC4RI2jqL1dVMNBu1+7oBx/c9QkjF2kg6Lg==} peerDependencies: react: '*' react-native: 0.83 - 0.86 - react-native-worklets: 0.10.x + react-native-worklets: 0.10.x - 0.11.x react-native-safe-area-context@5.7.0: resolution: {integrity: sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ==} @@ -9815,6 +9815,14 @@ packages: react: '*' react-native: 0.83 - 0.86 + react-native-worklets@0.11.4: + resolution: {integrity: sha512-yNiDDQAVvt1wacBhnrEMTBlQzZ8Y8Rg7Dbdxs/r595EXw4REsZGE5sY1Ug8SmlQUT+0L+fX7oY0SuCs0mjiA4w==} + peerDependencies: + '@babel/core': '*' + '@react-native/metro-config': '*' + react: '*' + react-native: 0.83 - 0.86 + react-native@0.86.3: resolution: {integrity: sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} @@ -12393,18 +12401,18 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@clerk/backend@3.17.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/backend@3.18.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.31.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.33.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) standardwebhooks: 1.0.0 tslib: 2.8.1 transitivePeerDependencies: - react - react-dom - '@clerk/clerk-js@6.31.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/clerk-js@6.32.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@clerk/shared': 4.31.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.33.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@stripe/stripe-js': 5.6.0 '@swc/helpers': 0.5.21 '@tanstack/query-core': 5.102.8 @@ -12419,9 +12427,9 @@ snapshots: - react - react-dom - '@clerk/clerk-js@6.31.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/clerk-js@6.32.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.31.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.33.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@stripe/stripe-js': 5.6.0 '@swc/helpers': 0.5.21 '@tanstack/query-core': 5.102.8 @@ -12455,11 +12463,11 @@ snapshots: '@clerk/electron-passkeys-win32-arm64-msvc': 0.0.3 '@clerk/electron-passkeys-win32-x64-msvc': 0.0.3 - '@clerk/electron@0.0.42(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/electron@0.0.44(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/clerk-js': 6.31.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@clerk/react': 6.15.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@clerk/shared': 4.31.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/clerk-js': 6.32.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/react': 6.16.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.33.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) electron: 44.1.0 react: 19.2.6 tslib: 2.8.1 @@ -12468,14 +12476,14 @@ snapshots: electron-store: 8.2.0 react-dom: 19.2.6(react@19.2.6) - '@clerk/expo@4.6.6(patch_hash=a82bb41039ee88a290a87d4ab58be7be4c49fa338603b17ea646e1ec140a1cfb)(dbc31631339ce74330e188d5d7a88158)': + '@clerk/expo@4.6.8(patch_hash=a82bb41039ee88a290a87d4ab58be7be4c49fa338603b17ea646e1ec140a1cfb)(dbc31631339ce74330e188d5d7a88158)': dependencies: - '@clerk/clerk-js': 6.31.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@clerk/react': 6.15.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@clerk/shared': 4.31.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/clerk-js': 6.32.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/react': 6.16.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.33.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@expo/config-plugins': 57.0.9(typescript@7.0.2) base-64: 1.0.0 - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-native-url-polyfill: 4.0.0(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) @@ -12491,21 +12499,21 @@ snapshots: - supports-color - typescript - '@clerk/react@6.15.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/react@6.16.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@clerk/shared': 4.31.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.33.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) tslib: 2.8.1 - '@clerk/react@6.15.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/react@6.16.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.31.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.33.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) tslib: 2.8.1 - '@clerk/shared@4.31.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/shared@4.33.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@tanstack/query-core': 5.102.8 dequal: 2.0.3 @@ -12515,7 +12523,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - '@clerk/shared@4.31.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/shared@4.33.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@tanstack/query-core': 5.102.8 dequal: 2.0.3 @@ -13170,7 +13178,7 @@ snapshots: connect: 3.7.0 debug: 4.4.3 dnssd-advertise: 1.1.4 - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) expo-server: 57.0.3 fetch-nodeshim: 0.4.10 getenv: 2.0.0 @@ -13354,7 +13362,7 @@ snapshots: '@expo/dom-webview@57.0.1(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) @@ -13441,7 +13449,7 @@ snapshots: dependencies: '@expo/dom-webview': 57.0.1(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) anser: 1.4.10 - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) stacktrace-parser: 0.1.11 @@ -13482,7 +13490,7 @@ snapshots: postcss: 8.5.15 resolve-from: 5.0.0 optionalDependencies: - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) transitivePeerDependencies: - bufferutil - supports-color @@ -13504,7 +13512,7 @@ snapshots: dependencies: '@expo/log-box': 57.0.4(@expo/dom-webview@57.0.1)(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) anser: 1.4.10 - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) pretty-format: 29.7.0 react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) @@ -13606,7 +13614,7 @@ snapshots: '@expo/router-server@57.0.8(@expo/metro-runtime@57.0.14)(expo-constants@57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo-font@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-server@57.0.3)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: debug: 4.4.3 - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) expo-constants: 57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-font: 57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-server: 57.0.3 @@ -13642,9 +13650,9 @@ snapshots: '@expo/sudo-prompt@9.3.2': {} - '@expo/ui@57.0.14(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@expo/ui@57.0.14(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.11.4(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) sf-symbols-typescript: 2.2.0 @@ -13652,7 +13660,7 @@ snapshots: optionalDependencies: '@babel/core': 7.29.7 react-dom: 19.2.3(react@19.2.3) - react-native-worklets: 0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-worklets: 0.11.4(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -15634,7 +15642,7 @@ snapshots: dependencies: '@t3tools/client-runtime': link:packages/client-runtime '@t3tools/shared': link:packages/shared - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) expo-asset: 57.0.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@7.0.2) expo-clipboard: 57.0.1(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-haptics: 57.0.2(expo@57.0.18) @@ -16884,8 +16892,8 @@ snapshots: react-refresh: 0.14.2 optionalDependencies: '@babel/runtime': 7.29.7 - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) - expo-widgets: 57.0.15(patch_hash=319a9ded5db49c5b5215c511a138b33f44c7ea2972eb418192e8d5342fe75ce6)(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) + expo-widgets: 57.0.15(patch_hash=319a9ded5db49c5b5215c511a138b33f44c7ea2972eb418192e8d5342fe75ce6)(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.11.4(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) transitivePeerDependencies: - '@babel/core' - supports-color @@ -17817,12 +17825,12 @@ snapshots: expo-application@57.0.2(expo@57.0.18): dependencies: - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) expo-asset@57.0.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@7.0.2): dependencies: '@expo/image-utils': 0.11.5(typescript@7.0.2) - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) expo-constants: 57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) @@ -17844,7 +17852,7 @@ snapshots: expo-audio@57.0.4(patch_hash=fa9a3e0442ed395d4071bb406e08c3a471c9a84700bdfa0b9ad7ff144c96041a)(expo-asset@57.0.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@7.0.2))(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) expo-asset: 57.0.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@7.0.2) react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) @@ -17865,21 +17873,21 @@ snapshots: expo-blur@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-build-properties@57.0.15(expo@57.0.18): dependencies: '@expo/schema-utils': 57.0.2 - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) resolve-from: 5.0.0 semver: 7.8.5 expo-camera@57.0.4(@types/emscripten@1.41.5)(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: barcode-detector: 3.2.0(@types/emscripten@1.41.5) - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: @@ -17887,14 +17895,14 @@ snapshots: expo-clipboard@57.0.1(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-constants@57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: '@expo/env': 2.4.3 - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - supports-color @@ -17910,11 +17918,11 @@ snapshots: expo-crypto@57.0.2(expo@57.0.18): dependencies: - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) expo-dev-client@57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) expo-dev-launcher: 57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-dev-menu: 57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-dev-menu-interface: 57.0.0(expo@57.0.18) @@ -17926,35 +17934,35 @@ snapshots: expo-dev-launcher@57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: '@expo/schema-utils': 57.0.2 - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) expo-dev-menu: 57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-manifests: 57.0.1(expo@57.0.18) react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-dev-menu-interface@57.0.0(expo@57.0.18): dependencies: - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) expo-dev-menu@57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) expo-dev-menu-interface: 57.0.0(expo@57.0.18) react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-device@57.0.1(expo@57.0.18): dependencies: - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) ua-parser-js: 0.7.41 expo-document-picker@57.0.1(expo@57.0.18): dependencies: - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) expo-eas-client@57.0.2: {} expo-file-system@57.0.6(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-file-system@57.0.6(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)): @@ -17965,7 +17973,7 @@ snapshots: expo-font@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) fontfaceobserver: 2.3.0 react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) @@ -17980,31 +17988,31 @@ snapshots: expo-glass-effect@57.0.1(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-haptics@57.0.2(expo@57.0.18): dependencies: - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) expo-image-loader@57.0.1(expo@57.0.18): dependencies: - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) expo-image-manipulator@57.0.17(expo@57.0.18): dependencies: - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) expo-image-loader: 57.0.1(expo@57.0.18) expo-image-picker@57.0.14(expo@57.0.18): dependencies: - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) expo-image-loader: 57.0.1(expo@57.0.18) expo-image@57.0.3(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) sf-symbols-typescript: 2.2.0 @@ -18013,7 +18021,7 @@ snapshots: expo-keep-awake@57.0.1(expo@57.0.18)(react@19.2.3): dependencies: - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) react: 19.2.3 expo-keep-awake@57.0.1(expo@57.0.18)(react@19.2.6): @@ -18034,7 +18042,7 @@ snapshots: expo-manifests@57.0.1(expo@57.0.18): dependencies: - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) expo-json-utils: 57.0.1 expo-modules-autolinking@57.0.12(typescript@7.0.2): @@ -18047,16 +18055,6 @@ snapshots: - supports-color - typescript - expo-modules-core@57.0.14(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): - dependencies: - '@expo/expo-modules-macros-plugin': 0.6.1 - expo-modules-jsi: 57.0.6(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - invariant: 2.2.4 - react: 19.2.3 - react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - optionalDependencies: - react-native-worklets: 0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-modules-core@57.0.14(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): dependencies: '@expo/expo-modules-macros-plugin': 0.6.1 @@ -18068,6 +18066,16 @@ snapshots: react-native-worklets: 0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) optional: true + expo-modules-core@57.0.14(react-native-worklets@0.11.4(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + dependencies: + '@expo/expo-modules-macros-plugin': 0.6.1 + expo-modules-jsi: 57.0.6(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + invariant: 2.2.4 + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + optionalDependencies: + react-native-worklets: 0.11.4(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-modules-jsi@57.0.6(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) @@ -18079,7 +18087,7 @@ snapshots: expo-network@57.0.1(expo@57.0.18)(react@19.2.3): dependencies: - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) react: 19.2.3 expo-notifications@57.0.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@7.0.2): @@ -18087,7 +18095,7 @@ snapshots: '@expo/image-utils': 0.11.5(typescript@7.0.2) abort-controller: 3.0.0 badgin: 1.2.3 - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) expo-application: 57.0.2(expo@57.0.18) expo-constants: 57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react: 19.2.3 @@ -18098,14 +18106,14 @@ snapshots: expo-paste-input@0.1.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-quick-actions@6.0.2(expo@57.0.18)(typescript@7.0.2): dependencies: '@expo/image-utils': 0.8.14(typescript@7.0.2) - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) schema-utils: 4.3.3 sf-symbols-typescript: 2.2.0 transitivePeerDependencies: @@ -18114,7 +18122,7 @@ snapshots: expo-secure-store@57.0.2(expo@57.0.18): dependencies: - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) expo-server@57.0.3: {} @@ -18123,7 +18131,7 @@ snapshots: '@expo/config-plugins': 57.0.9(typescript@7.0.2) '@expo/config-types': 57.0.2 '@expo/plist': 0.8.1 - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: @@ -18134,7 +18142,7 @@ snapshots: dependencies: '@expo/config-plugins': 57.0.9(typescript@7.0.2) '@expo/image-utils': 0.11.5(typescript@7.0.2) - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) xml2js: 0.6.0 transitivePeerDependencies: - supports-color @@ -18143,7 +18151,7 @@ snapshots: expo-sqlite@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: await-lock: 2.2.2 - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) @@ -18160,7 +18168,7 @@ snapshots: expo-symbols@57.0.2(expo-font@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@expo-google-fonts/material-symbols': 0.4.38 - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) expo-font: 57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) @@ -18168,7 +18176,7 @@ snapshots: expo-updates-interface@57.0.1(expo@57.0.18): dependencies: - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) expo-updates@57.0.19(expo-dev-client@57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: @@ -18178,7 +18186,7 @@ snapshots: arg: 4.1.3 chalk: 4.1.2 debug: 4.4.3 - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) expo-eas-client: 57.0.2 expo-manifests: 57.0.1(expo@57.0.18) expo-structured-headers: 57.0.0 @@ -18197,20 +18205,20 @@ snapshots: expo-video@57.0.3(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-web-browser@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-widgets@57.0.15(patch_hash=319a9ded5db49c5b5215c511a138b33f44c7ea2972eb418192e8d5342fe75ce6)(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-widgets@57.0.15(patch_hash=319a9ded5db49c5b5215c511a138b33f44c7ea2972eb418192e8d5342fe75ce6)(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.11.4(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@expo/plist': 0.8.1 - '@expo/ui': 57.0.14(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + '@expo/ui': 57.0.14(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.11.4(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: @@ -18263,7 +18271,7 @@ snapshots: - utf-8-validate optional: true - expo@57.0.18(fc5a731e35a0144aab60c7305f29cbed): + expo@57.0.18(41fd11498a34454c91128cdad32f22f8): dependencies: '@babel/runtime': 7.29.7 '@expo/cli': 57.0.20(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.14)(bufferutil@4.1.0)(expo-constants@57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo-font@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@7.0.2)(utf-8-validate@6.0.6) @@ -18283,7 +18291,7 @@ snapshots: expo-font: 57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-keep-awake: 57.0.1(expo@57.0.18)(react@19.2.3) expo-modules-autolinking: 57.0.12(typescript@7.0.2) - expo-modules-core: 57.0.14(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-modules-core: 57.0.14(react-native-worklets@0.11.4(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) pretty-format: 29.7.0 react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) @@ -20996,12 +21004,12 @@ snapshots: react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - ? react-native-keyboard-controller@1.21.13(patch_hash=6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787)(react-native-reanimated@4.5.1(patch_hash=a23baea5d82cbf1254720110aef6671e39d57a425f8be446756f91ab806eb72c)(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + ? react-native-keyboard-controller@1.21.13(patch_hash=6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787)(react-native-reanimated@4.5.5(patch_hash=bae9878a5bdba94e11c890e5ee164542feb622624fc81c13890063094608216d)(react-native-worklets@0.11.4(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) : dependencies: react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-native-is-edge-to-edge: 1.3.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-reanimated: 4.5.1(patch_hash=a23baea5d82cbf1254720110aef6671e39d57a425f8be446756f91ab806eb72c)(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-reanimated: 4.5.5(patch_hash=bae9878a5bdba94e11c890e5ee164542feb622624fc81c13890063094608216d)(react-native-worklets@0.11.4(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-nitro-markdown@0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: @@ -21016,12 +21024,12 @@ snapshots: react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-reanimated@4.5.1(patch_hash=a23baea5d82cbf1254720110aef6671e39d57a425f8be446756f91ab806eb72c)(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-reanimated@4.5.5(patch_hash=bae9878a5bdba94e11c890e5ee164542feb622624fc81c13890063094608216d)(react-native-worklets@0.11.4(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-native-is-edge-to-edge: 1.3.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-worklets: 0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-worklets: 0.11.4(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) semver: 7.8.5 react-native-safe-area-context@5.7.0(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): @@ -21070,7 +21078,7 @@ snapshots: react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) optional: true - react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7) @@ -21085,15 +21093,17 @@ snapshots: '@babel/types': 7.29.7 '@react-native/metro-config': 0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6) convert-source-map: 2.0.0 - react: 19.2.3 - react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) semver: 7.8.5 transitivePeerDependencies: - supports-color + optional: true - react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): + react-native-worklets@0.11.4(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@babel/core': 7.29.7 + '@babel/generator': 7.29.7 '@babel/plugin-transform-arrow-functions': 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) @@ -21103,15 +21113,15 @@ snapshots: '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 '@react-native/metro-config': 0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6) convert-source-map: 2.0.0 - react: 19.2.6 - react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) semver: 7.8.5 transitivePeerDependencies: - supports-color - optional: true react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6): dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d6a933a070a2..a4a6c3f6b069 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -23,13 +23,13 @@ allowBuilds: workerd: false catalog: - "@clerk/backend": 3.17.2 - "@clerk/clerk-js": 6.31.1 - "@clerk/electron": 0.0.42 + "@clerk/backend": 3.18.1 + "@clerk/clerk-js": 6.32.1 + "@clerk/electron": 0.0.44 "@clerk/electron-passkeys": 0.0.3 - "@clerk/expo": 4.6.6 - "@clerk/react": 6.15.2 - "@clerk/shared": 4.31.1 + "@clerk/expo": 4.6.8 + "@clerk/react": 6.16.1 + "@clerk/shared": 4.33.0 "@effect/atom-react": 4.0.0-rc.112 "@effect/openapi-generator": 4.0.0-rc.112 "@effect/platform-node": 4.0.0-rc.112 @@ -55,12 +55,12 @@ catalog: yaml: ^2.9.0 minimumReleaseAgeExclude: - - "@clerk/backend@3.17.2" - - "@clerk/clerk-js@6.31.1" - - "@clerk/electron@0.0.42" - - "@clerk/expo@4.6.6" - - "@clerk/react@6.15.2" - - "@clerk/shared@4.31.1" + - "@clerk/backend@3.18.1" + - "@clerk/clerk-js@6.32.1" + - "@clerk/electron@0.0.44" + - "@clerk/expo@4.6.8" + - "@clerk/react@6.16.1" + - "@clerk/shared@4.33.0" - "@distilled.cloud/aws@0.30.2" - "@distilled.cloud/axiom@0.30.2" - "@distilled.cloud/cloudflare@0.30.2" @@ -157,7 +157,7 @@ packageExtensions: vite: "catalog:" patchedDependencies: - "@clerk/expo@4.6.6": patches/@clerk__expo@4.6.6.patch + "@clerk/expo@4.6.8": patches/@clerk__expo@4.6.8.patch "@effect/vitest@4.0.0-rc.112": patches/@effect__vitest@4.0.0-rc.112.patch "@expo/metro-config@57.0.12": patches/@expo__metro-config@57.0.12.patch "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch @@ -175,7 +175,7 @@ patchedDependencies: react-native-keyboard-controller@1.21.13: patches/react-native-keyboard-controller@1.21.13.patch react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch # Preserve the final layout frame. Backport of [#10171](https://github.com/software-mansion/react-native-reanimated/pull/10171). - react-native-reanimated@4.5.1: patches/react-native-reanimated@4.5.1.patch + react-native-reanimated@4.5.5: patches/react-native-reanimated@4.5.5.patch react-native-screens@4.26.2: patches/react-native-screens@4.26.2.patch uniwind@1.11.0: patches/uniwind@1.11.0.patch diff --git a/vite.config.ts b/vite.config.ts index b9f2c9cc2c4b..e128bbb05f70 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -136,8 +136,8 @@ export default defineConfig({ rules: { "t3code/no-mobile-uniwind-theme-escape-hatches": "error" }, }, { - // Code that runs on Hermes. It has no ES2023 change-array-by-copy methods, and - // tsconfig targets ESNext, so only lint stands between a call and a fatal launch. + // Shared client code must not call APIs missing from Hermes. Our ESNext + // TypeScript target accepts them even when they would crash mobile at launch. // Tests run on Node and are exempt. files: [ "apps/mobile/src/**", @@ -146,7 +146,7 @@ export default defineConfig({ "packages/shared/src/**", ], excludeFiles: ["**/*.test.ts", "**/*.test.tsx"], - rules: { "t3code/no-hermes-unsupported-array-methods": "error" }, + rules: { "t3code/no-hermes-unsupported-apis": "error" }, }, { // Reviewed native and third-party interop boundaries that cannot consume a className.