From 9c3eb7bac628cee38d2843be5c9c9d5a394bc0c1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 3 Jul 2026 20:05:26 -0700 Subject: [PATCH 01/26] Improve live activity routing, widgets, and registration integrity - Per-device APNs routing (bundle id + environment) so preview/dev builds receive pushes from the shared relay (+ migration for the new columns) - Settings notification/Live Activity toggles reflect actual relay registration success; cannot read as enabled when the device is not registered - Persistent register-once: skip re-registering an unchanged payload for the same account across launches; clear only on sign-out - Headless publish + tunnel-free linking: `t3 connect publish` toggles agent activity publishing and auto-establishes a publish-only (no managed tunnel) link, and `t3 connect link --publish-only` links for publishing alone, so activity flows to mobile clients that reach the environment out of band (e.g. Tailscale) without T3 Connect. Relay accepts publish-only links with a nominal endpoint; env proofs advertise the manual provider kind - Foreground Live Activity refresh, stale/ghost-row hardening, dedup fixes - Tighten widget deep linking and per-row rendering for active agents Co-Authored-By: Claude Opus 4.8 --- apps/mobile/app.config.ts | 3 + .../agent-awareness/registrationPayload.ts | 11 + .../remoteRegistration.test.ts | 184 +- .../agent-awareness/remoteRegistration.ts | 146 +- .../features/settings/SettingsRouteScreen.tsx | 73 +- apps/mobile/src/lib/storage.ts | 35 + apps/mobile/src/widgets/AgentActivity.test.ts | 119 +- apps/mobile/src/widgets/AgentActivity.tsx | 252 +-- apps/server/src/cli/connect.test.ts | 13 +- apps/server/src/cli/connect.ts | 136 +- apps/server/src/cloud/CliState.test.ts | 23 + apps/server/src/cloud/CliState.ts | 28 +- apps/server/src/cloud/http.test.ts | 37 +- apps/server/src/cloud/http.ts | 58 +- apps/server/src/server.test.ts | 5 +- apps/web/src/cloud/linkEnvironment.test.ts | 55 + apps/web/src/cloud/linkEnvironment.ts | 24 +- apps/web/src/cloud/linkEnvironmentAtoms.ts | 8 +- .../settings/ConnectionsSettings.tsx | 213 +-- .../20260706061126_migration/migration.sql | 3 + .../20260706061126_migration/snapshot.json | 1479 +++++++++++++++++ .../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 | 4 + infra/relay/src/agentActivity/Devices.ts | 9 + .../relay/src/agentActivity/LiveActivities.ts | 4 + .../agentActivity/MobileRegistrations.test.ts | 2 + .../src/agentActivity/apnsDeliveryJobs.ts | 8 + .../environments/EnvironmentLinker.test.ts | 65 + .../src/environments/EnvironmentLinker.ts | 6 +- infra/relay/src/persistence/schema.ts | 2 + packages/contracts/src/environmentHttp.ts | 4 + packages/contracts/src/relay.ts | 9 + 37 files changed, 3036 insertions(+), 363 deletions(-) create mode 100644 infra/relay/migrations/postgres/20260706061126_migration/migration.sql create mode 100644 infra/relay/migrations/postgres/20260706061126_migration/snapshot.json diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index cc9da520ca0..d2d9862b9fe 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -161,6 +161,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/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..deff5160042 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -16,10 +16,17 @@ 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 { + clearAgentAwarenessRegistrationRecord, + loadAgentAwarenessRegistrationRecord, + loadOrCreateAgentAwarenessDeviceId, + saveAgentAwarenessRegistrationRecord, +} from "../../lib/storage"; +import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload"; import { AgentAwarenessOperationError, __resetAgentAwarenessRemoteRegistrationForTest, + getAgentAwarenessRegistrationStatus, refreshActiveLiveActivityRemoteRegistration, refreshAgentAwarenessRegistration, normalizeAgentAwarenessRelayBaseUrl, @@ -41,6 +48,12 @@ const backgroundRuntime = vi.hoisted(() => ({ readonly resolve: (exit: Exit.Exit) => void; }>, })); +const appStateMock = vi.hoisted(() => ({ + listeners: [] as Array<(state: string) => void>, +})); +const registrationRecordStore = vi.hoisted(() => ({ + current: null as { readonly identity: string; readonly signature: string } | null, +})); vi.mock("expo-constants", () => ({ default: { @@ -100,6 +113,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", () => ({ @@ -115,6 +141,17 @@ vi.mock("../../lib/storage", () => ({ loadAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), loadPreferences: vi.fn(() => Promise.resolve({})), + loadAgentAwarenessRegistrationRecord: vi.fn(() => + Promise.resolve(registrationRecordStore.current), + ), + saveAgentAwarenessRegistrationRecord: vi.fn((record: { identity: string; signature: string }) => { + registrationRecordStore.current = record; + return Promise.resolve(); + }), + clearAgentAwarenessRegistrationRecord: vi.fn(() => { + registrationRecordStore.current = null; + return Promise.resolve(); + }), })); function proofIat(proof: string): number { @@ -176,6 +213,12 @@ describe("makeRelayDeviceRegistrationRequest", () => { backgroundRuntime.pending.length = 0; Constants.expoConfig!.extra = {}; __resetAgentAwarenessRemoteRegistrationForTest(); + appStateMock.listeners.length = 0; + registrationRecordStore.current = null; + vi.mocked(saveAgentAwarenessRegistrationRecord).mockClear(); + vi.mocked(loadAgentAwarenessRegistrationRecord).mockClear(); + vi.mocked(clearAgentAwarenessRegistrationRecord).mockClear(); + vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockResolvedValue("device-1"); widgetMocks.getInstances.mockReset(); widgetMocks.getInstances.mockReturnValue([]); }); @@ -213,6 +256,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 +397,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* () { @@ -395,6 +510,73 @@ describe("makeRelayDeviceRegistrationRequest", () => { nowEpochSeconds: proofIat(dpop), }), ).toMatchObject({ ok: true }); + expect(getAgentAwarenessRegistrationStatus()).toBe("registered"); + }).pipe(Effect.provide(relayTestLayer)); + }); + + it.effect("marks registration failed when device registration cannot complete", () => { + Constants.expoConfig!.extra = { + relay: { + url: "https://relay.example.test/", + }, + }; + vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockRejectedValueOnce( + new Error("registration failed"), + ); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + + return Effect.gen(function* () { + // Drive the registration directly so the assertion does not depend on the + // background queue draining; refreshAgentAwarenessRegistration swallows the + // error but must record the failed status so the settings toggles cannot + // read as enabled. + yield* refreshAgentAwarenessRegistration(); + expect(getAgentAwarenessRegistrationStatus()).toBe("failed"); + }).pipe(Effect.provide(relayTestLayer)); + }); + + it("clears registration status on cloud sign-out", () => { + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + setAgentAwarenessRelayTokenProvider(null); + expect(getAgentAwarenessRegistrationStatus()).toBe("unknown"); + expect(clearAgentAwarenessRegistrationRecord).toHaveBeenCalled(); + }); + + it.effect("does not re-register the same account when nothing has changed", () => { + Constants.expoConfig!.extra = { + relay: { + url: "https://relay.example.test/", + }, + }; + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + + return Effect.gen(function* () { + yield* refreshAgentAwarenessRegistration(); + expect(getAgentAwarenessRegistrationStatus()).toBe("registered"); + expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(1); + expect(registrationRecordStore.current).not.toBeNull(); + + // Second attempt with an identical payload must skip the relay entirely, + // so no new registration record is written. + vi.mocked(saveAgentAwarenessRegistrationRecord).mockClear(); + yield* refreshAgentAwarenessRegistration(); + expect(getAgentAwarenessRegistrationStatus()).toBe("registered"); + expect(saveAgentAwarenessRegistrationRecord).not.toHaveBeenCalled(); + }).pipe(Effect.provide(relayTestLayer)); + }); + + it.effect("re-registers when the stored account identity differs", () => { + Constants.expoConfig!.extra = { + relay: { + url: "https://relay.example.test/", + }, + }; + registrationRecordStore.current = { identity: "someone-else", signature: "stale" }; + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + + return Effect.gen(function* () { + yield* refreshAgentAwarenessRegistration(); + expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(1); }).pipe(Effect.provide(relayTestLayer)); }); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 3281381e0e1..6b43d7dc3f1 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, @@ -20,13 +20,16 @@ import { import type { SavedRemoteConnection } from "../../lib/connection"; import { runtime } from "../../lib/runtime"; import { + clearAgentAwarenessRegistrationRecord, loadAgentAwarenessDeviceId, + loadAgentAwarenessRegistrationRecord, loadOrCreateAgentAwarenessDeviceId, loadPreferences, + saveAgentAwarenessRegistrationRecord, } 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 +63,37 @@ 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; + +// Whether the relay has actually accepted this device's registration. The +// notification/Live Activity settings toggles must reflect this rather than +// only local iOS permission or saved preferences: if the registration request +// never succeeded, the device cannot receive anything, so the switches must +// not read as enabled. +export type AgentAwarenessRegistrationStatus = "unknown" | "pending" | "registered" | "failed"; +let registrationStatus: AgentAwarenessRegistrationStatus = "unknown"; +const registrationStatusListeners = new Set<() => void>(); + +function setRegistrationStatus(next: AgentAwarenessRegistrationStatus): void { + if (registrationStatus === next) { + return; + } + registrationStatus = next; + for (const listener of registrationStatusListeners) { + listener(); + } +} + +export function getAgentAwarenessRegistrationStatus(): AgentAwarenessRegistrationStatus { + return registrationStatus; +} + +export function subscribeAgentAwarenessRegistrationStatus(listener: () => void): () => void { + registrationStatusListeners.add(listener); + return () => { + registrationStatusListeners.delete(listener); + }; +} let activeLiveActivityRegistrationRetry: ReturnType | null = null; let relayTokenProvider: (() => Promise) | null = null; let relayTokenProviderIdentity: string | null = null; @@ -128,14 +162,26 @@ 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"); + setRegistrationStatus("unknown"); + // Sign-out is the only thing that invalidates a stored registration, so the + // next sign-in re-registers. + void clearAgentAwarenessRegistrationRecord().catch((error: unknown) => { + logRegistrationError("clear registration record on sign-out failed", error); + }); return; } ensurePushToStartListener(); ensurePushTokenListener(); + ensureAppStateListener(); runRegistrationInBackground( refreshActiveLiveActivityRemoteRegistration(), "active live activity registration after cloud sign-in failed", @@ -211,6 +257,28 @@ const relayToken = ( }); }); +// Stable fingerprint of everything the relay stores for this device. When it +// matches the last accepted registration for the same account, re-registering +// is a no-op, so a launch that changed nothing skips the request entirely. +function registrationSignature(body: RelayDeviceRegistrationRequest): string { + return [ + body.deviceId, + body.pushToken ?? "", + body.pushToStartToken ?? "", + body.bundleId ?? "", + body.apsEnvironment ?? "", + body.appVersion ?? "", + body.label, + body.iosMajorVersion, + body.preferences.notificationsEnabled, + body.preferences.liveActivitiesEnabled, + body.preferences.notifyOnApproval, + body.preferences.notifyOnInput, + body.preferences.notifyOnCompletion, + body.preferences.notifyOnFailure, + ].join("|"); +} + function registerDeviceWithRelay( body: RelayDeviceRegistrationRequest, expectedGeneration: number, @@ -237,6 +305,24 @@ function registerDeviceWithRelay( return; } + // Skip the request when this account already registered an identical + // payload; the relay upsert would be a no-op. The record is only cleared on + // sign-out, so a device stays registered across launches without re-hitting + // the relay every time the app opens. + const identity = relayTokenProviderIdentity ?? ""; + const signature = registrationSignature(body); + const persisted = yield* Effect.tryPromise({ + try: () => loadAgentAwarenessRegistrationRecord(), + catch: (cause) => cause, + }).pipe(Effect.orElseSucceed(() => null)); + if (persisted && persisted.identity === identity && persisted.signature === signature) { + setRegistrationStatus("registered"); + logRegistrationDebug("relay device registration skipped; already registered for account", { + expectedGeneration, + }); + return; + } + const client = yield* ManagedRelay.ManagedRelayClient; logRegistrationDebug("relay device registration request started", { expectedGeneration, @@ -245,6 +331,12 @@ function registerDeviceWithRelay( clerkToken: token, payload: body, }); + setRegistrationStatus("registered"); + yield* Effect.promise(() => + saveAgentAwarenessRegistrationRecord({ identity, signature }).catch((error: unknown) => { + logRegistrationError("persist registration record failed", error); + }), + ); logRegistrationDebug("relay device registration request completed", { expectedGeneration, }); @@ -365,6 +457,9 @@ function startPendingDeviceRegistration(): void { hasObservedPushToken: next.input.observedPushToken !== undefined, hasPushToStartToken: next.input.pushToStartToken !== undefined, }); + if (registrationStatus !== "registered") { + setRegistrationStatus("pending"); + } const registration = { input: next.input, operation: Promise.resolve(), @@ -375,6 +470,7 @@ function startPendingDeviceRegistration(): void { runtime.runPromiseExit(registerDevice(next.input, generation)), ); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + setRegistrationStatus("failed"); logRegistrationError(next.context, squashAtomCommandFailure(result)); } logRegistrationDebug("device registration finished", { generation }); @@ -444,12 +540,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 +597,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 +640,7 @@ export function registerAgentAwarenessConnection(connection: SavedRemoteConnecti environmentConnections.set(connection.environmentId, connection); ensurePushToStartListener(); ensurePushTokenListener(); + ensureAppStateListener(); enqueueDeviceRegistration({}, "device registration failed"); runRegistrationInBackground( refreshActiveLiveActivityRemoteRegistration(), @@ -527,6 +662,8 @@ export function unregisterAllAgentAwarenessConnections(): void { pushToStartSubscription = null; pushTokenSubscription?.remove(); pushTokenSubscription = null; + appStateSubscription?.remove(); + appStateSubscription = null; if (activeLiveActivityRegistrationRetry) { clearTimeout(activeLiveActivityRegistrationRetry); activeLiveActivityRegistrationRetry = null; @@ -541,6 +678,7 @@ export function refreshAgentAwarenessRegistration(): Effect.Effect< return registerDeviceForCurrentUser().pipe( Effect.catch((error) => Effect.sync(() => { + setRegistrationStatus("failed"); logRegistrationError("device registration refresh failed", error); }), ), @@ -553,6 +691,8 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { pushToStartSubscription = null; pushTokenSubscription?.remove(); pushTokenSubscription = null; + appStateSubscription?.remove(); + appStateSubscription = null; if (activeLiveActivityRegistrationRetry) { clearTimeout(activeLiveActivityRegistrationRetry); activeLiveActivityRegistrationRetry = null; @@ -562,6 +702,8 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { deviceRegistrationGeneration++; activeDeviceRegistration = null; pendingDeviceRegistration = null; + registrationStatus = "unknown"; + registrationStatusListeners.clear(); } export function unregisterAgentAwarenessDeviceForCurrentUser( diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index d4d0a1ab992..2b07b96b3c0 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -6,7 +6,7 @@ import { useNavigation } from "@react-navigation/native"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { SymbolView } from "expo-symbols"; import * as Effect from "effect/Effect"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { Alert, Linking, Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -20,7 +20,11 @@ import { import { AppText as Text } from "../../components/AppText"; import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences"; import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions"; -import { refreshAgentAwarenessRegistration } from "../agent-awareness/remoteRegistration"; +import { + getAgentAwarenessRegistrationStatus, + refreshAgentAwarenessRegistration, + subscribeAgentAwarenessRegistrationStatus, +} from "../agent-awareness/remoteRegistration"; import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState"; import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent"; import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig"; @@ -37,6 +41,19 @@ import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; +// Reflects whether the relay actually accepted this device's registration. +// The notification and Live Activity switches are gated on this so they can +// never read as enabled when the device cannot receive anything (e.g. the +// registration request timed out). +function useDeviceRegistered(): boolean { + const status = useSyncExternalStore( + subscribeAgentAwarenessRegistrationStatus, + getAgentAwarenessRegistrationStatus, + () => "unknown" as const, + ); + return status === "registered"; +} + export function SettingsRouteScreen() { const navigation = useNavigation(); @@ -113,6 +130,7 @@ function ConfiguredSettingsRouteScreen() { const { savedConnectionsById } = useSavedRemoteConnections(); const [notificationStatus, setNotificationStatus] = useState("checking"); const [liveActivityStatus, setLiveActivityStatus] = useState("checking"); + const deviceRegistered = useDeviceRegistered(); const connections = useMemo(() => Object.values(savedConnectionsById), [savedConnectionsById]); const environmentCount = connections.length; @@ -182,10 +200,19 @@ function ConfiguredSettingsRouteScreen() { } if (result.value.type === "granted") { setNotificationStatus("enabled"); - Alert.alert( - "Notifications enabled", - "Live Activity notifications are enabled for this device.", - ); + // Permission alone is not enough: the switch stays off until the relay + // registration succeeds, so tell the user the truth about which happened. + if (getAgentAwarenessRegistrationStatus() === "registered") { + Alert.alert( + "Notifications enabled", + "Live Activity notifications are enabled for this device.", + ); + } else { + Alert.alert( + "Couldn't finish enabling notifications", + "Notification access was granted, but this device could not be registered with T3 Connect. Notifications will start once registration succeeds.", + ); + } return; } if (result.value.type === "unsupported") { @@ -271,12 +298,22 @@ function ConfiguredSettingsRouteScreen() { refreshManagedRelayEnvironments(); setLiveActivityStatus("enabled"); - Alert.alert( - "Live Activities enabled", - environmentCount > 0 - ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for Live Activity updates.` - : "Live Activity updates are enabled. Add an environment to start receiving updates.", - ); + // The environment link can succeed while this device's own registration + // (the push-to-start token the relay needs) has not — don't claim Live + // Activities are live until the device is actually registered. + if (getAgentAwarenessRegistrationStatus() === "registered") { + Alert.alert( + "Live Activities enabled", + environmentCount > 0 + ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for Live Activity updates.` + : "Live Activity updates are enabled. Add an environment to start receiving updates.", + ); + } else { + Alert.alert( + "Couldn't finish enabling Live Activities", + "This device could not be registered with T3 Connect, so Live Activities won't appear yet. They'll start once registration succeeds.", + ); + } }, [connections, environmentCount, getToken, isSignedIn, promptSignIn]); const handleDeviceNotificationsChange = useCallback( @@ -395,7 +432,10 @@ function ConfiguredSettingsRouteScreen() { icon="bell.badge" label="Device Notifications" disabled={notificationStatus === "checking" || notificationStatus === "unsupported"} - value={notificationStatus === "enabled"} + // Only reads as on when this device is actually registered with the + // relay; otherwise notifications cannot be delivered regardless of + // the local iOS permission. + value={notificationStatus === "enabled" && deviceRegistered} onValueChange={handleDeviceNotificationsChange} /> diff --git a/apps/mobile/src/lib/storage.ts b/apps/mobile/src/lib/storage.ts index 8ed999225e1..db200e8214e 100644 --- a/apps/mobile/src/lib/storage.ts +++ b/apps/mobile/src/lib/storage.ts @@ -13,10 +13,12 @@ import { const CONNECTIONS_KEY = "t3code.connections"; const PREFERENCES_KEY = "t3code.preferences"; const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id"; +const AGENT_AWARENESS_REGISTRATION_KEY = "t3code.agent-awareness.registration"; const MobileStorageKey = Schema.Literals([ CONNECTIONS_KEY, PREFERENCES_KEY, AGENT_AWARENESS_DEVICE_ID_KEY, + AGENT_AWARENESS_REGISTRATION_KEY, ]); type MobileStorageKeyValue = typeof MobileStorageKey.Type; @@ -222,3 +224,36 @@ export async function loadAgentAwarenessDeviceId(): Promise { const existing = await readStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY); return existing?.trim() ? existing : null; } + +export interface AgentAwarenessRegistrationRecord { + readonly identity: string; + readonly signature: string; +} + +// Remembers the account identity and payload signature the relay last accepted +// so the app does not re-register on every launch while nothing has changed. +// Cleared only on sign-out. +export async function loadAgentAwarenessRegistrationRecord(): Promise { + const parsed = await readJsonStorageItem( + AGENT_AWARENESS_REGISTRATION_KEY, + ); + if ( + !parsed || + typeof parsed !== "object" || + typeof parsed.identity !== "string" || + typeof parsed.signature !== "string" + ) { + return null; + } + return { identity: parsed.identity, signature: parsed.signature }; +} + +export async function saveAgentAwarenessRegistrationRecord( + record: AgentAwarenessRegistrationRecord, +): Promise { + await writeJsonStorageItem(AGENT_AWARENESS_REGISTRATION_KEY, record); +} + +export async function clearAgentAwarenessRegistrationRecord(): Promise { + await writeStorageItem(AGENT_AWARENESS_REGISTRATION_KEY, ""); +} 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/apps/server/src/cli/connect.test.ts b/apps/server/src/cli/connect.test.ts index 70b0329ac90..f01c93f0538 100644 --- a/apps/server/src/cli/connect.test.ts +++ b/apps/server/src/cli/connect.test.ts @@ -8,7 +8,11 @@ import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as References from "effect/References"; -import { acquireRelayClientForLink, reportCloudDisconnectResults } from "./connect.ts"; +import { + acquireRelayClientForLink, + isPublishAgentActivityEnabledValue, + reportCloudDisconnectResults, +} from "./connect.ts"; const managedExecutable = { status: "available", @@ -153,3 +157,10 @@ it.effect("keeps disconnect causes in structured logs and out of console warning ), ); }); + +it("treats only the literal 'true' as publish-enabled", () => { + assert.equal(isPublishAgentActivityEnabledValue("true"), true); + assert.equal(isPublishAgentActivityEnabledValue("false"), false); + assert.equal(isPublishAgentActivityEnabledValue(null), false); + assert.equal(isPublishAgentActivityEnabledValue("TRUE"), false); +}); diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 3ce53391fa6..5c279a69c12 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -29,7 +29,11 @@ import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as CliState from "../cloud/CliState.ts"; import * as CliTokenManager from "../cloud/CliTokenManager.ts"; -import { CLOUD_LINKED_USER_ID, RELAY_URL_SECRET } from "../cloud/config.ts"; +import { + CLOUD_LINKED_USER_ID, + PUBLISH_AGENT_ACTIVITY_SECRET, + RELAY_URL_SECRET, +} from "../cloud/config.ts"; import { relayUrlConfig } from "../cloud/publicConfig.ts"; import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts"; import * as ServerConfig from "../config.ts"; @@ -46,12 +50,21 @@ function bytesToString(value: Uint8Array): string { return new TextDecoder().decode(value); } +function stringToBytes(value: string): Uint8Array { + return new TextEncoder().encode(value); +} + +export function isPublishAgentActivityEnabledValue(value: string | null): boolean { + return value === "true"; +} + interface CloudCliStatus { readonly desired: boolean; readonly authenticated: boolean; readonly linked: boolean; readonly cloudUserId: string | null; readonly relayUrl: string | null; + readonly publishAgentActivity: boolean; readonly relayClient: RelayClient.RelayClientStatus; } @@ -104,6 +117,7 @@ function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: b ` Authorization: ${status.authenticated ? "stored credential" : "missing"}`, ` Environment link: ${provisioned}`, ` Relay: ${status.relayUrl ?? "not provisioned"}`, + ` Publish agent activity: ${status.publishAgentActivity ? "enabled" : "disabled"}`, ...formatRelayClientStatus(status.relayClient), ...(nextStep ? ["", `Next: ${nextStep}`] : []), ].join("\n"); @@ -368,31 +382,46 @@ const connectLoginCommand = Command.make("login", { const connectLinkCommand = Command.make("link", { ...projectLocationFlags, + publishOnly: Flag.boolean("publish-only").pipe( + Flag.withDescription( + "Link to publish agent activity only — no managed tunnel. Reach this environment out of band (e.g. Tailscale).", + ), + Flag.withDefault(false), + ), }).pipe( Command.withDescription("Authorize this environment for T3 Connect on next start."), Command.withHandler((flags) => runCloudCommand( flags, Effect.gen(function* () { - const relayClient = yield* RelayClient.RelayClient; - const installed = yield* acquireRelayClientForLink( - relayClient, - confirmRelayClientInstall, - reportRelayClientInstallProgress, - ); - if (Option.isNone(installed)) { - yield* Console.log("T3 Connect setup cancelled. The relay client was not installed."); - return; + // A publish-only link needs no Cloudflare tunnel, so skip installing the + // relay client entirely. + if (!flags.publishOnly) { + const relayClient = yield* RelayClient.RelayClient; + const installed = yield* acquireRelayClientForLink( + relayClient, + confirmRelayClientInstall, + reportRelayClientInstallProgress, + ); + if (Option.isNone(installed)) { + yield* Console.log("T3 Connect setup cancelled. The relay client was not installed."); + return; + } + yield* Console.log( + `Using relay client ${installed.value.version} from ${installed.value.executablePath}.`, + ); } - yield* Console.log( - `Using relay client ${installed.value.version} from ${installed.value.executablePath}.`, - ); const tokens = yield* CliTokenManager.CloudCliTokenManager; yield* tokens.get; - yield* CliState.setCliDesiredCloudLink(true); + yield* CliState.setCliDesiredCloudLink( + true, + flags.publishOnly ? "publish_only" : "managed", + ); yield* Console.log( - "This T3 environment will be available through T3 Connect the next time T3 starts.", + flags.publishOnly + ? "This environment will publish agent activity to your mobile clients the next time T3 starts (no managed tunnel)." + : "This T3 environment will be available through T3 Connect the next time T3 starts.", ); }), ), @@ -411,22 +440,27 @@ const connectStatusCommand = Command.make("status", { const secrets = yield* ServerSecretStore.ServerSecretStore; const relayClient = yield* RelayClient.RelayClient; const tokens = yield* CliTokenManager.CloudCliTokenManager; - const [desired, authenticated, cloudUserId, relayUrl, executable] = yield* Effect.all( - [ - CliState.readCliDesiredCloudLink, - tokens.hasCredential, - secrets.get(CLOUD_LINKED_USER_ID), - secrets.get(RELAY_URL_SECRET), - relayClient.resolve, - ], - { concurrency: "unbounded" }, - ); + const [desired, authenticated, cloudUserId, relayUrl, publishAgentActivity, executable] = + yield* Effect.all( + [ + CliState.readCliDesiredCloudLink, + tokens.hasCredential, + secrets.get(CLOUD_LINKED_USER_ID), + secrets.get(RELAY_URL_SECRET), + secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET), + relayClient.resolve, + ], + { concurrency: "unbounded" }, + ); const status: CloudCliStatus = { desired, authenticated, linked: Option.isSome(cloudUserId), cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null, relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null, + publishAgentActivity: isPublishAgentActivityEnabledValue( + Option.isSome(publishAgentActivity) ? bytesToString(publishAgentActivity.value) : null, + ), relayClient: executable, }; yield* Console.log(formatCloudStatus(status, { json: flags.json })); @@ -438,6 +472,57 @@ const connectStatusCommand = Command.make("status", { ), ); +const connectPublishCommand = Command.make("publish", { + ...projectLocationFlags, + disable: Flag.boolean("disable").pipe( + Flag.withDescription("Stop publishing agent activity to your mobile clients."), + Flag.withDefault(false), + ), +}).pipe( + Command.withDescription( + "Toggle publishing agent activity (push notifications and Live Activities) to your mobile clients.", + ), + Command.withHandler((flags) => + runCloudCommand( + flags, + Effect.gen(function* () { + const secrets = yield* ServerSecretStore.ServerSecretStore; + const tokens = yield* CliTokenManager.CloudCliTokenManager; + const enabled = !flags.disable; + yield* secrets.set( + PUBLISH_AGENT_ACTIVITY_SECRET, + stringToBytes(enabled ? "true" : "false"), + ); + if (!enabled) { + yield* Console.log("Publishing agent activity disabled."); + return; + } + + yield* Console.log("Publishing agent activity enabled."); + const linked = Option.isSome(yield* secrets.get(CLOUD_LINKED_USER_ID)); + if (linked) { + return; + } + + // Publishing needs the relay to know this environment belongs to you. + // Establish a tunnel-free publish-only link automatically so signing in + // is all it takes — the mobile client can still reach the environment + // out of band without T3 Connect. + if (!(yield* tokens.hasCredential)) { + yield* Console.log( + "Run `t3 connect login` first so this environment can be authorized to publish.", + ); + return; + } + yield* CliState.setCliDesiredCloudLink(true, "publish_only"); + yield* Console.log( + "Restart T3 to finish authorizing this environment to publish (no managed tunnel is created).", + ); + }), + ), + ), +); + const connectUnlinkCommand = Command.make("unlink", { ...projectLocationFlags, }).pipe( @@ -461,6 +546,7 @@ export const connectCommand = Command.make("connect").pipe( Command.withSubcommands([ connectLoginCommand, connectLinkCommand, + connectPublishCommand, connectStatusCommand, connectUnlinkCommand, connectLogoutCommand, diff --git a/apps/server/src/cloud/CliState.test.ts b/apps/server/src/cloud/CliState.test.ts index 3fbf4f12db2..39f904b47b8 100644 --- a/apps/server/src/cloud/CliState.test.ts +++ b/apps/server/src/cloud/CliState.test.ts @@ -56,4 +56,27 @@ it.layer(NodeServices.layer)("CliState", (it) => { } }).pipe(Effect.provide(makeTestLayer())), ); + + it.effect("round-trips the desired link mode and defaults legacy links to managed", () => + Effect.gen(function* () { + const secrets = yield* ServerSecretStore.ServerSecretStore; + + assert.equal(yield* CliState.readCliDesiredLinkMode, "managed"); + + yield* CliState.setCliDesiredCloudLink(true, "publish_only"); + assert.isTrue(yield* CliState.readCliDesiredCloudLink); + assert.equal(yield* CliState.readCliDesiredLinkMode, "publish_only"); + + yield* CliState.setCliDesiredCloudLink(true, "managed"); + assert.equal(yield* CliState.readCliDesiredLinkMode, "managed"); + + // A pre-existing link persisted the literal "true"; treat it as managed. + yield* secrets.set(CliState.CLOUD_CLI_DESIRED_LINK_SECRET, new TextEncoder().encode("true")); + assert.isTrue(yield* CliState.readCliDesiredCloudLink); + assert.equal(yield* CliState.readCliDesiredLinkMode, "managed"); + + yield* CliState.setCliDesiredCloudLink(false); + assert.equal(yield* CliState.readCliDesiredLinkMode, "managed"); + }).pipe(Effect.provide(makeTestLayer())), + ); }); diff --git a/apps/server/src/cloud/CliState.ts b/apps/server/src/cloud/CliState.ts index 2e18fff4250..9af9a032f85 100644 --- a/apps/server/src/cloud/CliState.ts +++ b/apps/server/src/cloud/CliState.ts @@ -14,19 +14,43 @@ import { export const CLOUD_CLI_DESIRED_LINK_SECRET = "cloud-cli-desired-link"; -const TRUE_BYTES = new TextEncoder().encode("true"); +// "managed" provisions a Cloudflare tunnel (default, legacy value "true"). +// "publish_only" links the environment to the relay purely to publish agent +// activity — no tunnel, no relay-advertised endpoint — so activity can flow to +// mobile clients even when they reach the environment out of band (Tailscale, +// direct pairing) without T3 Connect. +export type CliDesiredLinkMode = "managed" | "publish_only"; + +const MANAGED_BYTES = new TextEncoder().encode("managed"); +const PUBLISH_ONLY_BYTES = new TextEncoder().encode("publish_only"); export const readCliDesiredCloudLink = Effect.gen(function* () { const secrets = yield* ServerSecretStore.ServerSecretStore; return Option.isSome(yield* secrets.get(CLOUD_CLI_DESIRED_LINK_SECRET)); }); +export const readCliDesiredLinkMode = Effect.gen(function* () { + const secrets = yield* ServerSecretStore.ServerSecretStore; + const value = yield* secrets.get(CLOUD_CLI_DESIRED_LINK_SECRET); + if (Option.isNone(value)) { + return "managed" as CliDesiredLinkMode; + } + // Legacy links stored the literal "true" and are always managed. + return new TextDecoder().decode(value.value) === "publish_only" + ? ("publish_only" as CliDesiredLinkMode) + : ("managed" as CliDesiredLinkMode); +}); + export const setCliDesiredCloudLink = Effect.fn("cloud.cli_state.set_desired")(function* ( desired: boolean, + mode: CliDesiredLinkMode = "managed", ) { const secrets = yield* ServerSecretStore.ServerSecretStore; if (desired) { - yield* secrets.set(CLOUD_CLI_DESIRED_LINK_SECRET, TRUE_BYTES); + yield* secrets.set( + CLOUD_CLI_DESIRED_LINK_SECRET, + mode === "publish_only" ? PUBLISH_ONLY_BYTES : MANAGED_BYTES, + ); } else { yield* secrets.remove(CLOUD_CLI_DESIRED_LINK_SECRET); } diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index ed2e5a4cf75..bab9cdd27f3 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -11,7 +11,13 @@ import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as CliTokenManager from "./CliTokenManager.ts"; -import { consumeCloudReplayGuards, reconcileDesiredCloudLink } from "./http.ts"; +import type { RelayLinkProofRequest } from "@t3tools/contracts/relay"; +import { + consumeCloudReplayGuards, + isSupportedLinkProviderKind, + linkProofScopes, + reconcileDesiredCloudLink, +} from "./http.ts"; import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts"; @@ -205,3 +211,32 @@ describe("reconcileDesiredCloudLink", () => { ), ); }); + +describe("link proof provider kinds", () => { + const proofRequest = ( + providerKind: RelayLinkProofRequest["endpoint"]["providerKind"], + ): RelayLinkProofRequest => ({ + challenge: "challenge", + relayIssuer: "https://relay.example.test", + endpoint: { + httpBaseUrl: "http://127.0.0.1:7331", + wsBaseUrl: "ws://127.0.0.1:7331", + providerKind, + }, + origin: { localHttpHost: "127.0.0.1", localHttpPort: 7331 }, + }); + + it("accepts managed and manual endpoints but not t3_relay", () => { + expect(isSupportedLinkProviderKind(proofRequest("cloudflare_tunnel"))).toBe(true); + expect(isSupportedLinkProviderKind(proofRequest("manual"))).toBe(true); + expect(isSupportedLinkProviderKind(proofRequest("t3_relay"))).toBe(false); + }); + + it("only claims the managed-tunnel scope for tunnel links", () => { + expect(linkProofScopes(proofRequest("cloudflare_tunnel"))).toEqual([ + "agent_activity_notifications", + "managed_tunnels", + ]); + expect(linkProofScopes(proofRequest("manual"))).toEqual(["agent_activity_notifications"]); + }); +}); diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index fc2adca9fbc..92423bb215e 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -68,7 +68,7 @@ import { RELAY_URL_SECRET, } from "./config.ts"; import { relayUrlConfig } from "./publicConfig.ts"; -import { setCliDesiredCloudLink } from "./CliState.ts"; +import { readCliDesiredLinkMode, setCliDesiredCloudLink } from "./CliState.ts"; import * as CliTokenManager from "./CliTokenManager.ts"; import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; import { traceRelayRequest } from "./traceRelayRequest.ts"; @@ -299,8 +299,23 @@ function isAllowedEndpointOrigin(input: { return input.origin.localHttpPort === endpointRequestPort(url); } -function providerKindMatchesRequestedLinkScopes(request: RelayLinkProofRequest): boolean { - return request.endpoint.providerKind === "cloudflare_tunnel"; +// A managed (Cloudflare tunnel) endpoint is provisioned by the relay and must +// point at a loopback origin. A manual endpoint is reached out of band (e.g. +// Tailscale) or not advertised at all for publish-only links, so it is not +// tied to the managed-tunnel scope. +export function isSupportedLinkProviderKind(request: RelayLinkProofRequest): boolean { + return ( + request.endpoint.providerKind === "cloudflare_tunnel" || + request.endpoint.providerKind === "manual" + ); +} + +export function linkProofScopes( + request: RelayLinkProofRequest, +): RelayEnvironmentLinkProofPayload["scopes"] { + return request.endpoint.providerKind === "cloudflare_tunnel" + ? ["agent_activity_notifications", "managed_tunnels"] + : ["agent_activity_notifications"]; } function hasExactScope(input: { @@ -352,7 +367,7 @@ const makeCloudLinkProof = Effect.fn("environment.cloud.makeLinkProof")(function ) { const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(dependencies.secrets); if ( - !providerKindMatchesRequestedLinkScopes(request) || + !isSupportedLinkProviderKind(request) || !isAllowedEndpointOrigin({ origin: request.origin, requestUrl, @@ -379,7 +394,7 @@ const makeCloudLinkProof = Effect.fn("environment.cloud.makeLinkProof")(function environmentPublicKey: normalizePemForSignedPayload(keyPair.publicKey), endpoint: request.endpoint, origin: request.origin, - scopes: ["agent_activity_notifications", "managed_tunnels"], + scopes: linkProofScopes(request), } satisfies RelayEnvironmentLinkProofPayload; return yield* signRelayJwt({ privateKey: keyPair.privateKey, @@ -537,6 +552,8 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi }), ), ); + const mode = yield* readCliDesiredLinkMode; + const managedTunnelsEnabled = mode !== "publish_only"; const relayUrl = yield* requireRelayUrl; const challenge = yield* relayClientRequest(dependencies, { url: `${relayUrl}/v1/client/environment-link-challenges`, @@ -544,7 +561,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi payload: { notificationsEnabled: true, liveActivitiesEnabled: true, - managedTunnelsEnabled: true, + managedTunnelsEnabled, }, schema: RelayEnvironmentLinkChallengeResponse, }); @@ -556,7 +573,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi endpoint: { httpBaseUrl: localOrigin, wsBaseUrl: localWsOrigin, - providerKind: "cloudflare_tunnel", + providerKind: managedTunnelsEnabled ? "cloudflare_tunnel" : "manual", }, origin: { localHttpHost: localUrl.hostname, @@ -572,11 +589,11 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi proof, notificationsEnabled: true, liveActivitiesEnabled: true, - managedTunnelsEnabled: true, + managedTunnelsEnabled, }, schema: RelayEnvironmentLinkResponse, }); - yield* setCliDesiredCloudLink(true); + yield* setCliDesiredCloudLink(true, mode); return yield* applyCloudRelayConfig(dependencies, { relayUrl, relayIssuer: link.relayIssuer, @@ -608,20 +625,25 @@ export const reconcileDesiredCloudLink = Effect.fn("environment.cloud.reconcileD const readCloudLinkState = Effect.fn("environment.cloud.readLinkState")(function* ( dependencies: CloudHttpDependencies, ) { - const [cloudUserId, relayUrl, relayIssuer, publishAgentActivity] = yield* Effect.all( - [ - dependencies.secrets.get(CLOUD_LINKED_USER_ID), - dependencies.secrets.get(RELAY_URL_SECRET), - dependencies.secrets.get(RELAY_ISSUER_SECRET), - dependencies.secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET), - ], - { concurrency: 4 }, - ); + const [cloudUserId, relayUrl, relayIssuer, endpointRuntimeConfig, publishAgentActivity] = + yield* Effect.all( + [ + dependencies.secrets.get(CLOUD_LINKED_USER_ID), + dependencies.secrets.get(RELAY_URL_SECRET), + dependencies.secrets.get(RELAY_ISSUER_SECRET), + dependencies.secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG), + dependencies.secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET), + ], + { concurrency: 5 }, + ); return { linked: Option.isSome(cloudUserId), cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null, relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null, relayIssuer: Option.isSome(relayIssuer) ? bytesToString(relayIssuer.value) : null, + // The managed tunnel runtime config is only stored for managed links; a + // publish-only link leaves it absent. + managedTunnelActive: Option.isSome(endpointRuntimeConfig), publishAgentActivity: Option.isSome(publishAgentActivity) ? bytesToString(publishAgentActivity.value) === "true" : false, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 6a776afe48d..347c2920792 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1700,7 +1700,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("rejects managed cloud link proofs for manual endpoint providers", () => + it.effect("rejects cloud link proofs for unsupported endpoint providers", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -1721,7 +1721,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { wsBaseUrl: linkProofUrl .replace("http://", "ws://") .replace("/api/connect/link-proof", "/ws"), - providerKind: "manual", + // "manual" and "cloudflare_tunnel" are supported; "t3_relay" is not. + providerKind: "t3_relay", }, origin: { localHttpHost: "127.0.0.1", diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index 7e6f2365e50..38e205beabb 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -204,6 +204,7 @@ describe("web cloud link environment client", () => { cloudUserId: "user-1", relayUrl: "https://relay.example.test", relayIssuer: "https://relay.example.test", + managedTunnelActive: true, publishAgentActivity: false, }), ); @@ -217,6 +218,7 @@ describe("web cloud link environment client", () => { cloudUserId: "user-1", relayUrl: "https://relay.example.test", relayIssuer: "https://relay.example.test", + managedTunnelActive: true, publishAgentActivity: false, }), ); @@ -234,6 +236,7 @@ describe("web cloud link environment client", () => { cloudUserId: "user-1", relayUrl: "https://relay.example.test", relayIssuer: "https://relay.example.test", + managedTunnelActive: true, publishAgentActivity: false, }), ); @@ -261,6 +264,7 @@ describe("web cloud link environment client", () => { cloudUserId: "user-1", relayUrl: "https://relay.example.test", relayIssuer: "https://relay.example.test", + managedTunnelActive: true, publishAgentActivity: true, }), ); @@ -339,6 +343,57 @@ describe("web cloud link environment client", () => { }), ); + it.effect("links publish-only without a managed tunnel", () => + Effect.gen(function* () { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + Response.json({ + challenge: "challenge", + expiresAt: "2026-06-06T00:05:00.000Z", + }), + ) + .mockResolvedValueOnce(Response.json("signed-proof")) + .mockResolvedValueOnce( + Response.json({ + ok: true, + environmentId: TARGET.environmentId, + endpoint: { + httpBaseUrl: TARGET.httpBaseUrl, + wsBaseUrl: TARGET.wsBaseUrl, + providerKind: "manual", + }, + endpointRuntime: null, + relayIssuer: "https://relay.example.test", + cloudUserId: "user-1", + environmentCredential: "environment-credential", + cloudMintPublicKey: "public-key", + }), + ) + .mockResolvedValueOnce( + Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }), + ); + vi.stubGlobal("fetch", fetchMock); + + yield* withServices( + linkPrimaryEnvironmentToCloud({ + target: TARGET, + clerkToken: "clerk-token", + mode: "publish_only", + }), + ); + + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(bodyText(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({ + managedTunnelsEnabled: false, + }); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(bodyText(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({ + endpoint: { providerKind: "manual" }, + }); + }), + ); + it.effect("installs a missing relay client before linking", () => Effect.gen(function* () { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ malformed: true }))); diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index 20bf75c7d6d..ec25fd104d1 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -383,9 +383,17 @@ export function unlinkPrimaryEnvironmentFromCloud(input: { }).pipe(Effect.provide(primaryEnvironmentHttpLayer)); } +// "publish_only" links the environment to the relay for agent-activity +// publishing alone: no managed tunnel is provisioned, so it can be toggled +// independently of T3 Connect while clients reach the environment out of band. +export type CloudLinkMode = "managed" | "publish_only"; + +const PUBLISH_ONLY_PROVIDER_KIND = "manual" satisfies RelayManagedEndpointProviderKind; + export function linkPrimaryEnvironmentToCloud(input: { readonly target: CloudLinkTarget; readonly clerkToken: string; + readonly mode?: CloudLinkMode; }): Effect.Effect< void, CloudEnvironmentLinkError, @@ -398,9 +406,15 @@ export function linkPrimaryEnvironmentToCloud(input: { message: "T3CODE_RELAY_URL is not configured.", }); } + const managedTunnelsEnabled = (input.mode ?? "managed") === "managed"; + const providerKind = managedTunnelsEnabled + ? MANAGED_ENDPOINT_PROVIDER_KIND + : PUBLISH_ONLY_PROVIDER_KIND; const relayClient = yield* ManagedRelay.ManagedRelayClient; const environmentClient = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); - yield* ensureRelayClientAvailable(EnvironmentId.make(input.target.environmentId)); + if (managedTunnelsEnabled) { + yield* ensureRelayClientAvailable(EnvironmentId.make(input.target.environmentId)); + } const challenge = yield* relayClient .createEnvironmentLinkChallenge({ @@ -408,7 +422,7 @@ export function linkPrimaryEnvironmentToCloud(input: { payload: { notificationsEnabled: true, liveActivitiesEnabled: true, - managedTunnelsEnabled: true, + managedTunnelsEnabled, }, }) .pipe( @@ -427,7 +441,7 @@ export function linkPrimaryEnvironmentToCloud(input: { endpoint: { httpBaseUrl: input.target.httpBaseUrl, wsBaseUrl: input.target.wsBaseUrl, - providerKind: MANAGED_ENDPOINT_PROVIDER_KIND, + providerKind, }, origin: endpointOrigin(input.target.httpBaseUrl), }, @@ -440,7 +454,7 @@ export function linkPrimaryEnvironmentToCloud(input: { proof, notificationsEnabled: true, liveActivitiesEnabled: true, - managedTunnelsEnabled: true, + managedTunnelsEnabled, }, }) .pipe( @@ -450,7 +464,7 @@ export function linkPrimaryEnvironmentToCloud(input: { ); yield* ensureLinkedEnvironmentMatches({ expectedEnvironmentId: input.target.environmentId, - expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND, + expectedProviderKind: providerKind, link, }); diff --git a/apps/web/src/cloud/linkEnvironmentAtoms.ts b/apps/web/src/cloud/linkEnvironmentAtoms.ts index 4cb62271a48..1094e860e46 100644 --- a/apps/web/src/cloud/linkEnvironmentAtoms.ts +++ b/apps/web/src/cloud/linkEnvironmentAtoms.ts @@ -6,6 +6,7 @@ import { import { connectionAtomRuntime } from "../connection/runtime"; import { linkPrimaryEnvironmentToCloud, + type CloudLinkMode, type CloudLinkTarget, unlinkPrimaryEnvironmentFromCloud, updatePrimaryCloudPreferences, @@ -21,8 +22,11 @@ export const linkPrimaryEnvironment = createRuntimeCommand(connectionAtomRuntime label: "web:cloud:link-primary-environment", scheduler: cloudLinkScheduler, concurrency: cloudLinkConcurrency, - execute: (input: { readonly target: CloudLinkTarget; readonly clerkToken: string }) => - linkPrimaryEnvironmentToCloud(input), + execute: (input: { + readonly target: CloudLinkTarget; + readonly clerkToken: string; + readonly mode?: CloudLinkMode; + }) => linkPrimaryEnvironmentToCloud(input), }); export const unlinkPrimaryEnvironment = createRuntimeCommand(connectionAtomRuntime, { diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index d0fb12f6153..ba832036d7e 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1602,15 +1602,17 @@ function CloudLinkSwitch({ disabled, disabledReason, onCheckedChange, + ariaLabel = "Enable T3 Connect", }: { readonly checked: boolean; readonly disabled: boolean; readonly disabledReason: string | null; readonly onCheckedChange?: (enabled: boolean) => void; + readonly ariaLabel?: string; }) { const control = ( { - setIsUpdating(true); + const managedTunnelActive = primaryCloudLinkState.data?.managedTunnelActive ?? false; + const publishAgentActivity = primaryCloudLinkState.data?.publishAgentActivity ?? false; + const linked = primaryCloudLinkState.data?.linked ?? false; + const disabledReason = !isSignedIn + ? "Sign in to T3 Connect to manage this environment." + : !canManageRelay + ? "Your session does not have permission to manage T3 Connect access." + : null; + const isBusy = isUpdating || isUpdatingPreference; + + // T3 Connect (managed tunnel) and publishing are independent capabilities + // backed by a single relay link. Reconcile the whole desired state: unlink + // when neither is wanted, otherwise (re)link with the mode the managed-tunnel + // bit implies and set the publish preference. Re-linking only happens when the + // managed-tunnel mode actually changes, so flipping publish alone is cheap. + const reconcileCloudState = async (desired: { + readonly managedTunnel: boolean; + readonly publish: boolean; + }): Promise => { setOperationError(null); - const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); - if (tokenResult._tag === "Failure") { - reportUpdateFailure(squashAtomCommandFailure(tokenResult)); - setIsUpdating(false); - return; - } - const target = primaryCloudLinkState.target; if (!target) { reportUpdateFailure(new Error("Local environment is not ready yet.")); - setIsUpdating(false); - return; + return false; } - if (enabled && !tokenResult.value) { - reportUpdateFailure(new Error("Sign in to T3 Connect before linking this environment.")); - setIsUpdating(false); - return; + const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); + if (tokenResult._tag === "Failure") { + reportUpdateFailure(squashAtomCommandFailure(tokenResult)); + return false; } + const clerkToken = tokenResult.value; + const wantsLink = desired.managedTunnel || desired.publish; - const linkResult = - enabled && tokenResult.value - ? await linkPrimaryEnvironment({ - target, - clerkToken: tokenResult.value, - }) - : await unlinkPrimaryEnvironment({ - target, - clerkToken: tokenResult.value ?? null, - }); - if (linkResult._tag === "Failure") { - if (!isAtomCommandInterrupted(linkResult)) { - reportUpdateFailure(squashAtomCommandFailure(linkResult)); + if (!wantsLink) { + const unlinkResult = await unlinkPrimaryEnvironment({ + target, + clerkToken: clerkToken ?? null, + }); + if (unlinkResult._tag === "Failure") { + if (!isAtomCommandInterrupted(unlinkResult)) { + reportUpdateFailure(squashAtomCommandFailure(unlinkResult)); + } + return false; + } + } else { + if (!clerkToken) { + reportUpdateFailure(new Error("Sign in to T3 Connect before enabling this.")); + return false; + } + if (!linked || managedTunnelActive !== desired.managedTunnel) { + const linkResult = await linkPrimaryEnvironment({ + target, + clerkToken, + mode: desired.managedTunnel ? "managed" : "publish_only", + }); + if (linkResult._tag === "Failure") { + if (!isAtomCommandInterrupted(linkResult)) { + reportUpdateFailure(squashAtomCommandFailure(linkResult)); + } + return false; + } + } + const prefResult = await updatePrimaryEnvironmentPreferences({ + target, + publishAgentActivity: desired.publish, + }); + if (prefResult._tag === "Failure") { + if (!isAtomCommandInterrupted(prefResult)) { + reportUpdateFailure(squashAtomCommandFailure(prefResult)); + } + return false; } - setIsUpdating(false); - return; } primaryCloudLinkState.refresh(); const refreshResult = await refreshRelayEnvironments(); - if (refreshResult._tag === "Failure") { - if (!isAtomCommandInterrupted(refreshResult)) { - reportUpdateFailure(squashAtomCommandFailure(refreshResult)); - } - setIsUpdating(false); - return; + if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) { + reportUpdateFailure(squashAtomCommandFailure(refreshResult)); + return false; } + return true; + }; - toastManager.add({ - type: "success", - title: enabled ? "T3 Connect linked" : "T3 Connect unlinked", - description: enabled - ? "This environment is available through T3 Connect." - : "This environment is no longer available through T3 Connect.", - }); + const updateManagedTunnel = async (enabled: boolean) => { + setIsUpdating(true); + const ok = await reconcileCloudState({ managedTunnel: enabled, publish: publishAgentActivity }); + if (ok) { + toastManager.add({ + type: "success", + title: enabled ? "T3 Connect linked" : "T3 Connect unlinked", + description: enabled + ? "This environment is available through T3 Connect." + : "This environment is no longer available through T3 Connect.", + }); + } setIsUpdating(false); }; const updatePublishAgentActivity = async (enabled: boolean) => { - const target = primaryCloudLinkState.target; - if (!target) { - reportUpdateFailure(new Error("Local environment is not ready yet.")); - return; - } - setIsUpdatingPreference(true); - setOperationError(null); - const updateResult = await updatePrimaryEnvironmentPreferences({ - target, - publishAgentActivity: enabled, - }); - if (updateResult._tag === "Failure") { - if (!isAtomCommandInterrupted(updateResult)) { - reportUpdateFailure(squashAtomCommandFailure(updateResult)); - } - setIsUpdatingPreference(false); - return; + const ok = await reconcileCloudState({ managedTunnel: managedTunnelActive, publish: enabled }); + if (ok) { + toastManager.add({ + type: "success", + title: enabled ? "Agent activity enabled" : "Agent activity disabled", + description: enabled + ? "This environment publishes agent activity to your mobile clients." + : "This environment will stop publishing agent activity.", + }); } - - primaryCloudLinkState.refresh(); - toastManager.add({ - type: "success", - title: enabled ? "Agent activity enabled" : "Agent activity disabled", - description: enabled - ? "This environment can publish agent activity to your mobile clients." - : "This environment will stop publishing agent activity.", - }); setIsUpdatingPreference(false); }; - const disabledReason = !isSignedIn - ? "Sign in to T3 Connect to manage this environment." - : !canManageRelay - ? "Your session does not have permission to manage T3 Connect access." - : null; - const linked = primaryCloudLinkState.data?.linked ?? false; return ( <> void updateLink(enabled)} + onCheckedChange={(enabled) => void updateManagedTunnel(enabled)} + /> + } + /> + void updatePublishAgentActivity(enabled)} /> } /> - {linked ? ( - void updatePublishAgentActivity(enabled)} - /> - } - /> - ) : null} ); } diff --git a/infra/relay/migrations/postgres/20260706061126_migration/migration.sql b/infra/relay/migrations/postgres/20260706061126_migration/migration.sql new file mode 100644 index 00000000000..cba591f3757 --- /dev/null +++ b/infra/relay/migrations/postgres/20260706061126_migration/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE "relay_mobile_devices" ADD COLUMN "bundle_id" varchar(255); +--> statement-breakpoint +ALTER TABLE "relay_mobile_devices" ADD COLUMN "aps_environment" varchar(16); diff --git a/infra/relay/migrations/postgres/20260706061126_migration/snapshot.json b/infra/relay/migrations/postgres/20260706061126_migration/snapshot.json new file mode 100644 index 00000000000..0d75da1e486 --- /dev/null +++ b/infra/relay/migrations/postgres/20260706061126_migration/snapshot.json @@ -0,0 +1,1479 @@ +{ + "dialect": "postgres", + "id": "f4151a65-091a-4601-bde8-029b155255b0", + "prevIds": ["385d476b-d4f6-48a3-99e6-0a95af4ee4e4"], + "version": "8", + "ddl": [ + { + "isRlsEnabled": false, + "name": "relay_agent_activity_rows", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_delivery_attempts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_dpop_proofs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_environment_credentials", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_environment_links", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_live_activities", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_managed_endpoint_allocations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_mobile_devices", + "entityType": "tables", + "schema": "public" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thread_id", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state_json", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thread_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source_job_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_suffix", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apns_status", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apns_reason", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apns_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "transport_error", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbprint", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "jti", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "iat", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credential_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credential_hash", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'T3 Environment'", + "generated": null, + "identity": null, + "name": "environment_label", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint_http_base_url", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint_ws_base_url", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint_provider_kind", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "notifications_enabled", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "live_activities_enabled", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "managed_tunnels_enabled", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by_device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "activity_push_token", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "remote_start_queued_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "remote_started_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ended_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_aggregate_json", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_live_activity_delivery_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hostname", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tunnel_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tunnel_name", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dns_record_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ready_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'iOS device'", + "generated": null, + "identity": null, + "name": "label", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "platform", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ios_major_version", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "app_version", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bundle_id", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aps_environment", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "push_token", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "push_to_start_token", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preferences_json", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updated_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_agent_activity_rows_updated", + "entityType": "indexes", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "thread_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_delivery_attempts_environment", + "entityType": "indexes", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "source_job_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_delivery_attempts_source_job", + "entityType": "indexes", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_dpop_proofs_expires_at", + "entityType": "indexes", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "credential_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_credentials_hash", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "revoked_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_credentials_environment", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "environment_public_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "revoked_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_credentials_environment_key", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "revoked_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_links_environment", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_links" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_live_activities_user", + "entityType": "indexes", + "schema": "public", + "table": "relay_live_activities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "activity_push_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_live_activities_activity_push_token", + "entityType": "indexes", + "schema": "public", + "table": "relay_live_activities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hostname", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_managed_endpoint_allocations_hostname", + "entityType": "indexes", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tunnel_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_managed_endpoint_allocations_tunnel_name", + "entityType": "indexes", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_mobile_devices_user", + "entityType": "indexes", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "push_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_mobile_devices_push_token", + "entityType": "indexes", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "push_to_start_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_mobile_devices_push_to_start_token", + "entityType": "indexes", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "columns": ["environment_id", "environment_public_key", "thread_id"], + "nameExplicit": false, + "name": "relay_agent_activity_rows_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "columns": ["thumbprint", "jti"], + "nameExplicit": false, + "name": "relay_dpop_proofs_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "columns": ["user_id", "environment_id"], + "nameExplicit": false, + "name": "relay_environment_links_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_environment_links" + }, + { + "columns": ["user_id", "device_id"], + "nameExplicit": false, + "name": "relay_live_activities_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_live_activities" + }, + { + "columns": ["user_id", "environment_id"], + "nameExplicit": false, + "name": "relay_managed_endpoint_allocations_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "columns": ["user_id", "device_id"], + "nameExplicit": false, + "name": "relay_mobile_devices_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "relay_delivery_attempts_pkey", + "schema": "public", + "table": "relay_delivery_attempts", + "entityType": "pks" + }, + { + "columns": ["credential_id"], + "nameExplicit": false, + "name": "relay_environment_credentials_pkey", + "schema": "public", + "table": "relay_environment_credentials", + "entityType": "pks" + } + ], + "renames": [] +} 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..5a37b1f20fd 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", }), diff --git a/infra/relay/src/agentActivity/Devices.ts b/infra/relay/src/agentActivity/Devices.ts index 86e3564d5be..f1c09fb3b28 100644 --- a/infra/relay/src/agentActivity/Devices.ts +++ b/infra/relay/src/agentActivity/Devices.ts @@ -130,6 +130,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 +145,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, 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/environments/EnvironmentLinker.test.ts b/infra/relay/src/environments/EnvironmentLinker.test.ts index f6bd1c6d977..f76e95ccfbc 100644 --- a/infra/relay/src/environments/EnvironmentLinker.test.ts +++ b/infra/relay/src/environments/EnvironmentLinker.test.ts @@ -173,6 +173,71 @@ describe("EnvironmentLinker", () => { ); }); + it.effect("links a publish-only environment with a non-secure nominal endpoint", () => { + let persistedEndpoint: string | null = null; + return Effect.gen(function* () { + const now = yield* DateTime.now; + const expiresAt = DateTime.add(now, { minutes: 5 }); + const relayTokens = yield* RelayTokens.RelayTokens; + const challenge = yield* relayTokens.issueLinkChallenge({ + userId: "user_123", + request: { + notificationsEnabled: true, + liveActivitiesEnabled: true, + managedTunnelsEnabled: false, + }, + jti: "publish-only-challenge-jti", + issuedAtEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), + expiresAtEpochSeconds: Math.floor(expiresAt.epochMilliseconds / 1_000), + }); + const payload = { + iss: "t3-env:env-link-test", + aud: "https://relay.example.test", + sub: "env-link-test", + jti: "publish-only-proof-jti", + iat: Math.floor(now.epochMilliseconds / 1_000), + exp: Math.floor(expiresAt.epochMilliseconds / 1_000), + challenge, + environmentId: "env-link-test" as RelayEnvironmentLinkProofPayload["environmentId"], + descriptor: { + environmentId: "env-link-test" as RelayEnvironmentLinkProofPayload["environmentId"], + label: "Link Test Environment", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + environmentPublicKey: environmentKeyPair.publicKey.trim(), + endpoint: { + httpBaseUrl: "http://127.0.0.1:3773/", + wsBaseUrl: "ws://127.0.0.1:3773/", + providerKind: "manual", + }, + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + scopes: ["agent_activity_notifications"], + } satisfies RelayEnvironmentLinkProofPayload; + const request = { + proof: signTestJwt(payload, RELAY_LINK_PROOF_TYP, environmentKeyPair.privateKey), + notificationsEnabled: true, + liveActivitiesEnabled: true, + managedTunnelsEnabled: false, + } satisfies RelayEnvironmentLinkRequest; + const linker = yield* EnvironmentLinker.EnvironmentLinker; + const result = yield* linker.link({ userId: "user_123", request }); + expect(result.environmentCredential).toBe("t3env_credential_secret"); + expect(result.endpointRuntime).toBeNull(); + expect(persistedEndpoint).toBe("http://127.0.0.1:3773/"); + }).pipe( + Effect.provide( + testLayer({ + upsert: (input) => + Effect.sync(() => { + persistedEndpoint = input.endpoint.httpBaseUrl; + }), + }), + ), + ); + }); + it.effect("rejects a tampered compact proof before persistence", () => { let persisted = false; return Effect.gen(function* () { diff --git a/infra/relay/src/environments/EnvironmentLinker.ts b/infra/relay/src/environments/EnvironmentLinker.ts index 6a97eefffa0..a2b528ca372 100644 --- a/infra/relay/src/environments/EnvironmentLinker.ts +++ b/infra/relay/src/environments/EnvironmentLinker.ts @@ -295,7 +295,11 @@ const make = Effect.gen(function* () { }) : null; const endpoint = provisioned?.endpoint ?? verified.endpoint; - if (!isSecureManagedEndpoint(endpoint)) { + // The secure-endpoint requirement only matters when the relay advertises + // this endpoint for other devices to reach (managed tunnel). Publish-only + // links are reached out of band (e.g. Tailscale) and their stored endpoint + // is never used for routing, so a nominal endpoint is acceptable. + if (input.request.managedTunnelsEnabled && !isSecureManagedEndpoint(endpoint)) { return yield* new EnvironmentLinkProofInvalid({ userId: input.userId, environmentId: verified.environmentId, 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/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 86b9f151f98..52b0767307d 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -342,6 +342,10 @@ export const EnvironmentCloudLinkStateResult = Schema.Struct({ cloudUserId: Schema.NullOr(Schema.String), relayUrl: Schema.NullOr(Schema.String), relayIssuer: Schema.NullOr(Schema.String), + // A managed Cloudflare tunnel is provisioned for this link. False for a + // publish-only link (activity publishing without a relay-managed tunnel), so + // clients can present the two capabilities as independent settings. + managedTunnelActive: Schema.Boolean, publishAgentActivity: Schema.Boolean, }); export type EnvironmentCloudLinkStateResult = typeof EnvironmentCloudLinkStateResult.Type; diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index dea3709f488..f1c4cd12cf3 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -35,12 +35,21 @@ 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, From 73e2d1dcb95fe63fa225a6fc0b81f50eef3b6748 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Jul 2026 15:25:29 -0700 Subject: [PATCH 02/26] Redesign Live Activity UI, brand it with the T3 mark, and stop relay re-registration spam - Rework the AgentActivity layout: attention-first row ordering, single-line rows (title + inline project + status) shared by the banner and expanded Dynamic Island, centered dot-separated banner headline, up to 5 banner rows, a dedicated watch/CarPlay bannerSmall, and a phase-glyph minimal view for the shared island - Match status tints to the web sidebar pills (Sidebar.logic.ts), switching between the light (-600) and dark (-300) palette off the activity color scheme: iPhone renders on dark material, but macOS mirroring/notification center renders on light where the dark palette was illegible - Align the relay's "Starting" status label to the web sidebar's "Connecting" - Ship the branded T3 mark to the widget extension: expo-widgets generates the target with no Resources phase and hand-added ones are never scheduled, so withWidgetLogoAsset.cjs installs the SVG template image set and an alwaysOutOfDate actool shell phase (scripts/wire-widget-asset-catalog.cjs applies the same wiring to an already-generated ios/ without a prebuild) - Register a Live Activity push token with the relay only once per accepted token: the refresh runs on sign-in, app foreground, and every environment connection update, and was re-POSTing identical {deviceId, token} payloads in a loop; the register-once set clears on sign-out/identity change Co-Authored-By: Claude Fable 5 --- apps/mobile/app.config.ts | 3 + apps/mobile/assets/widget/T3Mark.svg | 3 + .../plugins/lib/addWidgetAssetCatalog.cjs | 73 ++++ apps/mobile/plugins/withWidgetLogoAsset.cjs | 62 +++ .../scripts/wire-widget-asset-catalog.cjs | 30 ++ .../agent-awareness/remoteRegistration.ts | 12 + apps/mobile/src/widgets/AgentActivity.test.ts | 103 +++-- apps/mobile/src/widgets/AgentActivity.tsx | 357 ++++++++++++------ .../agentActivity/AgentActivityPublisher.ts | 4 +- 9 files changed, 511 insertions(+), 136 deletions(-) create mode 100644 apps/mobile/assets/widget/T3Mark.svg create mode 100644 apps/mobile/plugins/lib/addWidgetAssetCatalog.cjs create mode 100644 apps/mobile/plugins/withWidgetLogoAsset.cjs create mode 100644 apps/mobile/scripts/wire-widget-asset-catalog.cjs diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index d2d9862b9fe..d03e52e66bd 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -174,6 +174,9 @@ const config: ExpoConfig = { ], }, ], + // Must run after expo-widgets so the widget target exists when it wires in + // the branded logo asset catalog. + "./plugins/withWidgetLogoAsset.cjs", "./plugins/withIosSceneLifecycle.cjs", "./plugins/withAndroidCleartextTraffic.cjs", ], diff --git a/apps/mobile/assets/widget/T3Mark.svg b/apps/mobile/assets/widget/T3Mark.svg new file mode 100644 index 00000000000..48d1b09446f --- /dev/null +++ b/apps/mobile/assets/widget/T3Mark.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/plugins/lib/addWidgetAssetCatalog.cjs b/apps/mobile/plugins/lib/addWidgetAssetCatalog.cjs new file mode 100644 index 00000000000..e4907be509d --- /dev/null +++ b/apps/mobile/plugins/lib/addWidgetAssetCatalog.cjs @@ -0,0 +1,73 @@ +"use strict"; + +// Bundle the widget asset catalog into an app-extension (widget) target. +// +// expo-widgets generates the widget target without a Resources build phase, and +// hand-adding a PBXResourcesBuildPhase does NOT get picked up — xcodebuild's +// planner never schedules `actool` for it (verified: even a full re-plan skips +// it). So instead we add a shell-script phase that runs `actool` directly and +// drops the compiled Assets.car into the extension bundle. Marked +// alwaysOutOfDate so the build system always runs it. +// +// Idempotent across re-runs. Returns true when it added the phase, false when it +// was already present or the target was not found. + +const PHASE_NAME = "Compile Widget Assets"; + +// Compiles ExpoWidgetsTarget/Assets.xcassets into the extension's resources dir. +// Uses only Xcode-provided build settings so it works for device + simulator. +const ACTOOL_SCRIPT = [ + "set -e", + 'CATALOG="${SRCROOT}/ExpoWidgetsTarget/Assets.xcassets"', + 'if [ ! -d "$CATALOG" ]; then', + ' echo "warning: widget asset catalog not found at $CATALOG"', + " exit 0", + "fi", + 'DEST="${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}"', + 'mkdir -p "$DEST"', + 'xcrun actool "$CATALOG" --compile "$DEST" --platform "${PLATFORM_NAME}" --minimum-deployment-target "${IPHONEOS_DEPLOYMENT_TARGET:-16.0}" --output-format human-readable-text', +].join("\n"); + +function stripComments(map) { + const out = {}; + for (const key of Object.keys(map || {})) { + if (key.endsWith("_comment")) continue; + out[key] = map[key]; + } + return out; +} + +function findByName(map, name) { + for (const [uuid, value] of Object.entries(stripComments(map))) { + if (value && value.name === name) return { uuid, value }; + } + return null; +} + +/** + * @param {import('xcode').XcodeProject} proj + * @param {{ targetName: string }} opts + */ +function addWidgetAssetCatalog(proj, opts) { + const objects = proj.hash.project.objects; + const target = findByName(objects.PBXNativeTarget, opts.targetName); + if (!target) return false; + + const phases = target.value.buildPhases || []; + const existing = objects.PBXShellScriptBuildPhase || {}; + const already = Object.entries(stripComments(existing)).some( + ([uuid, value]) => + value && value.name === `"${PHASE_NAME}"` && phases.some((p) => p.value === uuid), + ); + if (already) return false; + + const { uuid } = proj.addBuildPhase([], "PBXShellScriptBuildPhase", PHASE_NAME, target.uuid, { + shellPath: "/bin/sh", + shellScript: ACTOOL_SCRIPT, + }); + // Always run: input-analysis is exactly what skipped the Resources phase. + objects.PBXShellScriptBuildPhase[uuid].alwaysOutOfDate = 1; + return true; +} + +module.exports = { addWidgetAssetCatalog }; diff --git a/apps/mobile/plugins/withWidgetLogoAsset.cjs b/apps/mobile/plugins/withWidgetLogoAsset.cjs new file mode 100644 index 00000000000..939a92af4c4 --- /dev/null +++ b/apps/mobile/plugins/withWidgetLogoAsset.cjs @@ -0,0 +1,62 @@ +"use strict"; + +// Ships the branded T3 mark to the Live Activity / widget extension. +// +// expo-widgets generates ExpoWidgetsTarget without a Resources build phase and +// has no asset support, so this plugin (a) writes an SVG template image set into +// the generated widget asset catalog and (b) wires that catalog into the widget +// target with a dedicated Resources build phase. Both steps are idempotent and +// survive `expo prebuild --clean`. Must be listed AFTER "expo-widgets" in the +// plugins array so the widget target exists when this runs. + +const path = require("path"); +const fs = require("fs"); +const { withDangerousMod, withXcodeProject } = require("expo/config-plugins"); +const { addWidgetAssetCatalog } = require("./lib/addWidgetAssetCatalog.cjs"); + +const TARGET_NAME = "ExpoWidgetsTarget"; +const CATALOG_NAME = "Assets.xcassets"; +const IMAGE_SET = "T3Mark.imageset"; +const SVG_NAME = "T3Mark.svg"; + +const CATALOG_CONTENTS = JSON.stringify({ info: { author: "expo", version: 1 } }, null, 2) + "\n"; +const IMAGE_SET_CONTENTS = + JSON.stringify( + { + images: [{ idiom: "universal", filename: SVG_NAME }], + info: { author: "expo", version: 1 }, + properties: { + "preserves-vector-representation": true, + "template-rendering-intent": "template", + }, + }, + null, + 2, + ) + "\n"; + +function withAssetFiles(config) { + return withDangerousMod(config, [ + "ios", + (cfg) => { + const source = path.join(cfg.modRequest.projectRoot, "assets", "widget", SVG_NAME); + const catalogDir = path.join(cfg.modRequest.platformProjectRoot, TARGET_NAME, CATALOG_NAME); + const imageSetDir = path.join(catalogDir, IMAGE_SET); + fs.mkdirSync(imageSetDir, { recursive: true }); + fs.writeFileSync(path.join(catalogDir, "Contents.json"), CATALOG_CONTENTS); + fs.writeFileSync(path.join(imageSetDir, "Contents.json"), IMAGE_SET_CONTENTS); + fs.copyFileSync(source, path.join(imageSetDir, SVG_NAME)); + return cfg; + }, + ]); +} + +function withAssetWiring(config) { + return withXcodeProject(config, (cfg) => { + addWidgetAssetCatalog(cfg.modResults, { targetName: TARGET_NAME }); + return cfg; + }); +} + +module.exports = function withWidgetLogoAsset(config) { + return withAssetWiring(withAssetFiles(config)); +}; diff --git a/apps/mobile/scripts/wire-widget-asset-catalog.cjs b/apps/mobile/scripts/wire-widget-asset-catalog.cjs new file mode 100644 index 00000000000..ad96a4dabf7 --- /dev/null +++ b/apps/mobile/scripts/wire-widget-asset-catalog.cjs @@ -0,0 +1,30 @@ +"use strict"; + +// One-off: apply the widget asset-catalog wiring to the already-generated +// ios/ project so the current build compiles ExpoWidgetsTarget/Assets.xcassets +// without a full `expo prebuild`. The durable equivalent lives in +// plugins/withWidgetLogoAsset.cjs and runs on prebuild. + +const path = require("path"); +const fs = require("fs"); + +const xcodePath = require.resolve("xcode", { + paths: [ + require.resolve("@expo/config-plugins", { paths: [require.resolve("expo/package.json")] }), + ], +}); +const xcode = require(xcodePath); +const { addWidgetAssetCatalog } = require("../plugins/lib/addWidgetAssetCatalog.cjs"); + +const pbxprojPath = path.join(__dirname, "..", "ios", "T3CodeDev.xcodeproj", "project.pbxproj"); +const proj = xcode.project(pbxprojPath); +proj.parseSync(); + +const added = addWidgetAssetCatalog(proj, { targetName: "ExpoWidgetsTarget" }); + +if (added) { + fs.writeFileSync(pbxprojPath, proj.writeSync()); + console.log("Added widget asset-compile phase to ExpoWidgetsTarget."); +} else { + console.log("No change: phase already present or target not found."); +} diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 6b43d7dc3f1..bcb00877dde 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -61,6 +61,13 @@ export class AgentAwarenessOperationError extends Schema.TaggedErrorClass(); const activityPushTokenListeners = new WeakSet>(); +// Activity tokens the relay has already accepted this session. The payload is +// just {deviceId, activityPushToken}, so re-registering an accepted token is a +// pure no-op relay round-trip — and the refresh runs on sign-in, every app +// foreground, and every environment-connection update, which spammed identical +// registrations. Cleared on sign-out/identity change alongside the device +// registration state. +const registeredActivityPushTokens = new Set(); let pushToStartSubscription: { remove: () => void } | null = null; let pushTokenSubscription: { remove: () => void } | null = null; let appStateSubscription: { remove: () => void } | null = null; @@ -154,6 +161,7 @@ export function setAgentAwarenessRelayTokenProvider( deviceRegistrationGeneration++; activeDeviceRegistration = null; pendingDeviceRegistration = null; + registeredActivityPushTokens.clear(); } relayTokenProvider = provider; relayTokenProviderIdentity = provider ? (identity ?? null) : null; @@ -791,6 +799,9 @@ function registerLiveActivityPushTokenValue(input: { readonly activityPushToken: string; }): Effect.Effect { return Effect.gen(function* () { + if (registeredActivityPushTokens.has(input.activityPushToken)) { + return true; + } const deviceId = yield* Effect.tryPromise({ try: () => loadOrCreateAgentAwarenessDeviceId(), catch: (cause) => @@ -804,6 +815,7 @@ function registerLiveActivityPushTokenValue(input: { activityPushToken: input.activityPushToken, }); if (registered) { + registeredActivityPushTokens.add(input.activityPushToken); logRegistrationDebug("live activity push token registered", { tokenSuffix: input.activityPushToken.slice(-8), }); diff --git a/apps/mobile/src/widgets/AgentActivity.test.ts b/apps/mobile/src/widgets/AgentActivity.test.ts index a367ccdfd73..fc25b5bf539 100644 --- a/apps/mobile/src/widgets/AgentActivity.test.ts +++ b/apps/mobile/src/widgets/AgentActivity.test.ts @@ -2,16 +2,21 @@ import { describe, expect, it, vi } from "vite-plus/test"; vi.mock("@expo/ui/swift-ui", () => ({ HStack: "HStack", + Image: "Image", Spacer: "Spacer", Text: "Text", VStack: "VStack", + ZStack: "ZStack", })); vi.mock("@expo/ui/swift-ui/modifiers", () => ({ font: (value: unknown) => value, foregroundStyle: (value: unknown) => value, + frame: (value: unknown) => value, + layoutPriority: (value: unknown) => value, lineLimit: (value: unknown) => value, padding: (value: unknown) => value, + resizable: (value: unknown) => value, widgetURL: (value: unknown) => ({ widgetURL: value }), })); @@ -53,41 +58,87 @@ 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}`; -} +const lightEnvironment = { + colorScheme: "light", + isLuminanceReduced: false, +} as const; describe("AgentActivity widget layout", () => { - it("formats its updated-at label in device-local time", () => { - expect(JSON.stringify(AgentActivity(props, environment as never))).toContain( - `"children":["Updated ","${expectedLocalTime(props.updatedAt)}"]`, + it("tints each row by its own phase using the web sidebar's dark palette", () => { + const layout = AgentActivity( + { + ...props, + activeCount: 2, + activities: [ + makeRow({}), + makeRow({ threadId: "thread-2", phase: "waiting_for_approval", status: "Approval" }), + ], + }, + environment as never, ); - expect(AgentActivity.toString()).not.toContain("formatAgentActivityUpdatedAtLabel"); + const banner = JSON.stringify(layout.banner); + expect(banner).toContain("#7dd3fc"); // sky-300: running + expect(banner).toContain("#fcd34d"); // amber-300: waiting_for_approval }); - it("uses now when the updated-at timestamp is malformed", () => { - expect( - JSON.stringify(AgentActivity({ ...props, updatedAt: "not-a-date" }, environment as never)), - ).toContain('"children":["Updated ","now"]'); + it("switches to the web sidebar's light palette when the scheme is light", () => { + // macOS (iPhone Mirroring / Mac notification center) renders the activity + // on a light background; the dark-material palette is illegible there. + const layout = AgentActivity( + { + ...props, + activeCount: 2, + activities: [ + makeRow({}), + makeRow({ threadId: "thread-2", phase: "waiting_for_approval", status: "Approval" }), + ], + }, + lightEnvironment as never, + ); + const banner = JSON.stringify(layout.banner); + expect(banner).toContain("#0284c7"); // sky-600: running + expect(banner).toContain("#d97706"); // amber-600: waiting_for_approval + expect(banner).not.toContain("#7dd3fc"); + expect(banner).not.toContain("#fcd34d"); }); - it("tints each row by its own phase", () => { + it("orders rows attention-first in the banner", () => { const layout = AgentActivity( { ...props, activeCount: 2, + activities: [ + makeRow({ threadTitle: "Working thread" }), + makeRow({ + threadId: "thread-2", + threadTitle: "Blocked thread", + phase: "waiting_for_approval", + status: "Approval", + }), + ], + }, + environment as never, + ); + const banner = JSON.stringify(layout.banner); + expect(banner.indexOf("Blocked thread")).toBeGreaterThan(-1); + expect(banner.indexOf("Blocked thread")).toBeLessThan(banner.indexOf("Working thread")); + }); + + it("summarizes the attention count in the banner header", () => { + const layout = AgentActivity( + { + ...props, + activeCount: 3, activities: [ makeRow({}), - makeRow({ threadId: "thread-2", phase: "waiting_for_approval", status: "Approval" }), + makeRow({ threadId: "thread-2", phase: "waiting_for_input", status: "Input" }), ], }, environment as never, ); const banner = JSON.stringify(layout.banner); - expect(banner).toContain("#14b8a6"); - expect(banner).toContain("#f97316"); + expect(banner).toContain("3 active agents"); + expect(banner).toContain("1 needs attention"); }); it("uses the attention tint for the compact presentations when a row needs input", () => { @@ -102,9 +153,9 @@ describe("AgentActivity widget layout", () => { }, environment as never, ); - expect(JSON.stringify(layout.compactLeading)).toContain("#f97316"); + expect(JSON.stringify(layout.compactLeading)).toContain("#a5b4fc"); // indigo-300 expect(JSON.stringify(layout.compactTrailing)).toContain("Input"); - expect(JSON.stringify(layout.minimal)).toContain("#f97316"); + expect(JSON.stringify(layout.minimal)).toContain("#a5b4fc"); }); it("deep links the banner to the row that needs attention", () => { @@ -148,15 +199,21 @@ describe("AgentActivity widget layout", () => { ).not.toContain("widgetURL"); }); - it("shows an overflow indicator when more activities are active than displayed", () => { + it("renders up to five rows in the banner", () => { const layout = AgentActivity( { ...props, - activeCount: 5, - activities: [makeRow({}), makeRow({ threadId: "t2" }), makeRow({ threadId: "t3" })], + activeCount: 6, + activities: [1, 2, 3, 4, 5, 6].map((n) => + makeRow({ threadId: `t${n}`, threadTitle: `Thread ${n}` }), + ), }, environment as never, ); - expect(JSON.stringify(layout.banner)).toContain("+2 more - Updated "); + const banner = JSON.stringify(layout.banner); + for (const visible of [1, 2, 3, 4, 5]) { + expect(banner).toContain(`Thread ${visible}`); + } + expect(banner).not.toContain("Thread 6"); }); }); diff --git a/apps/mobile/src/widgets/AgentActivity.tsx b/apps/mobile/src/widgets/AgentActivity.tsx index 1f7efd2c6c8..92e61caaea1 100644 --- a/apps/mobile/src/widgets/AgentActivity.tsx +++ b/apps/mobile/src/widgets/AgentActivity.tsx @@ -1,5 +1,15 @@ -import { HStack, Spacer, Text, VStack } from "@expo/ui/swift-ui"; -import { font, foregroundStyle, lineLimit, padding, widgetURL } from "@expo/ui/swift-ui/modifiers"; +import { HStack, Image, Spacer, Text, VStack, ZStack } from "@expo/ui/swift-ui"; +import type { ComponentProps } from "react"; +import { + font, + foregroundStyle, + frame, + layoutPriority, + lineLimit, + padding, + resizable, + widgetURL, +} from "@expo/ui/swift-ui/modifiers"; import { createLiveActivity, type LiveActivityComponent, @@ -46,32 +56,83 @@ export function AgentActivity( ): LiveActivityLayout { "widget"; - const isLight = environment.colorScheme === "light"; - const primaryForeground = isLight ? "#262626" : "#f5f5f5"; - const secondaryForeground = isLight ? "#525252" : "#a3a3a3"; - const mutedForeground = isLight ? "#737373" : "#8e8e93"; + // Use SwiftUI's semantic label colors rather than fixed hex keyed off the + // device color scheme. A Live Activity banner always renders over a dark + // system material regardless of the device's light/dark setting, so + // scheme-derived dark text read as unreadable dark-on-dark on the lock + // screen. Semantic colors adapt to whatever material the OS places them on: + // the dark LA banner and the (light or dark) home-screen widget alike. + const primaryForeground = "primary"; + const secondaryForeground = "secondary"; + // Status tints mirror the web sidebar's pills + // (apps/web/src/components/Sidebar.logic.ts resolveThreadStatusPill): amber + // for approval, indigo for input, sky for working, emerald for completed. + // On iPhone the LA sits on a dark material, but macOS (iPhone Mirroring / + // Mac notification center) renders it on a light one — so pick the web + // palette's light (-600) or dark (-300) variant off the color scheme. + const isLightScheme = environment.colorScheme === "light"; 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"; + switch (phase) { + case "waiting_for_approval": + return isLightScheme ? "#d97706" : "#fcd34d"; // amber-600 / amber-300 + case "waiting_for_input": + return isLightScheme ? "#4f46e5" : "#a5b4fc"; // indigo-600 / indigo-300 + case "failed": + return isLightScheme ? "#dc2626" : "#fca5a5"; // red-600 / red-300 + case "completed": + return isLightScheme ? "#059669" : "#6ee7b7"; // emerald-600 / emerald-300 + case "starting": + case "running": + default: + return isLightScheme ? "#0284c7" : "#7dd3fc"; // sky-600 / sky-300 } - return "#14b8a6"; }; - const row0 = props.activities[0]; - const row1 = props.activities[1]; - const row2 = props.activities[2]; - const attentionRow = props.activities.find( + // Order attention-first so whatever needs the user floats to the top of every + // presentation, then failures, then in-flight work, then finished/stale. + const phasePriority = (phase: AgentActivityPhase): number => { + if (phase === "waiting_for_approval" || phase === "waiting_for_input") return 0; + if (phase === "failed") return 1; + if (phase === "running" || phase === "starting") return 2; + return 3; + }; + const ordered = [...props.activities].sort( + (a, b) => phasePriority(a.phase) - phasePriority(b.phase), + ); + const row0 = ordered[0]; + const row1 = ordered[1]; + const row2 = ordered[2]; + const row3 = ordered[3]; + const row4 = ordered[4]; + + const attentionRows = props.activities.filter( (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input", ); + const attentionRow = attentionRows[0]; const failedRow = props.activities.find((row) => row.phase === "failed"); - const tint = phaseTint((attentionRow ?? failedRow ?? row0)?.phase); + const heroRow = attentionRow ?? failedRow ?? row0; + const tint = phaseTint(heroRow?.phase); + // Headline count leans on the accent when a human is actually blocked. + const headerTint = attentionRow + ? phaseTint(attentionRow.phase) + : failedRow + ? phaseTint(failedRow.phase) + : tint; + + // Header copy: "5 active agents" + (", 1 needs attention"). The banner renders + // the two parts in-line so the attention half can carry the accent color; + // `summary` is the short form for tight spots (expanded center, watch card). + const agentWord = props.activeCount === 1 ? "agent" : "agents"; + const agentsLabel = `${props.activeCount} active ${agentWord}`; + const attentionSuffix = + attentionRows.length > 0 + ? `${attentionRows.length} need${attentionRows.length === 1 ? "s" : ""} attention` + : ""; + const summary = attentionSuffix || `${props.activeCount} active`; // 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. @@ -81,107 +142,176 @@ export function AgentActivity( ? `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} - - + + // A scannable status glyph per phase — reads faster than colored words and + // ties the compact / expanded / banner / watch presentations together. + type SFName = NonNullable["systemName"]>; + const phaseSymbol = (phase: AgentActivityPhase): SFName => { + switch (phase) { + case "waiting_for_approval": + return "exclamationmark.circle.fill"; + case "waiting_for_input": + return "questionmark.circle.fill"; + case "failed": + return "xmark.octagon.fill"; + case "completed": + return "checkmark.circle.fill"; + case "starting": + return "circle.dotted"; + case "stale": + return "clock.arrow.circlepath"; + case "running": + default: + return "arrow.triangle.2.circlepath"; + } + }; + + // SF Symbols, like the logo, ignore frame/foregroundStyle applied directly to + // the image; size + tint them through a container the resizable symbol fills. + const renderGlyph = (systemName: SFName, size: number, color: string) => ( + + + + ); + + // Single-line row used by every presentation: glyph, title, inline project, + // status. The project and status carry layoutPriority(1) so when space runs + // out it's the title that truncates, never the (short) project name or the + // status label. Single-line keeps rows inside the expanded island's hard + // height budget (~160pt) and lets the banner fit more agents. + const renderCompactRow = (row: AgentActivityRowProps) => ( + + + {row.threadTitle} + + {/* No layoutPriority and no frame on the project: two bare texts take + their ideal width when it fits and shrink proportionally only when it + doesn't — so short rows never truncate, and long title + long project + truncate together. (A maxWidth frame is greedy and reserved its full + width even for short names; layoutPriority let the project starve the + title.) */} + + {row.projectTitle} + {row.status} ); + // The branded T3 mark. `assetName` resolves the template image set bundled in + // the widget extension's asset catalog. Image views only honor `resizable` + // directly (frame/foregroundStyle are dropped), so we size it via a container + // frame the resizable image fills and tint it through the container's + // foreground style, which the template image inherits. The 3:2 frame matches + // the glyph's aspect ratio so it never distorts. + const renderLogo = (height: number, color: string) => ( + + + + ); + return { banner: ( - - + {/* Logo pinned to the leading edge; the status texts centered across the + full width (ZStack so the logo doesn't skew the centering). No footer — + overflow beyond the visible rows is inferable from the count. */} + + + {renderLogo(13, primaryForeground)} + + + + - {props.title} - - - {props.subtitle} + {agentsLabel} - - - - {activeLabel} - - - {row0 ? renderRow(row0) : null} - {row1 ? renderRow(row1) : null} - {row2 ? renderRow(row2) : null} - - {overflowCount > 0 ? `+${overflowCount} more - Updated ` : "Updated "} - {updatedAt} - + {attentionSuffix ? ( + · + ) : null} + {attentionSuffix ? ( + + {attentionSuffix} + + ) : null} + + + + {row0 ? renderCompactRow(row0) : null} + {row1 ? renderCompactRow(row1) : null} + {row2 ? renderCompactRow(row2) : null} + {row3 ? renderCompactRow(row3) : null} + {row4 ? renderCompactRow(row4) : null} ), + // Compact card for the watchOS Smart Stack + CarPlay (the `.small` family): + // brand + count, then the single most important agent with its status glyph. bannerSmall: ( - - + + + {renderLogo(14, primaryForeground)} - {props.title} + {attentionRows.length > 0 ? summary : activeLabel} - - {activeLabel} - {row0 ? ( - + {row0.threadTitle} - - {row0.projectTitle} - {row0.status} + + + {row0.status} - + ) : null} ), - compactLeading: ( - T3 - ), + compactLeading: renderLogo(14, tint), compactTrailing: ( {attentionRow @@ -191,42 +321,45 @@ export function AgentActivity( : activeLabel} ), - minimal: ( - T3 - ), + // The shared/minimal form is a ~22pt circle — a single signal reads there, + // the wordmark does not. Show the blocking phase glyph, else the mark. + minimal: + attentionRow || failedRow + ? renderGlyph(phaseSymbol(heroRow.phase), 13, phaseTint(heroRow.phase)) + : renderLogo(11, tint), expandedLeading: ( - - - {activeLabel} + + {renderLogo(15, tint)} + + {`${props.activeCount}`} - - ), - expandedCenter: row0 ? ( - - - {row0.threadTitle} - - - {row0.projectTitle} - {row0.status} - - - ) : null, - expandedTrailing: ( - - Updated {updatedAt} - + ), + // No center content: the phase glyphs + statuses in expandedBottom already + // carry the attention signal, and the expanded island's height budget is + // tight enough that a summary line there pushed the third row off. + expandedCenter: null, + // No trailing content: a timestamp is glanceable-lock-screen info, not + // useful in a view the user is actively holding open — and the trailing + // region hugs the island's corner radius, which clipped it anyway. + expandedTrailing: null, expandedBottom: ( - - {row0 ? renderRow(row0) : null} - {row1 ? renderRow(row1) : null} - {row2 ? renderRow(row2) : null} + // Vertical padding only: the expanded region provides its own horizontal + // content margins, so `all` padding double-indented the rows. + // Horizontal padding keeps both edges clear of the island's corner + // curvature (right edge clipped status labels; titles hugged the left). + + {row0 ? renderCompactRow(row0) : null} + {row1 ? renderCompactRow(row1) : null} + {row2 ? renderCompactRow(row2) : null} ), }; diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts index 8455702e2d3..2bd9c053e90 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts @@ -181,7 +181,9 @@ function statusForPhase(phase: RelayAgentActivityState["phase"]): string { case "failed": return "Failed"; case "starting": - return "Starting"; + // Matches the web sidebar's pill wording (Sidebar.logic.ts) so the same + // thread reads the same across surfaces. + return "Connecting"; case "running": return "Working"; case "stale": From 5ad2eeacc80b08845de61deb708bf3c912975b41 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Jul 2026 16:05:46 -0700 Subject: [PATCH 03/26] Fix review findings across publish linking and registration - `t3 connect publish` no longer downgrades an already-pending managed link to publish-only; it defers to the pending link, which also covers publishing - Device register-once now carries the last accepted push-to-start token into registrations triggered without a token event, so token absence reads as "unchanged" and the per-launch skip actually fires (previously the signature alternated between trigger shapes and re-POSTed twice every launch) - Activity-token registrations dedupe within a 60s window instead of forever: bursts collapse to one request while a warm foreground after real time away still triggers the relay's aggregate replay (the drift/orphan repair) - Downgrading a managed link to publish-only now deprovisions the previously provisioned tunnel + DNS on the relay (best effort; nothing else released them before a full unlink) - managedTunnelActive is optional in the link-state contract so newer clients tolerate older environment servers; the web falls back to `linked` (old semantics) when absent - Web reconcile refreshes rendered link state on failure exits that follow a partially applied mutation, and the tunnel-off toast says "tunnel disabled" instead of "unlinked" when publishing stays on Co-Authored-By: Claude Fable 5 --- .../remoteRegistration.test.ts | 100 +++++++++++++++++- .../agent-awareness/remoteRegistration.ts | 50 ++++++--- apps/mobile/src/lib/storage.ts | 12 ++- apps/server/src/cli/connect.ts | 10 ++ .../settings/ConnectionsSettings.tsx | 24 ++++- .../environments/EnvironmentLinker.test.ts | 11 +- .../src/environments/EnvironmentLinker.ts | 21 ++++ packages/contracts/src/environmentHttp.ts | 3 +- 8 files changed, 211 insertions(+), 20 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index deff5160042..dc82760e8f4 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -37,6 +37,7 @@ import { unregisterAgentAwarenessConnection, } from "./remoteRegistration"; import * as Notifications from "expo-notifications"; +import { addPushToStartTokenListener } from "expo-widgets"; const secureStore = vi.hoisted(() => new Map()); const widgetMocks = vi.hoisted(() => ({ @@ -52,7 +53,11 @@ const appStateMock = vi.hoisted(() => ({ listeners: [] as Array<(state: string) => void>, })); const registrationRecordStore = vi.hoisted(() => ({ - current: null as { readonly identity: string; readonly signature: string } | null, + current: null as { + readonly identity: string; + readonly signature: string; + readonly pushToStartToken?: string; + } | null, })); vi.mock("expo-constants", () => ({ @@ -565,6 +570,99 @@ describe("makeRelayDeviceRegistrationRequest", () => { }).pipe(Effect.provide(relayTestLayer)); }); + it.effect("carries the accepted push-to-start token forward so tokenless refreshes skip", () => { + const fetchMock = vi.fn((request: RequestInfo | URL) => { + const url = request instanceof Request ? request.url : String(request); + return Promise.resolve( + Response.json( + url.endsWith("/v1/client/dpop-token") + ? { + access_token: "relay-dpop-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 300, + scope: "mobile:registration", + } + : { ok: true }, + ), + ); + }); + vi.stubGlobal("fetch", fetchMock); + Constants.expoConfig!.extra = { + relay: { + url: "https://relay.example.test/", + }, + }; + + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + const pushToStartListener = vi.mocked(addPushToStartTokenListener).mock.calls.at(-1)?.[0]; + expect(pushToStartListener).toBeDefined(); + pushToStartListener?.({ activityPushToStartToken: "push-to-start-token" }); + + return Effect.gen(function* () { + yield* runBackgroundOperations(); + expect(registrationRecordStore.current?.pushToStartToken).toBe("push-to-start-token"); + + // A later refresh has no token event to carry — it must reuse the + // persisted token and skip instead of re-posting. (Fetch counts are + // unreliable here: the module-level relay layer captures the first + // test's fetch, so assert via the record save that only follows a real + // relay POST.) + vi.mocked(saveAgentAwarenessRegistrationRecord).mockClear(); + yield* refreshAgentAwarenessRegistration(); + expect(saveAgentAwarenessRegistrationRecord).not.toHaveBeenCalled(); + expect(getAgentAwarenessRegistrationStatus()).toBe("registered"); + }).pipe(Effect.provide(relayTestLayer)); + }); + + it.effect("dedupes rapid activity-token re-registrations within the replay window", () => { + // Fetch counts are unreliable here (the module-level relay layer captures + // the first test's fetch), so assert on the flow's own seams: a real + // registration attempt loads the device id, a deduped one short-circuits + // before it. + const fetchMock = vi.fn((request: RequestInfo | URL) => { + const url = request instanceof Request ? request.url : String(request); + return Promise.resolve( + Response.json( + url.endsWith("/v1/client/dpop-token") + ? { + access_token: "relay-dpop-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 300, + scope: "mobile:registration", + } + : { ok: true }, + ), + ); + }); + vi.stubGlobal("fetch", fetchMock); + Constants.expoConfig!.extra = { + relay: { + url: "https://relay.example.test/", + }, + }; + 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* () { + // Drains the sign-in refresh, which registers the activity token. + yield* runBackgroundOperations(); + expect(activity.getPushToken).toHaveBeenCalled(); + + // A burst refresh (foreground / connection update seconds later) must + // dedupe: it reads the token but never proceeds to a registration + // attempt (which would load the device id first). + vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockClear(); + yield* refreshActiveLiveActivityRemoteRegistration(); + expect(loadOrCreateAgentAwarenessDeviceId).not.toHaveBeenCalled(); + }).pipe(Effect.provide(relayTestLayer)); + }); + it.effect("re-registers when the stored account identity differs", () => { Constants.expoConfig!.extra = { relay: { diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index bcb00877dde..167376987e3 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -61,13 +61,17 @@ export class AgentAwarenessOperationError extends Schema.TaggedErrorClass(); const activityPushTokenListeners = new WeakSet>(); -// Activity tokens the relay has already accepted this session. The payload is -// just {deviceId, activityPushToken}, so re-registering an accepted token is a -// pure no-op relay round-trip — and the refresh runs on sign-in, every app -// foreground, and every environment-connection update, which spammed identical -// registrations. Cleared on sign-out/identity change alongside the device -// registration state. -const registeredActivityPushTokens = new Set(); +// Activity tokens the relay recently accepted, by acceptance time. The refresh +// runs on sign-in, every app foreground, and every environment-connection +// update, which arrive in bursts and spammed identical registrations. But the +// registration is not a pure no-op: the relay replays the current aggregate to +// this device on every accepted registration, and that replay is the +// foreground reconciliation that repairs drifted or orphaned activities. So +// dedupe only within a short window — bursts collapse to one request, while a +// foreground after real time away still triggers a replay. Cleared on +// sign-out/identity change alongside the device registration state. +const ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS = 60_000; +const registeredActivityPushTokens = new Map(); let pushToStartSubscription: { remove: () => void } | null = null; let pushTokenSubscription: { remove: () => void } | null = null; let appStateSubscription: { remove: () => void } | null = null; @@ -318,11 +322,20 @@ function registerDeviceWithRelay( // sign-out, so a device stays registered across launches without re-hitting // the relay every time the app opens. const identity = relayTokenProviderIdentity ?? ""; - const signature = registrationSignature(body); const persisted = yield* Effect.tryPromise({ try: () => loadAgentAwarenessRegistrationRecord(), catch: (cause) => cause, }).pipe(Effect.orElseSucceed(() => null)); + // The push-to-start token only rides along on registrations triggered by a + // native token event; ones triggered by sign-in or app foreground omit it. + // Carry the last accepted token forward so its absence means "unchanged", + // not "cleared" — otherwise the signature alternates between the two + // trigger shapes and the skip below never fires. + const payload = + !body.pushToStartToken && persisted?.identity === identity && persisted.pushToStartToken + ? { ...body, pushToStartToken: persisted.pushToStartToken } + : body; + const signature = registrationSignature(payload); if (persisted && persisted.identity === identity && persisted.signature === signature) { setRegistrationStatus("registered"); logRegistrationDebug("relay device registration skipped; already registered for account", { @@ -337,11 +350,15 @@ function registerDeviceWithRelay( }); yield* client.registerDevice({ clerkToken: token, - payload: body, + payload, }); setRegistrationStatus("registered"); yield* Effect.promise(() => - saveAgentAwarenessRegistrationRecord({ identity, signature }).catch((error: unknown) => { + saveAgentAwarenessRegistrationRecord({ + identity, + signature, + ...(payload.pushToStartToken ? { pushToStartToken: payload.pushToStartToken } : {}), + }).catch((error: unknown) => { logRegistrationError("persist registration record failed", error); }), ); @@ -608,7 +625,9 @@ 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. +// never arrived. (Deduped by ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS: rapid +// foreground/sign-in bursts collapse to one registration, but returning after +// real time away still replays.) function ensureAppStateListener(): void { if (appStateSubscription || !canRegisterRemoteLiveActivities()) { return; @@ -712,6 +731,7 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { pendingDeviceRegistration = null; registrationStatus = "unknown"; registrationStatusListeners.clear(); + registeredActivityPushTokens.clear(); } export function unregisterAgentAwarenessDeviceForCurrentUser( @@ -799,7 +819,11 @@ function registerLiveActivityPushTokenValue(input: { readonly activityPushToken: string; }): Effect.Effect { return Effect.gen(function* () { - if (registeredActivityPushTokens.has(input.activityPushToken)) { + const acceptedAt = registeredActivityPushTokens.get(input.activityPushToken); + if ( + acceptedAt !== undefined && + Date.now() - acceptedAt < ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS + ) { return true; } const deviceId = yield* Effect.tryPromise({ @@ -815,7 +839,7 @@ function registerLiveActivityPushTokenValue(input: { activityPushToken: input.activityPushToken, }); if (registered) { - registeredActivityPushTokens.add(input.activityPushToken); + registeredActivityPushTokens.set(input.activityPushToken, Date.now()); logRegistrationDebug("live activity push token registered", { tokenSuffix: input.activityPushToken.slice(-8), }); diff --git a/apps/mobile/src/lib/storage.ts b/apps/mobile/src/lib/storage.ts index db200e8214e..57f7359ec43 100644 --- a/apps/mobile/src/lib/storage.ts +++ b/apps/mobile/src/lib/storage.ts @@ -228,6 +228,10 @@ export async function loadAgentAwarenessDeviceId(): Promise { export interface AgentAwarenessRegistrationRecord { readonly identity: string; readonly signature: string; + // Last push-to-start token the relay accepted. Registrations triggered + // without a token event merge it back in so token absence never reads as a + // change (which would defeat the register-once skip every launch). + readonly pushToStartToken?: string; } // Remembers the account identity and payload signature the relay last accepted @@ -245,7 +249,13 @@ export async function loadAgentAwarenessRegistrationRecord(): Promise Effect.succeed(false), }), Layer.succeed(ManagedEndpointProvider.ManagedEndpointProvider, { - deprovision: () => Effect.void, + deprovision: input?.deprovision ?? (() => Effect.void), provision: () => Effect.succeed({ endpoint: { @@ -175,6 +176,7 @@ describe("EnvironmentLinker", () => { it.effect("links a publish-only environment with a non-secure nominal endpoint", () => { let persistedEndpoint: string | null = null; + let deprovisionedEnvironmentId: string | null = null; return Effect.gen(function* () { const now = yield* DateTime.now; const expiresAt = DateTime.add(now, { minutes: 5 }); @@ -226,6 +228,9 @@ describe("EnvironmentLinker", () => { expect(result.environmentCredential).toBe("t3env_credential_secret"); expect(result.endpointRuntime).toBeNull(); expect(persistedEndpoint).toBe("http://127.0.0.1:3773/"); + // Downgrading from a managed link must release the previously provisioned + // tunnel; nothing else cleans it up before a full unlink. + expect(deprovisionedEnvironmentId).toBe("env-link-test"); }).pipe( Effect.provide( testLayer({ @@ -233,6 +238,10 @@ describe("EnvironmentLinker", () => { Effect.sync(() => { persistedEndpoint = input.endpoint.httpBaseUrl; }), + deprovision: (input) => + Effect.sync(() => { + deprovisionedEnvironmentId = input.environmentId; + }), }), ), ); diff --git a/infra/relay/src/environments/EnvironmentLinker.ts b/infra/relay/src/environments/EnvironmentLinker.ts index a2b528ca372..fbfdc6428a4 100644 --- a/infra/relay/src/environments/EnvironmentLinker.ts +++ b/infra/relay/src/environments/EnvironmentLinker.ts @@ -287,6 +287,27 @@ const make = Effect.gen(function* () { stage: "validate_origin", }); } + // Downgrading a managed link to publish-only must release the tunnel and + // DNS that were provisioned for it — nothing else cleans them up until a + // full unlink. Best effort: a cleanup failure must not block the link + // itself, and the provider treats an absent allocation as already + // deprovisioned, so retrying on every non-tunnel link is cheap. + if (!input.request.managedTunnelsEnabled) { + yield* managedEndpointProvider + .deprovision({ + userId: input.userId, + environmentId: verified.environmentId, + }) + .pipe( + Effect.tapError((error) => + Effect.logWarning("managed endpoint deprovision on publish-only link failed", { + environmentId: verified.environmentId, + errorTag: error._tag, + }), + ), + Effect.ignore, + ); + } const provisioned = input.request.managedTunnelsEnabled ? yield* managedEndpointProvider.provision({ userId: input.userId, diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 52b0767307d..2d40dad60cc 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -345,7 +345,8 @@ export const EnvironmentCloudLinkStateResult = Schema.Struct({ // A managed Cloudflare tunnel is provisioned for this link. False for a // publish-only link (activity publishing without a relay-managed tunnel), so // clients can present the two capabilities as independent settings. - managedTunnelActive: Schema.Boolean, + // Optional so newer clients tolerate older environment servers. + managedTunnelActive: Schema.optional(Schema.Boolean), publishAgentActivity: Schema.Boolean, }); export type EnvironmentCloudLinkStateResult = typeof EnvironmentCloudLinkStateResult.Type; From 34338a836497835aa1134fc1a0f527bf42ed2529 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Jul 2026 16:07:01 -0700 Subject: [PATCH 04/26] Alert Live Activity updates when agents newly need attention or finish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live Activity updates were always silent (priority 5, no alert), so for users with Live Activities enabled a thread flipping to Approval/Input redrew the lock-screen card without any haptic — and since the notification channel is suppressed when Live Activities are on, they got no interruption at all. - APNs liveactivity update/end payloads can now carry an alert dict (title/body/sound), which makes iOS wake the screen and play the haptic; alerting updates are sent at priority 10 - An update alerts when a thread newly enters an attention phase relative to the previously delivered aggregate (no baseline → no alert, so replays and fresh registrations cannot buzz); multiple newly blocked threads collapse into one summary alert - An end event alerts with the Done/Failed row only when the companion push notification is not also being sent, so notification-enabled users are not buzzed twice while LA-only users still get the completion buzz - Alerts honor the same per-event switches as the push channel (notifyOnApproval/Input/Completion/Failure) - The alert rides the signed delivery job (optional field, omitted when absent so signatures of alert-less jobs match older relay builds) Co-Authored-By: Claude Fable 5 --- .../src/agentActivity/ApnsClient.test.ts | 26 ++++ infra/relay/src/agentActivity/ApnsClient.ts | 23 +++- .../src/agentActivity/ApnsDeliveries.test.ts | 112 ++++++++++++++++ .../relay/src/agentActivity/ApnsDeliveries.ts | 121 +++++++++++++++++- .../src/agentActivity/ApnsDeliveryQueue.ts | 1 + .../src/agentActivity/apnsDeliveryJobs.ts | 15 +++ 6 files changed, 291 insertions(+), 7 deletions(-) diff --git a/infra/relay/src/agentActivity/ApnsClient.test.ts b/infra/relay/src/agentActivity/ApnsClient.test.ts index bb557376862..ff34a73e3df 100644 --- a/infra/relay/src/agentActivity/ApnsClient.test.ts +++ b/infra/relay/src/agentActivity/ApnsClient.test.ts @@ -96,6 +96,32 @@ describe("ApnsClient", () => { }).pipe(Effect.provide(TestLayer)), ); + it.effect("builds a high-priority alerting update payload when an alert is attached", () => + Effect.gen(function* () { + const apns = yield* ApnsClient.ApnsClient; + const request = apns.makeLiveActivityRequest({ + event: "update", + token: "token", + state, + alert: { title: "Thread", body: "Approval: Project" }, + nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), + nowIso: DateTime.formatIso(now), + }); + + expect(request.priority).toBe("10"); + expect(request.payload).toMatchObject({ + aps: { + event: "update", + alert: { + title: "Thread", + body: "Approval: Project", + sound: "default", + }, + }, + }); + }).pipe(Effect.provide(TestLayer)), + ); + it.effect("builds an end payload with a dismissal date", () => Effect.gen(function* () { const apns = yield* ApnsClient.ApnsClient; diff --git a/infra/relay/src/agentActivity/ApnsClient.ts b/infra/relay/src/agentActivity/ApnsClient.ts index 1854bc06a7a..166dc6fc4b9 100644 --- a/infra/relay/src/agentActivity/ApnsClient.ts +++ b/infra/relay/src/agentActivity/ApnsClient.ts @@ -12,7 +12,7 @@ import * as Headers from "effect/unstable/http/Headers"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import { ApnsEnvironment as ApnsEnvironmentSchema, type ApnsCredentials } from "../Config.ts"; -import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts"; +import type { ApnsLiveActivityAlert, ApnsNotificationPayload } from "./apnsDeliveryJobs.ts"; const LIVE_ACTIVITY_NAME = "AgentActivity"; // Updates only flow on domain events, so a healthy agent can be silent for @@ -201,12 +201,27 @@ type MakeLiveActivityRequestInput = | (LiveActivityRequestBase & { readonly event: "end"; readonly state: RelayAgentActivityAggregateState | null; + readonly alert?: ApnsLiveActivityAlert | null; }) | (LiveActivityRequestBase & { readonly event: "start" | "update"; readonly state: RelayAgentActivityAggregateState; + readonly alert?: ApnsLiveActivityAlert | null; }); +// An alert dict on an update/end makes it an "alerting" update: iOS wakes the +// screen and plays the haptic (the Apple Sports score-change behavior) instead +// of silently redrawing the activity. +function liveActivityAlertPayload(alert: ApnsLiveActivityAlert) { + return { + alert: { + title: alert.title, + body: alert.body, + sound: "default", + }, + }; +} + function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveActivityRequest { const timestamp = input.nowEpochSeconds; if (input.event === "end") { @@ -219,6 +234,7 @@ function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveA timestamp, event: "end", ...(input.state ? { "content-state": contentState(input.state) } : {}), + ...(input.alert ? liveActivityAlertPayload(input.alert) : {}), "dismissal-date": timestamp + DISMISS_AFTER_SECONDS, }, }, @@ -229,7 +245,9 @@ function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveA return { token: input.token, event: input.event, - priority: input.event === "update" ? "5" : "10", + // Alerting updates must land immediately; routine redraws stay at the + // budget-friendly low priority. + priority: input.event === "update" && !input.alert ? "5" : "10", payload: { aps: { timestamp, @@ -245,6 +263,7 @@ function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveA }, } : {}), + ...(input.event === "update" && input.alert ? liveActivityAlertPayload(input.alert) : {}), "content-state": contentState(state), "stale-date": timestamp + STALE_AFTER_SECONDS, }, diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index 171ed6eeaab..2e2301870aa 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -1369,3 +1369,115 @@ describe("ApnsDeliveries", () => { ); }); }); + +describe("live activity alert decisions", () => { + const preferences = { + liveActivitiesEnabled: true, + notificationsEnabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + }; + + const attentionRow = { + ...aggregate.activities[0]!, + threadId: "thread-2" as RelayAgentActivityState["threadId"], + threadTitle: "Blocked thread", + phase: "waiting_for_approval" as const, + status: "Approval", + }; + + it("alerts when a thread newly enters an attention phase", () => { + const alert = ApnsDeliveries.alertForAttentionTransition({ + previousAggregate: aggregate, + nextAggregate: { + ...aggregate, + activeCount: 2, + activities: [...aggregate.activities, attentionRow], + }, + preferences, + }); + expect(alert).toEqual({ title: "Blocked thread", body: "Approval: Project" }); + }); + + it("stays silent when the attention phase was already delivered", () => { + const withAttention = { + ...aggregate, + activeCount: 2, + activities: [...aggregate.activities, attentionRow], + }; + expect( + ApnsDeliveries.alertForAttentionTransition({ + previousAggregate: withAttention, + nextAggregate: withAttention, + preferences, + }), + ).toBeNull(); + }); + + it("stays silent without a delivered baseline so replays cannot buzz", () => { + expect( + ApnsDeliveries.alertForAttentionTransition({ + previousAggregate: null, + nextAggregate: { ...aggregate, activities: [attentionRow] }, + preferences, + }), + ).toBeNull(); + }); + + it("honors the per-event notification switch for attention alerts", () => { + expect( + ApnsDeliveries.alertForAttentionTransition({ + previousAggregate: aggregate, + nextAggregate: { + ...aggregate, + activeCount: 2, + activities: [...aggregate.activities, attentionRow], + }, + preferences: { ...preferences, notifyOnApproval: false }, + }), + ).toBeNull(); + }); + + it("summarizes multiple newly blocked threads in one alert", () => { + const secondAttentionRow = { + ...attentionRow, + threadId: "thread-3" as RelayAgentActivityState["threadId"], + threadTitle: "Other blocked thread", + phase: "waiting_for_input" as const, + status: "Input", + }; + const alert = ApnsDeliveries.alertForAttentionTransition({ + previousAggregate: aggregate, + nextAggregate: { + ...aggregate, + activeCount: 3, + activities: [...aggregate.activities, attentionRow, secondAttentionRow], + }, + preferences, + }); + expect(alert).toEqual({ + title: "2 agents need attention", + body: "Blocked thread, Other blocked thread", + }); + }); + + it("alerts for a terminal aggregate and honors the completion switch", () => { + const terminalAggregate = { + ...aggregate, + activeCount: 0, + activities: [{ ...aggregate.activities[0]!, phase: "completed" as const, status: "Done" }], + }; + expect( + ApnsDeliveries.alertForTerminalAggregate({ aggregate: terminalAggregate, preferences }), + ).toEqual({ title: "Thread", body: "Done: Project" }); + expect( + ApnsDeliveries.alertForTerminalAggregate({ + aggregate: terminalAggregate, + preferences: { ...preferences, notifyOnCompletion: false }, + }), + ).toBeNull(); + expect(ApnsDeliveries.alertForTerminalAggregate({ aggregate: null, preferences })).toBeNull(); + }); +}); diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index 927d8f2a316..95461561898 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -26,6 +26,7 @@ import { ApnsDeliveryJobLiveActivityAggregateMissing, ApnsDeliveryJobPushNotificationMissing, ApnsDeliveryJobQueuePayloadInvalid, + type ApnsLiveActivityAlert, type ApnsNotificationPayload, SignedApnsDeliveryJob, isApnsDeliveryJobVerificationError, @@ -55,11 +56,13 @@ type ChosenLiveActivityDelivery = readonly kind: "live_activity_start" | "live_activity_update"; readonly token: string; readonly aggregate: RelayAgentActivityAggregateState; + readonly alert: ApnsLiveActivityAlert | null; } | { readonly kind: "live_activity_end"; readonly token: string; readonly aggregate: RelayAgentActivityAggregateState | null; + readonly alert: ApnsLiveActivityAlert | null; }; type ChosenPushNotificationDelivery = { @@ -136,6 +139,85 @@ function aggregateNeedsAttention(aggregate: RelayAgentActivityAggregateState): b ); } +function isAttentionPhase(phase: string): boolean { + return phase === "waiting_for_approval" || phase === "waiting_for_input"; +} + +// Honors the same per-event notification switches the push channel uses; a +// missing/corrupt preferences blob only disables nothing (matching how the +// liveActivitiesEnabled check treats it), since every registration writes one. +function alertAllowedForPhase( + preferences: RelayAgentAwarenessPreferences | null, + phase: string, +): boolean { + if (preferences === null) { + return true; + } + switch (phase) { + case "waiting_for_approval": + return preferences.notifyOnApproval; + case "waiting_for_input": + return preferences.notifyOnInput; + case "completed": + return preferences.notifyOnCompletion; + case "failed": + return preferences.notifyOnFailure; + default: + return false; + } +} + +// Alert copy for an update whose aggregate contains threads that were NOT in an +// attention phase in the previously delivered aggregate. A null previous +// aggregate means there is no known baseline (fresh registration, replay after +// data loss) — alerting there would buzz on reconnect, not on a transition. +export function alertForAttentionTransition(input: { + readonly previousAggregate: RelayAgentActivityAggregateState | null; + readonly nextAggregate: RelayAgentActivityAggregateState; + readonly preferences: RelayAgentAwarenessPreferences | null; +}): ApnsLiveActivityAlert | null { + if (input.previousAggregate === null) { + return null; + } + const previouslyAttention = new Set( + input.previousAggregate.activities + .filter((row) => isAttentionPhase(row.phase)) + .map((row) => row.threadId), + ); + const newlyAttention = input.nextAggregate.activities.filter( + (row) => + isAttentionPhase(row.phase) && + !previouslyAttention.has(row.threadId) && + alertAllowedForPhase(input.preferences, row.phase), + ); + const first = newlyAttention[0]; + if (!first) { + return null; + } + if (newlyAttention.length === 1) { + return { title: first.threadTitle, body: `${first.status}: ${first.projectTitle}` }; + } + return { + title: `${newlyAttention.length} agents need attention`, + body: newlyAttention.map((row) => row.threadTitle).join(", "), + }; +} + +// Alert copy for an end event carrying a terminal (Done/Failed) aggregate. +export function alertForTerminalAggregate(input: { + readonly aggregate: RelayAgentActivityAggregateState | null; + readonly preferences: RelayAgentAwarenessPreferences | null; +}): ApnsLiveActivityAlert | null { + const row = input.aggregate?.activities[0]; + if (!row || (row.phase !== "completed" && row.phase !== "failed")) { + return null; + } + if (!alertAllowedForPhase(input.preferences, row.phase)) { + return null; + } + return { title: row.threadTitle, body: `${row.status}: ${row.projectTitle}` }; +} + function shouldUpdateLiveActivity(input: { readonly previousAggregate: RelayAgentActivityAggregateState | null; readonly nextAggregate: RelayAgentActivityAggregateState; @@ -220,6 +302,7 @@ function chooseLiveActivityDelivery(input: { kind: "live_activity_end", token: input.target.activity_push_token, aggregate: null, + alert: null, } : null; } @@ -229,6 +312,7 @@ function chooseLiveActivityDelivery(input: { kind: "live_activity_end", token: input.target.activity_push_token, aggregate: input.aggregate, + alert: null, } : null; } @@ -238,14 +322,16 @@ function chooseLiveActivityDelivery(input: { kind: "live_activity_start", token: input.target.push_to_start_token, aggregate: input.aggregate, + alert: null, } : null; } if (!input.target.activity_push_token) { return null; } + const previousAggregate = parseAggregate(input.target.last_aggregate_json); return shouldUpdateLiveActivity({ - previousAggregate: parseAggregate(input.target.last_aggregate_json), + previousAggregate, nextAggregate: input.aggregate, lastDeliveryAt: input.target.last_live_activity_delivery_at, nowMs: input.nowMs, @@ -254,6 +340,11 @@ function chooseLiveActivityDelivery(input: { kind: "live_activity_update", token: input.target.activity_push_token, aggregate: input.aggregate, + alert: alertForAttentionTransition({ + previousAggregate, + nextAggregate: input.aggregate, + preferences, + }), } : "suppressed"; } @@ -422,10 +513,12 @@ export type SendLiveActivityDeliveryInput = | (SendLiveActivityDeliveryInputBase & { readonly kind: "live_activity_start" | "live_activity_update"; readonly aggregate: RelayAgentActivityAggregateState; + readonly alert?: ApnsLiveActivityAlert | null; }) | (SendLiveActivityDeliveryInputBase & { readonly kind: "live_activity_end"; readonly aggregate: RelayAgentActivityAggregateState | null; + readonly alert?: ApnsLiveActivityAlert | null; }); function makeLiveActivityDeliveryRequest( @@ -449,6 +542,7 @@ function makeLiveActivityDeliveryRequest( ...base, event: deliveryEvent(input.kind), state: input.aggregate, + alert: input.alert ?? null, }), }; case "live_activity_end": @@ -459,6 +553,7 @@ function makeLiveActivityDeliveryRequest( ...base, event: "end", state: input.aggregate, + alert: input.alert ?? null, }), }; } @@ -789,6 +884,7 @@ export const make = Effect.gen(function* () { sourceJobId: payload.jobId, kind: payload.kind, aggregate: payload.aggregate, + alert: payload.alert ?? null, }); case "live_activity_end": return sendLiveActivity({ @@ -802,6 +898,7 @@ export const make = Effect.gen(function* () { sourceJobId: payload.jobId, kind: payload.kind, aggregate: payload.aggregate, + alert: payload.alert ?? null, }); case "push_notification": if (payload.notification === null) { @@ -866,6 +963,23 @@ export const make = Effect.gen(function* () { }); return result; } + const notification = notificationForAggregate({ + target: input.target, + aggregate: input.aggregate, + }); + // The end event doubles as the "task finished" moment. When a companion + // push notification is about to ring the device (below), the activity end + // stays silent; otherwise the end itself carries the alert so LA-only + // users still get the buzz. + const alert = + delivery.kind === "live_activity_end" + ? notification && input.target.push_token + ? null + : alertForTerminalAggregate({ + aggregate: delivery.aggregate, + preferences: parsePreferences(input.target.preferences_json), + }) + : delivery.alert; const result = yield* deliveryQueue.enqueueLiveActivity({ userId: input.target.user_id, deviceId: input.target.device_id, @@ -874,10 +988,7 @@ export const make = Effect.gen(function* () { bundleId: input.target.bundle_id, apsEnvironment: input.target.aps_environment, aggregate: delivery.aggregate, - }); - const notification = notificationForAggregate({ - target: input.target, - aggregate: input.aggregate, + alert, }); if (delivery.kind === "live_activity_end" && notification && input.target.push_token) { yield* deliveryQueue.enqueuePushNotification({ diff --git a/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts index 66614830149..3b095d6fd56 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts @@ -61,6 +61,7 @@ export class ApnsDeliveryQueue extends Context.Service< readonly bundleId?: string | null; readonly apsEnvironment?: "sandbox" | "production" | null; readonly aggregate: ApnsDeliveryJobPayload["aggregate"]; + readonly alert?: ApnsDeliveryJobPayload["alert"]; }) => Effect.Effect; readonly enqueuePushNotification: (input: { readonly userId: string; diff --git a/infra/relay/src/agentActivity/apnsDeliveryJobs.ts b/infra/relay/src/agentActivity/apnsDeliveryJobs.ts index aba4db8d538..027b17bd5e0 100644 --- a/infra/relay/src/agentActivity/apnsDeliveryJobs.ts +++ b/infra/relay/src/agentActivity/apnsDeliveryJobs.ts @@ -41,6 +41,15 @@ export const ApnsNotificationPayload = Schema.Struct({ }); export type ApnsNotificationPayload = typeof ApnsNotificationPayload.Type; +// Alert copy attached to a Live Activity update/end push. Its presence makes +// the update "alerting": iOS wakes the screen, plays the haptic, and briefly +// expands the Dynamic Island instead of silently redrawing. +export const ApnsLiveActivityAlert = Schema.Struct({ + title: Schema.String, + body: Schema.String, +}); +export type ApnsLiveActivityAlert = typeof ApnsLiveActivityAlert.Type; + export const ApnsDeliveryJobPayload = Schema.Struct({ version: Schema.Literal(1), jobId: Schema.String, @@ -56,6 +65,8 @@ export const ApnsDeliveryJobPayload = Schema.Struct({ }), aggregate: Schema.NullOr(RelayAgentActivityAggregateState), notification: Schema.NullOr(ApnsNotificationPayload), + // Optional so jobs queued by older relay builds still decode. + alert: Schema.optional(Schema.NullOr(ApnsLiveActivityAlert)), createdAt: Schema.String, expiresAt: Schema.String, }); @@ -232,6 +243,7 @@ export function makeApnsDeliveryJobPayload(input: { readonly apsEnvironment?: "sandbox" | "production" | null | undefined; readonly aggregate: ApnsDeliveryJobPayload["aggregate"]; readonly notification?: ApnsNotificationPayload | null; + readonly alert?: ApnsLiveActivityAlert | null | undefined; readonly createdAt: string; readonly expiresAt: string; readonly jobId: string; @@ -249,6 +261,9 @@ export function makeApnsDeliveryJobPayload(input: { }, aggregate: input.aggregate, notification: input.notification ?? null, + // Omitted (not null) when absent so signatures stay identical to jobs from + // relay builds that predate the field. + ...(input.alert ? { alert: input.alert } : {}), createdAt: input.createdAt, expiresAt: input.expiresAt, }; From 870cc6fee91ce66ad86c6e2a514788553219e2d6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Jul 2026 16:37:06 -0700 Subject: [PATCH 05/26] Fix registration lifecycle drift and publish-only CLI gaps from PR review - Early registration exits (no relay config, no token) now report status "unknown" instead of leaving the pending state stuck, and failure paths no longer demote an already-registered device. - Discard stale registration successes that complete after sign-out by re-checking the account generation around the relay call, and scope the dedupe signature to the relay URL so switching relays re-registers. - Carry the persisted push-to-start token into re-registrations so PTS alternation can't defeat the register-once skip. - CloudAuthBridge unmount now releases the token provider without ending lock-screen activities or wiping the persisted registration; real sign-out paths are unchanged. - `t3 connect publish --disable` clears a pending publish-only link, and `t3 connect link --publish-only` enables the publish secret it depends on. Co-Authored-By: Claude Fable 5 --- .../remoteRegistration.test.ts | 54 ++++++++++++++ .../agent-awareness/remoteRegistration.ts | 72 +++++++++++++++++-- .../src/features/cloud/CloudAuthProvider.tsx | 7 +- apps/server/src/cli/connect.ts | 15 ++++ 4 files changed, 143 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index dc82760e8f4..99686898eb7 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -32,6 +32,7 @@ import { normalizeAgentAwarenessRelayBaseUrl, registerAgentAwarenessConnection, registerLiveActivityPushToken, + releaseAgentAwarenessRelayTokenProvider, setAgentAwarenessRelayTokenProvider, shouldRegisterAgentAwarenessDeviceForProvider, unregisterAgentAwarenessConnection, @@ -547,6 +548,59 @@ describe("makeRelayDeviceRegistrationRequest", () => { expect(clearAgentAwarenessRegistrationRecord).toHaveBeenCalled(); }); + it("releases the provider without ending activities or clearing the registration", () => { + 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); + registrationRecordStore.current = { identity: "", signature: "sig" }; + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + expect(appStateMock.listeners).toHaveLength(1); + + releaseAgentAwarenessRelayTokenProvider(); + + expect(appStateMock.listeners).toHaveLength(0); + expect(end).not.toHaveBeenCalled(); + expect(clearAgentAwarenessRegistrationRecord).not.toHaveBeenCalled(); + expect(registrationRecordStore.current).not.toBeNull(); + }); + + it.effect("resets a pending status to unknown when relay config is missing", () => { + // No relay url configured: registration can neither run nor ever succeed, + // so the status must not stick at "pending". + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + + return Effect.gen(function* () { + yield* runBackgroundOperations(); + expect(getAgentAwarenessRegistrationStatus()).toBe("unknown"); + }).pipe(Effect.provide(relayTestLayer)); + }); + + it.effect("keeps a registered status when a later refresh fails", () => { + Constants.expoConfig!.extra = { + relay: { + url: "https://relay.example.test/", + }, + }; + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + + return Effect.gen(function* () { + yield* runBackgroundOperations(); + expect(getAgentAwarenessRegistrationStatus()).toBe("registered"); + + // The relay still holds the accepted registration; a transient refresh + // failure must not flip the settings toggles off. + vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockRejectedValueOnce( + new Error("transient failure"), + ); + yield* refreshAgentAwarenessRegistration(); + expect(getAgentAwarenessRegistrationStatus()).toBe("registered"); + }).pipe(Effect.provide(relayTestLayer)); + }); + it.effect("does not re-register the same account when nothing has changed", () => { Constants.expoConfig!.extra = { relay: { diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 167376987e3..b99e349a363 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -199,11 +199,37 @@ export function setAgentAwarenessRelayTokenProvider( "active live activity registration after cloud sign-in failed", ); if (isExistingIdentity) { + // Same account re-activating (e.g. Clerk token refresh) normally needs no + // re-registration — but if the previous attempt never succeeded, this is + // the only trigger that will retry it before the next cold start. + if (registrationStatus !== "registered") { + enqueueDeviceRegistration({}, "device registration retry after cloud session refresh failed"); + } return; } enqueueDeviceRegistration({}, "device registration after cloud sign-in failed"); } +// Detach the provider and native listeners without the destructive sign-out +// cleanup. For provider teardown while the user is still signed in (e.g. the +// auth bridge unmounting/remounting), ending lock-screen activities and wiping +// the persisted registration would be wrong — the relay still holds a valid +// registration and the next mount reuses it. +export function releaseAgentAwarenessRelayTokenProvider(): void { + relayTokenProvider = null; + relayTokenProviderIdentity = null; + pushToStartSubscription?.remove(); + pushToStartSubscription = null; + pushTokenSubscription?.remove(); + pushTokenSubscription = null; + appStateSubscription?.remove(); + appStateSubscription = null; + if (activeLiveActivityRegistrationRetry) { + clearTimeout(activeLiveActivityRegistrationRetry); + activeLiveActivityRegistrationRetry = null; + } +} + function iosMajorVersion(): number { const version = Platform.Version; if (typeof version === "number") { @@ -303,7 +329,13 @@ function registerDeviceWithRelay( }); return; } - if (!readRelayConfig()) return; + const relayConfig = readRelayConfig(); + if (!relayConfig) { + // Nothing is in flight and nothing can succeed until configuration + // appears; "pending" would otherwise stick forever. + setRegistrationStatus("unknown"); + return; + } const token = yield* relayToken("read-device-registration-relay-token"); if (expectedGeneration !== deviceRegistrationGeneration) { logRegistrationDebug("device registration cancelled after auth lookup", { @@ -314,6 +346,7 @@ function registerDeviceWithRelay( } if (!token) { logRegistrationDebug("relay device registration skipped; user is not signed in"); + setRegistrationStatus("unknown"); return; } @@ -326,6 +359,15 @@ function registerDeviceWithRelay( try: () => loadAgentAwarenessRegistrationRecord(), catch: (cause) => cause, }).pipe(Effect.orElseSucceed(() => null)); + if (expectedGeneration !== deviceRegistrationGeneration) { + // Signed out while the record loaded — do not resurrect the cleared + // record or report the previous account's registration as current. + logRegistrationDebug("device registration cancelled after record lookup", { + expectedGeneration, + currentGeneration: deviceRegistrationGeneration, + }); + return; + } // The push-to-start token only rides along on registrations triggered by a // native token event; ones triggered by sign-in or app foreground omit it. // Carry the last accepted token forward so its absence means "unchanged", @@ -335,7 +377,9 @@ function registerDeviceWithRelay( !body.pushToStartToken && persisted?.identity === identity && persisted.pushToStartToken ? { ...body, pushToStartToken: persisted.pushToStartToken } : body; - const signature = registrationSignature(payload); + // The relay URL participates so pointing the app at a different relay + // invalidates the record and re-registers there. + const signature = `${relayConfig.url}|${registrationSignature(payload)}`; if (persisted && persisted.identity === identity && persisted.signature === signature) { setRegistrationStatus("registered"); logRegistrationDebug("relay device registration skipped; already registered for account", { @@ -352,6 +396,16 @@ function registerDeviceWithRelay( clerkToken: token, payload, }); + if (expectedGeneration !== deviceRegistrationGeneration) { + // Signed out while the request was in flight: the sign-out path already + // reset the status and cleared the record for the next account, so a + // stale success must not overwrite either. + logRegistrationDebug("device registration completed after sign-out; result discarded", { + expectedGeneration, + currentGeneration: deviceRegistrationGeneration, + }); + return; + } setRegistrationStatus("registered"); yield* Effect.promise(() => saveAgentAwarenessRegistrationRecord({ @@ -495,7 +549,13 @@ function startPendingDeviceRegistration(): void { runtime.runPromiseExit(registerDevice(next.input, generation)), ); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - setRegistrationStatus("failed"); + // A transient failure on a later refresh (e.g. token rotation) leaves + // the prior accepted registration intact on the relay, so an already + // registered device stays "registered" rather than flipping the + // settings toggles off. + if (registrationStatus !== "registered") { + setRegistrationStatus("failed"); + } logRegistrationError(next.context, squashAtomCommandFailure(result)); } logRegistrationDebug("device registration finished", { generation }); @@ -705,7 +765,11 @@ export function refreshAgentAwarenessRegistration(): Effect.Effect< return registerDeviceForCurrentUser().pipe( Effect.catch((error) => Effect.sync(() => { - setRegistrationStatus("failed"); + // Same rationale as the queued path: a failed refresh does not undo an + // already accepted registration. + if (registrationStatus !== "registered") { + setRegistrationStatus("failed"); + } logRegistrationError("device registration refresh failed", error); }), ), diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx index c89aeb9249a..4180ae15c5e 100644 --- a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx +++ b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx @@ -14,6 +14,7 @@ import { runtime } from "../../lib/runtime"; import { appAtomRegistry } from "../../state/atom-registry"; import { useAtomCommand } from "../../state/use-atom-command"; import { + releaseAgentAwarenessRelayTokenProvider, setAgentAwarenessRelayTokenProvider, unregisterAgentAwarenessDeviceForCurrentUser, } from "../agent-awareness/remoteRegistration"; @@ -143,7 +144,11 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { useEffect( () => () => { previousTokenProviderRef.current = null; - deactivateCloudRelayAccount(); + // Unmounting is not a sign-out: the user is usually still signed in, so + // detach the provider without ending lock-screen activities or wiping the + // persisted registration (a remount reuses both). + releaseAgentAwarenessRelayTokenProvider(); + setManagedRelaySession(appAtomRegistry, null); }, [], ); diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 6a5c592afa3..d09d0814222 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -418,6 +418,12 @@ const connectLinkCommand = Command.make("link", { true, flags.publishOnly ? "publish_only" : "managed", ); + if (flags.publishOnly) { + // A publish-only link exists solely to publish; without the publish + // flag the link would be inert and the success message a lie. + const secrets = yield* ServerSecretStore.ServerSecretStore; + yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, stringToBytes("true")); + } yield* Console.log( flags.publishOnly ? "This environment will publish agent activity to your mobile clients the next time T3 starts (no managed tunnel)." @@ -494,6 +500,15 @@ const connectPublishCommand = Command.make("publish", { stringToBytes(enabled ? "true" : "false"), ); if (!enabled) { + // If enabling scheduled a publish-only link that hasn't been + // provisioned yet, disabling must cancel it too — otherwise the next + // start still links an environment whose only purpose was publishing. + // A pending managed link is left alone; it exists for the tunnel. + const linkedNow = Option.isSome(yield* secrets.get(CLOUD_LINKED_USER_ID)); + if (!linkedNow && (yield* CliState.readCliDesiredLinkMode) === "publish_only") { + yield* CliState.setCliDesiredCloudLink(false); + yield* Console.log("Cancelled the pending publish-only T3 Connect link."); + } yield* Console.log("Publishing agent activity disabled."); return; } From 4ed4a4c98a496669eb6caa18129034af4e633ee6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Jul 2026 16:37:17 -0700 Subject: [PATCH 06/26] Make relay request hangs observable instead of vanishing Device registrations were timing out client-side at exactly 10s with no server trace: the client abort killed the worker invocation with the request span still open, so the batched spans never exported. - The relay worker now enforces a 9s request deadline (inside the tracer middleware, under the client's 10s abort). A hung request becomes a completed 504 whose exported trace contains the exact child span that stalled, annotated with relay.request.deadline_exceeded. - ManagedRelayRequestTimeoutError now carries the current trace id, so client timeout logs are searchable against server-side traces instead of dead-ending at "timed out". Co-Authored-By: Claude Fable 5 --- infra/relay/src/http/Api.test.ts | 31 +++++++++++++ infra/relay/src/http/Api.ts | 44 ++++++++++++++++++- .../src/connection/resolver.test.ts | 1 + .../src/relay/discovery.test.ts | 1 + .../client-runtime/src/relay/managedRelay.ts | 21 ++++++--- 5 files changed, 92 insertions(+), 6 deletions(-) diff --git a/infra/relay/src/http/Api.test.ts b/infra/relay/src/http/Api.test.ts index 158bcec9803..b43a2837590 100644 --- a/infra/relay/src/http/Api.test.ts +++ b/infra/relay/src/http/Api.test.ts @@ -2,11 +2,14 @@ import { createClerkClient, verifyToken } from "@clerk/backend"; import { describe, expect, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Predicate from "effect/Predicate"; import * as Redacted from "effect/Redacted"; +import * as TestClock from "effect/testing/TestClock"; import * as Tracer from "effect/Tracer"; import * as HttpRouter from "effect/unstable/http/HttpRouter"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; @@ -14,6 +17,7 @@ import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; import { RelayEnvironmentAuth } from "@t3tools/contracts/relay"; import { + RELAY_REQUEST_DEADLINE_MS, relayCors, relayDocsRedirectRoute, relayEnvironmentAuthLayer, @@ -203,6 +207,33 @@ describe("relay request tracing", () => { expect(Option.getOrUndefined(spans[1]!.parent)?.spanId).toBe(spans[0]?.spanId); }), ); + + it.effect("fails hung requests with a 504 before the client's 10s abort", () => + Effect.gen(function* () { + const spans: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + const request = HttpServerRequest.fromWeb( + new Request("https://relay.test/v1/mobile/devices", { method: "POST" }), + ); + + const fiber = yield* traceRelayHttpRequestWith( + Effect.never, + Layer.succeed(Tracer.Tracer, tracer), + ).pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, request), Effect.forkChild); + yield* TestClock.adjust(Duration.millis(RELAY_REQUEST_DEADLINE_MS)); + const response = yield* Fiber.join(fiber); + + expect(response.status).toBe(504); + expect(spans[0]?.attributes.get("relay.request.deadline_exceeded")).toBe(true); + expect(spans[0]?.attributes.get("http.response.status_code")).toBe(504); + }), + ); }); describe("relay routing fallback", () => { diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index 29e2026de3c..87044efa9da 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -6,6 +6,7 @@ import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Record from "effect/Record"; import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; @@ -155,6 +156,47 @@ export const relayDocsRedirectRoute = HttpRouter.add( HttpServerResponse.redirect("/docs"), ); +// Shorter than the mobile client's 10s request timeout on purpose: when a +// request hangs (e.g. a stuck upstream query), the client would otherwise +// abort first, the invocation would die with the request span still open, and +// the batched spans would never export — leaving no server-side trace at all. +// Failing server-side first turns the hang into a completed 504 whose trace +// contains the exact child span that stalled, and the response still carries +// the traceparent back to the client. +export const RELAY_REQUEST_DEADLINE_MS = 9_000; + +const relayRequestDeadline = ( + httpEffect: Effect.Effect< + HttpServerResponse.HttpServerResponse, + E, + HttpServerRequest.HttpServerRequest | R + >, +) => + httpEffect.pipe( + Effect.timeoutOption(Duration.millis(RELAY_REQUEST_DEADLINE_MS)), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + yield* Effect.logError("relay request exceeded deadline", { + "http.method": request.method, + "http.url": request.url, + "relay.request.deadline_ms": RELAY_REQUEST_DEADLINE_MS, + }); + yield* Effect.annotateCurrentSpan({ + "relay.request.deadline_exceeded": true, + }); + return HttpServerResponse.jsonUnsafe( + { error: "relay_request_deadline_exceeded" }, + { status: 504 }, + ); + }), + onSome: Effect.succeed, + }), + ), + ); + export const traceRelayHttpRequest = ( httpEffect: Effect.Effect< HttpServerResponse.HttpServerResponse, @@ -164,7 +206,7 @@ export const traceRelayHttpRequest = ( ) => // HttpMiddleware finalizes its span on the dispatcher; do not close a request-scoped exporter first. HttpMiddleware.tracer( - appendRelayTraceContextResponseHeader.pipe(Effect.andThen(httpEffect)), + appendRelayTraceContextResponseHeader.pipe(Effect.andThen(relayRequestDeadline(httpEffect))), ).pipe(Effect.ensuring(Effect.yieldNow)); export const traceRelayHttpRequestWith = ( diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index 0469e459d16..b4d990fc844 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -449,6 +449,7 @@ describe("ConnectionResolver", () => { new ManagedRelay.ManagedRelayRequestTimeoutError({ activity: "Relay environment connection", timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS, + traceId: null, }), ), }); diff --git a/packages/client-runtime/src/relay/discovery.test.ts b/packages/client-runtime/src/relay/discovery.test.ts index 6bdc7798fb2..01886beabf6 100644 --- a/packages/client-runtime/src/relay/discovery.test.ts +++ b/packages/client-runtime/src/relay/discovery.test.ts @@ -261,6 +261,7 @@ describe("RelayEnvironmentDiscovery", () => { new ManagedRelay.ManagedRelayRequestTimeoutError({ activity: "Relay environment listing", timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS, + traceId: null, }), ), getEnvironmentStatus: () => Effect.die("unused"), diff --git a/packages/client-runtime/src/relay/managedRelay.ts b/packages/client-runtime/src/relay/managedRelay.ts index 08b720b46a3..383fcf582c8 100644 --- a/packages/client-runtime/src/relay/managedRelay.ts +++ b/packages/client-runtime/src/relay/managedRelay.ts @@ -115,6 +115,10 @@ export class ManagedRelayRequestTimeoutError extends Schema.TaggedErrorClass - Effect.fail( - new ManagedRelayRequestTimeoutError({ - activity, - timeoutMs: MANAGED_RELAY_REQUEST_TIMEOUT_MS, - }), + Effect.currentParentSpan.pipe( + Effect.map((span) => span.traceId), + Effect.orElseSucceed(() => null), + Effect.flatMap((traceId) => + Effect.fail( + new ManagedRelayRequestTimeoutError({ + activity, + timeoutMs: MANAGED_RELAY_REQUEST_TIMEOUT_MS, + traceId, + }), + ), + ), ), onSome: Effect.succeed, }), From 2c68eb155b8fac0d960205bf6eb2c18e462223dd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Jul 2026 17:36:08 -0700 Subject: [PATCH 07/26] Fix device registration hang: never hand drizzle proxy chains to Effect.all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of every mobile device registration timing out at the client's 10s abort: alchemy's Drizzle.postgres returns a lazy proxy chain that only materializes into a real Effect when consumed via yield*. Devices.register passed the two APNs token-claim UPDATEs to Effect.all as values, so the fiber runtime probed the raw Proxy for its internal effect fields, got a fresh truthy Proxy back for every read, and spun the isolate at 100% CPU (3-20s cpuTime per invocation in wrangler tail) until the client aborted. No SQL ever reached the database, no spans exported (the event loop never yielded, which also starved the 9s deadline timer), and the invocation died as "canceled" with no server-side trace. This only fired when pushToken/pushToStartToken were present — the tokenless slots were Effect.void, which is why unauthenticated probes and tokenless registrations completed in milliseconds while every real phone registration hung. Devices.unregister had the same Effect.all shape for its two deletes and would hang unconditionally. Both sites now yield* each db chain sequentially (Effect.all's default was sequential anyway, so no concurrency is lost). Verified against the dev relay with a real minted mobile:registration DPoP token: registration with tokens went from a guaranteed 20s+ hang to ~300ms. Co-Authored-By: Claude Fable 5 --- infra/relay/src/agentActivity/Devices.ts | 161 +++++++++++------------ 1 file changed, 79 insertions(+), 82 deletions(-) diff --git a/infra/relay/src/agentActivity/Devices.ts b/infra/relay/src/agentActivity/Devices.ts index f1c09fb3b28..42087128c33 100644 --- a/infra/relay/src/agentActivity/Devices.ts +++ b/infra/relay/src/agentActivity/Devices.ts @@ -81,45 +81,45 @@ export const make = Effect.gen(function* () { const updatedAt = DateTime.formatIso(yield* DateTime.now); const registration = input.registration; - yield* Effect.all( - [ - registration.pushToken - ? db - .update(relayMobileDevices) - .set({ pushToken: null, updatedAt }) - .where(eq(relayMobileDevices.pushToken, registration.pushToken)) - .pipe( - Effect.mapError( - (cause) => - new DeviceRegistrationPersistenceError({ - userId: input.userId, - deviceId: registration.deviceId, - stage: "claim-push-token", - cause, - }), - ), - ) - : Effect.void, - registration.pushToStartToken - ? db - .update(relayMobileDevices) - .set({ pushToStartToken: null, updatedAt }) - .where(eq(relayMobileDevices.pushToStartToken, registration.pushToStartToken)) - .pipe( - Effect.mapError( - (cause) => - new DeviceRegistrationPersistenceError({ - userId: input.userId, - deviceId: registration.deviceId, - stage: "claim-push-to-start-token", - cause, - }), - ), - ) - : Effect.void, - ], - { discard: true }, - ); + // The drizzle handle is alchemy's lazy proxy chain: it only becomes a + // real Effect when consumed via `yield*`. Handing it to Effect.all sends + // the raw Proxy into the fiber runtime, which spins the isolate at 100% + // CPU (registrations then hang until the client aborts) — keep every db + // chain directly yielded. + if (registration.pushToken) { + yield* db + .update(relayMobileDevices) + .set({ pushToken: null, updatedAt }) + .where(eq(relayMobileDevices.pushToken, registration.pushToken)) + .pipe( + Effect.mapError( + (cause) => + new DeviceRegistrationPersistenceError({ + userId: input.userId, + deviceId: registration.deviceId, + stage: "claim-push-token", + cause, + }), + ), + ); + } + if (registration.pushToStartToken) { + yield* db + .update(relayMobileDevices) + .set({ pushToStartToken: null, updatedAt }) + .where(eq(relayMobileDevices.pushToStartToken, registration.pushToStartToken)) + .pipe( + Effect.mapError( + (cause) => + new DeviceRegistrationPersistenceError({ + userId: input.userId, + deviceId: registration.deviceId, + stage: "claim-push-to-start-token", + cause, + }), + ), + ); + } yield* db .insert(relayMobileDevices) @@ -177,49 +177,46 @@ export const make = Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ "relay.mobile.device_id": input.deviceId, }); - yield* Effect.all( - [ - db - .delete(relayLiveActivities) - .where( - and( - eq(relayLiveActivities.userId, input.userId), - eq(relayLiveActivities.deviceId, input.deviceId), - ), - ) - .pipe( - Effect.mapError( - (cause) => - new DeviceUnregistrationPersistenceError({ - userId: input.userId, - deviceId: input.deviceId, - stage: "delete-live-activity", - cause, - }), - ), - ), - db - .delete(relayMobileDevices) - .where( - and( - eq(relayMobileDevices.userId, input.userId), - eq(relayMobileDevices.deviceId, input.deviceId), - ), - ) - .pipe( - Effect.mapError( - (cause) => - new DeviceUnregistrationPersistenceError({ - userId: input.userId, - deviceId: input.deviceId, - stage: "delete-device", - cause, - }), - ), - ), - ], - { discard: true }, - ); + // Same proxy-chain constraint as register above: db chains must be + // consumed via `yield*`, never passed to Effect.all. + yield* db + .delete(relayLiveActivities) + .where( + and( + eq(relayLiveActivities.userId, input.userId), + eq(relayLiveActivities.deviceId, input.deviceId), + ), + ) + .pipe( + Effect.mapError( + (cause) => + new DeviceUnregistrationPersistenceError({ + userId: input.userId, + deviceId: input.deviceId, + stage: "delete-live-activity", + cause, + }), + ), + ); + yield* db + .delete(relayMobileDevices) + .where( + and( + eq(relayMobileDevices.userId, input.userId), + eq(relayMobileDevices.deviceId, input.deviceId), + ), + ) + .pipe( + Effect.mapError( + (cause) => + new DeviceUnregistrationPersistenceError({ + userId: input.userId, + deviceId: input.deviceId, + stage: "delete-device", + cause, + }), + ), + ); }), listForUser: Effect.fn("relay.devices.listForUser")(function* (input) { const rows = yield* db From e694f6624e3ff4ee25c04e57eefe40246de46969 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Jul 2026 18:03:25 -0700 Subject: [PATCH 08/26] Reuse the APNs provider JWT instead of re-signing per push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit APNs throttles providers that refresh their auth token more than about once per 20 minutes. The relay signed a fresh ES256 JWT for every single push, so bursty Live Activity traffic drew 429 TooManyProviderTokenUpdates and the throttled updates were silently dropped — observed live as a lock-screen activity stuck at "1 active agent" while the update carrying the 2-agent aggregate 429'd (delivery attempts 00:55:07-13Z). Cache the signed JWT per team/key at module level and reuse it for 45 minutes of its 60-minute validity, so APNs sees a byte-identical token across pushes. The cache key fingerprints the private key so rotated credentials mint fresh tokens immediately. Co-Authored-By: Claude Fable 5 --- .../src/agentActivity/ApnsClient.test.ts | 57 +++++++++++++++++++ infra/relay/src/agentActivity/ApnsClient.ts | 49 +++++++++++++++- 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/infra/relay/src/agentActivity/ApnsClient.test.ts b/infra/relay/src/agentActivity/ApnsClient.test.ts index ff34a73e3df..d7e74d84e56 100644 --- a/infra/relay/src/agentActivity/ApnsClient.test.ts +++ b/infra/relay/src/agentActivity/ApnsClient.test.ts @@ -10,6 +10,7 @@ import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import type { ApnsCredentials } from "../Config.ts"; import * as ApnsClient from "./ApnsClient.ts"; @@ -280,4 +281,60 @@ describe("ApnsClient", () => { }); }).pipe(Effect.provide(layer)); }); + + it.effect("reuses the signed provider JWT across pushes within the reuse window", () => { + ApnsClient.__resetApnsJwtCacheForTest(); + const { privateKey } = NodeCrypto.generateKeyPairSync("ec", { + namedCurve: "prime256v1", + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }); + const credentials = { + teamId: "team-jwt-cache", + keyId: "key-jwt-cache", + privateKey: Redacted.make(privateKey), + bundleId: "com.t3tools.test", + environment: "sandbox", + } satisfies ApnsCredentials; + const authorizations: Array = []; + const capturingHttpClient = HttpClient.make((request) => { + authorizations.push(request.headers.authorization ?? ""); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("", { status: 200, headers: { "apns-id": "apns-id-1" } }), + ), + ); + }); + const layer = ApnsClient.layer.pipe( + Layer.provide(Layer.succeed(HttpClient.HttpClient, capturingHttpClient)), + ); + + return Effect.gen(function* () { + const apns = yield* ApnsClient.ApnsClient; + const request = apns.makePushNotificationRequest({ + token: "push-token", + notification: { + title: "Thread", + body: "Input: Project", + environmentId: "env", + threadId: "thread", + deepLink: "/threads/env/thread", + }, + }); + const send = (issuedAtUnixSeconds: number) => + apns.sendPushNotificationRequest({ credentials, request, issuedAtUnixSeconds }); + + yield* send(1_000); + yield* send(1_000 + 44 * 60); + yield* send(1_000 + 46 * 60); + + expect(authorizations).toHaveLength(3); + // Within the 45-minute window APNs must see the byte-identical token; + // refreshing it per push trips TooManyProviderTokenUpdates. + expect(authorizations[1]).toBe(authorizations[0]); + expect(authorizations[2]).not.toBe(authorizations[0]); + ApnsClient.__resetApnsJwtCacheForTest(); + }).pipe(Effect.provide(layer)); + }); }); diff --git a/infra/relay/src/agentActivity/ApnsClient.ts b/infra/relay/src/agentActivity/ApnsClient.ts index 166dc6fc4b9..a28f0a60981 100644 --- a/infra/relay/src/agentActivity/ApnsClient.ts +++ b/infra/relay/src/agentActivity/ApnsClient.ts @@ -125,6 +125,51 @@ const encodeApnsJwtPayloadJson = Schema.encodeEffect( ), ); +// APNs requires REUSING the provider token: refreshing it more than roughly +// once per 20 minutes returns 403/429 TooManyProviderTokenUpdates and drops +// the push (observed live: bursty Live Activity updates got 429'd, leaving +// stale lock-screen state). Cache the signed JWT per signing key for most of +// its 60-minute validity; the cache is module-level so it survives across +// requests within a worker isolate. +const APNS_JWT_REUSE_SECONDS = 45 * 60; + +interface ApnsJwtCacheEntry { + readonly jwt: string; + readonly issuedAtUnixSeconds: number; +} + +const apnsJwtCache = new Map(); + +export function __resetApnsJwtCacheForTest(): void { + apnsJwtCache.clear(); +} + +const getOrMintApnsJwt = Effect.fnUntraced(function* (input: { + readonly teamId: ApnsCredentials["teamId"]; + readonly keyId: ApnsCredentials["keyId"]; + readonly privateKey: ApnsCredentials["privateKey"]; + readonly issuedAtUnixSeconds: number; +}) { + // Fingerprint the key material so rotated credentials never reuse a JWT + // signed by the previous key. + const keyFingerprint = NodeCrypto.createHash("sha256") + .update(Redacted.value(input.privateKey)) + .digest("hex") + .slice(0, 16); + const cacheKey = `${input.teamId}:${input.keyId}:${keyFingerprint}`; + const cached = apnsJwtCache.get(cacheKey); + if ( + cached && + input.issuedAtUnixSeconds - cached.issuedAtUnixSeconds < APNS_JWT_REUSE_SECONDS && + input.issuedAtUnixSeconds >= cached.issuedAtUnixSeconds + ) { + return cached.jwt; + } + const jwt = yield* makeApnsJwt(input); + apnsJwtCache.set(cacheKey, { jwt, issuedAtUnixSeconds: input.issuedAtUnixSeconds }); + return jwt; +}); + const makeApnsJwt = Effect.fn("relay.apns.make_jwt")(function* (input: { readonly teamId: ApnsCredentials["teamId"]; readonly keyId: ApnsCredentials["keyId"]; @@ -328,7 +373,7 @@ export const make = Effect.gen(function* () { "relay.apns.send_live_activity_request", )(function* (input) { yield* Effect.annotateCurrentSpan({ "relay.apns.event": input.request.event }); - const jwt = yield* makeApnsJwt({ + const jwt = yield* getOrMintApnsJwt({ ...input.credentials, issuedAtUnixSeconds: input.issuedAtUnixSeconds, }); @@ -386,7 +431,7 @@ export const make = Effect.gen(function* () { const sendPushNotificationRequest: ApnsClient["Service"]["sendPushNotificationRequest"] = Effect.fn("relay.apns.send_push_notification_request")(function* (input) { yield* Effect.annotateCurrentSpan({ "relay.apns.event": "push_notification" }); - const jwt = yield* makeApnsJwt({ + const jwt = yield* getOrMintApnsJwt({ ...input.credentials, issuedAtUnixSeconds: input.issuedAtUnixSeconds, }); From 990372afb19ab74fe93873c663f82b895c1b1851 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Jul 2026 18:13:40 -0700 Subject: [PATCH 09/26] Share the APNs provider JWT through the database, not just the isolate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Module-level caching only stabilizes the provider token within one worker isolate; Workers spin isolates up and down freely and can multiplex their egress connections, so isolates alternating their own tokens could still trip APNs' TooManyProviderTokenUpdates throttle. Move token reuse into an ApnsProviderTokens service backed by a relay_apns_provider_tokens row per signing key (drizzle migration auto-generates on deploy). The isolate Map stays as the fast path — the hot delivery path does zero extra DB work — and on a miss the service adopts the shared row when fresh, or mints and publishes via a newest-wins upsert so concurrent refreshers converge on a single token. Database failures degrade to minting locally instead of failing the push. JWT signing moves to apnsJwt.ts (error classes re-exported from ApnsClient for existing consumers); ApnsClient tests use the in-memory layer, and the DB layer gets its own suite. Co-Authored-By: Claude Fable 5 --- .../src/agentActivity/ApnsClient.test.ts | 8 +- infra/relay/src/agentActivity/ApnsClient.ts | 163 +---------------- .../src/agentActivity/ApnsDeliveries.test.ts | 2 + .../agentActivity/ApnsProviderTokens.test.ts | 126 +++++++++++++ .../src/agentActivity/ApnsProviderTokens.ts | 169 ++++++++++++++++++ .../agentActivity/MobileRegistrations.test.ts | 7 +- infra/relay/src/agentActivity/apnsJwt.ts | 131 ++++++++++++++ infra/relay/src/persistence/schema.ts | 12 ++ infra/relay/src/worker.ts | 3 +- 9 files changed, 461 insertions(+), 160 deletions(-) create mode 100644 infra/relay/src/agentActivity/ApnsProviderTokens.test.ts create mode 100644 infra/relay/src/agentActivity/ApnsProviderTokens.ts create mode 100644 infra/relay/src/agentActivity/apnsJwt.ts diff --git a/infra/relay/src/agentActivity/ApnsClient.test.ts b/infra/relay/src/agentActivity/ApnsClient.test.ts index d7e74d84e56..f4fbd9f0b48 100644 --- a/infra/relay/src/agentActivity/ApnsClient.test.ts +++ b/infra/relay/src/agentActivity/ApnsClient.test.ts @@ -14,11 +14,13 @@ import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import type { ApnsCredentials } from "../Config.ts"; import * as ApnsClient from "./ApnsClient.ts"; +import * as ApnsProviderTokens from "./ApnsProviderTokens.ts"; const isApnsJwtSigningError = Schema.is(ApnsClient.ApnsJwtSigningError); const isApnsHttpRequestError = Schema.is(ApnsClient.ApnsHttpRequestError); const TestLayer = ApnsClient.layer.pipe( + Layer.provide(ApnsProviderTokens.layerInMemory), Layer.provide( Layer.succeed( HttpClient.HttpClient, @@ -237,6 +239,7 @@ describe("ApnsClient", () => { ), ); const layer = ApnsClient.layer.pipe( + Layer.provide(ApnsProviderTokens.layerInMemory), Layer.provide(Layer.succeed(HttpClient.HttpClient, failingHttpClient)), ); @@ -283,7 +286,7 @@ describe("ApnsClient", () => { }); it.effect("reuses the signed provider JWT across pushes within the reuse window", () => { - ApnsClient.__resetApnsJwtCacheForTest(); + ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); const { privateKey } = NodeCrypto.generateKeyPairSync("ec", { namedCurve: "prime256v1", privateKeyEncoding: { type: "pkcs8", format: "pem" }, @@ -307,6 +310,7 @@ describe("ApnsClient", () => { ); }); const layer = ApnsClient.layer.pipe( + Layer.provide(ApnsProviderTokens.layerInMemory), Layer.provide(Layer.succeed(HttpClient.HttpClient, capturingHttpClient)), ); @@ -334,7 +338,7 @@ describe("ApnsClient", () => { // refreshing it per push trips TooManyProviderTokenUpdates. expect(authorizations[1]).toBe(authorizations[0]); expect(authorizations[2]).not.toBe(authorizations[0]); - ApnsClient.__resetApnsJwtCacheForTest(); + ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); }).pipe(Effect.provide(layer)); }); }); diff --git a/infra/relay/src/agentActivity/ApnsClient.ts b/infra/relay/src/agentActivity/ApnsClient.ts index a28f0a60981..fb5fa513b94 100644 --- a/infra/relay/src/agentActivity/ApnsClient.ts +++ b/infra/relay/src/agentActivity/ApnsClient.ts @@ -1,18 +1,18 @@ -import * as NodeCrypto from "node:crypto"; - import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; -import * as Encoding from "effect/Encoding"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import * as Headers from "effect/unstable/http/Headers"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import { ApnsEnvironment as ApnsEnvironmentSchema, type ApnsCredentials } from "../Config.ts"; import type { ApnsLiveActivityAlert, ApnsNotificationPayload } from "./apnsDeliveryJobs.ts"; +import { ApnsJwtEncodingError, ApnsJwtSigningError } from "./apnsJwt.ts"; +import * as ApnsProviderTokens from "./ApnsProviderTokens.ts"; + +export { ApnsJwtEncodingError, ApnsJwtSigningError } from "./apnsJwt.ts"; const LIVE_ACTIVITY_NAME = "AgentActivity"; // Updates only flow on domain events, so a healthy agent can be silent for @@ -47,35 +47,6 @@ export interface ApnsDeliveryResult { readonly apnsId: string | null; } -export class ApnsJwtEncodingError extends Schema.TaggedErrorClass()( - "ApnsJwtEncodingError", - { - component: Schema.Literals(["header", "payload"]), - teamId: Schema.String, - keyId: Schema.String, - issuedAtUnixSeconds: Schema.Number, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to encode APNs JWT ${this.component} for key ${this.keyId}.`; - } -} - -export class ApnsJwtSigningError extends Schema.TaggedErrorClass()( - "ApnsJwtSigningError", - { - teamId: Schema.String, - keyId: Schema.String, - issuedAtUnixSeconds: Schema.Number, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to sign APNs JWT for key ${this.keyId}.`; - } -} - export class ApnsHttpRequestError extends Schema.TaggedErrorClass()( "ApnsHttpRequestError", { @@ -108,127 +79,6 @@ const decodeApnsErrorResponseJson = Schema.decodeUnknownOption( }), ), ); -const encodeApnsJwtHeaderJson = Schema.encodeEffect( - Schema.fromJsonString( - Schema.Struct({ - alg: Schema.Literal("ES256"), - kid: Schema.String, - }), - ), -); -const encodeApnsJwtPayloadJson = Schema.encodeEffect( - Schema.fromJsonString( - Schema.Struct({ - iss: Schema.String, - iat: Schema.Number, - }), - ), -); - -// APNs requires REUSING the provider token: refreshing it more than roughly -// once per 20 minutes returns 403/429 TooManyProviderTokenUpdates and drops -// the push (observed live: bursty Live Activity updates got 429'd, leaving -// stale lock-screen state). Cache the signed JWT per signing key for most of -// its 60-minute validity; the cache is module-level so it survives across -// requests within a worker isolate. -const APNS_JWT_REUSE_SECONDS = 45 * 60; - -interface ApnsJwtCacheEntry { - readonly jwt: string; - readonly issuedAtUnixSeconds: number; -} - -const apnsJwtCache = new Map(); - -export function __resetApnsJwtCacheForTest(): void { - apnsJwtCache.clear(); -} - -const getOrMintApnsJwt = Effect.fnUntraced(function* (input: { - readonly teamId: ApnsCredentials["teamId"]; - readonly keyId: ApnsCredentials["keyId"]; - readonly privateKey: ApnsCredentials["privateKey"]; - readonly issuedAtUnixSeconds: number; -}) { - // Fingerprint the key material so rotated credentials never reuse a JWT - // signed by the previous key. - const keyFingerprint = NodeCrypto.createHash("sha256") - .update(Redacted.value(input.privateKey)) - .digest("hex") - .slice(0, 16); - const cacheKey = `${input.teamId}:${input.keyId}:${keyFingerprint}`; - const cached = apnsJwtCache.get(cacheKey); - if ( - cached && - input.issuedAtUnixSeconds - cached.issuedAtUnixSeconds < APNS_JWT_REUSE_SECONDS && - input.issuedAtUnixSeconds >= cached.issuedAtUnixSeconds - ) { - return cached.jwt; - } - const jwt = yield* makeApnsJwt(input); - apnsJwtCache.set(cacheKey, { jwt, issuedAtUnixSeconds: input.issuedAtUnixSeconds }); - return jwt; -}); - -const makeApnsJwt = Effect.fn("relay.apns.make_jwt")(function* (input: { - readonly teamId: ApnsCredentials["teamId"]; - readonly keyId: ApnsCredentials["keyId"]; - readonly privateKey: ApnsCredentials["privateKey"]; - readonly issuedAtUnixSeconds: number; -}) { - const headerJson = yield* encodeApnsJwtHeaderJson({ alg: "ES256", kid: input.keyId }).pipe( - Effect.mapError( - (cause) => - new ApnsJwtEncodingError({ - component: "header", - teamId: input.teamId, - keyId: input.keyId, - issuedAtUnixSeconds: input.issuedAtUnixSeconds, - cause, - }), - ), - ); - const payloadJson = yield* encodeApnsJwtPayloadJson({ - iss: input.teamId, - iat: input.issuedAtUnixSeconds, - }).pipe( - Effect.mapError( - (cause) => - new ApnsJwtEncodingError({ - component: "payload", - teamId: input.teamId, - keyId: input.keyId, - issuedAtUnixSeconds: input.issuedAtUnixSeconds, - cause, - }), - ), - ); - - const privateKey = Redacted.value(input.privateKey); - const header = Encoding.encodeBase64Url(headerJson); - const payload = Encoding.encodeBase64Url(payloadJson); - const signingInput = `${header}.${payload}`; - - return yield* Effect.try({ - try: () => { - const signature = NodeCrypto.createSign("sha256") - .update(signingInput) - .sign({ - key: privateKey.replace(/\\n/g, "\n"), - dsaEncoding: "ieee-p1363", - }); - return `${signingInput}.${Encoding.encodeBase64Url(signature)}`; - }, - catch: (cause) => - new ApnsJwtSigningError({ - teamId: input.teamId, - keyId: input.keyId, - issuedAtUnixSeconds: input.issuedAtUnixSeconds, - cause, - }), - }); -}); - function contentState(state: RelayAgentActivityAggregateState) { return { name: LIVE_ACTIVITY_NAME, @@ -368,12 +218,13 @@ export class ApnsClient extends Context.Service< export const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; + const providerTokens = yield* ApnsProviderTokens.ApnsProviderTokens; const sendLiveActivityRequest: ApnsClient["Service"]["sendLiveActivityRequest"] = Effect.fn( "relay.apns.send_live_activity_request", )(function* (input) { yield* Effect.annotateCurrentSpan({ "relay.apns.event": input.request.event }); - const jwt = yield* getOrMintApnsJwt({ + const jwt = yield* providerTokens.getJwt({ ...input.credentials, issuedAtUnixSeconds: input.issuedAtUnixSeconds, }); @@ -431,7 +282,7 @@ export const make = Effect.gen(function* () { const sendPushNotificationRequest: ApnsClient["Service"]["sendPushNotificationRequest"] = Effect.fn("relay.apns.send_push_notification_request")(function* (input) { yield* Effect.annotateCurrentSpan({ "relay.apns.event": "push_notification" }); - const jwt = yield* getOrMintApnsJwt({ + const jwt = yield* providerTokens.getJwt({ ...input.credentials, issuedAtUnixSeconds: input.issuedAtUnixSeconds, }); diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index 2e2301870aa..6853bf603cf 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -28,6 +28,7 @@ import * as RelayConfiguration from "../Config.ts"; import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts"; import * as ApnsDeliveries from "./ApnsDeliveries.ts"; import * as ApnsClient from "./ApnsClient.ts"; +import * as ApnsProviderTokens from "./ApnsProviderTokens.ts"; const config = RelayConfiguration.RelayConfiguration.of({ relayIssuer: "https://relay.example.test", @@ -164,6 +165,7 @@ function makeLayer(input: { }) { return ApnsDeliveries.layer.pipe( Layer.provide(ApnsClient.layer), + Layer.provide(ApnsProviderTokens.layerInMemory), Layer.provide(ApnsDeliveryQueue.layer.pipe(Layer.provide(NodeCryptoLayer.layer))), Layer.provide( Layer.mergeAll( diff --git a/infra/relay/src/agentActivity/ApnsProviderTokens.test.ts b/infra/relay/src/agentActivity/ApnsProviderTokens.test.ts new file mode 100644 index 00000000000..6955b5efe8a --- /dev/null +++ b/infra/relay/src/agentActivity/ApnsProviderTokens.test.ts @@ -0,0 +1,126 @@ +import * as NodeCrypto from "node:crypto"; + +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; + +import * as RelayDb from "../db.ts"; +import * as ApnsProviderTokens from "./ApnsProviderTokens.ts"; + +const { privateKey } = NodeCrypto.generateKeyPairSync("ec", { + namedCurve: "prime256v1", + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, +}); + +const signingInput = { + teamId: "team-1", + keyId: "key-1", + privateKey: Redacted.make(privateKey), +}; + +interface StoredRow { + jwt: string; + issuedAt: number; +} + +function makeFakeDb(input: { + readonly stored: StoredRow | null; + readonly onPublish?: (values: Record) => StoredRow; + readonly failReads?: boolean; +}) { + const calls: Array = []; + const db = { + select: () => ({ + from: () => ({ + where: () => { + calls.push("select"); + return input.failReads + ? Effect.die("database unavailable") + : Effect.succeed(input.stored ? [input.stored] : []); + }, + }), + }), + insert: () => ({ + values: (values: Record) => ({ + onConflictDoUpdate: () => ({ + returning: () => { + calls.push("insert"); + if (input.failReads) { + return Effect.die("database unavailable"); + } + const row = input.onPublish + ? input.onPublish(values) + : { jwt: values.jwt as string, issuedAt: values.issuedAt as number }; + return Effect.succeed([row]); + }, + }), + }), + }), + }; + return { db: db as unknown as RelayDb.RelayDb["Service"], calls }; +} + +function testLayer(db: RelayDb.RelayDb["Service"]) { + return ApnsProviderTokens.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, db))); +} + +describe("ApnsProviderTokens", () => { + it.effect("adopts a fresh shared token from the database without signing", () => { + ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); + const { db, calls } = makeFakeDb({ stored: { jwt: "shared-jwt", issuedAt: 1_000 } }); + return Effect.gen(function* () { + const tokens = yield* ApnsProviderTokens.ApnsProviderTokens; + const jwt = yield* tokens.getJwt({ ...signingInput, issuedAtUnixSeconds: 1_100 }); + expect(jwt).toBe("shared-jwt"); + expect(calls).toEqual(["select"]); + + // Second call is served from the isolate cache without touching the db. + const again = yield* tokens.getJwt({ ...signingInput, issuedAtUnixSeconds: 1_200 }); + expect(again).toBe("shared-jwt"); + expect(calls).toEqual(["select"]); + ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); + }).pipe(Effect.provide(testLayer(db))); + }); + + it.effect("mints and publishes when the shared token is stale", () => { + ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); + const staleIssuedAt = 1_000; + const now = staleIssuedAt + ApnsProviderTokens.APNS_JWT_REUSE_SECONDS + 1; + const { db, calls } = makeFakeDb({ stored: { jwt: "stale-jwt", issuedAt: staleIssuedAt } }); + return Effect.gen(function* () { + const tokens = yield* ApnsProviderTokens.ApnsProviderTokens; + const jwt = yield* tokens.getJwt({ ...signingInput, issuedAtUnixSeconds: now }); + expect(jwt).not.toBe("stale-jwt"); + expect(jwt.split(".")).toHaveLength(3); + expect(calls).toEqual(["select", "insert"]); + ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); + }).pipe(Effect.provide(testLayer(db))); + }); + + it.effect("adopts a concurrent refresher's newer token from the upsert result", () => { + ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); + const { db } = makeFakeDb({ + stored: null, + onPublish: () => ({ jwt: "winner-jwt", issuedAt: 2_000 }), + }); + return Effect.gen(function* () { + const tokens = yield* ApnsProviderTokens.ApnsProviderTokens; + const jwt = yield* tokens.getJwt({ ...signingInput, issuedAtUnixSeconds: 2_000 }); + expect(jwt).toBe("winner-jwt"); + ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); + }).pipe(Effect.provide(testLayer(db))); + }); + + it.effect("falls back to a locally minted token when the database is unavailable", () => { + ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); + const { db } = makeFakeDb({ stored: null, failReads: true }); + return Effect.gen(function* () { + const tokens = yield* ApnsProviderTokens.ApnsProviderTokens; + const jwt = yield* tokens.getJwt({ ...signingInput, issuedAtUnixSeconds: 3_000 }); + expect(jwt.split(".")).toHaveLength(3); + ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); + }).pipe(Effect.provide(testLayer(db))); + }); +}); diff --git a/infra/relay/src/agentActivity/ApnsProviderTokens.ts b/infra/relay/src/agentActivity/ApnsProviderTokens.ts new file mode 100644 index 00000000000..62e0e53310a --- /dev/null +++ b/infra/relay/src/agentActivity/ApnsProviderTokens.ts @@ -0,0 +1,169 @@ +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 { eq, sql } from "drizzle-orm"; + +import * as RelayDb from "../db.ts"; +import { relayApnsProviderTokens } from "../persistence/schema.ts"; +import { + apnsProviderTokenCacheKey, + makeApnsJwt, + type ApnsJwtError, + type ApnsJwtSigningInput, +} from "./apnsJwt.ts"; + +// APNs requires REUSING the provider token: refreshing it more than roughly +// once per 20 minutes returns 429 TooManyProviderTokenUpdates and drops the +// push (observed live: bursty Live Activity updates got 429'd, leaving stale +// lock-screen state). Reuse each signed JWT for most of its 60-minute +// validity. +export const APNS_JWT_REUSE_SECONDS = 45 * 60; + +export class ApnsProviderTokens extends Context.Service< + ApnsProviderTokens, + { + readonly getJwt: (input: ApnsJwtSigningInput) => Effect.Effect; + } +>()("t3code-relay/agentActivity/ApnsProviderTokens") {} + +interface CachedProviderToken { + readonly jwt: string; + readonly issuedAtUnixSeconds: number; +} + +// Per-isolate fast path in front of the shared database row. Worker isolates +// come and go, so this alone cannot keep the token stable fleet-wide — the +// database row is the source of truth all isolates converge on. +const isolateTokenCache = new Map(); + +export function __resetApnsProviderTokenCacheForTest(): void { + isolateTokenCache.clear(); +} + +function isReusable(cached: CachedProviderToken, nowUnixSeconds: number): boolean { + return ( + nowUnixSeconds >= cached.issuedAtUnixSeconds && + nowUnixSeconds - cached.issuedAtUnixSeconds < APNS_JWT_REUSE_SECONDS + ); +} + +const makeInMemory = Effect.sync(() => + ApnsProviderTokens.of({ + getJwt: Effect.fnUntraced(function* (input) { + const cacheKey = apnsProviderTokenCacheKey(input); + const cached = isolateTokenCache.get(cacheKey); + if (cached && isReusable(cached, input.issuedAtUnixSeconds)) { + return cached.jwt; + } + const jwt = yield* makeApnsJwt(input); + isolateTokenCache.set(cacheKey, { jwt, issuedAtUnixSeconds: input.issuedAtUnixSeconds }); + return jwt; + }), + }), +); + +const makeDatabase = Effect.gen(function* () { + const db = yield* RelayDb.RelayDb; + + const readSharedToken = Effect.fnUntraced(function* (cacheKey: string) { + return yield* db + .select({ + jwt: relayApnsProviderTokens.jwt, + issuedAt: relayApnsProviderTokens.issuedAt, + }) + .from(relayApnsProviderTokens) + .where(eq(relayApnsProviderTokens.cacheKey, cacheKey)) + .pipe( + Effect.map((rows) => rows[0] ?? null), + Effect.catchCause((cause) => + Effect.logWarning("APNs provider token read failed; minting locally", { cause }).pipe( + Effect.as(null), + ), + ), + ); + }); + + // Newest-wins upsert: when two isolates refresh concurrently the row keeps + // the most recently issued token and both callers adopt whatever won, so + // APNs sees a single stable token instead of isolates alternating theirs. + const publishSharedToken = Effect.fnUntraced(function* ( + cacheKey: string, + minted: CachedProviderToken, + ) { + const updatedAt = DateTime.formatIso(yield* DateTime.now); + return yield* db + .insert(relayApnsProviderTokens) + .values({ + cacheKey, + jwt: minted.jwt, + issuedAt: minted.issuedAtUnixSeconds, + updatedAt, + }) + .onConflictDoUpdate({ + target: relayApnsProviderTokens.cacheKey, + set: { + jwt: sql`CASE + WHEN ${relayApnsProviderTokens.issuedAt} < excluded.issued_at THEN excluded.jwt + ELSE ${relayApnsProviderTokens.jwt} + END`, + issuedAt: sql`GREATEST(${relayApnsProviderTokens.issuedAt}, excluded.issued_at)`, + updatedAt, + }, + }) + .returning({ + jwt: relayApnsProviderTokens.jwt, + issuedAt: relayApnsProviderTokens.issuedAt, + }) + .pipe( + Effect.map((rows) => rows[0] ?? null), + Effect.catchCause((cause) => + Effect.logWarning("APNs provider token publish failed; using local token", { + cause, + }).pipe(Effect.as(null)), + ), + ); + }); + + return ApnsProviderTokens.of({ + getJwt: Effect.fn("relay.apns.get_provider_jwt")(function* (input) { + const cacheKey = apnsProviderTokenCacheKey(input); + const cached = isolateTokenCache.get(cacheKey); + if (cached && isReusable(cached, input.issuedAtUnixSeconds)) { + return cached.jwt; + } + + const stored = yield* readSharedToken(cacheKey); + if (stored) { + const sharedToken = { jwt: stored.jwt, issuedAtUnixSeconds: stored.issuedAt }; + if (isReusable(sharedToken, input.issuedAtUnixSeconds)) { + yield* Effect.annotateCurrentSpan({ "relay.apns.provider_token": "shared" }); + isolateTokenCache.set(cacheKey, sharedToken); + return sharedToken.jwt; + } + } + + const jwt = yield* makeApnsJwt(input); + const minted = { jwt, issuedAtUnixSeconds: input.issuedAtUnixSeconds }; + const published = yield* publishSharedToken(cacheKey, minted); + const winner = + published && + isReusable( + { jwt: published.jwt, issuedAtUnixSeconds: published.issuedAt }, + input.issuedAtUnixSeconds, + ) + ? { jwt: published.jwt, issuedAtUnixSeconds: published.issuedAt } + : minted; + yield* Effect.annotateCurrentSpan({ + "relay.apns.provider_token": winner === minted ? "minted" : "adopted", + }); + isolateTokenCache.set(cacheKey, winner); + return winner.jwt; + }), + }); +}); + +/** In-memory-only variant for tests and non-worker harnesses. */ +export const layerInMemory = Layer.effect(ApnsProviderTokens, makeInMemory); + +export const layer = Layer.effect(ApnsProviderTokens, makeDatabase); diff --git a/infra/relay/src/agentActivity/MobileRegistrations.test.ts b/infra/relay/src/agentActivity/MobileRegistrations.test.ts index 2e2a4f3d245..f5425e28ffe 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.test.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts @@ -20,6 +20,7 @@ import * as RelayConfiguration from "../Config.ts"; import * as AgentActivityPublisher from "./AgentActivityPublisher.ts"; import * as ApnsDeliveries from "./ApnsDeliveries.ts"; import * as ApnsClient from "./ApnsClient.ts"; +import * as ApnsProviderTokens from "./ApnsProviderTokens.ts"; import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts"; import * as MobileRegistrations from "./MobileRegistrations.ts"; @@ -147,7 +148,11 @@ function makeRegistrationReplayLayer(input: { }) { return MobileRegistrations.layer.pipe( Layer.provide(AgentActivityPublisher.layer), - Layer.provide(ApnsDeliveries.layer.pipe(Layer.provide(ApnsClient.layer))), + Layer.provide( + ApnsDeliveries.layer.pipe( + Layer.provide(ApnsClient.layer.pipe(Layer.provide(ApnsProviderTokens.layerInMemory))), + ), + ), Layer.provide(ApnsDeliveryQueue.layer.pipe(Layer.provide(NodeCryptoLayer.layer))), Layer.provide( Layer.mergeAll( diff --git a/infra/relay/src/agentActivity/apnsJwt.ts b/infra/relay/src/agentActivity/apnsJwt.ts new file mode 100644 index 00000000000..5e1552373e0 --- /dev/null +++ b/infra/relay/src/agentActivity/apnsJwt.ts @@ -0,0 +1,131 @@ +import * as NodeCrypto from "node:crypto"; + +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; + +import type { ApnsCredentials } from "../Config.ts"; + +export class ApnsJwtEncodingError extends Schema.TaggedErrorClass()( + "ApnsJwtEncodingError", + { + component: Schema.Literals(["header", "payload"]), + teamId: Schema.String, + keyId: Schema.String, + issuedAtUnixSeconds: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to encode APNs JWT ${this.component} for key ${this.keyId}.`; + } +} + +export class ApnsJwtSigningError extends Schema.TaggedErrorClass()( + "ApnsJwtSigningError", + { + teamId: Schema.String, + keyId: Schema.String, + issuedAtUnixSeconds: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to sign APNs JWT for key ${this.keyId}.`; + } +} + +export type ApnsJwtError = ApnsJwtEncodingError | ApnsJwtSigningError; + +const encodeApnsJwtHeaderJson = Schema.encodeEffect( + Schema.fromJsonString( + Schema.Struct({ + alg: Schema.Literal("ES256"), + kid: Schema.String, + }), + ), +); +const encodeApnsJwtPayloadJson = Schema.encodeEffect( + Schema.fromJsonString( + Schema.Struct({ + iss: Schema.String, + iat: Schema.Number, + }), + ), +); + +export interface ApnsJwtSigningInput { + readonly teamId: ApnsCredentials["teamId"]; + readonly keyId: ApnsCredentials["keyId"]; + readonly privateKey: ApnsCredentials["privateKey"]; + readonly issuedAtUnixSeconds: number; +} + +export const makeApnsJwt = Effect.fn("relay.apns.make_jwt")(function* (input: ApnsJwtSigningInput) { + const headerJson = yield* encodeApnsJwtHeaderJson({ alg: "ES256", kid: input.keyId }).pipe( + Effect.mapError( + (cause) => + new ApnsJwtEncodingError({ + component: "header", + teamId: input.teamId, + keyId: input.keyId, + issuedAtUnixSeconds: input.issuedAtUnixSeconds, + cause, + }), + ), + ); + const payloadJson = yield* encodeApnsJwtPayloadJson({ + iss: input.teamId, + iat: input.issuedAtUnixSeconds, + }).pipe( + Effect.mapError( + (cause) => + new ApnsJwtEncodingError({ + component: "payload", + teamId: input.teamId, + keyId: input.keyId, + issuedAtUnixSeconds: input.issuedAtUnixSeconds, + cause, + }), + ), + ); + + const privateKey = Redacted.value(input.privateKey); + const header = Encoding.encodeBase64Url(headerJson); + const payload = Encoding.encodeBase64Url(payloadJson); + const signingInput = `${header}.${payload}`; + + return yield* Effect.try({ + try: () => { + const signature = NodeCrypto.createSign("sha256") + .update(signingInput) + .sign({ + key: privateKey.replace(/\\n/g, "\n"), + dsaEncoding: "ieee-p1363", + }); + return `${signingInput}.${Encoding.encodeBase64Url(signature)}`; + }, + catch: (cause) => + new ApnsJwtSigningError({ + teamId: input.teamId, + keyId: input.keyId, + issuedAtUnixSeconds: input.issuedAtUnixSeconds, + cause, + }), + }); +}); + +// Fingerprint the key material so rotated credentials never reuse a JWT +// signed by the previous key. +export function apnsProviderTokenCacheKey(input: { + readonly teamId: string; + readonly keyId: string; + readonly privateKey: ApnsCredentials["privateKey"]; +}): string { + const keyFingerprint = NodeCrypto.createHash("sha256") + .update(Redacted.value(input.privateKey)) + .digest("hex") + .slice(0, 16); + return `${input.teamId}:${input.keyId}:${keyFingerprint}`; +} diff --git a/infra/relay/src/persistence/schema.ts b/infra/relay/src/persistence/schema.ts index 0952ade1731..59d522d9046 100644 --- a/infra/relay/src/persistence/schema.ts +++ b/infra/relay/src/persistence/schema.ts @@ -170,6 +170,18 @@ export const relayDeliveryAttempts = pgTable( ], ); +// One row per APNs signing key: the shared provider JWT every worker isolate +// reuses so APNs sees a stable token (refreshing more than ~once per 20 +// minutes returns 429 TooManyProviderTokenUpdates and drops the push). The +// JWT is a short-lived (60 min) bearer credential derived from the APNs key +// the worker already holds in its environment. +export const relayApnsProviderTokens = pgTable("relay_apns_provider_tokens", { + cacheKey: varchar("cache_key", { length: 191 }).primaryKey(), + jwt: text("jwt").notNull(), + issuedAt: integer("issued_at").notNull(), + updatedAt: varchar("updated_at", { length: 64 }).notNull(), +}); + export const relayDpopProofs = pgTable( "relay_dpop_proofs", { diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index 0b4f1d1bbc0..d8598be6624 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -47,6 +47,7 @@ import { RelayApnsDeliveryDeadLetterQueue, RelayApnsDeliveryQueue } from "./queu import * as RelayConfiguration from "./Config.ts"; import * as AgentActivityPublisher from "./agentActivity/AgentActivityPublisher.ts"; import * as ApnsClient from "./agentActivity/ApnsClient.ts"; +import * as ApnsProviderTokens from "./agentActivity/ApnsProviderTokens.ts"; import * as ApnsDeliveryQueue from "./agentActivity/ApnsDeliveryQueue.ts"; import * as ApnsDeliveries from "./agentActivity/ApnsDeliveries.ts"; import * as EnvironmentConnector from "./environments/EnvironmentConnector.ts"; @@ -196,7 +197,7 @@ export default class Api extends Cloudflare.Worker()( ), Layer.provideMerge(DpopProofs.layer), Layer.provideMerge(ApnsDeliveries.layer), - Layer.provideMerge(ApnsClient.layer), + Layer.provideMerge(ApnsClient.layer.pipe(Layer.provideMerge(ApnsProviderTokens.layer))), Layer.provideMerge( ApnsDeliveryQueue.layerCloudflareQueues(apnsDeliveryQueueSender, alchemyRuntimeContext), ), From 011a09bce2a7d3d16ec42091bbf0649649d1a186 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Jul 2026 18:20:15 -0700 Subject: [PATCH 10/26] Derive the APNs provider JWT deterministically instead of storing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storing the shared provider JWT in the database left a live bearer credential sitting in plaintext next to the device push tokens it unlocks. Rather than encrypt it, stop persisting it entirely: sign with deterministic ES256 (RFC 6979 via @noble/curves — node's signer is randomized) and quantize iat to the 45-minute reuse window, so every worker isolate independently derives the byte-identical token. The fleet presents one stable token per window to APNs with no shared storage, no cold-start read, and nothing at rest; APNs' 60-minute iat limit is never exceeded and node crypto verifies noble's signatures (interop checked). Drops the relay_apns_provider_tokens table (never deployed) and the database-backed layer; the isolate map remains purely to avoid re-signing on every push. Co-Authored-By: Claude Fable 5 --- infra/relay/package.json | 2 + .../src/agentActivity/ApnsClient.test.ts | 18 ++- .../src/agentActivity/ApnsDeliveries.test.ts | 2 +- .../agentActivity/ApnsProviderTokens.test.ts | 113 ++++----------- .../src/agentActivity/ApnsProviderTokens.ts | 135 ++---------------- .../agentActivity/MobileRegistrations.test.ts | 2 +- infra/relay/src/agentActivity/apnsJwt.ts | 41 +++++- infra/relay/src/persistence/schema.ts | 12 -- pnpm-lock.yaml | 6 + 9 files changed, 102 insertions(+), 229 deletions(-) diff --git a/infra/relay/package.json b/infra/relay/package.json index eebd9f4721a..17a4d52d380 100644 --- a/infra/relay/package.json +++ b/infra/relay/package.json @@ -11,6 +11,8 @@ "dependencies": { "@clerk/backend": "catalog:", "@effect/sql-pg": "catalog:", + "@noble/curves": "catalog:", + "@noble/hashes": "catalog:", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", diff --git a/infra/relay/src/agentActivity/ApnsClient.test.ts b/infra/relay/src/agentActivity/ApnsClient.test.ts index f4fbd9f0b48..f9e0cb7aa26 100644 --- a/infra/relay/src/agentActivity/ApnsClient.test.ts +++ b/infra/relay/src/agentActivity/ApnsClient.test.ts @@ -20,7 +20,7 @@ const isApnsJwtSigningError = Schema.is(ApnsClient.ApnsJwtSigningError); const isApnsHttpRequestError = Schema.is(ApnsClient.ApnsHttpRequestError); const TestLayer = ApnsClient.layer.pipe( - Layer.provide(ApnsProviderTokens.layerInMemory), + Layer.provide(ApnsProviderTokens.layer), Layer.provide( Layer.succeed( HttpClient.HttpClient, @@ -210,7 +210,10 @@ describe("ApnsClient", () => { expect(error).toMatchObject({ teamId: "team-1", keyId: "key-1", - issuedAtUnixSeconds: 123, + // The provider-token service quantizes iat to the reuse window, so + // the signing context carries the window start rather than the raw + // request time. + issuedAtUnixSeconds: 0, cause: expect.any(Error), message: "Failed to sign APNs JWT for key key-1.", }); @@ -239,7 +242,7 @@ describe("ApnsClient", () => { ), ); const layer = ApnsClient.layer.pipe( - Layer.provide(ApnsProviderTokens.layerInMemory), + Layer.provide(ApnsProviderTokens.layer), Layer.provide(Layer.succeed(HttpClient.HttpClient, failingHttpClient)), ); @@ -310,7 +313,7 @@ describe("ApnsClient", () => { ); }); const layer = ApnsClient.layer.pipe( - Layer.provide(ApnsProviderTokens.layerInMemory), + Layer.provide(ApnsProviderTokens.layer), Layer.provide(Layer.succeed(HttpClient.HttpClient, capturingHttpClient)), ); @@ -329,9 +332,10 @@ describe("ApnsClient", () => { const send = (issuedAtUnixSeconds: number) => apns.sendPushNotificationRequest({ credentials, request, issuedAtUnixSeconds }); - yield* send(1_000); - yield* send(1_000 + 44 * 60); - yield* send(1_000 + 46 * 60); + const window = ApnsProviderTokens.APNS_JWT_REUSE_SECONDS; + yield* send(window + 10); + yield* send(window * 2 - 1); + yield* send(window * 2); expect(authorizations).toHaveLength(3); // Within the 45-minute window APNs must see the byte-identical token; diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index 6853bf603cf..f63e4e67ee0 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -165,7 +165,7 @@ function makeLayer(input: { }) { return ApnsDeliveries.layer.pipe( Layer.provide(ApnsClient.layer), - Layer.provide(ApnsProviderTokens.layerInMemory), + Layer.provide(ApnsProviderTokens.layer), Layer.provide(ApnsDeliveryQueue.layer.pipe(Layer.provide(NodeCryptoLayer.layer))), Layer.provide( Layer.mergeAll( diff --git a/infra/relay/src/agentActivity/ApnsProviderTokens.test.ts b/infra/relay/src/agentActivity/ApnsProviderTokens.test.ts index 6955b5efe8a..d98b9f920be 100644 --- a/infra/relay/src/agentActivity/ApnsProviderTokens.test.ts +++ b/infra/relay/src/agentActivity/ApnsProviderTokens.test.ts @@ -2,10 +2,9 @@ import * as NodeCrypto from "node:crypto"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; -import * as RelayDb from "../db.ts"; import * as ApnsProviderTokens from "./ApnsProviderTokens.ts"; const { privateKey } = NodeCrypto.generateKeyPairSync("ec", { @@ -20,107 +19,57 @@ const signingInput = { privateKey: Redacted.make(privateKey), }; -interface StoredRow { - jwt: string; - issuedAt: number; -} +const WINDOW = ApnsProviderTokens.APNS_JWT_REUSE_SECONDS; -function makeFakeDb(input: { - readonly stored: StoredRow | null; - readonly onPublish?: (values: Record) => StoredRow; - readonly failReads?: boolean; -}) { - const calls: Array = []; - const db = { - select: () => ({ - from: () => ({ - where: () => { - calls.push("select"); - return input.failReads - ? Effect.die("database unavailable") - : Effect.succeed(input.stored ? [input.stored] : []); - }, - }), - }), - insert: () => ({ - values: (values: Record) => ({ - onConflictDoUpdate: () => ({ - returning: () => { - calls.push("insert"); - if (input.failReads) { - return Effect.die("database unavailable"); - } - const row = input.onPublish - ? input.onPublish(values) - : { jwt: values.jwt as string, issuedAt: values.issuedAt as number }; - return Effect.succeed([row]); - }, - }), - }), - }), - }; - return { db: db as unknown as RelayDb.RelayDb["Service"], calls }; -} - -function testLayer(db: RelayDb.RelayDb["Service"]) { - return ApnsProviderTokens.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, db))); -} +const decodeJwtPayload = Schema.decodeEffect( + Schema.fromJsonString(Schema.Struct({ iat: Schema.Number })), +); describe("ApnsProviderTokens", () => { - it.effect("adopts a fresh shared token from the database without signing", () => { + it.effect("derives the byte-identical token across isolates within a window", () => { ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); - const { db, calls } = makeFakeDb({ stored: { jwt: "shared-jwt", issuedAt: 1_000 } }); return Effect.gen(function* () { const tokens = yield* ApnsProviderTokens.ApnsProviderTokens; - const jwt = yield* tokens.getJwt({ ...signingInput, issuedAtUnixSeconds: 1_100 }); - expect(jwt).toBe("shared-jwt"); - expect(calls).toEqual(["select"]); + const first = yield* tokens.getJwt({ ...signingInput, issuedAtUnixSeconds: WINDOW + 10 }); - // Second call is served from the isolate cache without touching the db. - const again = yield* tokens.getJwt({ ...signingInput, issuedAtUnixSeconds: 1_200 }); - expect(again).toBe("shared-jwt"); - expect(calls).toEqual(["select"]); + // A fresh isolate has no cache; deterministic signing plus quantized + // iat must still reproduce the exact same JWT for the same window. ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); - }).pipe(Effect.provide(testLayer(db))); - }); + const second = yield* tokens.getJwt({ + ...signingInput, + issuedAtUnixSeconds: WINDOW * 2 - 1, + }); + expect(second).toBe(first); - it.effect("mints and publishes when the shared token is stale", () => { - ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); - const staleIssuedAt = 1_000; - const now = staleIssuedAt + ApnsProviderTokens.APNS_JWT_REUSE_SECONDS + 1; - const { db, calls } = makeFakeDb({ stored: { jwt: "stale-jwt", issuedAt: staleIssuedAt } }); - return Effect.gen(function* () { - const tokens = yield* ApnsProviderTokens.ApnsProviderTokens; - const jwt = yield* tokens.getJwt({ ...signingInput, issuedAtUnixSeconds: now }); - expect(jwt).not.toBe("stale-jwt"); - expect(jwt.split(".")).toHaveLength(3); - expect(calls).toEqual(["select", "insert"]); + const payload = yield* decodeJwtPayload( + Buffer.from(first.split(".")[1]!, "base64url").toString("utf8"), + ); + expect(payload.iat).toBe(WINDOW); ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); - }).pipe(Effect.provide(testLayer(db))); + }).pipe(Effect.provide(ApnsProviderTokens.layer)); }); - it.effect("adopts a concurrent refresher's newer token from the upsert result", () => { + it.effect("rolls to a new token at the window boundary", () => { ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); - const { db } = makeFakeDb({ - stored: null, - onPublish: () => ({ jwt: "winner-jwt", issuedAt: 2_000 }), - }); return Effect.gen(function* () { const tokens = yield* ApnsProviderTokens.ApnsProviderTokens; - const jwt = yield* tokens.getJwt({ ...signingInput, issuedAtUnixSeconds: 2_000 }); - expect(jwt).toBe("winner-jwt"); + const first = yield* tokens.getJwt({ ...signingInput, issuedAtUnixSeconds: WINDOW + 10 }); + const next = yield* tokens.getJwt({ ...signingInput, issuedAtUnixSeconds: WINDOW * 2 }); + expect(next).not.toBe(first); ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); - }).pipe(Effect.provide(testLayer(db))); + }).pipe(Effect.provide(ApnsProviderTokens.layer)); }); - it.effect("falls back to a locally minted token when the database is unavailable", () => { + it.effect("serves repeat pushes from the isolate cache without re-signing", () => { ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); - const { db } = makeFakeDb({ stored: null, failReads: true }); return Effect.gen(function* () { const tokens = yield* ApnsProviderTokens.ApnsProviderTokens; - const jwt = yield* tokens.getJwt({ ...signingInput, issuedAtUnixSeconds: 3_000 }); - expect(jwt.split(".")).toHaveLength(3); + const first = yield* tokens.getJwt({ ...signingInput, issuedAtUnixSeconds: WINDOW + 10 }); + const again = yield* tokens.getJwt({ ...signingInput, issuedAtUnixSeconds: WINDOW + 500 }); + // Deterministic signing makes equality hold either way; toBe on the + // exact string documents the cache contract. + expect(again).toBe(first); ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); - }).pipe(Effect.provide(testLayer(db))); + }).pipe(Effect.provide(ApnsProviderTokens.layer)); }); }); diff --git a/infra/relay/src/agentActivity/ApnsProviderTokens.ts b/infra/relay/src/agentActivity/ApnsProviderTokens.ts index 62e0e53310a..ad42a5b1110 100644 --- a/infra/relay/src/agentActivity/ApnsProviderTokens.ts +++ b/infra/relay/src/agentActivity/ApnsProviderTokens.ts @@ -1,11 +1,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 { eq, sql } from "drizzle-orm"; -import * as RelayDb from "../db.ts"; -import { relayApnsProviderTokens } from "../persistence/schema.ts"; import { apnsProviderTokenCacheKey, makeApnsJwt, @@ -32,138 +28,37 @@ interface CachedProviderToken { readonly issuedAtUnixSeconds: number; } -// Per-isolate fast path in front of the shared database row. Worker isolates -// come and go, so this alone cannot keep the token stable fleet-wide — the -// database row is the source of truth all isolates converge on. +// Signing is deterministic (RFC 6979) and iat is quantized below, so every +// isolate independently derives the byte-identical token for a window; this +// map only avoids re-signing on every push. No shared storage is needed, and +// no provider token is ever written anywhere. const isolateTokenCache = new Map(); export function __resetApnsProviderTokenCacheForTest(): void { isolateTokenCache.clear(); } -function isReusable(cached: CachedProviderToken, nowUnixSeconds: number): boolean { - return ( - nowUnixSeconds >= cached.issuedAtUnixSeconds && - nowUnixSeconds - cached.issuedAtUnixSeconds < APNS_JWT_REUSE_SECONDS - ); +// Quantize iat to the reuse window so all isolates agree on it. The token's +// age stays under APNs' 60-minute limit, and the whole fleet rolls to the +// next token at the same instant — one provider-token update per window. +export function quantizedApnsJwtIssuedAt(nowUnixSeconds: number): number { + return Math.floor(nowUnixSeconds / APNS_JWT_REUSE_SECONDS) * APNS_JWT_REUSE_SECONDS; } -const makeInMemory = Effect.sync(() => +export const make = Effect.sync(() => ApnsProviderTokens.of({ getJwt: Effect.fnUntraced(function* (input) { + const issuedAtUnixSeconds = quantizedApnsJwtIssuedAt(input.issuedAtUnixSeconds); const cacheKey = apnsProviderTokenCacheKey(input); const cached = isolateTokenCache.get(cacheKey); - if (cached && isReusable(cached, input.issuedAtUnixSeconds)) { + if (cached && cached.issuedAtUnixSeconds === issuedAtUnixSeconds) { return cached.jwt; } - const jwt = yield* makeApnsJwt(input); - isolateTokenCache.set(cacheKey, { jwt, issuedAtUnixSeconds: input.issuedAtUnixSeconds }); + const jwt = yield* makeApnsJwt({ ...input, issuedAtUnixSeconds }); + isolateTokenCache.set(cacheKey, { jwt, issuedAtUnixSeconds }); return jwt; }), }), ); -const makeDatabase = Effect.gen(function* () { - const db = yield* RelayDb.RelayDb; - - const readSharedToken = Effect.fnUntraced(function* (cacheKey: string) { - return yield* db - .select({ - jwt: relayApnsProviderTokens.jwt, - issuedAt: relayApnsProviderTokens.issuedAt, - }) - .from(relayApnsProviderTokens) - .where(eq(relayApnsProviderTokens.cacheKey, cacheKey)) - .pipe( - Effect.map((rows) => rows[0] ?? null), - Effect.catchCause((cause) => - Effect.logWarning("APNs provider token read failed; minting locally", { cause }).pipe( - Effect.as(null), - ), - ), - ); - }); - - // Newest-wins upsert: when two isolates refresh concurrently the row keeps - // the most recently issued token and both callers adopt whatever won, so - // APNs sees a single stable token instead of isolates alternating theirs. - const publishSharedToken = Effect.fnUntraced(function* ( - cacheKey: string, - minted: CachedProviderToken, - ) { - const updatedAt = DateTime.formatIso(yield* DateTime.now); - return yield* db - .insert(relayApnsProviderTokens) - .values({ - cacheKey, - jwt: minted.jwt, - issuedAt: minted.issuedAtUnixSeconds, - updatedAt, - }) - .onConflictDoUpdate({ - target: relayApnsProviderTokens.cacheKey, - set: { - jwt: sql`CASE - WHEN ${relayApnsProviderTokens.issuedAt} < excluded.issued_at THEN excluded.jwt - ELSE ${relayApnsProviderTokens.jwt} - END`, - issuedAt: sql`GREATEST(${relayApnsProviderTokens.issuedAt}, excluded.issued_at)`, - updatedAt, - }, - }) - .returning({ - jwt: relayApnsProviderTokens.jwt, - issuedAt: relayApnsProviderTokens.issuedAt, - }) - .pipe( - Effect.map((rows) => rows[0] ?? null), - Effect.catchCause((cause) => - Effect.logWarning("APNs provider token publish failed; using local token", { - cause, - }).pipe(Effect.as(null)), - ), - ); - }); - - return ApnsProviderTokens.of({ - getJwt: Effect.fn("relay.apns.get_provider_jwt")(function* (input) { - const cacheKey = apnsProviderTokenCacheKey(input); - const cached = isolateTokenCache.get(cacheKey); - if (cached && isReusable(cached, input.issuedAtUnixSeconds)) { - return cached.jwt; - } - - const stored = yield* readSharedToken(cacheKey); - if (stored) { - const sharedToken = { jwt: stored.jwt, issuedAtUnixSeconds: stored.issuedAt }; - if (isReusable(sharedToken, input.issuedAtUnixSeconds)) { - yield* Effect.annotateCurrentSpan({ "relay.apns.provider_token": "shared" }); - isolateTokenCache.set(cacheKey, sharedToken); - return sharedToken.jwt; - } - } - - const jwt = yield* makeApnsJwt(input); - const minted = { jwt, issuedAtUnixSeconds: input.issuedAtUnixSeconds }; - const published = yield* publishSharedToken(cacheKey, minted); - const winner = - published && - isReusable( - { jwt: published.jwt, issuedAtUnixSeconds: published.issuedAt }, - input.issuedAtUnixSeconds, - ) - ? { jwt: published.jwt, issuedAtUnixSeconds: published.issuedAt } - : minted; - yield* Effect.annotateCurrentSpan({ - "relay.apns.provider_token": winner === minted ? "minted" : "adopted", - }); - isolateTokenCache.set(cacheKey, winner); - return winner.jwt; - }), - }); -}); - -/** In-memory-only variant for tests and non-worker harnesses. */ -export const layerInMemory = Layer.effect(ApnsProviderTokens, makeInMemory); - -export const layer = Layer.effect(ApnsProviderTokens, makeDatabase); +export const layer = Layer.effect(ApnsProviderTokens, make); diff --git a/infra/relay/src/agentActivity/MobileRegistrations.test.ts b/infra/relay/src/agentActivity/MobileRegistrations.test.ts index f5425e28ffe..40517241b90 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.test.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts @@ -150,7 +150,7 @@ function makeRegistrationReplayLayer(input: { Layer.provide(AgentActivityPublisher.layer), Layer.provide( ApnsDeliveries.layer.pipe( - Layer.provide(ApnsClient.layer.pipe(Layer.provide(ApnsProviderTokens.layerInMemory))), + Layer.provide(ApnsClient.layer.pipe(Layer.provide(ApnsProviderTokens.layer))), ), ), Layer.provide(ApnsDeliveryQueue.layer.pipe(Layer.provide(NodeCryptoLayer.layer))), diff --git a/infra/relay/src/agentActivity/apnsJwt.ts b/infra/relay/src/agentActivity/apnsJwt.ts index 5e1552373e0..74c3d15ce7d 100644 --- a/infra/relay/src/agentActivity/apnsJwt.ts +++ b/infra/relay/src/agentActivity/apnsJwt.ts @@ -1,8 +1,11 @@ import * as NodeCrypto from "node:crypto"; +import { p256 } from "@noble/curves/nist"; +import { sha256 } from "@noble/hashes/sha2"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as Redacted from "effect/Redacted"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import type { ApnsCredentials } from "../Config.ts"; @@ -98,12 +101,15 @@ export const makeApnsJwt = Effect.fn("relay.apns.make_jwt")(function* (input: Ap return yield* Effect.try({ try: () => { - const signature = NodeCrypto.createSign("sha256") - .update(signingInput) - .sign({ - key: privateKey.replace(/\\n/g, "\n"), - dsaEncoding: "ieee-p1363", - }); + // Deterministic ES256 (RFC 6979 via noble) instead of node's randomized + // signer: identical (key, iat) yields the byte-identical JWT on every + // worker isolate, so the fleet presents one stable provider token to + // APNs without any shared storage. Node crypto only converts the PEM to + // the raw scalar noble signs with. + const scalar = apnsSigningScalar(privateKey); + const signature = p256 + .sign(sha256(new TextEncoder().encode(signingInput)), scalar, { prehash: false }) + .toCompactRawBytes(); return `${signingInput}.${Encoding.encodeBase64Url(signature)}`; }, catch: (cause) => @@ -116,6 +122,29 @@ export const makeApnsJwt = Effect.fn("relay.apns.make_jwt")(function* (input: Ap }); }); +// PEM parsing is pure and the key set is static per deployment; memoize the +// extracted P-256 scalar so signing never re-parses the PKCS8 document. +const signingScalarCache = new Map(); + +function apnsSigningScalar(privateKeyPem: string): Uint8Array { + const cached = signingScalarCache.get(privateKeyPem); + if (cached) { + return cached; + } + const jwk = NodeCrypto.createPrivateKey(privateKeyPem.replace(/\\n/g, "\n")).export({ + format: "jwk", + }); + if (jwk.crv !== "P-256" || typeof jwk.d !== "string") { + throw new Error("APNs signing key is not a P-256 private key."); + } + const scalar = Result.getOrThrowWith( + Encoding.decodeBase64Url(jwk.d), + () => new Error("APNs signing key scalar is not valid base64url."), + ); + signingScalarCache.set(privateKeyPem, scalar); + return scalar; +} + // Fingerprint the key material so rotated credentials never reuse a JWT // signed by the previous key. export function apnsProviderTokenCacheKey(input: { diff --git a/infra/relay/src/persistence/schema.ts b/infra/relay/src/persistence/schema.ts index 59d522d9046..0952ade1731 100644 --- a/infra/relay/src/persistence/schema.ts +++ b/infra/relay/src/persistence/schema.ts @@ -170,18 +170,6 @@ export const relayDeliveryAttempts = pgTable( ], ); -// One row per APNs signing key: the shared provider JWT every worker isolate -// reuses so APNs sees a stable token (refreshing more than ~once per 20 -// minutes returns 429 TooManyProviderTokenUpdates and drops the push). The -// JWT is a short-lived (60 min) bearer credential derived from the APNs key -// the worker already holds in its environment. -export const relayApnsProviderTokens = pgTable("relay_apns_provider_tokens", { - cacheKey: varchar("cache_key", { length: 191 }).primaryKey(), - jwt: text("jwt").notNull(), - issuedAt: integer("issued_at").notNull(), - updatedAt: varchar("updated_at", { length: 64 }).notNull(), -}); - export const relayDpopProofs = pgTable( "relay_dpop_proofs", { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 636e6950fe5..272aa71c42c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -671,6 +671,12 @@ importers: '@effect/sql-pg': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + '@noble/curves': + specifier: 'catalog:' + version: 1.9.1 + '@noble/hashes': + specifier: 'catalog:' + version: 1.8.0 '@t3tools/client-runtime': specifier: workspace:* version: link:../../packages/client-runtime From 95e4ac2678231fd291f1f73600cdfef2bd4df6c3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Jul 2026 18:48:39 -0700 Subject: [PATCH 11/26] Keep finished agents visible in the Live Activity and buzz on completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an agent finished while others were still running, its row simply vanished from the next Live Activity update: terminal publishes deleted the thread row, the aggregate filtered terminal phases out of the activity list, and update-event alerts only covered attention transitions — so mid-flight completions produced neither a Done row nor a haptic (the end-event alert path only fires when the last agent finishes). - Terminal publishes now upsert the row; the aggregate appends recently finished threads (5-minute display window) after the active ones, with activeCount still counting only live work and active agents keeping display-slot priority. - Updates alert on newly terminal threads, gated by notifyOnCompletion / notifyOnFailure — same transition rules as attention alerts (requires a previously delivered non-terminal baseline, so replays never ring). - Lingering terminal rows never resurrect an activity on their own: the end/replay semantics when nothing is active are unchanged, and the 5-minute cron sweeps terminal rows once they age out. Co-Authored-By: Claude Fable 5 --- .../AgentActivityPublisher.test.ts | 103 +++++++++++++++++- .../agentActivity/AgentActivityPublisher.ts | 39 ++++++- .../src/agentActivity/AgentActivityRows.ts | 40 ++++++- .../src/agentActivity/ApnsDeliveries.test.ts | 51 +++++++++ .../relay/src/agentActivity/ApnsDeliveries.ts | 57 +++++++++- .../agentActivity/MobileRegistrations.test.ts | 1 + infra/relay/src/worker.ts | 14 ++- 7 files changed, 289 insertions(+), 16 deletions(-) diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts index e267d51275f..69304e0dd1c 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts @@ -62,6 +62,7 @@ function makeAgentActivityRows( return { upsert: () => Effect.void, remove: () => Effect.void, + pruneTerminal: () => Effect.void, listForUser: () => Effect.succeed([state]), ...overrides, }; @@ -308,7 +309,7 @@ describe("AgentActivityPublisher", () => { const sentAggregates: Array< Parameters[0] > = []; - const removals: Array[0]> = + const upserts: Array[0]> = []; return Effect.gen(function* () { @@ -328,9 +329,9 @@ describe("AgentActivityPublisher", () => { Layer.succeed( AgentActivityRows.AgentActivityRows, makeAgentActivityRows({ - remove: (input) => + upsert: (input) => Effect.sync(() => { - removals.push(input); + upserts.push(input); }), listForUser: () => Effect.succeed([]), }), @@ -380,11 +381,12 @@ describe("AgentActivityPublisher", () => { ok: true, }, ]); - expect(removals).toEqual([ + // Terminal states are persisted (and later pruned by the cron) so the + // finished thread can keep a Done row in later aggregates. + expect(upserts).toEqual([ { - environmentId: "env", environmentPublicKey: "environment-public-key", - threadId: "thread", + state: completedState, }, ]); expect(sentAggregates).toHaveLength(1); @@ -696,4 +698,93 @@ describe("makeAggregateState", () => { expect(aggregate?.activeCount).toBe(0); expect(aggregate?.activities).toMatchObject([{ phase: "completed" }]); }); + + it("keeps a recently finished thread visible as Done beside active agents", () => { + const active: RelayAgentActivityState = { + ...state, + threadId: "thread-active" as RelayAgentActivityState["threadId"], + updatedAt: "1970-01-01T00:58:00.000Z", + }; + const justCompleted: RelayAgentActivityState = { + ...state, + threadId: "thread-done" as RelayAgentActivityState["threadId"], + phase: "completed", + updatedAt: "1970-01-01T00:59:00.000Z", + }; + const aggregate = AgentActivityPublisher.makeAggregateState({ + activeStates: [active, justCompleted], + terminalState: null, + nowMs: hourMs, + }); + + expect(aggregate?.activeCount).toBe(1); + expect(aggregate?.subtitle).toBe("Agent work in progress"); + expect(aggregate?.activities).toMatchObject([ + { threadId: "thread-active", phase: "running" }, + { threadId: "thread-done", phase: "completed", status: "Done" }, + ]); + expect(aggregate?.updatedAt).toBe("1970-01-01T00:59:00.000Z"); + }); + + it("drops finished threads from the aggregate after the display window", () => { + const active: RelayAgentActivityState = { + ...state, + threadId: "thread-active" as RelayAgentActivityState["threadId"], + updatedAt: "1970-01-01T00:58:00.000Z", + }; + const staleCompleted: RelayAgentActivityState = { + ...state, + threadId: "thread-done" as RelayAgentActivityState["threadId"], + phase: "completed", + updatedAt: "1970-01-01T00:50:00.000Z", + }; + const aggregate = AgentActivityPublisher.makeAggregateState({ + activeStates: [active, staleCompleted], + terminalState: null, + nowMs: hourMs, + }); + + expect(aggregate?.activities).toMatchObject([{ threadId: "thread-active" }]); + }); + + it("never resurrects lingering terminal rows once nothing is active", () => { + const lingeringCompleted: RelayAgentActivityState = { + ...state, + phase: "completed", + updatedAt: "1970-01-01T00:59:00.000Z", + }; + expect( + AgentActivityPublisher.makeAggregateState({ + activeStates: [lingeringCompleted], + terminalState: null, + nowMs: hourMs, + }), + ).toBeNull(); + }); + + it("gives active agents the display slots before finished ones", () => { + const mkActive = (id: string): RelayAgentActivityState => ({ + ...state, + threadId: id as RelayAgentActivityState["threadId"], + updatedAt: "1970-01-01T00:58:00.000Z", + }); + const justCompleted: RelayAgentActivityState = { + ...state, + threadId: "thread-done" as RelayAgentActivityState["threadId"], + phase: "completed", + updatedAt: "1970-01-01T00:59:00.000Z", + }; + const aggregate = AgentActivityPublisher.makeAggregateState({ + activeStates: [mkActive("a-1"), mkActive("a-2"), mkActive("a-3"), justCompleted], + terminalState: null, + nowMs: hourMs, + }); + + expect(aggregate?.activeCount).toBe(3); + expect(aggregate?.activities).toMatchObject([ + { threadId: "a-1" }, + { threadId: "a-2" }, + { threadId: "a-3" }, + ]); + }); }); diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts index 2bd9c053e90..94eb4bcc263 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts @@ -131,7 +131,11 @@ export const make = Effect.gen(function* () { "relay.thread_id": input.threadId, "relay.agent_activity.phase": input.state?.phase ?? "deleted", }); - if (input.state && !isTerminalPhase(input.state)) { + if (input.state) { + // Terminal states are persisted too (pruned by the cron after they + // age out) so a thread that finishes while other agents are active + // stays visible as Done/Failed in subsequent aggregates instead of + // silently vanishing from the Live Activity. yield* rows.upsert({ environmentPublicKey: input.environmentPublicKey, state: input.state, @@ -247,6 +251,25 @@ function terminalAggregateState(state: RelayAgentActivityState): RelayAgentActiv }); } +// How long a finished thread keeps its Done/Failed row in the aggregate while +// other agents are still active. Long enough to be seen on the lock screen, +// short enough that the activity list stays about live work. +export const TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS = 5 * 60 * 1_000; + +function isRecentTerminalState(state: RelayAgentActivityState, nowMs: number): boolean { + if (!isTerminalPhase(state)) { + return false; + } + const updatedAtMs = Option.match(DateTime.make(state.updatedAt), { + onNone: () => Number.NaN, + onSome: (dt) => dt.epochMilliseconds, + }); + if (Number.isNaN(updatedAtMs)) { + return false; + } + return nowMs - updatedAtMs <= TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS; +} + export function makeAggregateState(input: { readonly activeStates: ReadonlyArray; readonly terminalState: RelayAgentActivityState | null; @@ -256,9 +279,19 @@ export function makeAggregateState(input: { (state) => !isTerminalPhase(state) && !isExpiredAgentActivityState(state, input.nowMs), ); if (activeStates.length === 0) { + // Only the just-published terminal event ends the activity with a Done + // aggregate; lingering terminal rows alone never do, so a replay after a + // fresh registration doesn't resurrect a finished thread. return input.terminalState === null ? null : terminalAggregateState(input.terminalState); } - const updatedAt = activeStates.reduce((latest, state) => + // Recently finished threads ride along after the active ones (display slots + // permitting) so a completion is visible as Done/Failed instead of the row + // silently vanishing while other agents keep the activity alive. + const recentTerminalStates = input.activeStates + .filter((state) => isRecentTerminalState(state, input.nowMs)) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + const displayedStates = [...activeStates.slice(0, 3), ...recentTerminalStates].slice(0, 3); + const updatedAt = [...activeStates, ...recentTerminalStates].reduce((latest, state) => state.updatedAt.localeCompare(latest.updatedAt) > 0 ? state : latest, ).updatedAt; return sanitizeAgentActivityAggregateState({ @@ -266,7 +299,7 @@ export function makeAggregateState(input: { subtitle: "Agent work in progress", activeCount: activeStates.length, updatedAt, - activities: activeStates.slice(0, 3).map(aggregateRowForState), + activities: displayedStates.map(aggregateRowForState), }); } diff --git a/infra/relay/src/agentActivity/AgentActivityRows.ts b/infra/relay/src/agentActivity/AgentActivityRows.ts index 7e1a8c50f1b..63868ad2d72 100644 --- a/infra/relay/src/agentActivity/AgentActivityRows.ts +++ b/infra/relay/src/agentActivity/AgentActivityRows.ts @@ -7,7 +7,7 @@ import * as Function from "effect/Function"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { and, desc, eq, isNull } from "drizzle-orm"; +import { and, desc, eq, isNull, lt, sql } from "drizzle-orm"; import * as RelayDb from "../db.ts"; import { relayAgentActivityRows, relayEnvironmentLinks } from "../persistence/schema.ts"; @@ -38,6 +38,18 @@ export class AgentActivityRowDeletePersistenceError extends Schema.TaggedErrorCl } } +export class AgentActivityRowPruneTerminalPersistenceError extends Schema.TaggedErrorClass()( + "AgentActivityRowPruneTerminalPersistenceError", + { + updatedBefore: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to prune terminal agent activity rows updated before ${this.updatedBefore}.`; + } +} + export class AgentActivityRowListPersistenceError extends Schema.TaggedErrorClass()( "AgentActivityRowListPersistenceError", { @@ -57,6 +69,9 @@ export class AgentActivityRows extends Context.Service< readonly environmentPublicKey: string; readonly state: RelayAgentActivityState; }) => Effect.Effect; + readonly pruneTerminal: (input: { + readonly updatedBefore: string; + }) => Effect.Effect; readonly remove: (input: { readonly environmentId: string; readonly environmentPublicKey: string; @@ -163,6 +178,29 @@ export const make = Effect.gen(function* () { ); }), + pruneTerminal: Effect.fn("relay.agent_activity_rows.prune_terminal")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.agent_activity_prune.before": input.updatedBefore, + }); + yield* db + .delete(relayAgentActivityRows) + .where( + and( + sql`${relayAgentActivityRows.stateJson} ->> 'phase' IN ('completed', 'failed')`, + lt(relayAgentActivityRows.updatedAt, input.updatedBefore), + ), + ) + .pipe( + Effect.mapError( + (cause) => + new AgentActivityRowPruneTerminalPersistenceError({ + updatedBefore: input.updatedBefore, + cause, + }), + ), + ); + }), + listForUser: Effect.fn("relay.agent_activity_rows.list_for_user")(function* (input) { return yield* db .select({ stateJson: relayAgentActivityRows.stateJson }) diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index f63e4e67ee0..d8315588323 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -1482,4 +1482,55 @@ describe("live activity alert decisions", () => { ).toBeNull(); expect(ApnsDeliveries.alertForTerminalAggregate({ aggregate: null, preferences })).toBeNull(); }); + + it("alerts when a previously active thread finishes mid-flight", () => { + const doneRow = { + ...aggregate.activities[0]!, + phase: "completed" as const, + status: "Done", + }; + const next = { + ...aggregate, + activeCount: 0, + activities: [attentionRow, doneRow], + }; + expect( + ApnsDeliveries.alertForNewlyTerminal({ + previousAggregate: aggregate, + nextAggregate: next, + preferences, + }), + ).toEqual({ title: "Thread", body: "Done: Project" }); + // The completion switch mutes it. + expect( + ApnsDeliveries.alertForNewlyTerminal({ + previousAggregate: aggregate, + nextAggregate: next, + preferences: { ...preferences, notifyOnCompletion: false }, + }), + ).toBeNull(); + // No baseline means no transition to ring on. + expect( + ApnsDeliveries.alertForNewlyTerminal({ + previousAggregate: null, + nextAggregate: next, + preferences, + }), + ).toBeNull(); + // A Done row that was already terminal (or absent) before stays silent. + expect( + ApnsDeliveries.alertForNewlyTerminal({ + previousAggregate: next, + nextAggregate: next, + preferences, + }), + ).toBeNull(); + expect( + ApnsDeliveries.alertForNewlyTerminal({ + previousAggregate: { ...aggregate, activities: [attentionRow] }, + nextAggregate: next, + preferences, + }), + ).toBeNull(); + }); }); diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index 95461561898..f00e08556aa 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -203,6 +203,47 @@ export function alertForAttentionTransition(input: { }; } +// Alert copy for an update whose aggregate contains threads that finished +// (Done/Failed) since the previously delivered aggregate — the mid-flight +// completion buzz while other agents keep the activity alive. Requires the +// thread to have been present and non-terminal before, so a baseline-less +// replay or a row that merely fell off the display cap never rings. +export function alertForNewlyTerminal(input: { + readonly previousAggregate: RelayAgentActivityAggregateState | null; + readonly nextAggregate: RelayAgentActivityAggregateState; + readonly preferences: RelayAgentAwarenessPreferences | null; +}): ApnsLiveActivityAlert | null { + if (input.previousAggregate === null) { + return null; + } + const previousPhases = new Map( + input.previousAggregate.activities.map((row) => [row.threadId, row.phase]), + ); + const newlyTerminal = input.nextAggregate.activities.filter((row) => { + if (row.phase !== "completed" && row.phase !== "failed") { + return false; + } + const previousPhase = previousPhases.get(row.threadId); + return ( + previousPhase !== undefined && + previousPhase !== "completed" && + previousPhase !== "failed" && + alertAllowedForPhase(input.preferences, row.phase) + ); + }); + const first = newlyTerminal[0]; + if (!first) { + return null; + } + if (newlyTerminal.length === 1) { + return { title: first.threadTitle, body: `${first.status}: ${first.projectTitle}` }; + } + return { + title: `${newlyTerminal.length} agents finished`, + body: newlyTerminal.map((row) => row.threadTitle).join(", "), + }; +} + // Alert copy for an end event carrying a terminal (Done/Failed) aggregate. export function alertForTerminalAggregate(input: { readonly aggregate: RelayAgentActivityAggregateState | null; @@ -340,11 +381,17 @@ function chooseLiveActivityDelivery(input: { kind: "live_activity_update", token: input.target.activity_push_token, aggregate: input.aggregate, - alert: alertForAttentionTransition({ - previousAggregate, - nextAggregate: input.aggregate, - preferences, - }), + alert: + alertForAttentionTransition({ + previousAggregate, + nextAggregate: input.aggregate, + preferences, + }) ?? + alertForNewlyTerminal({ + previousAggregate, + nextAggregate: input.aggregate, + preferences, + }), } : "suppressed"; } diff --git a/infra/relay/src/agentActivity/MobileRegistrations.test.ts b/infra/relay/src/agentActivity/MobileRegistrations.test.ts index 40517241b90..9183f88956a 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.test.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts @@ -71,6 +71,7 @@ function makeAgentActivityRows( return { upsert: () => Effect.void, remove: () => Effect.void, + pruneTerminal: () => Effect.void, listForUser: () => { const activeState: RelayAgentActivityState = { environmentId: "env-1" as RelayAgentActivityState["environmentId"], diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index d8598be6624..0e6aca809aa 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -2,6 +2,7 @@ import * as Alchemy from "alchemy"; import * as Cloudflare from "alchemy/Cloudflare"; import * as Drizzle from "alchemy/Drizzle"; import * as Config from "effect/Config"; +import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -243,7 +244,18 @@ export default class Api extends Cloudflare.Worker()( yield* Cloudflare.cron("*/5 * * * *").subscribe(() => DpopProofs.DpopProofReplay.pipe( Effect.flatMap((dpopProofs) => dpopProofs.pruneExpired), - Effect.withSpan("relay.cron.prune_expired_dpop_proofs"), + // Terminal thread rows are kept briefly so finished agents show as + // Done/Failed in the Live Activity; sweep them once they age out. + Effect.andThen( + Effect.all([AgentActivityRows.AgentActivityRows, DateTime.now]).pipe( + Effect.flatMap(([activityRows, now]) => + activityRows.pruneTerminal({ + updatedBefore: DateTime.formatIso(DateTime.subtract(now, { minutes: 30 })), + }), + ), + ), + ), + Effect.withSpan("relay.cron.prune_expired_state"), Effect.provide(runtimeLayer), ), ); From bf5de3cd6e786b2d9412f17a0912aa3ba76c1718 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Jul 2026 19:15:23 -0700 Subject: [PATCH 12/26] Re-validate queued Live Activity starts at delivery time Live repro of a lock-screen activity frozen at "Working" forever: three publishes landed within four seconds of a turn finishing, and the queue batch delivered a start (chosen from the running aggregate while the target row briefly looked inactive) 250ms before the terminal publish's end. The end killed the old activity; the start birthed a new one carrying the stale running aggregate. No token is ever registered for that orphan, so nothing can update or end it. Start jobs now recheck at delivery time whether the user still has any live (non-terminal, non-expired) agent-activity rows; if not, the job is skipped as stale ("Stale APNs start job skipped."), the queued-start marker is cleared so future work can start an activity again, and nothing reaches APNs. The recheck fails open on persistence errors so a database hiccup never drops a legitimate start. isTerminalPhase/isExpiredAgentActivityState move to agentActivityPayloads.ts so ApnsDeliveries can share them without a module cycle through the publisher. Co-Authored-By: Claude Fable 5 --- .../agentActivity/AgentActivityPublisher.ts | 40 +++---------- .../src/agentActivity/ApnsDeliveries.test.ts | 59 +++++++++++++++++++ .../relay/src/agentActivity/ApnsDeliveries.ts | 44 ++++++++++++++ .../agentActivity/agentActivityPayloads.ts | 36 +++++++++++ 4 files changed, 146 insertions(+), 33 deletions(-) diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts index 94eb4bcc263..91200fa7b1c 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts @@ -10,7 +10,13 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import { sanitizeAgentActivityAggregateState } from "./agentActivityPayloads.ts"; +import { + isExpiredAgentActivityState, + isTerminalPhase, + sanitizeAgentActivityAggregateState, +} from "./agentActivityPayloads.ts"; + +export { isExpiredAgentActivityState } from "./agentActivityPayloads.ts"; import * as AgentActivityRows from "./AgentActivityRows.ts"; import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; import * as LiveActivities from "./LiveActivities.ts"; @@ -195,38 +201,6 @@ function statusForPhase(phase: RelayAgentActivityState["phase"]): string { } } -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, diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index d8315588323..acfc97a2b88 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -5,6 +5,7 @@ import type { import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; import { describe, expect, it } from "@effect/vitest"; import * as NodeCrypto from "node:crypto"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; @@ -26,6 +27,7 @@ import * as DeliveryAttempts from "./DeliveryAttempts.ts"; import * as LiveActivities from "./LiveActivities.ts"; import * as RelayConfiguration from "../Config.ts"; import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts"; +import * as AgentActivityRows from "./AgentActivityRows.ts"; import * as ApnsDeliveries from "./ApnsDeliveries.ts"; import * as ApnsClient from "./ApnsClient.ts"; import * as ApnsProviderTokens from "./ApnsProviderTokens.ts"; @@ -159,6 +161,10 @@ function makeLayer(input: { >; readonly currentTargets?: ReadonlyArray; readonly config?: RelayConfiguration.RelayConfiguration["Service"]; + // Live agent-activity rows returned by the delivery-time start recheck. + // Defaults to one freshly-updated running thread so queued starts stay + // deliverable in tests that don't care about the recheck. + readonly activityStates?: ReadonlyArray; readonly execute?: ( request: HttpClientRequest.HttpClientRequest, ) => Effect.Effect; @@ -169,6 +175,17 @@ function makeLayer(input: { Layer.provide(ApnsDeliveryQueue.layer.pipe(Layer.provide(NodeCryptoLayer.layer))), Layer.provide( Layer.mergeAll( + Layer.succeed(AgentActivityRows.AgentActivityRows, { + upsert: () => Effect.void, + remove: () => Effect.void, + pruneTerminal: () => Effect.void, + listForUser: () => + input.activityStates !== undefined + ? Effect.succeed([...input.activityStates]) + : DateTime.now.pipe( + Effect.map((now) => [{ ...state, updatedAt: DateTime.formatIso(now) }]), + ), + } satisfies AgentActivityRows.AgentActivityRows["Service"]), Layer.succeed(ApnsDeliveryQueue.ApnsDeliveryQueueSender, { send: (body) => Effect.sync(() => { @@ -823,6 +840,48 @@ describe("ApnsDeliveries", () => { }).pipe(Effect.provide(makeLayer({ attempts }))); }); + it.effect("skips a queued start when the user no longer has live work", () => { + const attempts: Array = []; + const clearedStarts: Array< + Parameters[0] + > = []; + const payload = makeApnsDeliveryJobPayload({ + kind: "live_activity_start", + userId: target.user_id, + deviceId: target.device_id, + token: target.push_to_start_token ?? "start-token", + aggregate, + createdAt: "1970-01-01T00:00:00.000Z", + expiresAt: "1970-01-01T00:10:00.000Z", + jobId: "job-start-1", + }); + const signed = signApnsDeliveryJob({ + secret: config.apnsDeliveryJobSigningSecret, + payload, + }); + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + const result = yield* deliveries.processSignedJob(signed); + + // The start was decided from an aggregate that a newer terminal publish + // has since invalidated; delivering it would birth an orphan activity. + expect(result).toMatchObject({ + kind: "live_activity_start", + ok: true, + apnsStatus: null, + }); + expect(attempts).toMatchObject([ + { + kind: "live_activity_start", + sourceJobId: "job-start-1", + apnsReason: "Stale APNs start job skipped.", + }, + ]); + expect(clearedStarts).toMatchObject([{ userId: target.user_id, deviceId: target.device_id }]); + }).pipe(Effect.provide(makeLayer({ attempts, clearedStarts, activityStates: [] }))); + }); + it.effect("processes signed jobs through APNs and records attempts", () => { const attempts: Array = []; const transportErrors: Array = []; diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index f00e08556aa..c9674efc905 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -18,6 +18,8 @@ import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import { + isExpiredAgentActivityState, + isTerminalPhase, sanitizeAgentActivityAggregateState, sanitizeApnsNotificationPayload, } from "./agentActivityPayloads.ts"; @@ -33,6 +35,7 @@ import { verifySignedApnsDeliveryJob, type ApnsDeliveryJobVerificationError, } from "./apnsDeliveryJobs.ts"; +import * as AgentActivityRows from "./AgentActivityRows.ts"; import * as DeliveryAttempts from "./DeliveryAttempts.ts"; import * as LiveActivities from "./LiveActivities.ts"; import * as RelayConfiguration from "../Config.ts"; @@ -639,6 +642,31 @@ export const make = Effect.gen(function* () { const deliveryQueue = yield* ApnsDeliveryQueue.ApnsDeliveryQueue; const config = yield* RelayConfiguration.RelayConfiguration; const apns = yield* Apns.ApnsClient; + const activityRows = yield* AgentActivityRows.AgentActivityRows; + + // Start jobs are decided at publish time, but consecutive publishes land in + // the same queue batch: a start chosen from a running aggregate can be + // delivered moments after a newer terminal publish already ended the user's + // work, birthing an orphan activity that shows stale content forever (no + // token is ever registered for it, so nothing can update or end it). + // Re-validate at delivery time that the user still has live work; fail open + // on persistence errors so a database hiccup never drops a legitimate start. + const userStillHasLiveWork = Effect.fnUntraced(function* (userId: string) { + const now = yield* DateTime.now; + return yield* activityRows.listForUser({ userId }).pipe( + Effect.map((states) => + states.some( + (state) => + !isTerminalPhase(state) && !isExpiredAgentActivityState(state, now.epochMilliseconds), + ), + ), + Effect.catchCause((cause) => + Effect.logWarning("live-work recheck failed; allowing queued start", { cause }).pipe( + Effect.as(true), + ), + ), + ); + }); const isCurrentSignedJobToken = Effect.fnUntraced(function* (input: { readonly target: LiveActivityDeliveryTarget; @@ -710,6 +738,22 @@ export const make = Effect.gen(function* () { return staleJobResult({ deviceId: input.target.device_id, kind: input.kind }); } } + if ( + input.kind === "live_activity_start" && + !(yield* userStillHasLiveWork(input.target.user_id)) + ) { + yield* liveActivities.clearStartQueued({ + userId: input.target.user_id, + deviceId: input.target.device_id, + }); + if (input.sourceJobId) { + yield* attempts.completeSourceJob({ + sourceJobId: input.sourceJobId, + apnsReason: "Stale APNs start job skipped.", + }); + } + return staleJobResult({ deviceId: input.target.device_id, kind: input.kind }); + } const result = yield* apns .sendLiveActivityRequest({ credentials: credentialsForTarget(config.apns, input.target), diff --git a/infra/relay/src/agentActivity/agentActivityPayloads.ts b/infra/relay/src/agentActivity/agentActivityPayloads.ts index ed3fc3f0116..33f305fb7a0 100644 --- a/infra/relay/src/agentActivity/agentActivityPayloads.ts +++ b/infra/relay/src/agentActivity/agentActivityPayloads.ts @@ -1,9 +1,45 @@ import type { RelayAgentActivityAggregateRow, RelayAgentActivityAggregateState, + RelayAgentActivityState, } from "@t3tools/contracts/relay"; +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; + import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts"; +export 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; +} + const MAX_SUMMARY_TEXT_LENGTH = 120; const MAX_STATUS_TEXT_LENGTH = 40; const MAX_DEEP_LINK_LENGTH = 512; From 0c5ffc50de5ba94fc5136d7f09279ba3211d8315 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Jul 2026 19:27:16 -0700 Subject: [PATCH 13/26] Retire the stored activity token when a start or end delivery lands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live repro: a push-to-start birthed a fresh lock-screen activity, but the relay's live-activity row still held the PREVIOUS activity's update token, so every subsequent update and end (all APNs 200) routed to the dead activity while the new card sat frozen on its start content — "Working" even though the thread had moved to waiting-for-input (the correctly built Input aggregate went to the corpse token at 02:21:16Z). markDelivery now clears activity_push_token when a start (new activity generation) or end (activity gone) is delivered, and refreshes remote_started_at on starts instead of keeping the previous generation's timestamp. Deliveries pause until the app registers the fresh token; registerLiveActivity fully resets the row and the registration replay already reconciles the card (update to current content, or end it when work has finished), while queued jobs for the retired token are dropped by the existing current-token staleness check. Co-Authored-By: Claude Fable 5 --- .../src/agentActivity/LiveActivities.test.ts | 51 +++++++++++++++++++ .../relay/src/agentActivity/LiveActivities.ts | 24 +++++++-- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/infra/relay/src/agentActivity/LiveActivities.test.ts b/infra/relay/src/agentActivity/LiveActivities.test.ts index 8f3182279bb..7f2bce87431 100644 --- a/infra/relay/src/agentActivity/LiveActivities.test.ts +++ b/infra/relay/src/agentActivity/LiveActivities.test.ts @@ -198,6 +198,57 @@ describe("LiveActivities", () => { ); }); + it.effect("retires the previous activity token when a start or end is delivered", () => { + const conflictConfigs: Array<{ readonly set?: Record }> = []; + const fakeDb = { + insert: () => ({ + values: () => ({ + onConflictDoUpdate: (config: { readonly set?: Record }) => { + conflictConfigs.push(config); + return Effect.void; + }, + }), + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const liveActivities = yield* LiveActivities.LiveActivities; + const mark = (kind: "live_activity_start" | "live_activity_update" | "live_activity_end") => + liveActivities.markDelivery({ + userId: "user-2", + deviceId: "device-1", + kind, + aggregate, + deliveredAt: "2026-05-25T00:00:10.000Z", + }); + yield* mark("live_activity_start"); + yield* mark("live_activity_update"); + yield* mark("live_activity_end"); + + // A start begins a new activity generation and an end retires the + // current one; both must drop the stored update token so later + // deliveries can't route to the dead activity. Plain updates keep it. + expect(conflictConfigs[0]?.set).toEqual( + expect.objectContaining({ + activityPushToken: null, + remoteStartedAt: "2026-05-25T00:00:10.000Z", + endedAt: null, + }), + ); + expect(conflictConfigs[1]?.set?.activityPushToken).not.toBeNull(); + expect(conflictConfigs[2]?.set).toEqual( + expect.objectContaining({ + activityPushToken: null, + endedAt: "2026-05-25T00:00:10.000Z", + }), + ); + }).pipe( + Effect.provide( + LiveActivities.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))), + ), + ); + }); + it.effect("preserves correlation context and causes for persistence failures", () => { const cause = new Error("database unavailable"); const registration: RelayLiveActivityRegistrationRequest = { diff --git a/infra/relay/src/agentActivity/LiveActivities.ts b/infra/relay/src/agentActivity/LiveActivities.ts index f6109b07a19..97e87a65f2c 100644 --- a/infra/relay/src/agentActivity/LiveActivities.ts +++ b/infra/relay/src/agentActivity/LiveActivities.ts @@ -280,10 +280,26 @@ export const make = Effect.gen(function* () { .onConflictDoUpdate({ target: [relayLiveActivities.userId, relayLiveActivities.deviceId], set: { - remoteStartedAt: sql`coalesce( - ${relayLiveActivities.remoteStartedAt}, - excluded.remote_started_at - )`, + // A delivered start begins a NEW activity generation: the stored + // update token belongs to the previous activity (dead once a new + // one starts, and certainly dead after an end), so keep it only + // for plain updates. Deliveries pause until the app registers + // the fresh activity's token; registerLiveActivity + replay then + // reconcile content (or end the activity if work already + // finished). Without this, updates and ends route to the dead + // token and the new lock-screen card is stranded at its start + // content forever. + activityPushToken: + input.kind === "live_activity_update" + ? sql`${relayLiveActivities.activityPushToken}` + : null, + remoteStartedAt: + input.kind === "live_activity_start" + ? input.deliveredAt + : sql`coalesce( + ${relayLiveActivities.remoteStartedAt}, + excluded.remote_started_at + )`, remoteStartQueuedAt: null, endedAt: input.kind === "live_activity_start" From a2e08300bd1f2188779b99a69503976abbc1a65d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Jul 2026 20:14:36 -0700 Subject: [PATCH 14/26] Arm Live Activities from the app instead of remote push-to-start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remote push-to-start was the source of every hard Live Activity bug this branch fought: it depends on iOS waking the app in the background to hand the new activity's update token over, and that handoff proved unreliable (orphan cards frozen on their start content, deliveries routed to dead tokens, first-transition races inside the same queue batch). Now activities are only ever created by the app, in the foreground, where the token is observable and registered immediately: - Mobile: the existing sign-in / app-foreground / connection reconcile pass primes the card — if no activity is live and Live Activity updates are enabled, it starts one showing an idle state and registers its token on the spot. All push-to-start plumbing (token listener, registration ride-along, carry-forward merge logic) is removed; the contract and stored-record fields stay optional for rolling compat. - Relay: chooseLiveActivityDelivery never emits live_activity_start. An armed card with an empty aggregate idles ("Waiting for agents") instead of ending, so the token survives session gaps and agents started from other machines appear on it while the phone stays locked; identical idle redraws are suppressed. Ends remain only for the Live-Activities-disabled toggle (plus permanent token failures), and attention transitions without any armed card fall back to push notifications as before. Terminal aggregates now flow through the update path, so completion buzzes ride the newly-terminal alert and the Done card lingers as the session dashboard rather than tearing down and forcing the next session through a remote start. Co-Authored-By: Claude Fable 5 --- .../remoteRegistration.test.ts | 46 ------- .../agent-awareness/remoteRegistration.ts | 117 +++++++++--------- .../src/agentActivity/ApnsDeliveries.test.ts | 60 ++++----- .../relay/src/agentActivity/ApnsDeliveries.ts | 68 +++++----- .../agentActivity/MobileRegistrations.test.ts | 47 ++----- 5 files changed, 129 insertions(+), 209 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 99686898eb7..606e21be9fe 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -38,7 +38,6 @@ import { unregisterAgentAwarenessConnection, } from "./remoteRegistration"; import * as Notifications from "expo-notifications"; -import { addPushToStartTokenListener } from "expo-widgets"; const secureStore = vi.hoisted(() => new Map()); const widgetMocks = vi.hoisted(() => ({ @@ -624,51 +623,6 @@ describe("makeRelayDeviceRegistrationRequest", () => { }).pipe(Effect.provide(relayTestLayer)); }); - it.effect("carries the accepted push-to-start token forward so tokenless refreshes skip", () => { - const fetchMock = vi.fn((request: RequestInfo | URL) => { - const url = request instanceof Request ? request.url : String(request); - return Promise.resolve( - Response.json( - url.endsWith("/v1/client/dpop-token") - ? { - access_token: "relay-dpop-token", - issued_token_type: "urn:ietf:params:oauth:token-type:access_token", - token_type: "DPoP", - expires_in: 300, - scope: "mobile:registration", - } - : { ok: true }, - ), - ); - }); - vi.stubGlobal("fetch", fetchMock); - Constants.expoConfig!.extra = { - relay: { - url: "https://relay.example.test/", - }, - }; - - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); - const pushToStartListener = vi.mocked(addPushToStartTokenListener).mock.calls.at(-1)?.[0]; - expect(pushToStartListener).toBeDefined(); - pushToStartListener?.({ activityPushToStartToken: "push-to-start-token" }); - - return Effect.gen(function* () { - yield* runBackgroundOperations(); - expect(registrationRecordStore.current?.pushToStartToken).toBe("push-to-start-token"); - - // A later refresh has no token event to carry — it must reuse the - // persisted token and skip instead of re-posting. (Fetch counts are - // unreliable here: the module-level relay layer captures the first - // test's fetch, so assert via the record save that only follows a real - // relay POST.) - vi.mocked(saveAgentAwarenessRegistrationRecord).mockClear(); - yield* refreshAgentAwarenessRegistration(); - expect(saveAgentAwarenessRegistrationRecord).not.toHaveBeenCalled(); - expect(getAgentAwarenessRegistrationStatus()).toBe("registered"); - }).pipe(Effect.provide(relayTestLayer)); - }); - it.effect("dedupes rapid activity-token re-registrations within the replay window", () => { // Fetch counts are unreliable here (the module-level relay layer captures // the first test's fetch), so assert on the flow's own seams: a real diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index b99e349a363..dc4f1ed969e 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -1,4 +1,4 @@ -import { addPushToStartTokenListener, type LiveActivity } from "expo-widgets"; +import { type LiveActivity } from "expo-widgets"; import Constants from "expo-constants"; import * as Notifications from "expo-notifications"; import * as Effect from "effect/Effect"; @@ -45,6 +45,8 @@ const AgentAwarenessOperation = Schema.Literals([ "read-live-activity-push-token", "load-live-activity-registration-identifier", "list-active-live-activities", + "load-live-activity-prime-preferences", + "prime-live-activity", ]); export class AgentAwarenessOperationError extends Schema.TaggedErrorClass()( @@ -72,7 +74,6 @@ const activityPushTokenListeners = new WeakSet> // sign-out/identity change alongside the device registration state. const ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS = 60_000; const registeredActivityPushTokens = new Map(); -let pushToStartSubscription: { remove: () => void } | null = null; let pushTokenSubscription: { remove: () => void } | null = null; let appStateSubscription: { remove: () => void } | null = null; @@ -119,7 +120,6 @@ let pendingDeviceRegistration: { } | null = null; interface DeviceRegistrationInput { - readonly pushToStartToken?: string; readonly observedPushToken?: string; } @@ -170,8 +170,6 @@ export function setAgentAwarenessRelayTokenProvider( relayTokenProvider = provider; relayTokenProviderIdentity = provider ? (identity ?? null) : null; if (!provider) { - pushToStartSubscription?.remove(); - pushToStartSubscription = null; pushTokenSubscription?.remove(); pushTokenSubscription = null; appStateSubscription?.remove(); @@ -191,7 +189,6 @@ export function setAgentAwarenessRelayTokenProvider( }); return; } - ensurePushToStartListener(); ensurePushTokenListener(); ensureAppStateListener(); runRegistrationInBackground( @@ -218,8 +215,6 @@ export function setAgentAwarenessRelayTokenProvider( export function releaseAgentAwarenessRelayTokenProvider(): void { relayTokenProvider = null; relayTokenProviderIdentity = null; - pushToStartSubscription?.remove(); - pushToStartSubscription = null; pushTokenSubscription?.remove(); pushTokenSubscription = null; appStateSubscription?.remove(); @@ -302,7 +297,6 @@ function registrationSignature(body: RelayDeviceRegistrationRequest): string { return [ body.deviceId, body.pushToken ?? "", - body.pushToStartToken ?? "", body.bundleId ?? "", body.apsEnvironment ?? "", body.appVersion ?? "", @@ -368,15 +362,7 @@ function registerDeviceWithRelay( }); return; } - // The push-to-start token only rides along on registrations triggered by a - // native token event; ones triggered by sign-in or app foreground omit it. - // Carry the last accepted token forward so its absence means "unchanged", - // not "cleared" — otherwise the signature alternates between the two - // trigger shapes and the skip below never fires. - const payload = - !body.pushToStartToken && persisted?.identity === identity && persisted.pushToStartToken - ? { ...body, pushToStartToken: persisted.pushToStartToken } - : body; + const payload = body; // The relay URL participates so pointing the app at a different relay // invalidates the record and re-registers there. const signature = `${relayConfig.url}|${registrationSignature(payload)}`; @@ -411,7 +397,6 @@ function registerDeviceWithRelay( saveAgentAwarenessRegistrationRecord({ identity, signature, - ...(payload.pushToStartToken ? { pushToStartToken: payload.pushToStartToken } : {}), }).catch((error: unknown) => { logRegistrationError("persist registration record failed", error); }), @@ -503,14 +488,8 @@ function mergeDeviceRegistrationInput( current: DeviceRegistrationInput, next: DeviceRegistrationInput, ): DeviceRegistrationInput { - return { - ...((next.pushToStartToken ?? current.pushToStartToken) - ? { pushToStartToken: next.pushToStartToken ?? current.pushToStartToken } - : {}), - ...((next.observedPushToken ?? current.observedPushToken) - ? { observedPushToken: next.observedPushToken ?? current.observedPushToken } - : {}), - }; + const observedPushToken = next.observedPushToken ?? current.observedPushToken; + return observedPushToken ? { observedPushToken } : {}; } function registrationAddsInformation( @@ -518,8 +497,7 @@ function registrationAddsInformation( next: DeviceRegistrationInput, ): boolean { return ( - (next.pushToStartToken !== undefined && next.pushToStartToken !== current.pushToStartToken) || - (next.observedPushToken !== undefined && next.observedPushToken !== current.observedPushToken) + next.observedPushToken !== undefined && next.observedPushToken !== current.observedPushToken ); } @@ -534,7 +512,6 @@ function startPendingDeviceRegistration(): void { logRegistrationDebug("device registration started", { generation, hasObservedPushToken: next.input.observedPushToken !== undefined, - hasPushToStartToken: next.input.pushToStartToken !== undefined, }); if (registrationStatus !== "registered") { setRegistrationStatus("pending"); @@ -635,7 +612,6 @@ function registerDevice( ...(bundleId ? { bundleId } : {}), apsEnvironment: resolveApsEnvironment(Constants.expoConfig?.extra?.appVariant), ...(pushTokenRegistration.pushToken ? { pushToken: pushTokenRegistration.pushToken } : {}), - ...(input?.pushToStartToken ? { pushToStartToken: input.pushToStartToken } : {}), notificationsEnabled: pushTokenRegistration.notificationsEnabled, preferences, }), @@ -644,27 +620,12 @@ function registerDevice( }); } -function registerDeviceForCurrentUser( - pushToStartToken?: string, -): Effect.Effect { - return registerDevice(pushToStartToken ? { pushToStartToken } : undefined); -} - -function registerPushToStartTokenForCurrentUser(pushToStartToken: string): void { - enqueueDeviceRegistration({ pushToStartToken }, "push-to-start token registration failed"); -} - -function ensurePushToStartListener(): void { - if (pushToStartSubscription || !canRegisterRemoteLiveActivities()) { - return; - } - - pushToStartSubscription = addPushToStartTokenListener((event) => { - const token = event.activityPushToStartToken; - if (token) { - registerPushToStartTokenForCurrentUser(token); - } - }); +function registerDeviceForCurrentUser(): Effect.Effect< + void, + unknown, + ManagedRelay.ManagedRelayClient +> { + return registerDevice(undefined); } function ensurePushTokenListener(): void { @@ -725,7 +686,6 @@ export function registerAgentAwarenessConnection(connection: SavedRemoteConnecti } environmentConnections.set(connection.environmentId, connection); - ensurePushToStartListener(); ensurePushTokenListener(); ensureAppStateListener(); enqueueDeviceRegistration({}, "device registration failed"); @@ -745,8 +705,6 @@ export function unregisterAgentAwarenessConnection(environmentId: EnvironmentId) export function unregisterAllAgentAwarenessConnections(): void { environmentConnections.clear(); - pushToStartSubscription?.remove(); - pushToStartSubscription = null; pushTokenSubscription?.remove(); pushTokenSubscription = null; appStateSubscription?.remove(); @@ -778,8 +736,6 @@ export function refreshAgentAwarenessRegistration(): Effect.Effect< export function __resetAgentAwarenessRemoteRegistrationForTest(): void { environmentConnections.clear(); - pushToStartSubscription?.remove(); - pushToStartSubscription = null; pushTokenSubscription?.remove(); pushTokenSubscription = null; appStateSubscription?.remove(); @@ -936,7 +892,7 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< return; } - const activities = yield* Effect.try({ + let activities = yield* Effect.try({ try: () => AgentActivity.getInstances(), catch: (cause) => new AgentAwarenessOperationError({ @@ -952,6 +908,51 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< ), ); + // Activities are only ever created here, in the foreground, where the + // update token can be observed and registered immediately — the relay + // never remote-starts one (background push-to-start wakes proved too + // unreliable to hand the token over). Opening the app arms the card; the + // relay then drives its content, including agents started from other + // machines while the phone stays locked. + if (activities.length === 0) { + const preferences = yield* Effect.tryPromise({ + try: () => loadPreferences(), + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "load-live-activity-prime-preferences", + cause, + }), + }).pipe(Effect.orElseSucceed(() => null)); + if (preferences?.liveActivitiesEnabled) { + const primed = yield* Effect.try({ + try: () => + AgentActivity.start({ + title: "T3 Code", + subtitle: "Waiting for agents", + activeCount: 0, + updatedAt: new Date(Date.now()).toISOString(), + activities: [], + }), + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "prime-live-activity", + cause, + }), + }).pipe( + Effect.catch((error) => + Effect.sync(() => { + logRegistrationError("live activity priming failed", error); + return null; + }), + ), + ); + if (primed) { + logRegistrationDebug("live activity card primed", {}); + activities = [primed]; + } + } + } + const registrationResults = yield* Effect.forEach(activities, (activity) => registerLiveActivityPushToken({ activity }).pipe( Effect.map((registered) => !registered), diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index acfc97a2b88..71cd406d3cb 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -246,51 +246,38 @@ function makeLayer(input: { } describe("ApnsDeliveries", () => { - it.effect("queues a restart using the push-to-start token", () => { + it.effect("never starts an activity remotely when no update token is registered", () => { const attempts: Array = []; const queuedJobs: Array = []; const queuedStarts: Array< Parameters[0] > = []; - const markedDeliveries: Array< - Parameters[0] - > = []; return Effect.gen(function* () { const deliveries = yield* ApnsDeliveries.ApnsDeliveries; const result = yield* deliveries.sendForTarget({ target: { ...target, + activity_push_token: null, + remote_started_at: null, ended_at: "1970-01-01T00:00:05.000Z", }, aggregate, nowMs: 10_000, }); - expect(result?.kind).toBe("live_activity_start"); - expect(result?.ok).toBe(true); - expect(queuedJobs).toMatchObject([ - { - payload: { - kind: "live_activity_start", - target: { - token: "start-token", - }, - }, - }, - ]); + // Activities are armed by the app in the foreground; the relay never + // uses the push-to-start token, so the only fallback is the push + // notification channel (none here: the aggregate is not an attention + // phase). + expect(result).toBeNull(); + expect(queuedJobs).toEqual([]); + expect(queuedStarts).toEqual([]); expect(attempts).toEqual([]); - expect(queuedStarts).toMatchObject([ - { - userId: target.user_id, - deviceId: target.device_id, - }, - ]); - expect(markedDeliveries).toEqual([]); - }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs, queuedStarts, markedDeliveries }))); + }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs, queuedStarts }))); }); - it.effect("queues an end using the activity token", () => { + it.effect("updates an armed card to the idle state when nothing is running", () => { const attempts: Array = []; const queuedJobs: Array = []; @@ -302,15 +289,23 @@ describe("ApnsDeliveries", () => { nowMs: 5_000, }); - expect(result?.kind).toBe("live_activity_end"); + // The card is user-armed: tearing it down on an empty aggregate would + // force a remote start (which no longer exists) the next time work + // begins, so it idles instead. + expect(result?.kind).toBe("live_activity_update"); expect(result?.ok).toBe(true); expect(queuedJobs).toMatchObject([ { payload: { - kind: "live_activity_end", + kind: "live_activity_update", target: { token: "activity-token", }, + aggregate: { + activeCount: 0, + subtitle: "Waiting for agents", + activities: [], + }, }, }, ]); @@ -389,12 +384,7 @@ describe("ApnsDeliveries", () => { return Effect.gen(function* () { const deliveries = yield* ApnsDeliveries.ApnsDeliveries; yield* deliveries.sendForTarget({ - target: { - ...target, - activity_push_token: null, - remote_started_at: null, - ended_at: "1970-01-01T00:00:05.000Z", - }, + target, aggregate: inputAggregate, nowMs: 10_000, }); @@ -430,9 +420,9 @@ describe("ApnsDeliveries", () => { expect(queuedJobs).toMatchObject([ { payload: { - kind: "live_activity_start", + kind: "live_activity_update", target: { - token: "start-token", + token: "activity-token", bundleId: "com.t3tools.t3code.preview", apsEnvironment: "production", }, diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index c9674efc905..a575b24515c 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -329,19 +329,33 @@ 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. +// The lock-screen card shown when the user's card is armed but nothing is +// running: activities are created only by the app in the foreground (never +// remotely), so an empty aggregate means "show idle", not "tear the card +// down" — ending it would force an unreliable background token handoff the +// next time work starts. +export function idleAggregateState(nowMs: number): RelayAgentActivityAggregateState { + return { + title: "T3 Code", + subtitle: "Waiting for agents", + activeCount: 0, + updatedAt: DateTime.formatIso(DateTime.makeUnsafe(nowMs)), + activities: [], + }; +} + +function isIdleAggregate(aggregate: RelayAgentActivityAggregateState): boolean { + return aggregate.activeCount === 0 && aggregate.activities.length === 0; +} + function chooseLiveActivityDelivery(input: { readonly target: LiveActivities.TargetRow; readonly aggregate: RelayAgentActivityAggregateState | null; readonly nowMs: number; }): ChosenLiveActivityDelivery | "suppressed" | null { - const hasActiveActivity = - input.target.ended_at === null && - (input.target.remote_start_queued_at !== null || - input.target.remote_started_at !== null || - input.target.activity_push_token !== null); const preferences = parsePreferences(input.target.preferences_json); if (preferences?.liveActivitiesEnabled === false) { - return hasActiveActivity && input.target.activity_push_token + return input.target.activity_push_token ? { kind: "live_activity_end", token: input.target.activity_push_token, @@ -350,49 +364,43 @@ function chooseLiveActivityDelivery(input: { } : null; } - if (input.aggregate === null || input.aggregate.activeCount === 0) { - return hasActiveActivity && input.target.activity_push_token - ? { - kind: "live_activity_end", - token: input.target.activity_push_token, - aggregate: input.aggregate, - alert: null, - } - : null; - } - if (!hasActiveActivity) { - return input.target.push_to_start_token - ? { - kind: "live_activity_start", - token: input.target.push_to_start_token, - aggregate: input.aggregate, - alert: null, - } - : null; - } + // Activities are started by the app in the foreground, never remotely. + // Without a registered token there is nothing addressable; attention + // transitions fall back to the push notification channel until the user + // next arms the card from the app. if (!input.target.activity_push_token) { return null; } + const nextAggregate = input.aggregate ?? idleAggregateState(input.nowMs); const previousAggregate = parseAggregate(input.target.last_aggregate_json); + // An idle card that is already idle needs no redraw; updatedAt alone would + // otherwise defeat the equality check inside shouldUpdateLiveActivity. + if ( + isIdleAggregate(nextAggregate) && + previousAggregate !== null && + isIdleAggregate(previousAggregate) + ) { + return "suppressed"; + } return shouldUpdateLiveActivity({ previousAggregate, - nextAggregate: input.aggregate, + nextAggregate, lastDeliveryAt: input.target.last_live_activity_delivery_at, nowMs: input.nowMs, }) ? { kind: "live_activity_update", token: input.target.activity_push_token, - aggregate: input.aggregate, + aggregate: nextAggregate, alert: alertForAttentionTransition({ previousAggregate, - nextAggregate: input.aggregate, + nextAggregate, preferences, }) ?? alertForNewlyTerminal({ previousAggregate, - nextAggregate: input.aggregate, + nextAggregate, preferences, }), } diff --git a/infra/relay/src/agentActivity/MobileRegistrations.test.ts b/infra/relay/src/agentActivity/MobileRegistrations.test.ts index 9183f88956a..c18f1a18786 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.test.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts @@ -388,7 +388,7 @@ describe("MobileRegistrations", () => { }); it.effect( - "starts a remote Live Activity through the real publisher and APNs queue when a device registers after work is already active", + "does not remotely start a Live Activity when a device registers after work is already active", () => { const queuedJobs: Array = []; const queuedStarts: Array< @@ -440,46 +440,13 @@ describe("MobileRegistrations", () => { }, }); + // Activities are armed by the app in the foreground; a device + // registration alone never remote-starts one, even when work is + // already active and a push-to-start token is on file. expect(result).toEqual({ ok: true }); - expect(registeredDevices).toEqual([ - { - userId: "dev:julius", - registration: { - ...device, - pushToken: "apns-device-token", - pushToStartToken: "push-to-start-token", - }, - }, - ]); - expect(queuedStarts).toMatchObject([ - { - userId: "dev:julius", - deviceId: "device-1", - }, - ]); - expect(queuedJobs).toHaveLength(1); - expect(queuedJobs[0]?.payload).toMatchObject({ - kind: "live_activity_start", - target: { - userId: "dev:julius", - deviceId: "device-1", - token: "push-to-start-token", - }, - aggregate: { - title: "T3 Code", - subtitle: "Agent work in progress", - activeCount: 1, - activities: [ - { - environmentId: "env-1", - threadId: "thread-1", - threadTitle: "Implement APNs", - status: "Working", - }, - ], - }, - notification: null, - }); + expect(registeredDevices).toHaveLength(1); + expect(queuedStarts).toEqual([]); + expect(queuedJobs).toEqual([]); }).pipe(Effect.provide(makeRegistrationReplayLayer({ devices, liveActivities, queuedJobs }))); }, ); From 2e02973c16c5535583c9bd4eedd09754d8d5ad6f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Jul 2026 20:46:37 -0700 Subject: [PATCH 15/26] Arm the Live Activity only when there is something to show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Priming an idle card on every app open put an empty "0 agents" activity on the lock screen. The app now asks the relay what the card would show before creating one: a new GET /v1/mobile/agent-activity endpoint returns the current aggregate (same DPoP auth and aggregate computation as the registration replay). If nothing is live, no card is created — arming then happens the next time the app opens while agents are running, including via a tap on an agent notification. When there is live work, the activity is born seeded with the real aggregate instead of a placeholder, which also removes the idle flash before the replay landed. The snapshot request only fires when no card is currently armed and Live Activity updates are enabled; once a card exists, app opens make no extra requests. Piggybacking on the device registration response was considered instead of a new endpoint, but the register-once skip means warm opens send no registration request at all — exactly when the signal is needed. Co-Authored-By: Claude Fable 5 --- .../agent-awareness/remoteRegistration.ts | 87 +++++++++++++------ .../agentActivity/MobileRegistrations.test.ts | 32 +++++++ .../src/agentActivity/MobileRegistrations.ts | 23 ++++- infra/relay/src/http/Api.ts | 11 +++ .../src/connection/resolver.test.ts | 1 + .../src/relay/discovery.test.ts | 2 + .../client-runtime/src/relay/managedRelay.ts | 33 +++++++ .../src/relay/managedRelayState.test.ts | 1 + packages/contracts/src/relay.ts | 19 ++++ 9 files changed, 180 insertions(+), 29 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index dc4f1ed969e..7ca56d2fe5e 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -7,6 +7,7 @@ import { AppState, Platform } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; import { type RelayDeviceRegistrationRequest, + type RelayAgentActivitySnapshotResponse, type RelayLiveActivityRegistrationRequest, } from "@t3tools/contracts/relay"; import { findErrorTraceId } from "@t3tools/client-runtime/errors"; @@ -434,6 +435,29 @@ function unregisterDeviceWithRelay(input: { }); } +function readAgentActivitySnapshot(): Effect.Effect< + RelayAgentActivitySnapshotResponse | null, + never, + ManagedRelay.ManagedRelayClient +> { + return Effect.gen(function* () { + if (!readRelayConfig()) return null; + const token = yield* relayToken("read-live-activity-registration-relay-token"); + if (!token) { + return null; + } + const client = yield* ManagedRelay.ManagedRelayClient; + return yield* client.getAgentActivitySnapshot({ clerkToken: token }); + }).pipe( + Effect.catch((error) => + Effect.sync(() => { + logRegistrationError("agent activity snapshot read failed", error); + return null; + }), + ), + ); +} + function registerLiveActivityWithRelay( body: RelayLiveActivityRegistrationRequest, ): Effect.Effect { @@ -911,9 +935,10 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< // Activities are only ever created here, in the foreground, where the // update token can be observed and registered immediately — the relay // never remote-starts one (background push-to-start wakes proved too - // unreliable to hand the token over). Opening the app arms the card; the - // relay then drives its content, including agents started from other - // machines while the phone stays locked. + // unreliable to hand the token over). Arming is conditional: the relay is + // asked what the card would show first, so an idle open never creates an + // empty lock-screen card, and an armed card is born with the real + // aggregate instead of a placeholder. if (activities.length === 0) { const preferences = yield* Effect.tryPromise({ try: () => loadPreferences(), @@ -924,31 +949,37 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< }), }).pipe(Effect.orElseSucceed(() => null)); if (preferences?.liveActivitiesEnabled) { - const primed = yield* Effect.try({ - try: () => - AgentActivity.start({ - title: "T3 Code", - subtitle: "Waiting for agents", - activeCount: 0, - updatedAt: new Date(Date.now()).toISOString(), - activities: [], - }), - catch: (cause) => - new AgentAwarenessOperationError({ - operation: "prime-live-activity", - cause, - }), - }).pipe( - Effect.catch((error) => - Effect.sync(() => { - logRegistrationError("live activity priming failed", error); - return null; - }), - ), - ); - if (primed) { - logRegistrationDebug("live activity card primed", {}); - activities = [primed]; + const snapshot = yield* readAgentActivitySnapshot(); + if (snapshot?.aggregate && snapshot.aggregate.activeCount > 0) { + const aggregate = snapshot.aggregate; + const primed = yield* Effect.try({ + try: () => + AgentActivity.start({ + title: aggregate.title, + subtitle: aggregate.subtitle, + activeCount: aggregate.activeCount, + updatedAt: aggregate.updatedAt, + activities: aggregate.activities, + }), + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "prime-live-activity", + cause, + }), + }).pipe( + Effect.catch((error) => + Effect.sync(() => { + logRegistrationError("live activity priming failed", error); + return null; + }), + ), + ); + if (primed) { + logRegistrationDebug("live activity card primed", { + activeCount: aggregate.activeCount, + }); + activities = [primed]; + } } } } diff --git a/infra/relay/src/agentActivity/MobileRegistrations.test.ts b/infra/relay/src/agentActivity/MobileRegistrations.test.ts index c18f1a18786..1f1256e3567 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.test.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts @@ -213,6 +213,7 @@ describe("MobileRegistrations", () => { }), ), Layer.succeed(LiveActivities.LiveActivities, makeLiveActivities()), + Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()), Layer.succeed( AgentActivityPublisher.AgentActivityPublisher, makeAgentActivityPublisher({ @@ -255,6 +256,7 @@ describe("MobileRegistrations", () => { Layer.mergeAll( Layer.succeed(Devices.Devices, makeDevices()), Layer.succeed(LiveActivities.LiveActivities, makeLiveActivities()), + Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()), Layer.succeed( AgentActivityPublisher.AgentActivityPublisher, makeAgentActivityPublisher({ @@ -306,6 +308,7 @@ describe("MobileRegistrations", () => { }), ), Layer.succeed(LiveActivities.LiveActivities, makeLiveActivities()), + Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()), Layer.succeed( AgentActivityPublisher.AgentActivityPublisher, makeAgentActivityPublisher(), @@ -350,6 +353,7 @@ describe("MobileRegistrations", () => { Layer.provide( Layer.mergeAll( Layer.succeed(Devices.Devices, makeDevices()), + Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()), Layer.succeed( LiveActivities.LiveActivities, makeLiveActivities({ @@ -387,6 +391,34 @@ describe("MobileRegistrations", () => { }); }); + it.effect("returns the current aggregate for the app's arming decision", () => { + return Effect.gen(function* () { + const registrations = yield* MobileRegistrations.MobileRegistrations; + const snapshot = yield* registrations.getAgentActivitySnapshot({ userId: "dev:julius" }); + + expect(snapshot.aggregate).toMatchObject({ + activeCount: 1, + activities: [{ threadId: "thread-1", phase: "running" }], + }); + }).pipe( + Effect.provide( + MobileRegistrations.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(Devices.Devices, makeDevices()), + Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()), + Layer.succeed(LiveActivities.LiveActivities, makeLiveActivities()), + Layer.succeed( + AgentActivityPublisher.AgentActivityPublisher, + makeAgentActivityPublisher(), + ), + ), + ), + ), + ), + ); + }); + it.effect( "does not remotely start a Live Activity when a device registers after work is already active", () => { diff --git a/infra/relay/src/agentActivity/MobileRegistrations.ts b/infra/relay/src/agentActivity/MobileRegistrations.ts index 0df0379cded..aee83e4b06d 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.ts @@ -1,11 +1,14 @@ import type { + RelayAgentActivitySnapshotResponse, RelayDeviceRegistrationRequest, RelayLiveActivityRegistrationRequest, } from "@t3tools/contracts/relay"; +import * as DateTime from "effect/DateTime"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as AgentActivityRows from "./AgentActivityRows.ts"; import * as Devices from "./Devices.ts"; import * as LiveActivities from "./LiveActivities.ts"; import * as AgentActivityPublisher from "./AgentActivityPublisher.ts"; @@ -13,7 +16,8 @@ import * as AgentActivityPublisher from "./AgentActivityPublisher.ts"; export type MobileRegistrationError = | Devices.DeviceRegistrationPersistenceError | Devices.DeviceUnregistrationPersistenceError - | LiveActivities.LiveActivityRegistrationPersistenceError; + | LiveActivities.LiveActivityRegistrationPersistenceError + | AgentActivityRows.AgentActivityRowListPersistenceError; export class MobileRegistrations extends Context.Service< MobileRegistrations, @@ -30,10 +34,14 @@ export class MobileRegistrations extends Context.Service< readonly userId: string; readonly deviceId: string; }) => Effect.Effect<{ readonly ok: true }, MobileRegistrationError>; + readonly getAgentActivitySnapshot: (input: { + readonly userId: string; + }) => Effect.Effect; } >()("t3code-relay/agentActivity/MobileRegistrations") {} export const make = Effect.gen(function* () { + const rows = yield* AgentActivityRows.AgentActivityRows; const devices = yield* Devices.Devices; const liveActivities = yield* LiveActivities.LiveActivities; const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; @@ -82,6 +90,19 @@ export const make = Effect.gen(function* () { return { ok: true as const }; }, ), + getAgentActivitySnapshot: Effect.fn("relay.mobile_registrations.get_agent_activity_snapshot")( + function* (input) { + const activeStates = yield* rows.listForUser({ userId: input.userId }); + const now = yield* DateTime.now; + return { + aggregate: AgentActivityPublisher.makeAggregateState({ + activeStates, + terminalState: null, + nowMs: now.epochMilliseconds, + }), + }; + }, + ), unregisterDevice: Effect.fn("relay.mobile_registrations.unregister_device")(function* (input) { yield* Effect.annotateCurrentSpan({ "relay.mobile.device_id": input.deviceId, diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index 87044efa9da..386f119802a 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -433,6 +433,17 @@ export const mobileApi = HttpApiBuilder.group( return yield* registrations.registerLiveActivity({ userId, payload }); }, mapRelayCommonApiErrors("invalid_dpop")), ) + .handle( + "getAgentActivitySnapshot", + Effect.fn("relay.api.mobile.getAgentActivitySnapshot")(function* () { + const { userId, token } = yield* RelayClientPrincipal; + const proofKeyThumbprint = yield* requireDpopPrincipalScope("mobile:registration"); + yield* requireDpopThumbprint(proofKeyThumbprint, { + expectedAccessToken: token, + }).pipe(Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs)); + return yield* registrations.getAgentActivitySnapshot({ userId }); + }, mapRelayCommonApiErrors("invalid_dpop")), + ) .handle( "unregisterDevice", Effect.fn("relay.api.mobile.unregisterDevice")(function* (args) { diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index b4d990fc844..d0375e55556 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -84,6 +84,7 @@ function relayClient( registerDevice: () => unsupported("registerDevice"), unregisterDevice: () => unsupported("unregisterDevice"), registerLiveActivity: () => unsupported("registerLiveActivity"), + getAgentActivitySnapshot: () => unsupported("getAgentActivitySnapshot"), resetTokenCache: Effect.void, }); } diff --git a/packages/client-runtime/src/relay/discovery.test.ts b/packages/client-runtime/src/relay/discovery.test.ts index 01886beabf6..66c27b8678a 100644 --- a/packages/client-runtime/src/relay/discovery.test.ts +++ b/packages/client-runtime/src/relay/discovery.test.ts @@ -112,6 +112,7 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () { registerDevice: () => Effect.die("unused"), unregisterDevice: () => Effect.die("unused"), registerLiveActivity: () => Effect.die("unused"), + getAgentActivitySnapshot: () => Effect.die("unused"), resetTokenCache: Effect.void, } satisfies ManagedRelay.ManagedRelayClient["Service"]); const connectivity = Connectivity.Connectivity.of({ @@ -273,6 +274,7 @@ describe("RelayEnvironmentDiscovery", () => { registerDevice: () => Effect.die("unused"), unregisterDevice: () => Effect.die("unused"), registerLiveActivity: () => Effect.die("unused"), + getAgentActivitySnapshot: () => Effect.die("unused"), resetTokenCache: Effect.void, } satisfies ManagedRelay.ManagedRelayClient["Service"]); const layer = RelayEnvironmentDiscovery.layer.pipe( diff --git a/packages/client-runtime/src/relay/managedRelay.ts b/packages/client-runtime/src/relay/managedRelay.ts index 383fcf582c8..06769a4372f 100644 --- a/packages/client-runtime/src/relay/managedRelay.ts +++ b/packages/client-runtime/src/relay/managedRelay.ts @@ -17,11 +17,13 @@ import { RelayExchangeDpopAccessTokenEndpoint, RelayGetEnvironmentStatusEndpoint, RelayJwtSubjectTokenType, + type RelayAgentActivitySnapshotResponse, type RelayLiveActivityRegistrationRequest, RelayMobileRegistrationScope, type RelayOkResponse, type RelayPublicClientId, RelayRegisterDeviceEndpoint, + RelayAgentActivitySnapshotEndpoint, RelayRegisterLiveActivityEndpoint, RelayProtectedError, type RelayProtectedError as RelayProtectedErrorType, @@ -92,6 +94,7 @@ export const ManagedRelayRequestAction = Schema.Literals([ "register relay mobile device", "unregister relay mobile device", "register relay live activity", + "read relay agent activity snapshot", ]); export type ManagedRelayRequestAction = typeof ManagedRelayRequestAction.Type; @@ -107,6 +110,7 @@ export const ManagedRelayRequestActivity = Schema.Literals([ "Relay mobile device registration", "Relay mobile device unregistration", "Relay Live Activity registration", + "Relay agent activity snapshot", ]); export type ManagedRelayRequestActivity = typeof ManagedRelayRequestActivity.Type; @@ -290,6 +294,9 @@ export class ManagedRelayClient extends Context.Service< readonly clerkToken: string; readonly payload: RelayLiveActivityRegistrationRequest; }) => Effect.Effect; + readonly getAgentActivitySnapshot: (input: { + readonly clerkToken: string; + }) => Effect.Effect; readonly resetTokenCache: Effect.Effect; } >()("@t3tools/client-runtime/relay/managedRelay/ManagedRelayClient") {} @@ -410,6 +417,7 @@ function disabledManagedRelayClient(relayUrl: string): ManagedRelayClient["Servi registerDevice: unavailable("clientRuntime.managedRelay.registerDevice"), unregisterDevice: unavailable("clientRuntime.managedRelay.unregisterDevice"), registerLiveActivity: unavailable("clientRuntime.managedRelay.registerLiveActivity"), + getAgentActivitySnapshot: unavailable("clientRuntime.managedRelay.getAgentActivitySnapshot"), resetTokenCache: Effect.void.pipe( Effect.withSpan("clientRuntime.managedRelay.resetTokenCache"), ), @@ -457,6 +465,10 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( method: RelayUnregisterDeviceEndpoint.method, url: urlBuilder.mobile.unregisterDevice({ params: { deviceId } }), }), + getAgentActivitySnapshot: (): DpopProofTarget => ({ + method: RelayAgentActivitySnapshotEndpoint.method, + url: urlBuilder.mobile.getAgentActivitySnapshot(), + }), registerLiveActivity: (): DpopProofTarget => ({ method: RelayRegisterLiveActivityEndpoint.method, url: urlBuilder.mobile.registerLiveActivity(), @@ -856,6 +868,27 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( Effect.withSpan("clientRuntime.managedRelay.unregisterDevice"), withRelayClientTracing, ), + getAgentActivitySnapshot: Effect.fnUntraced( + function* (input) { + return yield* mobileRegistrationRequest( + { + clerkToken: input.clerkToken, + target: dpopProofTargets.getAgentActivitySnapshot(), + }, + (authorization) => + client.mobile + .getAgentActivitySnapshot({ + headers: dpopHeaders(authorization), + }) + .pipe( + Effect.mapError(relayRequestError("read relay agent activity snapshot")), + timeoutRelayRequest("Relay agent activity snapshot"), + ), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.getAgentActivitySnapshot"), + withRelayClientTracing, + ), registerLiveActivity: Effect.fnUntraced( function* (input) { return yield* mobileRegistrationRequest( diff --git a/packages/client-runtime/src/relay/managedRelayState.test.ts b/packages/client-runtime/src/relay/managedRelayState.test.ts index 49400d32aef..905bbcc819c 100644 --- a/packages/client-runtime/src/relay/managedRelayState.test.ts +++ b/packages/client-runtime/src/relay/managedRelayState.test.ts @@ -83,6 +83,7 @@ function createManager( registerDevice: () => Effect.die("unused"), unregisterDevice: () => Effect.die("unused"), registerLiveActivity: () => Effect.die("unused"), + getAgentActivitySnapshot: () => Effect.die("unused"), resetTokenCache: Effect.void, ...overrides, }); diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index f1c4cd12cf3..881f922ae7e 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -873,6 +873,24 @@ export const RelayRegisterLiveActivityEndpoint = HttpApiEndpoint.post( }, ).annotate(OpenApi.Summary, "Register a Live Activity push token"); +export const RelayAgentActivitySnapshotResponse = Schema.Struct({ + aggregate: Schema.NullOr(RelayAgentActivityAggregateState), +}); +export type RelayAgentActivitySnapshotResponse = typeof RelayAgentActivitySnapshotResponse.Type; + +// Lets the app decide whether arming a Live Activity is worthwhile before +// creating one (no empty lock-screen card when nothing is running) and seed +// the card with the real aggregate instead of a placeholder. +export const RelayAgentActivitySnapshotEndpoint = HttpApiEndpoint.get( + "getAgentActivitySnapshot", + "/v1/mobile/agent-activity", + { + headers: RelayDpopRequestHeaders, + success: RelayAgentActivitySnapshotResponse, + error: RelayAuthAndInternalErrors, + }, +).annotate(OpenApi.Summary, "Read the current Live Activity aggregate"); + export const RelayUnregisterDeviceEndpoint = HttpApiEndpoint.delete( "unregisterDevice", "/v1/mobile/devices/:deviceId", @@ -888,6 +906,7 @@ export const RelayMobileGroup = HttpApiGroup.make("mobile") .add( RelayRegisterDeviceEndpoint, RelayRegisterLiveActivityEndpoint, + RelayAgentActivitySnapshotEndpoint, RelayUnregisterDeviceEndpoint, ) .annotate(OpenApi.Description, "Mobile push-notification and Live Activity registration.") From 558a8113bdfdac51eeffdaf7954c892bd732dc82 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Jul 2026 22:00:46 -0700 Subject: [PATCH 16/26] Never render an empty Live Activity: show Done, end when truly empty, arm on send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returning to the app after work finished repainted the armed card as an empty "0 agents" state: the registration replay computed a null aggregate (only terminal rows remained) and the idle-state mapping turned that into an empty redraw. An armed card now always shows content or does not exist: - With no live work, the aggregate keeps rendering recently finished threads as Done/Failed (no alert — the newly-terminal rules key off the previously delivered aggregate, so replays repaint without buzzing). - A truly empty aggregate (terminal rows aged out or removed) ends the card instead of idling it. Re-arming is cheap: the app arms on any open with content, so the idle-keepalive no longer pays for itself. - Starting work from the phone arms the card immediately: thread creation and composer sends start an activity seeded with the new thread while the app is foregrounded, and the relay's replay repaints it with the authoritative aggregate seconds later. Previously phone-started work never armed a card at all (priming only ran on app open, which had found nothing running). Co-Authored-By: Claude Fable 5 --- .../agent-awareness/remoteRegistration.ts | 48 +++++++++++++++++++ .../features/threads/NewTaskDraftScreen.tsx | 8 ++++ .../src/features/threads/ThreadComposer.tsx | 15 +++++- .../AgentActivityPublisher.test.ts | 18 ++++++- .../agentActivity/AgentActivityPublisher.ts | 26 ++++++++-- .../src/agentActivity/ApnsDeliveries.test.ts | 17 +++---- .../relay/src/agentActivity/ApnsDeliveries.ts | 43 ++++++----------- 7 files changed, 128 insertions(+), 47 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 7ca56d2fe5e..ab14fba4d8e 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -435,6 +435,54 @@ function unregisterDeviceWithRelay(input: { }); } +// Arms the lock-screen card the moment the user starts agent work from this +// phone, while the app is still foregrounded and the fresh activity's token +// can be registered immediately. The seeded row is a best-effort placeholder; +// the relay's registration replay repaints it with the authoritative +// aggregate within seconds. No-ops when a card is already armed. +export function armAgentAwarenessLiveActivityForLocalWork(input: { + readonly threadTitle: string; + readonly projectTitle: string; +}): void { + if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) { + return; + } + try { + if (AgentActivity.getInstances().length > 0) { + return; + } + const nowIso = new Date(Date.now()).toISOString(); + const activity = AgentActivity.start({ + title: "T3 Code", + subtitle: "Agent work in progress", + activeCount: 1, + updatedAt: nowIso, + activities: [ + { + environmentId: "", + threadId: "", + projectTitle: input.projectTitle, + threadTitle: input.threadTitle, + modelTitle: "", + phase: "starting", + status: "Connecting", + updatedAt: nowIso, + deepLink: "/", + }, + ], + }); + logRegistrationDebug("live activity card armed for local work", { + threadTitle: input.threadTitle, + }); + runRegistrationInBackground( + registerLiveActivityPushToken({ activity }).pipe(Effect.asVoid), + "live activity arming after local task start failed", + ); + } catch (error) { + logRegistrationError("live activity arming failed", error); + } +} + function readAgentActivitySnapshot(): Effect.Effect< RelayAgentActivitySnapshotResponse | null, never, diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 9dfec046b53..42da29d709c 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -34,6 +34,8 @@ import { import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import { getComposerDraftSnapshot } from "../../state/use-composer-drafts"; import { useProjects } from "../../state/entities"; +import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; +import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox"; import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; @@ -548,6 +550,12 @@ export function NewTaskDraftScreen(props: { }); flow.setSubmitting(false); + if (result._tag !== "Failure") { + armAgentAwarenessLiveActivityForLocalWork({ + threadTitle: deriveThreadTitleFromPrompt(initialMessageText), + projectTitle: selectedProject.title, + }); + } if (result._tag === "Failure") { if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index bc107b0c3d2..3192e47667b 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -27,6 +27,7 @@ import { import ImageViewing from "react-native-image-viewing"; import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; import { useThemeColor } from "../../lib/useThemeColor"; +import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { AppText as Text } from "../../components/AppText"; @@ -499,12 +500,24 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); if (inFlightThreadIdsRef.current.has(threadKey)) return; inFlightThreadIdsRef.current.add(threadKey); + // Sending a prompt starts agent work: arm the lock-screen card now, while + // the app is foregrounded and the activity token can be registered. + armAgentAwarenessLiveActivityForLocalWork({ + threadTitle: props.selectedThread.title, + projectTitle: props.environmentLabel ?? "T3 Code", + }); try { await onSendMessage(); } finally { inFlightThreadIdsRef.current.delete(threadKey); } - }, [onSendMessage, props.environmentId, props.selectedThread.id]); + }, [ + onSendMessage, + props.environmentId, + props.environmentLabel, + props.selectedThread.id, + props.selectedThread.title, + ]); const handleCommandSelect = useCallback( (item: ComposerCommandItem) => { if (!composerTrigger) return; diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts index 69304e0dd1c..d46db985882 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts @@ -747,15 +747,29 @@ describe("makeAggregateState", () => { expect(aggregate?.activities).toMatchObject([{ threadId: "thread-active" }]); }); - it("never resurrects lingering terminal rows once nothing is active", () => { + it("keeps showing recently finished work when nothing is active", () => { const lingeringCompleted: RelayAgentActivityState = { ...state, phase: "completed", updatedAt: "1970-01-01T00:59:00.000Z", }; + const aggregate = AgentActivityPublisher.makeAggregateState({ + activeStates: [lingeringCompleted], + terminalState: null, + nowMs: hourMs, + }); + + // An armed card never renders an empty state: recently finished threads + // keep Done content on it, and once they age out the aggregate becomes + // null and the delivery layer ends the card. + expect(aggregate).toMatchObject({ + activeCount: 0, + subtitle: "Agent work completed", + activities: [{ phase: "completed", status: "Done" }], + }); expect( AgentActivityPublisher.makeAggregateState({ - activeStates: [lingeringCompleted], + activeStates: [{ ...lingeringCompleted, updatedAt: "1970-01-01T00:50:00.000Z" }], terminalState: null, nowMs: hourMs, }), diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts index 91200fa7b1c..5b739018ff0 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts @@ -253,10 +253,28 @@ export function makeAggregateState(input: { (state) => !isTerminalPhase(state) && !isExpiredAgentActivityState(state, input.nowMs), ); if (activeStates.length === 0) { - // Only the just-published terminal event ends the activity with a Done - // aggregate; lingering terminal rows alone never do, so a replay after a - // fresh registration doesn't resurrect a finished thread. - return input.terminalState === null ? null : terminalAggregateState(input.terminalState); + if (input.terminalState !== null) { + return terminalAggregateState(input.terminalState); + } + // With no live work, recently finished threads keep the card showing + // Done/Failed content (an armed card never renders an empty state). The + // newly-terminal alert rules key off the previously delivered aggregate, + // so replays repaint this without buzzing. Once the terminal rows age + // out, the aggregate is null and the delivery layer ends the card. + const recentTerminal = input.activeStates + .filter((state) => isRecentTerminalState(state, input.nowMs)) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + const newest = recentTerminal[0]; + if (!newest) { + return null; + } + return sanitizeAgentActivityAggregateState({ + title: "T3 Code", + subtitle: newest.phase === "failed" ? "Agent work failed" : "Agent work completed", + activeCount: 0, + updatedAt: newest.updatedAt, + activities: recentTerminal.slice(0, 3).map(aggregateRowForState), + }); } // Recently finished threads ride along after the active ones (display slots // permitting) so a completion is visible as Done/Failed instead of the row diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index 71cd406d3cb..058f5405fb6 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -277,7 +277,7 @@ describe("ApnsDeliveries", () => { }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs, queuedStarts }))); }); - it.effect("updates an armed card to the idle state when nothing is running", () => { + it.effect("ends the armed card when nothing remains to show", () => { const attempts: Array = []; const queuedJobs: Array = []; @@ -289,23 +289,18 @@ describe("ApnsDeliveries", () => { nowMs: 5_000, }); - // The card is user-armed: tearing it down on an empty aggregate would - // force a remote start (which no longer exists) the next time work - // begins, so it idles instead. - expect(result?.kind).toBe("live_activity_update"); + // An armed card always shows content (live or recently finished work); + // once the aggregate is empty the card ends rather than rendering an + // empty state. The app re-arms on the next open with content. + expect(result?.kind).toBe("live_activity_end"); expect(result?.ok).toBe(true); expect(queuedJobs).toMatchObject([ { payload: { - kind: "live_activity_update", + kind: "live_activity_end", target: { token: "activity-token", }, - aggregate: { - activeCount: 0, - subtitle: "Waiting for agents", - activities: [], - }, }, }, ]); diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index a575b24515c..d7d85f7be8e 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -329,25 +329,6 @@ 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. -// The lock-screen card shown when the user's card is armed but nothing is -// running: activities are created only by the app in the foreground (never -// remotely), so an empty aggregate means "show idle", not "tear the card -// down" — ending it would force an unreliable background token handoff the -// next time work starts. -export function idleAggregateState(nowMs: number): RelayAgentActivityAggregateState { - return { - title: "T3 Code", - subtitle: "Waiting for agents", - activeCount: 0, - updatedAt: DateTime.formatIso(DateTime.makeUnsafe(nowMs)), - activities: [], - }; -} - -function isIdleAggregate(aggregate: RelayAgentActivityAggregateState): boolean { - return aggregate.activeCount === 0 && aggregate.activities.length === 0; -} - function chooseLiveActivityDelivery(input: { readonly target: LiveActivities.TargetRow; readonly aggregate: RelayAgentActivityAggregateState | null; @@ -371,17 +352,21 @@ function chooseLiveActivityDelivery(input: { if (!input.target.activity_push_token) { return null; } - const nextAggregate = input.aggregate ?? idleAggregateState(input.nowMs); - const previousAggregate = parseAggregate(input.target.last_aggregate_json); - // An idle card that is already idle needs no redraw; updatedAt alone would - // otherwise defeat the equality check inside shouldUpdateLiveActivity. - if ( - isIdleAggregate(nextAggregate) && - previousAggregate !== null && - isIdleAggregate(previousAggregate) - ) { - return "suppressed"; + // An armed card always shows content: live agents, or recently finished + // ones (the publisher keeps Done/Failed rows in the aggregate for a + // while). A null aggregate means there is truly nothing left to show, so + // the card ends — arming is cheap now that the app re-arms on any open + // with content. + if (input.aggregate === null) { + return { + kind: "live_activity_end", + token: input.target.activity_push_token, + aggregate: null, + alert: null, + }; } + const nextAggregate = input.aggregate; + const previousAggregate = parseAggregate(input.target.last_aggregate_json); return shouldUpdateLiveActivity({ previousAggregate, nextAggregate, From 41296c53b0192dcab9cc72dd67cfbdcdb1032d4d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Jul 2026 22:06:17 -0700 Subject: [PATCH 17/26] Close the arm-on-create races: start before awaiting, never start twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two live repros from phone-created threads: - Creating a thread and immediately backgrounding produced no card: the arm call ran after `await createProjectThread`, and by then the app had left the foreground, where ActivityKit rejects starts. Arm at submit time instead — the seed content is already known — and a failed creation self-corrects because the token registration's replay finds no work and ends the card. - Two cards at once: the app-open reconcile checks for existing activities, then yields for the relay snapshot request; an arm-on-send landing inside that window made both paths see "no card" and both start one. The reconcile now re-checks instances after the snapshot await and adopts the meanwhile-armed card, and any duplicate that does slip through gets ended (keep-first) on the next reconcile pass, which also cleans up cards stranded by earlier builds. Co-Authored-By: Claude Fable 5 --- .../agent-awareness/remoteRegistration.ts | 21 ++++++++++++++++++- .../features/threads/NewTaskDraftScreen.tsx | 14 +++++++------ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index ab14fba4d8e..186160bb491 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -980,6 +980,17 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< ), ); + // The relay tracks exactly one card per device; if concurrent arming ever + // produced extras, end them so only one keeps receiving updates. + if (activities.length > 1) { + for (const extra of activities.slice(1)) { + extra.end("immediate").catch((error: unknown) => { + logRegistrationError("duplicate live activity cleanup failed", error); + }); + } + activities = activities.slice(0, 1); + } + // Activities are only ever created here, in the foreground, where the // update token can be observed and registered immediately — the relay // never remote-starts one (background push-to-start wakes proved too @@ -998,7 +1009,15 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< }).pipe(Effect.orElseSucceed(() => null)); if (preferences?.liveActivitiesEnabled) { const snapshot = yield* readAgentActivitySnapshot(); - if (snapshot?.aggregate && snapshot.aggregate.activeCount > 0) { + // The snapshot request yields; an arm-on-send may have created the + // card in the meantime. Re-check so two cards are never started. + const armedMeanwhile = yield* Effect.try({ + try: () => AgentActivity.getInstances(), + catch: () => [] as ReadonlyArray>, + }).pipe(Effect.orElseSucceed(() => [] as ReadonlyArray>)); + if (armedMeanwhile.length > 0) { + activities = [...armedMeanwhile]; + } else if (snapshot?.aggregate && snapshot.aggregate.activeCount > 0) { const aggregate = snapshot.aggregate; const primed = yield* Effect.try({ try: () => diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 42da29d709c..935776eac56 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -526,6 +526,14 @@ export function NewTaskDraftScreen(props: { } flow.setSubmitting(true); + // Arm the lock-screen card before the async thread creation: backgrounding + // the app right after tapping submit would otherwise reject the foreground + // -only Activity start. If creation fails, the token registration's replay + // finds no work and ends the card within seconds. + armAgentAwarenessLiveActivityForLocalWork({ + threadTitle: deriveThreadTitleFromPrompt(initialMessageText), + projectTitle: selectedProject.title, + }); const result = await createProjectThread({ project: selectedProject, modelSelection, @@ -550,12 +558,6 @@ export function NewTaskDraftScreen(props: { }); flow.setSubmitting(false); - if (result._tag !== "Failure") { - armAgentAwarenessLiveActivityForLocalWork({ - threadTitle: deriveThreadTitleFromPrompt(initialMessageText), - projectTitle: selectedProject.title, - }); - } if (result._tag === "Failure") { if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); From 617a88cf169eaeabf8999246fc84e77302f79316 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Jul 2026 22:19:54 -0700 Subject: [PATCH 18/26] Confirm tombstones before publishing them; dismiss contentless ends fast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live repro: answering an agent's question left the card frozen on "Input". The publish stream showed why — the thread's resolved awareness state flapped to null during the answer handoff (running, deleted, running, deleted), and each null publish removed the thread row, emptied the aggregate, and ended the armed card mid-conversation. Under push-to-start the next running publish would have resurrected a card; with app-armed activities a spurious tombstone is fatal until the next app open. - The env server no longer tombstones a thread that was live at its last publish: the null resolution is deferred five seconds and re-resolved, publishing only if it still holds. Genuine deletions propagate a few seconds later; transient handoff gaps and shell lookup races never fire. - A relay end with no final content-state used to leave the card's last frame (that stale "Input") on the lock screen for the full five-minute dismissal window; contentless ends now dismiss in seconds. Co-Authored-By: Claude Fable 5 --- apps/server/src/relay/AgentAwarenessRelay.ts | 41 ++++++++++++++++++- .../src/agentActivity/ApnsClient.test.ts | 16 ++++++++ infra/relay/src/agentActivity/ApnsClient.ts | 7 +++- 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 4e036e3ea0e..eccfe147e1d 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -306,7 +306,14 @@ export const make = Effect.gen(function* () { transformClient: relayEnvironmentClient(relayConfig.environmentCredential), }).pipe(Effect.provide(FetchHttpClient.layer)); - const publishThreadUnsafe = Effect.fn("publishThreadUnsafe")(function* (threadId: ThreadId) { + // Assigned after publishThreadUnsafe below; indirection keeps the deferred + // tombstone confirmation from making the definition self-referential. + let confirmTombstoneLater: (threadId: ThreadId) => Effect.Effect = () => Effect.void; + + const publishThreadUnsafe = Effect.fn("publishThreadUnsafe")(function* ( + threadId: ThreadId, + options?: { readonly confirmTombstone?: boolean }, + ) { const publishAgentActivity = yield* readPublishAgentActivityEnabled.pipe( Effect.orElseSucceed(() => false), ); @@ -390,6 +397,26 @@ export const make = Effect.gen(function* () { return; } + if ( + snapshot.state === null && + options?.confirmTombstone !== true && + publishedStateByThread.get(threadId) !== agentAwarenessPublishIdentity(null) + ) { + // A live thread that suddenly resolves to null is usually a transient + // handoff gap (an answered prompt whose next turn has not started yet, + // a shell lookup racing a write), and publishing the tombstone + // immediately deletes the thread from the lock-screen card mid- + // conversation. Defer, re-resolve, and only tombstone if it holds; + // genuinely deleted threads still propagate a few seconds later. + yield* Effect.logInfo("agent activity tombstone deferred pending confirmation", { + environmentId, + threadId, + reason: snapshot.reason, + }); + yield* Effect.forkDetach(confirmTombstoneLater(threadId)); + return; + } + if (snapshot.reason === "thread-not-found") { yield* Effect.logDebug("publishing agent activity tombstone; thread not found", { environmentId, @@ -415,6 +442,18 @@ export const make = Effect.gen(function* () { }); }); + confirmTombstoneLater = (threadId) => + publishThreadUnsafe(threadId, { confirmTombstone: true }).pipe( + Effect.delay("5 seconds"), + Effect.catchCause((cause) => + Effect.logWarning("deferred agent activity tombstone failed", { + threadId, + cause: Cause.pretty(cause), + }), + ), + Effect.asVoid, + ); + const publishThread: AgentAwarenessRelay["Service"]["publishThread"] = (threadId) => publishThreadUnsafe(threadId).pipe( Effect.catchCause((cause) => { diff --git a/infra/relay/src/agentActivity/ApnsClient.test.ts b/infra/relay/src/agentActivity/ApnsClient.test.ts index f9e0cb7aa26..9f987753351 100644 --- a/infra/relay/src/agentActivity/ApnsClient.test.ts +++ b/infra/relay/src/agentActivity/ApnsClient.test.ts @@ -143,6 +143,22 @@ describe("ApnsClient", () => { "dismissal-date": 300, }, }); + + // Without final content the card would freeze on its previous state; + // contentless ends dismiss quickly instead. + const contentless = apns.makeLiveActivityRequest({ + event: "end", + token: "token", + state: null, + nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), + nowIso: DateTime.formatIso(now), + }); + expect(contentless.payload).toMatchObject({ + aps: { + event: "end", + "dismissal-date": 15, + }, + }); }).pipe(Effect.provide(TestLayer)), ); diff --git a/infra/relay/src/agentActivity/ApnsClient.ts b/infra/relay/src/agentActivity/ApnsClient.ts index fb5fa513b94..c401eb6c2e4 100644 --- a/infra/relay/src/agentActivity/ApnsClient.ts +++ b/infra/relay/src/agentActivity/ApnsClient.ts @@ -21,6 +21,10 @@ const LIVE_ACTIVITY_NAME = "AgentActivity"; // environment can look alive. const STALE_AFTER_SECONDS = 10 * 60; const DISMISS_AFTER_SECONDS = 5 * 60; +// An end without a final content-state leaves whatever the card last showed +// frozen on the lock screen until dismissal — get it off quickly instead of +// parading stale state for the full window. +const CONTENTLESS_DISMISS_AFTER_SECONDS = 15; const ApnsLiveActivityEventSchema = Schema.Literals(["start", "update", "end"]); export type ApnsLiveActivityEvent = typeof ApnsLiveActivityEventSchema.Type; @@ -130,7 +134,8 @@ function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveA event: "end", ...(input.state ? { "content-state": contentState(input.state) } : {}), ...(input.alert ? liveActivityAlertPayload(input.alert) : {}), - "dismissal-date": timestamp + DISMISS_AFTER_SECONDS, + "dismissal-date": + timestamp + (input.state ? DISMISS_AFTER_SECONDS : CONTENTLESS_DISMISS_AFTER_SECONDS), }, }, }; From fd824692669694fab07b3074871f44e7f1d7e8c0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Jul 2026 22:40:24 -0700 Subject: [PATCH 19/26] Give freshly armed Live Activities a grace window before end-on-empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arming on send registers the card's token immediately, and the registration replay can reach the relay before the environment's first publish for the brand-new thread. With end-on-empty, that replay saw an empty aggregate, ended the seconds-old card, and retired its token — so the running/input updates that arrived moments later had no address and the card sat orphaned at its seed "Connecting" content (observed live: card armed and ended at 05:36:38Z, one second before the thread's waiting_for_input row landed). An empty aggregate within two minutes of arming now delivers nothing, leaving the seed content in place for the state that is about to arrive; past the grace window, empty still ends the card as designed. Co-Authored-By: Claude Fable 5 --- .../src/agentActivity/ApnsDeliveries.test.ts | 11 ++++++++++- .../relay/src/agentActivity/ApnsDeliveries.ts | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index 058f5405fb6..a0a9a3ce693 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -283,11 +283,20 @@ describe("ApnsDeliveries", () => { return Effect.gen(function* () { const deliveries = yield* ApnsDeliveries.ApnsDeliveries; - const result = yield* deliveries.sendForTarget({ + // Within the freshly-armed grace window an empty aggregate delivers + // nothing: the environment's first publish may still be in flight. + const graced = yield* deliveries.sendForTarget({ target, aggregate: null, nowMs: 5_000, }); + expect(graced).toBeNull(); + + const result = yield* deliveries.sendForTarget({ + target, + aggregate: null, + nowMs: 5_000 + 3 * 60 * 1_000, + }); // An armed card always shows content (live or recently finished work); // once the aggregate is empty the card ends rather than rendering an diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index d7d85f7be8e..4d760bd6b16 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -43,6 +43,10 @@ import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts"; import { withSpanAttributes } from "../observability.ts"; const MIN_LIVE_ACTIVITY_UPDATE_INTERVAL_MS = 15_000; +// How long a just-armed card may sit with an empty aggregate before an end is +// warranted; covers the gap between arming on send and the environment's +// first publish reaching the relay. +const FRESHLY_ARMED_GRACE_MS = 2 * 60 * 1_000; const PERMANENT_APNS_TOKEN_REASONS = new Set([ "BadDeviceToken", "DeviceTokenNotForTopic", @@ -358,6 +362,20 @@ function chooseLiveActivityDelivery(input: { // the card ends — arming is cheap now that the app re-arms on any open // with content. if (input.aggregate === null) { + // Except right after arming: the app arms the card the moment the user + // starts work, and the token registration's replay can land before the + // environment's first publish for the brand-new thread. Ending here + // would retire the token and orphan the card at its seed content, so a + // freshly armed card keeps its seed until real state arrives. + const armedAtMs = Option.match( + input.target.remote_started_at === null + ? Option.none() + : DateTime.make(input.target.remote_started_at), + { onNone: () => null, onSome: (dt) => dt.epochMilliseconds }, + ); + if (armedAtMs !== null && input.nowMs - armedAtMs < FRESHLY_ARMED_GRACE_MS) { + return null; + } return { kind: "live_activity_end", token: input.target.activity_push_token, From ed4c4ec2453fcad052cb7381f65aaf7b08292f5e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Jul 2026 22:57:28 -0700 Subject: [PATCH 20/26] Project completed-at-bearing turns as completed, not nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stuck-at-Working card: the thread's publish stream showed running → deleted with no completed publish in between, and the tombstone debounce correctly confirmed the null five seconds later — the post-completion state resolves to no phase PERSISTENTLY. Session teardown settles still-running turns by session status, and that write can race turn.completed, leaving latestTurn.state at "interrupted" for a turn that actually finished. The awareness ladder mapped interrupted turns to null, so quick finish-then-teardown threads were tombstoned instead of published as completed: the row vanished, the empty aggregate fell into the freshly-armed grace window, and the card froze on its last state with no further publish to correct it. completedAt survives the state-column race: a turn with a completion timestamp finished, whatever the settling wrote. The ladder now projects those as completed — the tombstone confirm publishes Done instead of deleting the thread — while genuinely interrupted turns (no completedAt) still resolve to null. Co-Authored-By: Claude Fable 5 --- packages/shared/src/agentAwareness.test.ts | 29 ++++++++++++++++++++++ packages/shared/src/agentAwareness.ts | 9 +++++++ 2 files changed, 38 insertions(+) diff --git a/packages/shared/src/agentAwareness.test.ts b/packages/shared/src/agentAwareness.test.ts index 217e0016352..fbdf361cd0a 100644 --- a/packages/shared/src/agentAwareness.test.ts +++ b/packages/shared/src/agentAwareness.test.ts @@ -102,6 +102,35 @@ describe("projectThreadAwareness", () => { }); }); + it("projects completed turns as completed even when teardown settled them as interrupted", () => { + const finishedTurn = { + turnId: "turn-1" as TurnId, + state: "interrupted" as const, + requestedAt: NOW, + startedAt: NOW, + completedAt: NOW, + assistantMessageId: null, + }; + const state = projectThreadAwareness({ + environmentId: "env-1" as EnvironmentId, + project, + thread: thread({ latestTurn: finishedTurn }), + }); + + // Session teardown settles still-running turns by session status, and + // that write can race turn.completed; the completion timestamp is the + // durable signal. Without this the thread resolves to null persistently + // and gets tombstoned off the lock-screen card instead of showing Done. + expect(state?.phase).toBe("completed"); + + const trulyInterrupted = projectThreadAwareness({ + environmentId: "env-1" as EnvironmentId, + project, + thread: thread({ latestTurn: { ...finishedTurn, completedAt: null } }), + }); + expect(trulyInterrupted).toBeNull(); + }); + it("projects failures with the session error detail", () => { const state = projectThreadAwareness({ environmentId: "env-1" as EnvironmentId, diff --git a/packages/shared/src/agentAwareness.ts b/packages/shared/src/agentAwareness.ts index 6831e8ba301..2b8a79ea3ad 100644 --- a/packages/shared/src/agentAwareness.ts +++ b/packages/shared/src/agentAwareness.ts @@ -103,6 +103,15 @@ function resolveThreadAwarenessPhase( if (thread.latestTurn?.state === "completed") { return "completed"; } + // A turn that finished can still read as "interrupted" here: session + // teardown settles still-running turns by session status, and that write + // can race the turn.completed one. completedAt survives the race — a turn + // that has a completion timestamp finished, whatever the state column says. + // Without this, quick finish-then-teardown threads resolve to null + // persistently and get tombstoned instead of published as completed. + if (thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null) { + return "completed"; + } return null; } From 42ad1f19f47111da154f60253e0363ab9ca39bb2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Jul 2026 23:08:20 -0700 Subject: [PATCH 21/26] Log the shell behind every tombstone and dedupe pending confirmations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stuck-at-Working repro survives the completedAt ladder fix: the confirmed tombstone five seconds after turn completion still resolves null, so the settled shell holds some combination the static analysis keeps missing. Instead of guessing another layer deep, the tombstone paths now log the exact fields the phase ladder reads (session status, latest turn id/state/completedAt, pendings) both when deferring and when a confirmation actually publishes — one repro pins the writer. Also collapses the deferral storm visible at thread birth (six confirm fibers forked in 600ms): one pending confirmation per thread. Co-Authored-By: Claude Fable 5 --- apps/server/src/relay/AgentAwarenessRelay.ts | 47 +++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index eccfe147e1d..25e769cb4cc 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -203,6 +203,26 @@ const makePublishProof = Effect.fn("makePublishProof")(function* (input: { return yield* signRelayAgentActivityPublishProof({ privateKey: input.privateKey, payload }); }); +// Compact, log-safe view of the fields the awareness phase ladder reads. +export function describeThreadShellForAwareness( + thread: Option.Option, +): Record { + if (Option.isNone(thread)) { + return { found: false }; + } + const shell = thread.value; + return { + found: true, + sessionStatus: shell.session?.status ?? null, + sessionActiveTurnId: shell.session?.activeTurnId ?? null, + latestTurnId: shell.latestTurn?.turnId ?? null, + latestTurnState: shell.latestTurn?.state ?? null, + latestTurnCompletedAt: shell.latestTurn?.completedAt ?? null, + hasPendingApprovals: shell.hasPendingApprovals, + hasPendingUserInput: shell.hasPendingUserInput, + }; +} + export function resolveAgentAwarenessRelayPublishSnapshot(input: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; @@ -309,6 +329,10 @@ export const make = Effect.gen(function* () { // Assigned after publishThreadUnsafe below; indirection keeps the deferred // tombstone confirmation from making the definition self-referential. let confirmTombstoneLater: (threadId: ThreadId) => Effect.Effect = () => Effect.void; + // One pending confirmation per thread: birth/teardown flapping can resolve + // null on several consecutive events, and each would otherwise fork its own + // confirm fiber. + const pendingTombstoneConfirms = new Set(); const publishThreadUnsafe = Effect.fn("publishThreadUnsafe")(function* ( threadId: ThreadId, @@ -408,15 +432,36 @@ export const make = Effect.gen(function* () { // immediately deletes the thread from the lock-screen card mid- // conversation. Defer, re-resolve, and only tombstone if it holds; // genuinely deleted threads still propagate a few seconds later. + if (pendingTombstoneConfirms.has(threadId)) { + return; + } + pendingTombstoneConfirms.add(threadId); yield* Effect.logInfo("agent activity tombstone deferred pending confirmation", { environmentId, threadId, reason: snapshot.reason, + shell: describeThreadShellForAwareness(thread), }); - yield* Effect.forkDetach(confirmTombstoneLater(threadId)); + yield* Effect.forkDetach( + confirmTombstoneLater(threadId).pipe( + Effect.ensuring(Effect.sync(() => pendingTombstoneConfirms.delete(threadId))), + ), + ); return; } + if (snapshot.state === null && options?.confirmTombstone === true) { + // Publishing a confirmed tombstone deletes the thread from every armed + // card; log exactly what the shell resolved to so a wrongly-null + // projection is diagnosable from the server log alone. + yield* Effect.logInfo("agent activity tombstone confirmed", { + environmentId, + threadId, + reason: snapshot.reason, + shell: describeThreadShellForAwareness(thread), + }); + } + if (snapshot.reason === "thread-not-found") { yield* Effect.logDebug("publishing agent activity tombstone; thread not found", { environmentId, From 4e2a4750cd242d8fd80ab0857734efe7a5f66a0b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Jul 2026 23:25:33 -0700 Subject: [PATCH 22/26] Project ready/idle sessions as completed: the shell has no other signal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnostics from the stuck-at-Working repro finally exposed the data hole: the confirmed tombstone's shell showed sessionStatus "ready" with latestTurn entirely absent. Threads whose turns produce no checkpoint never get a projection_turns row, and thread.session-set clears latest_turn_id the moment the session settles (activeTurnId goes null) — so "completed" is unrepresentable through turns for quick Q&A threads. Their entire lifecycle rode on session status and pendings, and at completion nothing remained on the awareness ladder: tombstone instead of Done. A live session at "ready"/"idle" with nothing pending and nothing running now projects as completed. This intentionally diverges from the web sidebar, which resolves the same null but renders it as "no pill" — a fine default in a list, while the publisher's null means "remove from the lock-screen card". (The sidebar's own Completed pill needs latestTurn.completedAt and silently can't fire for these threads either; persisting turn rows for checkpoint-less turns is the deeper future fix for both surfaces.) Co-Authored-By: Claude Fable 5 --- packages/shared/src/agentAwareness.test.ts | 23 ++++++++++++++++++++++ packages/shared/src/agentAwareness.ts | 9 +++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/shared/src/agentAwareness.test.ts b/packages/shared/src/agentAwareness.test.ts index fbdf361cd0a..28e07c04e6f 100644 --- a/packages/shared/src/agentAwareness.test.ts +++ b/packages/shared/src/agentAwareness.test.ts @@ -131,6 +131,29 @@ describe("projectThreadAwareness", () => { expect(trulyInterrupted).toBeNull(); }); + it("projects ready sessions with no materialized turn as completed", () => { + // Quick threads without code changes never get a checkpoint, so the SQL + // shell has no latestTurn row and latest_turn_id is cleared when the + // session settles; the ready session is the only completion signal left. + const state = projectThreadAwareness({ + environmentId: "env-1" as EnvironmentId, + project, + thread: thread({ + session: { + threadId: "thread-1" as ThreadId, + status: "ready", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }, + }), + }); + + expect(state?.phase).toBe("completed"); + }); + it("projects failures with the session error detail", () => { const state = projectThreadAwareness({ environmentId: "env-1" as EnvironmentId, diff --git a/packages/shared/src/agentAwareness.ts b/packages/shared/src/agentAwareness.ts index 2b8a79ea3ad..248b983cd4f 100644 --- a/packages/shared/src/agentAwareness.ts +++ b/packages/shared/src/agentAwareness.ts @@ -112,6 +112,15 @@ function resolveThreadAwarenessPhase( if (thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null) { return "completed"; } + // Threads whose turns never produce a checkpoint (no code changes) have no + // materialized latestTurn in the shell at all, and the session-set + // projection clears latest_turn_id the moment the session settles. The + // session status is then the only surviving completion signal: a live + // session at "ready"/"idle" with nothing pending and nothing running means + // the agent finished and is waiting for the next prompt — Done. + if (thread.session?.status === "ready" || thread.session?.status === "idle") { + return "completed"; + } return null; } From 387d068e6c5cbc388266a9ddc28e85f33c224042 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Jul 2026 23:33:15 -0700 Subject: [PATCH 23/26] Suppress spurious Done at thread birth and on replayed completions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full lifecycle finally works end to end, with one wart: a duplicate Done push notification fired one second after thread creation. Sessions boot at "ready" before their first turn starts, so the new ready-means-completed projection produced a momentary completed state at birth — and since the freshly armed card's token wasn't registered yet, it fell through to the notification channel. Server restarts had the same shape at scale: the startup snapshot republished completed for every recently finished thread, each queuing a push notification. - Env server: completed as a thread's FIRST published state defers and re-resolves like a tombstone — at birth the confirm sees running and completed never publishes; genuinely at-rest threads still publish five seconds later. Fresh completions (previous state was live) are unaffected, keeping the buzz immediate. - Relay: terminal push notifications only fire when the completion is fresh (row updated within two minutes), so replays and restart snapshots repaint state without ringing the device. Co-Authored-By: Claude Fable 5 --- apps/server/src/relay/AgentAwarenessRelay.ts | 28 +++++++++++++++++++ .../relay/src/agentActivity/ApnsDeliveries.ts | 22 ++++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 25e769cb4cc..07527ac2c58 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -421,6 +421,34 @@ export const make = Effect.gen(function* () { return; } + // A brand-new session boots at "ready" before its first turn starts, which + // projects as completed for an instant; publishing that sends a spurious + // Done notification at thread birth. Completed as the thread's FIRST + // published state gets the same defer-and-confirm treatment as + // tombstones: real completions (previous state was live) publish + // immediately, and at-rest threads still publish 5 seconds later. + if ( + snapshot.state?.phase === "completed" && + options?.confirmTombstone !== true && + !publishedStateByThread.has(threadId) + ) { + if (pendingTombstoneConfirms.has(threadId)) { + return; + } + pendingTombstoneConfirms.add(threadId); + yield* Effect.logInfo("agent activity first-state completion deferred", { + environmentId, + threadId, + shell: describeThreadShellForAwareness(thread), + }); + yield* Effect.forkDetach( + confirmTombstoneLater(threadId).pipe( + Effect.ensuring(Effect.sync(() => pendingTombstoneConfirms.delete(threadId))), + ), + ); + return; + } + if ( snapshot.state === null && options?.confirmTombstone !== true && diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index 4d760bd6b16..292640dbf5b 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -298,9 +298,14 @@ function shouldUpdateLiveActivity(input: { ); } +// Completions replayed long after the fact (server restarts republish every +// recently-finished thread) must not ring the device again. +const TERMINAL_NOTIFICATION_FRESHNESS_MS = 2 * 60 * 1_000; + function notificationForAggregate(input: { readonly target: LiveActivities.TargetRow; readonly aggregate: RelayAgentActivityAggregateState | null; + readonly nowMs: number; }): ApnsNotificationPayload | null { if (!input.target.push_token || input.aggregate === null) { return null; @@ -313,6 +318,15 @@ function notificationForAggregate(input: { if (!activity) { return null; } + if (activity.phase === "completed" || activity.phase === "failed") { + const updatedAtMs = Option.match(DateTime.make(activity.updatedAt), { + onNone: () => null, + onSome: (dt) => dt.epochMilliseconds, + }); + if (updatedAtMs === null || input.nowMs - updatedAtMs > TERMINAL_NOTIFICATION_FRESHNESS_MS) { + return null; + } + } const enabled = (activity.phase === "waiting_for_approval" && preferences.notifyOnApproval) || (activity.phase === "waiting_for_input" && preferences.notifyOnInput) || @@ -1032,7 +1046,12 @@ export const make = Effect.gen(function* () { sendPushNotification, processSignedJob, sendPushNotificationForTarget: Effect.fnUntraced(function* (input) { - const notification = notificationForAggregate(input); + const now = yield* DateTime.now; + const notification = notificationForAggregate({ + target: input.target, + aggregate: input.aggregate, + nowMs: now.epochMilliseconds, + }); const token = input.target.push_token; return yield* notification && token ? deliveryQueue.enqueuePushNotification({ @@ -1068,6 +1087,7 @@ export const make = Effect.gen(function* () { const notification = notificationForAggregate({ target: input.target, aggregate: input.aggregate, + nowMs: input.nowMs, }); // The end event doubles as the "task finished" moment. When a companion // push notification is about to ring the device (below), the activity end From 97ed442a49424b517c4a9df250d7137311f9b3b0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 7 Jul 2026 00:05:32 -0700 Subject: [PATCH 24/26] Address review findings: worker-ordered confirms, unthrottled Done, LA pref gates Fixes the real findings from the PR review bots ahead of the TestFlight build: - Deferred publish confirmations (tombstones and first-state completions) now re-enqueue through the same drainable worker as every other publish instead of a detached fiber calling the publisher directly, so a confirmed tombstone can never race an in-flight live update; a deadline map replaces the pending set, which also guarantees a single scheduled confirmation per thread and clears when the state recovers. (cursor/macroscope High) - Newly-terminal transitions bypass the 15s live-activity update throttle: a completion landing in the same window as a new start keeps activeCount unchanged and would previously be suppressed, silently dropping the Done transition and its buzz. (macroscope High) - The newly-terminal alert honors the same 2-minute freshness rule as terminal push notifications, so replayed aggregates repaint without ringing. (cursor Medium) - Live Activity arming and priming treat an unset preference as enabled (the toggle's default): fresh installs now prime, and arm-on-send respects an explicit off. (cursor High/Medium) Remaining findings triaged as stale (superseded designs), intended behavior (replay painting recent Done, LA-disabled end retiring the token), or accepted edge cases (grace extension on re-registration, completion alerts beyond the 3-row display cap, ready-with-lastError mapping to Done, cancelled turns showing Done). Co-Authored-By: Claude Fable 5 --- .../remoteRegistration.test.ts | 2 +- .../agent-awareness/remoteRegistration.ts | 18 ++- apps/server/src/relay/AgentAwarenessRelay.ts | 137 +++++++----------- .../src/agentActivity/ApnsDeliveries.test.ts | 5 + .../relay/src/agentActivity/ApnsDeliveries.ts | 55 +++++-- 5 files changed, 120 insertions(+), 97 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 606e21be9fe..e296825c7ee 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -145,7 +145,7 @@ vi.mock("../../lib/runtime", () => ({ vi.mock("../../lib/storage", () => ({ loadAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), - loadPreferences: vi.fn(() => Promise.resolve({})), + loadPreferences: vi.fn(() => Promise.resolve({ liveActivitiesEnabled: false })), loadAgentAwarenessRegistrationRecord: vi.fn(() => Promise.resolve(registrationRecordStore.current), ), diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 186160bb491..e9de93c1dcb 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -447,6 +447,20 @@ export function armAgentAwarenessLiveActivityForLocalWork(input: { if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) { return; } + void loadPreferences() + .catch(() => null) + .then((preferences) => { + if (preferences?.liveActivitiesEnabled === false) { + return; + } + armAgentAwarenessLiveActivityForLocalWorkNow(input); + }); +} + +function armAgentAwarenessLiveActivityForLocalWorkNow(input: { + readonly threadTitle: string; + readonly projectTitle: string; +}): void { try { if (AgentActivity.getInstances().length > 0) { return; @@ -1007,7 +1021,9 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< cause, }), }).pipe(Effect.orElseSucceed(() => null)); - if (preferences?.liveActivitiesEnabled) { + // The toggle defaults to on: an unset preference (fresh install) must + // prime, so only an explicit false blocks it. + if (preferences?.liveActivitiesEnabled !== false) { const snapshot = yield* readAgentActivitySnapshot(); // The snapshot request yields; an arm-on-send may have created the // card in the meantime. Re-check so two cards are never started. diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 07527ac2c58..f039b1754f9 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -326,18 +326,15 @@ export const make = Effect.gen(function* () { transformClient: relayEnvironmentClient(relayConfig.environmentCredential), }).pipe(Effect.provide(FetchHttpClient.layer)); - // Assigned after publishThreadUnsafe below; indirection keeps the deferred - // tombstone confirmation from making the definition self-referential. - let confirmTombstoneLater: (threadId: ThreadId) => Effect.Effect = () => Effect.void; - // One pending confirmation per thread: birth/teardown flapping can resolve - // null on several consecutive events, and each would otherwise fork its own - // confirm fiber. - const pendingTombstoneConfirms = new Set(); - - const publishThreadUnsafe = Effect.fn("publishThreadUnsafe")(function* ( - threadId: ThreadId, - options?: { readonly confirmTombstone?: boolean }, - ) { + // Deadlines for publishes that need confirmation (tombstones and + // first-state completions). The confirming publish is re-enqueued through + // the same drainable worker as every other publish, so a confirmed + // tombstone can never race an in-flight live update; a recovered state + // clears the deadline. Assigned after the worker exists. + const publishConfirmDeadlines = new Map(); + let schedulePublishConfirm: (threadId: ThreadId) => Effect.Effect = () => Effect.void; + + const publishThreadUnsafe = Effect.fn("publishThreadUnsafe")(function* (threadId: ThreadId) { const publishAgentActivity = yield* readPublishAgentActivityEnabled.pipe( Effect.orElseSucceed(() => false), ); @@ -421,73 +418,48 @@ export const make = Effect.gen(function* () { return; } - // A brand-new session boots at "ready" before its first turn starts, which - // projects as completed for an instant; publishing that sends a spurious - // Done notification at thread birth. Completed as the thread's FIRST - // published state gets the same defer-and-confirm treatment as - // tombstones: real completions (previous state was live) publish - // immediately, and at-rest threads still publish 5 seconds later. - if ( - snapshot.state?.phase === "completed" && - options?.confirmTombstone !== true && - !publishedStateByThread.has(threadId) - ) { - if (pendingTombstoneConfirms.has(threadId)) { + // Two projections need confirmation before publishing, because both can + // appear transiently while the projector is mid-write and publishing them + // immediately is destructive or noisy: + // - null (tombstone) while the previous published state was live: deletes + // the thread from every armed card mid-conversation. + // - completed as the thread's FIRST published state: sessions boot at + // "ready" before their first turn, which projects as completed for an + // instant and sends a spurious Done notification at thread birth. + // Defer, schedule a re-publish through the ordinary worker queue, and + // only publish if the projection still holds when it drains. + const requiresConfirmation = + (snapshot.state === null && + publishedStateByThread.get(threadId) !== agentAwarenessPublishIdentity(null)) || + (snapshot.state?.phase === "completed" && !publishedStateByThread.has(threadId)); + if (requiresConfirmation) { + const nowMs = (yield* DateTime.now).epochMilliseconds; + const deadline = publishConfirmDeadlines.get(threadId); + if (deadline === undefined) { + publishConfirmDeadlines.set(threadId, nowMs + 5_000); + yield* Effect.logInfo("agent activity publish deferred pending confirmation", { + environmentId, + threadId, + reason: snapshot.reason, + statePhase: snapshot.state?.phase ?? null, + shell: describeThreadShellForAwareness(thread), + }); + yield* schedulePublishConfirm(threadId); return; } - pendingTombstoneConfirms.add(threadId); - yield* Effect.logInfo("agent activity first-state completion deferred", { - environmentId, - threadId, - shell: describeThreadShellForAwareness(thread), - }); - yield* Effect.forkDetach( - confirmTombstoneLater(threadId).pipe( - Effect.ensuring(Effect.sync(() => pendingTombstoneConfirms.delete(threadId))), - ), - ); - return; - } - - if ( - snapshot.state === null && - options?.confirmTombstone !== true && - publishedStateByThread.get(threadId) !== agentAwarenessPublishIdentity(null) - ) { - // A live thread that suddenly resolves to null is usually a transient - // handoff gap (an answered prompt whose next turn has not started yet, - // a shell lookup racing a write), and publishing the tombstone - // immediately deletes the thread from the lock-screen card mid- - // conversation. Defer, re-resolve, and only tombstone if it holds; - // genuinely deleted threads still propagate a few seconds later. - if (pendingTombstoneConfirms.has(threadId)) { + if (nowMs < deadline) { return; } - pendingTombstoneConfirms.add(threadId); - yield* Effect.logInfo("agent activity tombstone deferred pending confirmation", { - environmentId, - threadId, - reason: snapshot.reason, - shell: describeThreadShellForAwareness(thread), - }); - yield* Effect.forkDetach( - confirmTombstoneLater(threadId).pipe( - Effect.ensuring(Effect.sync(() => pendingTombstoneConfirms.delete(threadId))), - ), - ); - return; - } - - if (snapshot.state === null && options?.confirmTombstone === true) { - // Publishing a confirmed tombstone deletes the thread from every armed - // card; log exactly what the shell resolved to so a wrongly-null - // projection is diagnosable from the server log alone. - yield* Effect.logInfo("agent activity tombstone confirmed", { + publishConfirmDeadlines.delete(threadId); + yield* Effect.logInfo("agent activity deferred publish confirmed", { environmentId, threadId, reason: snapshot.reason, + statePhase: snapshot.state?.phase ?? null, shell: describeThreadShellForAwareness(thread), }); + } else { + publishConfirmDeadlines.delete(threadId); } if (snapshot.reason === "thread-not-found") { @@ -515,18 +487,6 @@ export const make = Effect.gen(function* () { }); }); - confirmTombstoneLater = (threadId) => - publishThreadUnsafe(threadId, { confirmTombstone: true }).pipe( - Effect.delay("5 seconds"), - Effect.catchCause((cause) => - Effect.logWarning("deferred agent activity tombstone failed", { - threadId, - cause: Cause.pretty(cause), - }), - ), - Effect.asVoid, - ); - const publishThread: AgentAwarenessRelay["Service"]["publishThread"] = (threadId) => publishThreadUnsafe(threadId).pipe( Effect.catchCause((cause) => { @@ -590,6 +550,19 @@ export const make = Effect.gen(function* () { const worker = yield* makeDrainableWorker(publishThread); + schedulePublishConfirm = (threadId) => + Effect.forkDetach( + Effect.sleep("5 seconds").pipe( + Effect.andThen(worker.enqueue(threadId)), + Effect.catchCause((cause) => + Effect.logWarning("deferred agent activity confirmation failed", { + threadId, + cause: Cause.pretty(cause), + }), + ), + ), + ).pipe(Effect.asVoid); + const start: AgentAwarenessRelay["Service"]["start"] = Effect.fn("AgentAwarenessRelay.start")( function* () { const [relayConfig, publishEnabled] = yield* Effect.all([ diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index a0a9a3ce693..5a35b54fb70 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -1552,6 +1552,7 @@ describe("live activity alert decisions", () => { previousAggregate: aggregate, nextAggregate: next, preferences, + nowMs: 0, }), ).toEqual({ title: "Thread", body: "Done: Project" }); // The completion switch mutes it. @@ -1560,6 +1561,7 @@ describe("live activity alert decisions", () => { previousAggregate: aggregate, nextAggregate: next, preferences: { ...preferences, notifyOnCompletion: false }, + nowMs: 0, }), ).toBeNull(); // No baseline means no transition to ring on. @@ -1568,6 +1570,7 @@ describe("live activity alert decisions", () => { previousAggregate: null, nextAggregate: next, preferences, + nowMs: 0, }), ).toBeNull(); // A Done row that was already terminal (or absent) before stays silent. @@ -1576,6 +1579,7 @@ describe("live activity alert decisions", () => { previousAggregate: next, nextAggregate: next, preferences, + nowMs: 0, }), ).toBeNull(); expect( @@ -1583,6 +1587,7 @@ describe("live activity alert decisions", () => { previousAggregate: { ...aggregate, activities: [attentionRow] }, nextAggregate: next, preferences, + nowMs: 0, }), ).toBeNull(); }); diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index 292640dbf5b..9b86ed46417 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -215,29 +215,51 @@ export function alertForAttentionTransition(input: { // completion buzz while other agents keep the activity alive. Requires the // thread to have been present and non-terminal before, so a baseline-less // replay or a row that merely fell off the display cap never rings. -export function alertForNewlyTerminal(input: { - readonly previousAggregate: RelayAgentActivityAggregateState | null; - readonly nextAggregate: RelayAgentActivityAggregateState; - readonly preferences: RelayAgentAwarenessPreferences | null; -}): ApnsLiveActivityAlert | null { - if (input.previousAggregate === null) { - return null; +function newlyTerminalRows( + previousAggregate: RelayAgentActivityAggregateState | null, + nextAggregate: RelayAgentActivityAggregateState, +): ReadonlyArray { + if (previousAggregate === null) { + return []; } const previousPhases = new Map( - input.previousAggregate.activities.map((row) => [row.threadId, row.phase]), + previousAggregate.activities.map((row) => [row.threadId, row.phase]), ); - const newlyTerminal = input.nextAggregate.activities.filter((row) => { + return nextAggregate.activities.filter((row) => { if (row.phase !== "completed" && row.phase !== "failed") { return false; } const previousPhase = previousPhases.get(row.threadId); return ( - previousPhase !== undefined && - previousPhase !== "completed" && - previousPhase !== "failed" && - alertAllowedForPhase(input.preferences, row.phase) + previousPhase !== undefined && previousPhase !== "completed" && previousPhase !== "failed" ); }); +} + +function isFreshTerminalRow( + row: RelayAgentActivityAggregateState["activities"][number], + nowMs: number, +): boolean { + const updatedAtMs = Option.match(DateTime.make(row.updatedAt), { + onNone: () => null, + onSome: (dt) => dt.epochMilliseconds, + }); + return updatedAtMs !== null && nowMs - updatedAtMs <= TERMINAL_NOTIFICATION_FRESHNESS_MS; +} + +export function alertForNewlyTerminal(input: { + readonly previousAggregate: RelayAgentActivityAggregateState | null; + readonly nextAggregate: RelayAgentActivityAggregateState; + readonly preferences: RelayAgentAwarenessPreferences | null; + readonly nowMs: number; +}): ApnsLiveActivityAlert | null { + const newlyTerminal = newlyTerminalRows(input.previousAggregate, input.nextAggregate).filter( + (row) => + alertAllowedForPhase(input.preferences, row.phase) && + // Replays of old aggregates (server restarts, redeliveries) repaint + // state without ringing; only fresh completions buzz. + isFreshTerminalRow(row, input.nowMs), + ); const first = newlyTerminal[0]; if (!first) { return null; @@ -284,6 +306,12 @@ function shouldUpdateLiveActivity(input: { if (aggregateNeedsAttention(input.nextAggregate)) { return true; } + // A thread finishing must never be throttled away: when a completion and a + // new start land in the same window, activeCount is unchanged and the Done + // transition (and its alert) would otherwise be suppressed. + if (newlyTerminalRows(input.previousAggregate, input.nextAggregate).length > 0) { + return true; + } const lastDeliveryAtMs = input.lastDeliveryAt === null ? null @@ -419,6 +447,7 @@ function chooseLiveActivityDelivery(input: { previousAggregate, nextAggregate, preferences, + nowMs: input.nowMs, }), } : "suppressed"; From be4e710b7d307b1da09ed5152e48426122cbde78 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 7 Jul 2026 00:27:21 -0700 Subject: [PATCH 25/26] Clear stale confirm deadlines when the projection returns to published state A recovered projection exits at the unchanged-identity dedupe, which sits above the confirmation block, so a deferred publish's deadline survived recovery. A much later transient null would then find the deadline already expired and publish its tombstone immediately, bypassing the deferral window entirely. The dedupe path now clears any pending deadline: the state is back at what was last published, so the deferred confirmation is moot. Co-Authored-By: Claude Fable 5 --- apps/server/src/relay/AgentAwarenessRelay.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index f039b1754f9..d4bcd2096f1 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -410,6 +410,11 @@ export const make = Effect.gen(function* () { const publishIdentity = agentAwarenessPublishIdentity(snapshot.state); const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef); if (publishedStateByThread.get(threadId) === publishIdentity) { + // The projection is back at (or never left) the last published state, so + // any pending deferred confirmation is moot. Leaving the deadline in + // place would let a much later transient null find it already expired + // and publish a tombstone immediately, skipping the deferral window. + publishConfirmDeadlines.delete(threadId); yield* Effect.logDebug("agent activity publish skipped; projected state unchanged", { environmentId, threadId, From 6f0b32dd62d822fa5f8ca17927fbf391625fdaa0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 7 Jul 2026 00:47:32 -0700 Subject: [PATCH 26/26] Use Layer.succeed for the pure ApnsProviderTokens construction Fixes the Effect Service Conventions check: the service builds a plain object with no effectful acquisition, so wrapping it in Effect.sync just to feed Layer.effect was the make-only-to-force-Layer.effect shape the conventions call out. Co-Authored-By: Claude Fable 5 --- infra/relay/src/agentActivity/ApnsProviderTokens.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/infra/relay/src/agentActivity/ApnsProviderTokens.ts b/infra/relay/src/agentActivity/ApnsProviderTokens.ts index ad42a5b1110..329f0728320 100644 --- a/infra/relay/src/agentActivity/ApnsProviderTokens.ts +++ b/infra/relay/src/agentActivity/ApnsProviderTokens.ts @@ -45,7 +45,7 @@ export function quantizedApnsJwtIssuedAt(nowUnixSeconds: number): number { return Math.floor(nowUnixSeconds / APNS_JWT_REUSE_SECONDS) * APNS_JWT_REUSE_SECONDS; } -export const make = Effect.sync(() => +export const make = () => ApnsProviderTokens.of({ getJwt: Effect.fnUntraced(function* (input) { const issuedAtUnixSeconds = quantizedApnsJwtIssuedAt(input.issuedAtUnixSeconds); @@ -58,7 +58,6 @@ export const make = Effect.sync(() => isolateTokenCache.set(cacheKey, { jwt, issuedAtUnixSeconds }); return jwt; }), - }), -); + }); -export const layer = Layer.effect(ApnsProviderTokens, make); +export const layer = Layer.succeed(ApnsProviderTokens, make());