diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index cc9da520ca0..d03e52e66bd 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", @@ -171,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/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..e296825c7ee 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -16,15 +16,23 @@ 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, registerAgentAwarenessConnection, registerLiveActivityPushToken, + releaseAgentAwarenessRelayTokenProvider, setAgentAwarenessRelayTokenProvider, shouldRegisterAgentAwarenessDeviceForProvider, unregisterAgentAwarenessConnection, @@ -41,6 +49,16 @@ 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; + readonly pushToStartToken?: string; + } | null, +})); vi.mock("expo-constants", () => ({ default: { @@ -100,6 +118,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", () => ({ @@ -114,7 +145,18 @@ vi.mock("../../lib/runtime", () => ({ vi.mock("../../lib/storage", () => ({ loadAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), - loadPreferences: vi.fn(() => Promise.resolve({})), + loadPreferences: vi.fn(() => Promise.resolve({ liveActivitiesEnabled: false })), + loadAgentAwarenessRegistrationRecord: vi.fn(() => + Promise.resolve(registrationRecordStore.current), + ), + 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 +218,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 +261,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 +402,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 +515,174 @@ 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("releases the provider without ending activities or clearing the registration", () => { + const end = vi.fn(() => Promise.resolve()); + const activity = { + getPushToken: vi.fn(() => Promise.resolve("activity-token")), + addPushTokenListener: vi.fn(), + end, + }; + widgetMocks.getInstances.mockReturnValue([activity] as never); + registrationRecordStore.current = { identity: "", signature: "sig" }; + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + expect(appStateMock.listeners).toHaveLength(1); + + releaseAgentAwarenessRelayTokenProvider(); + + expect(appStateMock.listeners).toHaveLength(0); + expect(end).not.toHaveBeenCalled(); + expect(clearAgentAwarenessRegistrationRecord).not.toHaveBeenCalled(); + expect(registrationRecordStore.current).not.toBeNull(); + }); + + it.effect("resets a pending status to unknown when relay config is missing", () => { + // No relay url configured: registration can neither run nor ever succeed, + // so the status must not stick at "pending". + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + + return Effect.gen(function* () { + yield* runBackgroundOperations(); + expect(getAgentAwarenessRegistrationStatus()).toBe("unknown"); + }).pipe(Effect.provide(relayTestLayer)); + }); + + it.effect("keeps a registered status when a later refresh fails", () => { + Constants.expoConfig!.extra = { + relay: { + url: "https://relay.example.test/", + }, + }; + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + + return Effect.gen(function* () { + yield* runBackgroundOperations(); + expect(getAgentAwarenessRegistrationStatus()).toBe("registered"); + + // The relay still holds the accepted registration; a transient refresh + // failure must not flip the settings toggles off. + vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockRejectedValueOnce( + new Error("transient failure"), + ); + yield* refreshAgentAwarenessRegistration(); + expect(getAgentAwarenessRegistrationStatus()).toBe("registered"); + }).pipe(Effect.provide(relayTestLayer)); + }); + + it.effect("does not re-register the same account when nothing has changed", () => { + Constants.expoConfig!.extra = { + relay: { + 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("dedupes rapid activity-token re-registrations within the replay window", () => { + // Fetch counts are unreliable here (the module-level relay layer captures + // the first test's fetch), so assert on the flow's own seams: a real + // registration attempt loads the device id, a deduped one short-circuits + // before it. + const fetchMock = vi.fn((request: RequestInfo | URL) => { + const url = request instanceof Request ? request.url : String(request); + return Promise.resolve( + Response.json( + url.endsWith("/v1/client/dpop-token") + ? { + access_token: "relay-dpop-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 300, + scope: "mobile:registration", + } + : { ok: true }, + ), + ); + }); + vi.stubGlobal("fetch", fetchMock); + Constants.expoConfig!.extra = { + relay: { + url: "https://relay.example.test/", + }, + }; + const activity = { + getPushToken: vi.fn(() => Promise.resolve("activity-token")), + addPushTokenListener: vi.fn(), + }; + widgetMocks.getInstances.mockReturnValue([activity] as never); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + + return Effect.gen(function* () { + // Drains the sign-in refresh, which registers the activity token. + yield* runBackgroundOperations(); + expect(activity.getPushToken).toHaveBeenCalled(); + + // A burst refresh (foreground / connection update seconds later) must + // dedupe: it reads the token but never proceeds to a registration + // attempt (which would load the device id first). + vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockClear(); + yield* refreshActiveLiveActivityRemoteRegistration(); + expect(loadOrCreateAgentAwarenessDeviceId).not.toHaveBeenCalled(); + }).pipe(Effect.provide(relayTestLayer)); + }); + + it.effect("re-registers when the stored account identity differs", () => { + Constants.expoConfig!.extra = { + relay: { + 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..e9de93c1dcb 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -1,12 +1,13 @@ -import { addPushToStartTokenListener, type LiveActivity } from "expo-widgets"; +import { type LiveActivity } from "expo-widgets"; import Constants from "expo-constants"; import * as Notifications from "expo-notifications"; import * as Effect from "effect/Effect"; 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, + type RelayAgentActivitySnapshotResponse, type RelayLiveActivityRegistrationRequest, } from "@t3tools/contracts/relay"; import { findErrorTraceId } from "@t3tools/client-runtime/errors"; @@ -20,13 +21,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; @@ -42,6 +46,8 @@ const AgentAwarenessOperation = Schema.Literals([ "read-live-activity-push-token", "load-live-activity-registration-identifier", "list-active-live-activities", + "load-live-activity-prime-preferences", + "prime-live-activity", ]); export class AgentAwarenessOperationError extends Schema.TaggedErrorClass()( @@ -58,8 +64,49 @@ export class AgentAwarenessOperationError extends Schema.TaggedErrorClass(); const activityPushTokenListeners = new WeakSet>(); -let pushToStartSubscription: { remove: () => void } | null = null; +// Activity tokens the relay recently accepted, by acceptance time. The refresh +// runs on sign-in, every app foreground, and every environment-connection +// update, which arrive in bursts and spammed identical registrations. But the +// registration is not a pure no-op: the relay replays the current aggregate to +// this device on every accepted registration, and that replay is the +// foreground reconciliation that repairs drifted or orphaned activities. So +// dedupe only within a short window — bursts collapse to one request, while a +// foreground after real time away still triggers a replay. Cleared on +// sign-out/identity change alongside the device registration state. +const ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS = 60_000; +const registeredActivityPushTokens = new Map(); let 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; @@ -74,7 +121,6 @@ let pendingDeviceRegistration: { } | null = null; interface DeviceRegistrationInput { - readonly pushToStartToken?: string; readonly observedPushToken?: string; } @@ -120,32 +166,66 @@ export function setAgentAwarenessRelayTokenProvider( deviceRegistrationGeneration++; activeDeviceRegistration = null; pendingDeviceRegistration = null; + registeredActivityPushTokens.clear(); } relayTokenProvider = provider; relayTokenProviderIdentity = provider ? (identity ?? null) : null; if (!provider) { - pushToStartSubscription?.remove(); - 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", ); if (isExistingIdentity) { + // Same account re-activating (e.g. Clerk token refresh) normally needs no + // re-registration — but if the previous attempt never succeeded, this is + // the only trigger that will retry it before the next cold start. + if (registrationStatus !== "registered") { + enqueueDeviceRegistration({}, "device registration retry after cloud session refresh failed"); + } return; } enqueueDeviceRegistration({}, "device registration after cloud sign-in failed"); } +// Detach the provider and native listeners without the destructive sign-out +// cleanup. For provider teardown while the user is still signed in (e.g. the +// auth bridge unmounting/remounting), ending lock-screen activities and wiping +// the persisted registration would be wrong — the relay still holds a valid +// registration and the next mount reuses it. +export function releaseAgentAwarenessRelayTokenProvider(): void { + relayTokenProvider = null; + relayTokenProviderIdentity = null; + pushTokenSubscription?.remove(); + pushTokenSubscription = null; + appStateSubscription?.remove(); + appStateSubscription = null; + if (activeLiveActivityRegistrationRetry) { + clearTimeout(activeLiveActivityRegistrationRetry); + activeLiveActivityRegistrationRetry = null; + } +} + function iosMajorVersion(): number { const version = Platform.Version; if (typeof version === "number") { @@ -211,6 +291,27 @@ 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.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, @@ -223,7 +324,13 @@ function registerDeviceWithRelay( }); return; } - if (!readRelayConfig()) return; + const relayConfig = readRelayConfig(); + if (!relayConfig) { + // Nothing is in flight and nothing can succeed until configuration + // appears; "pending" would otherwise stick forever. + setRegistrationStatus("unknown"); + return; + } const token = yield* relayToken("read-device-registration-relay-token"); if (expectedGeneration !== deviceRegistrationGeneration) { logRegistrationDebug("device registration cancelled after auth lookup", { @@ -234,6 +341,37 @@ function registerDeviceWithRelay( } if (!token) { logRegistrationDebug("relay device registration skipped; user is not signed in"); + setRegistrationStatus("unknown"); + 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 persisted = yield* Effect.tryPromise({ + try: () => loadAgentAwarenessRegistrationRecord(), + catch: (cause) => cause, + }).pipe(Effect.orElseSucceed(() => null)); + if (expectedGeneration !== deviceRegistrationGeneration) { + // Signed out while the record loaded — do not resurrect the cleared + // record or report the previous account's registration as current. + logRegistrationDebug("device registration cancelled after record lookup", { + expectedGeneration, + currentGeneration: deviceRegistrationGeneration, + }); + return; + } + const payload = body; + // The relay URL participates so pointing the app at a different relay + // invalidates the record and re-registers there. + const signature = `${relayConfig.url}|${registrationSignature(payload)}`; + if (persisted && persisted.identity === identity && persisted.signature === signature) { + setRegistrationStatus("registered"); + logRegistrationDebug("relay device registration skipped; already registered for account", { + expectedGeneration, + }); return; } @@ -243,8 +381,27 @@ function registerDeviceWithRelay( }); yield* client.registerDevice({ clerkToken: token, - payload: body, + payload, }); + if (expectedGeneration !== deviceRegistrationGeneration) { + // Signed out while the request was in flight: the sign-out path already + // reset the status and cleared the record for the next account, so a + // stale success must not overwrite either. + logRegistrationDebug("device registration completed after sign-out; result discarded", { + expectedGeneration, + currentGeneration: deviceRegistrationGeneration, + }); + return; + } + setRegistrationStatus("registered"); + yield* Effect.promise(() => + saveAgentAwarenessRegistrationRecord({ + identity, + signature, + }).catch((error: unknown) => { + logRegistrationError("persist registration record failed", error); + }), + ); logRegistrationDebug("relay device registration request completed", { expectedGeneration, }); @@ -278,6 +435,91 @@ function unregisterDeviceWithRelay(input: { }); } +// Arms the lock-screen card the moment the user starts agent work from this +// phone, while the app is still foregrounded and the fresh activity's token +// can be registered immediately. The seeded row is a best-effort placeholder; +// the relay's registration replay repaints it with the authoritative +// aggregate within seconds. No-ops when a card is already armed. +export function armAgentAwarenessLiveActivityForLocalWork(input: { + readonly threadTitle: string; + readonly projectTitle: string; +}): void { + if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) { + return; + } + void loadPreferences() + .catch(() => null) + .then((preferences) => { + if (preferences?.liveActivitiesEnabled === false) { + return; + } + armAgentAwarenessLiveActivityForLocalWorkNow(input); + }); +} + +function armAgentAwarenessLiveActivityForLocalWorkNow(input: { + readonly threadTitle: string; + readonly projectTitle: string; +}): void { + try { + if (AgentActivity.getInstances().length > 0) { + return; + } + const nowIso = new Date(Date.now()).toISOString(); + const activity = AgentActivity.start({ + title: "T3 Code", + subtitle: "Agent work in progress", + activeCount: 1, + updatedAt: nowIso, + activities: [ + { + environmentId: "", + threadId: "", + projectTitle: input.projectTitle, + threadTitle: input.threadTitle, + modelTitle: "", + phase: "starting", + status: "Connecting", + updatedAt: nowIso, + deepLink: "/", + }, + ], + }); + logRegistrationDebug("live activity card armed for local work", { + threadTitle: input.threadTitle, + }); + runRegistrationInBackground( + registerLiveActivityPushToken({ activity }).pipe(Effect.asVoid), + "live activity arming after local task start failed", + ); + } catch (error) { + logRegistrationError("live activity arming failed", error); + } +} + +function readAgentActivitySnapshot(): Effect.Effect< + RelayAgentActivitySnapshotResponse | null, + never, + ManagedRelay.ManagedRelayClient +> { + return Effect.gen(function* () { + if (!readRelayConfig()) return null; + const token = yield* relayToken("read-live-activity-registration-relay-token"); + if (!token) { + return null; + } + const client = yield* ManagedRelay.ManagedRelayClient; + return yield* client.getAgentActivitySnapshot({ clerkToken: token }); + }).pipe( + Effect.catch((error) => + Effect.sync(() => { + logRegistrationError("agent activity snapshot read failed", error); + return null; + }), + ), + ); +} + function registerLiveActivityWithRelay( body: RelayLiveActivityRegistrationRequest, ): Effect.Effect { @@ -332,14 +574,8 @@ function mergeDeviceRegistrationInput( current: DeviceRegistrationInput, next: DeviceRegistrationInput, ): DeviceRegistrationInput { - return { - ...((next.pushToStartToken ?? current.pushToStartToken) - ? { pushToStartToken: next.pushToStartToken ?? current.pushToStartToken } - : {}), - ...((next.observedPushToken ?? current.observedPushToken) - ? { observedPushToken: next.observedPushToken ?? current.observedPushToken } - : {}), - }; + const observedPushToken = next.observedPushToken ?? current.observedPushToken; + return observedPushToken ? { observedPushToken } : {}; } function registrationAddsInformation( @@ -347,8 +583,7 @@ function registrationAddsInformation( next: DeviceRegistrationInput, ): boolean { return ( - (next.pushToStartToken !== undefined && next.pushToStartToken !== current.pushToStartToken) || - (next.observedPushToken !== undefined && next.observedPushToken !== current.observedPushToken) + next.observedPushToken !== undefined && next.observedPushToken !== current.observedPushToken ); } @@ -363,8 +598,10 @@ function startPendingDeviceRegistration(): void { logRegistrationDebug("device registration started", { generation, 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 +612,13 @@ function startPendingDeviceRegistration(): void { runtime.runPromiseExit(registerDevice(next.input, generation)), ); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + // A transient failure on a later refresh (e.g. token rotation) leaves + // the prior accepted registration intact on the relay, so an already + // registered device stays "registered" rather than flipping the + // settings toggles off. + if (registrationStatus !== "registered") { + setRegistrationStatus("failed"); + } logRegistrationError(next.context, squashAtomCommandFailure(result)); } logRegistrationDebug("device registration finished", { generation }); @@ -444,14 +688,16 @@ 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, preferences, }), @@ -460,27 +706,12 @@ function registerDevice( }); } -function registerDeviceForCurrentUser( - pushToStartToken?: string, -): Effect.Effect { - return registerDevice(pushToStartToken ? { pushToStartToken } : undefined); -} - -function registerPushToStartTokenForCurrentUser(pushToStartToken: string): void { - enqueueDeviceRegistration({ pushToStartToken }, "push-to-start token registration failed"); -} - -function ensurePushToStartListener(): void { - if (pushToStartSubscription || !canRegisterRemoteLiveActivities()) { - return; - } - - pushToStartSubscription = addPushToStartTokenListener((event) => { - const token = event.activityPushToStartToken; - if (token) { - registerPushToStartTokenForCurrentUser(token); - } - }); +function registerDeviceForCurrentUser(): Effect.Effect< + void, + unknown, + ManagedRelay.ManagedRelayClient +> { + return registerDevice(undefined); } function ensurePushTokenListener(): void { @@ -498,14 +729,51 @@ 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. (Deduped by ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS: rapid +// foreground/sign-in bursts collapse to one registration, but returning after +// real time away still replays.) +function ensureAppStateListener(): void { + if (appStateSubscription || !canRegisterRemoteLiveActivities()) { + return; + } + + 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; } environmentConnections.set(connection.environmentId, connection); - ensurePushToStartListener(); ensurePushTokenListener(); + ensureAppStateListener(); enqueueDeviceRegistration({}, "device registration failed"); runRegistrationInBackground( refreshActiveLiveActivityRemoteRegistration(), @@ -523,10 +791,10 @@ export function unregisterAgentAwarenessConnection(environmentId: EnvironmentId) export function unregisterAllAgentAwarenessConnections(): void { environmentConnections.clear(); - pushToStartSubscription?.remove(); - pushToStartSubscription = null; pushTokenSubscription?.remove(); pushTokenSubscription = null; + appStateSubscription?.remove(); + appStateSubscription = null; if (activeLiveActivityRegistrationRetry) { clearTimeout(activeLiveActivityRegistrationRetry); activeLiveActivityRegistrationRetry = null; @@ -541,6 +809,11 @@ export function refreshAgentAwarenessRegistration(): Effect.Effect< return registerDeviceForCurrentUser().pipe( Effect.catch((error) => Effect.sync(() => { + // Same rationale as the queued path: a failed refresh does not undo an + // already accepted registration. + if (registrationStatus !== "registered") { + setRegistrationStatus("failed"); + } logRegistrationError("device registration refresh failed", error); }), ), @@ -549,10 +822,10 @@ export function refreshAgentAwarenessRegistration(): Effect.Effect< export function __resetAgentAwarenessRemoteRegistrationForTest(): void { environmentConnections.clear(); - pushToStartSubscription?.remove(); - pushToStartSubscription = null; pushTokenSubscription?.remove(); pushTokenSubscription = null; + appStateSubscription?.remove(); + appStateSubscription = null; if (activeLiveActivityRegistrationRetry) { clearTimeout(activeLiveActivityRegistrationRetry); activeLiveActivityRegistrationRetry = null; @@ -562,6 +835,9 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { deviceRegistrationGeneration++; activeDeviceRegistration = null; pendingDeviceRegistration = null; + registrationStatus = "unknown"; + registrationStatusListeners.clear(); + registeredActivityPushTokens.clear(); } export function unregisterAgentAwarenessDeviceForCurrentUser( @@ -649,6 +925,13 @@ function registerLiveActivityPushTokenValue(input: { readonly activityPushToken: string; }): Effect.Effect { return Effect.gen(function* () { + const acceptedAt = registeredActivityPushTokens.get(input.activityPushToken); + if ( + acceptedAt !== undefined && + Date.now() - acceptedAt < ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS + ) { + return true; + } const deviceId = yield* Effect.tryPromise({ try: () => loadOrCreateAgentAwarenessDeviceId(), catch: (cause) => @@ -662,6 +945,7 @@ function registerLiveActivityPushTokenValue(input: { activityPushToken: input.activityPushToken, }); if (registered) { + registeredActivityPushTokens.set(input.activityPushToken, Date.now()); logRegistrationDebug("live activity push token registered", { tokenSuffix: input.activityPushToken.slice(-8), }); @@ -694,7 +978,7 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< return; } - const activities = yield* Effect.try({ + let activities = yield* Effect.try({ try: () => AgentActivity.getInstances(), catch: (cause) => new AgentAwarenessOperationError({ @@ -710,6 +994,79 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< ), ); + // The relay tracks exactly one card per device; if concurrent arming ever + // produced extras, end them so only one keeps receiving updates. + if (activities.length > 1) { + for (const extra of activities.slice(1)) { + extra.end("immediate").catch((error: unknown) => { + logRegistrationError("duplicate live activity cleanup failed", error); + }); + } + activities = activities.slice(0, 1); + } + + // Activities are only ever created here, in the foreground, where the + // update token can be observed and registered immediately — the relay + // never remote-starts one (background push-to-start wakes proved too + // unreliable to hand the token over). Arming is conditional: the relay is + // asked what the card would show first, so an idle open never creates an + // empty lock-screen card, and an armed card is born with the real + // aggregate instead of a placeholder. + if (activities.length === 0) { + const preferences = yield* Effect.tryPromise({ + try: () => loadPreferences(), + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "load-live-activity-prime-preferences", + cause, + }), + }).pipe(Effect.orElseSucceed(() => null)); + // The toggle defaults to on: an unset preference (fresh install) must + // prime, so only an explicit false blocks it. + if (preferences?.liveActivitiesEnabled !== false) { + const snapshot = yield* readAgentActivitySnapshot(); + // The snapshot request yields; an arm-on-send may have created the + // card in the meantime. Re-check so two cards are never started. + const armedMeanwhile = yield* Effect.try({ + try: () => AgentActivity.getInstances(), + catch: () => [] as ReadonlyArray>, + }).pipe(Effect.orElseSucceed(() => [] as ReadonlyArray>)); + if (armedMeanwhile.length > 0) { + activities = [...armedMeanwhile]; + } else if (snapshot?.aggregate && snapshot.aggregate.activeCount > 0) { + const aggregate = snapshot.aggregate; + const primed = yield* Effect.try({ + try: () => + AgentActivity.start({ + title: aggregate.title, + subtitle: aggregate.subtitle, + activeCount: aggregate.activeCount, + updatedAt: aggregate.updatedAt, + activities: aggregate.activities, + }), + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "prime-live-activity", + cause, + }), + }).pipe( + Effect.catch((error) => + Effect.sync(() => { + logRegistrationError("live activity priming failed", error); + return null; + }), + ), + ); + if (primed) { + logRegistrationDebug("live activity card primed", { + activeCount: aggregate.activeCount, + }); + activities = [primed]; + } + } + } + } + const registrationResults = yield* Effect.forEach(activities, (activity) => registerLiveActivityPushToken({ activity }).pipe( Effect.map((registered) => !registered), diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx index c89aeb9249a..4180ae15c5e 100644 --- a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx +++ b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx @@ -14,6 +14,7 @@ import { runtime } from "../../lib/runtime"; import { appAtomRegistry } from "../../state/atom-registry"; import { useAtomCommand } from "../../state/use-atom-command"; import { + releaseAgentAwarenessRelayTokenProvider, setAgentAwarenessRelayTokenProvider, unregisterAgentAwarenessDeviceForCurrentUser, } from "../agent-awareness/remoteRegistration"; @@ -143,7 +144,11 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { useEffect( () => () => { previousTokenProviderRef.current = null; - deactivateCloudRelayAccount(); + // Unmounting is not a sign-out: the user is usually still signed in, so + // detach the provider without ending lock-screen activities or wiping the + // persisted registration (a remount reuses both). + releaseAgentAwarenessRelayTokenProvider(); + setManagedRelaySession(appAtomRegistry, null); }, [], ); diff --git a/apps/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/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 9dfec046b53..935776eac56 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -34,6 +34,8 @@ import { import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import { getComposerDraftSnapshot } from "../../state/use-composer-drafts"; import { useProjects } from "../../state/entities"; +import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; +import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox"; import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; @@ -524,6 +526,14 @@ export function NewTaskDraftScreen(props: { } flow.setSubmitting(true); + // Arm the lock-screen card before the async thread creation: backgrounding + // the app right after tapping submit would otherwise reject the foreground + // -only Activity start. If creation fails, the token registration's replay + // finds no work and ends the card within seconds. + armAgentAwarenessLiveActivityForLocalWork({ + threadTitle: deriveThreadTitleFromPrompt(initialMessageText), + projectTitle: selectedProject.title, + }); const result = await createProjectThread({ project: selectedProject, modelSelection, diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index bc107b0c3d2..3192e47667b 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -27,6 +27,7 @@ import { import ImageViewing from "react-native-image-viewing"; import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; import { useThemeColor } from "../../lib/useThemeColor"; +import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { AppText as Text } from "../../components/AppText"; @@ -499,12 +500,24 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); if (inFlightThreadIdsRef.current.has(threadKey)) return; inFlightThreadIdsRef.current.add(threadKey); + // Sending a prompt starts agent work: arm the lock-screen card now, while + // the app is foregrounded and the activity token can be registered. + armAgentAwarenessLiveActivityForLocalWork({ + threadTitle: props.selectedThread.title, + projectTitle: props.environmentLabel ?? "T3 Code", + }); try { await onSendMessage(); } finally { inFlightThreadIdsRef.current.delete(threadKey); } - }, [onSendMessage, props.environmentId, props.selectedThread.id]); + }, [ + onSendMessage, + props.environmentId, + props.environmentLabel, + props.selectedThread.id, + props.selectedThread.title, + ]); const handleCommandSelect = useCallback( (item: ComposerCommandItem) => { if (!composerTrigger) return; diff --git a/apps/mobile/src/lib/storage.ts b/apps/mobile/src/lib/storage.ts index 8ed999225e1..57f7359ec43 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,46 @@ 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; + // Last push-to-start token the relay accepted. Registrations triggered + // without a token event merge it back in so token absence never reads as a + // change (which would defeat the register-once skip every launch). + readonly pushToStartToken?: string; +} + +// Remembers the account identity and payload signature the relay last accepted +// 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, + ...(typeof parsed.pushToStartToken === "string" && parsed.pushToStartToken + ? { pushToStartToken: parsed.pushToStartToken } + : {}), + }; +} + +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..fc25b5bf539 100644 --- a/apps/mobile/src/widgets/AgentActivity.test.ts +++ b/apps/mobile/src/widgets/AgentActivity.test.ts @@ -2,23 +2,48 @@ 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 }), })); 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,17 +58,162 @@ const environment = { isLuminanceReduced: false, } as const; +const lightEnvironment = { + colorScheme: "light", + isLuminanceReduced: false, +} as const; + describe("AgentActivity widget layout", () => { - it("formats its updated-at label without app-runtime helper references", () => { - expect(JSON.stringify(AgentActivity(props, environment as never))).toContain( - '"children":["Updated ","1:07"]', + 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, + ); + const banner = JSON.stringify(layout.banner); + expect(banner).toContain("#7dd3fc"); // sky-300: running + expect(banner).toContain("#fcd34d"); // amber-300: waiting_for_approval + }); + + 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("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_input", status: "Input" }), + ], + }, + environment as never, ); - expect(AgentActivity.toString()).not.toContain("formatAgentActivityUpdatedAtLabel"); + const banner = JSON.stringify(layout.banner); + expect(banner).toContain("3 active agents"); + expect(banner).toContain("1 needs attention"); }); - it("uses now when the updated-at timestamp is malformed", () => { + 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("#a5b4fc"); // indigo-300 + expect(JSON.stringify(layout.compactTrailing)).toContain("Input"); + expect(JSON.stringify(layout.minimal)).toContain("#a5b4fc"); + }); + + 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, updatedAt: "not-a-date" }, environment as never)), - ).toContain('"children":["Updated ","now"]'); + JSON.stringify( + AgentActivity( + { ...props, activities: [makeRow({ deepLink: "//evil.example" })] }, + environment as never, + ), + ), + ).not.toContain("widgetURL"); + }); + + it("renders up to five rows in the banner", () => { + const layout = AgentActivity( + { + ...props, + activeCount: 6, + activities: [1, 2, 3, 4, 5, 6].map((n) => + makeRow({ threadId: `t${n}`, threadTitle: `Thread ${n}` }), + ), + }, + environment as never, + ); + 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 56ada5f2a02..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 } 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, @@ -37,282 +47,319 @@ 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"; + // 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; + } + 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 + } + }; + + // 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 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. + const deepLinkRow = attentionRow ?? row0; + const deepLink = + deepLinkRow && deepLinkRow.deepLink.startsWith("/") && !deepLinkRow.deepLink.startsWith("//") + ? `t3code://${deepLinkRow.deepLink.slice(1)}` + : null; + 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"; + + // 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: ( - - - - - {props.title} - + + {/* 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.subtitle} + {agentsLabel} - - - - {activeLabel} - - - {row0 ? ( - - + {attentionSuffix ? ( + · + ) : null} + {attentionSuffix ? ( - {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} + {attentionSuffix} - - - - {row2.status} - + ) : null} + - ) : null} - - Updated {updatedAt} - + + {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: ( - {activeLabel} + {attentionRow + ? attentionRow.phase === "waiting_for_approval" + ? "Approval" + : "Input" + : 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} - - - ), - expandedCenter: row0 ? ( - - - {row0.threadTitle} + + {renderLogo(15, tint)} + + {`${props.activeCount}`} - - {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 ? ( - - - - {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} + // 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/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..d09d0814222 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,52 @@ 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", + ); + if (flags.publishOnly) { + // A publish-only link exists solely to publish; without the publish + // flag the link would be inert and the success message a lie. + const secrets = yield* ServerSecretStore.ServerSecretStore; + yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, stringToBytes("true")); + } yield* Console.log( - "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 +446,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 +478,76 @@ 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) { + // If enabling scheduled a publish-only link that hasn't been + // provisioned yet, disabling must cancel it too — otherwise the next + // start still links an environment whose only purpose was publishing. + // A pending managed link is left alone; it exists for the tunnel. + const linkedNow = Option.isSome(yield* secrets.get(CLOUD_LINKED_USER_ID)); + if (!linkedNow && (yield* CliState.readCliDesiredLinkMode) === "publish_only") { + yield* CliState.setCliDesiredCloudLink(false); + yield* Console.log("Cancelled the pending publish-only T3 Connect link."); + } + yield* Console.log("Publishing agent activity disabled."); + return; + } + + 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; + } + // A link may already be desired (e.g. `t3 connect link` before the + // server's first start). Never downgrade it: a desired managed link + // also covers publishing, so only request a publish-only link when no + // link is pending at all. + if (yield* CliState.readCliDesiredCloudLink) { + yield* Console.log( + "A T3 Connect link is already pending. Start T3 to finish provisioning it; publishing starts once it links.", + ); + 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 +571,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/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 4e036e3ea0e..d4bcd2096f1 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -203,6 +203,26 @@ const makePublishProof = Effect.fn("makePublishProof")(function* (input: { return yield* signRelayAgentActivityPublishProof({ privateKey: input.privateKey, payload }); }); +// Compact, log-safe view of the fields the awareness phase ladder reads. +export function describeThreadShellForAwareness( + thread: Option.Option, +): Record { + if (Option.isNone(thread)) { + return { found: false }; + } + const shell = thread.value; + return { + found: true, + sessionStatus: shell.session?.status ?? null, + sessionActiveTurnId: shell.session?.activeTurnId ?? null, + latestTurnId: shell.latestTurn?.turnId ?? null, + latestTurnState: shell.latestTurn?.state ?? null, + latestTurnCompletedAt: shell.latestTurn?.completedAt ?? null, + hasPendingApprovals: shell.hasPendingApprovals, + hasPendingUserInput: shell.hasPendingUserInput, + }; +} + export function resolveAgentAwarenessRelayPublishSnapshot(input: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; @@ -306,6 +326,14 @@ export const make = Effect.gen(function* () { transformClient: relayEnvironmentClient(relayConfig.environmentCredential), }).pipe(Effect.provide(FetchHttpClient.layer)); + // Deadlines for publishes that need confirmation (tombstones and + // first-state completions). The confirming publish is re-enqueued through + // the same drainable worker as every other publish, so a confirmed + // tombstone can never race an in-flight live update; a recovered state + // clears the deadline. Assigned after the worker exists. + const publishConfirmDeadlines = new Map(); + let schedulePublishConfirm: (threadId: ThreadId) => Effect.Effect = () => Effect.void; + const publishThreadUnsafe = Effect.fn("publishThreadUnsafe")(function* (threadId: ThreadId) { const publishAgentActivity = yield* readPublishAgentActivityEnabled.pipe( Effect.orElseSucceed(() => false), @@ -382,6 +410,11 @@ export const make = Effect.gen(function* () { const publishIdentity = agentAwarenessPublishIdentity(snapshot.state); const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef); if (publishedStateByThread.get(threadId) === publishIdentity) { + // The projection is back at (or never left) the last published state, so + // any pending deferred confirmation is moot. Leaving the deadline in + // place would let a much later transient null find it already expired + // and publish a tombstone immediately, skipping the deferral window. + publishConfirmDeadlines.delete(threadId); yield* Effect.logDebug("agent activity publish skipped; projected state unchanged", { environmentId, threadId, @@ -390,6 +423,50 @@ export const make = Effect.gen(function* () { return; } + // Two projections need confirmation before publishing, because both can + // appear transiently while the projector is mid-write and publishing them + // immediately is destructive or noisy: + // - null (tombstone) while the previous published state was live: deletes + // the thread from every armed card mid-conversation. + // - completed as the thread's FIRST published state: sessions boot at + // "ready" before their first turn, which projects as completed for an + // instant and sends a spurious Done notification at thread birth. + // Defer, schedule a re-publish through the ordinary worker queue, and + // only publish if the projection still holds when it drains. + const requiresConfirmation = + (snapshot.state === null && + publishedStateByThread.get(threadId) !== agentAwarenessPublishIdentity(null)) || + (snapshot.state?.phase === "completed" && !publishedStateByThread.has(threadId)); + if (requiresConfirmation) { + const nowMs = (yield* DateTime.now).epochMilliseconds; + const deadline = publishConfirmDeadlines.get(threadId); + if (deadline === undefined) { + publishConfirmDeadlines.set(threadId, nowMs + 5_000); + yield* Effect.logInfo("agent activity publish deferred pending confirmation", { + environmentId, + threadId, + reason: snapshot.reason, + statePhase: snapshot.state?.phase ?? null, + shell: describeThreadShellForAwareness(thread), + }); + yield* schedulePublishConfirm(threadId); + return; + } + if (nowMs < deadline) { + return; + } + publishConfirmDeadlines.delete(threadId); + yield* Effect.logInfo("agent activity deferred publish confirmed", { + environmentId, + threadId, + reason: snapshot.reason, + statePhase: snapshot.state?.phase ?? null, + shell: describeThreadShellForAwareness(thread), + }); + } else { + publishConfirmDeadlines.delete(threadId); + } + if (snapshot.reason === "thread-not-found") { yield* Effect.logDebug("publishing agent activity tombstone; thread not found", { environmentId, @@ -478,6 +555,19 @@ export const make = Effect.gen(function* () { const worker = yield* makeDrainableWorker(publishThread); + schedulePublishConfirm = (threadId) => + Effect.forkDetach( + Effect.sleep("5 seconds").pipe( + Effect.andThen(worker.enqueue(threadId)), + Effect.catchCause((cause) => + Effect.logWarning("deferred agent activity confirmation failed", { + threadId, + cause: Cause.pretty(cause), + }), + ), + ), + ).pipe(Effect.asVoid); + const start: AgentAwarenessRelay["Service"]["start"] = Effect.fn("AgentAwarenessRelay.start")( function* () { const [relayConfig, publishEnabled] = yield* Effect.all([ diff --git a/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..f058a1cc61c 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); + // Older environment servers predate the managedTunnelActive field; for them a + // link always implies a managed tunnel, so fall back to `linked`. + const managedTunnelActive = + primaryCloudLinkState.data?.managedTunnelActive ?? primaryCloudLinkState.data?.linked ?? 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 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)); + const clerkToken = tokenResult.value; + const wantsLink = desired.managedTunnel || desired.publish; + + // A failure after this point may follow a partially applied mutation (e.g. + // the link succeeded but the preference update did not), so every exit — + // success or failure — refreshes the rendered state to whatever the server + // actually holds now. + if (!wantsLink) { + const unlinkResult = await unlinkPrimaryEnvironment({ + target, + clerkToken: clerkToken ?? null, + }); + if (unlinkResult._tag === "Failure") { + if (!isAtomCommandInterrupted(unlinkResult)) { + reportUpdateFailure(squashAtomCommandFailure(unlinkResult)); + } + primaryCloudLinkState.refresh(); + 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)); + } + primaryCloudLinkState.refresh(); + return false; + } + } + const prefResult = await updatePrimaryEnvironmentPreferences({ + target, + publishAgentActivity: desired.publish, + }); + if (prefResult._tag === "Failure") { + if (!isAtomCommandInterrupted(prefResult)) { + reportUpdateFailure(squashAtomCommandFailure(prefResult)); + } + primaryCloudLinkState.refresh(); + 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) { + // Turning the tunnel off while publishing stays on downgrades the link + // rather than removing it — say so instead of claiming an unlink. + toastManager.add({ + type: "success", + title: enabled + ? "T3 Connect linked" + : publishAgentActivity + ? "T3 Connect tunnel disabled" + : "T3 Connect unlinked", + description: enabled + ? "This environment is available through T3 Connect." + : publishAgentActivity + ? "The managed tunnel was removed. Agent activity publishing stays on." + : "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/package.json b/infra/relay/package.json index eebd9f4721a..17a4d52d380 100644 --- a/infra/relay/package.json +++ b/infra/relay/package.json @@ -11,6 +11,8 @@ "dependencies": { "@clerk/backend": "catalog:", "@effect/sql-pg": "catalog:", + "@noble/curves": "catalog:", + "@noble/hashes": "catalog:", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts index 9671f4984b2..d46db985882 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: "{}", @@ -60,6 +62,7 @@ function makeAgentActivityRows( return { upsert: () => Effect.void, remove: () => Effect.void, + pruneTerminal: () => Effect.void, listForUser: () => Effect.succeed([state]), ...overrides, }; @@ -306,7 +309,7 @@ describe("AgentActivityPublisher", () => { const sentAggregates: Array< Parameters[0] > = []; - const removals: Array[0]> = + const upserts: Array[0]> = []; return Effect.gen(function* () { @@ -326,9 +329,9 @@ describe("AgentActivityPublisher", () => { Layer.succeed( AgentActivityRows.AgentActivityRows, makeAgentActivityRows({ - remove: (input) => + upsert: (input) => Effect.sync(() => { - removals.push(input); + upserts.push(input); }), listForUser: () => Effect.succeed([]), }), @@ -378,11 +381,12 @@ describe("AgentActivityPublisher", () => { ok: true, }, ]); - expect(removals).toEqual([ + // Terminal states are persisted (and later pruned by the cron) so the + // finished thread can keep a Done row in later aggregates. + expect(upserts).toEqual([ { - environmentId: "env", environmentPublicKey: "environment-public-key", - threadId: "thread", + state: completedState, }, ]); expect(sentAggregates).toHaveLength(1); @@ -628,3 +632,173 @@ 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" }]); + }); + + it("keeps a recently finished thread visible as Done beside active agents", () => { + const active: RelayAgentActivityState = { + ...state, + threadId: "thread-active" as RelayAgentActivityState["threadId"], + updatedAt: "1970-01-01T00:58:00.000Z", + }; + const justCompleted: RelayAgentActivityState = { + ...state, + threadId: "thread-done" as RelayAgentActivityState["threadId"], + phase: "completed", + updatedAt: "1970-01-01T00:59:00.000Z", + }; + const aggregate = AgentActivityPublisher.makeAggregateState({ + activeStates: [active, justCompleted], + terminalState: null, + nowMs: hourMs, + }); + + expect(aggregate?.activeCount).toBe(1); + expect(aggregate?.subtitle).toBe("Agent work in progress"); + expect(aggregate?.activities).toMatchObject([ + { threadId: "thread-active", phase: "running" }, + { threadId: "thread-done", phase: "completed", status: "Done" }, + ]); + expect(aggregate?.updatedAt).toBe("1970-01-01T00:59:00.000Z"); + }); + + it("drops finished threads from the aggregate after the display window", () => { + const active: RelayAgentActivityState = { + ...state, + threadId: "thread-active" as RelayAgentActivityState["threadId"], + updatedAt: "1970-01-01T00:58:00.000Z", + }; + const staleCompleted: RelayAgentActivityState = { + ...state, + threadId: "thread-done" as RelayAgentActivityState["threadId"], + phase: "completed", + updatedAt: "1970-01-01T00:50:00.000Z", + }; + const aggregate = AgentActivityPublisher.makeAggregateState({ + activeStates: [active, staleCompleted], + terminalState: null, + nowMs: hourMs, + }); + + expect(aggregate?.activities).toMatchObject([{ threadId: "thread-active" }]); + }); + + it("keeps showing recently finished work when nothing is active", () => { + const lingeringCompleted: RelayAgentActivityState = { + ...state, + phase: "completed", + updatedAt: "1970-01-01T00:59:00.000Z", + }; + const aggregate = AgentActivityPublisher.makeAggregateState({ + activeStates: [lingeringCompleted], + terminalState: null, + nowMs: hourMs, + }); + + // An armed card never renders an empty state: recently finished threads + // keep Done content on it, and once they age out the aggregate becomes + // null and the delivery layer ends the card. + expect(aggregate).toMatchObject({ + activeCount: 0, + subtitle: "Agent work completed", + activities: [{ phase: "completed", status: "Done" }], + }); + expect( + AgentActivityPublisher.makeAggregateState({ + activeStates: [{ ...lingeringCompleted, updatedAt: "1970-01-01T00:50:00.000Z" }], + terminalState: null, + nowMs: hourMs, + }), + ).toBeNull(); + }); + + it("gives active agents the display slots before finished ones", () => { + const mkActive = (id: string): RelayAgentActivityState => ({ + ...state, + threadId: id as RelayAgentActivityState["threadId"], + updatedAt: "1970-01-01T00:58:00.000Z", + }); + const justCompleted: RelayAgentActivityState = { + ...state, + threadId: "thread-done" as RelayAgentActivityState["threadId"], + phase: "completed", + updatedAt: "1970-01-01T00:59:00.000Z", + }; + const aggregate = AgentActivityPublisher.makeAggregateState({ + activeStates: [mkActive("a-1"), mkActive("a-2"), mkActive("a-3"), justCompleted], + terminalState: null, + nowMs: hourMs, + }); + + expect(aggregate?.activeCount).toBe(3); + expect(aggregate?.activities).toMatchObject([ + { threadId: "a-1" }, + { threadId: "a-2" }, + { threadId: "a-3" }, + ]); + }); +}); diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts index abe05f07da2..5b739018ff0 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts @@ -8,8 +8,15 @@ 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 { + isExpiredAgentActivityState, + isTerminalPhase, + sanitizeAgentActivityAggregateState, +} from "./agentActivityPayloads.ts"; + +export { isExpiredAgentActivityState } from "./agentActivityPayloads.ts"; import * as AgentActivityRows from "./AgentActivityRows.ts"; import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; import * as LiveActivities from "./LiveActivities.ts"; @@ -55,6 +62,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 +72,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 +119,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, @@ -124,7 +137,11 @@ export const make = Effect.gen(function* () { "relay.thread_id": input.threadId, "relay.agent_activity.phase": input.state?.phase ?? "deleted", }); - if (input.state && !isTerminalPhase(input.state)) { + if (input.state) { + // Terminal states are persisted too (pruned by the cron after they + // age out) so a thread that finishes while other agents are active + // stays visible as Done/Failed in subsequent aggregates instead of + // silently vanishing from the Live Activity. yield* rows.upsert({ environmentPublicKey: input.environmentPublicKey, state: input.state, @@ -174,7 +191,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": @@ -182,10 +201,6 @@ function statusForPhase(phase: RelayAgentActivityState["phase"]): string { } } -function isTerminalPhase(state: RelayAgentActivityState): boolean { - return state.phase === "completed" || state.phase === "failed"; -} - function aggregateRowForState(state: RelayAgentActivityState) { return { environmentId: state.environmentId, @@ -210,15 +225,65 @@ function terminalAggregateState(state: RelayAgentActivityState): RelayAgentActiv }); } -function makeAggregateState(input: { +// How long a finished thread keeps its Done/Failed row in the aggregate while +// other agents are still active. Long enough to be seen on the lock screen, +// short enough that the activity list stays about live work. +export const TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS = 5 * 60 * 1_000; + +function isRecentTerminalState(state: RelayAgentActivityState, nowMs: number): boolean { + if (!isTerminalPhase(state)) { + return false; + } + const updatedAtMs = Option.match(DateTime.make(state.updatedAt), { + onNone: () => Number.NaN, + onSome: (dt) => dt.epochMilliseconds, + }); + if (Number.isNaN(updatedAtMs)) { + return false; + } + return nowMs - updatedAtMs <= TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS; +} + +export function makeAggregateState(input: { readonly activeStates: ReadonlyArray; readonly terminalState: RelayAgentActivityState | null; + 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); + if (input.terminalState !== null) { + return terminalAggregateState(input.terminalState); + } + // With no live work, recently finished threads keep the card showing + // Done/Failed content (an armed card never renders an empty state). The + // newly-terminal alert rules key off the previously delivered aggregate, + // so replays repaint this without buzzing. Once the terminal rows age + // out, the aggregate is null and the delivery layer ends the card. + const recentTerminal = input.activeStates + .filter((state) => isRecentTerminalState(state, input.nowMs)) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + const newest = recentTerminal[0]; + if (!newest) { + return null; + } + return sanitizeAgentActivityAggregateState({ + title: "T3 Code", + subtitle: newest.phase === "failed" ? "Agent work failed" : "Agent work completed", + activeCount: 0, + updatedAt: newest.updatedAt, + activities: recentTerminal.slice(0, 3).map(aggregateRowForState), + }); } - const updatedAt = activeStates.reduce((latest, state) => + // Recently finished threads ride along after the active ones (display slots + // permitting) so a completion is visible as Done/Failed instead of the row + // silently vanishing while other agents keep the activity alive. + const recentTerminalStates = input.activeStates + .filter((state) => isRecentTerminalState(state, input.nowMs)) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + const displayedStates = [...activeStates.slice(0, 3), ...recentTerminalStates].slice(0, 3); + const updatedAt = [...activeStates, ...recentTerminalStates].reduce((latest, state) => state.updatedAt.localeCompare(latest.updatedAt) > 0 ? state : latest, ).updatedAt; return sanitizeAgentActivityAggregateState({ @@ -226,7 +291,7 @@ function makeAggregateState(input: { subtitle: "Agent work in progress", activeCount: activeStates.length, updatedAt, - activities: activeStates.slice(0, 3).map(aggregateRowForState), + activities: displayedStates.map(aggregateRowForState), }); } diff --git a/infra/relay/src/agentActivity/AgentActivityRows.ts b/infra/relay/src/agentActivity/AgentActivityRows.ts index 7e1a8c50f1b..63868ad2d72 100644 --- a/infra/relay/src/agentActivity/AgentActivityRows.ts +++ b/infra/relay/src/agentActivity/AgentActivityRows.ts @@ -7,7 +7,7 @@ import * as Function from "effect/Function"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { and, desc, eq, isNull } from "drizzle-orm"; +import { and, desc, eq, isNull, lt, sql } from "drizzle-orm"; import * as RelayDb from "../db.ts"; import { relayAgentActivityRows, relayEnvironmentLinks } from "../persistence/schema.ts"; @@ -38,6 +38,18 @@ export class AgentActivityRowDeletePersistenceError extends Schema.TaggedErrorCl } } +export class AgentActivityRowPruneTerminalPersistenceError extends Schema.TaggedErrorClass()( + "AgentActivityRowPruneTerminalPersistenceError", + { + updatedBefore: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to prune terminal agent activity rows updated before ${this.updatedBefore}.`; + } +} + export class AgentActivityRowListPersistenceError extends Schema.TaggedErrorClass()( "AgentActivityRowListPersistenceError", { @@ -57,6 +69,9 @@ export class AgentActivityRows extends Context.Service< readonly environmentPublicKey: string; readonly state: RelayAgentActivityState; }) => Effect.Effect; + readonly pruneTerminal: (input: { + readonly updatedBefore: string; + }) => Effect.Effect; readonly remove: (input: { readonly environmentId: string; readonly environmentPublicKey: string; @@ -163,6 +178,29 @@ export const make = Effect.gen(function* () { ); }), + pruneTerminal: Effect.fn("relay.agent_activity_rows.prune_terminal")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.agent_activity_prune.before": input.updatedBefore, + }); + yield* db + .delete(relayAgentActivityRows) + .where( + and( + sql`${relayAgentActivityRows.stateJson} ->> 'phase' IN ('completed', 'failed')`, + lt(relayAgentActivityRows.updatedAt, input.updatedBefore), + ), + ) + .pipe( + Effect.mapError( + (cause) => + new AgentActivityRowPruneTerminalPersistenceError({ + updatedBefore: input.updatedBefore, + cause, + }), + ), + ); + }), + listForUser: Effect.fn("relay.agent_activity_rows.list_for_user")(function* (input) { return yield* db .select({ stateJson: relayAgentActivityRows.stateJson }) diff --git a/infra/relay/src/agentActivity/ApnsClient.test.ts b/infra/relay/src/agentActivity/ApnsClient.test.ts index bb557376862..9f987753351 100644 --- a/infra/relay/src/agentActivity/ApnsClient.test.ts +++ b/infra/relay/src/agentActivity/ApnsClient.test.ts @@ -10,14 +10,17 @@ import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import type { ApnsCredentials } from "../Config.ts"; import * as ApnsClient from "./ApnsClient.ts"; +import * as ApnsProviderTokens from "./ApnsProviderTokens.ts"; const isApnsJwtSigningError = Schema.is(ApnsClient.ApnsJwtSigningError); const isApnsHttpRequestError = Schema.is(ApnsClient.ApnsHttpRequestError); const TestLayer = ApnsClient.layer.pipe( + Layer.provide(ApnsProviderTokens.layer), Layer.provide( Layer.succeed( HttpClient.HttpClient, @@ -96,6 +99,32 @@ describe("ApnsClient", () => { }).pipe(Effect.provide(TestLayer)), ); + it.effect("builds a high-priority alerting update payload when an alert is attached", () => + Effect.gen(function* () { + const apns = yield* ApnsClient.ApnsClient; + const request = apns.makeLiveActivityRequest({ + event: "update", + token: "token", + state, + alert: { title: "Thread", body: "Approval: Project" }, + nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), + nowIso: DateTime.formatIso(now), + }); + + expect(request.priority).toBe("10"); + expect(request.payload).toMatchObject({ + aps: { + event: "update", + alert: { + title: "Thread", + body: "Approval: Project", + sound: "default", + }, + }, + }); + }).pipe(Effect.provide(TestLayer)), + ); + it.effect("builds an end payload with a dismissal date", () => Effect.gen(function* () { const apns = yield* ApnsClient.ApnsClient; @@ -114,6 +143,22 @@ describe("ApnsClient", () => { "dismissal-date": 300, }, }); + + // Without final content the card would freeze on its previous state; + // contentless ends dismiss quickly instead. + const contentless = apns.makeLiveActivityRequest({ + event: "end", + token: "token", + state: null, + nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), + nowIso: DateTime.formatIso(now), + }); + expect(contentless.payload).toMatchObject({ + aps: { + event: "end", + "dismissal-date": 15, + }, + }); }).pipe(Effect.provide(TestLayer)), ); @@ -181,7 +226,10 @@ describe("ApnsClient", () => { expect(error).toMatchObject({ teamId: "team-1", keyId: "key-1", - issuedAtUnixSeconds: 123, + // The provider-token service quantizes iat to the reuse window, so + // the signing context carries the window start rather than the raw + // request time. + issuedAtUnixSeconds: 0, cause: expect.any(Error), message: "Failed to sign APNs JWT for key key-1.", }); @@ -210,6 +258,7 @@ describe("ApnsClient", () => { ), ); const layer = ApnsClient.layer.pipe( + Layer.provide(ApnsProviderTokens.layer), Layer.provide(Layer.succeed(HttpClient.HttpClient, failingHttpClient)), ); @@ -254,4 +303,62 @@ describe("ApnsClient", () => { }); }).pipe(Effect.provide(layer)); }); + + it.effect("reuses the signed provider JWT across pushes within the reuse window", () => { + ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); + const { privateKey } = NodeCrypto.generateKeyPairSync("ec", { + namedCurve: "prime256v1", + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }); + const credentials = { + teamId: "team-jwt-cache", + keyId: "key-jwt-cache", + privateKey: Redacted.make(privateKey), + bundleId: "com.t3tools.test", + environment: "sandbox", + } satisfies ApnsCredentials; + const authorizations: Array = []; + const capturingHttpClient = HttpClient.make((request) => { + authorizations.push(request.headers.authorization ?? ""); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("", { status: 200, headers: { "apns-id": "apns-id-1" } }), + ), + ); + }); + const layer = ApnsClient.layer.pipe( + Layer.provide(ApnsProviderTokens.layer), + Layer.provide(Layer.succeed(HttpClient.HttpClient, capturingHttpClient)), + ); + + return Effect.gen(function* () { + const apns = yield* ApnsClient.ApnsClient; + const request = apns.makePushNotificationRequest({ + token: "push-token", + notification: { + title: "Thread", + body: "Input: Project", + environmentId: "env", + threadId: "thread", + deepLink: "/threads/env/thread", + }, + }); + const send = (issuedAtUnixSeconds: number) => + apns.sendPushNotificationRequest({ credentials, request, issuedAtUnixSeconds }); + + const window = ApnsProviderTokens.APNS_JWT_REUSE_SECONDS; + yield* send(window + 10); + yield* send(window * 2 - 1); + yield* send(window * 2); + + expect(authorizations).toHaveLength(3); + // Within the 45-minute window APNs must see the byte-identical token; + // refreshing it per push trips TooManyProviderTokenUpdates. + expect(authorizations[1]).toBe(authorizations[0]); + expect(authorizations[2]).not.toBe(authorizations[0]); + ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); + }).pipe(Effect.provide(layer)); + }); }); diff --git a/infra/relay/src/agentActivity/ApnsClient.ts b/infra/relay/src/agentActivity/ApnsClient.ts index 1ac218cdd3c..c401eb6c2e4 100644 --- a/infra/relay/src/agentActivity/ApnsClient.ts +++ b/infra/relay/src/agentActivity/ApnsClient.ts @@ -1,22 +1,30 @@ -import * as NodeCrypto from "node:crypto"; - import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; -import * as Encoding from "effect/Encoding"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import * as Headers from "effect/unstable/http/Headers"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import { ApnsEnvironment as ApnsEnvironmentSchema, type ApnsCredentials } from "../Config.ts"; -import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts"; +import type { ApnsLiveActivityAlert, ApnsNotificationPayload } from "./apnsDeliveryJobs.ts"; +import { ApnsJwtEncodingError, ApnsJwtSigningError } from "./apnsJwt.ts"; +import * as ApnsProviderTokens from "./ApnsProviderTokens.ts"; + +export { ApnsJwtEncodingError, ApnsJwtSigningError } from "./apnsJwt.ts"; const LIVE_ACTIVITY_NAME = "AgentActivity"; -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; +// An end without a final content-state leaves whatever the card last showed +// frozen on the lock screen until dismissal — get it off quickly instead of +// parading stale state for the full window. +const CONTENTLESS_DISMISS_AFTER_SECONDS = 15; const ApnsLiveActivityEventSchema = Schema.Literals(["start", "update", "end"]); export type ApnsLiveActivityEvent = typeof ApnsLiveActivityEventSchema.Type; @@ -43,35 +51,6 @@ export interface ApnsDeliveryResult { readonly apnsId: string | null; } -export class ApnsJwtEncodingError extends Schema.TaggedErrorClass()( - "ApnsJwtEncodingError", - { - component: Schema.Literals(["header", "payload"]), - teamId: Schema.String, - keyId: Schema.String, - issuedAtUnixSeconds: Schema.Number, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to encode APNs JWT ${this.component} for key ${this.keyId}.`; - } -} - -export class ApnsJwtSigningError extends Schema.TaggedErrorClass()( - "ApnsJwtSigningError", - { - teamId: Schema.String, - keyId: Schema.String, - issuedAtUnixSeconds: Schema.Number, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to sign APNs JWT for key ${this.keyId}.`; - } -} - export class ApnsHttpRequestError extends Schema.TaggedErrorClass()( "ApnsHttpRequestError", { @@ -104,82 +83,6 @@ const decodeApnsErrorResponseJson = Schema.decodeUnknownOption( }), ), ); -const encodeApnsJwtHeaderJson = Schema.encodeEffect( - Schema.fromJsonString( - Schema.Struct({ - alg: Schema.Literal("ES256"), - kid: Schema.String, - }), - ), -); -const encodeApnsJwtPayloadJson = Schema.encodeEffect( - Schema.fromJsonString( - Schema.Struct({ - iss: Schema.String, - iat: Schema.Number, - }), - ), -); - -const makeApnsJwt = Effect.fn("relay.apns.make_jwt")(function* (input: { - readonly teamId: ApnsCredentials["teamId"]; - readonly keyId: ApnsCredentials["keyId"]; - readonly privateKey: ApnsCredentials["privateKey"]; - readonly issuedAtUnixSeconds: number; -}) { - const headerJson = yield* encodeApnsJwtHeaderJson({ alg: "ES256", kid: input.keyId }).pipe( - Effect.mapError( - (cause) => - new ApnsJwtEncodingError({ - component: "header", - teamId: input.teamId, - keyId: input.keyId, - issuedAtUnixSeconds: input.issuedAtUnixSeconds, - cause, - }), - ), - ); - const payloadJson = yield* encodeApnsJwtPayloadJson({ - iss: input.teamId, - iat: input.issuedAtUnixSeconds, - }).pipe( - Effect.mapError( - (cause) => - new ApnsJwtEncodingError({ - component: "payload", - teamId: input.teamId, - keyId: input.keyId, - issuedAtUnixSeconds: input.issuedAtUnixSeconds, - cause, - }), - ), - ); - - const privateKey = Redacted.value(input.privateKey); - const header = Encoding.encodeBase64Url(headerJson); - const payload = Encoding.encodeBase64Url(payloadJson); - const signingInput = `${header}.${payload}`; - - return yield* Effect.try({ - try: () => { - const signature = NodeCrypto.createSign("sha256") - .update(signingInput) - .sign({ - key: privateKey.replace(/\\n/g, "\n"), - dsaEncoding: "ieee-p1363", - }); - return `${signingInput}.${Encoding.encodeBase64Url(signature)}`; - }, - catch: (cause) => - new ApnsJwtSigningError({ - teamId: input.teamId, - keyId: input.keyId, - issuedAtUnixSeconds: input.issuedAtUnixSeconds, - cause, - }), - }); -}); - function contentState(state: RelayAgentActivityAggregateState) { return { name: LIVE_ACTIVITY_NAME, @@ -197,12 +100,27 @@ type MakeLiveActivityRequestInput = | (LiveActivityRequestBase & { readonly event: "end"; readonly state: RelayAgentActivityAggregateState | null; + readonly alert?: ApnsLiveActivityAlert | null; }) | (LiveActivityRequestBase & { readonly event: "start" | "update"; readonly state: RelayAgentActivityAggregateState; + readonly alert?: ApnsLiveActivityAlert | null; }); +// An alert dict on an update/end makes it an "alerting" update: iOS wakes the +// screen and plays the haptic (the Apple Sports score-change behavior) instead +// of silently redrawing the activity. +function liveActivityAlertPayload(alert: ApnsLiveActivityAlert) { + return { + alert: { + title: alert.title, + body: alert.body, + sound: "default", + }, + }; +} + function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveActivityRequest { const timestamp = input.nowEpochSeconds; if (input.event === "end") { @@ -215,7 +133,9 @@ function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveA timestamp, event: "end", ...(input.state ? { "content-state": contentState(input.state) } : {}), - "dismissal-date": timestamp + DISMISS_AFTER_SECONDS, + ...(input.alert ? liveActivityAlertPayload(input.alert) : {}), + "dismissal-date": + timestamp + (input.state ? DISMISS_AFTER_SECONDS : CONTENTLESS_DISMISS_AFTER_SECONDS), }, }, }; @@ -225,7 +145,9 @@ function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveA return { token: input.token, event: input.event, - priority: input.event === "update" ? "5" : "10", + // Alerting updates must land immediately; routine redraws stay at the + // budget-friendly low priority. + priority: input.event === "update" && !input.alert ? "5" : "10", payload: { aps: { timestamp, @@ -241,6 +163,7 @@ function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveA }, } : {}), + ...(input.event === "update" && input.alert ? liveActivityAlertPayload(input.alert) : {}), "content-state": contentState(state), "stale-date": timestamp + STALE_AFTER_SECONDS, }, @@ -300,12 +223,13 @@ export class ApnsClient extends Context.Service< export const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; + const providerTokens = yield* ApnsProviderTokens.ApnsProviderTokens; const sendLiveActivityRequest: ApnsClient["Service"]["sendLiveActivityRequest"] = Effect.fn( "relay.apns.send_live_activity_request", )(function* (input) { yield* Effect.annotateCurrentSpan({ "relay.apns.event": input.request.event }); - const jwt = yield* makeApnsJwt({ + const jwt = yield* providerTokens.getJwt({ ...input.credentials, issuedAtUnixSeconds: input.issuedAtUnixSeconds, }); @@ -363,7 +287,7 @@ export const make = Effect.gen(function* () { const sendPushNotificationRequest: ApnsClient["Service"]["sendPushNotificationRequest"] = Effect.fn("relay.apns.send_push_notification_request")(function* (input) { yield* Effect.annotateCurrentSpan({ "relay.apns.event": "push_notification" }); - const jwt = yield* makeApnsJwt({ + const jwt = yield* providerTokens.getJwt({ ...input.credentials, issuedAtUnixSeconds: input.issuedAtUnixSeconds, }); diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index da3c39cfa71..5a35b54fb70 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -5,6 +5,7 @@ import type { import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; import { describe, expect, it } from "@effect/vitest"; import * as NodeCrypto from "node:crypto"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; @@ -26,8 +27,10 @@ import * as DeliveryAttempts from "./DeliveryAttempts.ts"; import * as LiveActivities from "./LiveActivities.ts"; import * as RelayConfiguration from "../Config.ts"; import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts"; +import * as AgentActivityRows from "./AgentActivityRows.ts"; import * as ApnsDeliveries from "./ApnsDeliveries.ts"; import * as ApnsClient from "./ApnsClient.ts"; +import * as ApnsProviderTokens from "./ApnsProviderTokens.ts"; const config = RelayConfiguration.RelayConfiguration.of({ relayIssuer: "https://relay.example.test", @@ -127,6 +130,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, @@ -156,15 +161,31 @@ function makeLayer(input: { >; readonly currentTargets?: ReadonlyArray; readonly config?: RelayConfiguration.RelayConfiguration["Service"]; + // Live agent-activity rows returned by the delivery-time start recheck. + // Defaults to one freshly-updated running thread so queued starts stay + // deliverable in tests that don't care about the recheck. + readonly activityStates?: ReadonlyArray; readonly execute?: ( request: HttpClientRequest.HttpClientRequest, ) => Effect.Effect; }) { return ApnsDeliveries.layer.pipe( Layer.provide(ApnsClient.layer), + Layer.provide(ApnsProviderTokens.layer), Layer.provide(ApnsDeliveryQueue.layer.pipe(Layer.provide(NodeCryptoLayer.layer))), Layer.provide( Layer.mergeAll( + Layer.succeed(AgentActivityRows.AgentActivityRows, { + upsert: () => Effect.void, + remove: () => Effect.void, + pruneTerminal: () => Effect.void, + listForUser: () => + input.activityStates !== undefined + ? Effect.succeed([...input.activityStates]) + : DateTime.now.pipe( + Effect.map((now) => [{ ...state, updatedAt: DateTime.formatIso(now) }]), + ), + } satisfies AgentActivityRows.AgentActivityRows["Service"]), Layer.succeed(ApnsDeliveryQueue.ApnsDeliveryQueueSender, { send: (body) => Effect.sync(() => { @@ -225,62 +246,61 @@ function makeLayer(input: { } describe("ApnsDeliveries", () => { - it.effect("queues a restart using the push-to-start token", () => { + it.effect("never starts an activity remotely when no update token is registered", () => { const attempts: Array = []; const queuedJobs: Array = []; const queuedStarts: Array< Parameters[0] > = []; - const markedDeliveries: Array< - Parameters[0] - > = []; return Effect.gen(function* () { const deliveries = yield* ApnsDeliveries.ApnsDeliveries; const result = yield* deliveries.sendForTarget({ target: { ...target, + activity_push_token: null, + remote_started_at: null, ended_at: "1970-01-01T00:00:05.000Z", }, aggregate, nowMs: 10_000, }); - expect(result?.kind).toBe("live_activity_start"); - expect(result?.ok).toBe(true); - expect(queuedJobs).toMatchObject([ - { - payload: { - kind: "live_activity_start", - target: { - token: "start-token", - }, - }, - }, - ]); + // Activities are armed by the app in the foreground; the relay never + // uses the push-to-start token, so the only fallback is the push + // notification channel (none here: the aggregate is not an attention + // phase). + expect(result).toBeNull(); + expect(queuedJobs).toEqual([]); + expect(queuedStarts).toEqual([]); expect(attempts).toEqual([]); - expect(queuedStarts).toMatchObject([ - { - userId: target.user_id, - deviceId: target.device_id, - }, - ]); - expect(markedDeliveries).toEqual([]); - }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs, queuedStarts, markedDeliveries }))); + }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs, queuedStarts }))); }); - it.effect("queues an end using the activity token", () => { + it.effect("ends the armed card when nothing remains to show", () => { const attempts: Array = []; const queuedJobs: Array = []; return Effect.gen(function* () { const deliveries = yield* ApnsDeliveries.ApnsDeliveries; - const result = yield* deliveries.sendForTarget({ + // Within the freshly-armed grace window an empty aggregate delivers + // nothing: the environment's first publish may still be in flight. + const graced = yield* deliveries.sendForTarget({ target, aggregate: null, nowMs: 5_000, }); + expect(graced).toBeNull(); + const result = yield* deliveries.sendForTarget({ + target, + aggregate: null, + nowMs: 5_000 + 3 * 60 * 1_000, + }); + + // An armed card always shows content (live or recently finished work); + // once the aggregate is empty the card ends rather than rendering an + // empty state. The app re-arms on the next open with content. expect(result?.kind).toBe("live_activity_end"); expect(result?.ok).toBe(true); expect(queuedJobs).toMatchObject([ @@ -368,12 +388,7 @@ describe("ApnsDeliveries", () => { return Effect.gen(function* () { const deliveries = yield* ApnsDeliveries.ApnsDeliveries; yield* deliveries.sendForTarget({ - target: { - ...target, - activity_push_token: null, - remote_started_at: null, - ended_at: "1970-01-01T00:00:05.000Z", - }, + target, aggregate: inputAggregate, nowMs: 10_000, }); @@ -389,6 +404,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_update", + target: { + token: "activity-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 = []; @@ -629,6 +834,48 @@ describe("ApnsDeliveries", () => { }).pipe(Effect.provide(makeLayer({ attempts }))); }); + it.effect("skips a queued start when the user no longer has live work", () => { + const attempts: Array = []; + const clearedStarts: Array< + Parameters[0] + > = []; + const payload = makeApnsDeliveryJobPayload({ + kind: "live_activity_start", + userId: target.user_id, + deviceId: target.device_id, + token: target.push_to_start_token ?? "start-token", + aggregate, + createdAt: "1970-01-01T00:00:00.000Z", + expiresAt: "1970-01-01T00:10:00.000Z", + jobId: "job-start-1", + }); + const signed = signApnsDeliveryJob({ + secret: config.apnsDeliveryJobSigningSecret, + payload, + }); + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + const result = yield* deliveries.processSignedJob(signed); + + // The start was decided from an aggregate that a newer terminal publish + // has since invalidated; delivering it would birth an orphan activity. + expect(result).toMatchObject({ + kind: "live_activity_start", + ok: true, + apnsStatus: null, + }); + expect(attempts).toMatchObject([ + { + kind: "live_activity_start", + sourceJobId: "job-start-1", + apnsReason: "Stale APNs start job skipped.", + }, + ]); + expect(clearedStarts).toMatchObject([{ userId: target.user_id, deviceId: target.device_id }]); + }).pipe(Effect.provide(makeLayer({ attempts, clearedStarts, activityStates: [] }))); + }); + it.effect("processes signed jobs through APNs and records attempts", () => { const attempts: Array = []; const transportErrors: Array = []; @@ -1177,3 +1424,171 @@ describe("ApnsDeliveries", () => { ); }); }); + +describe("live activity alert decisions", () => { + const preferences = { + liveActivitiesEnabled: true, + notificationsEnabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + }; + + const attentionRow = { + ...aggregate.activities[0]!, + threadId: "thread-2" as RelayAgentActivityState["threadId"], + threadTitle: "Blocked thread", + phase: "waiting_for_approval" as const, + status: "Approval", + }; + + it("alerts when a thread newly enters an attention phase", () => { + const alert = ApnsDeliveries.alertForAttentionTransition({ + previousAggregate: aggregate, + nextAggregate: { + ...aggregate, + activeCount: 2, + activities: [...aggregate.activities, attentionRow], + }, + preferences, + }); + expect(alert).toEqual({ title: "Blocked thread", body: "Approval: Project" }); + }); + + it("stays silent when the attention phase was already delivered", () => { + const withAttention = { + ...aggregate, + activeCount: 2, + activities: [...aggregate.activities, attentionRow], + }; + expect( + ApnsDeliveries.alertForAttentionTransition({ + previousAggregate: withAttention, + nextAggregate: withAttention, + preferences, + }), + ).toBeNull(); + }); + + it("stays silent without a delivered baseline so replays cannot buzz", () => { + expect( + ApnsDeliveries.alertForAttentionTransition({ + previousAggregate: null, + nextAggregate: { ...aggregate, activities: [attentionRow] }, + preferences, + }), + ).toBeNull(); + }); + + it("honors the per-event notification switch for attention alerts", () => { + expect( + ApnsDeliveries.alertForAttentionTransition({ + previousAggregate: aggregate, + nextAggregate: { + ...aggregate, + activeCount: 2, + activities: [...aggregate.activities, attentionRow], + }, + preferences: { ...preferences, notifyOnApproval: false }, + }), + ).toBeNull(); + }); + + it("summarizes multiple newly blocked threads in one alert", () => { + const secondAttentionRow = { + ...attentionRow, + threadId: "thread-3" as RelayAgentActivityState["threadId"], + threadTitle: "Other blocked thread", + phase: "waiting_for_input" as const, + status: "Input", + }; + const alert = ApnsDeliveries.alertForAttentionTransition({ + previousAggregate: aggregate, + nextAggregate: { + ...aggregate, + activeCount: 3, + activities: [...aggregate.activities, attentionRow, secondAttentionRow], + }, + preferences, + }); + expect(alert).toEqual({ + title: "2 agents need attention", + body: "Blocked thread, Other blocked thread", + }); + }); + + it("alerts for a terminal aggregate and honors the completion switch", () => { + const terminalAggregate = { + ...aggregate, + activeCount: 0, + activities: [{ ...aggregate.activities[0]!, phase: "completed" as const, status: "Done" }], + }; + expect( + ApnsDeliveries.alertForTerminalAggregate({ aggregate: terminalAggregate, preferences }), + ).toEqual({ title: "Thread", body: "Done: Project" }); + expect( + ApnsDeliveries.alertForTerminalAggregate({ + aggregate: terminalAggregate, + preferences: { ...preferences, notifyOnCompletion: false }, + }), + ).toBeNull(); + expect(ApnsDeliveries.alertForTerminalAggregate({ aggregate: null, preferences })).toBeNull(); + }); + + it("alerts when a previously active thread finishes mid-flight", () => { + const doneRow = { + ...aggregate.activities[0]!, + phase: "completed" as const, + status: "Done", + }; + const next = { + ...aggregate, + activeCount: 0, + activities: [attentionRow, doneRow], + }; + expect( + ApnsDeliveries.alertForNewlyTerminal({ + previousAggregate: aggregate, + nextAggregate: next, + preferences, + nowMs: 0, + }), + ).toEqual({ title: "Thread", body: "Done: Project" }); + // The completion switch mutes it. + expect( + ApnsDeliveries.alertForNewlyTerminal({ + previousAggregate: aggregate, + nextAggregate: next, + preferences: { ...preferences, notifyOnCompletion: false }, + nowMs: 0, + }), + ).toBeNull(); + // No baseline means no transition to ring on. + expect( + ApnsDeliveries.alertForNewlyTerminal({ + previousAggregate: null, + nextAggregate: next, + preferences, + nowMs: 0, + }), + ).toBeNull(); + // A Done row that was already terminal (or absent) before stays silent. + expect( + ApnsDeliveries.alertForNewlyTerminal({ + previousAggregate: next, + nextAggregate: next, + preferences, + nowMs: 0, + }), + ).toBeNull(); + expect( + ApnsDeliveries.alertForNewlyTerminal({ + previousAggregate: { ...aggregate, activities: [attentionRow] }, + nextAggregate: next, + preferences, + nowMs: 0, + }), + ).toBeNull(); + }); +}); diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index c83eaf34f2e..9b86ed46417 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -18,6 +18,8 @@ import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import { + isExpiredAgentActivityState, + isTerminalPhase, sanitizeAgentActivityAggregateState, sanitizeApnsNotificationPayload, } from "./agentActivityPayloads.ts"; @@ -26,12 +28,14 @@ import { ApnsDeliveryJobLiveActivityAggregateMissing, ApnsDeliveryJobPushNotificationMissing, ApnsDeliveryJobQueuePayloadInvalid, + type ApnsLiveActivityAlert, type ApnsNotificationPayload, SignedApnsDeliveryJob, isApnsDeliveryJobVerificationError, verifySignedApnsDeliveryJob, type ApnsDeliveryJobVerificationError, } from "./apnsDeliveryJobs.ts"; +import * as AgentActivityRows from "./AgentActivityRows.ts"; import * as DeliveryAttempts from "./DeliveryAttempts.ts"; import * as LiveActivities from "./LiveActivities.ts"; import * as RelayConfiguration from "../Config.ts"; @@ -39,6 +43,10 @@ import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts"; import { withSpanAttributes } from "../observability.ts"; const MIN_LIVE_ACTIVITY_UPDATE_INTERVAL_MS = 15_000; +// How long a just-armed card may sit with an empty aggregate before an end is +// warranted; covers the gap between arming on send and the environment's +// first publish reaching the relay. +const FRESHLY_ARMED_GRACE_MS = 2 * 60 * 1_000; const PERMANENT_APNS_TOKEN_REASONS = new Set([ "BadDeviceToken", "DeviceTokenNotForTopic", @@ -55,11 +63,13 @@ type ChosenLiveActivityDelivery = readonly kind: "live_activity_start" | "live_activity_update"; readonly token: string; readonly aggregate: RelayAgentActivityAggregateState; + readonly alert: ApnsLiveActivityAlert | null; } | { readonly kind: "live_activity_end"; readonly token: string; readonly aggregate: RelayAgentActivityAggregateState | null; + readonly alert: ApnsLiveActivityAlert | null; }; type ChosenPushNotificationDelivery = { @@ -130,6 +140,154 @@ 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 isAttentionPhase(phase: string): boolean { + return phase === "waiting_for_approval" || phase === "waiting_for_input"; +} + +// Honors the same per-event notification switches the push channel uses; a +// missing/corrupt preferences blob only disables nothing (matching how the +// liveActivitiesEnabled check treats it), since every registration writes one. +function alertAllowedForPhase( + preferences: RelayAgentAwarenessPreferences | null, + phase: string, +): boolean { + if (preferences === null) { + return true; + } + switch (phase) { + case "waiting_for_approval": + return preferences.notifyOnApproval; + case "waiting_for_input": + return preferences.notifyOnInput; + case "completed": + return preferences.notifyOnCompletion; + case "failed": + return preferences.notifyOnFailure; + default: + return false; + } +} + +// Alert copy for an update whose aggregate contains threads that were NOT in an +// attention phase in the previously delivered aggregate. A null previous +// aggregate means there is no known baseline (fresh registration, replay after +// data loss) — alerting there would buzz on reconnect, not on a transition. +export function alertForAttentionTransition(input: { + readonly previousAggregate: RelayAgentActivityAggregateState | null; + readonly nextAggregate: RelayAgentActivityAggregateState; + readonly preferences: RelayAgentAwarenessPreferences | null; +}): ApnsLiveActivityAlert | null { + if (input.previousAggregate === null) { + return null; + } + const previouslyAttention = new Set( + input.previousAggregate.activities + .filter((row) => isAttentionPhase(row.phase)) + .map((row) => row.threadId), + ); + const newlyAttention = input.nextAggregate.activities.filter( + (row) => + isAttentionPhase(row.phase) && + !previouslyAttention.has(row.threadId) && + alertAllowedForPhase(input.preferences, row.phase), + ); + const first = newlyAttention[0]; + if (!first) { + return null; + } + if (newlyAttention.length === 1) { + return { title: first.threadTitle, body: `${first.status}: ${first.projectTitle}` }; + } + return { + title: `${newlyAttention.length} agents need attention`, + body: newlyAttention.map((row) => row.threadTitle).join(", "), + }; +} + +// Alert copy for an update whose aggregate contains threads that finished +// (Done/Failed) since the previously delivered aggregate — the mid-flight +// completion buzz while other agents keep the activity alive. Requires the +// thread to have been present and non-terminal before, so a baseline-less +// replay or a row that merely fell off the display cap never rings. +function newlyTerminalRows( + previousAggregate: RelayAgentActivityAggregateState | null, + nextAggregate: RelayAgentActivityAggregateState, +): ReadonlyArray { + if (previousAggregate === null) { + return []; + } + const previousPhases = new Map( + previousAggregate.activities.map((row) => [row.threadId, row.phase]), + ); + return nextAggregate.activities.filter((row) => { + if (row.phase !== "completed" && row.phase !== "failed") { + return false; + } + const previousPhase = previousPhases.get(row.threadId); + return ( + previousPhase !== undefined && previousPhase !== "completed" && previousPhase !== "failed" + ); + }); +} + +function isFreshTerminalRow( + row: RelayAgentActivityAggregateState["activities"][number], + nowMs: number, +): boolean { + const updatedAtMs = Option.match(DateTime.make(row.updatedAt), { + onNone: () => null, + onSome: (dt) => dt.epochMilliseconds, + }); + return updatedAtMs !== null && nowMs - updatedAtMs <= TERMINAL_NOTIFICATION_FRESHNESS_MS; +} + +export function alertForNewlyTerminal(input: { + readonly previousAggregate: RelayAgentActivityAggregateState | null; + readonly nextAggregate: RelayAgentActivityAggregateState; + readonly preferences: RelayAgentAwarenessPreferences | null; + readonly nowMs: number; +}): ApnsLiveActivityAlert | null { + const newlyTerminal = newlyTerminalRows(input.previousAggregate, input.nextAggregate).filter( + (row) => + alertAllowedForPhase(input.preferences, row.phase) && + // Replays of old aggregates (server restarts, redeliveries) repaint + // state without ringing; only fresh completions buzz. + isFreshTerminalRow(row, input.nowMs), + ); + const first = newlyTerminal[0]; + if (!first) { + return null; + } + if (newlyTerminal.length === 1) { + return { title: first.threadTitle, body: `${first.status}: ${first.projectTitle}` }; + } + return { + title: `${newlyTerminal.length} agents finished`, + body: newlyTerminal.map((row) => row.threadTitle).join(", "), + }; +} + +// Alert copy for an end event carrying a terminal (Done/Failed) aggregate. +export function alertForTerminalAggregate(input: { + readonly aggregate: RelayAgentActivityAggregateState | null; + readonly preferences: RelayAgentAwarenessPreferences | null; +}): ApnsLiveActivityAlert | null { + const row = input.aggregate?.activities[0]; + if (!row || (row.phase !== "completed" && row.phase !== "failed")) { + return null; + } + if (!alertAllowedForPhase(input.preferences, row.phase)) { + return null; + } + return { title: row.threadTitle, body: `${row.status}: ${row.projectTitle}` }; +} + function shouldUpdateLiveActivity(input: { readonly previousAggregate: RelayAgentActivityAggregateState | null; readonly nextAggregate: RelayAgentActivityAggregateState; @@ -139,11 +297,20 @@ 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; + } + // A thread finishing must never be throttled away: when a completion and a + // new start land in the same window, activeCount is unchanged and the Done + // transition (and its alert) would otherwise be suppressed. + if (newlyTerminalRows(input.previousAggregate, input.nextAggregate).length > 0) { + return true; } const lastDeliveryAtMs = input.lastDeliveryAt === null @@ -159,9 +326,14 @@ function shouldUpdateLiveActivity(input: { ); } +// Completions replayed long after the fact (server restarts republish every +// recently-finished thread) must not ring the device again. +const TERMINAL_NOTIFICATION_FRESHNESS_MS = 2 * 60 * 1_000; + function notificationForAggregate(input: { readonly target: LiveActivities.TargetRow; readonly aggregate: RelayAgentActivityAggregateState | null; + readonly nowMs: number; }): ApnsNotificationPayload | null { if (!input.target.push_token || input.aggregate === null) { return null; @@ -174,6 +346,15 @@ function notificationForAggregate(input: { if (!activity) { return null; } + if (activity.phase === "completed" || activity.phase === "failed") { + const updatedAtMs = Option.match(DateTime.make(activity.updatedAt), { + onNone: () => null, + onSome: (dt) => dt.epochMilliseconds, + }); + if (updatedAtMs === null || input.nowMs - updatedAtMs > TERMINAL_NOTIFICATION_FRESHNESS_MS) { + return null; + } + } const enabled = (activity.phase === "waiting_for_approval" && preferences.notifyOnApproval) || (activity.phase === "waiting_for_input" && preferences.notifyOnInput) || @@ -191,62 +372,85 @@ 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 { - const hasActiveActivity = - input.target.ended_at === null && - (input.target.remote_start_queued_at !== null || - input.target.remote_started_at !== null || - input.target.activity_push_token !== null); +}): ChosenLiveActivityDelivery | "suppressed" | null { const preferences = parsePreferences(input.target.preferences_json); if (preferences?.liveActivitiesEnabled === false) { - return hasActiveActivity && input.target.activity_push_token + return input.target.activity_push_token ? { kind: "live_activity_end", token: input.target.activity_push_token, aggregate: null, + alert: null, } : null; } - if (input.aggregate === null || input.aggregate.activeCount === 0) { - return hasActiveActivity && input.target.activity_push_token - ? { - kind: "live_activity_end", - token: input.target.activity_push_token, - aggregate: input.aggregate, - } - : null; - } - if (!hasActiveActivity) { - return input.target.push_to_start_token - ? { - kind: "live_activity_start", - token: input.target.push_to_start_token, - aggregate: input.aggregate, - } - : null; - } + // Activities are started by the app in the foreground, never remotely. + // Without a registered token there is nothing addressable; attention + // transitions fall back to the push notification channel until the user + // next arms the card from the app. if (!input.target.activity_push_token) { return null; } + // An armed card always shows content: live agents, or recently finished + // ones (the publisher keeps Done/Failed rows in the aggregate for a + // while). A null aggregate means there is truly nothing left to show, so + // the card ends — arming is cheap now that the app re-arms on any open + // with content. + if (input.aggregate === null) { + // Except right after arming: the app arms the card the moment the user + // starts work, and the token registration's replay can land before the + // environment's first publish for the brand-new thread. Ending here + // would retire the token and orphan the card at its seed content, so a + // freshly armed card keeps its seed until real state arrives. + const armedAtMs = Option.match( + input.target.remote_started_at === null + ? Option.none() + : DateTime.make(input.target.remote_started_at), + { onNone: () => null, onSome: (dt) => dt.epochMilliseconds }, + ); + if (armedAtMs !== null && input.nowMs - armedAtMs < FRESHLY_ARMED_GRACE_MS) { + return null; + } + return { + kind: "live_activity_end", + token: input.target.activity_push_token, + aggregate: null, + alert: null, + }; + } + const nextAggregate = input.aggregate; + const previousAggregate = parseAggregate(input.target.last_aggregate_json); return shouldUpdateLiveActivity({ - previousAggregate: parseAggregate(input.target.last_aggregate_json), - nextAggregate: input.aggregate, + previousAggregate, + nextAggregate, 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, + aggregate: nextAggregate, + alert: + alertForAttentionTransition({ + previousAggregate, + nextAggregate, + preferences, + }) ?? + alertForNewlyTerminal({ + previousAggregate, + nextAggregate, + preferences, + nowMs: input.nowMs, + }), } - : null; + : "suppressed"; } function chooseDelivery(input: { @@ -255,6 +459,9 @@ function chooseDelivery(input: { readonly nowMs: number; }): ChosenDelivery | null { const liveActivityDelivery = chooseLiveActivityDelivery(input); + if (liveActivityDelivery === "suppressed") { + return null; + } if (liveActivityDelivery) { return liveActivityDelivery; } @@ -365,6 +572,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: { @@ -392,10 +617,12 @@ export type SendLiveActivityDeliveryInput = | (SendLiveActivityDeliveryInputBase & { readonly kind: "live_activity_start" | "live_activity_update"; readonly aggregate: RelayAgentActivityAggregateState; + readonly alert?: ApnsLiveActivityAlert | null; }) | (SendLiveActivityDeliveryInputBase & { readonly kind: "live_activity_end"; readonly aggregate: RelayAgentActivityAggregateState | null; + readonly alert?: ApnsLiveActivityAlert | null; }); function makeLiveActivityDeliveryRequest( @@ -419,6 +646,7 @@ function makeLiveActivityDeliveryRequest( ...base, event: deliveryEvent(input.kind), state: input.aggregate, + alert: input.alert ?? null, }), }; case "live_activity_end": @@ -429,6 +657,7 @@ function makeLiveActivityDeliveryRequest( ...base, event: "end", state: input.aggregate, + alert: input.alert ?? null, }), }; } @@ -467,6 +696,31 @@ export const make = Effect.gen(function* () { const deliveryQueue = yield* ApnsDeliveryQueue.ApnsDeliveryQueue; const config = yield* RelayConfiguration.RelayConfiguration; const apns = yield* Apns.ApnsClient; + const activityRows = yield* AgentActivityRows.AgentActivityRows; + + // Start jobs are decided at publish time, but consecutive publishes land in + // the same queue batch: a start chosen from a running aggregate can be + // delivered moments after a newer terminal publish already ended the user's + // work, birthing an orphan activity that shows stale content forever (no + // token is ever registered for it, so nothing can update or end it). + // Re-validate at delivery time that the user still has live work; fail open + // on persistence errors so a database hiccup never drops a legitimate start. + const userStillHasLiveWork = Effect.fnUntraced(function* (userId: string) { + const now = yield* DateTime.now; + return yield* activityRows.listForUser({ userId }).pipe( + Effect.map((states) => + states.some( + (state) => + !isTerminalPhase(state) && !isExpiredAgentActivityState(state, now.epochMilliseconds), + ), + ), + Effect.catchCause((cause) => + Effect.logWarning("live-work recheck failed; allowing queued start", { cause }).pipe( + Effect.as(true), + ), + ), + ); + }); const isCurrentSignedJobToken = Effect.fnUntraced(function* (input: { readonly target: LiveActivityDeliveryTarget; @@ -538,9 +792,25 @@ export const make = Effect.gen(function* () { return staleJobResult({ deviceId: input.target.device_id, kind: input.kind }); } } + if ( + input.kind === "live_activity_start" && + !(yield* userStillHasLiveWork(input.target.user_id)) + ) { + yield* liveActivities.clearStartQueued({ + userId: input.target.user_id, + deviceId: input.target.device_id, + }); + if (input.sourceJobId) { + yield* attempts.completeSourceJob({ + sourceJobId: input.sourceJobId, + apnsReason: "Stale APNs start job skipped.", + }); + } + return staleJobResult({ deviceId: input.target.device_id, kind: input.kind }); + } const result = yield* apns .sendLiveActivityRequest({ - credentials: config.apns, + credentials: credentialsForTarget(config.apns, input.target), request, issuedAtUnixSeconds: epochSeconds, }) @@ -663,7 +933,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,22 +1022,28 @@ 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, kind: payload.kind, aggregate: payload.aggregate, + alert: payload.alert ?? null, }); case "live_activity_end": return sendLiveActivity({ 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, kind: payload.kind, aggregate: payload.aggregate, + alert: payload.alert ?? null, }); case "push_notification": if (payload.notification === null) { @@ -783,6 +1059,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, @@ -797,13 +1075,20 @@ export const make = Effect.gen(function* () { sendPushNotification, processSignedJob, sendPushNotificationForTarget: Effect.fnUntraced(function* (input) { - const notification = notificationForAggregate(input); + const now = yield* DateTime.now; + const notification = notificationForAggregate({ + target: input.target, + aggregate: input.aggregate, + nowMs: now.epochMilliseconds, + }); const token = input.target.push_token; return yield* notification && token ? deliveryQueue.enqueuePushNotification({ 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,26 +1107,47 @@ 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; } + const notification = notificationForAggregate({ + target: input.target, + aggregate: input.aggregate, + nowMs: input.nowMs, + }); + // The end event doubles as the "task finished" moment. When a companion + // push notification is about to ring the device (below), the activity end + // stays silent; otherwise the end itself carries the alert so LA-only + // users still get the buzz. + const alert = + delivery.kind === "live_activity_end" + ? notification && input.target.push_token + ? null + : alertForTerminalAggregate({ + aggregate: delivery.aggregate, + preferences: parsePreferences(input.target.preferences_json), + }) + : delivery.alert; const result = yield* deliveryQueue.enqueueLiveActivity({ userId: input.target.user_id, deviceId: input.target.device_id, kind: delivery.kind, token: delivery.token, + bundleId: input.target.bundle_id, + apsEnvironment: input.target.aps_environment, aggregate: delivery.aggregate, - }); - const notification = notificationForAggregate({ - target: input.target, - aggregate: input.aggregate, + alert, }); if (delivery.kind === "live_activity_end" && notification && input.target.push_token) { yield* deliveryQueue.enqueuePushNotification({ 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..3b095d6fd56 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts @@ -58,12 +58,17 @@ 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"]; + readonly alert?: ApnsDeliveryJobPayload["alert"]; }) => 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 +165,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/ApnsProviderTokens.test.ts b/infra/relay/src/agentActivity/ApnsProviderTokens.test.ts new file mode 100644 index 00000000000..d98b9f920be --- /dev/null +++ b/infra/relay/src/agentActivity/ApnsProviderTokens.test.ts @@ -0,0 +1,75 @@ +import * as NodeCrypto from "node:crypto"; + +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; + +import * as ApnsProviderTokens from "./ApnsProviderTokens.ts"; + +const { privateKey } = NodeCrypto.generateKeyPairSync("ec", { + namedCurve: "prime256v1", + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, +}); + +const signingInput = { + teamId: "team-1", + keyId: "key-1", + privateKey: Redacted.make(privateKey), +}; + +const WINDOW = ApnsProviderTokens.APNS_JWT_REUSE_SECONDS; + +const decodeJwtPayload = Schema.decodeEffect( + Schema.fromJsonString(Schema.Struct({ iat: Schema.Number })), +); + +describe("ApnsProviderTokens", () => { + it.effect("derives the byte-identical token across isolates within a window", () => { + ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); + return Effect.gen(function* () { + const tokens = yield* ApnsProviderTokens.ApnsProviderTokens; + const first = yield* tokens.getJwt({ ...signingInput, issuedAtUnixSeconds: WINDOW + 10 }); + + // A fresh isolate has no cache; deterministic signing plus quantized + // iat must still reproduce the exact same JWT for the same window. + ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); + const second = yield* tokens.getJwt({ + ...signingInput, + issuedAtUnixSeconds: WINDOW * 2 - 1, + }); + expect(second).toBe(first); + + const payload = yield* decodeJwtPayload( + Buffer.from(first.split(".")[1]!, "base64url").toString("utf8"), + ); + expect(payload.iat).toBe(WINDOW); + ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); + }).pipe(Effect.provide(ApnsProviderTokens.layer)); + }); + + it.effect("rolls to a new token at the window boundary", () => { + ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); + return Effect.gen(function* () { + const tokens = yield* ApnsProviderTokens.ApnsProviderTokens; + const first = yield* tokens.getJwt({ ...signingInput, issuedAtUnixSeconds: WINDOW + 10 }); + const next = yield* tokens.getJwt({ ...signingInput, issuedAtUnixSeconds: WINDOW * 2 }); + expect(next).not.toBe(first); + ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); + }).pipe(Effect.provide(ApnsProviderTokens.layer)); + }); + + it.effect("serves repeat pushes from the isolate cache without re-signing", () => { + ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); + return Effect.gen(function* () { + const tokens = yield* ApnsProviderTokens.ApnsProviderTokens; + const first = yield* tokens.getJwt({ ...signingInput, issuedAtUnixSeconds: WINDOW + 10 }); + const again = yield* tokens.getJwt({ ...signingInput, issuedAtUnixSeconds: WINDOW + 500 }); + // Deterministic signing makes equality hold either way; toBe on the + // exact string documents the cache contract. + expect(again).toBe(first); + ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); + }).pipe(Effect.provide(ApnsProviderTokens.layer)); + }); +}); diff --git a/infra/relay/src/agentActivity/ApnsProviderTokens.ts b/infra/relay/src/agentActivity/ApnsProviderTokens.ts new file mode 100644 index 00000000000..329f0728320 --- /dev/null +++ b/infra/relay/src/agentActivity/ApnsProviderTokens.ts @@ -0,0 +1,63 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { + apnsProviderTokenCacheKey, + makeApnsJwt, + type ApnsJwtError, + type ApnsJwtSigningInput, +} from "./apnsJwt.ts"; + +// APNs requires REUSING the provider token: refreshing it more than roughly +// once per 20 minutes returns 429 TooManyProviderTokenUpdates and drops the +// push (observed live: bursty Live Activity updates got 429'd, leaving stale +// lock-screen state). Reuse each signed JWT for most of its 60-minute +// validity. +export const APNS_JWT_REUSE_SECONDS = 45 * 60; + +export class ApnsProviderTokens extends Context.Service< + ApnsProviderTokens, + { + readonly getJwt: (input: ApnsJwtSigningInput) => Effect.Effect; + } +>()("t3code-relay/agentActivity/ApnsProviderTokens") {} + +interface CachedProviderToken { + readonly jwt: string; + readonly issuedAtUnixSeconds: number; +} + +// Signing is deterministic (RFC 6979) and iat is quantized below, so every +// isolate independently derives the byte-identical token for a window; this +// map only avoids re-signing on every push. No shared storage is needed, and +// no provider token is ever written anywhere. +const isolateTokenCache = new Map(); + +export function __resetApnsProviderTokenCacheForTest(): void { + isolateTokenCache.clear(); +} + +// Quantize iat to the reuse window so all isolates agree on it. The token's +// age stays under APNs' 60-minute limit, and the whole fleet rolls to the +// next token at the same instant — one provider-token update per window. +export function quantizedApnsJwtIssuedAt(nowUnixSeconds: number): number { + return Math.floor(nowUnixSeconds / APNS_JWT_REUSE_SECONDS) * APNS_JWT_REUSE_SECONDS; +} + +export const make = () => + ApnsProviderTokens.of({ + getJwt: Effect.fnUntraced(function* (input) { + const issuedAtUnixSeconds = quantizedApnsJwtIssuedAt(input.issuedAtUnixSeconds); + const cacheKey = apnsProviderTokenCacheKey(input); + const cached = isolateTokenCache.get(cacheKey); + if (cached && cached.issuedAtUnixSeconds === issuedAtUnixSeconds) { + return cached.jwt; + } + const jwt = yield* makeApnsJwt({ ...input, issuedAtUnixSeconds }); + isolateTokenCache.set(cacheKey, { jwt, issuedAtUnixSeconds }); + return jwt; + }), + }); + +export const layer = Layer.succeed(ApnsProviderTokens, make()); 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..42087128c33 100644 --- a/infra/relay/src/agentActivity/Devices.ts +++ b/infra/relay/src/agentActivity/Devices.ts @@ -81,45 +81,45 @@ export const make = Effect.gen(function* () { const updatedAt = DateTime.formatIso(yield* DateTime.now); const registration = input.registration; - yield* Effect.all( - [ - registration.pushToken - ? db - .update(relayMobileDevices) - .set({ pushToken: null, updatedAt }) - .where(eq(relayMobileDevices.pushToken, registration.pushToken)) - .pipe( - Effect.mapError( - (cause) => - new DeviceRegistrationPersistenceError({ - userId: input.userId, - deviceId: registration.deviceId, - stage: "claim-push-token", - cause, - }), - ), - ) - : Effect.void, - registration.pushToStartToken - ? db - .update(relayMobileDevices) - .set({ pushToStartToken: null, updatedAt }) - .where(eq(relayMobileDevices.pushToStartToken, registration.pushToStartToken)) - .pipe( - Effect.mapError( - (cause) => - new DeviceRegistrationPersistenceError({ - userId: input.userId, - deviceId: registration.deviceId, - stage: "claim-push-to-start-token", - cause, - }), - ), - ) - : Effect.void, - ], - { discard: true }, - ); + // The drizzle handle is alchemy's lazy proxy chain: it only becomes a + // real Effect when consumed via `yield*`. Handing it to Effect.all sends + // the raw Proxy into the fiber runtime, which spins the isolate at 100% + // CPU (registrations then hang until the client aborts) — keep every db + // chain directly yielded. + if (registration.pushToken) { + yield* db + .update(relayMobileDevices) + .set({ pushToken: null, updatedAt }) + .where(eq(relayMobileDevices.pushToken, registration.pushToken)) + .pipe( + Effect.mapError( + (cause) => + new DeviceRegistrationPersistenceError({ + userId: input.userId, + deviceId: registration.deviceId, + stage: "claim-push-token", + cause, + }), + ), + ); + } + if (registration.pushToStartToken) { + yield* db + .update(relayMobileDevices) + .set({ pushToStartToken: null, updatedAt }) + .where(eq(relayMobileDevices.pushToStartToken, registration.pushToStartToken)) + .pipe( + Effect.mapError( + (cause) => + new DeviceRegistrationPersistenceError({ + userId: input.userId, + deviceId: registration.deviceId, + stage: "claim-push-to-start-token", + cause, + }), + ), + ); + } yield* db .insert(relayMobileDevices) @@ -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, @@ -168,49 +177,46 @@ export const make = Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ "relay.mobile.device_id": input.deviceId, }); - yield* Effect.all( - [ - db - .delete(relayLiveActivities) - .where( - and( - eq(relayLiveActivities.userId, input.userId), - eq(relayLiveActivities.deviceId, input.deviceId), - ), - ) - .pipe( - Effect.mapError( - (cause) => - new DeviceUnregistrationPersistenceError({ - userId: input.userId, - deviceId: input.deviceId, - stage: "delete-live-activity", - cause, - }), - ), - ), - db - .delete(relayMobileDevices) - .where( - and( - eq(relayMobileDevices.userId, input.userId), - eq(relayMobileDevices.deviceId, input.deviceId), - ), - ) - .pipe( - Effect.mapError( - (cause) => - new DeviceUnregistrationPersistenceError({ - userId: input.userId, - deviceId: input.deviceId, - stage: "delete-device", - cause, - }), - ), - ), - ], - { discard: true }, - ); + // Same proxy-chain constraint as register above: db chains must be + // consumed via `yield*`, never passed to Effect.all. + yield* db + .delete(relayLiveActivities) + .where( + and( + eq(relayLiveActivities.userId, input.userId), + eq(relayLiveActivities.deviceId, input.deviceId), + ), + ) + .pipe( + Effect.mapError( + (cause) => + new DeviceUnregistrationPersistenceError({ + userId: input.userId, + deviceId: input.deviceId, + stage: "delete-live-activity", + cause, + }), + ), + ); + yield* db + .delete(relayMobileDevices) + .where( + and( + eq(relayMobileDevices.userId, input.userId), + eq(relayMobileDevices.deviceId, input.deviceId), + ), + ) + .pipe( + Effect.mapError( + (cause) => + new DeviceUnregistrationPersistenceError({ + userId: input.userId, + deviceId: input.deviceId, + stage: "delete-device", + cause, + }), + ), + ); }), listForUser: Effect.fn("relay.devices.listForUser")(function* (input) { const rows = yield* db diff --git a/infra/relay/src/agentActivity/LiveActivities.test.ts b/infra/relay/src/agentActivity/LiveActivities.test.ts index 8f3182279bb..7f2bce87431 100644 --- a/infra/relay/src/agentActivity/LiveActivities.test.ts +++ b/infra/relay/src/agentActivity/LiveActivities.test.ts @@ -198,6 +198,57 @@ describe("LiveActivities", () => { ); }); + it.effect("retires the previous activity token when a start or end is delivered", () => { + const conflictConfigs: Array<{ readonly set?: Record }> = []; + const fakeDb = { + insert: () => ({ + values: () => ({ + onConflictDoUpdate: (config: { readonly set?: Record }) => { + conflictConfigs.push(config); + return Effect.void; + }, + }), + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const liveActivities = yield* LiveActivities.LiveActivities; + const mark = (kind: "live_activity_start" | "live_activity_update" | "live_activity_end") => + liveActivities.markDelivery({ + userId: "user-2", + deviceId: "device-1", + kind, + aggregate, + deliveredAt: "2026-05-25T00:00:10.000Z", + }); + yield* mark("live_activity_start"); + yield* mark("live_activity_update"); + yield* mark("live_activity_end"); + + // A start begins a new activity generation and an end retires the + // current one; both must drop the stored update token so later + // deliveries can't route to the dead activity. Plain updates keep it. + expect(conflictConfigs[0]?.set).toEqual( + expect.objectContaining({ + activityPushToken: null, + remoteStartedAt: "2026-05-25T00:00:10.000Z", + endedAt: null, + }), + ); + expect(conflictConfigs[1]?.set?.activityPushToken).not.toBeNull(); + expect(conflictConfigs[2]?.set).toEqual( + expect.objectContaining({ + activityPushToken: null, + endedAt: "2026-05-25T00:00:10.000Z", + }), + ); + }).pipe( + Effect.provide( + LiveActivities.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))), + ), + ); + }); + it.effect("preserves correlation context and causes for persistence failures", () => { const cause = new Error("database unavailable"); const registration: RelayLiveActivityRegistrationRequest = { diff --git a/infra/relay/src/agentActivity/LiveActivities.ts b/infra/relay/src/agentActivity/LiveActivities.ts index 608ee0704ab..97e87a65f2c 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, @@ -276,10 +280,26 @@ export const make = Effect.gen(function* () { .onConflictDoUpdate({ target: [relayLiveActivities.userId, relayLiveActivities.deviceId], set: { - remoteStartedAt: sql`coalesce( - ${relayLiveActivities.remoteStartedAt}, - excluded.remote_started_at - )`, + // A delivered start begins a NEW activity generation: the stored + // update token belongs to the previous activity (dead once a new + // one starts, and certainly dead after an end), so keep it only + // for plain updates. Deliveries pause until the app registers + // the fresh activity's token; registerLiveActivity + replay then + // reconcile content (or end the activity if work already + // finished). Without this, updates and ends route to the dead + // token and the new lock-screen card is stranded at its start + // content forever. + activityPushToken: + input.kind === "live_activity_update" + ? sql`${relayLiveActivities.activityPushToken}` + : null, + remoteStartedAt: + input.kind === "live_activity_start" + ? input.deliveredAt + : sql`coalesce( + ${relayLiveActivities.remoteStartedAt}, + excluded.remote_started_at + )`, remoteStartQueuedAt: null, endedAt: input.kind === "live_activity_start" diff --git a/infra/relay/src/agentActivity/MobileRegistrations.test.ts b/infra/relay/src/agentActivity/MobileRegistrations.test.ts index a223e9707c4..1f1256e3567 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.test.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts @@ -20,6 +20,7 @@ import * as RelayConfiguration from "../Config.ts"; import * as AgentActivityPublisher from "./AgentActivityPublisher.ts"; import * as ApnsDeliveries from "./ApnsDeliveries.ts"; import * as ApnsClient from "./ApnsClient.ts"; +import * as ApnsProviderTokens from "./ApnsProviderTokens.ts"; import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts"; import * as MobileRegistrations from "./MobileRegistrations.ts"; @@ -70,6 +71,7 @@ function makeAgentActivityRows( return { upsert: () => Effect.void, remove: () => Effect.void, + pruneTerminal: () => Effect.void, listForUser: () => { const activeState: RelayAgentActivityState = { environmentId: "env-1" as RelayAgentActivityState["environmentId"], @@ -147,7 +149,11 @@ function makeRegistrationReplayLayer(input: { }) { return MobileRegistrations.layer.pipe( Layer.provide(AgentActivityPublisher.layer), - Layer.provide(ApnsDeliveries.layer.pipe(Layer.provide(ApnsClient.layer))), + Layer.provide( + ApnsDeliveries.layer.pipe( + Layer.provide(ApnsClient.layer.pipe(Layer.provide(ApnsProviderTokens.layer))), + ), + ), Layer.provide(ApnsDeliveryQueue.layer.pipe(Layer.provide(NodeCryptoLayer.layer))), Layer.provide( Layer.mergeAll( @@ -207,6 +213,7 @@ describe("MobileRegistrations", () => { }), ), Layer.succeed(LiveActivities.LiveActivities, makeLiveActivities()), + Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()), Layer.succeed( AgentActivityPublisher.AgentActivityPublisher, makeAgentActivityPublisher({ @@ -249,6 +256,7 @@ describe("MobileRegistrations", () => { Layer.mergeAll( Layer.succeed(Devices.Devices, makeDevices()), Layer.succeed(LiveActivities.LiveActivities, makeLiveActivities()), + Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()), Layer.succeed( AgentActivityPublisher.AgentActivityPublisher, makeAgentActivityPublisher({ @@ -300,6 +308,7 @@ describe("MobileRegistrations", () => { }), ), Layer.succeed(LiveActivities.LiveActivities, makeLiveActivities()), + Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()), Layer.succeed( AgentActivityPublisher.AgentActivityPublisher, makeAgentActivityPublisher(), @@ -344,6 +353,7 @@ describe("MobileRegistrations", () => { Layer.provide( Layer.mergeAll( Layer.succeed(Devices.Devices, makeDevices()), + Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()), Layer.succeed( LiveActivities.LiveActivities, makeLiveActivities({ @@ -381,8 +391,36 @@ describe("MobileRegistrations", () => { }); }); + it.effect("returns the current aggregate for the app's arming decision", () => { + return Effect.gen(function* () { + const registrations = yield* MobileRegistrations.MobileRegistrations; + const snapshot = yield* registrations.getAgentActivitySnapshot({ userId: "dev:julius" }); + + expect(snapshot.aggregate).toMatchObject({ + activeCount: 1, + activities: [{ threadId: "thread-1", phase: "running" }], + }); + }).pipe( + Effect.provide( + MobileRegistrations.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(Devices.Devices, makeDevices()), + Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()), + Layer.succeed(LiveActivities.LiveActivities, makeLiveActivities()), + Layer.succeed( + AgentActivityPublisher.AgentActivityPublisher, + makeAgentActivityPublisher(), + ), + ), + ), + ), + ), + ); + }); + it.effect( - "starts a remote Live Activity through the real publisher and APNs queue when a device registers after work is already active", + "does not remotely start a Live Activity when a device registers after work is already active", () => { const queuedJobs: Array = []; const queuedStarts: Array< @@ -404,6 +442,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), @@ -432,46 +472,13 @@ describe("MobileRegistrations", () => { }, }); + // Activities are armed by the app in the foreground; a device + // registration alone never remote-starts one, even when work is + // already active and a push-to-start token is on file. expect(result).toEqual({ ok: true }); - expect(registeredDevices).toEqual([ - { - userId: "dev:julius", - registration: { - ...device, - pushToken: "apns-device-token", - pushToStartToken: "push-to-start-token", - }, - }, - ]); - expect(queuedStarts).toMatchObject([ - { - userId: "dev:julius", - deviceId: "device-1", - }, - ]); - expect(queuedJobs).toHaveLength(1); - expect(queuedJobs[0]?.payload).toMatchObject({ - kind: "live_activity_start", - target: { - userId: "dev:julius", - deviceId: "device-1", - token: "push-to-start-token", - }, - aggregate: { - title: "T3 Code", - subtitle: "Agent work in progress", - activeCount: 1, - activities: [ - { - environmentId: "env-1", - threadId: "thread-1", - threadTitle: "Implement APNs", - status: "Working", - }, - ], - }, - notification: null, - }); + expect(registeredDevices).toHaveLength(1); + expect(queuedStarts).toEqual([]); + expect(queuedJobs).toEqual([]); }).pipe(Effect.provide(makeRegistrationReplayLayer({ devices, liveActivities, queuedJobs }))); }, ); diff --git a/infra/relay/src/agentActivity/MobileRegistrations.ts b/infra/relay/src/agentActivity/MobileRegistrations.ts index 0df0379cded..aee83e4b06d 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.ts @@ -1,11 +1,14 @@ import type { + RelayAgentActivitySnapshotResponse, RelayDeviceRegistrationRequest, RelayLiveActivityRegistrationRequest, } from "@t3tools/contracts/relay"; +import * as DateTime from "effect/DateTime"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as AgentActivityRows from "./AgentActivityRows.ts"; import * as Devices from "./Devices.ts"; import * as LiveActivities from "./LiveActivities.ts"; import * as AgentActivityPublisher from "./AgentActivityPublisher.ts"; @@ -13,7 +16,8 @@ import * as AgentActivityPublisher from "./AgentActivityPublisher.ts"; export type MobileRegistrationError = | Devices.DeviceRegistrationPersistenceError | Devices.DeviceUnregistrationPersistenceError - | LiveActivities.LiveActivityRegistrationPersistenceError; + | LiveActivities.LiveActivityRegistrationPersistenceError + | AgentActivityRows.AgentActivityRowListPersistenceError; export class MobileRegistrations extends Context.Service< MobileRegistrations, @@ -30,10 +34,14 @@ export class MobileRegistrations extends Context.Service< readonly userId: string; readonly deviceId: string; }) => Effect.Effect<{ readonly ok: true }, MobileRegistrationError>; + readonly getAgentActivitySnapshot: (input: { + readonly userId: string; + }) => Effect.Effect; } >()("t3code-relay/agentActivity/MobileRegistrations") {} export const make = Effect.gen(function* () { + const rows = yield* AgentActivityRows.AgentActivityRows; const devices = yield* Devices.Devices; const liveActivities = yield* LiveActivities.LiveActivities; const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; @@ -82,6 +90,19 @@ export const make = Effect.gen(function* () { return { ok: true as const }; }, ), + getAgentActivitySnapshot: Effect.fn("relay.mobile_registrations.get_agent_activity_snapshot")( + function* (input) { + const activeStates = yield* rows.listForUser({ userId: input.userId }); + const now = yield* DateTime.now; + return { + aggregate: AgentActivityPublisher.makeAggregateState({ + activeStates, + terminalState: null, + nowMs: now.epochMilliseconds, + }), + }; + }, + ), unregisterDevice: Effect.fn("relay.mobile_registrations.unregister_device")(function* (input) { yield* Effect.annotateCurrentSpan({ "relay.mobile.device_id": input.deviceId, diff --git a/infra/relay/src/agentActivity/agentActivityPayloads.ts b/infra/relay/src/agentActivity/agentActivityPayloads.ts index ed3fc3f0116..33f305fb7a0 100644 --- a/infra/relay/src/agentActivity/agentActivityPayloads.ts +++ b/infra/relay/src/agentActivity/agentActivityPayloads.ts @@ -1,9 +1,45 @@ import type { RelayAgentActivityAggregateRow, RelayAgentActivityAggregateState, + RelayAgentActivityState, } from "@t3tools/contracts/relay"; +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; + import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts"; +export function isTerminalPhase(state: RelayAgentActivityState): boolean { + return state.phase === "completed" || state.phase === "failed"; +} + +// Rows are only removed when their environment publishes a terminal state. An +// environment that dies mid-run (machine off, process killed) never does, so +// without an age cutoff its threads inflate activeCount forever. Actively +// running phases expire quickly; waiting phases can legitimately sit for hours +// while a user ignores an approval prompt, so they get a longer window. The +// underlying database row is left in place: a late publish for the thread +// refreshes updatedAt and the row becomes visible again. +const RUNNING_AGENT_ACTIVITY_ROW_TTL_MS = 2 * 60 * 60 * 1_000; +const WAITING_AGENT_ACTIVITY_ROW_TTL_MS = 24 * 60 * 60 * 1_000; + +export function isExpiredAgentActivityState( + state: RelayAgentActivityState, + nowMs: number, +): boolean { + const updatedAtMs = Option.match(DateTime.make(state.updatedAt), { + onNone: () => Number.NaN, + onSome: (dt) => dt.epochMilliseconds, + }); + if (Number.isNaN(updatedAtMs)) { + return true; + } + const ttlMs = + state.phase === "running" || state.phase === "starting" + ? RUNNING_AGENT_ACTIVITY_ROW_TTL_MS + : WAITING_AGENT_ACTIVITY_ROW_TTL_MS; + return nowMs - updatedAtMs > ttlMs; +} + const MAX_SUMMARY_TEXT_LENGTH = 120; const MAX_STATUS_TEXT_LENGTH = 40; const MAX_DEEP_LINK_LENGTH = 512; diff --git a/infra/relay/src/agentActivity/apnsDeliveryJobs.ts b/infra/relay/src/agentActivity/apnsDeliveryJobs.ts index 2af61085eab..027b17bd5e0 100644 --- a/infra/relay/src/agentActivity/apnsDeliveryJobs.ts +++ b/infra/relay/src/agentActivity/apnsDeliveryJobs.ts @@ -41,6 +41,15 @@ export const ApnsNotificationPayload = Schema.Struct({ }); export type ApnsNotificationPayload = typeof ApnsNotificationPayload.Type; +// Alert copy attached to a Live Activity update/end push. Its presence makes +// the update "alerting": iOS wakes the screen, plays the haptic, and briefly +// expands the Dynamic Island instead of silently redrawing. +export const ApnsLiveActivityAlert = Schema.Struct({ + title: Schema.String, + body: Schema.String, +}); +export type ApnsLiveActivityAlert = typeof ApnsLiveActivityAlert.Type; + export const ApnsDeliveryJobPayload = Schema.Struct({ version: Schema.Literal(1), jobId: Schema.String, @@ -49,9 +58,15 @@ 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), + // Optional so jobs queued by older relay builds still decode. + alert: Schema.optional(Schema.NullOr(ApnsLiveActivityAlert)), createdAt: Schema.String, expiresAt: Schema.String, }); @@ -224,8 +239,11 @@ 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 alert?: ApnsLiveActivityAlert | null | undefined; readonly createdAt: string; readonly expiresAt: string; readonly jobId: string; @@ -238,9 +256,14 @@ 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, + // Omitted (not null) when absent so signatures stay identical to jobs from + // relay builds that predate the field. + ...(input.alert ? { alert: input.alert } : {}), createdAt: input.createdAt, expiresAt: input.expiresAt, }; diff --git a/infra/relay/src/agentActivity/apnsJwt.ts b/infra/relay/src/agentActivity/apnsJwt.ts new file mode 100644 index 00000000000..74c3d15ce7d --- /dev/null +++ b/infra/relay/src/agentActivity/apnsJwt.ts @@ -0,0 +1,160 @@ +import * as NodeCrypto from "node:crypto"; + +import { p256 } from "@noble/curves/nist"; +import { sha256 } from "@noble/hashes/sha2"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as Redacted from "effect/Redacted"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; + +import type { ApnsCredentials } from "../Config.ts"; + +export class ApnsJwtEncodingError extends Schema.TaggedErrorClass()( + "ApnsJwtEncodingError", + { + component: Schema.Literals(["header", "payload"]), + teamId: Schema.String, + keyId: Schema.String, + issuedAtUnixSeconds: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to encode APNs JWT ${this.component} for key ${this.keyId}.`; + } +} + +export class ApnsJwtSigningError extends Schema.TaggedErrorClass()( + "ApnsJwtSigningError", + { + teamId: Schema.String, + keyId: Schema.String, + issuedAtUnixSeconds: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to sign APNs JWT for key ${this.keyId}.`; + } +} + +export type ApnsJwtError = ApnsJwtEncodingError | ApnsJwtSigningError; + +const encodeApnsJwtHeaderJson = Schema.encodeEffect( + Schema.fromJsonString( + Schema.Struct({ + alg: Schema.Literal("ES256"), + kid: Schema.String, + }), + ), +); +const encodeApnsJwtPayloadJson = Schema.encodeEffect( + Schema.fromJsonString( + Schema.Struct({ + iss: Schema.String, + iat: Schema.Number, + }), + ), +); + +export interface ApnsJwtSigningInput { + readonly teamId: ApnsCredentials["teamId"]; + readonly keyId: ApnsCredentials["keyId"]; + readonly privateKey: ApnsCredentials["privateKey"]; + readonly issuedAtUnixSeconds: number; +} + +export const makeApnsJwt = Effect.fn("relay.apns.make_jwt")(function* (input: ApnsJwtSigningInput) { + const headerJson = yield* encodeApnsJwtHeaderJson({ alg: "ES256", kid: input.keyId }).pipe( + Effect.mapError( + (cause) => + new ApnsJwtEncodingError({ + component: "header", + teamId: input.teamId, + keyId: input.keyId, + issuedAtUnixSeconds: input.issuedAtUnixSeconds, + cause, + }), + ), + ); + const payloadJson = yield* encodeApnsJwtPayloadJson({ + iss: input.teamId, + iat: input.issuedAtUnixSeconds, + }).pipe( + Effect.mapError( + (cause) => + new ApnsJwtEncodingError({ + component: "payload", + teamId: input.teamId, + keyId: input.keyId, + issuedAtUnixSeconds: input.issuedAtUnixSeconds, + cause, + }), + ), + ); + + const privateKey = Redacted.value(input.privateKey); + const header = Encoding.encodeBase64Url(headerJson); + const payload = Encoding.encodeBase64Url(payloadJson); + const signingInput = `${header}.${payload}`; + + return yield* Effect.try({ + try: () => { + // Deterministic ES256 (RFC 6979 via noble) instead of node's randomized + // signer: identical (key, iat) yields the byte-identical JWT on every + // worker isolate, so the fleet presents one stable provider token to + // APNs without any shared storage. Node crypto only converts the PEM to + // the raw scalar noble signs with. + const scalar = apnsSigningScalar(privateKey); + const signature = p256 + .sign(sha256(new TextEncoder().encode(signingInput)), scalar, { prehash: false }) + .toCompactRawBytes(); + return `${signingInput}.${Encoding.encodeBase64Url(signature)}`; + }, + catch: (cause) => + new ApnsJwtSigningError({ + teamId: input.teamId, + keyId: input.keyId, + issuedAtUnixSeconds: input.issuedAtUnixSeconds, + cause, + }), + }); +}); + +// PEM parsing is pure and the key set is static per deployment; memoize the +// extracted P-256 scalar so signing never re-parses the PKCS8 document. +const signingScalarCache = new Map(); + +function apnsSigningScalar(privateKeyPem: string): Uint8Array { + const cached = signingScalarCache.get(privateKeyPem); + if (cached) { + return cached; + } + const jwk = NodeCrypto.createPrivateKey(privateKeyPem.replace(/\\n/g, "\n")).export({ + format: "jwk", + }); + if (jwk.crv !== "P-256" || typeof jwk.d !== "string") { + throw new Error("APNs signing key is not a P-256 private key."); + } + const scalar = Result.getOrThrowWith( + Encoding.decodeBase64Url(jwk.d), + () => new Error("APNs signing key scalar is not valid base64url."), + ); + signingScalarCache.set(privateKeyPem, scalar); + return scalar; +} + +// Fingerprint the key material so rotated credentials never reuse a JWT +// signed by the previous key. +export function apnsProviderTokenCacheKey(input: { + readonly teamId: string; + readonly keyId: string; + readonly privateKey: ApnsCredentials["privateKey"]; +}): string { + const keyFingerprint = NodeCrypto.createHash("sha256") + .update(Redacted.value(input.privateKey)) + .digest("hex") + .slice(0, 16); + return `${input.teamId}:${input.keyId}:${keyFingerprint}`; +} diff --git a/infra/relay/src/environments/EnvironmentLinker.test.ts b/infra/relay/src/environments/EnvironmentLinker.test.ts index f6bd1c6d977..18a362f5247 100644 --- a/infra/relay/src/environments/EnvironmentLinker.test.ts +++ b/infra/relay/src/environments/EnvironmentLinker.test.ts @@ -109,6 +109,7 @@ const makeRequest = Effect.gen(function* () { function testLayer(input?: { readonly upsert?: EnvironmentLinks.EnvironmentLinks["Service"]["upsert"]; readonly consume?: DpopProofs.DpopProofReplay["Service"]["consume"]; + readonly deprovision?: ManagedEndpointProvider.ManagedEndpointProvider["Service"]["deprovision"]; }) { return EnvironmentLinker.layer.pipe( Layer.provideMerge(RelayTokens.layer), @@ -135,7 +136,7 @@ function testLayer(input?: { revokeForEnvironmentPublicKey: () => Effect.succeed(false), }), Layer.succeed(ManagedEndpointProvider.ManagedEndpointProvider, { - deprovision: () => Effect.void, + deprovision: input?.deprovision ?? (() => Effect.void), provision: () => Effect.succeed({ endpoint: { @@ -173,6 +174,79 @@ describe("EnvironmentLinker", () => { ); }); + it.effect("links a publish-only environment with a non-secure nominal endpoint", () => { + let persistedEndpoint: string | null = null; + let deprovisionedEnvironmentId: string | null = null; + return Effect.gen(function* () { + const now = yield* DateTime.now; + const expiresAt = DateTime.add(now, { minutes: 5 }); + 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/"); + // Downgrading from a managed link must release the previously provisioned + // tunnel; nothing else cleans it up before a full unlink. + expect(deprovisionedEnvironmentId).toBe("env-link-test"); + }).pipe( + Effect.provide( + testLayer({ + upsert: (input) => + Effect.sync(() => { + persistedEndpoint = input.endpoint.httpBaseUrl; + }), + deprovision: (input) => + Effect.sync(() => { + deprovisionedEnvironmentId = input.environmentId; + }), + }), + ), + ); + }); + 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..fbfdc6428a4 100644 --- a/infra/relay/src/environments/EnvironmentLinker.ts +++ b/infra/relay/src/environments/EnvironmentLinker.ts @@ -287,6 +287,27 @@ const make = Effect.gen(function* () { stage: "validate_origin", }); } + // Downgrading a managed link to publish-only must release the tunnel and + // DNS that were provisioned for it — nothing else cleans them up until a + // full unlink. Best effort: a cleanup failure must not block the link + // itself, and the provider treats an absent allocation as already + // deprovisioned, so retrying on every non-tunnel link is cheap. + if (!input.request.managedTunnelsEnabled) { + yield* managedEndpointProvider + .deprovision({ + userId: input.userId, + environmentId: verified.environmentId, + }) + .pipe( + Effect.tapError((error) => + Effect.logWarning("managed endpoint deprovision on publish-only link failed", { + environmentId: verified.environmentId, + errorTag: error._tag, + }), + ), + Effect.ignore, + ); + } const provisioned = input.request.managedTunnelsEnabled ? yield* managedEndpointProvider.provision({ userId: input.userId, @@ -295,7 +316,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/http/Api.test.ts b/infra/relay/src/http/Api.test.ts index 158bcec9803..b43a2837590 100644 --- a/infra/relay/src/http/Api.test.ts +++ b/infra/relay/src/http/Api.test.ts @@ -2,11 +2,14 @@ import { createClerkClient, verifyToken } from "@clerk/backend"; import { describe, expect, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Predicate from "effect/Predicate"; import * as Redacted from "effect/Redacted"; +import * as TestClock from "effect/testing/TestClock"; import * as Tracer from "effect/Tracer"; import * as HttpRouter from "effect/unstable/http/HttpRouter"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; @@ -14,6 +17,7 @@ import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; import { RelayEnvironmentAuth } from "@t3tools/contracts/relay"; import { + RELAY_REQUEST_DEADLINE_MS, relayCors, relayDocsRedirectRoute, relayEnvironmentAuthLayer, @@ -203,6 +207,33 @@ describe("relay request tracing", () => { expect(Option.getOrUndefined(spans[1]!.parent)?.spanId).toBe(spans[0]?.spanId); }), ); + + it.effect("fails hung requests with a 504 before the client's 10s abort", () => + Effect.gen(function* () { + const spans: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + const request = HttpServerRequest.fromWeb( + new Request("https://relay.test/v1/mobile/devices", { method: "POST" }), + ); + + const fiber = yield* traceRelayHttpRequestWith( + Effect.never, + Layer.succeed(Tracer.Tracer, tracer), + ).pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, request), Effect.forkChild); + yield* TestClock.adjust(Duration.millis(RELAY_REQUEST_DEADLINE_MS)); + const response = yield* Fiber.join(fiber); + + expect(response.status).toBe(504); + expect(spans[0]?.attributes.get("relay.request.deadline_exceeded")).toBe(true); + expect(spans[0]?.attributes.get("http.response.status_code")).toBe(504); + }), + ); }); describe("relay routing fallback", () => { diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index 29e2026de3c..386f119802a 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -6,6 +6,7 @@ import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Record from "effect/Record"; import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; @@ -155,6 +156,47 @@ export const relayDocsRedirectRoute = HttpRouter.add( HttpServerResponse.redirect("/docs"), ); +// Shorter than the mobile client's 10s request timeout on purpose: when a +// request hangs (e.g. a stuck upstream query), the client would otherwise +// abort first, the invocation would die with the request span still open, and +// the batched spans would never export — leaving no server-side trace at all. +// Failing server-side first turns the hang into a completed 504 whose trace +// contains the exact child span that stalled, and the response still carries +// the traceparent back to the client. +export const RELAY_REQUEST_DEADLINE_MS = 9_000; + +const relayRequestDeadline = ( + httpEffect: Effect.Effect< + HttpServerResponse.HttpServerResponse, + E, + HttpServerRequest.HttpServerRequest | R + >, +) => + httpEffect.pipe( + Effect.timeoutOption(Duration.millis(RELAY_REQUEST_DEADLINE_MS)), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + yield* Effect.logError("relay request exceeded deadline", { + "http.method": request.method, + "http.url": request.url, + "relay.request.deadline_ms": RELAY_REQUEST_DEADLINE_MS, + }); + yield* Effect.annotateCurrentSpan({ + "relay.request.deadline_exceeded": true, + }); + return HttpServerResponse.jsonUnsafe( + { error: "relay_request_deadline_exceeded" }, + { status: 504 }, + ); + }), + onSome: Effect.succeed, + }), + ), + ); + export const traceRelayHttpRequest = ( httpEffect: Effect.Effect< HttpServerResponse.HttpServerResponse, @@ -164,7 +206,7 @@ export const traceRelayHttpRequest = ( ) => // HttpMiddleware finalizes its span on the dispatcher; do not close a request-scoped exporter first. HttpMiddleware.tracer( - appendRelayTraceContextResponseHeader.pipe(Effect.andThen(httpEffect)), + appendRelayTraceContextResponseHeader.pipe(Effect.andThen(relayRequestDeadline(httpEffect))), ).pipe(Effect.ensuring(Effect.yieldNow)); export const traceRelayHttpRequestWith = ( @@ -391,6 +433,17 @@ export const mobileApi = HttpApiBuilder.group( return yield* registrations.registerLiveActivity({ userId, payload }); }, mapRelayCommonApiErrors("invalid_dpop")), ) + .handle( + "getAgentActivitySnapshot", + Effect.fn("relay.api.mobile.getAgentActivitySnapshot")(function* () { + const { userId, token } = yield* RelayClientPrincipal; + const proofKeyThumbprint = yield* requireDpopPrincipalScope("mobile:registration"); + yield* requireDpopThumbprint(proofKeyThumbprint, { + expectedAccessToken: token, + }).pipe(Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs)); + return yield* registrations.getAgentActivitySnapshot({ userId }); + }, mapRelayCommonApiErrors("invalid_dpop")), + ) .handle( "unregisterDevice", Effect.fn("relay.api.mobile.unregisterDevice")(function* (args) { diff --git a/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/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index 0b4f1d1bbc0..0e6aca809aa 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -2,6 +2,7 @@ import * as Alchemy from "alchemy"; import * as Cloudflare from "alchemy/Cloudflare"; import * as Drizzle from "alchemy/Drizzle"; import * as Config from "effect/Config"; +import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -47,6 +48,7 @@ import { RelayApnsDeliveryDeadLetterQueue, RelayApnsDeliveryQueue } from "./queu import * as RelayConfiguration from "./Config.ts"; import * as AgentActivityPublisher from "./agentActivity/AgentActivityPublisher.ts"; import * as ApnsClient from "./agentActivity/ApnsClient.ts"; +import * as ApnsProviderTokens from "./agentActivity/ApnsProviderTokens.ts"; import * as ApnsDeliveryQueue from "./agentActivity/ApnsDeliveryQueue.ts"; import * as ApnsDeliveries from "./agentActivity/ApnsDeliveries.ts"; import * as EnvironmentConnector from "./environments/EnvironmentConnector.ts"; @@ -196,7 +198,7 @@ export default class Api extends Cloudflare.Worker()( ), Layer.provideMerge(DpopProofs.layer), Layer.provideMerge(ApnsDeliveries.layer), - Layer.provideMerge(ApnsClient.layer), + Layer.provideMerge(ApnsClient.layer.pipe(Layer.provideMerge(ApnsProviderTokens.layer))), Layer.provideMerge( ApnsDeliveryQueue.layerCloudflareQueues(apnsDeliveryQueueSender, alchemyRuntimeContext), ), @@ -242,7 +244,18 @@ export default class Api extends Cloudflare.Worker()( yield* Cloudflare.cron("*/5 * * * *").subscribe(() => DpopProofs.DpopProofReplay.pipe( Effect.flatMap((dpopProofs) => dpopProofs.pruneExpired), - Effect.withSpan("relay.cron.prune_expired_dpop_proofs"), + // Terminal thread rows are kept briefly so finished agents show as + // Done/Failed in the Live Activity; sweep them once they age out. + Effect.andThen( + Effect.all([AgentActivityRows.AgentActivityRows, DateTime.now]).pipe( + Effect.flatMap(([activityRows, now]) => + activityRows.pruneTerminal({ + updatedBefore: DateTime.formatIso(DateTime.subtract(now, { minutes: 30 })), + }), + ), + ), + ), + Effect.withSpan("relay.cron.prune_expired_state"), Effect.provide(runtimeLayer), ), ); diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index 0469e459d16..d0375e55556 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -84,6 +84,7 @@ function relayClient( registerDevice: () => unsupported("registerDevice"), unregisterDevice: () => unsupported("unregisterDevice"), registerLiveActivity: () => unsupported("registerLiveActivity"), + getAgentActivitySnapshot: () => unsupported("getAgentActivitySnapshot"), resetTokenCache: Effect.void, }); } @@ -449,6 +450,7 @@ describe("ConnectionResolver", () => { new ManagedRelay.ManagedRelayRequestTimeoutError({ activity: "Relay environment connection", timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS, + traceId: null, }), ), }); diff --git a/packages/client-runtime/src/relay/discovery.test.ts b/packages/client-runtime/src/relay/discovery.test.ts index 6bdc7798fb2..66c27b8678a 100644 --- a/packages/client-runtime/src/relay/discovery.test.ts +++ b/packages/client-runtime/src/relay/discovery.test.ts @@ -112,6 +112,7 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () { registerDevice: () => Effect.die("unused"), unregisterDevice: () => Effect.die("unused"), registerLiveActivity: () => Effect.die("unused"), + getAgentActivitySnapshot: () => Effect.die("unused"), resetTokenCache: Effect.void, } satisfies ManagedRelay.ManagedRelayClient["Service"]); const connectivity = Connectivity.Connectivity.of({ @@ -261,6 +262,7 @@ describe("RelayEnvironmentDiscovery", () => { new ManagedRelay.ManagedRelayRequestTimeoutError({ activity: "Relay environment listing", timeoutMs: ManagedRelay.MANAGED_RELAY_REQUEST_TIMEOUT_MS, + traceId: null, }), ), getEnvironmentStatus: () => Effect.die("unused"), @@ -272,6 +274,7 @@ describe("RelayEnvironmentDiscovery", () => { registerDevice: () => Effect.die("unused"), unregisterDevice: () => Effect.die("unused"), registerLiveActivity: () => Effect.die("unused"), + getAgentActivitySnapshot: () => Effect.die("unused"), resetTokenCache: Effect.void, } satisfies ManagedRelay.ManagedRelayClient["Service"]); const layer = RelayEnvironmentDiscovery.layer.pipe( diff --git a/packages/client-runtime/src/relay/managedRelay.ts b/packages/client-runtime/src/relay/managedRelay.ts index 08b720b46a3..06769a4372f 100644 --- a/packages/client-runtime/src/relay/managedRelay.ts +++ b/packages/client-runtime/src/relay/managedRelay.ts @@ -17,11 +17,13 @@ import { RelayExchangeDpopAccessTokenEndpoint, RelayGetEnvironmentStatusEndpoint, RelayJwtSubjectTokenType, + type RelayAgentActivitySnapshotResponse, type RelayLiveActivityRegistrationRequest, RelayMobileRegistrationScope, type RelayOkResponse, type RelayPublicClientId, RelayRegisterDeviceEndpoint, + RelayAgentActivitySnapshotEndpoint, RelayRegisterLiveActivityEndpoint, RelayProtectedError, type RelayProtectedError as RelayProtectedErrorType, @@ -92,6 +94,7 @@ export const ManagedRelayRequestAction = Schema.Literals([ "register relay mobile device", "unregister relay mobile device", "register relay live activity", + "read relay agent activity snapshot", ]); export type ManagedRelayRequestAction = typeof ManagedRelayRequestAction.Type; @@ -107,6 +110,7 @@ export const ManagedRelayRequestActivity = Schema.Literals([ "Relay mobile device registration", "Relay mobile device unregistration", "Relay Live Activity registration", + "Relay agent activity snapshot", ]); export type ManagedRelayRequestActivity = typeof ManagedRelayRequestActivity.Type; @@ -115,6 +119,10 @@ export class ManagedRelayRequestTimeoutError extends Schema.TaggedErrorClass Effect.Effect; + readonly getAgentActivitySnapshot: (input: { + readonly clerkToken: string; + }) => Effect.Effect; readonly resetTokenCache: Effect.Effect; } >()("@t3tools/client-runtime/relay/managedRelay/ManagedRelayClient") {} @@ -326,11 +337,18 @@ function timeoutRelayRequest(activity: ManagedRelayRequestActivity) { Effect.flatMap( Option.match({ onNone: () => - Effect.fail( - new ManagedRelayRequestTimeoutError({ - activity, - timeoutMs: MANAGED_RELAY_REQUEST_TIMEOUT_MS, - }), + Effect.currentParentSpan.pipe( + Effect.map((span) => span.traceId), + Effect.orElseSucceed(() => null), + Effect.flatMap((traceId) => + Effect.fail( + new ManagedRelayRequestTimeoutError({ + activity, + timeoutMs: MANAGED_RELAY_REQUEST_TIMEOUT_MS, + traceId, + }), + ), + ), ), onSome: Effect.succeed, }), @@ -399,6 +417,7 @@ function disabledManagedRelayClient(relayUrl: string): ManagedRelayClient["Servi registerDevice: unavailable("clientRuntime.managedRelay.registerDevice"), unregisterDevice: unavailable("clientRuntime.managedRelay.unregisterDevice"), registerLiveActivity: unavailable("clientRuntime.managedRelay.registerLiveActivity"), + getAgentActivitySnapshot: unavailable("clientRuntime.managedRelay.getAgentActivitySnapshot"), resetTokenCache: Effect.void.pipe( Effect.withSpan("clientRuntime.managedRelay.resetTokenCache"), ), @@ -446,6 +465,10 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( method: RelayUnregisterDeviceEndpoint.method, url: urlBuilder.mobile.unregisterDevice({ params: { deviceId } }), }), + getAgentActivitySnapshot: (): DpopProofTarget => ({ + method: RelayAgentActivitySnapshotEndpoint.method, + url: urlBuilder.mobile.getAgentActivitySnapshot(), + }), registerLiveActivity: (): DpopProofTarget => ({ method: RelayRegisterLiveActivityEndpoint.method, url: urlBuilder.mobile.registerLiveActivity(), @@ -845,6 +868,27 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( Effect.withSpan("clientRuntime.managedRelay.unregisterDevice"), withRelayClientTracing, ), + getAgentActivitySnapshot: Effect.fnUntraced( + function* (input) { + return yield* mobileRegistrationRequest( + { + clerkToken: input.clerkToken, + target: dpopProofTargets.getAgentActivitySnapshot(), + }, + (authorization) => + client.mobile + .getAgentActivitySnapshot({ + headers: dpopHeaders(authorization), + }) + .pipe( + Effect.mapError(relayRequestError("read relay agent activity snapshot")), + timeoutRelayRequest("Relay agent activity snapshot"), + ), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.getAgentActivitySnapshot"), + withRelayClientTracing, + ), registerLiveActivity: Effect.fnUntraced( function* (input) { return yield* mobileRegistrationRequest( diff --git a/packages/client-runtime/src/relay/managedRelayState.test.ts b/packages/client-runtime/src/relay/managedRelayState.test.ts index 49400d32aef..905bbcc819c 100644 --- a/packages/client-runtime/src/relay/managedRelayState.test.ts +++ b/packages/client-runtime/src/relay/managedRelayState.test.ts @@ -83,6 +83,7 @@ function createManager( registerDevice: () => Effect.die("unused"), unregisterDevice: () => Effect.die("unused"), registerLiveActivity: () => Effect.die("unused"), + getAgentActivitySnapshot: () => Effect.die("unused"), resetTokenCache: Effect.void, ...overrides, }); diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 86b9f151f98..2d40dad60cc 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -342,6 +342,11 @@ 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. + // Optional so newer clients tolerate older environment servers. + managedTunnelActive: Schema.optional(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..881f922ae7e 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, @@ -864,6 +873,24 @@ export const RelayRegisterLiveActivityEndpoint = HttpApiEndpoint.post( }, ).annotate(OpenApi.Summary, "Register a Live Activity push token"); +export const RelayAgentActivitySnapshotResponse = Schema.Struct({ + aggregate: Schema.NullOr(RelayAgentActivityAggregateState), +}); +export type RelayAgentActivitySnapshotResponse = typeof RelayAgentActivitySnapshotResponse.Type; + +// Lets the app decide whether arming a Live Activity is worthwhile before +// creating one (no empty lock-screen card when nothing is running) and seed +// the card with the real aggregate instead of a placeholder. +export const RelayAgentActivitySnapshotEndpoint = HttpApiEndpoint.get( + "getAgentActivitySnapshot", + "/v1/mobile/agent-activity", + { + headers: RelayDpopRequestHeaders, + success: RelayAgentActivitySnapshotResponse, + error: RelayAuthAndInternalErrors, + }, +).annotate(OpenApi.Summary, "Read the current Live Activity aggregate"); + export const RelayUnregisterDeviceEndpoint = HttpApiEndpoint.delete( "unregisterDevice", "/v1/mobile/devices/:deviceId", @@ -879,6 +906,7 @@ export const RelayMobileGroup = HttpApiGroup.make("mobile") .add( RelayRegisterDeviceEndpoint, RelayRegisterLiveActivityEndpoint, + RelayAgentActivitySnapshotEndpoint, RelayUnregisterDeviceEndpoint, ) .annotate(OpenApi.Description, "Mobile push-notification and Live Activity registration.") diff --git a/packages/shared/src/agentAwareness.test.ts b/packages/shared/src/agentAwareness.test.ts index 217e0016352..28e07c04e6f 100644 --- a/packages/shared/src/agentAwareness.test.ts +++ b/packages/shared/src/agentAwareness.test.ts @@ -102,6 +102,58 @@ describe("projectThreadAwareness", () => { }); }); + it("projects completed turns as completed even when teardown settled them as interrupted", () => { + const finishedTurn = { + turnId: "turn-1" as TurnId, + state: "interrupted" as const, + requestedAt: NOW, + startedAt: NOW, + completedAt: NOW, + assistantMessageId: null, + }; + const state = projectThreadAwareness({ + environmentId: "env-1" as EnvironmentId, + project, + thread: thread({ latestTurn: finishedTurn }), + }); + + // Session teardown settles still-running turns by session status, and + // that write can race turn.completed; the completion timestamp is the + // durable signal. Without this the thread resolves to null persistently + // and gets tombstoned off the lock-screen card instead of showing Done. + expect(state?.phase).toBe("completed"); + + const trulyInterrupted = projectThreadAwareness({ + environmentId: "env-1" as EnvironmentId, + project, + thread: thread({ latestTurn: { ...finishedTurn, completedAt: null } }), + }); + expect(trulyInterrupted).toBeNull(); + }); + + it("projects ready sessions with no materialized turn as completed", () => { + // Quick threads without code changes never get a checkpoint, so the SQL + // shell has no latestTurn row and latest_turn_id is cleared when the + // session settles; the ready session is the only completion signal left. + const state = projectThreadAwareness({ + environmentId: "env-1" as EnvironmentId, + project, + thread: thread({ + session: { + threadId: "thread-1" as ThreadId, + status: "ready", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }, + }), + }); + + expect(state?.phase).toBe("completed"); + }); + it("projects failures with the session error detail", () => { const state = projectThreadAwareness({ environmentId: "env-1" as EnvironmentId, diff --git a/packages/shared/src/agentAwareness.ts b/packages/shared/src/agentAwareness.ts index 6831e8ba301..248b983cd4f 100644 --- a/packages/shared/src/agentAwareness.ts +++ b/packages/shared/src/agentAwareness.ts @@ -103,6 +103,24 @@ function resolveThreadAwarenessPhase( if (thread.latestTurn?.state === "completed") { return "completed"; } + // A turn that finished can still read as "interrupted" here: session + // teardown settles still-running turns by session status, and that write + // can race the turn.completed one. completedAt survives the race — a turn + // that has a completion timestamp finished, whatever the state column says. + // Without this, quick finish-then-teardown threads resolve to null + // persistently and get tombstoned instead of published as completed. + if (thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null) { + return "completed"; + } + // Threads whose turns never produce a checkpoint (no code changes) have no + // materialized latestTurn in the shell at all, and the session-set + // projection clears latest_turn_id the moment the session settles. The + // session status is then the only surviving completion signal: a live + // session at "ready"/"idle" with nothing pending and nothing running means + // the agent finished and is waiting for the next prompt — Done. + if (thread.session?.status === "ready" || thread.session?.status === "idle") { + return "completed"; + } return null; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 636e6950fe5..272aa71c42c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -671,6 +671,12 @@ importers: '@effect/sql-pg': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + '@noble/curves': + specifier: 'catalog:' + version: 1.9.1 + '@noble/hashes': + specifier: 'catalog:' + version: 1.8.0 '@t3tools/client-runtime': specifier: workspace:* version: link:../../packages/client-runtime