From f81c2ca24a63bff7dba5d4275ceccd904ea210e4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 3 Jul 2026 20:05:26 -0700 Subject: [PATCH] Improve live activity diagnostics and relay routing - Surface device push/live activity diagnostics in Settings - Add APNs environment routing and foreground Live Activity refresh - Tighten widget deep linking and row rendering for active agents --- apps/mobile/app.config.ts | 3 + .../agent-awareness/deviceDiagnostics.test.ts | 95 +++++++ .../agent-awareness/deviceDiagnostics.ts | 96 +++++++ .../agent-awareness/registrationPayload.ts | 11 + .../remoteRegistration.test.ts | 91 ++++++- .../agent-awareness/remoteRegistration.ts | 54 +++- .../features/settings/SettingsRouteScreen.tsx | 79 +++++- apps/mobile/src/widgets/AgentActivity.test.ts | 119 ++++++++- apps/mobile/src/widgets/AgentActivity.tsx | 252 ++++++------------ .../AgentActivityPublisher.test.ts | 69 +++++ .../agentActivity/AgentActivityPublisher.ts | 44 ++- infra/relay/src/agentActivity/ApnsClient.ts | 6 +- .../src/agentActivity/ApnsDeliveries.test.ts | 192 +++++++++++++ .../relay/src/agentActivity/ApnsDeliveries.ts | 64 ++++- .../src/agentActivity/ApnsDeliveryQueue.ts | 6 + infra/relay/src/agentActivity/Devices.test.ts | 89 +++++-- infra/relay/src/agentActivity/Devices.ts | 149 ++++++++--- .../relay/src/agentActivity/LiveActivities.ts | 4 + .../agentActivity/MobileRegistrations.test.ts | 2 + .../src/agentActivity/apnsDeliveryJobs.ts | 8 + infra/relay/src/persistence/schema.ts | 2 + packages/contracts/src/relay.ts | 24 ++ 22 files changed, 1215 insertions(+), 244 deletions(-) create mode 100644 apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts create mode 100644 apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 192153316b0..51bd873a23b 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -157,6 +157,9 @@ const config: ExpoConfig = { bundleIdentifier: `${variant.iosBundleIdentifier}.widgets`, groupIdentifier: `group.${variant.iosBundleIdentifier}`, enablePushNotifications: true, + // Agent activity can update many times an hour; without the + // frequent-updates entitlement iOS throttles the update budget sooner. + frequentUpdates: true, widgets: [ { name: "AgentActivity", diff --git a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts new file mode 100644 index 00000000000..ef5bd3dd190 --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay"; + +import { formatDeviceDiagnosticsRows } from "./deviceDiagnostics"; + +function makeDevice(diagnostics: RelayClientDeviceRecord["diagnostics"]): RelayClientDeviceRecord { + return { + deviceId: "device-1", + label: "iPhone", + platform: "ios", + iosMajorVersion: 18, + appVersion: "1.0.0", + notifications: { + enabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + }, + liveActivities: { enabled: true }, + ...(diagnostics ? { diagnostics } : {}), + updatedAt: "2026-07-04T00:00:00.000Z", + } as RelayClientDeviceRecord; +} + +describe("formatDeviceDiagnosticsRows", () => { + it("flags an unregistered device", () => { + expect(formatDeviceDiagnosticsRows(null)).toEqual([ + { label: "Relay Registration", value: "Not registered", tone: "warn" }, + ]); + }); + + it("explains when the relay predates delivery diagnostics", () => { + expect(formatDeviceDiagnosticsRows(makeDevice(undefined))).toEqual([ + { label: "Relay Registration", value: "Registered", tone: "ok" }, + { label: "Delivery Details", value: "Requires a relay update", tone: "muted" }, + ]); + }); + + it("warns about missing tokens and surfaces the last delivery failure", () => { + const rows = formatDeviceDiagnosticsRows( + makeDevice({ + bundleId: "com.t3tools.t3code.preview", + apsEnvironment: "production", + hasPushToken: false, + hasPushToStartToken: false, + hasLiveActivityToken: false, + lastDeliveryAt: "2026-06-05T01:02:59.566Z", + lastDeliveryKind: "live_activity_end", + lastDeliveryStatus: 400, + lastDeliveryError: "DeviceTokenNotForTopic", + }), + ); + + expect(rows).toEqual([ + { label: "Notification Token", value: "Missing", tone: "warn" }, + { label: "Live Activity Start Token", value: "Missing", tone: "warn" }, + { label: "Active Live Activity", value: "None", tone: "muted" }, + { + label: "APNs Route", + value: "com.t3tools.t3code.preview (production)", + tone: "muted", + }, + { + label: "Last Delivery", + value: "DeviceTokenNotForTopic (400)", + tone: "warn", + }, + ]); + }); + + it("reports healthy registrations with a successful delivery", () => { + const rows = formatDeviceDiagnosticsRows( + makeDevice({ + bundleId: "com.t3tools.t3code", + apsEnvironment: "production", + hasPushToken: true, + hasPushToStartToken: true, + hasLiveActivityToken: true, + lastDeliveryAt: "2026-07-04T00:00:00.000Z", + lastDeliveryKind: "live_activity_update", + lastDeliveryStatus: 200, + lastDeliveryError: null, + }), + ); + + expect(rows.slice(0, 3)).toEqual([ + { label: "Notification Token", value: "Registered", tone: "ok" }, + { label: "Live Activity Start Token", value: "Registered", tone: "ok" }, + { label: "Active Live Activity", value: "Connected", tone: "ok" }, + ]); + expect(rows[4]).toMatchObject({ label: "Last Delivery", tone: "ok" }); + expect(rows[4]?.value).toContain("Delivered"); + }); +}); diff --git a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts new file mode 100644 index 00000000000..4807d256d5f --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts @@ -0,0 +1,96 @@ +import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay"; + +export interface DeviceDiagnosticsRow { + readonly label: string; + readonly value: string; + readonly tone: "ok" | "warn" | "muted"; +} + +function formatLastDeliveryTimestamp(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) { + return ""; + } + return date.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +// Renders the relay's view of this device so "why am I not getting pushes" +// is answerable from the Settings screen instead of the relay database. +export function formatDeviceDiagnosticsRows( + device: RelayClientDeviceRecord | null, +): ReadonlyArray { + if (device === null) { + return [ + { + label: "Relay Registration", + value: "Not registered", + tone: "warn", + }, + ]; + } + const diagnostics = device.diagnostics; + if (!diagnostics) { + return [ + { label: "Relay Registration", value: "Registered", tone: "ok" }, + { label: "Delivery Details", value: "Requires a relay update", tone: "muted" }, + ]; + } + + const rows: Array = [ + { + label: "Notification Token", + value: diagnostics.hasPushToken ? "Registered" : "Missing", + tone: diagnostics.hasPushToken ? "ok" : "warn", + }, + { + label: "Live Activity Start Token", + value: diagnostics.hasPushToStartToken ? "Registered" : "Missing", + tone: diagnostics.hasPushToStartToken ? "ok" : "warn", + }, + { + label: "Active Live Activity", + value: diagnostics.hasLiveActivityToken ? "Connected" : "None", + tone: diagnostics.hasLiveActivityToken ? "ok" : "muted", + }, + ]; + + if (diagnostics.bundleId) { + rows.push({ + label: "APNs Route", + value: `${diagnostics.bundleId} (${diagnostics.apsEnvironment ?? "default"})`, + tone: "muted", + }); + } else { + rows.push({ + label: "APNs Route", + value: "Relay default (update the app to register)", + tone: "warn", + }); + } + + if (diagnostics.lastDeliveryAt === null) { + rows.push({ label: "Last Delivery", value: "None yet", tone: "muted" }); + } else if (diagnostics.lastDeliveryError !== null) { + const status = + diagnostics.lastDeliveryStatus === null ? "" : ` (${diagnostics.lastDeliveryStatus})`; + rows.push({ + label: "Last Delivery", + value: `${diagnostics.lastDeliveryError}${status}`, + tone: "warn", + }); + } else { + const timestamp = formatLastDeliveryTimestamp(diagnostics.lastDeliveryAt); + rows.push({ + label: "Last Delivery", + value: timestamp ? `Delivered ${timestamp}` : "Delivered", + tone: "ok", + }); + } + + return rows; +} diff --git a/apps/mobile/src/features/agent-awareness/registrationPayload.ts b/apps/mobile/src/features/agent-awareness/registrationPayload.ts index a4e6fc3d6db..cd2e36a403c 100644 --- a/apps/mobile/src/features/agent-awareness/registrationPayload.ts +++ b/apps/mobile/src/features/agent-awareness/registrationPayload.ts @@ -2,11 +2,20 @@ import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay"; import type { Preferences } from "../../lib/storage"; +// Development builds are Xcode-signed and receive sandbox APNs tokens; +// preview and production builds are distribution-signed and use production +// APNs. The relay routes each device's pushes accordingly. +export function resolveApsEnvironment(appVariant: unknown): "sandbox" | "production" { + return appVariant === "development" ? "sandbox" : "production"; +} + export function makeRelayDeviceRegistrationRequest(input: { readonly deviceId: string; readonly label: string; readonly iosMajorVersion: number; readonly appVersion?: string; + readonly bundleId?: string; + readonly apsEnvironment?: "sandbox" | "production"; readonly pushToken?: string; readonly pushToStartToken?: string; readonly notificationsEnabled: boolean; @@ -19,6 +28,8 @@ export function makeRelayDeviceRegistrationRequest(input: { platform: "ios", iosMajorVersion: input.iosMajorVersion, appVersion: input.appVersion, + ...(input.bundleId ? { bundleId: input.bundleId } : {}), + ...(input.apsEnvironment ? { apsEnvironment: input.apsEnvironment } : {}), ...(input.pushToken ? { pushToken: input.pushToken } : {}), ...(input.pushToStartToken ? { pushToStartToken: input.pushToStartToken } : {}), preferences: { diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 7f97d7c718c..487944e2e56 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -16,7 +16,7 @@ import { verifyDpopProof } from "@t3tools/shared/dpop"; import type { SavedRemoteConnection } from "../../lib/connection"; import { cryptoLayer } from "../cloud/dpop"; import { managedRelayClientLayer } from "../cloud/managedRelayLayer"; -import { makeRelayDeviceRegistrationRequest } from "./registrationPayload"; +import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload"; import { AgentAwarenessOperationError, __resetAgentAwarenessRemoteRegistrationForTest, @@ -41,6 +41,9 @@ const backgroundRuntime = vi.hoisted(() => ({ readonly resolve: (exit: Exit.Exit) => void; }>, })); +const appStateMock = vi.hoisted(() => ({ + listeners: [] as Array<(state: string) => void>, +})); vi.mock("expo-constants", () => ({ default: { @@ -100,6 +103,19 @@ vi.mock("react-native", () => ({ OS: "ios", Version: "18.0", }, + AppState: { + addEventListener: (_event: string, listener: (state: string) => void) => { + appStateMock.listeners.push(listener); + return { + remove: () => { + const index = appStateMock.listeners.indexOf(listener); + if (index >= 0) { + appStateMock.listeners.splice(index, 1); + } + }, + }; + }, + }, })); vi.mock("../../lib/runtime", () => ({ @@ -176,6 +192,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { backgroundRuntime.pending.length = 0; Constants.expoConfig!.extra = {}; __resetAgentAwarenessRemoteRegistrationForTest(); + appStateMock.listeners.length = 0; widgetMocks.getInstances.mockReset(); widgetMocks.getInstances.mockReturnValue([]); }); @@ -213,6 +230,31 @@ describe("makeRelayDeviceRegistrationRequest", () => { }); }); + it("registers the app's APNs routing so the relay targets the right bundle", () => { + expect( + makeRelayDeviceRegistrationRequest({ + deviceId: "device-1", + label: "Julius's iPhone", + iosMajorVersion: 18, + appVersion: "1.0.0", + bundleId: "com.t3tools.t3code.preview", + apsEnvironment: resolveApsEnvironment("preview"), + notificationsEnabled: true, + preferences: {}, + }), + ).toMatchObject({ + bundleId: "com.t3tools.t3code.preview", + apsEnvironment: "production", + }); + }); + + it("routes development builds to the APNs sandbox", () => { + expect(resolveApsEnvironment("development")).toBe("sandbox"); + expect(resolveApsEnvironment("preview")).toBe("production"); + expect(resolveApsEnvironment("production")).toBe("production"); + expect(resolveApsEnvironment(undefined)).toBe("production"); + }); + it("marks notification delivery disabled when APNs permission is unavailable", () => { expect( makeRelayDeviceRegistrationRequest({ @@ -329,6 +371,53 @@ describe("makeRelayDeviceRegistrationRequest", () => { }, ); + it.effect( + "re-registers active Live Activity tokens when the app returns to the foreground", + () => { + const activity = { + getPushToken: vi.fn(() => Promise.resolve("activity-token")), + addPushTokenListener: vi.fn(), + }; + widgetMocks.getInstances.mockReturnValue([activity] as never); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + + return Effect.gen(function* () { + yield* runBackgroundOperations(); + activity.getPushToken.mockClear(); + + expect(appStateMock.listeners).toHaveLength(1); + for (const listener of appStateMock.listeners) { + listener("background"); + } + yield* runBackgroundOperations(); + expect(activity.getPushToken).not.toHaveBeenCalled(); + + for (const listener of appStateMock.listeners) { + listener("active"); + } + yield* runBackgroundOperations(); + expect(activity.getPushToken).toHaveBeenCalled(); + }).pipe(Effect.provide(relayTestLayer)); + }, + ); + + it("ends local Live Activities and stops foreground reconciliation on cloud sign-out", () => { + const end = vi.fn(() => Promise.resolve()); + const activity = { + getPushToken: vi.fn(() => Promise.resolve("activity-token")), + addPushTokenListener: vi.fn(), + end, + }; + widgetMocks.getInstances.mockReturnValue([activity] as never); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + expect(appStateMock.listeners).toHaveLength(1); + + setAgentAwarenessRelayTokenProvider(null); + + expect(end).toHaveBeenCalledWith("immediate"); + expect(appStateMock.listeners).toHaveLength(0); + }); + it.effect("refreshes APNs registration for connected environments after settings changes", () => { registerAgentAwarenessConnection(savedConnection()); return Effect.gen(function* () { diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 3281381e0e1..3dae9677e50 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -3,7 +3,7 @@ import Constants from "expo-constants"; import * as Notifications from "expo-notifications"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { Platform } from "react-native"; +import { AppState, Platform } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; import { type RelayDeviceRegistrationRequest, @@ -26,7 +26,7 @@ import { } from "../../lib/storage"; import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity"; import { resolveCloudPublicConfig } from "../cloud/publicConfig"; -import { makeRelayDeviceRegistrationRequest } from "./registrationPayload"; +import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload"; const REMOTE_ACTIVITY_REGISTRATION_RETRY_MS = 15_000; @@ -60,6 +60,7 @@ const environmentConnections = new Map(); const activityPushTokenListeners = new WeakSet>(); let pushToStartSubscription: { remove: () => void } | null = null; let pushTokenSubscription: { remove: () => void } | null = null; +let appStateSubscription: { remove: () => void } | null = null; let activeLiveActivityRegistrationRetry: ReturnType | null = null; let relayTokenProvider: (() => Promise) | null = null; let relayTokenProviderIdentity: string | null = null; @@ -128,14 +129,20 @@ export function setAgentAwarenessRelayTokenProvider( pushToStartSubscription = null; pushTokenSubscription?.remove(); pushTokenSubscription = null; + appStateSubscription?.remove(); + appStateSubscription = null; if (activeLiveActivityRegistrationRetry) { clearTimeout(activeLiveActivityRegistrationRetry); activeLiveActivityRegistrationRetry = null; } + // Without a signed-in user the relay can no longer update or end these + // activities, so they would sit orphaned on the lock screen. + endLocalLiveActivities("live activity cleanup after cloud sign-out failed"); return; } ensurePushToStartListener(); ensurePushTokenListener(); + ensureAppStateListener(); runRegistrationInBackground( refreshActiveLiveActivityRemoteRegistration(), "active live activity registration after cloud sign-in failed", @@ -444,12 +451,15 @@ function registerDevice( expectedGeneration, notificationsEnabled: pushTokenRegistration.notificationsEnabled, }); + const bundleId = Constants.expoConfig?.ios?.bundleIdentifier?.trim(); yield* registerDeviceWithRelay( makeRelayDeviceRegistrationRequest({ deviceId, label: Constants.deviceName?.trim() || "iOS device", iosMajorVersion: iosMajorVersion(), appVersion: Constants.expoConfig?.version, + ...(bundleId ? { bundleId } : {}), + apsEnvironment: resolveApsEnvironment(Constants.expoConfig?.extra?.appVariant), ...(pushTokenRegistration.pushToken ? { pushToken: pushTokenRegistration.pushToken } : {}), ...(input?.pushToStartToken ? { pushToStartToken: input.pushToStartToken } : {}), notificationsEnabled: pushTokenRegistration.notificationsEnabled, @@ -498,6 +508,41 @@ function ensurePushTokenListener(): void { }); } +// Re-registering activity tokens on foreground makes the relay replay the +// current aggregate to this device, which updates content that drifted while +// pushes could not be delivered and ends orphaned activities whose end push +// never arrived. +function ensureAppStateListener(): void { + if (appStateSubscription || !canRegisterRemoteLiveActivities()) { + return; + } + + appStateSubscription = AppState.addEventListener("change", (state) => { + if (state !== "active") { + return; + } + runRegistrationInBackground( + refreshActiveLiveActivityRemoteRegistration(), + "active live activity reconciliation after app foreground failed", + ); + }); +} + +function endLocalLiveActivities(context: string): void { + if (!canRegisterRemoteLiveActivities()) { + return; + } + try { + for (const activity of AgentActivity.getInstances()) { + activity.end("immediate").catch((error: unknown) => { + logRegistrationError(context, error); + }); + } + } catch (error) { + logRegistrationError(context, error); + } +} + export function registerAgentAwarenessConnection(connection: SavedRemoteConnection): void { if (!canRegisterRemoteLiveActivities()) { return; @@ -506,6 +551,7 @@ export function registerAgentAwarenessConnection(connection: SavedRemoteConnecti environmentConnections.set(connection.environmentId, connection); ensurePushToStartListener(); ensurePushTokenListener(); + ensureAppStateListener(); enqueueDeviceRegistration({}, "device registration failed"); runRegistrationInBackground( refreshActiveLiveActivityRemoteRegistration(), @@ -527,6 +573,8 @@ export function unregisterAllAgentAwarenessConnections(): void { pushToStartSubscription = null; pushTokenSubscription?.remove(); pushTokenSubscription = null; + appStateSubscription?.remove(); + appStateSubscription = null; if (activeLiveActivityRegistrationRetry) { clearTimeout(activeLiveActivityRegistrationRetry); activeLiveActivityRegistrationRetry = null; @@ -553,6 +601,8 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { pushToStartSubscription = null; pushTokenSubscription?.remove(); pushTokenSubscription = null; + appStateSubscription?.remove(); + appStateSubscription = null; if (activeLiveActivityRegistrationRetry) { clearTimeout(activeLiveActivityRegistrationRetry); activeLiveActivityRegistrationRetry = null; diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 11d363dbf69..8299d8eb0a3 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -15,7 +15,12 @@ import { settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; import { AppText as Text } from "../../components/AppText"; +import { + formatDeviceDiagnosticsRows, + type DeviceDiagnosticsRow, +} from "../agent-awareness/deviceDiagnostics"; import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences"; import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions"; import { refreshAgentAwarenessRegistration } from "../agent-awareness/remoteRegistration"; @@ -25,7 +30,7 @@ import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/pu import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; import { runtime } from "../../lib/runtime"; -import { loadPreferences } from "../../lib/storage"; +import { loadAgentAwarenessDeviceId, loadPreferences } from "../../lib/storage"; import { useThemeColor } from "../../lib/useThemeColor"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { SettingsRow } from "./components/SettingsRow"; @@ -407,6 +412,8 @@ function ConfiguredSettingsRouteScreen() { /> + {isSignedIn ? : null} + @@ -419,6 +426,76 @@ function ConfiguredSettingsRouteScreen() { ); } +function PushDeliverySection(props: { readonly getToken: ReturnType["getToken"] }) { + const { getToken } = props; + const [rows, setRows] = useState | null>(null); + + useEffect(() => { + let cancelled = false; + void (async () => { + const result = await settleAsyncResult(() => + runtime.runPromiseExit( + Effect.gen(function* () { + const [deviceId, clerkToken] = yield* Effect.all([ + Effect.promise(() => loadAgentAwarenessDeviceId()), + Effect.promise(() => getToken(resolveRelayClerkTokenOptions())), + ]); + if (!deviceId || !clerkToken) { + return formatDeviceDiagnosticsRows(null); + } + const client = yield* ManagedRelay.ManagedRelayClient; + const devices = yield* client.listDevices({ clerkToken }); + return formatDeviceDiagnosticsRows( + devices.find((device) => device.deviceId === deviceId) ?? null, + ); + }), + ), + ); + if (cancelled) { + return; + } + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + reportAtomCommandResult(result, { label: "push delivery diagnostics" }); + } + setRows([{ label: "Relay Registration", value: "Unavailable", tone: "muted" }]); + return; + } + setRows(result.value); + })(); + return () => { + cancelled = true; + }; + }, [getToken]); + + return ( + + {(rows ?? [{ label: "Relay Registration", value: "Checking", tone: "muted" as const }]).map( + (row) => ( + + + {row.label} + + + + {row.value} + + + + ), + )} + + ); +} + function AppSettingsSection() { const icon = useThemeColor("--color-icon"); diff --git a/apps/mobile/src/widgets/AgentActivity.test.ts b/apps/mobile/src/widgets/AgentActivity.test.ts index 719e39554fb..a367ccdfd73 100644 --- a/apps/mobile/src/widgets/AgentActivity.test.ts +++ b/apps/mobile/src/widgets/AgentActivity.test.ts @@ -12,13 +12,33 @@ vi.mock("@expo/ui/swift-ui/modifiers", () => ({ foregroundStyle: (value: unknown) => value, lineLimit: (value: unknown) => value, padding: (value: unknown) => value, + widgetURL: (value: unknown) => ({ widgetURL: value }), })); vi.mock("expo-widgets", () => ({ createLiveActivity: vi.fn((name: string, layout: unknown) => ({ layout, name })), })); -import { AgentActivity, type AgentActivityProps } from "./AgentActivity"; +import { + AgentActivity, + type AgentActivityProps, + type AgentActivityRowProps, +} from "./AgentActivity"; + +function makeRow(overrides: Partial): AgentActivityRowProps { + return { + environmentId: "env-1", + threadId: "thread-1", + projectTitle: "Project", + threadTitle: "Thread", + modelTitle: "gpt-5.4", + phase: "running", + status: "Working", + updatedAt: "2026-05-25T13:07:00.000Z", + deepLink: "/threads/env-1/thread-1", + ...overrides, + }; +} const props = { title: "T3 Code", @@ -33,10 +53,16 @@ const environment = { isLuminanceReduced: false, } as const; +function expectedLocalTime(iso: string): string { + const date = new Date(iso); + const minutes = date.getMinutes(); + return `${date.getHours() % 12 || 12}:${minutes < 10 ? "0" : ""}${minutes}`; +} + describe("AgentActivity widget layout", () => { - it("formats its updated-at label without app-runtime helper references", () => { + it("formats its updated-at label in device-local time", () => { expect(JSON.stringify(AgentActivity(props, environment as never))).toContain( - '"children":["Updated ","1:07"]', + `"children":["Updated ","${expectedLocalTime(props.updatedAt)}"]`, ); expect(AgentActivity.toString()).not.toContain("formatAgentActivityUpdatedAtLabel"); }); @@ -46,4 +72,91 @@ describe("AgentActivity widget layout", () => { JSON.stringify(AgentActivity({ ...props, updatedAt: "not-a-date" }, environment as never)), ).toContain('"children":["Updated ","now"]'); }); + + it("tints each row by its own phase", () => { + const layout = AgentActivity( + { + ...props, + activeCount: 2, + activities: [ + makeRow({}), + makeRow({ threadId: "thread-2", phase: "waiting_for_approval", status: "Approval" }), + ], + }, + environment as never, + ); + const banner = JSON.stringify(layout.banner); + expect(banner).toContain("#14b8a6"); + expect(banner).toContain("#f97316"); + }); + + it("uses the attention tint for the compact presentations when a row needs input", () => { + const layout = AgentActivity( + { + ...props, + activeCount: 2, + activities: [ + makeRow({}), + makeRow({ threadId: "thread-2", phase: "waiting_for_input", status: "Input" }), + ], + }, + environment as never, + ); + expect(JSON.stringify(layout.compactLeading)).toContain("#f97316"); + expect(JSON.stringify(layout.compactTrailing)).toContain("Input"); + expect(JSON.stringify(layout.minimal)).toContain("#f97316"); + }); + + it("deep links the banner to the row that needs attention", () => { + const layout = AgentActivity( + { + ...props, + activeCount: 2, + activities: [ + makeRow({}), + makeRow({ + threadId: "thread-2", + phase: "waiting_for_approval", + status: "Approval", + deepLink: "/threads/env-1/thread-2", + }), + ], + }, + environment as never, + ); + expect(JSON.stringify(layout.banner)).toContain( + '"widgetURL":"t3code://threads/env-1/thread-2"', + ); + }); + + it("deep links the banner to the first row when nothing needs attention", () => { + const layout = AgentActivity({ ...props, activities: [makeRow({})] }, environment as never); + expect(JSON.stringify(layout.banner)).toContain( + '"widgetURL":"t3code://threads/env-1/thread-1"', + ); + }); + + it("omits the deep link for unsafe paths and empty aggregates", () => { + expect(JSON.stringify(AgentActivity(props, environment as never))).not.toContain("widgetURL"); + expect( + JSON.stringify( + AgentActivity( + { ...props, activities: [makeRow({ deepLink: "//evil.example" })] }, + environment as never, + ), + ), + ).not.toContain("widgetURL"); + }); + + it("shows an overflow indicator when more activities are active than displayed", () => { + const layout = AgentActivity( + { + ...props, + activeCount: 5, + activities: [makeRow({}), makeRow({ threadId: "t2" }), makeRow({ threadId: "t3" })], + }, + environment as never, + ); + expect(JSON.stringify(layout.banner)).toContain("+2 more - Updated "); + }); }); diff --git a/apps/mobile/src/widgets/AgentActivity.tsx b/apps/mobile/src/widgets/AgentActivity.tsx index 56ada5f2a02..1f7efd2c6c8 100644 --- a/apps/mobile/src/widgets/AgentActivity.tsx +++ b/apps/mobile/src/widgets/AgentActivity.tsx @@ -1,5 +1,5 @@ import { HStack, Spacer, Text, VStack } from "@expo/ui/swift-ui"; -import { font, foregroundStyle, lineLimit, padding } from "@expo/ui/swift-ui/modifiers"; +import { font, foregroundStyle, lineLimit, padding, widgetURL } from "@expo/ui/swift-ui/modifiers"; import { createLiveActivity, type LiveActivityComponent, @@ -37,41 +37,88 @@ export interface AgentActivityProps { readonly activities: ReadonlyArray; } +// This function is serialized into the widget extension's JS bundle, so it +// must stay self-contained: no references to module-scope helpers, only the +// imported view/modifier factories. export function AgentActivity( props: AgentActivityProps, environment: LiveActivityEnvironment, ): LiveActivityLayout { "widget"; - const row0 = props.activities[0]; - const row1 = props.activities[1]; - const row2 = props.activities[2]; - const updatedAtMatch = /^\d{4}-\d{2}-\d{2}T(\d{2}):(\d{2}):/.exec(props.updatedAt); - const updatedAtHours24 = Number(updatedAtMatch?.[1]); - const updatedAtMinutes = updatedAtMatch?.[2]; - const updatedAt = - Number.isInteger(updatedAtHours24) && - updatedAtHours24 >= 0 && - updatedAtHours24 <= 23 && - updatedAtMinutes - ? `${updatedAtHours24 % 12 || 12}:${updatedAtMinutes}` - : "now"; - const activeLabel = `${props.activeCount} active`; const isLight = environment.colorScheme === "light"; const primaryForeground = isLight ? "#262626" : "#f5f5f5"; const secondaryForeground = isLight ? "#525252" : "#a3a3a3"; const mutedForeground = isLight ? "#737373" : "#8e8e93"; - const tint = environment.isLuminanceReduced - ? secondaryForeground - : row0?.phase === "waiting_for_approval" || row0?.phase === "waiting_for_input" - ? "#f97316" - : row0?.phase === "failed" - ? "#ef4444" - : "#14b8a6"; + + const phaseTint = (phase: AgentActivityPhase | undefined): string => { + if (environment.isLuminanceReduced) { + return secondaryForeground; + } + if (phase === "waiting_for_approval" || phase === "waiting_for_input") { + return "#f97316"; + } + if (phase === "failed") { + return "#ef4444"; + } + return "#14b8a6"; + }; + + const row0 = props.activities[0]; + const row1 = props.activities[1]; + const row2 = props.activities[2]; + const attentionRow = props.activities.find( + (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input", + ); + const failedRow = props.activities.find((row) => row.phase === "failed"); + const tint = phaseTint((attentionRow ?? failedRow ?? row0)?.phase); + + // Any registered scheme variant routes back to this app; taps are delivered + // to the widget's containing app, so the prod scheme is safe for all builds. + const deepLinkRow = attentionRow ?? row0; + const deepLink = + deepLinkRow && deepLinkRow.deepLink.startsWith("/") && !deepLinkRow.deepLink.startsWith("//") + ? `t3code://${deepLinkRow.deepLink.slice(1)}` + : null; + + const updatedDate = new Date(props.updatedAt); + const updatedMinutes = updatedDate.getMinutes(); + const updatedAt = Number.isNaN(updatedDate.getTime()) + ? "now" + : `${updatedDate.getHours() % 12 || 12}:${updatedMinutes < 10 ? "0" : ""}${updatedMinutes}`; + const activeLabel = `${props.activeCount} active`; + const overflowCount = props.activeCount - Math.min(props.activities.length, 3); + + const renderRow = (row: AgentActivityRowProps) => ( + + + + {row.threadTitle} + + + {row.projectTitle} - {row.modelTitle} + + + + + {row.status} + + + ); return { banner: ( - + - {row0 ? ( - - - - {row0.threadTitle} - - - {row0.projectTitle} - {row0.modelTitle} - - - - - {row0.status} - - - ) : null} - {row1 ? ( - - - - {row1.threadTitle} - - - {row1.projectTitle} - {row1.modelTitle} - - - - - {row1.status} - - - ) : null} - {row2 ? ( - - - - {row2.threadTitle} - - - {row2.projectTitle} - {row2.modelTitle} - - - - - {row2.status} - - - ) : null} + {row0 ? renderRow(row0) : null} + {row1 ? renderRow(row1) : null} + {row2 ? renderRow(row2) : null} - Updated {updatedAt} + {overflowCount > 0 ? `+${overflowCount} more - Updated ` : "Updated "} + {updatedAt} ), @@ -205,7 +184,11 @@ export function AgentActivity( ), compactTrailing: ( - {activeLabel} + {attentionRow + ? attentionRow.phase === "waiting_for_approval" + ? "Approval" + : "Input" + : activeLabel} ), minimal: ( @@ -241,78 +224,9 @@ export function AgentActivity( ), expandedBottom: ( - {row0 ? ( - - - - {row0.threadTitle} - - - {row0.projectTitle} - {row0.modelTitle} - - - - - {row0.status} - - - ) : null} - {row1 ? ( - - - - {row1.threadTitle} - - - {row1.projectTitle} - {row1.modelTitle} - - - - - {row1.status} - - - ) : null} - {row2 ? ( - - - - {row2.threadTitle} - - - {row2.projectTitle} - {row2.modelTitle} - - - - - {row2.status} - - - ) : null} + {row0 ? renderRow(row0) : null} + {row1 ? renderRow(row1) : null} + {row2 ? renderRow(row2) : null} ), }; diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts index 9671f4984b2..e267d51275f 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts @@ -28,6 +28,8 @@ function target(deviceId: string): LiveActivities.TargetRow { platform: "ios", ios_major_version: 18, app_version: "1.0.0", + bundle_id: null, + aps_environment: null, push_token: null, push_to_start_token: "start-token", preferences_json: "{}", @@ -628,3 +630,70 @@ describe("AgentActivityPublisher", () => { }, ); }); + +describe("isExpiredAgentActivityState", () => { + const hourMs = 60 * 60 * 1_000; + + it("expires running rows after two hours without an update", () => { + expect(AgentActivityPublisher.isExpiredAgentActivityState(state, 2 * hourMs - 1)).toBe(false); + expect(AgentActivityPublisher.isExpiredAgentActivityState(state, 2 * hourMs + 1)).toBe(true); + }); + + it("keeps waiting rows for a day", () => { + const waiting: RelayAgentActivityState = { ...state, phase: "waiting_for_approval" }; + expect(AgentActivityPublisher.isExpiredAgentActivityState(waiting, 23 * hourMs)).toBe(false); + expect(AgentActivityPublisher.isExpiredAgentActivityState(waiting, 25 * hourMs)).toBe(true); + }); + + it("treats rows with unparseable timestamps as expired", () => { + expect( + AgentActivityPublisher.isExpiredAgentActivityState({ ...state, updatedAt: "not-a-date" }, 0), + ).toBe(true); + }); +}); + +describe("makeAggregateState", () => { + const hourMs = 60 * 60 * 1_000; + + it("drops expired rows from the aggregate", () => { + const fresh: RelayAgentActivityState = { + ...state, + threadId: "thread-fresh" as RelayAgentActivityState["threadId"], + updatedAt: "1970-01-01T03:00:00.000Z", + }; + const aggregate = AgentActivityPublisher.makeAggregateState({ + activeStates: [state, fresh], + terminalState: null, + nowMs: 3 * hourMs, + }); + + expect(aggregate?.activeCount).toBe(1); + expect(aggregate?.activities).toMatchObject([{ threadId: "thread-fresh" }]); + }); + + it("returns null when every row has expired and nothing terminal remains", () => { + expect( + AgentActivityPublisher.makeAggregateState({ + activeStates: [state], + terminalState: null, + nowMs: 3 * hourMs, + }), + ).toBeNull(); + }); + + it("still reports the terminal state when active rows have expired", () => { + const terminalState: RelayAgentActivityState = { + ...state, + phase: "completed", + updatedAt: "1970-01-01T03:00:00.000Z", + }; + const aggregate = AgentActivityPublisher.makeAggregateState({ + activeStates: [state], + terminalState, + nowMs: 3 * hourMs, + }); + + expect(aggregate?.activeCount).toBe(0); + expect(aggregate?.activities).toMatchObject([{ phase: "completed" }]); + }); +}); diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts index abe05f07da2..8455702e2d3 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts @@ -8,6 +8,7 @@ import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import { sanitizeAgentActivityAggregateState } from "./agentActivityPayloads.ts"; import * as AgentActivityRows from "./AgentActivityRows.ts"; @@ -55,6 +56,7 @@ export const make = Effect.gen(function* () { ? makeAggregateState({ activeStates, terminalState: input.state && isTerminalPhase(input.state) ? input.state : null, + nowMs: input.nowMs, }) : null; const notificationOnlyAggregate = @@ -64,6 +66,7 @@ export const make = Effect.gen(function* () { ? makeAggregateState({ activeStates: isTerminalPhase(input.state) ? [] : [input.state], terminalState: isTerminalPhase(input.state) ? input.state : null, + nowMs: input.nowMs, }) : null; const targets = yield* liveActivities.listTargets({ userId: input.deliveryUser.userId }); @@ -110,8 +113,12 @@ export const make = Effect.gen(function* () { if (target === null) { return null; } - const aggregate = makeAggregateState({ activeStates, terminalState: null }); const now = yield* DateTime.now; + const aggregate = makeAggregateState({ + activeStates, + terminalState: null, + nowMs: now.epochMilliseconds, + }); return yield* apnsDeliveries.sendForTarget({ target, aggregate, @@ -186,6 +193,34 @@ function isTerminalPhase(state: RelayAgentActivityState): boolean { return state.phase === "completed" || state.phase === "failed"; } +// Rows are only removed when their environment publishes a terminal state. An +// environment that dies mid-run (machine off, process killed) never does, so +// without an age cutoff its threads inflate activeCount forever. Actively +// running phases expire quickly; waiting phases can legitimately sit for hours +// while a user ignores an approval prompt, so they get a longer window. The +// underlying database row is left in place: a late publish for the thread +// refreshes updatedAt and the row becomes visible again. +const RUNNING_AGENT_ACTIVITY_ROW_TTL_MS = 2 * 60 * 60 * 1_000; +const WAITING_AGENT_ACTIVITY_ROW_TTL_MS = 24 * 60 * 60 * 1_000; + +export function isExpiredAgentActivityState( + state: RelayAgentActivityState, + nowMs: number, +): boolean { + const updatedAtMs = Option.match(DateTime.make(state.updatedAt), { + onNone: () => Number.NaN, + onSome: (dt) => dt.epochMilliseconds, + }); + if (Number.isNaN(updatedAtMs)) { + return true; + } + const ttlMs = + state.phase === "running" || state.phase === "starting" + ? RUNNING_AGENT_ACTIVITY_ROW_TTL_MS + : WAITING_AGENT_ACTIVITY_ROW_TTL_MS; + return nowMs - updatedAtMs > ttlMs; +} + function aggregateRowForState(state: RelayAgentActivityState) { return { environmentId: state.environmentId, @@ -210,11 +245,14 @@ function terminalAggregateState(state: RelayAgentActivityState): RelayAgentActiv }); } -function makeAggregateState(input: { +export function makeAggregateState(input: { readonly activeStates: ReadonlyArray; readonly terminalState: RelayAgentActivityState | null; + readonly nowMs: number; }): RelayAgentActivityAggregateState | null { - const activeStates = input.activeStates.filter((state) => !isTerminalPhase(state)); + const activeStates = input.activeStates.filter( + (state) => !isTerminalPhase(state) && !isExpiredAgentActivityState(state, input.nowMs), + ); if (activeStates.length === 0) { return input.terminalState === null ? null : terminalAggregateState(input.terminalState); } diff --git a/infra/relay/src/agentActivity/ApnsClient.ts b/infra/relay/src/agentActivity/ApnsClient.ts index 1ac218cdd3c..1854bc06a7a 100644 --- a/infra/relay/src/agentActivity/ApnsClient.ts +++ b/infra/relay/src/agentActivity/ApnsClient.ts @@ -15,7 +15,11 @@ import { ApnsEnvironment as ApnsEnvironmentSchema, type ApnsCredentials } from " import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts"; const LIVE_ACTIVITY_NAME = "AgentActivity"; -const STALE_AFTER_SECONDS = 2 * 60; +// Updates only flow on domain events, so a healthy agent can be silent for +// minutes (long tool calls, pending approvals). Two minutes made iOS dim +// perfectly healthy activities; ten minutes still bounds how long a dead +// environment can look alive. +const STALE_AFTER_SECONDS = 10 * 60; const DISMISS_AFTER_SECONDS = 5 * 60; const ApnsLiveActivityEventSchema = Schema.Literals(["start", "update", "end"]); diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index da3c39cfa71..171ed6eeaab 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -127,6 +127,8 @@ const target: LiveActivities.TargetRow = { platform: "ios", ios_major_version: 18, app_version: "1.0.0", + bundle_id: null, + aps_environment: null, push_token: null, push_to_start_token: "start-token", preferences_json: enabledPreferences, @@ -389,6 +391,196 @@ describe("ApnsDeliveries", () => { }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs }))); }); + it.effect("queues Live Activity jobs with the device's APNs routing", () => { + const attempts: Array = []; + const queuedJobs: Array = []; + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + yield* deliveries.sendForTarget({ + target: { + ...target, + bundle_id: "com.t3tools.t3code.preview", + aps_environment: "production", + ended_at: "1970-01-01T00:00:05.000Z", + }, + aggregate, + nowMs: 10_000, + }); + + expect(queuedJobs).toMatchObject([ + { + payload: { + kind: "live_activity_start", + target: { + token: "start-token", + bundleId: "com.t3tools.t3code.preview", + apsEnvironment: "production", + }, + }, + }, + ]); + }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs }))); + }); + + it.effect("sends signed jobs to the device's APNs environment and bundle topic", () => { + const attempts: Array = []; + const requests: Array = []; + const payload = makeApnsDeliveryJobPayload({ + kind: "live_activity_update", + userId: target.user_id, + deviceId: target.device_id, + token: "activity-token", + bundleId: "com.t3tools.t3code.preview", + apsEnvironment: "sandbox", + aggregate, + createdAt: "1970-01-01T00:00:00.000Z", + expiresAt: "1970-01-01T00:10:00.000Z", + jobId: "job-routing-1", + }); + const signed = signApnsDeliveryJob({ + secret: config.apnsDeliveryJobSigningSecret, + payload, + }); + const execute = (request: HttpClientRequest.HttpClientRequest) => + Effect.sync(() => { + requests.push(request); + return HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); + }); + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + const result = yield* deliveries.processSignedJob(signed); + + expect(result.ok).toBe(true); + expect(requests).toHaveLength(1); + expect(requests[0]?.url).toBe("https://api.sandbox.push.apple.com/3/device/activity-token"); + expect(requests[0]?.headers["apns-topic"]).toBe( + "com.t3tools.t3code.preview.push-type.liveactivity", + ); + }).pipe( + Effect.provide( + makeLayer({ + attempts, + config: signingConfig, + execute, + }), + ), + ); + }); + + it.effect( + "suppresses all deliveries when the aggregate is unchanged while a row awaits input", + () => { + const attempts: Array = []; + const queuedJobs: Array = []; + const waitingAggregate: RelayAgentActivityAggregateState = { + ...aggregate, + activities: [ + { + ...aggregate.activities[0]!, + phase: "waiting_for_input", + status: "Input", + }, + ], + }; + const waitingAggregateJson = JSON.stringify(waitingAggregate); + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + const result = yield* deliveries.sendForTarget({ + target: { + ...target, + // A registered alert token must not turn the suppressed Live + // Activity update into an alert push on every republish. + push_token: "apns-device-token", + last_aggregate_json: waitingAggregateJson, + last_live_activity_delivery_at: "1970-01-01T00:00:04.000Z", + }, + aggregate: waitingAggregate, + nowMs: 5_000, + }); + + expect(result).toBeNull(); + expect(queuedJobs).toEqual([]); + expect(attempts).toEqual([]); + }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs }))); + }, + ); + + it.effect( + "queues an update inside the throttle window when a changed aggregate awaits input", + () => { + const attempts: Array = []; + const queuedJobs: Array = []; + const waitingAggregate: RelayAgentActivityAggregateState = { + ...aggregate, + activities: [ + { + ...aggregate.activities[0]!, + phase: "waiting_for_input", + status: "Input", + }, + ], + }; + const previousAggregateJson = JSON.stringify(aggregate); + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + const result = yield* deliveries.sendForTarget({ + target: { + ...target, + last_aggregate_json: previousAggregateJson, + last_live_activity_delivery_at: "1970-01-01T00:00:04.000Z", + }, + aggregate: waitingAggregate, + nowMs: 5_000, + }); + + expect(result?.kind).toBe("live_activity_update"); + expect(queuedJobs).toMatchObject([ + { + payload: { + kind: "live_activity_update", + target: { + token: "activity-token", + }, + }, + }, + ]); + }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs }))); + }, + ); + + it.effect( + "throttles updates for changed aggregates with stable counts and no pending attention", + () => { + const attempts: Array = []; + const queuedJobs: Array = []; + const changedAggregate: RelayAgentActivityAggregateState = { + ...aggregate, + updatedAt: "1970-01-01T00:00:04.000Z", + }; + const previousAggregateJson = JSON.stringify(aggregate); + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + const result = yield* deliveries.sendForTarget({ + target: { + ...target, + last_aggregate_json: previousAggregateJson, + last_live_activity_delivery_at: "1970-01-01T00:00:04.000Z", + }, + aggregate: changedAggregate, + nowMs: 5_000, + }); + + expect(result).toBeNull(); + expect(queuedJobs).toEqual([]); + }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs }))); + }, + ); + it.effect("queues an end for an active Live Activity when Live Activities are disabled", () => { const attempts: Array = []; const queuedJobs: Array = []; diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index c83eaf34f2e..927d8f2a316 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -130,6 +130,12 @@ function parsePreferences(value: string): RelayAgentAwarenessPreferences | null return Option.getOrNull(decodeRelayAgentAwarenessPreferencesJson(value)); } +function aggregateNeedsAttention(aggregate: RelayAgentActivityAggregateState): boolean { + return aggregate.activities.some( + (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input", + ); +} + function shouldUpdateLiveActivity(input: { readonly previousAggregate: RelayAgentActivityAggregateState | null; readonly nextAggregate: RelayAgentActivityAggregateState; @@ -139,11 +145,14 @@ function shouldUpdateLiveActivity(input: { if (!input.previousAggregate) { return true; } + if (JSON.stringify(input.previousAggregate) === JSON.stringify(input.nextAggregate)) { + return false; + } if (input.previousAggregate.activeCount !== input.nextAggregate.activeCount) { return true; } - if (JSON.stringify(input.previousAggregate) === JSON.stringify(input.nextAggregate)) { - return false; + if (aggregateNeedsAttention(input.nextAggregate)) { + return true; } const lastDeliveryAtMs = input.lastDeliveryAt === null @@ -191,11 +200,14 @@ function notificationForAggregate(input: { }; } +// "suppressed" means a Live Activity owns this state but no update is due +// (unchanged or throttled); callers must not fall back to an alert push, or +// every republish of a waiting aggregate would ring the device. function chooseLiveActivityDelivery(input: { readonly target: LiveActivities.TargetRow; readonly aggregate: RelayAgentActivityAggregateState | null; readonly nowMs: number; -}): ChosenLiveActivityDelivery | null { +}): ChosenLiveActivityDelivery | "suppressed" | null { const hasActiveActivity = input.target.ended_at === null && (input.target.remote_start_queued_at !== null || @@ -237,16 +249,13 @@ function chooseLiveActivityDelivery(input: { nextAggregate: input.aggregate, lastDeliveryAt: input.target.last_live_activity_delivery_at, nowMs: input.nowMs, - }) || - input.aggregate.activities.some( - (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input", - ) + }) ? { kind: "live_activity_update", token: input.target.activity_push_token, aggregate: input.aggregate, } - : null; + : "suppressed"; } function chooseDelivery(input: { @@ -255,6 +264,9 @@ function chooseDelivery(input: { readonly nowMs: number; }): ChosenDelivery | null { const liveActivityDelivery = chooseLiveActivityDelivery(input); + if (liveActivityDelivery === "suppressed") { + return null; + } if (liveActivityDelivery) { return liveActivityDelivery; } @@ -365,6 +377,24 @@ const recoverApnsDeliveryTransportError = ( interface LiveActivityDeliveryTarget { readonly user_id: string; readonly device_id: string; + readonly bundle_id?: string | null; + readonly aps_environment?: "sandbox" | "production" | null; +} + +// Devices register the bundle id and APS environment of the build they run +// (dev/preview/prod variants have distinct bundle ids; development-signed +// builds get sandbox tokens). Sending with mismatched routing yields +// DeviceTokenNotForTopic/BadDeviceToken, so per-device values override the +// relay-wide defaults when present. +function credentialsForTarget( + credentials: RelayConfiguration.RelayConfiguration["Service"]["apns"], + target: LiveActivityDeliveryTarget, +): RelayConfiguration.RelayConfiguration["Service"]["apns"] { + return { + ...credentials, + ...(target.bundle_id ? { bundleId: target.bundle_id } : {}), + ...(target.aps_environment ? { environment: target.aps_environment } : {}), + }; } function expectedCurrentToken(input: { @@ -540,7 +570,7 @@ export const make = Effect.gen(function* () { } const result = yield* apns .sendLiveActivityRequest({ - credentials: config.apns, + credentials: credentialsForTarget(config.apns, input.target), request, issuedAtUnixSeconds: epochSeconds, }) @@ -663,7 +693,7 @@ export const make = Effect.gen(function* () { } const result = yield* apns .sendPushNotificationRequest({ - credentials: config.apns, + credentials: credentialsForTarget(config.apns, input.target), request, issuedAtUnixSeconds: epochSeconds, }) @@ -752,6 +782,8 @@ export const make = Effect.gen(function* () { target: { user_id: payload.target.userId, device_id: payload.target.deviceId, + bundle_id: payload.target.bundleId ?? null, + aps_environment: payload.target.apsEnvironment ?? null, }, token: payload.target.token, sourceJobId: payload.jobId, @@ -763,6 +795,8 @@ export const make = Effect.gen(function* () { target: { user_id: payload.target.userId, device_id: payload.target.deviceId, + bundle_id: payload.target.bundleId ?? null, + aps_environment: payload.target.apsEnvironment ?? null, }, token: payload.target.token, sourceJobId: payload.jobId, @@ -783,6 +817,8 @@ export const make = Effect.gen(function* () { target: { user_id: payload.target.userId, device_id: payload.target.deviceId, + bundle_id: payload.target.bundleId ?? null, + aps_environment: payload.target.apsEnvironment ?? null, }, token: payload.target.token, sourceJobId: payload.jobId, @@ -804,6 +840,8 @@ export const make = Effect.gen(function* () { userId: input.target.user_id, deviceId: input.target.device_id, token, + bundleId: input.target.bundle_id, + apsEnvironment: input.target.aps_environment, notification, }) : Effect.succeed(null); @@ -822,6 +860,8 @@ export const make = Effect.gen(function* () { userId: input.target.user_id, deviceId: input.target.device_id, token: delivery.token, + bundleId: input.target.bundle_id, + apsEnvironment: input.target.aps_environment, notification: delivery.notification, }); return result; @@ -831,6 +871,8 @@ export const make = Effect.gen(function* () { deviceId: input.target.device_id, kind: delivery.kind, token: delivery.token, + bundleId: input.target.bundle_id, + apsEnvironment: input.target.aps_environment, aggregate: delivery.aggregate, }); const notification = notificationForAggregate({ @@ -842,6 +884,8 @@ export const make = Effect.gen(function* () { userId: input.target.user_id, deviceId: input.target.device_id, token: input.target.push_token, + bundleId: input.target.bundle_id, + apsEnvironment: input.target.aps_environment, notification, }); } diff --git a/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts index 6c1fd79dc1c..66614830149 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts @@ -58,12 +58,16 @@ export class ApnsDeliveryQueue extends Context.Service< readonly userId: string; readonly deviceId: string; readonly token: string; + readonly bundleId?: string | null; + readonly apsEnvironment?: "sandbox" | "production" | null; readonly aggregate: ApnsDeliveryJobPayload["aggregate"]; }) => Effect.Effect; readonly enqueuePushNotification: (input: { readonly userId: string; readonly deviceId: string; readonly token: string; + readonly bundleId?: string | null; + readonly apsEnvironment?: "sandbox" | "production" | null; readonly notification: NonNullable; }) => Effect.Effect; } @@ -160,6 +164,8 @@ export const make = Effect.gen(function* () { userId: input.userId, deviceId: input.deviceId, token: input.token, + bundleId: input.bundleId, + apsEnvironment: input.apsEnvironment, aggregate: null, notification: sanitizeApnsNotificationPayload(input.notification), jobId, diff --git a/infra/relay/src/agentActivity/Devices.test.ts b/infra/relay/src/agentActivity/Devices.test.ts index 553899da178..17c454238d6 100644 --- a/infra/relay/src/agentActivity/Devices.test.ts +++ b/infra/relay/src/agentActivity/Devices.test.ts @@ -15,6 +15,8 @@ const registration: RelayDeviceRegistrationRequest = { platform: "ios", iosMajorVersion: 18, appVersion: "1.0.0" as RelayDeviceRegistrationRequest["appVersion"], + bundleId: "com.t3tools.t3code.preview" as RelayDeviceRegistrationRequest["bundleId"], + apsEnvironment: "production", pushToken: "apns-device-token" as RelayDeviceRegistrationRequest["pushToken"], pushToStartToken: "push-to-start-token" as RelayDeviceRegistrationRequest["pushToStartToken"], preferences: { @@ -106,6 +108,8 @@ describe("Devices", () => { expect.objectContaining({ userId: "user-2", deviceId: "device-1", + bundleId: "com.t3tools.t3code.preview", + apsEnvironment: "production", pushToken: "apns-device-token", pushToStartToken: "push-to-start-token", }), @@ -163,28 +167,64 @@ describe("Devices", () => { ); }); - it.effect("lists safe notification state without exposing APNs tokens", () => { + it.effect("lists notification state with delivery diagnostics without exposing tokens", () => { const dialect = new PgDialect(); let condition: SQL | null = null; const fakeDb = { select: () => ({ from: (table: unknown) => { - expect(table).toBe(relayMobileDevices); + if (table === relayMobileDevices) { + return { + where: (nextCondition: SQL) => { + condition = nextCondition; + return Effect.succeed([ + { + deviceId: "device-1", + label: "Julius's iPhone", + platform: "ios" as const, + iosMajorVersion: 18, + appVersion: "1.0.0", + bundleId: "com.t3tools.t3code.preview", + apsEnvironment: "production" as const, + pushToken: "apns-device-token", + pushToStartToken: null, + preferences: registration.preferences, + updatedAt: "2026-06-01T00:00:00.000Z", + }, + ]); + }, + }; + } + if (table === relayLiveActivities) { + return { + where: () => + Effect.succeed([{ deviceId: "device-1", activityPushToken: "activity-token" }]), + }; + } return { - where: (nextCondition: SQL) => { - condition = nextCondition; - return Effect.succeed([ - { - deviceId: "device-1", - label: "Julius's iPhone", - platform: "ios" as const, - iosMajorVersion: 18, - appVersion: "1.0.0", - preferences: registration.preferences, - updatedAt: "2026-06-01T00:00:00.000Z", - }, - ]); - }, + where: () => ({ + orderBy: () => ({ + limit: () => + Effect.succeed([ + { + deviceId: "device-1", + createdAt: "2026-06-05T01:02:59.566Z", + kind: "live_activity_end", + apnsStatus: 400, + apnsReason: "DeviceTokenNotForTopic", + transportError: null, + }, + { + deviceId: "device-1", + createdAt: "2026-06-01T00:00:00.000Z", + kind: "live_activity_update", + apnsStatus: 200, + apnsReason: null, + transportError: null, + }, + ]), + }), + }), }; }, }), @@ -216,6 +256,17 @@ describe("Devices", () => { liveActivities: { enabled: true, }, + diagnostics: { + bundleId: "com.t3tools.t3code.preview", + apsEnvironment: "production", + hasPushToken: true, + hasPushToStartToken: false, + hasLiveActivityToken: true, + lastDeliveryAt: "2026-06-05T01:02:59.566Z", + lastDeliveryKind: "live_activity_end", + lastDeliveryStatus: 400, + lastDeliveryError: "DeviceTokenNotForTopic", + }, updatedAt: "2026-06-01T00:00:00.000Z", }, ]); @@ -282,10 +333,14 @@ describe("Devices", () => { it.effect("attaches the user to device list failures", () => { const cause = new Error("device list failed"); + const failing = Effect.fail(cause); const fakeDb = { select: () => ({ from: () => ({ - where: () => Effect.fail(cause), + where: () => + Object.assign(failing, { + orderBy: () => ({ limit: () => failing }), + }), }), }), } as unknown as RelayDb.RelayDb["Service"]; diff --git a/infra/relay/src/agentActivity/Devices.ts b/infra/relay/src/agentActivity/Devices.ts index 86e3564d5be..b88435cd8c2 100644 --- a/infra/relay/src/agentActivity/Devices.ts +++ b/infra/relay/src/agentActivity/Devices.ts @@ -7,11 +7,15 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import { and, eq } from "drizzle-orm"; +import { and, desc, eq } from "drizzle-orm"; import { sql } from "drizzle-orm"; import * as RelayDb from "../db.ts"; -import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts"; +import { + relayDeliveryAttempts, + relayLiveActivities, + relayMobileDevices, +} from "../persistence/schema.ts"; export class DeviceRegistrationPersistenceError extends Schema.TaggedErrorClass()( "DeviceRegistrationPersistenceError", @@ -130,6 +134,8 @@ export const make = Effect.gen(function* () { platform: registration.platform, iosMajorVersion: registration.iosMajorVersion, appVersion: registration.appVersion ?? null, + bundleId: registration.bundleId ?? null, + apsEnvironment: registration.apsEnvironment ?? null, pushToken: registration.pushToken ?? null, pushToStartToken: registration.pushToStartToken ?? null, preferencesJson: registration.preferences, @@ -143,6 +149,13 @@ export const make = Effect.gen(function* () { label: registration.label, iosMajorVersion: registration.iosMajorVersion, appVersion: registration.appVersion ?? null, + // Preserve routing from newer app builds when an older build + // re-registers without these fields. + bundleId: sql`coalesce(excluded.bundle_id, ${relayMobileDevices.bundleId})`, + apsEnvironment: sql`coalesce( + excluded.aps_environment, + ${relayMobileDevices.apsEnvironment} + )`, pushToken: sql`coalesce(excluded.push_token, ${relayMobileDevices.pushToken})`, pushToStartToken: sql`coalesce( excluded.push_to_start_token, @@ -213,41 +226,103 @@ export const make = Effect.gen(function* () { ); }), listForUser: Effect.fn("relay.devices.listForUser")(function* (input) { - const rows = yield* db - .select({ - deviceId: relayMobileDevices.deviceId, - label: relayMobileDevices.label, - platform: relayMobileDevices.platform, - iosMajorVersion: relayMobileDevices.iosMajorVersion, - appVersion: relayMobileDevices.appVersion, - preferences: relayMobileDevices.preferencesJson, - updatedAt: relayMobileDevices.updatedAt, - }) - .from(relayMobileDevices) - .where(eq(relayMobileDevices.userId, input.userId)) - .pipe( - Effect.mapError( - (cause) => new DeviceListPersistenceError({ userId: input.userId, cause }), - ), - ); - return rows.map((row) => ({ - deviceId: row.deviceId, - label: row.label, - platform: row.platform, - iosMajorVersion: row.iosMajorVersion, - appVersion: row.appVersion, - notifications: { - enabled: row.preferences.notificationsEnabled, - notifyOnApproval: row.preferences.notifyOnApproval, - notifyOnInput: row.preferences.notifyOnInput, - notifyOnCompletion: row.preferences.notifyOnCompletion, - notifyOnFailure: row.preferences.notifyOnFailure, - }, - liveActivities: { - enabled: row.preferences.liveActivitiesEnabled, - }, - updatedAt: row.updatedAt, - })); + const mapListError = Effect.mapError( + (cause: unknown) => new DeviceListPersistenceError({ userId: input.userId, cause }), + ); + const [rows, activityRows, attemptRows] = yield* Effect.all( + [ + db + .select({ + deviceId: relayMobileDevices.deviceId, + label: relayMobileDevices.label, + platform: relayMobileDevices.platform, + iosMajorVersion: relayMobileDevices.iosMajorVersion, + appVersion: relayMobileDevices.appVersion, + bundleId: relayMobileDevices.bundleId, + apsEnvironment: relayMobileDevices.apsEnvironment, + pushToken: relayMobileDevices.pushToken, + pushToStartToken: relayMobileDevices.pushToStartToken, + preferences: relayMobileDevices.preferencesJson, + updatedAt: relayMobileDevices.updatedAt, + }) + .from(relayMobileDevices) + .where(eq(relayMobileDevices.userId, input.userId)) + .pipe(mapListError), + db + .select({ + deviceId: relayLiveActivities.deviceId, + activityPushToken: relayLiveActivities.activityPushToken, + }) + .from(relayLiveActivities) + .where(eq(relayLiveActivities.userId, input.userId)) + .pipe(mapListError), + db + .select({ + deviceId: relayDeliveryAttempts.deviceId, + createdAt: relayDeliveryAttempts.createdAt, + kind: relayDeliveryAttempts.kind, + apnsStatus: relayDeliveryAttempts.apnsStatus, + apnsReason: relayDeliveryAttempts.apnsReason, + transportError: relayDeliveryAttempts.transportError, + }) + .from(relayDeliveryAttempts) + .where(eq(relayDeliveryAttempts.userId, input.userId)) + .orderBy(desc(relayDeliveryAttempts.createdAt)) + .limit(100) + .pipe(mapListError), + ], + { concurrency: 3 }, + ); + + const activityTokenByDevice = new Map( + activityRows.map((row) => [row.deviceId, row.activityPushToken]), + ); + const lastAttemptByDevice = new Map(); + for (const attempt of attemptRows) { + if (attempt.deviceId && !lastAttemptByDevice.has(attempt.deviceId)) { + lastAttemptByDevice.set(attempt.deviceId, attempt); + } + } + + return rows.map((row) => { + const attempt = lastAttemptByDevice.get(row.deviceId) ?? null; + const attemptOk = + attempt?.apnsStatus != null && attempt.apnsStatus >= 200 && attempt.apnsStatus < 300; + const lastDeliveryError = attempt + ? ((attempt.transportError?.trim() || + (attempt.apnsStatus !== null && !attemptOk ? attempt.apnsReason?.trim() : null)) ?? + null) + : null; + return { + deviceId: row.deviceId, + label: row.label, + platform: row.platform, + iosMajorVersion: row.iosMajorVersion, + appVersion: row.appVersion, + notifications: { + enabled: row.preferences.notificationsEnabled, + notifyOnApproval: row.preferences.notifyOnApproval, + notifyOnInput: row.preferences.notifyOnInput, + notifyOnCompletion: row.preferences.notifyOnCompletion, + notifyOnFailure: row.preferences.notifyOnFailure, + }, + liveActivities: { + enabled: row.preferences.liveActivitiesEnabled, + }, + diagnostics: { + bundleId: row.bundleId, + apsEnvironment: row.apsEnvironment, + hasPushToken: row.pushToken !== null, + hasPushToStartToken: row.pushToStartToken !== null, + hasLiveActivityToken: (activityTokenByDevice.get(row.deviceId) ?? null) !== null, + lastDeliveryAt: attempt?.createdAt ?? null, + lastDeliveryKind: attempt?.kind ?? null, + lastDeliveryStatus: attempt?.apnsStatus ?? null, + lastDeliveryError: lastDeliveryError || null, + }, + updatedAt: row.updatedAt, + }; + }); }), }); }); diff --git a/infra/relay/src/agentActivity/LiveActivities.ts b/infra/relay/src/agentActivity/LiveActivities.ts index 608ee0704ab..f6109b07a19 100644 --- a/infra/relay/src/agentActivity/LiveActivities.ts +++ b/infra/relay/src/agentActivity/LiveActivities.ts @@ -69,6 +69,8 @@ export interface DeviceRow { readonly platform: "ios"; readonly ios_major_version: number; readonly app_version: string | null; + readonly bundle_id: string | null; + readonly aps_environment: "sandbox" | "production" | null; readonly push_token: string | null; readonly push_to_start_token: string | null; readonly preferences_json: string; @@ -196,6 +198,8 @@ export const make = Effect.gen(function* () { platform: relayMobileDevices.platform, ios_major_version: relayMobileDevices.iosMajorVersion, app_version: relayMobileDevices.appVersion, + bundle_id: relayMobileDevices.bundleId, + aps_environment: relayMobileDevices.apsEnvironment, push_token: relayMobileDevices.pushToken, push_to_start_token: relayMobileDevices.pushToStartToken, preferences_json: relayMobileDevices.preferencesJson, diff --git a/infra/relay/src/agentActivity/MobileRegistrations.test.ts b/infra/relay/src/agentActivity/MobileRegistrations.test.ts index a223e9707c4..2e2a4f3d245 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.test.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts @@ -404,6 +404,8 @@ describe("MobileRegistrations", () => { platform: "ios", ios_major_version: 18, app_version: "1.0.0", + bundle_id: null, + aps_environment: null, push_token: "apns-device-token", push_to_start_token: "push-to-start-token", preferences_json: JSON.stringify(device.preferences), diff --git a/infra/relay/src/agentActivity/apnsDeliveryJobs.ts b/infra/relay/src/agentActivity/apnsDeliveryJobs.ts index 2af61085eab..aba4db8d538 100644 --- a/infra/relay/src/agentActivity/apnsDeliveryJobs.ts +++ b/infra/relay/src/agentActivity/apnsDeliveryJobs.ts @@ -49,6 +49,10 @@ export const ApnsDeliveryJobPayload = Schema.Struct({ userId: Schema.String, deviceId: Schema.String, token: Schema.String, + // Per-device APNs routing; absent on jobs queued by older relay builds, + // which fall back to the configured defaults. + bundleId: Schema.optional(Schema.NullOr(Schema.String)), + apsEnvironment: Schema.optional(Schema.NullOr(Schema.Literals(["sandbox", "production"]))), }), aggregate: Schema.NullOr(RelayAgentActivityAggregateState), notification: Schema.NullOr(ApnsNotificationPayload), @@ -224,6 +228,8 @@ export function makeApnsDeliveryJobPayload(input: { readonly userId: string; readonly deviceId: string; readonly token: string; + readonly bundleId?: string | null | undefined; + readonly apsEnvironment?: "sandbox" | "production" | null | undefined; readonly aggregate: ApnsDeliveryJobPayload["aggregate"]; readonly notification?: ApnsNotificationPayload | null; readonly createdAt: string; @@ -238,6 +244,8 @@ export function makeApnsDeliveryJobPayload(input: { userId: input.userId, deviceId: input.deviceId, token: input.token, + ...(input.bundleId ? { bundleId: input.bundleId } : {}), + ...(input.apsEnvironment ? { apsEnvironment: input.apsEnvironment } : {}), }, aggregate: input.aggregate, notification: input.notification ?? null, diff --git a/infra/relay/src/persistence/schema.ts b/infra/relay/src/persistence/schema.ts index ab3d2dfd97a..0952ade1731 100644 --- a/infra/relay/src/persistence/schema.ts +++ b/infra/relay/src/persistence/schema.ts @@ -24,6 +24,8 @@ export const relayMobileDevices = pgTable( platform: varchar("platform", { length: 16 }).notNull().$type<"ios">(), iosMajorVersion: integer("ios_major_version").notNull(), appVersion: varchar("app_version", { length: 64 }), + bundleId: varchar("bundle_id", { length: 255 }), + apsEnvironment: varchar("aps_environment", { length: 16 }).$type<"sandbox" | "production">(), pushToken: text("push_token"), pushToStartToken: text("push_to_start_token"), preferencesJson: jsonb("preferences_json").notNull().$type(), diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index dea3709f488..f79e1b697d8 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -35,18 +35,40 @@ export const RelayAgentAwarenessPreferences = Schema.Struct({ }); export type RelayAgentAwarenessPreferences = typeof RelayAgentAwarenessPreferences.Type; +export const RelayApnsEnvironment = Schema.Literals(["sandbox", "production"]); +export type RelayApnsEnvironment = typeof RelayApnsEnvironment.Type; + export const RelayDeviceRegistrationRequest = Schema.Struct({ deviceId: TrimmedNonEmptyString, label: TrimmedNonEmptyString, platform: RelayAgentAwarenessPlatform, iosMajorVersion: Schema.Int.check(Schema.isGreaterThanOrEqualTo(18)), appVersion: Schema.optional(TrimmedNonEmptyString), + // APNs routing for this install: the topic must match the app's bundle id + // (dev/preview/prod variants differ) and development-signed builds receive + // sandbox tokens. Optional so older app builds keep registering; the relay + // falls back to its configured defaults. + bundleId: Schema.optional(TrimmedNonEmptyString), + apsEnvironment: Schema.optional(RelayApnsEnvironment), pushToken: Schema.optional(TrimmedNonEmptyString), pushToStartToken: Schema.optional(TrimmedNonEmptyString), preferences: RelayAgentAwarenessPreferences, }); export type RelayDeviceRegistrationRequest = typeof RelayDeviceRegistrationRequest.Type; +export const RelayClientDeviceDeliveryDiagnostics = Schema.Struct({ + bundleId: Schema.NullOr(TrimmedNonEmptyString), + apsEnvironment: Schema.NullOr(RelayApnsEnvironment), + hasPushToken: Schema.Boolean, + hasPushToStartToken: Schema.Boolean, + hasLiveActivityToken: Schema.Boolean, + lastDeliveryAt: Schema.NullOr(TrimmedNonEmptyString), + lastDeliveryKind: Schema.NullOr(TrimmedNonEmptyString), + lastDeliveryStatus: Schema.NullOr(Schema.Int), + lastDeliveryError: Schema.NullOr(TrimmedNonEmptyString), +}); +export type RelayClientDeviceDeliveryDiagnostics = typeof RelayClientDeviceDeliveryDiagnostics.Type; + export const RelayClientDeviceRecord = Schema.Struct({ deviceId: TrimmedNonEmptyString, label: TrimmedNonEmptyString, @@ -63,6 +85,8 @@ export const RelayClientDeviceRecord = Schema.Struct({ liveActivities: Schema.Struct({ enabled: Schema.Boolean, }), + // Optional so clients tolerate older relay deployments. + diagnostics: Schema.optional(RelayClientDeviceDeliveryDiagnostics), updatedAt: TrimmedNonEmptyString, }); export type RelayClientDeviceRecord = typeof RelayClientDeviceRecord.Type;