From 9c3eb7bac628cee38d2843be5c9c9d5a394bc0c1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 3 Jul 2026 20:05:26 -0700 Subject: [PATCH 1/2] 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 2/2] 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":